86baaf3a1c
Deploy / deploy (push) Successful in 1m13s
原来 uniqueIndex:uk_shop_username 只在 Username 字段上,导致用户名全局唯一。 S002/S003 的 admin 用户与 S001 冲突,INSERT 被静默跳过,无法登录。 将 User 的 TenantBase 改为直接定义 ShopID,使其参与复合唯一索引。 需要 Reset DB 使新索引生效。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
776 lines
31 KiB
Go
776 lines
31 KiB
Go
// 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)
|
||
}
|
||
}
|