// seed — 初始化/重置测试数据 // // 用法(在 backend/ 目录下执行): // // go run cmd/seed/main.go # 写入数据(已存在则跳过) // go run cmd/seed/main.go --reset # 删表重建 + 写入数据 // go run cmd/seed/main.go --clear # 清空业务数据 + 重新写入(保留表结构) package main import ( "fmt" stdlog "log" "os" "time" "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" ) var allModels = []any{ &model.Shop{}, &model.User{}, &model.License{}, &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)} } 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) // ═══════════════════════════════════════════════════ // 商品 // ═══════════════════════════════════════════════════ 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) } // ═══════════════════════════════════════════════════ // 商品名称/系列/规格选项 // ═══════════════════════════════════════════════════ 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) } } // ═══════════════════════════════════════════════════ // 往来单位 // ═══════════════════════════════════════════════════ 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) // ═══════════════════════════════════════════════════ // 入库单 // ═══════════════════════════════════════════════════ // #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年批次"}, }, 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", "剑南春水晶剑 首批"}, }, 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年进口"}, }, 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 第二批次"}, }, 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", "飞天茅台 第二批次"}, }, 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) // ═══════════════════════════════════════════════════ // 出库单 // ═══════════════════════════════════════════════════ // #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瓶"}, }, 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瓶"}, }, 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瓶"}, }, 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瓶"}, }, 0, nil) _ = out3 _ = out4 // 根据已审核出库单减库存 deductInventoryBatch(db, shop.ID, wh1.ID, admin.ID, out1) deductInventoryBatch(db, shop.ID, 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, d(3), "君悦大酒店4月供货应收款") createFinanceRecord(db, shop.ID, partners["CUS002"].ID, admin.ID, "receivable", out2.order.TotalAmount, out2.order.TotalAmount, "stock_out", out2.order.ID, d(2), "外滩华尔道夫进口酒应收款") // 模拟一笔已收款 createFinanceRecord(db, shop.ID, partners["CUS001"].ID, admin.ID, "receipt", out1.order.TotalAmount, 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(" 数据概览:") fmt.Printf(" 仓库:%s / %s\n", wh1.Name, wh2.Name) fmt.Printf(" 商品分类:白酒 / 进口烈酒,共 %d 种商品\n", len(prods)) fmt.Println(" 往来单位:4 供应商 + 3 客户") fmt.Println(" 入库单:5 张(2已审核 / 1待审核 / 1草稿 / 1进口专库已审核)") fmt.Println(" 出库单:4 张(2已审核 / 1待审核 / 1草稿)") fmt.Println(" 财务记录:2 应收 + 1 收款") fmt.Println("═══════════════════════════════════════════") } // ── 辅助类型 ──────────────────────────────────────────── type inItemSeed struct { productID uint64 qty float64 price float64 batchNo string remark string } type outItemSeed struct { productID uint64 qty float64 price float64 remark string } type stockInResult struct { order model.StockInOrder items []inItemSeed } type stockOutResult struct { order model.StockOutOrder items []outItemSeed } // ── 核心 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.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 { 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 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, 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) } }