出库售价(应收按售价) + 库存选择器分页上限修复 + seed 改为序列号模型 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
This commit is contained in:
+381
-310
@@ -5,6 +5,12 @@
|
||||
// go run cmd/seed/main.go # 写入数据(已存在则跳过)
|
||||
// go run cmd/seed/main.go --reset # 删表重建 + 写入数据
|
||||
// go run cmd/seed/main.go --clear # 清空业务数据 + 重新写入(保留表结构)
|
||||
//
|
||||
// 数据遵循「序列号模型」规范(见 CLAUDE.md 数据模型铁律):
|
||||
// - 入库每录一行 = 新建一个独立 product(唯一商品编号),绝不按名称复用;
|
||||
// - 入库/出库明细、库存均带快照列(product_code/name/series/spec…);
|
||||
// - 审核生成库存时关联 stock_in_item_id、拷贝 unit_price/批次/生产日期快照;
|
||||
// - 出库记录 sale_price(售价),应收(total_amount)按 售价×数量。
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -13,6 +19,7 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
@@ -20,6 +27,7 @@ import (
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
var allModels = []any{
|
||||
@@ -111,6 +119,7 @@ func main() {
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
d := func(daysAgo int) model.Date { return model.Date{Time: today.AddDate(0, 0, -daysAgo)} }
|
||||
dptr := func(daysAgo int) *model.Date { dd := model.Date{Time: today.AddDate(0, 0, -daysAgo)}; return &dd }
|
||||
ptrT := func(t time.Time) *time.Time { return &t }
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
@@ -180,41 +189,8 @@ func main() {
|
||||
}).(model.ProductCategory)
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 商品
|
||||
// ═══════════════════════════════════════════════════
|
||||
type prodSeed struct {
|
||||
code, barcode, name, series, spec, unit, brand string
|
||||
catID *uint64
|
||||
purchase, sale float64
|
||||
minStock int
|
||||
remark string
|
||||
}
|
||||
prodSeeds := []prodSeed{
|
||||
{"MT-001", "6901234567890", "飞天茅台 53度", "茅台", "500ml/瓶", "瓶", "贵州茅台", &catBaijiu.ID, 2350, 2800, 10, "酱香型白酒,53度,飞天系列"},
|
||||
{"WLY-001", "6902345678901", "五粮液 52度", "五粮液", "500ml/瓶", "瓶", "宜宾五粮液", &catBaijiu.ID, 950, 1200, 6, "浓香型白酒,52度,普五系列"},
|
||||
{"YH-001", "6903456789012", "洋河梦之蓝 M6", "洋河", "500ml/瓶", "瓶", "江苏洋河", &catBaijiu.ID, 560, 680, 6, "浓香型,绵柔苏酒代表"},
|
||||
{"LZ-001", "6904567890123", "泸州老窖 特曲", "泸州老窖", "500ml/瓶", "瓶", "泸州老窖", &catBaijiu.ID, 420, 520, 6, "浓香鼻祖,特曲系列"},
|
||||
{"JNC-001", "6905678901234", "剑南春 水晶剑", "剑南春", "500ml/瓶", "瓶", "剑南春", &catBaijiu.ID, 390, 480, 6, "浓香型,绵竹名酒"},
|
||||
{"LJ-001", "6906789012345", "郎酒 红花郎10", "郎酒", "500ml/瓶", "瓶", "古蔺郎酒", &catBaijiu.ID, 320, 420, 6, "酱香型,赤水河畔酿造"},
|
||||
{"LF-001", "3760093550058", "拉菲古堡 2018", "波尔多", "750ml/瓶", "瓶", "Château Lafite", &catImport.ID, 3800, 5200, 3, "波尔多一级名庄,2018年份"},
|
||||
{"RTM-001", "3021691010008", "人头马 VSOP", "人头马", "700ml/瓶", "瓶", "Rémy Martin", &catImport.ID, 480, 680, 3, "法国干邑,VSOP级别"},
|
||||
}
|
||||
var prods []model.Product
|
||||
for _, s := range prodSeeds {
|
||||
p := upsert(db, &model.Product{}, "shop_id = ? AND code = ?", shop.ID, s.code, func() any {
|
||||
return &model.Product{
|
||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
Code: s.code, Barcode: s.barcode, Name: s.name,
|
||||
Series: s.series, Spec: s.spec, Unit: s.unit, Brand: s.brand,
|
||||
CategoryID: s.catID, PurchasePrice: s.purchase, SalePrice: s.sale,
|
||||
MinStock: s.minStock, Remark: s.remark,
|
||||
}
|
||||
}).(model.Product)
|
||||
prods = append(prods, p)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 商品名称/系列/规格选项
|
||||
// 商品基础数据字典(品牌/型号/版本 = 名称/系列/规格选项)
|
||||
// 序列号模型下:product 由入库逐行生成,字典只是录入时的可选项来源。
|
||||
// ═══════════════════════════════════════════════════
|
||||
type dimSeed struct{ code, name string }
|
||||
|
||||
@@ -243,7 +219,10 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range []struct{ code, name string; quantity int }{
|
||||
for _, s := range []struct {
|
||||
code, name string
|
||||
quantity int
|
||||
}{
|
||||
{"GG001", "500ml/瓶", 1}, {"GG002", "750ml/瓶", 1}, {"GG003", "700ml/瓶", 1},
|
||||
} {
|
||||
var opt model.ProductSpecOption
|
||||
@@ -259,6 +238,22 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 商品模板:仅作为入库逐行生成 product 的来源,本身不直接落 products 表。
|
||||
cat := func(c *model.ProductCategory) *uint64 { id := c.ID; return &id }
|
||||
catalog := []goods{
|
||||
{"飞天茅台 53度", "茅台", "500ml/瓶", "瓶", "贵州茅台", cat(&catBaijiu), 2350, 2800, 10},
|
||||
{"五粮液 52度", "五粮液", "500ml/瓶", "瓶", "宜宾五粮液", cat(&catBaijiu), 950, 1200, 6},
|
||||
{"洋河梦之蓝 M6", "洋河", "500ml/瓶", "瓶", "江苏洋河", cat(&catBaijiu), 560, 680, 6},
|
||||
{"泸州老窖 特曲", "泸州老窖", "500ml/瓶", "瓶", "泸州老窖", cat(&catBaijiu), 420, 520, 6},
|
||||
{"剑南春 水晶剑", "剑南春", "500ml/瓶", "瓶", "剑南春", cat(&catBaijiu), 390, 480, 6},
|
||||
{"郎酒 红花郎10", "郎酒", "500ml/瓶", "瓶", "古蔺郎酒", cat(&catBaijiu), 320, 420, 6},
|
||||
{"拉菲古堡 2018", "波尔多", "750ml/瓶", "瓶", "Château Lafite", cat(&catImport), 3800, 5200, 3},
|
||||
{"人头马 VSOP", "人头马", "700ml/瓶", "瓶", "Rémy Martin", cat(&catImport), 480, 680, 3},
|
||||
}
|
||||
// 商品编码顺序生成器(序列号:每入库一行发一个唯一编号 P0001、P0002…)
|
||||
codeSeq := 0
|
||||
nextCode := func() string { codeSeq++; return fmt.Sprintf("P%04d", codeSeq) }
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 往来单位
|
||||
// ═══════════════════════════════════════════════════
|
||||
@@ -317,110 +312,100 @@ func main() {
|
||||
upsertNumberRule(db, shop.ID, "stock_out", "CK", 3)
|
||||
upsertNumberRule(db, shop.ID, "inventory_check", "PD", 1)
|
||||
|
||||
mk := func(db *gorm.DB) *seeder {
|
||||
return &seeder{db: db, shopID: shop.ID, catalog: catalog, nextCode: nextCode, supplier: partners}
|
||||
}
|
||||
sd := mk(db)
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 入库单
|
||||
// 入库单(每行新建独立 product + 快照)
|
||||
// ═══════════════════════════════════════════════════
|
||||
// #1 已审核:茅台+五粮液,主仓库,茅台供应商
|
||||
in1 := createStockInOrder(db, shop.ID, wh1.ID, admin.ID, partners["SUP001"].ID,
|
||||
"RK20260401001", d(6), "approved", "purchase",
|
||||
"茅台系列首批入库,含飞天茅台及五粮液",
|
||||
[]inItemSeed{
|
||||
{prods[0].ID, 120, 2350, "BATCH-MT-2024001", "飞天茅台 2024年第一批次"},
|
||||
{prods[1].ID, 60, 950, "BATCH-WLY-2024001", "五粮液普五 2024年批次"},
|
||||
in1 := sd.createStockIn(wh1.ID, wh1.Name, admin.ID, partners["SUP001"].ID,
|
||||
"RK20260401001", d(6), "approved", "purchase", "茅台系列首批入库,含飞天茅台及五粮液",
|
||||
[]inLine{
|
||||
{0, 120, 2350, "BATCH-MT-2024001", dptr(40), "飞天茅台 2024年第一批次"},
|
||||
{1, 60, 950, "BATCH-WLY-2024001", dptr(50), "五粮液普五 2024年批次"},
|
||||
}, admin.ID, ptrT(today.AddDate(0, 0, -5)))
|
||||
|
||||
// #2 已审核:洋河+泸州+剑南春,主仓库,洋河供应商
|
||||
in2 := createStockInOrder(db, shop.ID, wh1.ID, operator.ID, partners["SUP002"].ID,
|
||||
"RK20260403001", d(4), "approved", "purchase",
|
||||
"浓香型白酒补货入库",
|
||||
[]inItemSeed{
|
||||
{prods[2].ID, 48, 560, "BATCH-YH-2024003", "洋河梦之蓝 M6 3月批次"},
|
||||
{prods[3].ID, 36, 420, "BATCH-LZ-2024002", "泸州老窖特曲 春季批次"},
|
||||
{prods[4].ID, 24, 390, "BATCH-JNC-2024001", "剑南春水晶剑 首批"},
|
||||
in2 := sd.createStockIn(wh1.ID, wh1.Name, operator.ID, partners["SUP002"].ID,
|
||||
"RK20260403001", d(4), "approved", "purchase", "浓香型白酒补货入库",
|
||||
[]inLine{
|
||||
{2, 48, 560, "BATCH-YH-2024003", dptr(60), "洋河梦之蓝 M6 3月批次"},
|
||||
{3, 36, 420, "BATCH-LZ-2024002", dptr(70), "泸州老窖特曲 春季批次"},
|
||||
{4, 24, 390, "BATCH-JNC-2024001", dptr(45), "剑南春水晶剑 首批"},
|
||||
}, admin.ID, ptrT(today.AddDate(0, 0, -3)))
|
||||
|
||||
// #3 已审核:进口酒,进口酒专库,茅台供应商(代理进口)
|
||||
in3 := createStockInOrder(db, shop.ID, wh2.ID, operator.ID, partners["SUP004"].ID,
|
||||
"RK20260404001", d(3), "approved", "purchase",
|
||||
"进口烈酒专库入库,含拉菲及人头马",
|
||||
[]inItemSeed{
|
||||
{prods[6].ID, 24, 3800, "BATCH-LF-2018001", "拉菲古堡2018,原箱"},
|
||||
{prods[7].ID, 36, 480, "BATCH-RTM-2024001", "人头马VSOP,2024年进口"},
|
||||
// #3 已审核:进口酒,进口酒专库,郎酒供应商(代理进口)
|
||||
in3 := sd.createStockIn(wh2.ID, wh2.Name, operator.ID, partners["SUP004"].ID,
|
||||
"RK20260404001", d(3), "approved", "purchase", "进口烈酒专库入库,含拉菲及人头马",
|
||||
[]inLine{
|
||||
{6, 24, 3800, "BATCH-LF-2018001", dptr(400), "拉菲古堡2018,原箱"},
|
||||
{7, 36, 480, "BATCH-RTM-2024001", dptr(90), "人头马VSOP,2024年进口"},
|
||||
}, admin.ID, ptrT(today.AddDate(0, 0, -2)))
|
||||
|
||||
// #4 待审核:郎酒补货
|
||||
in4 := createStockInOrder(db, shop.ID, wh1.ID, operator.ID, partners["SUP004"].ID,
|
||||
"RK20260406001", d(1), "pending", "purchase",
|
||||
"郎酒红花郎补货,待仓库管理员审核",
|
||||
[]inItemSeed{
|
||||
{prods[5].ID, 60, 320, "BATCH-LJ-2024002", "郎酒红花郎10 第二批次"},
|
||||
// #4 待审核:郎酒补货(仅建单+建 product,不生成库存)
|
||||
in4 := sd.createStockIn(wh1.ID, wh1.Name, operator.ID, partners["SUP004"].ID,
|
||||
"RK20260406001", d(1), "pending", "purchase", "郎酒红花郎补货,待仓库管理员审核",
|
||||
[]inLine{
|
||||
{5, 60, 320, "BATCH-LJ-2024002", dptr(30), "郎酒红花郎10 第二批次"},
|
||||
}, 0, nil)
|
||||
|
||||
// #5 草稿:追加茅台
|
||||
in5 := createStockInOrder(db, shop.ID, wh1.ID, operator.ID, partners["SUP001"].ID,
|
||||
"RK20260407001", d(0), "draft", "purchase",
|
||||
"茅台追加订货,草稿中",
|
||||
[]inItemSeed{
|
||||
{prods[0].ID, 60, 2380, "BATCH-MT-2024002", "飞天茅台 第二批次"},
|
||||
sd.createStockIn(wh1.ID, wh1.Name, operator.ID, partners["SUP001"].ID,
|
||||
"RK20260407001", d(0), "draft", "purchase", "茅台追加订货,草稿中",
|
||||
[]inLine{
|
||||
{0, 60, 2380, "BATCH-MT-2024002", dptr(20), "飞天茅台 第二批次"},
|
||||
}, 0, nil)
|
||||
|
||||
_ = in4
|
||||
_ = in5
|
||||
|
||||
// 根据已审核入库单更新库存
|
||||
updateInventoryBatch(db, shop.ID, wh1.ID, admin.ID, in1)
|
||||
updateInventoryBatch(db, shop.ID, wh1.ID, admin.ID, in2)
|
||||
updateInventoryBatch(db, shop.ID, wh2.ID, admin.ID, in3)
|
||||
// 已审核入库单 → 生成库存(带快照 + 关联 stock_in_item_id)
|
||||
sd.applyStockInToInventory(in1)
|
||||
sd.applyStockInToInventory(in2)
|
||||
sd.applyStockInToInventory(in3)
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 出库单
|
||||
// 出库单(引用入库已建 product,记 sale_price,应收按售价)
|
||||
// ═══════════════════════════════════════════════════
|
||||
// #1 已审核:北京君悦大酒店,主仓库
|
||||
out1 := createStockOutOrder(db, shop.ID, wh1.ID, operator.ID, partners["CUS001"].ID,
|
||||
"CK20260404001", d(3), "approved", "sale",
|
||||
"北京君悦大酒店 4月份定期供货",
|
||||
[]outItemSeed{
|
||||
{prods[0].ID, 12, 2800, "飞天茅台 12瓶,整箱出库"},
|
||||
{prods[1].ID, 6, 1200, "五粮液普五 6瓶"},
|
||||
{prods[2].ID, 12, 680, "洋河梦之蓝 M6 12瓶"},
|
||||
out1 := sd.createStockOut(wh1.ID, operator.ID, partners["CUS001"].ID,
|
||||
"CK20260404001", d(3), "approved", "sale", "北京君悦大酒店 4月份定期供货",
|
||||
[]outLine{
|
||||
{in1.products[0], 12, 2800, "飞天茅台 12瓶,整箱出库"},
|
||||
{in1.products[1], 6, 1200, "五粮液普五 6瓶"},
|
||||
{in2.products[0], 12, 680, "洋河梦之蓝 M6 12瓶"},
|
||||
}, admin.ID, ptrT(today.AddDate(0, 0, -2)))
|
||||
|
||||
// #2 已审核:上海外滩华尔道夫,进口酒专库
|
||||
out2 := createStockOutOrder(db, shop.ID, wh2.ID, operator.ID, partners["CUS002"].ID,
|
||||
"CK20260405001", d(2), "approved", "sale",
|
||||
"上海外滩华尔道夫 进口酒专属采购",
|
||||
[]outItemSeed{
|
||||
{prods[6].ID, 6, 5200, "拉菲古堡2018 6瓶"},
|
||||
{prods[7].ID, 12, 680, "人头马VSOP 12瓶"},
|
||||
out2 := sd.createStockOut(wh2.ID, operator.ID, partners["CUS002"].ID,
|
||||
"CK20260405001", d(2), "approved", "sale", "上海外滩华尔道夫 进口酒专属采购",
|
||||
[]outLine{
|
||||
{in3.products[0], 6, 5200, "拉菲古堡2018 6瓶"},
|
||||
{in3.products[1], 12, 680, "人头马VSOP 12瓶"},
|
||||
}, admin.ID, ptrT(today.AddDate(0, 0, -1)))
|
||||
|
||||
// #3 待审核:广州白天鹅宾馆
|
||||
out3 := createStockOutOrder(db, shop.ID, wh1.ID, operator.ID, partners["CUS003"].ID,
|
||||
"CK20260406001", d(1), "pending", "sale",
|
||||
"广州白天鹅宾馆 月度供货申请",
|
||||
[]outItemSeed{
|
||||
{prods[3].ID, 24, 520, "泸州老窖特曲 24瓶"},
|
||||
{prods[4].ID, 12, 480, "剑南春水晶剑 12瓶"},
|
||||
{prods[5].ID, 12, 420, "郎酒红花郎10 12瓶"},
|
||||
sd.createStockOut(wh1.ID, operator.ID, partners["CUS003"].ID,
|
||||
"CK20260406001", d(1), "pending", "sale", "广州白天鹅宾馆 月度供货申请",
|
||||
[]outLine{
|
||||
{in2.products[1], 24, 520, "泸州老窖特曲 24瓶"},
|
||||
{in2.products[2], 12, 480, "剑南春水晶剑 12瓶"},
|
||||
{in4.products[0], 12, 420, "郎酒红花郎10 12瓶"},
|
||||
}, 0, nil)
|
||||
|
||||
// #4 草稿:君悦追加
|
||||
out4 := createStockOutOrder(db, shop.ID, wh1.ID, operator.ID, partners["CUS001"].ID,
|
||||
"CK20260407001", d(0), "draft", "sale",
|
||||
"北京君悦追加订单,草稿中",
|
||||
[]outItemSeed{
|
||||
{prods[0].ID, 6, 2800, "飞天茅台 追加 6瓶"},
|
||||
sd.createStockOut(wh1.ID, operator.ID, partners["CUS001"].ID,
|
||||
"CK20260407001", d(0), "draft", "sale", "北京君悦追加订单,草稿中",
|
||||
[]outLine{
|
||||
{in1.products[0], 6, 2800, "飞天茅台 追加 6瓶"},
|
||||
}, 0, nil)
|
||||
|
||||
_ = out3
|
||||
_ = out4
|
||||
|
||||
// 根据已审核出库单减库存
|
||||
deductInventoryBatch(db, shop.ID, wh1.ID, admin.ID, out1)
|
||||
deductInventoryBatch(db, shop.ID, wh2.ID, admin.ID, out2)
|
||||
// 已审核出库单 → 扣减库存(FIFO)
|
||||
sd.deductStockOut(wh1.ID, admin.ID, out1)
|
||||
sd.deductStockOut(wh2.ID, admin.ID, out2)
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 财务记录(与已审核出库单对应的应收款)
|
||||
// 财务记录(与已审核出库单对应的应收款,按售价口径)
|
||||
// ═══════════════════════════════════════════════════
|
||||
createFinanceRecord(db, shop.ID, partners["CUS001"].ID, admin.ID,
|
||||
"receivable", out1.order.TotalAmount, out1.order.TotalAmount, "stock_out", out1.order.ID,
|
||||
@@ -446,44 +431,313 @@ func main() {
|
||||
fmt.Println(" operator / password123 (操作员)")
|
||||
fmt.Println(" test / password123 (只读)")
|
||||
fmt.Println()
|
||||
fmt.Println(" 数据概览:")
|
||||
fmt.Println(" 数据概览(序列号模型:入库逐行建独立 product):")
|
||||
fmt.Printf(" 仓库:%s / %s\n", wh1.Name, wh2.Name)
|
||||
fmt.Printf(" 商品分类:白酒 / 进口烈酒,共 %d 种商品\n", len(prods))
|
||||
fmt.Printf(" 入库逐行生成 product:%d 个独立商品编号\n", codeSeq)
|
||||
fmt.Println(" 往来单位:4 供应商 + 3 客户")
|
||||
fmt.Println(" 入库单:5 张(2已审核 / 1待审核 / 1草稿 / 1进口专库已审核)")
|
||||
fmt.Println(" 出库单:4 张(2已审核 / 1待审核 / 1草稿)")
|
||||
fmt.Println(" 入库单:5 张(3已审核 / 1待审核 / 1草稿)")
|
||||
fmt.Println(" 出库单:4 张(2已审核 / 1待审核 / 1草稿),记 sale_price,应收按售价")
|
||||
fmt.Println(" 财务记录:2 应收 + 1 收款")
|
||||
fmt.Println("═══════════════════════════════════════════")
|
||||
}
|
||||
|
||||
// ── 辅助类型 ────────────────────────────────────────────
|
||||
// ── 数据模板 ────────────────────────────────────────────
|
||||
|
||||
type inItemSeed struct {
|
||||
productID uint64
|
||||
qty float64
|
||||
price float64
|
||||
batchNo string
|
||||
remark string
|
||||
// goods 商品模板:入库逐行据此 new 独立 product,模板本身不落库。
|
||||
type goods struct {
|
||||
name, series, spec, unit, brand string
|
||||
cat *uint64
|
||||
purchase, sale float64
|
||||
minStock int
|
||||
}
|
||||
|
||||
type outItemSeed struct {
|
||||
productID uint64
|
||||
qty float64
|
||||
price float64
|
||||
// inLine 入库行:g=catalog 下标,qty/price,批次/生产日期/备注。
|
||||
type inLine struct {
|
||||
g int
|
||||
qty, price float64
|
||||
batch string
|
||||
prodDate *model.Date
|
||||
remark string
|
||||
}
|
||||
|
||||
// outLine 出库行:引用入库已建 product,salePrice=售价。
|
||||
type outLine struct {
|
||||
product model.Product
|
||||
qty, sale float64
|
||||
remark string
|
||||
}
|
||||
|
||||
type stockInResult struct {
|
||||
order model.StockInOrder
|
||||
items []inItemSeed
|
||||
order model.StockInOrder
|
||||
items []model.StockInItem
|
||||
products []model.Product
|
||||
}
|
||||
|
||||
type stockOutResult struct {
|
||||
order model.StockOutOrder
|
||||
items []outItemSeed
|
||||
items []model.StockOutItem
|
||||
}
|
||||
|
||||
// ── 核心 upsert ────────────────────────────────────────────
|
||||
// seeder 收敛公共上下文(db/shop/商品模板/编码序列/供应商)。
|
||||
type seeder struct {
|
||||
db *gorm.DB
|
||||
shopID uint64
|
||||
catalog []goods
|
||||
nextCode func() string
|
||||
supplier map[string]model.Partner
|
||||
}
|
||||
|
||||
// ── 入库单(每行新建独立 product + 写快照) ───────────────
|
||||
|
||||
func (s *seeder) createStockIn(
|
||||
whID uint64, whName string, opID, partnerID uint64,
|
||||
orderNo string, date model.Date, status, orderType, remark string,
|
||||
lines []inLine, reviewerID uint64, reviewedAt *time.Time,
|
||||
) stockInResult {
|
||||
var o model.StockInOrder
|
||||
if s.db.Where("shop_id = ? AND order_no = ?", s.shopID, orderNo).First(&o).Error == nil {
|
||||
fmt.Printf("⏭ 入库单:%s\n", orderNo)
|
||||
return s.loadStockIn(o)
|
||||
}
|
||||
|
||||
o = model.StockInOrder{
|
||||
TenantBase: model.TenantBase{ShopID: s.shopID},
|
||||
OrderNo: orderNo,
|
||||
Type: orderType,
|
||||
WarehouseID: whID,
|
||||
PartnerID: &partnerID,
|
||||
OperatorID: opID,
|
||||
Status: status,
|
||||
OrderDate: date,
|
||||
Remark: remark,
|
||||
}
|
||||
if reviewerID > 0 {
|
||||
o.ReviewerID = &reviewerID
|
||||
o.ReviewedAt = reviewedAt
|
||||
}
|
||||
s.db.Create(&o)
|
||||
|
||||
var (
|
||||
total float64
|
||||
items []model.StockInItem
|
||||
prods []model.Product
|
||||
)
|
||||
for _, ln := range lines {
|
||||
g := s.catalog[ln.g]
|
||||
code := s.nextCode()
|
||||
full, initials := util.ToPinyin(g.name)
|
||||
// 铁律:入库每行 = 新建一个独立 product(唯一编号),不按名称复用
|
||||
p := model.Product{
|
||||
TenantBase: model.TenantBase{ShopID: s.shopID},
|
||||
PublicID: uuid.New().String(),
|
||||
Code: code,
|
||||
Name: g.name,
|
||||
Series: g.series,
|
||||
Spec: g.spec,
|
||||
Unit: g.unit,
|
||||
Brand: g.brand,
|
||||
CategoryID: g.cat,
|
||||
PurchasePrice: ln.price,
|
||||
SalePrice: g.sale,
|
||||
MinStock: g.minStock,
|
||||
BatchNo: ln.batch,
|
||||
ProductionDate: ln.prodDate,
|
||||
NamePinyin: full,
|
||||
NameInitials: initials,
|
||||
}
|
||||
s.db.Create(&p)
|
||||
|
||||
item := model.StockInItem{
|
||||
OrderID: o.ID,
|
||||
ShopID: s.shopID,
|
||||
ProductID: p.ID,
|
||||
ProductCode: code,
|
||||
ProductName: g.name,
|
||||
Series: g.series,
|
||||
Spec: g.spec,
|
||||
Quantity: ln.qty,
|
||||
UnitPrice: ln.price,
|
||||
TotalPrice: ln.qty * ln.price,
|
||||
BatchNo: ln.batch,
|
||||
ProductionDate: ln.prodDate,
|
||||
Remark: ln.remark,
|
||||
}
|
||||
s.db.Create(&item)
|
||||
total += item.TotalPrice
|
||||
items = append(items, item)
|
||||
prods = append(prods, p)
|
||||
}
|
||||
s.db.Model(&o).Update("total_amount", total)
|
||||
o.TotalAmount = total
|
||||
|
||||
fmt.Printf("✅ 入库单:%s [%s] 供应商ID=%d 仓库=%s 行数=%d 金额=%.0f\n",
|
||||
orderNo, status, partnerID, whName, len(lines), total)
|
||||
return stockInResult{order: o, items: items, products: prods}
|
||||
}
|
||||
|
||||
// loadStockIn 单据已存在时,回读其明细与 product,供出库行引用(保证重复执行可用)。
|
||||
func (s *seeder) loadStockIn(o model.StockInOrder) stockInResult {
|
||||
var items []model.StockInItem
|
||||
s.db.Where("order_id = ?", o.ID).Order("id ASC").Find(&items)
|
||||
prods := make([]model.Product, 0, len(items))
|
||||
for _, it := range items {
|
||||
var p model.Product
|
||||
s.db.First(&p, it.ProductID)
|
||||
prods = append(prods, p)
|
||||
}
|
||||
return stockInResult{order: o, items: items, products: prods}
|
||||
}
|
||||
|
||||
// applyStockInToInventory 已审核入库单 → 逐明细生成库存行(带快照 + 关联 stock_in_item_id)。
|
||||
func (s *seeder) applyStockInToInventory(r stockInResult) {
|
||||
for i := range r.items {
|
||||
it := r.items[i]
|
||||
var unit string
|
||||
if i < len(r.products) {
|
||||
unit = r.products[i].Unit
|
||||
}
|
||||
var qtyBefore float64
|
||||
s.db.Model(&model.Inventory{}).
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", s.shopID, r.order.WarehouseID, it.ProductID).
|
||||
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
|
||||
|
||||
whIDCopy := r.order.WarehouseID
|
||||
pidCopy := it.ProductID
|
||||
itemIDCopy := it.ID
|
||||
price := it.UnitPrice
|
||||
inv := model.Inventory{
|
||||
ShopID: s.shopID,
|
||||
WarehouseID: &whIDCopy,
|
||||
ProductID: &pidCopy,
|
||||
StockInItemID: &itemIDCopy,
|
||||
Quantity: it.Quantity,
|
||||
ProductCode: it.ProductCode,
|
||||
ProductName: it.ProductName,
|
||||
Series: it.Series,
|
||||
Spec: it.Spec,
|
||||
Unit: unit,
|
||||
UnitPrice: &price,
|
||||
BatchNo: it.BatchNo,
|
||||
ProductionDate: it.ProductionDate,
|
||||
}
|
||||
s.db.Create(&inv)
|
||||
|
||||
opID := r.order.OperatorID
|
||||
s.db.Create(&model.InventoryLog{
|
||||
ShopID: s.shopID, WarehouseID: r.order.WarehouseID, ProductID: it.ProductID,
|
||||
Direction: "in", Quantity: it.Quantity, QtyBefore: qtyBefore, QtyAfter: qtyBefore + it.Quantity,
|
||||
RefType: "stock_in", RefID: r.order.ID, OperatorID: &opID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── 出库单(记 sale_price,应收按售价) ───────────────────
|
||||
|
||||
func (s *seeder) createStockOut(
|
||||
whID, opID, partnerID uint64,
|
||||
orderNo string, date model.Date, status, orderType, remark string,
|
||||
lines []outLine, reviewerID uint64, reviewedAt *time.Time,
|
||||
) stockOutResult {
|
||||
var o model.StockOutOrder
|
||||
if s.db.Where("shop_id = ? AND order_no = ?", s.shopID, orderNo).First(&o).Error == nil {
|
||||
fmt.Printf("⏭ 出库单:%s\n", orderNo)
|
||||
var items []model.StockOutItem
|
||||
s.db.Where("order_id = ?", o.ID).Order("id ASC").Find(&items)
|
||||
return stockOutResult{order: o, items: items}
|
||||
}
|
||||
|
||||
o = model.StockOutOrder{
|
||||
TenantBase: model.TenantBase{ShopID: s.shopID},
|
||||
OrderNo: orderNo,
|
||||
Type: orderType,
|
||||
WarehouseID: whID,
|
||||
PartnerID: &partnerID,
|
||||
OperatorID: opID,
|
||||
Status: status,
|
||||
OrderDate: date,
|
||||
Remark: remark,
|
||||
}
|
||||
if reviewerID > 0 {
|
||||
o.ReviewerID = &reviewerID
|
||||
o.ReviewedAt = reviewedAt
|
||||
}
|
||||
s.db.Create(&o)
|
||||
|
||||
var (
|
||||
total float64 // 应收 = Σ 售价×数量
|
||||
items []model.StockOutItem
|
||||
)
|
||||
for _, ln := range lines {
|
||||
p := ln.product
|
||||
item := model.StockOutItem{
|
||||
OrderID: o.ID,
|
||||
ShopID: s.shopID,
|
||||
ProductID: p.ID,
|
||||
ProductCode: p.Code,
|
||||
ProductName: p.Name,
|
||||
Series: p.Series,
|
||||
Spec: p.Spec,
|
||||
Quantity: ln.qty,
|
||||
UnitPrice: p.PurchasePrice, // 成本单价(快照)
|
||||
SalePrice: ln.sale, // 售价
|
||||
TotalPrice: ln.qty * p.PurchasePrice, // 成本小计
|
||||
BatchNo: p.BatchNo,
|
||||
Remark: ln.remark,
|
||||
}
|
||||
s.db.Create(&item)
|
||||
total += ln.qty * ln.sale
|
||||
items = append(items, item)
|
||||
}
|
||||
s.db.Model(&o).Update("total_amount", total)
|
||||
o.TotalAmount = total
|
||||
|
||||
fmt.Printf("✅ 出库单:%s [%s] 客户ID=%d 仓库ID=%d 行数=%d 应收=%.0f\n",
|
||||
orderNo, status, partnerID, whID, len(lines), total)
|
||||
return stockOutResult{order: o, items: items}
|
||||
}
|
||||
|
||||
// deductStockOut 已审核出库单 → FIFO 扣减库存 + 写流水。
|
||||
func (s *seeder) deductStockOut(whID, opID uint64, r stockOutResult) {
|
||||
now := time.Now()
|
||||
for _, it := range r.items {
|
||||
var qtyBefore float64
|
||||
s.db.Model(&model.Inventory{}).
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", s.shopID, whID, it.ProductID).
|
||||
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
|
||||
|
||||
var batches []model.Inventory
|
||||
s.db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
|
||||
s.shopID, whID, it.ProductID).
|
||||
Order("created_at ASC").Find(&batches)
|
||||
|
||||
remaining := it.Quantity
|
||||
for i := range batches {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
b := &batches[i]
|
||||
if b.Quantity <= remaining {
|
||||
remaining -= b.Quantity
|
||||
s.db.Model(b).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now})
|
||||
} else {
|
||||
s.db.Model(b).Update("quantity", gorm.Expr("quantity - ?", remaining))
|
||||
remaining = 0
|
||||
}
|
||||
}
|
||||
|
||||
after := qtyBefore - it.Quantity
|
||||
if after < 0 {
|
||||
after = 0
|
||||
}
|
||||
s.db.Create(&model.InventoryLog{
|
||||
ShopID: s.shopID, WarehouseID: whID, ProductID: it.ProductID,
|
||||
Direction: "out", Quantity: it.Quantity, QtyBefore: qtyBefore, QtyAfter: after,
|
||||
RefType: "stock_out", RefID: r.order.ID, OperatorID: &opID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── 通用 upsert(主数据) ────────────────────────────────
|
||||
|
||||
// upsert 通用:查不到就调 factory 建,返回具体值(interface{})
|
||||
func upsert(db *gorm.DB, dest any, cond string, args ...any) any {
|
||||
@@ -534,16 +788,6 @@ func upsert(db *gorm.DB, dest any, cond string, args ...any) any {
|
||||
}
|
||||
fmt.Printf("⏭ 分类:%s\n", v.Name)
|
||||
return *v
|
||||
case *model.Product:
|
||||
found = db.Where(cond, queryArgs...).First(v).Error == nil
|
||||
if !found {
|
||||
obj := factory().(*model.Product)
|
||||
db.Create(obj)
|
||||
fmt.Printf("✅ 商品:%s(%s)\n", obj.Name, obj.Code)
|
||||
return *obj
|
||||
}
|
||||
fmt.Printf("⏭ 商品:%s\n", v.Name)
|
||||
return *v
|
||||
case *model.Partner:
|
||||
found = db.Where(cond, queryArgs...).First(v).Error == nil
|
||||
if !found {
|
||||
@@ -558,179 +802,6 @@ func upsert(db *gorm.DB, dest any, cond string, args ...any) any {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── 入库单 ────────────────────────────────────────────
|
||||
|
||||
func createStockInOrder(
|
||||
db *gorm.DB, shopID, whID, opID, partnerID uint64,
|
||||
orderNo string, date model.Date, status, orderType, remark string,
|
||||
items []inItemSeed,
|
||||
reviewerID uint64, reviewedAt *time.Time,
|
||||
) stockInResult {
|
||||
var o model.StockInOrder
|
||||
if db.Where("shop_id = ? AND order_no = ?", shopID, orderNo).First(&o).Error != nil {
|
||||
o = model.StockInOrder{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
OrderNo: orderNo,
|
||||
Type: orderType,
|
||||
WarehouseID: whID,
|
||||
PartnerID: &partnerID,
|
||||
OperatorID: opID,
|
||||
Status: status,
|
||||
OrderDate: date,
|
||||
Remark: remark,
|
||||
}
|
||||
if reviewerID > 0 {
|
||||
o.ReviewerID = &reviewerID
|
||||
o.ReviewedAt = reviewedAt
|
||||
}
|
||||
db.Create(&o)
|
||||
|
||||
var total float64
|
||||
for _, it := range items {
|
||||
item := model.StockInItem{
|
||||
OrderID: o.ID,
|
||||
ShopID: shopID,
|
||||
ProductID: it.productID,
|
||||
Quantity: it.qty,
|
||||
UnitPrice: it.price,
|
||||
TotalPrice: it.qty * it.price,
|
||||
BatchNo: it.batchNo,
|
||||
Remark: it.remark,
|
||||
}
|
||||
db.Create(&item)
|
||||
total += item.TotalPrice
|
||||
}
|
||||
db.Model(&o).Update("total_amount", total)
|
||||
o.TotalAmount = total
|
||||
|
||||
fmt.Printf("✅ 入库单:%s [%s] 供应商ID=%d 仓库ID=%d 金额=%.0f\n",
|
||||
orderNo, status, partnerID, whID, total)
|
||||
} else {
|
||||
fmt.Printf("⏭ 入库单:%s\n", orderNo)
|
||||
}
|
||||
return stockInResult{order: o, items: items}
|
||||
}
|
||||
|
||||
// ── 出库单 ────────────────────────────────────────────
|
||||
|
||||
func createStockOutOrder(
|
||||
db *gorm.DB, shopID, whID, opID, partnerID uint64,
|
||||
orderNo string, date model.Date, status, orderType, remark string,
|
||||
items []outItemSeed,
|
||||
reviewerID uint64, reviewedAt *time.Time,
|
||||
) stockOutResult {
|
||||
var o model.StockOutOrder
|
||||
if db.Where("shop_id = ? AND order_no = ?", shopID, orderNo).First(&o).Error != nil {
|
||||
o = model.StockOutOrder{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
OrderNo: orderNo,
|
||||
Type: orderType,
|
||||
WarehouseID: whID,
|
||||
PartnerID: &partnerID,
|
||||
OperatorID: opID,
|
||||
Status: status,
|
||||
OrderDate: date,
|
||||
Remark: remark,
|
||||
}
|
||||
if reviewerID > 0 {
|
||||
o.ReviewerID = &reviewerID
|
||||
o.ReviewedAt = reviewedAt
|
||||
}
|
||||
db.Create(&o)
|
||||
|
||||
var total float64
|
||||
for _, it := range items {
|
||||
item := model.StockOutItem{
|
||||
OrderID: o.ID,
|
||||
ShopID: shopID,
|
||||
ProductID: it.productID,
|
||||
Quantity: it.qty,
|
||||
UnitPrice: it.price,
|
||||
TotalPrice: it.qty * it.price,
|
||||
Remark: it.remark,
|
||||
}
|
||||
db.Create(&item)
|
||||
total += item.TotalPrice
|
||||
}
|
||||
db.Model(&o).Update("total_amount", total)
|
||||
o.TotalAmount = total
|
||||
|
||||
fmt.Printf("✅ 出库单:%s [%s] 客户ID=%d 仓库ID=%d 金额=%.0f\n",
|
||||
orderNo, status, partnerID, whID, total)
|
||||
} else {
|
||||
fmt.Printf("⏭ 出库单:%s\n", orderNo)
|
||||
}
|
||||
return stockOutResult{order: o, items: items}
|
||||
}
|
||||
|
||||
// ── 库存 ────────────────────────────────────────────
|
||||
|
||||
func updateInventoryBatch(db *gorm.DB, shopID, whID, opID uint64, r stockInResult) {
|
||||
for _, it := range r.items {
|
||||
var qtyBefore float64
|
||||
db.Model(&model.Inventory{}).
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", shopID, whID, it.productID).
|
||||
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
|
||||
|
||||
productIDCopy := it.productID
|
||||
whIDCopy := whID
|
||||
inv := model.Inventory{
|
||||
ShopID: shopID,
|
||||
WarehouseID: &whIDCopy,
|
||||
ProductID: &productIDCopy,
|
||||
Quantity: it.qty,
|
||||
}
|
||||
db.Create(&inv)
|
||||
|
||||
db.Create(&model.InventoryLog{
|
||||
ShopID: shopID, WarehouseID: whID, ProductID: it.productID,
|
||||
Direction: "in", Quantity: it.qty, QtyBefore: qtyBefore, QtyAfter: qtyBefore + it.qty,
|
||||
RefType: "stock_in", RefID: r.order.ID, OperatorID: &opID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func deductInventoryBatch(db *gorm.DB, shopID, whID, opID uint64, r stockOutResult) {
|
||||
now := time.Now()
|
||||
for _, it := range r.items {
|
||||
var qtyBefore float64
|
||||
db.Model(&model.Inventory{}).
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", shopID, whID, it.productID).
|
||||
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
|
||||
|
||||
// FIFO deduction
|
||||
var batches []model.Inventory
|
||||
db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
|
||||
shopID, whID, it.productID).
|
||||
Order("created_at ASC").Find(&batches)
|
||||
|
||||
remaining := it.qty
|
||||
for i := range batches {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
b := &batches[i]
|
||||
if b.Quantity <= remaining {
|
||||
remaining -= b.Quantity
|
||||
db.Model(b).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now})
|
||||
} else {
|
||||
db.Model(b).Update("quantity", gorm.Expr("quantity - ?", remaining))
|
||||
remaining = 0
|
||||
}
|
||||
}
|
||||
|
||||
after := qtyBefore - it.qty
|
||||
if after < 0 {
|
||||
after = 0
|
||||
}
|
||||
db.Create(&model.InventoryLog{
|
||||
ShopID: shopID, WarehouseID: whID, ProductID: it.productID,
|
||||
Direction: "out", Quantity: it.qty, QtyBefore: qtyBefore, QtyAfter: after,
|
||||
RefType: "stock_out", RefID: r.order.ID, OperatorID: &opID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── 财务记录 ────────────────────────────────────────────
|
||||
|
||||
func createFinanceRecord(db *gorm.DB, shopID, partnerID, opID uint64,
|
||||
|
||||
Reference in New Issue
Block a user