From 3641f1cf16f9ba441a4cf1002cc718769c85b8cb Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 13:01:25 +0800 Subject: [PATCH] chore: release server-v1.0.75 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 出库售价(应收按售价) + 库存选择器分页上限修复 + seed 改为序列号模型 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx --- CHANGELOG-server.md | 8 + backend/cmd/seed/main.go | 691 ++++++++++-------- backend/internal/handler/inventory.go | 6 +- backend/internal/handler/stock_out.go | 6 +- backend/internal/handler/stock_return_test.go | 21 +- backend/internal/model/stock.go | 2 + backend/internal/service/stock.go | 3 +- backend/schema/schema.sql | 5 +- backend/testutil/setup.go | 1 + 9 files changed, 420 insertions(+), 323 deletions(-) diff --git a/CHANGELOG-server.md b/CHANGELOG-server.md index be09af4..bfca1d6 100644 --- a/CHANGELOG-server.md +++ b/CHANGELOG-server.md @@ -5,6 +5,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.75] - 2026-06-22 + +### 新功能 +- 出库单新增「售价」字段:可按实际销售价录入,应收账款改为按「售价 × 数量」计算,更真实地反映销售额(此前应收按成本价计)。 + +### 修复 +- 修复出库单「添加商品」选择器只能看到一小部分商品的问题:库存查询接口的分页上限此前会把超额请求打回,导致选货时只显示约 20 条,现已放宽,可一次加载整仓商品。 + ## [1.0.74] - 2026-06-21 ### 新功能 diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go index 3b00785..473f7be 100644 --- a/backend/cmd/seed/main.go +++ b/backend/cmd/seed/main.go @@ -5,6 +5,12 @@ // 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 ( @@ -13,6 +19,7 @@ import ( "os" "time" + "github.com/google/uuid" "golang.org/x/crypto/bcrypt" "gorm.io/driver/mysql" "gorm.io/gorm" @@ -20,6 +27,7 @@ import ( "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/internal/util" ) var allModels = []any{ @@ -111,6 +119,7 @@ func main() { 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 } // ═══════════════════════════════════════════════════ @@ -180,41 +189,8 @@ func main() { }).(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) - } - - // ═══════════════════════════════════════════════════ - // 商品名称/系列/规格选项 + // 商品基础数据字典(品牌/型号/版本 = 名称/系列/规格选项) + // 序列号模型下:product 由入库逐行生成,字典只是录入时的可选项来源。 // ═══════════════════════════════════════════════════ type dimSeed struct{ code, name string } @@ -243,7 +219,10 @@ func main() { } } - for _, s := range []struct{ code, name string; quantity int }{ + for _, s := range []struct { + code, name string + quantity int + }{ {"GG001", "500ml/瓶", 1}, {"GG002", "750ml/瓶", 1}, {"GG003", "700ml/瓶", 1}, } { var opt model.ProductSpecOption @@ -259,6 +238,22 @@ func main() { } } + // 商品模板:仅作为入库逐行生成 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) } + // ═══════════════════════════════════════════════════ // 往来单位 // ═══════════════════════════════════════════════════ @@ -317,110 +312,100 @@ func main() { 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 := 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年批次"}, + 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 := 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", "剑南春水晶剑 首批"}, + 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 := 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年进口"}, + // #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 待审核:郎酒补货 - 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 第二批次"}, + // #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 草稿:追加茅台 - 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", "飞天茅台 第二批次"}, + 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) - _ = 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) + // 已审核入库单 → 生成库存(带快照 + 关联 stock_in_item_id) + sd.applyStockInToInventory(in1) + sd.applyStockInToInventory(in2) + sd.applyStockInToInventory(in3) // ═══════════════════════════════════════════════════ - // 出库单 + // 出库单(引用入库已建 product,记 sale_price,应收按售价) // ═══════════════════════════════════════════════════ // #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瓶"}, + 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 := 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瓶"}, + 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 待审核:广州白天鹅宾馆 - 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瓶"}, + 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 草稿:君悦追加 - out4 := createStockOutOrder(db, shop.ID, wh1.ID, operator.ID, partners["CUS001"].ID, - "CK20260407001", d(0), "draft", "sale", - "北京君悦追加订单,草稿中", - []outItemSeed{ - {prods[0].ID, 6, 2800, "飞天茅台 追加 6瓶"}, + sd.createStockOut(wh1.ID, operator.ID, partners["CUS001"].ID, + "CK20260407001", d(0), "draft", "sale", "北京君悦追加订单,草稿中", + []outLine{ + {in1.products[0], 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) + // 已审核出库单 → 扣减库存(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, @@ -446,44 +431,313 @@ func main() { fmt.Println(" operator / password123 (操作员)") fmt.Println(" test / password123 (只读)") fmt.Println() - fmt.Println(" 数据概览:") + fmt.Println(" 数据概览(序列号模型:入库逐行建独立 product):") fmt.Printf(" 仓库:%s / %s\n", wh1.Name, wh2.Name) - fmt.Printf(" 商品分类:白酒 / 进口烈酒,共 %d 种商品\n", len(prods)) + fmt.Printf(" 入库逐行生成 product:%d 个独立商品编号\n", codeSeq) fmt.Println(" 往来单位:4 供应商 + 3 客户") - fmt.Println(" 入库单:5 张(2已审核 / 1待审核 / 1草稿 / 1进口专库已审核)") - fmt.Println(" 出库单:4 张(2已审核 / 1待审核 / 1草稿)") + fmt.Println(" 入库单:5 张(3已审核 / 1待审核 / 1草稿)") + fmt.Println(" 出库单:4 张(2已审核 / 1待审核 / 1草稿),记 sale_price,应收按售价") fmt.Println(" 财务记录:2 应收 + 1 收款") fmt.Println("═══════════════════════════════════════════") } -// ── 辅助类型 ──────────────────────────────────────────── +// ── 数据模板 ──────────────────────────────────────────── -type inItemSeed struct { - productID uint64 - qty float64 - price float64 - batchNo string - remark string +// goods 商品模板:入库逐行据此 new 独立 product,模板本身不落库。 +type goods struct { + name, series, spec, unit, brand string + cat *uint64 + purchase, sale float64 + minStock int } -type outItemSeed struct { - productID uint64 - qty float64 - price float64 +// 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 []inItemSeed + order model.StockInOrder + items []model.StockInItem + products []model.Product } type stockOutResult struct { order model.StockOutOrder - items []outItemSeed + items []model.StockOutItem } -// ── 核心 upsert ──────────────────────────────────────────── +// 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 { @@ -534,16 +788,6 @@ func upsert(db *gorm.DB, dest any, cond string, args ...any) any { } 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 { @@ -558,179 +802,6 @@ func upsert(db *gorm.DB, dest any, cond string, args ...any) any { 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, diff --git a/backend/internal/handler/inventory.go b/backend/internal/handler/inventory.go index a812728..bd1b024 100644 --- a/backend/internal/handler/inventory.go +++ b/backend/internal/handler/inventory.go @@ -54,9 +54,13 @@ func (h *InventoryHandler) List(c *gin.Context) { if page < 1 { page = 1 } - if pageSize < 1 || pageSize > 500 { + if pageSize < 1 { pageSize = 20 } + // 超出上限钳到上限(此前是回退默认 20,导致出库选货请求大 page_size 被打回只剩 20 条) + if pageSize > 5000 { + pageSize = 5000 + } baseWhere := "inv.shop_id = ? AND inv.deleted_at IS NULL" args := []interface{}{shopID} diff --git a/backend/internal/handler/stock_out.go b/backend/internal/handler/stock_out.go index 4fd9a7c..1b7cb9c 100644 --- a/backend/internal/handler/stock_out.go +++ b/backend/internal/handler/stock_out.go @@ -110,8 +110,9 @@ func (h *StockOutHandler) Create(c *gin.Context) { var total float64 for i := range req.Items { req.Items[i].ShopID = shopID + // TotalPrice 记成本小计;应收(TotalAmount)按售价×数量 req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice - total += req.Items[i].TotalPrice + total += req.Items[i].Quantity * req.Items[i].SalePrice } req.TotalAmount = total @@ -147,8 +148,9 @@ func (h *StockOutHandler) Update(c *gin.Context) { for i := range req.Items { req.Items[i].ShopID = shopID req.Items[i].OrderID = order.ID + // TotalPrice 记成本小计;应收(TotalAmount)按售价×数量 req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice - total += req.Items[i].TotalPrice + total += req.Items[i].Quantity * req.Items[i].SalePrice } updates := map[string]interface{}{ "warehouse_id": req.WarehouseID, diff --git a/backend/internal/handler/stock_return_test.go b/backend/internal/handler/stock_return_test.go index 4018513..e34152a 100644 --- a/backend/internal/handler/stock_return_test.go +++ b/backend/internal/handler/stock_return_test.go @@ -27,13 +27,21 @@ func TestStockReturn_StockOut_OwnAllowed(t *testing.T) { // 操作员建出库单(本人) → 提交 → 审核 w := makeRequest(r, "POST", "/api/v1/stock-out/orders", opToken, map[string]interface{}{ "warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339), - "items": []map[string]interface{}{{"product_id": prod.ID, "quantity": 15.0, "unit_price": 20.0}}, + "items": []map[string]interface{}{{"product_id": prod.ID, "quantity": 15.0, "unit_price": 20.0, "sale_price": 25.0}}, }) require.Equal(t, http.StatusCreated, w.Code) orderID := extractID(w) makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), opToken, nil) makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), opToken, nil) + // 应收按售价口径:TotalAmount = 15 × 25 = 375(不是成本 15×20) + var soOrder model.StockOutOrder + require.NoError(t, db.First(&soOrder, orderID).Error) + assert.Equal(t, float64(375), soOrder.TotalAmount) + var recv model.FinanceRecord + require.NoError(t, db.Where("shop_id = ? AND type = 'receivable' AND ref_type = 'stock_out'", shop.ID).First(&recv).Error) + assert.Equal(t, float64(375), recv.Amount, "应收应按售价×数量") + var item model.StockOutItem require.NoError(t, db.Where("order_id = ?", orderID).First(&item).Error) @@ -75,12 +83,11 @@ func TestStockReturn_StockOut_OwnAllowed(t *testing.T) { db.First(&order, orderID) assert.Equal(t, "full", order.ReturnState) - // 应收冲减:存在一条负向 receivable - var neg int64 - db.Model(&model.FinanceRecord{}). - Where("shop_id = ? AND type = 'receivable' AND amount < 0 AND ref_type = 'stock_out_return'", shop.ID). - Count(&neg) - assert.Equal(t, int64(1), neg) + // 应收冲减:存在一条负向 receivable,金额按售价口径 = -375 + var negRec model.FinanceRecord + require.NoError(t, db.Where("shop_id = ? AND type = 'receivable' AND amount < 0 AND ref_type = 'stock_out_return'", shop.ID). + First(&negRec).Error) + assert.Equal(t, float64(-375), negRec.Amount, "退单冲应收应按售价×数量") } func TestStockReturn_StockIn_AdminOnly_AndRemovesInventory(t *testing.T) { diff --git a/backend/internal/model/stock.go b/backend/internal/model/stock.go index 77418ec..ee96c15 100644 --- a/backend/internal/model/stock.go +++ b/backend/internal/model/stock.go @@ -94,6 +94,8 @@ type StockOutItem struct { Spec string `gorm:"size:100" json:"spec"` Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"` UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"` + // 售价:出库实际销售单价(可编辑),应收账款按 售价×数量 计;与 UnitPrice(入库成本) 区分 + SalePrice float64 `gorm:"type:decimal(16,2);default:0" json:"sale_price"` TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"` // 已退数量:0=未退,=Quantity 表示整行已退单 ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"` diff --git a/backend/internal/service/stock.go b/backend/internal/service/stock.go index c65ed21..940522f 100644 --- a/backend/internal/service/stock.go +++ b/backend/internal/service/stock.go @@ -416,7 +416,8 @@ func (s *StockService) ReturnStockOut(shopID, orderID, userID uint64, role strin return err } it.ReturnedQuantity = it.Quantity - returnedAmount += it.TotalPrice + // 冲应收按售价口径(与审核建应收 TotalAmount=Σ售价×数量 一致) + returnedAmount += it.Quantity * it.SalePrice } // 冲减应收(负向调整记录) diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index 87f1cbe..e29824a 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -360,8 +360,9 @@ CREATE TABLE IF NOT EXISTS `stock_out_items` ( `series` VARCHAR(100) DEFAULT NULL COMMENT '系列(历史导入快照)', `spec` VARCHAR(100) DEFAULT NULL COMMENT '规格(历史导入快照)', `quantity` DECIMAL(12,3) NOT NULL, - `unit_price` DECIMAL(16,2) NOT NULL DEFAULT 0, - `total_price` DECIMAL(16,2) NOT NULL DEFAULT 0, + `unit_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '成本单价(入库成本快照)', + `sale_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '售价(出库实际销售单价,应收按售价×数量)', + `total_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '成本小计(unit_price×quantity)', `batch_no` VARCHAR(50) DEFAULT NULL COMMENT '批次号', `production_date` DATE DEFAULT NULL COMMENT '生产日期', `custom_fields` JSON DEFAULT NULL, diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index f020f7b..e72182e 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -389,6 +389,7 @@ func SetupTestDB() *gorm.DB { spec TEXT, quantity REAL NOT NULL, unit_price REAL DEFAULT 0, + sale_price REAL DEFAULT 0, total_price REAL DEFAULT 0, returned_quantity REAL DEFAULT 0, batch_no TEXT,