Files
jiu/backend/cmd/seed/main.go
T
wangjia 3641f1cf16
Deploy Server / release-deploy-server (push) Successful in 1m7s
chore: release server-v1.0.75
出库售价(应收按售价) + 库存选择器分页上限修复 + seed 改为序列号模型

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-22 13:01:25 +08:00

848 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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), "人头马VSOP2024年进口"},
}, 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 出库行:引用入库已建 productsalePrice=售价。
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)
}
}