Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b0b2645ce | |||
| 3641f1cf16 | |||
| 6c43800354 | |||
| 51acee2705 |
@@ -5,6 +5,22 @@ 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/),
|
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).
|
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
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- 已审核单据「退单」:入库/出库列表点红色「退单」→ 弹出退单明细窗,可逐行退或一键「全部退单」,确认框显示名称/系列/规格;已退明细以红色「已退单」醒目标注,列表状态旁显示「部分退单/已退单」徽章
|
||||||
|
- 数据导入页新增「入库单/出库单」导入(老系统单据打印格式,每个文件一张单)
|
||||||
|
|
||||||
## [1.0.70] - 2026-06-21
|
## [1.0.70] - 2026-06-21
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
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).
|
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
|
## [1.0.74] - 2026-06-21
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|||||||
+381
-310
@@ -5,6 +5,12 @@
|
|||||||
// go run cmd/seed/main.go # 写入数据(已存在则跳过)
|
// go run cmd/seed/main.go # 写入数据(已存在则跳过)
|
||||||
// go run cmd/seed/main.go --reset # 删表重建 + 写入数据
|
// go run cmd/seed/main.go --reset # 删表重建 + 写入数据
|
||||||
// go run cmd/seed/main.go --clear # 清空业务数据 + 重新写入(保留表结构)
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,6 +19,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -20,6 +27,7 @@ import (
|
|||||||
|
|
||||||
"github.com/wangjia/jiu/backend/config"
|
"github.com/wangjia/jiu/backend/config"
|
||||||
"github.com/wangjia/jiu/backend/internal/model"
|
"github.com/wangjia/jiu/backend/internal/model"
|
||||||
|
"github.com/wangjia/jiu/backend/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
var allModels = []any{
|
var allModels = []any{
|
||||||
@@ -111,6 +119,7 @@ func main() {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
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)} }
|
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 }
|
ptrT := func(t time.Time) *time.Time { return &t }
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════
|
||||||
@@ -180,41 +189,8 @@ func main() {
|
|||||||
}).(model.ProductCategory)
|
}).(model.ProductCategory)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════
|
||||||
// 商品
|
// 商品基础数据字典(品牌/型号/版本 = 名称/系列/规格选项)
|
||||||
// ═══════════════════════════════════════════════════
|
// 序列号模型下:product 由入库逐行生成,字典只是录入时的可选项来源。
|
||||||
type prodSeed struct {
|
|
||||||
code, barcode, name, series, spec, unit, brand string
|
|
||||||
catID *uint64
|
|
||||||
purchase, sale float64
|
|
||||||
minStock int
|
|
||||||
remark string
|
|
||||||
}
|
|
||||||
prodSeeds := []prodSeed{
|
|
||||||
{"MT-001", "6901234567890", "飞天茅台 53度", "茅台", "500ml/瓶", "瓶", "贵州茅台", &catBaijiu.ID, 2350, 2800, 10, "酱香型白酒,53度,飞天系列"},
|
|
||||||
{"WLY-001", "6902345678901", "五粮液 52度", "五粮液", "500ml/瓶", "瓶", "宜宾五粮液", &catBaijiu.ID, 950, 1200, 6, "浓香型白酒,52度,普五系列"},
|
|
||||||
{"YH-001", "6903456789012", "洋河梦之蓝 M6", "洋河", "500ml/瓶", "瓶", "江苏洋河", &catBaijiu.ID, 560, 680, 6, "浓香型,绵柔苏酒代表"},
|
|
||||||
{"LZ-001", "6904567890123", "泸州老窖 特曲", "泸州老窖", "500ml/瓶", "瓶", "泸州老窖", &catBaijiu.ID, 420, 520, 6, "浓香鼻祖,特曲系列"},
|
|
||||||
{"JNC-001", "6905678901234", "剑南春 水晶剑", "剑南春", "500ml/瓶", "瓶", "剑南春", &catBaijiu.ID, 390, 480, 6, "浓香型,绵竹名酒"},
|
|
||||||
{"LJ-001", "6906789012345", "郎酒 红花郎10", "郎酒", "500ml/瓶", "瓶", "古蔺郎酒", &catBaijiu.ID, 320, 420, 6, "酱香型,赤水河畔酿造"},
|
|
||||||
{"LF-001", "3760093550058", "拉菲古堡 2018", "波尔多", "750ml/瓶", "瓶", "Château Lafite", &catImport.ID, 3800, 5200, 3, "波尔多一级名庄,2018年份"},
|
|
||||||
{"RTM-001", "3021691010008", "人头马 VSOP", "人头马", "700ml/瓶", "瓶", "Rémy Martin", &catImport.ID, 480, 680, 3, "法国干邑,VSOP级别"},
|
|
||||||
}
|
|
||||||
var prods []model.Product
|
|
||||||
for _, s := range prodSeeds {
|
|
||||||
p := upsert(db, &model.Product{}, "shop_id = ? AND code = ?", shop.ID, s.code, func() any {
|
|
||||||
return &model.Product{
|
|
||||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
|
||||||
Code: s.code, Barcode: s.barcode, Name: s.name,
|
|
||||||
Series: s.series, Spec: s.spec, Unit: s.unit, Brand: s.brand,
|
|
||||||
CategoryID: s.catID, PurchasePrice: s.purchase, SalePrice: s.sale,
|
|
||||||
MinStock: s.minStock, Remark: s.remark,
|
|
||||||
}
|
|
||||||
}).(model.Product)
|
|
||||||
prods = append(prods, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════
|
|
||||||
// 商品名称/系列/规格选项
|
|
||||||
// ═══════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════
|
||||||
type dimSeed struct{ code, name string }
|
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},
|
{"GG001", "500ml/瓶", 1}, {"GG002", "750ml/瓶", 1}, {"GG003", "700ml/瓶", 1},
|
||||||
} {
|
} {
|
||||||
var opt model.ProductSpecOption
|
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, "stock_out", "CK", 3)
|
||||||
upsertNumberRule(db, shop.ID, "inventory_check", "PD", 1)
|
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 已审核:茅台+五粮液,主仓库,茅台供应商
|
// #1 已审核:茅台+五粮液,主仓库,茅台供应商
|
||||||
in1 := createStockInOrder(db, shop.ID, wh1.ID, admin.ID, partners["SUP001"].ID,
|
in1 := sd.createStockIn(wh1.ID, wh1.Name, admin.ID, partners["SUP001"].ID,
|
||||||
"RK20260401001", d(6), "approved", "purchase",
|
"RK20260401001", d(6), "approved", "purchase", "茅台系列首批入库,含飞天茅台及五粮液",
|
||||||
"茅台系列首批入库,含飞天茅台及五粮液",
|
[]inLine{
|
||||||
[]inItemSeed{
|
{0, 120, 2350, "BATCH-MT-2024001", dptr(40), "飞天茅台 2024年第一批次"},
|
||||||
{prods[0].ID, 120, 2350, "BATCH-MT-2024001", "飞天茅台 2024年第一批次"},
|
{1, 60, 950, "BATCH-WLY-2024001", dptr(50), "五粮液普五 2024年批次"},
|
||||||
{prods[1].ID, 60, 950, "BATCH-WLY-2024001", "五粮液普五 2024年批次"},
|
|
||||||
}, admin.ID, ptrT(today.AddDate(0, 0, -5)))
|
}, admin.ID, ptrT(today.AddDate(0, 0, -5)))
|
||||||
|
|
||||||
// #2 已审核:洋河+泸州+剑南春,主仓库,洋河供应商
|
// #2 已审核:洋河+泸州+剑南春,主仓库,洋河供应商
|
||||||
in2 := createStockInOrder(db, shop.ID, wh1.ID, operator.ID, partners["SUP002"].ID,
|
in2 := sd.createStockIn(wh1.ID, wh1.Name, operator.ID, partners["SUP002"].ID,
|
||||||
"RK20260403001", d(4), "approved", "purchase",
|
"RK20260403001", d(4), "approved", "purchase", "浓香型白酒补货入库",
|
||||||
"浓香型白酒补货入库",
|
[]inLine{
|
||||||
[]inItemSeed{
|
{2, 48, 560, "BATCH-YH-2024003", dptr(60), "洋河梦之蓝 M6 3月批次"},
|
||||||
{prods[2].ID, 48, 560, "BATCH-YH-2024003", "洋河梦之蓝 M6 3月批次"},
|
{3, 36, 420, "BATCH-LZ-2024002", dptr(70), "泸州老窖特曲 春季批次"},
|
||||||
{prods[3].ID, 36, 420, "BATCH-LZ-2024002", "泸州老窖特曲 春季批次"},
|
{4, 24, 390, "BATCH-JNC-2024001", dptr(45), "剑南春水晶剑 首批"},
|
||||||
{prods[4].ID, 24, 390, "BATCH-JNC-2024001", "剑南春水晶剑 首批"},
|
|
||||||
}, admin.ID, ptrT(today.AddDate(0, 0, -3)))
|
}, admin.ID, ptrT(today.AddDate(0, 0, -3)))
|
||||||
|
|
||||||
// #3 已审核:进口酒,进口酒专库,茅台供应商(代理进口)
|
// #3 已审核:进口酒,进口酒专库,郎酒供应商(代理进口)
|
||||||
in3 := createStockInOrder(db, shop.ID, wh2.ID, operator.ID, partners["SUP004"].ID,
|
in3 := sd.createStockIn(wh2.ID, wh2.Name, operator.ID, partners["SUP004"].ID,
|
||||||
"RK20260404001", d(3), "approved", "purchase",
|
"RK20260404001", d(3), "approved", "purchase", "进口烈酒专库入库,含拉菲及人头马",
|
||||||
"进口烈酒专库入库,含拉菲及人头马",
|
[]inLine{
|
||||||
[]inItemSeed{
|
{6, 24, 3800, "BATCH-LF-2018001", dptr(400), "拉菲古堡2018,原箱"},
|
||||||
{prods[6].ID, 24, 3800, "BATCH-LF-2018001", "拉菲古堡2018,原箱"},
|
{7, 36, 480, "BATCH-RTM-2024001", dptr(90), "人头马VSOP,2024年进口"},
|
||||||
{prods[7].ID, 36, 480, "BATCH-RTM-2024001", "人头马VSOP,2024年进口"},
|
|
||||||
}, admin.ID, ptrT(today.AddDate(0, 0, -2)))
|
}, admin.ID, ptrT(today.AddDate(0, 0, -2)))
|
||||||
|
|
||||||
// #4 待审核:郎酒补货
|
// #4 待审核:郎酒补货(仅建单+建 product,不生成库存)
|
||||||
in4 := createStockInOrder(db, shop.ID, wh1.ID, operator.ID, partners["SUP004"].ID,
|
in4 := sd.createStockIn(wh1.ID, wh1.Name, operator.ID, partners["SUP004"].ID,
|
||||||
"RK20260406001", d(1), "pending", "purchase",
|
"RK20260406001", d(1), "pending", "purchase", "郎酒红花郎补货,待仓库管理员审核",
|
||||||
"郎酒红花郎补货,待仓库管理员审核",
|
[]inLine{
|
||||||
[]inItemSeed{
|
{5, 60, 320, "BATCH-LJ-2024002", dptr(30), "郎酒红花郎10 第二批次"},
|
||||||
{prods[5].ID, 60, 320, "BATCH-LJ-2024002", "郎酒红花郎10 第二批次"},
|
|
||||||
}, 0, nil)
|
}, 0, nil)
|
||||||
|
|
||||||
// #5 草稿:追加茅台
|
// #5 草稿:追加茅台
|
||||||
in5 := createStockInOrder(db, shop.ID, wh1.ID, operator.ID, partners["SUP001"].ID,
|
sd.createStockIn(wh1.ID, wh1.Name, operator.ID, partners["SUP001"].ID,
|
||||||
"RK20260407001", d(0), "draft", "purchase",
|
"RK20260407001", d(0), "draft", "purchase", "茅台追加订货,草稿中",
|
||||||
"茅台追加订货,草稿中",
|
[]inLine{
|
||||||
[]inItemSeed{
|
{0, 60, 2380, "BATCH-MT-2024002", dptr(20), "飞天茅台 第二批次"},
|
||||||
{prods[0].ID, 60, 2380, "BATCH-MT-2024002", "飞天茅台 第二批次"},
|
|
||||||
}, 0, nil)
|
}, 0, nil)
|
||||||
|
|
||||||
_ = in4
|
// 已审核入库单 → 生成库存(带快照 + 关联 stock_in_item_id)
|
||||||
_ = in5
|
sd.applyStockInToInventory(in1)
|
||||||
|
sd.applyStockInToInventory(in2)
|
||||||
// 根据已审核入库单更新库存
|
sd.applyStockInToInventory(in3)
|
||||||
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)
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════
|
||||||
// 出库单
|
// 出库单(引用入库已建 product,记 sale_price,应收按售价)
|
||||||
// ═══════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════
|
||||||
// #1 已审核:北京君悦大酒店,主仓库
|
// #1 已审核:北京君悦大酒店,主仓库
|
||||||
out1 := createStockOutOrder(db, shop.ID, wh1.ID, operator.ID, partners["CUS001"].ID,
|
out1 := sd.createStockOut(wh1.ID, operator.ID, partners["CUS001"].ID,
|
||||||
"CK20260404001", d(3), "approved", "sale",
|
"CK20260404001", d(3), "approved", "sale", "北京君悦大酒店 4月份定期供货",
|
||||||
"北京君悦大酒店 4月份定期供货",
|
[]outLine{
|
||||||
[]outItemSeed{
|
{in1.products[0], 12, 2800, "飞天茅台 12瓶,整箱出库"},
|
||||||
{prods[0].ID, 12, 2800, "飞天茅台 12瓶,整箱出库"},
|
{in1.products[1], 6, 1200, "五粮液普五 6瓶"},
|
||||||
{prods[1].ID, 6, 1200, "五粮液普五 6瓶"},
|
{in2.products[0], 12, 680, "洋河梦之蓝 M6 12瓶"},
|
||||||
{prods[2].ID, 12, 680, "洋河梦之蓝 M6 12瓶"},
|
|
||||||
}, admin.ID, ptrT(today.AddDate(0, 0, -2)))
|
}, admin.ID, ptrT(today.AddDate(0, 0, -2)))
|
||||||
|
|
||||||
// #2 已审核:上海外滩华尔道夫,进口酒专库
|
// #2 已审核:上海外滩华尔道夫,进口酒专库
|
||||||
out2 := createStockOutOrder(db, shop.ID, wh2.ID, operator.ID, partners["CUS002"].ID,
|
out2 := sd.createStockOut(wh2.ID, operator.ID, partners["CUS002"].ID,
|
||||||
"CK20260405001", d(2), "approved", "sale",
|
"CK20260405001", d(2), "approved", "sale", "上海外滩华尔道夫 进口酒专属采购",
|
||||||
"上海外滩华尔道夫 进口酒专属采购",
|
[]outLine{
|
||||||
[]outItemSeed{
|
{in3.products[0], 6, 5200, "拉菲古堡2018 6瓶"},
|
||||||
{prods[6].ID, 6, 5200, "拉菲古堡2018 6瓶"},
|
{in3.products[1], 12, 680, "人头马VSOP 12瓶"},
|
||||||
{prods[7].ID, 12, 680, "人头马VSOP 12瓶"},
|
|
||||||
}, admin.ID, ptrT(today.AddDate(0, 0, -1)))
|
}, admin.ID, ptrT(today.AddDate(0, 0, -1)))
|
||||||
|
|
||||||
// #3 待审核:广州白天鹅宾馆
|
// #3 待审核:广州白天鹅宾馆
|
||||||
out3 := createStockOutOrder(db, shop.ID, wh1.ID, operator.ID, partners["CUS003"].ID,
|
sd.createStockOut(wh1.ID, operator.ID, partners["CUS003"].ID,
|
||||||
"CK20260406001", d(1), "pending", "sale",
|
"CK20260406001", d(1), "pending", "sale", "广州白天鹅宾馆 月度供货申请",
|
||||||
"广州白天鹅宾馆 月度供货申请",
|
[]outLine{
|
||||||
[]outItemSeed{
|
{in2.products[1], 24, 520, "泸州老窖特曲 24瓶"},
|
||||||
{prods[3].ID, 24, 520, "泸州老窖特曲 24瓶"},
|
{in2.products[2], 12, 480, "剑南春水晶剑 12瓶"},
|
||||||
{prods[4].ID, 12, 480, "剑南春水晶剑 12瓶"},
|
{in4.products[0], 12, 420, "郎酒红花郎10 12瓶"},
|
||||||
{prods[5].ID, 12, 420, "郎酒红花郎10 12瓶"},
|
|
||||||
}, 0, nil)
|
}, 0, nil)
|
||||||
|
|
||||||
// #4 草稿:君悦追加
|
// #4 草稿:君悦追加
|
||||||
out4 := createStockOutOrder(db, shop.ID, wh1.ID, operator.ID, partners["CUS001"].ID,
|
sd.createStockOut(wh1.ID, operator.ID, partners["CUS001"].ID,
|
||||||
"CK20260407001", d(0), "draft", "sale",
|
"CK20260407001", d(0), "draft", "sale", "北京君悦追加订单,草稿中",
|
||||||
"北京君悦追加订单,草稿中",
|
[]outLine{
|
||||||
[]outItemSeed{
|
{in1.products[0], 6, 2800, "飞天茅台 追加 6瓶"},
|
||||||
{prods[0].ID, 6, 2800, "飞天茅台 追加 6瓶"},
|
|
||||||
}, 0, nil)
|
}, 0, nil)
|
||||||
|
|
||||||
_ = out3
|
// 已审核出库单 → 扣减库存(FIFO)
|
||||||
_ = out4
|
sd.deductStockOut(wh1.ID, admin.ID, out1)
|
||||||
|
sd.deductStockOut(wh2.ID, admin.ID, out2)
|
||||||
// 根据已审核出库单减库存
|
|
||||||
deductInventoryBatch(db, shop.ID, wh1.ID, admin.ID, out1)
|
|
||||||
deductInventoryBatch(db, shop.ID, wh2.ID, admin.ID, out2)
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════
|
||||||
// 财务记录(与已审核出库单对应的应收款)
|
// 财务记录(与已审核出库单对应的应收款,按售价口径)
|
||||||
// ═══════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════
|
||||||
createFinanceRecord(db, shop.ID, partners["CUS001"].ID, admin.ID,
|
createFinanceRecord(db, shop.ID, partners["CUS001"].ID, admin.ID,
|
||||||
"receivable", out1.order.TotalAmount, out1.order.TotalAmount, "stock_out", out1.order.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(" operator / password123 (操作员)")
|
||||||
fmt.Println(" test / password123 (只读)")
|
fmt.Println(" test / password123 (只读)")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println(" 数据概览:")
|
fmt.Println(" 数据概览(序列号模型:入库逐行建独立 product):")
|
||||||
fmt.Printf(" 仓库:%s / %s\n", wh1.Name, wh2.Name)
|
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(" 往来单位:4 供应商 + 3 客户")
|
||||||
fmt.Println(" 入库单:5 张(2已审核 / 1待审核 / 1草稿 / 1进口专库已审核)")
|
fmt.Println(" 入库单:5 张(3已审核 / 1待审核 / 1草稿)")
|
||||||
fmt.Println(" 出库单:4 张(2已审核 / 1待审核 / 1草稿)")
|
fmt.Println(" 出库单:4 张(2已审核 / 1待审核 / 1草稿),记 sale_price,应收按售价")
|
||||||
fmt.Println(" 财务记录:2 应收 + 1 收款")
|
fmt.Println(" 财务记录:2 应收 + 1 收款")
|
||||||
fmt.Println("═══════════════════════════════════════════")
|
fmt.Println("═══════════════════════════════════════════")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 辅助类型 ────────────────────────────────────────────
|
// ── 数据模板 ────────────────────────────────────────────
|
||||||
|
|
||||||
type inItemSeed struct {
|
// goods 商品模板:入库逐行据此 new 独立 product,模板本身不落库。
|
||||||
productID uint64
|
type goods struct {
|
||||||
qty float64
|
name, series, spec, unit, brand string
|
||||||
price float64
|
cat *uint64
|
||||||
batchNo string
|
purchase, sale float64
|
||||||
remark string
|
minStock int
|
||||||
}
|
}
|
||||||
|
|
||||||
type outItemSeed struct {
|
// inLine 入库行:g=catalog 下标,qty/price,批次/生产日期/备注。
|
||||||
productID uint64
|
type inLine struct {
|
||||||
qty float64
|
g int
|
||||||
price float64
|
qty, price float64
|
||||||
|
batch string
|
||||||
|
prodDate *model.Date
|
||||||
|
remark string
|
||||||
|
}
|
||||||
|
|
||||||
|
// outLine 出库行:引用入库已建 product,salePrice=售价。
|
||||||
|
type outLine struct {
|
||||||
|
product model.Product
|
||||||
|
qty, sale float64
|
||||||
remark string
|
remark string
|
||||||
}
|
}
|
||||||
|
|
||||||
type stockInResult struct {
|
type stockInResult struct {
|
||||||
order model.StockInOrder
|
order model.StockInOrder
|
||||||
items []inItemSeed
|
items []model.StockInItem
|
||||||
|
products []model.Product
|
||||||
}
|
}
|
||||||
|
|
||||||
type stockOutResult struct {
|
type stockOutResult struct {
|
||||||
order model.StockOutOrder
|
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{})
|
// upsert 通用:查不到就调 factory 建,返回具体值(interface{})
|
||||||
func upsert(db *gorm.DB, dest any, cond string, args ...any) any {
|
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)
|
fmt.Printf("⏭ 分类:%s\n", v.Name)
|
||||||
return *v
|
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:
|
case *model.Partner:
|
||||||
found = db.Where(cond, queryArgs...).First(v).Error == nil
|
found = db.Where(cond, queryArgs...).First(v).Error == nil
|
||||||
if !found {
|
if !found {
|
||||||
@@ -558,179 +802,6 @@ func upsert(db *gorm.DB, dest any, cond string, args ...any) any {
|
|||||||
return nil
|
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,
|
func createFinanceRecord(db *gorm.DB, shopID, partnerID, opID uint64,
|
||||||
|
|||||||
@@ -54,9 +54,13 @@ func (h *InventoryHandler) List(c *gin.Context) {
|
|||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
if pageSize < 1 || pageSize > 500 {
|
if pageSize < 1 {
|
||||||
pageSize = 20
|
pageSize = 20
|
||||||
}
|
}
|
||||||
|
// 超出上限钳到上限(此前是回退默认 20,导致出库选货请求大 page_size 被打回只剩 20 条)
|
||||||
|
if pageSize > 5000 {
|
||||||
|
pageSize = 5000
|
||||||
|
}
|
||||||
|
|
||||||
baseWhere := "inv.shop_id = ? AND inv.deleted_at IS NULL"
|
baseWhere := "inv.shop_id = ? AND inv.deleted_at IS NULL"
|
||||||
args := []interface{}{shopID}
|
args := []interface{}{shopID}
|
||||||
|
|||||||
@@ -110,8 +110,9 @@ func (h *StockOutHandler) Create(c *gin.Context) {
|
|||||||
var total float64
|
var total float64
|
||||||
for i := range req.Items {
|
for i := range req.Items {
|
||||||
req.Items[i].ShopID = shopID
|
req.Items[i].ShopID = shopID
|
||||||
|
// TotalPrice 记成本小计;应收(TotalAmount)按售价×数量
|
||||||
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
|
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
|
req.TotalAmount = total
|
||||||
|
|
||||||
@@ -147,8 +148,9 @@ func (h *StockOutHandler) Update(c *gin.Context) {
|
|||||||
for i := range req.Items {
|
for i := range req.Items {
|
||||||
req.Items[i].ShopID = shopID
|
req.Items[i].ShopID = shopID
|
||||||
req.Items[i].OrderID = order.ID
|
req.Items[i].OrderID = order.ID
|
||||||
|
// TotalPrice 记成本小计;应收(TotalAmount)按售价×数量
|
||||||
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
|
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{}{
|
updates := map[string]interface{}{
|
||||||
"warehouse_id": req.WarehouseID,
|
"warehouse_id": req.WarehouseID,
|
||||||
|
|||||||
@@ -27,13 +27,21 @@ func TestStockReturn_StockOut_OwnAllowed(t *testing.T) {
|
|||||||
// 操作员建出库单(本人) → 提交 → 审核
|
// 操作员建出库单(本人) → 提交 → 审核
|
||||||
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", opToken, map[string]interface{}{
|
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", opToken, map[string]interface{}{
|
||||||
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
|
"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)
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
orderID := extractID(w)
|
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/submit", orderID), opToken, nil)
|
||||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", 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
|
var item model.StockOutItem
|
||||||
require.NoError(t, db.Where("order_id = ?", orderID).First(&item).Error)
|
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)
|
db.First(&order, orderID)
|
||||||
assert.Equal(t, "full", order.ReturnState)
|
assert.Equal(t, "full", order.ReturnState)
|
||||||
|
|
||||||
// 应收冲减:存在一条负向 receivable
|
// 应收冲减:存在一条负向 receivable,金额按售价口径 = -375
|
||||||
var neg int64
|
var negRec model.FinanceRecord
|
||||||
db.Model(&model.FinanceRecord{}).
|
require.NoError(t, db.Where("shop_id = ? AND type = 'receivable' AND amount < 0 AND ref_type = 'stock_out_return'", shop.ID).
|
||||||
Where("shop_id = ? AND type = 'receivable' AND amount < 0 AND ref_type = 'stock_out_return'", shop.ID).
|
First(&negRec).Error)
|
||||||
Count(&neg)
|
assert.Equal(t, float64(-375), negRec.Amount, "退单冲应收应按售价×数量")
|
||||||
assert.Equal(t, int64(1), neg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStockReturn_StockIn_AdminOnly_AndRemovesInventory(t *testing.T) {
|
func TestStockReturn_StockIn_AdminOnly_AndRemovesInventory(t *testing.T) {
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ type StockOutItem struct {
|
|||||||
Spec string `gorm:"size:100" json:"spec"`
|
Spec string `gorm:"size:100" json:"spec"`
|
||||||
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
|
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
|
||||||
UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"`
|
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"`
|
TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"`
|
||||||
// 已退数量:0=未退,=Quantity 表示整行已退单
|
// 已退数量:0=未退,=Quantity 表示整行已退单
|
||||||
ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"`
|
ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"`
|
||||||
|
|||||||
@@ -416,7 +416,8 @@ func (s *StockService) ReturnStockOut(shopID, orderID, userID uint64, role strin
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
it.ReturnedQuantity = it.Quantity
|
it.ReturnedQuantity = it.Quantity
|
||||||
returnedAmount += it.TotalPrice
|
// 冲应收按售价口径(与审核建应收 TotalAmount=Σ售价×数量 一致)
|
||||||
|
returnedAmount += it.Quantity * it.SalePrice
|
||||||
}
|
}
|
||||||
|
|
||||||
// 冲减应收(负向调整记录)
|
// 冲减应收(负向调整记录)
|
||||||
|
|||||||
@@ -360,8 +360,9 @@ CREATE TABLE IF NOT EXISTS `stock_out_items` (
|
|||||||
`series` VARCHAR(100) DEFAULT NULL COMMENT '系列(历史导入快照)',
|
`series` VARCHAR(100) DEFAULT NULL COMMENT '系列(历史导入快照)',
|
||||||
`spec` VARCHAR(100) DEFAULT NULL COMMENT '规格(历史导入快照)',
|
`spec` VARCHAR(100) DEFAULT NULL COMMENT '规格(历史导入快照)',
|
||||||
`quantity` DECIMAL(12,3) NOT NULL,
|
`quantity` DECIMAL(12,3) NOT NULL,
|
||||||
`unit_price` DECIMAL(16,2) NOT NULL DEFAULT 0,
|
`unit_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '成本单价(入库成本快照)',
|
||||||
`total_price` DECIMAL(16,2) NOT NULL DEFAULT 0,
|
`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 '批次号',
|
`batch_no` VARCHAR(50) DEFAULT NULL COMMENT '批次号',
|
||||||
`production_date` DATE DEFAULT NULL COMMENT '生产日期',
|
`production_date` DATE DEFAULT NULL COMMENT '生产日期',
|
||||||
`custom_fields` JSON DEFAULT NULL,
|
`custom_fields` JSON DEFAULT NULL,
|
||||||
|
|||||||
@@ -389,6 +389,7 @@ func SetupTestDB() *gorm.DB {
|
|||||||
spec TEXT,
|
spec TEXT,
|
||||||
quantity REAL NOT NULL,
|
quantity REAL NOT NULL,
|
||||||
unit_price REAL DEFAULT 0,
|
unit_price REAL DEFAULT 0,
|
||||||
|
sale_price REAL DEFAULT 0,
|
||||||
total_price REAL DEFAULT 0,
|
total_price REAL DEFAULT 0,
|
||||||
returned_quantity REAL DEFAULT 0,
|
returned_quantity REAL DEFAULT 0,
|
||||||
batch_no TEXT,
|
batch_no TEXT,
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
class StockInItem {
|
class StockInItem {
|
||||||
|
final int? id;
|
||||||
final int? orderId;
|
final int? orderId;
|
||||||
final int productId;
|
final int productId;
|
||||||
final double quantity;
|
final double quantity;
|
||||||
final double unitPrice;
|
final double unitPrice;
|
||||||
final double totalPrice;
|
final double totalPrice;
|
||||||
|
final double returnedQuantity; // 0=未退;>=quantity 表示整行已退单
|
||||||
final String? batchNo;
|
final String? batchNo;
|
||||||
final String? productionDate;
|
final String? productionDate;
|
||||||
|
|
||||||
|
/// 该明细是否已退单(整行已退)。
|
||||||
|
bool get isReturned => returnedQuantity >= quantity && quantity > 0;
|
||||||
// Denormalized for display
|
// Denormalized for display
|
||||||
final String? productName;
|
final String? productName;
|
||||||
final String? productCode;
|
final String? productCode;
|
||||||
@@ -17,11 +22,13 @@ class StockInItem {
|
|||||||
final String? productStorage;
|
final String? productStorage;
|
||||||
|
|
||||||
const StockInItem({
|
const StockInItem({
|
||||||
|
this.id,
|
||||||
this.orderId,
|
this.orderId,
|
||||||
required this.productId,
|
required this.productId,
|
||||||
required this.quantity,
|
required this.quantity,
|
||||||
required this.unitPrice,
|
required this.unitPrice,
|
||||||
required this.totalPrice,
|
required this.totalPrice,
|
||||||
|
this.returnedQuantity = 0,
|
||||||
this.batchNo,
|
this.batchNo,
|
||||||
this.productionDate,
|
this.productionDate,
|
||||||
this.productName,
|
this.productName,
|
||||||
@@ -45,6 +52,7 @@ class StockInItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return StockInItem(
|
return StockInItem(
|
||||||
|
id: json['id'] != null ? (json['id'] as num).toInt() : null,
|
||||||
orderId: json['order_id'] != null
|
orderId: json['order_id'] != null
|
||||||
? (json['order_id'] as num).toInt()
|
? (json['order_id'] as num).toInt()
|
||||||
: null,
|
: null,
|
||||||
@@ -52,6 +60,7 @@ class StockInItem {
|
|||||||
quantity: (json['quantity'] as num).toDouble(),
|
quantity: (json['quantity'] as num).toDouble(),
|
||||||
unitPrice: (json['unit_price'] as num).toDouble(),
|
unitPrice: (json['unit_price'] as num).toDouble(),
|
||||||
totalPrice: (json['total_price'] as num).toDouble(),
|
totalPrice: (json['total_price'] as num).toDouble(),
|
||||||
|
returnedQuantity: (json['returned_quantity'] as num?)?.toDouble() ?? 0,
|
||||||
batchNo: json['batch_no'] as String?,
|
batchNo: json['batch_no'] as String?,
|
||||||
productionDate: json['production_date'] as String?,
|
productionDate: json['production_date'] as String?,
|
||||||
productName: lineOrProduct('product_name', 'name'),
|
productName: lineOrProduct('product_name', 'name'),
|
||||||
@@ -91,6 +100,7 @@ class StockInOrder {
|
|||||||
final int? reviewerId;
|
final int? reviewerId;
|
||||||
final String? reviewerName;
|
final String? reviewerName;
|
||||||
final String status; // draft | pending | approved | rejected
|
final String status; // draft | pending | approved | rejected
|
||||||
|
final String returnState; // none | partial | full(退单状态)
|
||||||
final String? orderDate;
|
final String? orderDate;
|
||||||
final String? reviewedAt;
|
final String? reviewedAt;
|
||||||
final double? totalAmount;
|
final double? totalAmount;
|
||||||
@@ -110,6 +120,7 @@ class StockInOrder {
|
|||||||
this.reviewerId,
|
this.reviewerId,
|
||||||
this.reviewerName,
|
this.reviewerName,
|
||||||
required this.status,
|
required this.status,
|
||||||
|
this.returnState = 'none',
|
||||||
this.orderDate,
|
this.orderDate,
|
||||||
this.reviewedAt,
|
this.reviewedAt,
|
||||||
this.totalAmount,
|
this.totalAmount,
|
||||||
@@ -136,6 +147,7 @@ class StockInOrder {
|
|||||||
: null,
|
: null,
|
||||||
reviewerName: (json['reviewer'] as Map<String, dynamic>?)?['real_name'] as String?,
|
reviewerName: (json['reviewer'] as Map<String, dynamic>?)?['real_name'] as String?,
|
||||||
status: json['status'] as String,
|
status: json['status'] as String,
|
||||||
|
returnState: json['return_state'] as String? ?? 'none',
|
||||||
orderDate: json['order_date'] as String?,
|
orderDate: json['order_date'] as String?,
|
||||||
reviewedAt: json['reviewed_at'] as String?,
|
reviewedAt: json['reviewed_at'] as String?,
|
||||||
totalAmount: json['total_amount'] != null
|
totalAmount: json['total_amount'] != null
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
class StockOutItem {
|
class StockOutItem {
|
||||||
|
final int? id;
|
||||||
final int? orderId;
|
final int? orderId;
|
||||||
final int productId;
|
final int productId;
|
||||||
final double quantity;
|
final double quantity;
|
||||||
final double unitPrice;
|
final double unitPrice;
|
||||||
|
final double salePrice;
|
||||||
final double totalPrice;
|
final double totalPrice;
|
||||||
|
final double returnedQuantity;
|
||||||
final String? productName;
|
final String? productName;
|
||||||
final String? productCode;
|
final String? productCode;
|
||||||
final String? productSeries;
|
final String? productSeries;
|
||||||
final String? productSpec;
|
final String? productSpec;
|
||||||
final String? productUnit;
|
final String? productUnit;
|
||||||
|
|
||||||
|
bool get isReturned => returnedQuantity >= quantity && quantity > 0;
|
||||||
|
|
||||||
const StockOutItem({
|
const StockOutItem({
|
||||||
|
this.id,
|
||||||
this.orderId,
|
this.orderId,
|
||||||
required this.productId,
|
required this.productId,
|
||||||
required this.quantity,
|
required this.quantity,
|
||||||
required this.unitPrice,
|
required this.unitPrice,
|
||||||
|
this.salePrice = 0,
|
||||||
required this.totalPrice,
|
required this.totalPrice,
|
||||||
|
this.returnedQuantity = 0,
|
||||||
this.productName,
|
this.productName,
|
||||||
this.productCode,
|
this.productCode,
|
||||||
this.productSeries,
|
this.productSeries,
|
||||||
@@ -34,13 +42,16 @@ class StockOutItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return StockOutItem(
|
return StockOutItem(
|
||||||
|
id: json['id'] != null ? (json['id'] as num).toInt() : null,
|
||||||
orderId: json['order_id'] != null
|
orderId: json['order_id'] != null
|
||||||
? (json['order_id'] as num).toInt()
|
? (json['order_id'] as num).toInt()
|
||||||
: null,
|
: null,
|
||||||
productId: (json['product_id'] as num).toInt(),
|
productId: (json['product_id'] as num).toInt(),
|
||||||
quantity: (json['quantity'] as num).toDouble(),
|
quantity: (json['quantity'] as num).toDouble(),
|
||||||
unitPrice: (json['unit_price'] 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(),
|
totalPrice: (json['total_price'] as num).toDouble(),
|
||||||
|
returnedQuantity: (json['returned_quantity'] as num?)?.toDouble() ?? 0,
|
||||||
productName: lineOrProduct('product_name', 'name'),
|
productName: lineOrProduct('product_name', 'name'),
|
||||||
productCode: lineOrProduct('product_code', 'code'),
|
productCode: lineOrProduct('product_code', 'code'),
|
||||||
productSeries: lineOrProduct('series', 'series'),
|
productSeries: lineOrProduct('series', 'series'),
|
||||||
@@ -63,6 +74,7 @@ class StockOutOrder {
|
|||||||
final int? reviewerId;
|
final int? reviewerId;
|
||||||
final String? reviewerName;
|
final String? reviewerName;
|
||||||
final String status; // draft | pending | approved | rejected
|
final String status; // draft | pending | approved | rejected
|
||||||
|
final String returnState; // none | partial | full
|
||||||
final String? orderDate;
|
final String? orderDate;
|
||||||
final String? reviewedAt;
|
final String? reviewedAt;
|
||||||
final String? createdAt;
|
final String? createdAt;
|
||||||
@@ -83,6 +95,7 @@ class StockOutOrder {
|
|||||||
this.reviewerId,
|
this.reviewerId,
|
||||||
this.reviewerName,
|
this.reviewerName,
|
||||||
required this.status,
|
required this.status,
|
||||||
|
this.returnState = 'none',
|
||||||
this.orderDate,
|
this.orderDate,
|
||||||
this.reviewedAt,
|
this.reviewedAt,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
@@ -110,6 +123,7 @@ class StockOutOrder {
|
|||||||
: null,
|
: null,
|
||||||
reviewerName: (json['reviewer'] as Map<String, dynamic>?)?['real_name'] as String?,
|
reviewerName: (json['reviewer'] as Map<String, dynamic>?)?['real_name'] as String?,
|
||||||
status: json['status'] as String,
|
status: json['status'] as String,
|
||||||
|
returnState: json['return_state'] as String? ?? 'none',
|
||||||
orderDate: json['order_date'] as String?,
|
orderDate: json['order_date'] as String?,
|
||||||
reviewedAt: json['reviewed_at'] as String?,
|
reviewedAt: json['reviewed_at'] as String?,
|
||||||
createdAt: json['created_at'] as String?,
|
createdAt: json['created_at'] as String?,
|
||||||
|
|||||||
@@ -128,4 +128,10 @@ class StockInListNotifier extends AsyncNotifier<PageResult<StockInOrder>> {
|
|||||||
await ref.read(stockInRepositoryProvider).withdraw(id);
|
await ref.read(stockInRepositoryProvider).withdraw(id);
|
||||||
reload();
|
reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> returnItems(int id, List<int> itemIds) async {
|
||||||
|
await ref.read(stockInRepositoryProvider).returnItems(id, itemIds);
|
||||||
|
reload();
|
||||||
|
ref.invalidate(inventoryListProvider);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,4 +128,10 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
|
|||||||
await ref.read(stockOutRepositoryProvider).withdraw(id);
|
await ref.read(stockOutRepositoryProvider).withdraw(id);
|
||||||
reload();
|
reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> returnItems(int id, List<int> itemIds) async {
|
||||||
|
await ref.read(stockOutRepositoryProvider).returnItems(id, itemIds);
|
||||||
|
reload();
|
||||||
|
ref.invalidate(inventoryListProvider);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,4 +130,16 @@ class StockInRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 退单:退掉指定明细(item ids)。入库退单对应库存从库存删除。
|
||||||
|
Future<void> returnItems(int id, List<int> itemIds) async {
|
||||||
|
try {
|
||||||
|
await _client.post('/stock-in/orders/$id/return', data: {'item_ids': itemIds});
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw AppException(
|
||||||
|
e.response?.data?['error'] as String? ?? '退单失败',
|
||||||
|
statusCode: e.response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,4 +130,16 @@ class StockOutRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 退单:退掉指定明细(item ids)。出库退单数量加回库存。
|
||||||
|
Future<void> returnItems(int id, List<int> itemIds) async {
|
||||||
|
try {
|
||||||
|
await _client.post('/stock-out/orders/$id/return', data: {'item_ids': itemIds});
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw AppException(
|
||||||
|
e.response?.data?['error'] as String? ?? '退单失败',
|
||||||
|
statusCode: e.response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
ColDef('series', '系列'),
|
ColDef('series', '系列'),
|
||||||
ColDef('batch', '批次号'),
|
ColDef('batch', '批次号'),
|
||||||
ColDef('warehouse', '仓库'),
|
ColDef('warehouse', '仓库'),
|
||||||
ColDef('qty', '库存量'),
|
// 库存量列已移除:序列号模型下每件独立,原数量列含历史装箱数脏数据易误导
|
||||||
ColDef('price', '单价'),
|
ColDef('price', '单价'),
|
||||||
ColDef('prodDate', '生产日期'),
|
ColDef('prodDate', '生产日期'),
|
||||||
ColDef('inTime', '入库时间'),
|
ColDef('inTime', '入库时间'),
|
||||||
@@ -311,15 +311,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
'batch' => DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo,
|
'batch' => DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo,
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
|
||||||
'warehouse' => DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)),
|
'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(
|
'price' => DataCell(Text(
|
||||||
item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '-')),
|
item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '-')),
|
||||||
'prodDate' => DataCell(Text(item.productionDate ?? '-')),
|
'prodDate' => DataCell(Text(item.productionDate ?? '-')),
|
||||||
@@ -367,19 +358,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
if (item.series.isNotEmpty) MobileCardField('系列', item.series),
|
if (item.series.isNotEmpty) MobileCardField('系列', item.series),
|
||||||
if (item.batchNo.isNotEmpty) MobileCardField('批次', item.batchNo),
|
if (item.batchNo.isNotEmpty) MobileCardField('批次', item.batchNo),
|
||||||
MobileCardField('仓库', item.warehouseName.isEmpty ? '-' : item.warehouseName),
|
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)
|
if (item.unitPrice != null)
|
||||||
MobileCardField('单价', '¥${item.unitPrice!.toStringAsFixed(2)}'),
|
MobileCardField('单价', '¥${item.unitPrice!.toStringAsFixed(2)}'),
|
||||||
if (item.supplierName.isNotEmpty)
|
if (item.supplierName.isNotEmpty)
|
||||||
@@ -763,7 +741,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
};
|
};
|
||||||
return DataColumn(
|
return DataColumn(
|
||||||
label: label,
|
label: label,
|
||||||
numeric: c.key == 'qty' || c.key == 'price',
|
numeric: c.key == 'price',
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
rows: items.isEmpty
|
rows: items.isEmpty
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
|||||||
import '../../providers/shop_provider.dart' show shopInfoProvider;
|
import '../../providers/shop_provider.dart' show shopInfoProvider;
|
||||||
import '../../widgets/write_guard.dart';
|
import '../../widgets/write_guard.dart';
|
||||||
import '../../widgets/order_row_actions.dart';
|
import '../../widgets/order_row_actions.dart';
|
||||||
|
import '../../widgets/order_return_dialog.dart';
|
||||||
import '../../core/auth/auth_state.dart'
|
import '../../core/auth/auth_state.dart'
|
||||||
show isAdminProvider, currentUserIdProvider;
|
show isAdminProvider, currentUserIdProvider;
|
||||||
|
|
||||||
@@ -268,7 +269,11 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
|||||||
? '¥${o.totalAmount!.toStringAsFixed(2)}'
|
? '¥${o.totalAmount!.toStringAsFixed(2)}'
|
||||||
: '-'));
|
: '-'));
|
||||||
case 'status':
|
case 'status':
|
||||||
return DataCell(StatusBadge(_apiStatusToEnum(o.status)));
|
final rb = returnStateBadge(o.returnState);
|
||||||
|
return DataCell(Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
StatusBadge(_apiStatusToEnum(o.status)),
|
||||||
|
if (rb != null) ...[const SizedBox(width: 4), rb],
|
||||||
|
]));
|
||||||
case 'date':
|
case 'date':
|
||||||
return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-'));
|
return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-'));
|
||||||
case 'operator':
|
case 'operator':
|
||||||
@@ -531,6 +536,9 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
|||||||
onApprove: () => _confirmApprove(context, o),
|
onApprove: () => _confirmApprove(context, o),
|
||||||
onReject: () => _confirmReject(context, o),
|
onReject: () => _confirmReject(context, o),
|
||||||
onWithdraw: () => _confirmWithdraw(context, o),
|
onWithdraw: () => _confirmWithdraw(context, o),
|
||||||
|
// 入库退单:仅管理员/超管
|
||||||
|
canReturn: ref.watch(isAdminProvider),
|
||||||
|
onReturn: () => _confirmReturn(context, o),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,7 +548,13 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
|||||||
onTap: () => _showDetail(context, o.id),
|
onTap: () => _showDetail(context, o.id),
|
||||||
title: Text(o.orderNo,
|
title: Text(o.orderNo,
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 14)),
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 14)),
|
||||||
trailing: StatusBadge(_apiStatusToEnum(o.status)),
|
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
StatusBadge(_apiStatusToEnum(o.status)),
|
||||||
|
if (returnStateBadge(o.returnState) != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
returnStateBadge(o.returnState)!,
|
||||||
|
],
|
||||||
|
]),
|
||||||
fields: [
|
fields: [
|
||||||
MobileCardField('供应商', o.partnerName ?? '-'),
|
MobileCardField('供应商', o.partnerName ?? '-'),
|
||||||
MobileCardField('仓库', o.warehouseName ?? '-'),
|
MobileCardField('仓库', o.warehouseName ?? '-'),
|
||||||
@@ -797,6 +811,68 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmReturn(BuildContext context, StockInOrder o) async {
|
||||||
|
final go = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('退单'),
|
||||||
|
content: Text('确认对已审核入库单「${o.orderNo}」发起退单?将打开退单明细窗口选择要退的商品。'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('取消')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.danger, foregroundColor: Colors.white),
|
||||||
|
child: const Text('确认'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (go != true || !mounted) return;
|
||||||
|
final StockInOrder full;
|
||||||
|
try {
|
||||||
|
full = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('加载明细失败:$e'), backgroundColor: AppTheme.danger));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
final lines = full.items
|
||||||
|
.where((it) => it.id != null)
|
||||||
|
.map((it) => ReturnLine(
|
||||||
|
itemId: it.id!,
|
||||||
|
code: it.productCode ?? '',
|
||||||
|
name: it.productName ?? '',
|
||||||
|
series: it.productSeries ?? '',
|
||||||
|
spec: it.productSpec ?? '',
|
||||||
|
quantity: it.quantity,
|
||||||
|
unitPrice: it.unitPrice,
|
||||||
|
totalPrice: it.totalPrice,
|
||||||
|
alreadyReturned: it.isReturned,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
await showOrderReturnDialog(
|
||||||
|
context: context,
|
||||||
|
title: '入库单退单 — ${o.orderNo}',
|
||||||
|
isOut: false,
|
||||||
|
meta: [
|
||||||
|
('供应商', o.partnerName ?? '-'),
|
||||||
|
('仓库', o.warehouseName ?? '-'),
|
||||||
|
('入库日期', o.orderDate != null && o.orderDate!.length >= 10
|
||||||
|
? o.orderDate!.substring(0, 10)
|
||||||
|
: (o.orderDate ?? '-')),
|
||||||
|
],
|
||||||
|
lines: lines,
|
||||||
|
onSubmit: (ids) =>
|
||||||
|
ref.read(stockInListProvider.notifier).returnItems(o.id, ids),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detail dialog — fetches full order with items
|
// Detail dialog — fetches full order with items
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class _ItemRow {
|
|||||||
final String spec;
|
final String spec;
|
||||||
final double? unitPrice;
|
final double? unitPrice;
|
||||||
final TextEditingController qtyCtrl;
|
final TextEditingController qtyCtrl;
|
||||||
|
final TextEditingController salePriceCtrl;
|
||||||
|
|
||||||
_ItemRow({
|
_ItemRow({
|
||||||
this.productId,
|
this.productId,
|
||||||
@@ -91,9 +92,19 @@ class _ItemRow {
|
|||||||
this.series = '',
|
this.series = '',
|
||||||
this.spec = '',
|
this.spec = '',
|
||||||
this.unitPrice,
|
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 {
|
class StockOutFormScreen extends ConsumerStatefulWidget {
|
||||||
@@ -152,6 +163,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
|||||||
series: item.productSeries ?? '',
|
series: item.productSeries ?? '',
|
||||||
spec: item.productSpec ?? '',
|
spec: item.productSpec ?? '',
|
||||||
unitPrice: item.unitPrice,
|
unitPrice: item.unitPrice,
|
||||||
|
salePrice: item.salePrice,
|
||||||
);
|
);
|
||||||
row.qtyCtrl.text = item.quantity.toStringAsFixed(0);
|
row.qtyCtrl.text = item.quantity.toStringAsFixed(0);
|
||||||
_items.add(row);
|
_items.add(row);
|
||||||
@@ -178,11 +190,13 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 出库单总金额 = 应收 = Σ(售价×数量)
|
||||||
double get _totalAmount {
|
double get _totalAmount {
|
||||||
double total = 0;
|
double total = 0;
|
||||||
for (final item in _items) {
|
for (final item in _items) {
|
||||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
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;
|
return total;
|
||||||
}
|
}
|
||||||
@@ -280,10 +294,12 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
|||||||
final itemsData = _items.map((item) {
|
final itemsData = _items.map((item) {
|
||||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||||
final price = item.unitPrice ?? 0;
|
final price = item.unitPrice ?? 0;
|
||||||
|
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
|
||||||
return {
|
return {
|
||||||
'product_id': item.productId ?? 0,
|
'product_id': item.productId ?? 0,
|
||||||
'quantity': qty,
|
'quantity': qty,
|
||||||
'unit_price': price,
|
'unit_price': price,
|
||||||
|
'sale_price': sale,
|
||||||
'total_price': qty * price,
|
'total_price': qty * price,
|
||||||
};
|
};
|
||||||
}).toList();
|
}).toList();
|
||||||
@@ -647,10 +663,11 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
|||||||
2: FlexColumnWidth(2.0), // 商品名称
|
2: FlexColumnWidth(2.0), // 商品名称
|
||||||
3: FlexColumnWidth(1.2), // 系列
|
3: FlexColumnWidth(1.2), // 系列
|
||||||
4: FlexColumnWidth(1.2), // 规格
|
4: FlexColumnWidth(1.2), // 规格
|
||||||
5: FlexColumnWidth(1.0), // 单价
|
5: FlexColumnWidth(1.0), // 成本价
|
||||||
6: FlexColumnWidth(0.8), // 数量
|
6: FlexColumnWidth(1.0), // 售价
|
||||||
7: FlexColumnWidth(1.0), // 金额
|
7: FlexColumnWidth(0.8), // 数量
|
||||||
8: FixedColumnWidth(48), // 操作
|
8: FlexColumnWidth(1.0), // 金额
|
||||||
|
9: FixedColumnWidth(48), // 操作
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
TableRow(
|
TableRow(
|
||||||
@@ -662,7 +679,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
|||||||
'商品名称',
|
'商品名称',
|
||||||
'系列',
|
'系列',
|
||||||
'规格',
|
'规格',
|
||||||
'单价',
|
'成本价',
|
||||||
|
'售价',
|
||||||
'数量',
|
'数量',
|
||||||
'金额',
|
'金额',
|
||||||
'操作',
|
'操作',
|
||||||
@@ -734,7 +752,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
|||||||
final item = _items[index];
|
final item = _items[index];
|
||||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||||
final price = item.unitPrice ?? 0;
|
final price = item.unitPrice ?? 0;
|
||||||
final amount = qty * price;
|
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
|
||||||
|
final amount = qty * sale;
|
||||||
|
|
||||||
return TableRow(
|
return TableRow(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -761,12 +780,16 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
|||||||
_cell(Text(item.spec,
|
_cell(Text(item.spec,
|
||||||
style:
|
style:
|
||||||
const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
|
const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
|
||||||
// 单价
|
// 成本价
|
||||||
_cell(Text(price > 0 ? '¥${price.toStringAsFixed(2)}' : '-',
|
_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)),
|
Padding(padding: const EdgeInsets.all(4), child: _qtyField(item)),
|
||||||
// 金额
|
// 金额(=售价×数量)
|
||||||
_cell(Text('¥${amount.toStringAsFixed(2)}',
|
_cell(Text('¥${amount.toStringAsFixed(2)}',
|
||||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))),
|
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) {
|
Widget _buildItemCard(int index) {
|
||||||
final item = _items[index];
|
final item = _items[index];
|
||||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||||
final price = item.unitPrice ?? 0;
|
final price = item.unitPrice ?? 0;
|
||||||
final amount = qty * price;
|
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
|
||||||
|
final amount = qty * sale;
|
||||||
|
|
||||||
return MobileListCard(
|
return MobileListCard(
|
||||||
title: Text(item.productName),
|
title: Text(item.productName),
|
||||||
@@ -824,7 +862,9 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
|||||||
fields: [
|
fields: [
|
||||||
if (item.series.isNotEmpty) MobileCardField('系列', item.series),
|
if (item.series.isNotEmpty) MobileCardField('系列', item.series),
|
||||||
if (item.spec.isNotEmpty) MobileCardField('规格', item.spec),
|
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('数量', null, valueWidget: _qtyField(item)),
|
||||||
MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'),
|
MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import '../../providers/product_provider.dart';
|
|||||||
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
||||||
import '../../widgets/write_guard.dart';
|
import '../../widgets/write_guard.dart';
|
||||||
import '../../widgets/order_row_actions.dart';
|
import '../../widgets/order_row_actions.dart';
|
||||||
|
import '../../widgets/order_return_dialog.dart';
|
||||||
import '../../core/auth/auth_state.dart'
|
import '../../core/auth/auth_state.dart'
|
||||||
show isAdminProvider, currentUserIdProvider;
|
show isAdminProvider, currentUserIdProvider;
|
||||||
|
|
||||||
@@ -269,7 +270,11 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
? '¥${o.totalAmount!.toStringAsFixed(2)}'
|
? '¥${o.totalAmount!.toStringAsFixed(2)}'
|
||||||
: '-'));
|
: '-'));
|
||||||
case 'status':
|
case 'status':
|
||||||
return DataCell(StatusBadge(_apiStatusToEnum(o.status)));
|
final rb = returnStateBadge(o.returnState);
|
||||||
|
return DataCell(Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
StatusBadge(_apiStatusToEnum(o.status)),
|
||||||
|
if (rb != null) ...[const SizedBox(width: 4), rb],
|
||||||
|
]));
|
||||||
case 'date':
|
case 'date':
|
||||||
return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-'));
|
return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-'));
|
||||||
case 'created_at':
|
case 'created_at':
|
||||||
@@ -505,6 +510,11 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
onApprove: () => _confirmApprove(context, o),
|
onApprove: () => _confirmApprove(context, o),
|
||||||
onReject: () => _confirmReject(context, o),
|
onReject: () => _confirmReject(context, o),
|
||||||
onWithdraw: () => _confirmWithdraw(context, o),
|
onWithdraw: () => _confirmWithdraw(context, o),
|
||||||
|
// 出库退单:管理员/超管 或 本人单
|
||||||
|
canReturn: ref.watch(isAdminProvider) ||
|
||||||
|
(o.operatorId != null &&
|
||||||
|
o.operatorId == ref.watch(currentUserIdProvider)),
|
||||||
|
onReturn: () => _confirmReturn(context, o),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,7 +524,13 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
onTap: () => _showDetail(context, o.id),
|
onTap: () => _showDetail(context, o.id),
|
||||||
title: Text(o.orderNo,
|
title: Text(o.orderNo,
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 14)),
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 14)),
|
||||||
trailing: StatusBadge(_apiStatusToEnum(o.status)),
|
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
StatusBadge(_apiStatusToEnum(o.status)),
|
||||||
|
if (returnStateBadge(o.returnState) != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
returnStateBadge(o.returnState)!,
|
||||||
|
],
|
||||||
|
]),
|
||||||
fields: [
|
fields: [
|
||||||
MobileCardField('客户', o.partnerName ?? '-'),
|
MobileCardField('客户', o.partnerName ?? '-'),
|
||||||
MobileCardField('仓库', o.warehouseName ?? '-'),
|
MobileCardField('仓库', o.warehouseName ?? '-'),
|
||||||
@@ -776,6 +792,68 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmReturn(BuildContext context, StockOutOrder o) async {
|
||||||
|
final go = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('退单'),
|
||||||
|
content: Text('确认对已审核出库单「${o.orderNo}」发起退单?将打开退单明细窗口选择要退的商品。'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('取消')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.danger, foregroundColor: Colors.white),
|
||||||
|
child: const Text('确认'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (go != true || !mounted) return;
|
||||||
|
final StockOutOrder full;
|
||||||
|
try {
|
||||||
|
full = await ref.read(stockOutRepositoryProvider).get(o.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('加载明细失败:$e'), backgroundColor: AppTheme.danger));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
final lines = full.items
|
||||||
|
.where((it) => it.id != null)
|
||||||
|
.map((it) => ReturnLine(
|
||||||
|
itemId: it.id!,
|
||||||
|
code: it.productCode ?? '',
|
||||||
|
name: it.productName ?? '',
|
||||||
|
series: it.productSeries ?? '',
|
||||||
|
spec: it.productSpec ?? '',
|
||||||
|
quantity: it.quantity,
|
||||||
|
unitPrice: it.unitPrice,
|
||||||
|
totalPrice: it.totalPrice,
|
||||||
|
alreadyReturned: it.isReturned,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
await showOrderReturnDialog(
|
||||||
|
context: context,
|
||||||
|
title: '出库单退单 — ${o.orderNo}',
|
||||||
|
isOut: true,
|
||||||
|
meta: [
|
||||||
|
('客户', o.partnerName ?? '-'),
|
||||||
|
('仓库', o.warehouseName ?? '-'),
|
||||||
|
('出库日期', o.orderDate != null && o.orderDate!.length >= 10
|
||||||
|
? o.orderDate!.substring(0, 10)
|
||||||
|
: (o.orderDate ?? '-')),
|
||||||
|
],
|
||||||
|
lines: lines,
|
||||||
|
onSubmit: (ids) =>
|
||||||
|
ref.read(stockOutListProvider.notifier).returnItems(o.id, ids),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detail dialog — fetches full order with items
|
// Detail dialog — fetches full order with items
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../core/responsive/responsive.dart';
|
||||||
|
import '../core/theme/app_theme.dart';
|
||||||
|
import '../core/utils/dialog_util.dart';
|
||||||
|
|
||||||
|
/// 退单状态小徽章(none 返回 null 不显示)。partial=部分退单(amber),full=已退单(红)。
|
||||||
|
Widget? returnStateBadge(String state) {
|
||||||
|
Color? c;
|
||||||
|
String? t;
|
||||||
|
if (state == 'partial') {
|
||||||
|
c = AppTheme.warning;
|
||||||
|
t = '部分退单';
|
||||||
|
} else if (state == 'full') {
|
||||||
|
c = AppTheme.danger;
|
||||||
|
t = '已退单';
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: c.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8)),
|
||||||
|
child: Text(t,
|
||||||
|
style: TextStyle(fontSize: 10, color: c, fontWeight: FontWeight.w600)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 退单弹窗的一条明细。
|
||||||
|
class ReturnLine {
|
||||||
|
final int itemId;
|
||||||
|
final String code;
|
||||||
|
final String name;
|
||||||
|
final String series;
|
||||||
|
final String spec;
|
||||||
|
final double quantity;
|
||||||
|
final double unitPrice;
|
||||||
|
final double totalPrice;
|
||||||
|
final bool alreadyReturned; // 之前已退过(不可再选)
|
||||||
|
|
||||||
|
const ReturnLine({
|
||||||
|
required this.itemId,
|
||||||
|
required this.code,
|
||||||
|
required this.name,
|
||||||
|
required this.series,
|
||||||
|
required this.spec,
|
||||||
|
required this.quantity,
|
||||||
|
required this.unitPrice,
|
||||||
|
required this.totalPrice,
|
||||||
|
this.alreadyReturned = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开退单弹窗。提交成功返回 true(调用方据此刷新列表),取消返回 null。
|
||||||
|
/// - [isOut] true=出库(退回库存)/ false=入库(从库存删除),影响文案。
|
||||||
|
/// - [onSubmit] 收到选中的 itemId 列表,向后端提交;抛异常表示失败。
|
||||||
|
Future<bool?> showOrderReturnDialog({
|
||||||
|
required BuildContext context,
|
||||||
|
required String title,
|
||||||
|
required List<(String, String)> meta,
|
||||||
|
required bool isOut,
|
||||||
|
required List<ReturnLine> lines,
|
||||||
|
required Future<void> Function(List<int> itemIds) onSubmit,
|
||||||
|
}) {
|
||||||
|
return showAppDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _OrderReturnDialog(
|
||||||
|
title: title,
|
||||||
|
meta: meta,
|
||||||
|
isOut: isOut,
|
||||||
|
lines: lines,
|
||||||
|
onSubmit: onSubmit,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OrderReturnDialog extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final List<(String, String)> meta;
|
||||||
|
final bool isOut;
|
||||||
|
final List<ReturnLine> lines;
|
||||||
|
final Future<void> Function(List<int> itemIds) onSubmit;
|
||||||
|
|
||||||
|
const _OrderReturnDialog({
|
||||||
|
required this.title,
|
||||||
|
required this.meta,
|
||||||
|
required this.isOut,
|
||||||
|
required this.lines,
|
||||||
|
required this.onSubmit,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_OrderReturnDialog> createState() => _OrderReturnDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OrderReturnDialogState extends State<_OrderReturnDialog> {
|
||||||
|
final Set<int> _staged = {}; // 本次暂存要退的 itemId
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
bool _isReturned(ReturnLine l) => l.alreadyReturned || _staged.contains(l.itemId);
|
||||||
|
|
||||||
|
List<ReturnLine> get _selectable =>
|
||||||
|
widget.lines.where((l) => !l.alreadyReturned).toList();
|
||||||
|
|
||||||
|
Future<void> _confirmLine(ReturnLine l) async {
|
||||||
|
final ok = await showAppDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('退单确认', style: TextStyle(fontSize: 16)),
|
||||||
|
content: RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary, height: 1.6),
|
||||||
|
children: [
|
||||||
|
const TextSpan(text: '确认退掉以下明细?\n'),
|
||||||
|
TextSpan(
|
||||||
|
text: l.name,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||||
|
TextSpan(text: ' · ${l.series} · ${l.spec}\n'),
|
||||||
|
TextSpan(
|
||||||
|
text: '编码 ${l.code} · 数量 ${_q(l.quantity)}',
|
||||||
|
style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('取消')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.danger, foregroundColor: Colors.white),
|
||||||
|
child: const Text('退单'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true) setState(() => _staged.add(l.itemId));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmAll() async {
|
||||||
|
final pending = _selectable.where((l) => !_staged.contains(l.itemId)).toList();
|
||||||
|
if (pending.isEmpty) return;
|
||||||
|
final ok = await showAppDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('全部退单确认', style: TextStyle(fontSize: 16)),
|
||||||
|
content: Text('确认退掉本单全部 ${pending.length} 条未退明细?',
|
||||||
|
style: const TextStyle(fontSize: 13)),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('取消')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.danger, foregroundColor: Colors.white),
|
||||||
|
child: const Text('全部退单'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true) {
|
||||||
|
setState(() => _staged.addAll(pending.map((e) => e.itemId)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_staged.isEmpty) return;
|
||||||
|
final ok = await showAppDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('提交退单', style: TextStyle(fontSize: 16)),
|
||||||
|
content: Text(
|
||||||
|
'确认提交退单?${widget.isOut ? '退回库存' : '从库存删除'} 将立即生效,且不可撤销。',
|
||||||
|
style: const TextStyle(fontSize: 13)),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('取消')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.danger, foregroundColor: Colors.white),
|
||||||
|
child: const Text('提交退单'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true) return;
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
await widget.onSubmit(_staged.toList());
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||||
|
content: Text('退单成功'), backgroundColor: AppTheme.success));
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _submitting = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
|
content: Text('退单失败:$e'), backgroundColor: AppTheme.danger));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final stagedQty = widget.lines
|
||||||
|
.where((l) => _staged.contains(l.itemId))
|
||||||
|
.fold<double>(0, (s, l) => s + l.quantity);
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
child: SizedBox(
|
||||||
|
width: context.dialogWidth(820),
|
||||||
|
height: 560,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// 头部
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 12, 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(widget.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15, fontWeight: FontWeight.w600),
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _submitting ? null : _confirmAll,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppTheme.danger,
|
||||||
|
side: const BorderSide(color: AppTheme.danger),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
child: const Text('全部退单', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, size: 20),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
// 单据信息
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 20,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: [
|
||||||
|
for (final (k, v) in widget.meta)
|
||||||
|
Text.rich(TextSpan(
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
||||||
|
children: [
|
||||||
|
TextSpan(text: '$k:'),
|
||||||
|
TextSpan(text: v, style: const TextStyle(color: AppTheme.textPrimary)),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 行为提示
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 2, 16, 8),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF0F6FF),
|
||||||
|
border: const Border(left: BorderSide(color: AppTheme.primary, width: 3)),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
widget.isOut
|
||||||
|
? '提交后,已退单明细的数量将「加回库存」。退单的明细不会删除,以红色「已退单」标注。'
|
||||||
|
: '提交后,已退单明细对应的库存将「从库存删除」。退单的明细不会删除,以红色「已退单」标注。',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppTheme.primaryDark),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 明细列表
|
||||||
|
Expanded(
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
itemCount: widget.lines.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
|
itemBuilder: (_, i) => _lineTile(widget.lines[i], i),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
// 底部
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_staged.isEmpty
|
||||||
|
? '未选择退单明细'
|
||||||
|
: '已退单 ${_staged.length} 项 · ${widget.isOut ? '退回库存 +' : '从库存移除 −'}${_q(stagedQty)} 件',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: _staged.isEmpty
|
||||||
|
? AppTheme.textSecondary
|
||||||
|
: AppTheme.danger,
|
||||||
|
fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _submitting ? null : () => Navigator.pop(context),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: (_staged.isEmpty || _submitting) ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.danger,
|
||||||
|
foregroundColor: Colors.white),
|
||||||
|
child: _submitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: Colors.white))
|
||||||
|
: const Text('提交退单'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _lineTile(ReturnLine l, int idx) {
|
||||||
|
final returned = _isReturned(l);
|
||||||
|
final nameStyle = TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: returned ? AppTheme.danger : AppTheme.textPrimary,
|
||||||
|
decoration: returned ? TextDecoration.lineThrough : null,
|
||||||
|
);
|
||||||
|
final subStyle = TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: returned ? AppTheme.danger : AppTheme.textSecondary,
|
||||||
|
decoration: returned ? TextDecoration.lineThrough : null,
|
||||||
|
);
|
||||||
|
return Container(
|
||||||
|
color: returned ? const Color(0xFFFFF4F4) : null,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 9),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
child: Text('${idx + 1}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: AppTheme.textSecondary))),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(children: [
|
||||||
|
Flexible(child: Text(l.name, style: nameStyle, overflow: TextOverflow.ellipsis)),
|
||||||
|
if (returned) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.danger,
|
||||||
|
borderRadius: BorderRadius.circular(8)),
|
||||||
|
child: const Text('已退单',
|
||||||
|
style: TextStyle(fontSize: 10, color: Colors.white)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${l.code} · ${l.series} · ${l.spec} · ${_q(l.quantity)}×¥${l.unitPrice.toStringAsFixed(2)} = ¥${l.totalPrice.toStringAsFixed(2)}',
|
||||||
|
style: subStyle,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: 64,
|
||||||
|
child: l.alreadyReturned
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: (_staged.contains(l.itemId)
|
||||||
|
? TextButton(
|
||||||
|
onPressed: _submitting
|
||||||
|
? null
|
||||||
|
: () => setState(() => _staged.remove(l.itemId)),
|
||||||
|
child: const Text('撤销',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||||
|
)
|
||||||
|
: OutlinedButton(
|
||||||
|
onPressed: _submitting ? null : () => _confirmLine(l),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppTheme.danger,
|
||||||
|
side: const BorderSide(color: AppTheme.danger),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
),
|
||||||
|
child: const Text('退单', style: TextStyle(fontSize: 12)),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _q(double v) =>
|
||||||
|
v == v.roundToDouble() ? v.toInt().toString() : v.toString();
|
||||||
|
}
|
||||||
@@ -24,7 +24,9 @@ List<Widget> buildOrderRowActions({
|
|||||||
required VoidCallback onApprove,
|
required VoidCallback onApprove,
|
||||||
required VoidCallback onReject,
|
required VoidCallback onReject,
|
||||||
required VoidCallback onWithdraw,
|
required VoidCallback onWithdraw,
|
||||||
|
required VoidCallback onReturn,
|
||||||
bool canWithdraw = false,
|
bool canWithdraw = false,
|
||||||
|
bool canReturn = false,
|
||||||
List<Widget> afterPrint = const [],
|
List<Widget> afterPrint = const [],
|
||||||
}) {
|
}) {
|
||||||
TextButton btn(String text, Color color, VoidCallback onPressed, {Key? key}) =>
|
TextButton btn(String text, Color color, VoidCallback onPressed, {Key? key}) =>
|
||||||
@@ -33,12 +35,37 @@ List<Widget> buildOrderRowActions({
|
|||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
child: Text(text, style: TextStyle(fontSize: 12, color: color)),
|
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 [
|
return [
|
||||||
btn('详情', AppTheme.primary, onDetail),
|
btn('详情', AppTheme.primary, onDetail),
|
||||||
btn('打印', AppTheme.primary, onPrint),
|
btn('打印', AppTheme.primary, onPrint),
|
||||||
...afterPrint,
|
...afterPrint,
|
||||||
if (!readonly && status == 'approved')
|
if (!readonly && status == 'approved')
|
||||||
WriteGuard(child: btn('结清', AppTheme.accent, onSettle)),
|
WriteGuard(child: btn('结清', AppTheme.accent, onSettle)),
|
||||||
|
// 退单(已审核单退货):红色实心按钮突出(与原型一致),按权限显示
|
||||||
|
if (!readonly && status == 'approved' && canReturn)
|
||||||
|
WriteGuard(
|
||||||
|
child: filledBtn('退单', AppTheme.danger, onReturn,
|
||||||
|
key: Key('btn_return_$orderId'))),
|
||||||
if (!readonly && status == 'draft') ...[
|
if (!readonly && status == 'draft') ...[
|
||||||
WriteGuard(child: btn('修改', AppTheme.primary, onEdit)),
|
WriteGuard(child: btn('修改', AppTheme.primary, onEdit)),
|
||||||
WriteGuard(child: btn('删除', AppTheme.danger, onDelete)),
|
WriteGuard(child: btn('删除', AppTheme.danger, onDelete)),
|
||||||
|
|||||||
Reference in New Issue
Block a user