Compare commits

..

3 Commits

Author SHA1 Message Date
wangjia 6b0b2645ce chore: release client-v1.0.72
Deploy Client / build-client-web (push) Successful in 41s
Deploy Client / build-windows (push) Successful in 2m34s
Deploy Client / build-macos (push) Successful in 2m17s
Deploy Client / build-android (push) Successful in 1m26s
Deploy Client / build-ios (push) Successful in 2m42s
Deploy Client / release-deploy-client (push) Successful in 1m35s
出库售价可编辑列(应收按售价) + 退单按钮红色实心 + 库存列表隐藏数量列

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-22 13:02:29 +08:00
wangjia 3641f1cf16 chore: release server-v1.0.75
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
2026-06-22 13:01:25 +08:00
wangjia 6c43800354 fix(client): 退单按钮改红色实心样式,与原型一致突出危险操作
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 22:51:40 +08:00
14 changed files with 511 additions and 363 deletions
+10
View File
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
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.72] - 2026-06-22
### 新功能
- 出库单录入新增「售价」可编辑列:可按实际销售价录入,金额与单据应收按「售价 × 数量」实时计算,同时保留成本价一栏便于对比
### 改进
- 「退单」按钮改为红色实心样式,更醒目地突出该危险操作(与原型一致)
- 库存列表隐藏「库存量」列:序列号模型下每件商品独立,原数量列易受历史数据误导,故不再展示(缺货/预警逻辑不受影响)
- 出库「添加商品」可一次看到整仓商品(需配合后端 v1.0.75)
## [1.0.71] - 2026-06-21
### 新功能
+8
View File
@@ -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
### 新功能
+381 -310
View File
@@ -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", "人头马VSOP2024年进口"},
// #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 待审核:郎酒补货
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 出库行:引用入库已建 productsalePrice=售价。
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,
+5 -1
View File
@@ -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}
+4 -2
View File
@@ -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,
+14 -7
View File
@@ -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) {
+2
View File
@@ -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"`
+2 -1
View File
@@ -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
}
// 冲减应收(负向调整记录)
+3 -2
View File
@@ -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,
+1
View File
@@ -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,
+3
View File
@@ -4,6 +4,7 @@ class StockOutItem {
final int productId;
final double quantity;
final double unitPrice;
final double salePrice;
final double totalPrice;
final double returnedQuantity;
final String? productName;
@@ -20,6 +21,7 @@ class StockOutItem {
required this.productId,
required this.quantity,
required this.unitPrice,
this.salePrice = 0,
required this.totalPrice,
this.returnedQuantity = 0,
this.productName,
@@ -47,6 +49,7 @@ class StockOutItem {
productId: (json['product_id'] as num).toInt(),
quantity: (json['quantity'] as num).toDouble(),
unitPrice: (json['unit_price'] as num).toDouble(),
salePrice: (json['sale_price'] as num?)?.toDouble() ?? 0,
totalPrice: (json['total_price'] as num).toDouble(),
returnedQuantity: (json['returned_quantity'] as num?)?.toDouble() ?? 0,
productName: lineOrProduct('product_name', 'name'),
@@ -51,7 +51,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
ColDef('series', '系列'),
ColDef('batch', '批次号'),
ColDef('warehouse', '仓库'),
ColDef('qty', '库存量'),
// 库存量列已移除:序列号模型下每件独立,原数量列含历史装箱数脏数据易误导
ColDef('price', '单价'),
ColDef('prodDate', '生产日期'),
ColDef('inTime', '入库时间'),
@@ -311,15 +311,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
'batch' => DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
'warehouse' => DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)),
'qty' => DataCell(Text(
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
style: TextStyle(
fontWeight: FontWeight.w600,
color: item.quantity == 0
? AppTheme.danger
: (item.minStock != null && item.quantity < item.minStock!)
? AppTheme.accent
: AppTheme.textPrimary))),
'price' => DataCell(Text(
item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '-')),
'prodDate' => DataCell(Text(item.productionDate ?? '-')),
@@ -367,19 +358,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
if (item.series.isNotEmpty) MobileCardField('系列', item.series),
if (item.batchNo.isNotEmpty) MobileCardField('批次', item.batchNo),
MobileCardField('仓库', item.warehouseName.isEmpty ? '-' : item.warehouseName),
MobileCardField('库存', null,
valueWidget: Text(
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: item.quantity == 0
? AppTheme.danger
: (item.minStock != null && item.quantity < item.minStock!)
? AppTheme.accent
: AppTheme.textPrimary,
),
)),
if (item.unitPrice != null)
MobileCardField('单价', '¥${item.unitPrice!.toStringAsFixed(2)}'),
if (item.supplierName.isNotEmpty)
@@ -763,7 +741,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
};
return DataColumn(
label: label,
numeric: c.key == 'qty' || c.key == 'price',
numeric: c.key == 'price',
);
}).toList(),
rows: items.isEmpty
@@ -83,6 +83,7 @@ class _ItemRow {
final String spec;
final double? unitPrice;
final TextEditingController qtyCtrl;
final TextEditingController salePriceCtrl;
_ItemRow({
this.productId,
@@ -91,9 +92,19 @@ class _ItemRow {
this.series = '',
this.spec = '',
this.unitPrice,
}) : qtyCtrl = TextEditingController(text: '1');
double? salePrice,
}) : qtyCtrl = TextEditingController(text: '1'),
// 售价默认带出成本单价(无历史售价时),可手工改成实际卖价
salePriceCtrl = TextEditingController(
text: ((salePrice != null && salePrice > 0)
? salePrice
: (unitPrice ?? 0))
.toStringAsFixed(2));
void dispose() => qtyCtrl.dispose();
void dispose() {
qtyCtrl.dispose();
salePriceCtrl.dispose();
}
}
class StockOutFormScreen extends ConsumerStatefulWidget {
@@ -152,6 +163,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
series: item.productSeries ?? '',
spec: item.productSpec ?? '',
unitPrice: item.unitPrice,
salePrice: item.salePrice,
);
row.qtyCtrl.text = item.quantity.toStringAsFixed(0);
_items.add(row);
@@ -178,11 +190,13 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
super.dispose();
}
// 出库单总金额 = 应收 = Σ(售价×数量)
double get _totalAmount {
double total = 0;
for (final item in _items) {
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
total += qty * (item.unitPrice ?? 0);
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
total += qty * sale;
}
return total;
}
@@ -280,10 +294,12 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
final itemsData = _items.map((item) {
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = item.unitPrice ?? 0;
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
return {
'product_id': item.productId ?? 0,
'quantity': qty,
'unit_price': price,
'sale_price': sale,
'total_price': qty * price,
};
}).toList();
@@ -647,10 +663,11 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
2: FlexColumnWidth(2.0), // 商品名称
3: FlexColumnWidth(1.2), // 系列
4: FlexColumnWidth(1.2), // 规格
5: FlexColumnWidth(1.0), //
6: FlexColumnWidth(0.8), // 数量
7: FlexColumnWidth(1.0), // 金额
8: FixedColumnWidth(48), // 操作
5: FlexColumnWidth(1.0), // 成本
6: FlexColumnWidth(1.0), // 售价
7: FlexColumnWidth(0.8), // 数量
8: FlexColumnWidth(1.0), // 金额
9: FixedColumnWidth(48), // 操作
},
children: [
TableRow(
@@ -662,7 +679,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
'商品名称',
'系列',
'规格',
'',
'成本',
'售价',
'数量',
'金额',
'操作',
@@ -734,7 +752,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
final item = _items[index];
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = item.unitPrice ?? 0;
final amount = qty * price;
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
final amount = qty * sale;
return TableRow(
decoration: BoxDecoration(
@@ -761,12 +780,16 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
_cell(Text(item.spec,
style:
const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
//
// 成本
_cell(Text(price > 0 ? '¥${price.toStringAsFixed(2)}' : '-',
style: const TextStyle(fontSize: 13))),
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary))),
// 售价(可编辑)
Padding(
padding: const EdgeInsets.all(4), child: _salePriceField(item)),
// 数量
Padding(padding: const EdgeInsets.all(4), child: _qtyField(item)),
// 金额
// 金额=售价×数量)
_cell(Text('¥${amount.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))),
// 操作
@@ -803,12 +826,27 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
);
}
// 售价输入:可编辑,金额(应收)按 售价×数量 联动
Widget _salePriceField(_ItemRow item) {
return TextFormField(
controller: item.salePriceCtrl,
decoration: const InputDecoration(hintText: '0.00', isDense: true),
style: const TextStyle(fontSize: 13),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
],
onChanged: (_) => setState(() {}),
);
}
/// 窄屏(手机):每个明细行渲染为一张卡片,字段竖排,避免表格横向溢出。
Widget _buildItemCard(int index) {
final item = _items[index];
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = item.unitPrice ?? 0;
final amount = qty * price;
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
final amount = qty * sale;
return MobileListCard(
title: Text(item.productName),
@@ -824,7 +862,9 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
fields: [
if (item.series.isNotEmpty) MobileCardField('系列', item.series),
if (item.spec.isNotEmpty) MobileCardField('规格', item.spec),
MobileCardField('单价', price > 0 ? '¥${price.toStringAsFixed(2)}' : '-'),
MobileCardField(
'成本价', price > 0 ? '¥${price.toStringAsFixed(2)}' : '-'),
MobileCardField('售价', null, valueWidget: _salePriceField(item)),
MobileCardField('数量', null, valueWidget: _qtyField(item)),
MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'),
],
+22 -2
View File
@@ -35,16 +35,36 @@ List<Widget> buildOrderRowActions({
onPressed: onPressed,
child: Text(text, style: TextStyle(fontSize: 12, color: color)),
);
// 实心强调按钮(用于「退单」这类需突出的危险操作,与原型一致)。
Widget filledBtn(String text, Color color, VoidCallback onPressed, {Key? key}) =>
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ElevatedButton(
key: key,
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: color,
foregroundColor: Colors.white,
elevation: 0,
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(0, 30),
padding: const EdgeInsets.symmetric(horizontal: 12),
textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
child: Text(text),
),
);
return [
btn('详情', AppTheme.primary, onDetail),
btn('打印', AppTheme.primary, onPrint),
...afterPrint,
if (!readonly && status == 'approved')
WriteGuard(child: btn('结清', AppTheme.accent, onSettle)),
// 退单(已审核单退货):error 样式,按权限显示
// 退单(已审核单退货):红色实心按钮突出(与原型一致),按权限显示
if (!readonly && status == 'approved' && canReturn)
WriteGuard(
child: btn('退单', AppTheme.danger, onReturn,
child: filledBtn('退单', AppTheme.danger, onReturn,
key: Key('btn_return_$orderId'))),
if (!readonly && status == 'draft') ...[
WriteGuard(child: btn('修改', AppTheme.primary, onEdit)),