3641f1cf16
Deploy Server / release-deploy-server (push) Successful in 1m7s
出库售价(应收按售价) + 库存选择器分页上限修复 + seed 改为序列号模型 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
848 lines
33 KiB
Go
848 lines
33 KiB
Go
// 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.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(" 数据概览(序列号模型:入库逐行建独立 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,
|
||
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 {
|
||
// 最后一个 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)
|
||
}
|
||
}
|