// seed — 初始化/重置测试数据 // // 用法(在 backend/ 目录下执行): // // 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 ( "fmt" stdlog "log" "os" "time" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/util" ) var allModels = []any{ &model.Shop{}, &model.User{}, &model.License{}, &model.LicenseCode{}, &model.ProductCategory{}, &model.Product{}, &model.ProductNameOption{}, &model.ProductSeriesOption{}, &model.ProductSpecOption{}, &model.Warehouse{}, &model.Partner{}, &model.StockInOrder{}, &model.StockInItem{}, &model.StockOutOrder{}, &model.StockOutItem{}, &model.Inventory{}, &model.InventoryLog{}, &model.InventoryCheck{}, &model.InventoryCheckItem{}, &model.FinanceRecord{}, &model.NumberRule{}, } // 清空顺序(子表先清) var truncateOrder = []string{ "inventory_check_items", "inventory_checks", "inventory_logs", "inventories", "stock_out_items", "stock_out_orders", "stock_in_items", "stock_in_orders", "finance_records", "number_rules", "partners", "warehouses", "products", "product_spec_options", "product_series_options", "product_name_options", "product_categories", "licenses", "users", "shops", } func main() { mode := "" if len(os.Args) > 1 { mode = os.Args[1] } config.Load() db, err := gorm.Open(mysql.Open(config.C.Database.DSN), &gorm.Config{ Logger: logger.New( stdlog.New(os.Stdout, "\r\n", stdlog.LstdFlags), logger.Config{LogLevel: logger.Warn, IgnoreRecordNotFoundError: true}, ), }) if err != nil { stdlog.Fatalf("连接数据库失败: %v", err) } switch mode { case "--reset": fmt.Println("⚠️ --reset:删除所有表并重建...") db.Exec("SET FOREIGN_KEY_CHECKS = 0") if err := db.Migrator().DropTable(allModels...); err != nil { stdlog.Fatalf("删表失败: %v", err) } db.Exec("SET FOREIGN_KEY_CHECKS = 1") fmt.Println("✅ 所有表已删除") case "--clear": fmt.Println("🧹 --clear:清空所有业务数据...") db.Exec("SET FOREIGN_KEY_CHECKS = 0") for _, t := range truncateOrder { if err := db.Exec("TRUNCATE TABLE `" + t + "`").Error; err != nil { fmt.Printf(" 跳过 %s(%v)\n", t, err) } else { fmt.Printf(" 清空 %s\n", t) } } db.Exec("SET FOREIGN_KEY_CHECKS = 1") fmt.Println("✅ 数据已清空") return } // 同步表结构 if err := db.AutoMigrate(allModels...); err != nil { stdlog.Fatalf("AutoMigrate 失败: %v", err) } fmt.Println("✅ 表结构已同步") fmt.Println() 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 } // ═══════════════════════════════════════════════════ // 门店 // ═══════════════════════════════════════════════════ shop := upsert(db, &model.Shop{}, "code = ?", "S001", func() any { return &model.Shop{ Name: "测试酒库门店", Code: "S001", Address: "北京市朝阳区酒仙桥路甲1号", Phone: "010-12345678", ManagerName: "张三", BusinessLicense: "91110105MA0000000X", } }).(model.Shop) // ═══════════════════════════════════════════════════ // 用户 // ═══════════════════════════════════════════════════ hash := mustHash("password123") admin := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "admin", func() any { return &model.User{ ShopID: shop.ID, Username: "admin", PasswordHash: hash, RealName: "张三(管理员)", Phone: "13800000001", Role: "admin", IsActive: true, } }).(model.User) operator := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "operator", func() any { return &model.User{ ShopID: shop.ID, Username: "operator", PasswordHash: hash, RealName: "李四(操作员)", Phone: "13800000002", Role: "operator", IsActive: true, } }).(model.User) upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "test", func() any { return &model.User{ ShopID: shop.ID, Username: "test", PasswordHash: hash, RealName: "王五(只读)", Phone: "13800000003", Role: "readonly", IsActive: true, } }) // ═══════════════════════════════════════════════════ // 仓库 // ═══════════════════════════════════════════════════ wh1 := upsert(db, &model.Warehouse{}, "shop_id = ? AND name = ?", shop.ID, "主仓库", func() any { return &model.Warehouse{ TenantBase: model.TenantBase{ShopID: shop.ID}, Name: "主仓库", Location: "A栋1层东侧", IsDefault: true, } }).(model.Warehouse) wh2 := upsert(db, &model.Warehouse{}, "shop_id = ? AND name = ?", shop.ID, "进口酒专库", func() any { return &model.Warehouse{ TenantBase: model.TenantBase{ShopID: shop.ID}, Name: "进口酒专库", Location: "B栋2层恒温区", IsDefault: false, } }).(model.Warehouse) // ═══════════════════════════════════════════════════ // 商品分类 // ═══════════════════════════════════════════════════ catBaijiu := upsert(db, &model.ProductCategory{}, "shop_id = ? AND name = ?", shop.ID, "白酒", func() any { return &model.ProductCategory{TenantBase: model.TenantBase{ShopID: shop.ID}, Name: "白酒", SortOrder: 1} }).(model.ProductCategory) catImport := upsert(db, &model.ProductCategory{}, "shop_id = ? AND name = ?", shop.ID, "进口烈酒", func() any { return &model.ProductCategory{TenantBase: model.TenantBase{ShopID: shop.ID}, Name: "进口烈酒", SortOrder: 2} }).(model.ProductCategory) // ═══════════════════════════════════════════════════ // 商品基础数据字典(品牌/型号/版本 = 名称/系列/规格选项) // 序列号模型下:product 由入库逐行生成,字典只是录入时的可选项来源。 // ═══════════════════════════════════════════════════ type dimSeed struct{ code, name string } for _, s := range []dimSeed{ {"NA001", "飞天茅台 53度"}, {"NA002", "五粮液 52度"}, {"NA003", "洋河梦之蓝 M6"}, {"NA004", "泸州老窖 特曲"}, {"NA005", "剑南春 水晶剑"}, {"NA006", "郎酒 红花郎10"}, {"NA007", "拉菲古堡 2018"}, {"NA008", "人头马 VSOP"}, } { var opt model.ProductNameOption if db.Where("shop_id = ? AND name = ?", shop.ID, s.name).First(&opt).Error != nil { opt = model.ProductNameOption{TenantBase: model.TenantBase{ShopID: shop.ID}, Code: s.code, Name: s.name} db.Create(&opt) fmt.Printf("✅ 名称选项:%s %s\n", s.code, s.name) } } for _, s := range []dimSeed{ {"SE001", "茅台"}, {"SE002", "五粮液"}, {"SE003", "洋河"}, {"SE004", "泸州老窖"}, {"SE005", "剑南春"}, {"SE006", "郎酒"}, {"SE007", "波尔多"}, {"SE008", "人头马"}, } { var opt model.ProductSeriesOption if db.Where("shop_id = ? AND name = ?", shop.ID, s.name).First(&opt).Error != nil { opt = model.ProductSeriesOption{TenantBase: model.TenantBase{ShopID: shop.ID}, Code: s.code, Name: s.name} db.Create(&opt) fmt.Printf("✅ 系列选项:%s %s\n", s.code, s.name) } } for _, s := range []struct { code, name string quantity int }{ {"GG001", "500ml/瓶", 1}, {"GG002", "750ml/瓶", 1}, {"GG003", "700ml/瓶", 1}, } { var opt model.ProductSpecOption if db.Where("shop_id = ? AND name = ?", shop.ID, s.name).First(&opt).Error != nil { opt = model.ProductSpecOption{ TenantBase: model.TenantBase{ShopID: shop.ID}, Code: s.code, Name: s.name, Quantity: s.quantity, } db.Create(&opt) fmt.Printf("✅ 规格选项:%s\n", s.name) } } // 商品模板:仅作为入库逐行生成 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) } // ═══════════════════════════════════════════════════ // 往来单位 // ═══════════════════════════════════════════════════ type partnerSeed struct { code, name, ptype, contact, phone, address, bank string creditLimit float64 remark string } partnerSeeds := []partnerSeed{ {"SUP001", "贵州茅台酒股份有限公司", "supplier", "张经理", "0851-22222001", "贵州省仁怀市茅台镇", "工商银行仁怀支行 6222 0000 0001 0001", 5000000, "茅台系列直供,账期30天"}, {"SUP002", "四川五粮液股份有限公司", "supplier", "王总监", "0831-33333001", "四川省宜宾市翠屏区", "建设银行宜宾支行 6227 0000 0001 0002", 3000000, "五粮液系列授权经销"}, {"SUP003", "江苏洋河酒厂股份有限公司", "supplier", "赵主任", "0527-44444001", "江苏省宿迁市洋河新区", "农业银行宿迁支行 6228 0000 0001 0003", 2000000, "洋河梦之蓝系列直供"}, {"SUP004", "四川郎酒股份有限公司", "supplier", "刘经理", "0830-55555001", "四川省古蔺县二郎镇", "中国银行古蔺支行 6013 0000 0001 0004", 1500000, "郎酒红花郎系列"}, {"CUS001", "北京君悦大酒店", "customer", "李采购", "010-65888001", "北京市朝阳区建国门外大街2号", "中信银行北京朝阳支行 6217 0000 0002 0001", 500000, "五星级酒店,月结"}, {"CUS002", "上海外滩华尔道夫酒店", "customer", "陈主任", "021-62308001", "上海市黄浦区中山东一路2号", "浦发银行上海黄浦支行 6210 0000 0002 0002", 800000, "豪华酒店,按季结算"}, {"CUS003", "广州白天鹅宾馆", "customer", "吴采购", "020-81886001", "广州市荔湾区沙面南街1号", "招商银行广州荔湾支行 6225 0000 0002 0003", 300000, "高端宾馆,月结30天"}, } partners := make(map[string]model.Partner) for _, s := range partnerSeeds { p := upsert(db, &model.Partner{}, "shop_id = ? AND code = ?", shop.ID, s.code, func() any { return &model.Partner{ TenantBase: model.TenantBase{ShopID: shop.ID}, Code: s.code, Name: s.name, Type: s.ptype, Contact: s.contact, Phone: s.phone, Address: s.address, BankAccount: s.bank, CreditLimit: s.creditLimit, Balance: 0, Remark: s.remark, } }).(model.Partner) partners[s.code] = p } // ═══════════════════════════════════════════════════ // 编号规则 // ═══════════════════════════════════════════════════ upsertNumberRule(db, shop.ID, "stock_in", "RK", 5) 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 := 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 := 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 := 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 待审核:郎酒补货(仅建单+建 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 草稿:追加茅台 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) // 已审核入库单 → 生成库存(带快照 + 关联 stock_in_item_id) sd.applyStockInToInventory(in1) sd.applyStockInToInventory(in2) sd.applyStockInToInventory(in3) // ═══════════════════════════════════════════════════ // 出库单(引用入库已建 product,记 sale_price,应收按售价) // ═══════════════════════════════════════════════════ // #1 已审核:北京君悦大酒店,主仓库 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 := 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 待审核:广州白天鹅宾馆 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 草稿:君悦追加 sd.createStockOut(wh1.ID, operator.ID, partners["CUS001"].ID, "CK20260407001", d(0), "draft", "sale", "北京君悦追加订单,草稿中", []outLine{ {in1.products[0], 6, 2800, "飞天茅台 追加 6瓶"}, }, 0, nil) // 已审核出库单 → 扣减库存(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.SaleTotal, out1.order.SaleTotal, "stock_out", out1.order.ID, d(3), "君悦大酒店4月供货应收款") createFinanceRecord(db, shop.ID, partners["CUS002"].ID, admin.ID, "receivable", out2.order.SaleTotal, out2.order.SaleTotal, "stock_out", out2.order.ID, d(2), "外滩华尔道夫进口酒应收款") // 模拟一笔已收款 createFinanceRecord(db, shop.ID, partners["CUS001"].ID, admin.ID, "receipt", out1.order.SaleTotal, 0, "stock_out", out1.order.ID, d(1), "君悦大酒店回款,结清") // ═══════════════════════════════════════════════════ // 打印汇总 // ═══════════════════════════════════════════════════ fmt.Println("═══════════════════════════════════════════") fmt.Println(" 测试数据写入完成") fmt.Println("═══════════════════════════════════════════") fmt.Println() fmt.Println(" 登录信息:") fmt.Printf(" 门店编号:%s\n", shop.Code) fmt.Println(" admin / password123 (管理员)") fmt.Println(" operator / password123 (操作员)") fmt.Println(" test / password123 (只读)") fmt.Println() fmt.Println(" 数据概览(序列号模型:入库逐行建独立 product):") fmt.Printf(" 仓库:%s / %s\n", wh1.Name, wh2.Name) fmt.Printf(" 入库逐行生成 product:%d 个独立商品编号\n", codeSeq) fmt.Println(" 往来单位:4 供应商 + 3 客户") fmt.Println(" 入库单:5 张(3已审核 / 1待审核 / 1草稿)") fmt.Println(" 出库单:4 张(2已审核 / 1待审核 / 1草稿),记 sale_price,应收按售价") fmt.Println(" 财务记录:2 应收 + 1 收款") fmt.Println("═══════════════════════════════════════════") } // ── 数据模板 ──────────────────────────────────────────── // goods 商品模板:入库逐行据此 new 独立 product,模板本身不落库。 type goods struct { name, series, spec, unit, brand string cat *uint64 purchase, sale float64 minStock int } // 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 []model.StockInItem products []model.Product } type stockOutResult struct { order model.StockOutOrder items []model.StockOutItem } // 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, CostPrice: ln.price, CostAmount: ln.qty * ln.price, BatchNo: ln.batch, ProductionDate: ln.prodDate, Remark: ln.remark, } s.db.Create(&item) total += item.CostAmount items = append(items, item) prods = append(prods, p) } s.db.Model(&o).Update("cost_total", total) o.CostTotal = 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.CostPrice 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, profit 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, CostPrice: p.PurchasePrice, // 成本单价(快照) SalePrice: ln.sale, // 售价 CostAmount: ln.qty * p.PurchasePrice, // 成本小计 SaleAmount: ln.qty * ln.sale, // 售价小计 BatchNo: p.BatchNo, Remark: ln.remark, } s.db.Create(&item) total += ln.qty * ln.sale profit += (ln.sale - p.PurchasePrice) * ln.qty items = append(items, item) } s.db.Model(&o).Updates(map[string]interface{}{"sale_total": total, "profit_total": profit}) o.SaleTotal = total o.ProfitTotal = profit 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 { // 最后一个 arg 是 factory func factoryIdx := len(args) - 1 factory := args[factoryIdx].(func() any) queryArgs := args[:factoryIdx] found := false switch v := dest.(type) { case *model.Shop: found = db.Where(cond, queryArgs...).First(v).Error == nil if !found { obj := factory().(*model.Shop) db.Create(obj) fmt.Printf("✅ 门店:%s (%s)\n", obj.Name, obj.Code) return *obj } fmt.Printf("⏭ 门店:%s (%s)\n", v.Name, v.Code) return *v case *model.User: found = db.Where(cond, queryArgs...).First(v).Error == nil if !found { obj := factory().(*model.User) db.Create(obj) fmt.Printf("✅ 用户:%s(%s)\n", obj.Username, obj.Role) return *obj } fmt.Printf("⏭ 用户:%s\n", v.Username) return *v case *model.Warehouse: found = db.Where(cond, queryArgs...).First(v).Error == nil if !found { obj := factory().(*model.Warehouse) db.Create(obj) fmt.Printf("✅ 仓库:%s(%s)\n", obj.Name, obj.Location) return *obj } fmt.Printf("⏭ 仓库:%s\n", v.Name) return *v case *model.ProductCategory: found = db.Where(cond, queryArgs...).First(v).Error == nil if !found { obj := factory().(*model.ProductCategory) db.Create(obj) fmt.Printf("✅ 分类:%s\n", obj.Name) return *obj } fmt.Printf("⏭ 分类:%s\n", v.Name) return *v case *model.Partner: found = db.Where(cond, queryArgs...).First(v).Error == nil if !found { obj := factory().(*model.Partner) db.Create(obj) fmt.Printf("✅ 往来单位:%s(%s)\n", obj.Name, obj.Type) return *obj } fmt.Printf("⏭ 往来单位:%s\n", v.Name) return *v } return nil } // ── 财务记录 ──────────────────────────────────────────── func createFinanceRecord(db *gorm.DB, shopID, partnerID, opID uint64, ftype string, amount, balance float64, refType string, refID uint64, date model.Date, remark string, ) { var f model.FinanceRecord if db.Where("shop_id = ? AND ref_type = ? AND ref_id = ? AND type = ?", shopID, refType, refID, ftype).First(&f).Error != nil { f = model.FinanceRecord{ ShopID: shopID, PartnerID: &partnerID, Type: ftype, Amount: amount, Balance: balance, RefType: refType, RefID: &refID, OperatorID: opID, RecordDate: date.Time, Remark: remark, } db.Create(&f) fmt.Printf("✅ 财务记录:%s %.0f 元(%s)\n", ftype, amount, remark) } } // ── 工具函数 ──────────────────────────────────────────── func mustHash(plain string) string { b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) if err != nil { stdlog.Fatalf("bcrypt 失败: %v", err) } return string(b) } func upsertNumberRule(db *gorm.DB, shopID uint64, ruleType, prefix string, currentNo int) { var r model.NumberRule if db.Where("shop_id = ? AND type = ?", shopID, ruleType).First(&r).Error != nil { r = model.NumberRule{ShopID: shopID, Type: ruleType, Prefix: prefix, CurrentNo: currentNo, DateFormat: "YYYYMMDD"} db.Create(&r) fmt.Printf("✅ 编号规则:%s → %s\n", ruleType, prefix) } }