Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a20645671b | |||
| 6570dfc240 | |||
| 078fab30f0 | |||
| 08f9e20010 | |||
| 2fac1aff66 | |||
| 6b0b2645ce | |||
| 3641f1cf16 | |||
| 6c43800354 |
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.75] - 2026-06-22
|
||||
|
||||
### 新功能
|
||||
- 出库单列表新增「商品编码」搜索框:输入完整商品编码回车,即可精确查出包含该商品的所有出库单(与原「单号/往来单位」搜索互不影响)
|
||||
|
||||
### 变更
|
||||
- 移除「系统设置 → 数据管理」里的数据导入入口:数据导入不再对用户开放
|
||||
|
||||
## [1.0.74] - 2026-06-22
|
||||
|
||||
### 改进
|
||||
- 入库单 / 出库单详情页的商品明细表新增「生产日期」「批次号」两列,便于核对货品批次信息
|
||||
|
||||
## [1.0.73] - 2026-06-22
|
||||
|
||||
### 改进
|
||||
- 出库「添加商品」选货弹窗改为分页加载:默认显示前 20 条,底部显示库存总数并支持上一页/下一页翻页;弹窗打开即加载、按编码/名称/系列搜索时即时分页,避免一次拉取过多导致加载缓慢或看不到商品
|
||||
|
||||
## [1.0.72] - 2026-06-22
|
||||
|
||||
### 新功能
|
||||
- 出库单录入新增「售价」可编辑列:可按实际销售价录入,金额与单据应收按「售价 × 数量」实时计算,同时保留成本价一栏便于对比
|
||||
|
||||
### 改进
|
||||
- 「退单」按钮改为红色实心样式,更醒目地突出该危险操作(与原型一致)
|
||||
- 库存列表隐藏「库存量」列:序列号模型下每件商品独立,原数量列易受历史数据误导,故不再展示(缺货/预警逻辑不受影响)
|
||||
- 出库「添加商品」可一次看到整仓商品(需配合后端 v1.0.75)
|
||||
|
||||
## [1.0.71] - 2026-06-21
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -5,6 +5,24 @@
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.77] - 2026-06-22
|
||||
|
||||
### 新功能
|
||||
- 出库单列表支持按「商品编码」精确反查:可查出包含某个商品编码的所有出库单。同时为出库明细新增 (门店, 商品编码) 复合索引,在数万条明细下仍保持毫秒级查询,不影响写入。
|
||||
|
||||
## [1.0.76] - 2026-06-22
|
||||
|
||||
### 修复
|
||||
- 修复「编辑入库单草稿后合计金额变成 0」的问题:此前每次编辑草稿会把单据合计金额错误清零(明细的单价/金额正常,仅单据头合计未重算),现已按明细正确重算。
|
||||
|
||||
## [1.0.75] - 2026-06-22
|
||||
|
||||
### 新功能
|
||||
- 出库单新增「售价」字段:可按实际销售价录入,应收账款改为按「售价 × 数量」计算,更真实地反映销售额(此前应收按成本价计)。
|
||||
|
||||
### 修复
|
||||
- 修复出库单「添加商品」选择器只能看到一小部分商品的问题:库存查询接口的分页上限此前会把超额请求打回,导致选货时只显示约 20 条,现已放宽,可一次加载整仓商品。
|
||||
|
||||
## [1.0.74] - 2026-06-21
|
||||
|
||||
### 新功能
|
||||
|
||||
+381
-310
@@ -5,6 +5,12 @@
|
||||
// go run cmd/seed/main.go # 写入数据(已存在则跳过)
|
||||
// go run cmd/seed/main.go --reset # 删表重建 + 写入数据
|
||||
// go run cmd/seed/main.go --clear # 清空业务数据 + 重新写入(保留表结构)
|
||||
//
|
||||
// 数据遵循「序列号模型」规范(见 CLAUDE.md 数据模型铁律):
|
||||
// - 入库每录一行 = 新建一个独立 product(唯一商品编号),绝不按名称复用;
|
||||
// - 入库/出库明细、库存均带快照列(product_code/name/series/spec…);
|
||||
// - 审核生成库存时关联 stock_in_item_id、拷贝 unit_price/批次/生产日期快照;
|
||||
// - 出库记录 sale_price(售价),应收(total_amount)按 售价×数量。
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -13,6 +19,7 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
@@ -20,6 +27,7 @@ import (
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
var allModels = []any{
|
||||
@@ -111,6 +119,7 @@ func main() {
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
d := func(daysAgo int) model.Date { return model.Date{Time: today.AddDate(0, 0, -daysAgo)} }
|
||||
dptr := func(daysAgo int) *model.Date { dd := model.Date{Time: today.AddDate(0, 0, -daysAgo)}; return &dd }
|
||||
ptrT := func(t time.Time) *time.Time { return &t }
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
@@ -180,41 +189,8 @@ func main() {
|
||||
}).(model.ProductCategory)
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 商品
|
||||
// ═══════════════════════════════════════════════════
|
||||
type prodSeed struct {
|
||||
code, barcode, name, series, spec, unit, brand string
|
||||
catID *uint64
|
||||
purchase, sale float64
|
||||
minStock int
|
||||
remark string
|
||||
}
|
||||
prodSeeds := []prodSeed{
|
||||
{"MT-001", "6901234567890", "飞天茅台 53度", "茅台", "500ml/瓶", "瓶", "贵州茅台", &catBaijiu.ID, 2350, 2800, 10, "酱香型白酒,53度,飞天系列"},
|
||||
{"WLY-001", "6902345678901", "五粮液 52度", "五粮液", "500ml/瓶", "瓶", "宜宾五粮液", &catBaijiu.ID, 950, 1200, 6, "浓香型白酒,52度,普五系列"},
|
||||
{"YH-001", "6903456789012", "洋河梦之蓝 M6", "洋河", "500ml/瓶", "瓶", "江苏洋河", &catBaijiu.ID, 560, 680, 6, "浓香型,绵柔苏酒代表"},
|
||||
{"LZ-001", "6904567890123", "泸州老窖 特曲", "泸州老窖", "500ml/瓶", "瓶", "泸州老窖", &catBaijiu.ID, 420, 520, 6, "浓香鼻祖,特曲系列"},
|
||||
{"JNC-001", "6905678901234", "剑南春 水晶剑", "剑南春", "500ml/瓶", "瓶", "剑南春", &catBaijiu.ID, 390, 480, 6, "浓香型,绵竹名酒"},
|
||||
{"LJ-001", "6906789012345", "郎酒 红花郎10", "郎酒", "500ml/瓶", "瓶", "古蔺郎酒", &catBaijiu.ID, 320, 420, 6, "酱香型,赤水河畔酿造"},
|
||||
{"LF-001", "3760093550058", "拉菲古堡 2018", "波尔多", "750ml/瓶", "瓶", "Château Lafite", &catImport.ID, 3800, 5200, 3, "波尔多一级名庄,2018年份"},
|
||||
{"RTM-001", "3021691010008", "人头马 VSOP", "人头马", "700ml/瓶", "瓶", "Rémy Martin", &catImport.ID, 480, 680, 3, "法国干邑,VSOP级别"},
|
||||
}
|
||||
var prods []model.Product
|
||||
for _, s := range prodSeeds {
|
||||
p := upsert(db, &model.Product{}, "shop_id = ? AND code = ?", shop.ID, s.code, func() any {
|
||||
return &model.Product{
|
||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
Code: s.code, Barcode: s.barcode, Name: s.name,
|
||||
Series: s.series, Spec: s.spec, Unit: s.unit, Brand: s.brand,
|
||||
CategoryID: s.catID, PurchasePrice: s.purchase, SalePrice: s.sale,
|
||||
MinStock: s.minStock, Remark: s.remark,
|
||||
}
|
||||
}).(model.Product)
|
||||
prods = append(prods, p)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 商品名称/系列/规格选项
|
||||
// 商品基础数据字典(品牌/型号/版本 = 名称/系列/规格选项)
|
||||
// 序列号模型下:product 由入库逐行生成,字典只是录入时的可选项来源。
|
||||
// ═══════════════════════════════════════════════════
|
||||
type dimSeed struct{ code, name string }
|
||||
|
||||
@@ -243,7 +219,10 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range []struct{ code, name string; quantity int }{
|
||||
for _, s := range []struct {
|
||||
code, name string
|
||||
quantity int
|
||||
}{
|
||||
{"GG001", "500ml/瓶", 1}, {"GG002", "750ml/瓶", 1}, {"GG003", "700ml/瓶", 1},
|
||||
} {
|
||||
var opt model.ProductSpecOption
|
||||
@@ -259,6 +238,22 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 商品模板:仅作为入库逐行生成 product 的来源,本身不直接落 products 表。
|
||||
cat := func(c *model.ProductCategory) *uint64 { id := c.ID; return &id }
|
||||
catalog := []goods{
|
||||
{"飞天茅台 53度", "茅台", "500ml/瓶", "瓶", "贵州茅台", cat(&catBaijiu), 2350, 2800, 10},
|
||||
{"五粮液 52度", "五粮液", "500ml/瓶", "瓶", "宜宾五粮液", cat(&catBaijiu), 950, 1200, 6},
|
||||
{"洋河梦之蓝 M6", "洋河", "500ml/瓶", "瓶", "江苏洋河", cat(&catBaijiu), 560, 680, 6},
|
||||
{"泸州老窖 特曲", "泸州老窖", "500ml/瓶", "瓶", "泸州老窖", cat(&catBaijiu), 420, 520, 6},
|
||||
{"剑南春 水晶剑", "剑南春", "500ml/瓶", "瓶", "剑南春", cat(&catBaijiu), 390, 480, 6},
|
||||
{"郎酒 红花郎10", "郎酒", "500ml/瓶", "瓶", "古蔺郎酒", cat(&catBaijiu), 320, 420, 6},
|
||||
{"拉菲古堡 2018", "波尔多", "750ml/瓶", "瓶", "Château Lafite", cat(&catImport), 3800, 5200, 3},
|
||||
{"人头马 VSOP", "人头马", "700ml/瓶", "瓶", "Rémy Martin", cat(&catImport), 480, 680, 3},
|
||||
}
|
||||
// 商品编码顺序生成器(序列号:每入库一行发一个唯一编号 P0001、P0002…)
|
||||
codeSeq := 0
|
||||
nextCode := func() string { codeSeq++; return fmt.Sprintf("P%04d", codeSeq) }
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 往来单位
|
||||
// ═══════════════════════════════════════════════════
|
||||
@@ -317,110 +312,100 @@ func main() {
|
||||
upsertNumberRule(db, shop.ID, "stock_out", "CK", 3)
|
||||
upsertNumberRule(db, shop.ID, "inventory_check", "PD", 1)
|
||||
|
||||
mk := func(db *gorm.DB) *seeder {
|
||||
return &seeder{db: db, shopID: shop.ID, catalog: catalog, nextCode: nextCode, supplier: partners}
|
||||
}
|
||||
sd := mk(db)
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 入库单
|
||||
// 入库单(每行新建独立 product + 快照)
|
||||
// ═══════════════════════════════════════════════════
|
||||
// #1 已审核:茅台+五粮液,主仓库,茅台供应商
|
||||
in1 := createStockInOrder(db, shop.ID, wh1.ID, admin.ID, partners["SUP001"].ID,
|
||||
"RK20260401001", d(6), "approved", "purchase",
|
||||
"茅台系列首批入库,含飞天茅台及五粮液",
|
||||
[]inItemSeed{
|
||||
{prods[0].ID, 120, 2350, "BATCH-MT-2024001", "飞天茅台 2024年第一批次"},
|
||||
{prods[1].ID, 60, 950, "BATCH-WLY-2024001", "五粮液普五 2024年批次"},
|
||||
in1 := sd.createStockIn(wh1.ID, wh1.Name, admin.ID, partners["SUP001"].ID,
|
||||
"RK20260401001", d(6), "approved", "purchase", "茅台系列首批入库,含飞天茅台及五粮液",
|
||||
[]inLine{
|
||||
{0, 120, 2350, "BATCH-MT-2024001", dptr(40), "飞天茅台 2024年第一批次"},
|
||||
{1, 60, 950, "BATCH-WLY-2024001", dptr(50), "五粮液普五 2024年批次"},
|
||||
}, admin.ID, ptrT(today.AddDate(0, 0, -5)))
|
||||
|
||||
// #2 已审核:洋河+泸州+剑南春,主仓库,洋河供应商
|
||||
in2 := createStockInOrder(db, shop.ID, wh1.ID, operator.ID, partners["SUP002"].ID,
|
||||
"RK20260403001", d(4), "approved", "purchase",
|
||||
"浓香型白酒补货入库",
|
||||
[]inItemSeed{
|
||||
{prods[2].ID, 48, 560, "BATCH-YH-2024003", "洋河梦之蓝 M6 3月批次"},
|
||||
{prods[3].ID, 36, 420, "BATCH-LZ-2024002", "泸州老窖特曲 春季批次"},
|
||||
{prods[4].ID, 24, 390, "BATCH-JNC-2024001", "剑南春水晶剑 首批"},
|
||||
in2 := sd.createStockIn(wh1.ID, wh1.Name, operator.ID, partners["SUP002"].ID,
|
||||
"RK20260403001", d(4), "approved", "purchase", "浓香型白酒补货入库",
|
||||
[]inLine{
|
||||
{2, 48, 560, "BATCH-YH-2024003", dptr(60), "洋河梦之蓝 M6 3月批次"},
|
||||
{3, 36, 420, "BATCH-LZ-2024002", dptr(70), "泸州老窖特曲 春季批次"},
|
||||
{4, 24, 390, "BATCH-JNC-2024001", dptr(45), "剑南春水晶剑 首批"},
|
||||
}, admin.ID, ptrT(today.AddDate(0, 0, -3)))
|
||||
|
||||
// #3 已审核:进口酒,进口酒专库,茅台供应商(代理进口)
|
||||
in3 := createStockInOrder(db, shop.ID, wh2.ID, operator.ID, partners["SUP004"].ID,
|
||||
"RK20260404001", d(3), "approved", "purchase",
|
||||
"进口烈酒专库入库,含拉菲及人头马",
|
||||
[]inItemSeed{
|
||||
{prods[6].ID, 24, 3800, "BATCH-LF-2018001", "拉菲古堡2018,原箱"},
|
||||
{prods[7].ID, 36, 480, "BATCH-RTM-2024001", "人头马VSOP,2024年进口"},
|
||||
// #3 已审核:进口酒,进口酒专库,郎酒供应商(代理进口)
|
||||
in3 := sd.createStockIn(wh2.ID, wh2.Name, operator.ID, partners["SUP004"].ID,
|
||||
"RK20260404001", d(3), "approved", "purchase", "进口烈酒专库入库,含拉菲及人头马",
|
||||
[]inLine{
|
||||
{6, 24, 3800, "BATCH-LF-2018001", dptr(400), "拉菲古堡2018,原箱"},
|
||||
{7, 36, 480, "BATCH-RTM-2024001", dptr(90), "人头马VSOP,2024年进口"},
|
||||
}, admin.ID, ptrT(today.AddDate(0, 0, -2)))
|
||||
|
||||
// #4 待审核:郎酒补货
|
||||
in4 := createStockInOrder(db, shop.ID, wh1.ID, operator.ID, partners["SUP004"].ID,
|
||||
"RK20260406001", d(1), "pending", "purchase",
|
||||
"郎酒红花郎补货,待仓库管理员审核",
|
||||
[]inItemSeed{
|
||||
{prods[5].ID, 60, 320, "BATCH-LJ-2024002", "郎酒红花郎10 第二批次"},
|
||||
// #4 待审核:郎酒补货(仅建单+建 product,不生成库存)
|
||||
in4 := sd.createStockIn(wh1.ID, wh1.Name, operator.ID, partners["SUP004"].ID,
|
||||
"RK20260406001", d(1), "pending", "purchase", "郎酒红花郎补货,待仓库管理员审核",
|
||||
[]inLine{
|
||||
{5, 60, 320, "BATCH-LJ-2024002", dptr(30), "郎酒红花郎10 第二批次"},
|
||||
}, 0, nil)
|
||||
|
||||
// #5 草稿:追加茅台
|
||||
in5 := createStockInOrder(db, shop.ID, wh1.ID, operator.ID, partners["SUP001"].ID,
|
||||
"RK20260407001", d(0), "draft", "purchase",
|
||||
"茅台追加订货,草稿中",
|
||||
[]inItemSeed{
|
||||
{prods[0].ID, 60, 2380, "BATCH-MT-2024002", "飞天茅台 第二批次"},
|
||||
sd.createStockIn(wh1.ID, wh1.Name, operator.ID, partners["SUP001"].ID,
|
||||
"RK20260407001", d(0), "draft", "purchase", "茅台追加订货,草稿中",
|
||||
[]inLine{
|
||||
{0, 60, 2380, "BATCH-MT-2024002", dptr(20), "飞天茅台 第二批次"},
|
||||
}, 0, nil)
|
||||
|
||||
_ = in4
|
||||
_ = in5
|
||||
|
||||
// 根据已审核入库单更新库存
|
||||
updateInventoryBatch(db, shop.ID, wh1.ID, admin.ID, in1)
|
||||
updateInventoryBatch(db, shop.ID, wh1.ID, admin.ID, in2)
|
||||
updateInventoryBatch(db, shop.ID, wh2.ID, admin.ID, in3)
|
||||
// 已审核入库单 → 生成库存(带快照 + 关联 stock_in_item_id)
|
||||
sd.applyStockInToInventory(in1)
|
||||
sd.applyStockInToInventory(in2)
|
||||
sd.applyStockInToInventory(in3)
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 出库单
|
||||
// 出库单(引用入库已建 product,记 sale_price,应收按售价)
|
||||
// ═══════════════════════════════════════════════════
|
||||
// #1 已审核:北京君悦大酒店,主仓库
|
||||
out1 := createStockOutOrder(db, shop.ID, wh1.ID, operator.ID, partners["CUS001"].ID,
|
||||
"CK20260404001", d(3), "approved", "sale",
|
||||
"北京君悦大酒店 4月份定期供货",
|
||||
[]outItemSeed{
|
||||
{prods[0].ID, 12, 2800, "飞天茅台 12瓶,整箱出库"},
|
||||
{prods[1].ID, 6, 1200, "五粮液普五 6瓶"},
|
||||
{prods[2].ID, 12, 680, "洋河梦之蓝 M6 12瓶"},
|
||||
out1 := sd.createStockOut(wh1.ID, operator.ID, partners["CUS001"].ID,
|
||||
"CK20260404001", d(3), "approved", "sale", "北京君悦大酒店 4月份定期供货",
|
||||
[]outLine{
|
||||
{in1.products[0], 12, 2800, "飞天茅台 12瓶,整箱出库"},
|
||||
{in1.products[1], 6, 1200, "五粮液普五 6瓶"},
|
||||
{in2.products[0], 12, 680, "洋河梦之蓝 M6 12瓶"},
|
||||
}, admin.ID, ptrT(today.AddDate(0, 0, -2)))
|
||||
|
||||
// #2 已审核:上海外滩华尔道夫,进口酒专库
|
||||
out2 := createStockOutOrder(db, shop.ID, wh2.ID, operator.ID, partners["CUS002"].ID,
|
||||
"CK20260405001", d(2), "approved", "sale",
|
||||
"上海外滩华尔道夫 进口酒专属采购",
|
||||
[]outItemSeed{
|
||||
{prods[6].ID, 6, 5200, "拉菲古堡2018 6瓶"},
|
||||
{prods[7].ID, 12, 680, "人头马VSOP 12瓶"},
|
||||
out2 := sd.createStockOut(wh2.ID, operator.ID, partners["CUS002"].ID,
|
||||
"CK20260405001", d(2), "approved", "sale", "上海外滩华尔道夫 进口酒专属采购",
|
||||
[]outLine{
|
||||
{in3.products[0], 6, 5200, "拉菲古堡2018 6瓶"},
|
||||
{in3.products[1], 12, 680, "人头马VSOP 12瓶"},
|
||||
}, admin.ID, ptrT(today.AddDate(0, 0, -1)))
|
||||
|
||||
// #3 待审核:广州白天鹅宾馆
|
||||
out3 := createStockOutOrder(db, shop.ID, wh1.ID, operator.ID, partners["CUS003"].ID,
|
||||
"CK20260406001", d(1), "pending", "sale",
|
||||
"广州白天鹅宾馆 月度供货申请",
|
||||
[]outItemSeed{
|
||||
{prods[3].ID, 24, 520, "泸州老窖特曲 24瓶"},
|
||||
{prods[4].ID, 12, 480, "剑南春水晶剑 12瓶"},
|
||||
{prods[5].ID, 12, 420, "郎酒红花郎10 12瓶"},
|
||||
sd.createStockOut(wh1.ID, operator.ID, partners["CUS003"].ID,
|
||||
"CK20260406001", d(1), "pending", "sale", "广州白天鹅宾馆 月度供货申请",
|
||||
[]outLine{
|
||||
{in2.products[1], 24, 520, "泸州老窖特曲 24瓶"},
|
||||
{in2.products[2], 12, 480, "剑南春水晶剑 12瓶"},
|
||||
{in4.products[0], 12, 420, "郎酒红花郎10 12瓶"},
|
||||
}, 0, nil)
|
||||
|
||||
// #4 草稿:君悦追加
|
||||
out4 := createStockOutOrder(db, shop.ID, wh1.ID, operator.ID, partners["CUS001"].ID,
|
||||
"CK20260407001", d(0), "draft", "sale",
|
||||
"北京君悦追加订单,草稿中",
|
||||
[]outItemSeed{
|
||||
{prods[0].ID, 6, 2800, "飞天茅台 追加 6瓶"},
|
||||
sd.createStockOut(wh1.ID, operator.ID, partners["CUS001"].ID,
|
||||
"CK20260407001", d(0), "draft", "sale", "北京君悦追加订单,草稿中",
|
||||
[]outLine{
|
||||
{in1.products[0], 6, 2800, "飞天茅台 追加 6瓶"},
|
||||
}, 0, nil)
|
||||
|
||||
_ = out3
|
||||
_ = out4
|
||||
|
||||
// 根据已审核出库单减库存
|
||||
deductInventoryBatch(db, shop.ID, wh1.ID, admin.ID, out1)
|
||||
deductInventoryBatch(db, shop.ID, wh2.ID, admin.ID, out2)
|
||||
// 已审核出库单 → 扣减库存(FIFO)
|
||||
sd.deductStockOut(wh1.ID, admin.ID, out1)
|
||||
sd.deductStockOut(wh2.ID, admin.ID, out2)
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 财务记录(与已审核出库单对应的应收款)
|
||||
// 财务记录(与已审核出库单对应的应收款,按售价口径)
|
||||
// ═══════════════════════════════════════════════════
|
||||
createFinanceRecord(db, shop.ID, partners["CUS001"].ID, admin.ID,
|
||||
"receivable", out1.order.TotalAmount, out1.order.TotalAmount, "stock_out", out1.order.ID,
|
||||
@@ -446,44 +431,313 @@ func main() {
|
||||
fmt.Println(" operator / password123 (操作员)")
|
||||
fmt.Println(" test / password123 (只读)")
|
||||
fmt.Println()
|
||||
fmt.Println(" 数据概览:")
|
||||
fmt.Println(" 数据概览(序列号模型:入库逐行建独立 product):")
|
||||
fmt.Printf(" 仓库:%s / %s\n", wh1.Name, wh2.Name)
|
||||
fmt.Printf(" 商品分类:白酒 / 进口烈酒,共 %d 种商品\n", len(prods))
|
||||
fmt.Printf(" 入库逐行生成 product:%d 个独立商品编号\n", codeSeq)
|
||||
fmt.Println(" 往来单位:4 供应商 + 3 客户")
|
||||
fmt.Println(" 入库单:5 张(2已审核 / 1待审核 / 1草稿 / 1进口专库已审核)")
|
||||
fmt.Println(" 出库单:4 张(2已审核 / 1待审核 / 1草稿)")
|
||||
fmt.Println(" 入库单:5 张(3已审核 / 1待审核 / 1草稿)")
|
||||
fmt.Println(" 出库单:4 张(2已审核 / 1待审核 / 1草稿),记 sale_price,应收按售价")
|
||||
fmt.Println(" 财务记录:2 应收 + 1 收款")
|
||||
fmt.Println("═══════════════════════════════════════════")
|
||||
}
|
||||
|
||||
// ── 辅助类型 ────────────────────────────────────────────
|
||||
// ── 数据模板 ────────────────────────────────────────────
|
||||
|
||||
type inItemSeed struct {
|
||||
productID uint64
|
||||
qty float64
|
||||
price float64
|
||||
batchNo string
|
||||
remark string
|
||||
// goods 商品模板:入库逐行据此 new 独立 product,模板本身不落库。
|
||||
type goods struct {
|
||||
name, series, spec, unit, brand string
|
||||
cat *uint64
|
||||
purchase, sale float64
|
||||
minStock int
|
||||
}
|
||||
|
||||
type outItemSeed struct {
|
||||
productID uint64
|
||||
qty float64
|
||||
price float64
|
||||
// inLine 入库行:g=catalog 下标,qty/price,批次/生产日期/备注。
|
||||
type inLine struct {
|
||||
g int
|
||||
qty, price float64
|
||||
batch string
|
||||
prodDate *model.Date
|
||||
remark string
|
||||
}
|
||||
|
||||
// outLine 出库行:引用入库已建 product,salePrice=售价。
|
||||
type outLine struct {
|
||||
product model.Product
|
||||
qty, sale float64
|
||||
remark string
|
||||
}
|
||||
|
||||
type stockInResult struct {
|
||||
order model.StockInOrder
|
||||
items []inItemSeed
|
||||
order model.StockInOrder
|
||||
items []model.StockInItem
|
||||
products []model.Product
|
||||
}
|
||||
|
||||
type stockOutResult struct {
|
||||
order model.StockOutOrder
|
||||
items []outItemSeed
|
||||
items []model.StockOutItem
|
||||
}
|
||||
|
||||
// ── 核心 upsert ────────────────────────────────────────────
|
||||
// seeder 收敛公共上下文(db/shop/商品模板/编码序列/供应商)。
|
||||
type seeder struct {
|
||||
db *gorm.DB
|
||||
shopID uint64
|
||||
catalog []goods
|
||||
nextCode func() string
|
||||
supplier map[string]model.Partner
|
||||
}
|
||||
|
||||
// ── 入库单(每行新建独立 product + 写快照) ───────────────
|
||||
|
||||
func (s *seeder) createStockIn(
|
||||
whID uint64, whName string, opID, partnerID uint64,
|
||||
orderNo string, date model.Date, status, orderType, remark string,
|
||||
lines []inLine, reviewerID uint64, reviewedAt *time.Time,
|
||||
) stockInResult {
|
||||
var o model.StockInOrder
|
||||
if s.db.Where("shop_id = ? AND order_no = ?", s.shopID, orderNo).First(&o).Error == nil {
|
||||
fmt.Printf("⏭ 入库单:%s\n", orderNo)
|
||||
return s.loadStockIn(o)
|
||||
}
|
||||
|
||||
o = model.StockInOrder{
|
||||
TenantBase: model.TenantBase{ShopID: s.shopID},
|
||||
OrderNo: orderNo,
|
||||
Type: orderType,
|
||||
WarehouseID: whID,
|
||||
PartnerID: &partnerID,
|
||||
OperatorID: opID,
|
||||
Status: status,
|
||||
OrderDate: date,
|
||||
Remark: remark,
|
||||
}
|
||||
if reviewerID > 0 {
|
||||
o.ReviewerID = &reviewerID
|
||||
o.ReviewedAt = reviewedAt
|
||||
}
|
||||
s.db.Create(&o)
|
||||
|
||||
var (
|
||||
total float64
|
||||
items []model.StockInItem
|
||||
prods []model.Product
|
||||
)
|
||||
for _, ln := range lines {
|
||||
g := s.catalog[ln.g]
|
||||
code := s.nextCode()
|
||||
full, initials := util.ToPinyin(g.name)
|
||||
// 铁律:入库每行 = 新建一个独立 product(唯一编号),不按名称复用
|
||||
p := model.Product{
|
||||
TenantBase: model.TenantBase{ShopID: s.shopID},
|
||||
PublicID: uuid.New().String(),
|
||||
Code: code,
|
||||
Name: g.name,
|
||||
Series: g.series,
|
||||
Spec: g.spec,
|
||||
Unit: g.unit,
|
||||
Brand: g.brand,
|
||||
CategoryID: g.cat,
|
||||
PurchasePrice: ln.price,
|
||||
SalePrice: g.sale,
|
||||
MinStock: g.minStock,
|
||||
BatchNo: ln.batch,
|
||||
ProductionDate: ln.prodDate,
|
||||
NamePinyin: full,
|
||||
NameInitials: initials,
|
||||
}
|
||||
s.db.Create(&p)
|
||||
|
||||
item := model.StockInItem{
|
||||
OrderID: o.ID,
|
||||
ShopID: s.shopID,
|
||||
ProductID: p.ID,
|
||||
ProductCode: code,
|
||||
ProductName: g.name,
|
||||
Series: g.series,
|
||||
Spec: g.spec,
|
||||
Quantity: ln.qty,
|
||||
UnitPrice: ln.price,
|
||||
TotalPrice: ln.qty * ln.price,
|
||||
BatchNo: ln.batch,
|
||||
ProductionDate: ln.prodDate,
|
||||
Remark: ln.remark,
|
||||
}
|
||||
s.db.Create(&item)
|
||||
total += item.TotalPrice
|
||||
items = append(items, item)
|
||||
prods = append(prods, p)
|
||||
}
|
||||
s.db.Model(&o).Update("total_amount", total)
|
||||
o.TotalAmount = total
|
||||
|
||||
fmt.Printf("✅ 入库单:%s [%s] 供应商ID=%d 仓库=%s 行数=%d 金额=%.0f\n",
|
||||
orderNo, status, partnerID, whName, len(lines), total)
|
||||
return stockInResult{order: o, items: items, products: prods}
|
||||
}
|
||||
|
||||
// loadStockIn 单据已存在时,回读其明细与 product,供出库行引用(保证重复执行可用)。
|
||||
func (s *seeder) loadStockIn(o model.StockInOrder) stockInResult {
|
||||
var items []model.StockInItem
|
||||
s.db.Where("order_id = ?", o.ID).Order("id ASC").Find(&items)
|
||||
prods := make([]model.Product, 0, len(items))
|
||||
for _, it := range items {
|
||||
var p model.Product
|
||||
s.db.First(&p, it.ProductID)
|
||||
prods = append(prods, p)
|
||||
}
|
||||
return stockInResult{order: o, items: items, products: prods}
|
||||
}
|
||||
|
||||
// applyStockInToInventory 已审核入库单 → 逐明细生成库存行(带快照 + 关联 stock_in_item_id)。
|
||||
func (s *seeder) applyStockInToInventory(r stockInResult) {
|
||||
for i := range r.items {
|
||||
it := r.items[i]
|
||||
var unit string
|
||||
if i < len(r.products) {
|
||||
unit = r.products[i].Unit
|
||||
}
|
||||
var qtyBefore float64
|
||||
s.db.Model(&model.Inventory{}).
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", s.shopID, r.order.WarehouseID, it.ProductID).
|
||||
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
|
||||
|
||||
whIDCopy := r.order.WarehouseID
|
||||
pidCopy := it.ProductID
|
||||
itemIDCopy := it.ID
|
||||
price := it.UnitPrice
|
||||
inv := model.Inventory{
|
||||
ShopID: s.shopID,
|
||||
WarehouseID: &whIDCopy,
|
||||
ProductID: &pidCopy,
|
||||
StockInItemID: &itemIDCopy,
|
||||
Quantity: it.Quantity,
|
||||
ProductCode: it.ProductCode,
|
||||
ProductName: it.ProductName,
|
||||
Series: it.Series,
|
||||
Spec: it.Spec,
|
||||
Unit: unit,
|
||||
UnitPrice: &price,
|
||||
BatchNo: it.BatchNo,
|
||||
ProductionDate: it.ProductionDate,
|
||||
}
|
||||
s.db.Create(&inv)
|
||||
|
||||
opID := r.order.OperatorID
|
||||
s.db.Create(&model.InventoryLog{
|
||||
ShopID: s.shopID, WarehouseID: r.order.WarehouseID, ProductID: it.ProductID,
|
||||
Direction: "in", Quantity: it.Quantity, QtyBefore: qtyBefore, QtyAfter: qtyBefore + it.Quantity,
|
||||
RefType: "stock_in", RefID: r.order.ID, OperatorID: &opID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── 出库单(记 sale_price,应收按售价) ───────────────────
|
||||
|
||||
func (s *seeder) createStockOut(
|
||||
whID, opID, partnerID uint64,
|
||||
orderNo string, date model.Date, status, orderType, remark string,
|
||||
lines []outLine, reviewerID uint64, reviewedAt *time.Time,
|
||||
) stockOutResult {
|
||||
var o model.StockOutOrder
|
||||
if s.db.Where("shop_id = ? AND order_no = ?", s.shopID, orderNo).First(&o).Error == nil {
|
||||
fmt.Printf("⏭ 出库单:%s\n", orderNo)
|
||||
var items []model.StockOutItem
|
||||
s.db.Where("order_id = ?", o.ID).Order("id ASC").Find(&items)
|
||||
return stockOutResult{order: o, items: items}
|
||||
}
|
||||
|
||||
o = model.StockOutOrder{
|
||||
TenantBase: model.TenantBase{ShopID: s.shopID},
|
||||
OrderNo: orderNo,
|
||||
Type: orderType,
|
||||
WarehouseID: whID,
|
||||
PartnerID: &partnerID,
|
||||
OperatorID: opID,
|
||||
Status: status,
|
||||
OrderDate: date,
|
||||
Remark: remark,
|
||||
}
|
||||
if reviewerID > 0 {
|
||||
o.ReviewerID = &reviewerID
|
||||
o.ReviewedAt = reviewedAt
|
||||
}
|
||||
s.db.Create(&o)
|
||||
|
||||
var (
|
||||
total float64 // 应收 = Σ 售价×数量
|
||||
items []model.StockOutItem
|
||||
)
|
||||
for _, ln := range lines {
|
||||
p := ln.product
|
||||
item := model.StockOutItem{
|
||||
OrderID: o.ID,
|
||||
ShopID: s.shopID,
|
||||
ProductID: p.ID,
|
||||
ProductCode: p.Code,
|
||||
ProductName: p.Name,
|
||||
Series: p.Series,
|
||||
Spec: p.Spec,
|
||||
Quantity: ln.qty,
|
||||
UnitPrice: p.PurchasePrice, // 成本单价(快照)
|
||||
SalePrice: ln.sale, // 售价
|
||||
TotalPrice: ln.qty * p.PurchasePrice, // 成本小计
|
||||
BatchNo: p.BatchNo,
|
||||
Remark: ln.remark,
|
||||
}
|
||||
s.db.Create(&item)
|
||||
total += ln.qty * ln.sale
|
||||
items = append(items, item)
|
||||
}
|
||||
s.db.Model(&o).Update("total_amount", total)
|
||||
o.TotalAmount = total
|
||||
|
||||
fmt.Printf("✅ 出库单:%s [%s] 客户ID=%d 仓库ID=%d 行数=%d 应收=%.0f\n",
|
||||
orderNo, status, partnerID, whID, len(lines), total)
|
||||
return stockOutResult{order: o, items: items}
|
||||
}
|
||||
|
||||
// deductStockOut 已审核出库单 → FIFO 扣减库存 + 写流水。
|
||||
func (s *seeder) deductStockOut(whID, opID uint64, r stockOutResult) {
|
||||
now := time.Now()
|
||||
for _, it := range r.items {
|
||||
var qtyBefore float64
|
||||
s.db.Model(&model.Inventory{}).
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", s.shopID, whID, it.ProductID).
|
||||
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
|
||||
|
||||
var batches []model.Inventory
|
||||
s.db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
|
||||
s.shopID, whID, it.ProductID).
|
||||
Order("created_at ASC").Find(&batches)
|
||||
|
||||
remaining := it.Quantity
|
||||
for i := range batches {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
b := &batches[i]
|
||||
if b.Quantity <= remaining {
|
||||
remaining -= b.Quantity
|
||||
s.db.Model(b).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now})
|
||||
} else {
|
||||
s.db.Model(b).Update("quantity", gorm.Expr("quantity - ?", remaining))
|
||||
remaining = 0
|
||||
}
|
||||
}
|
||||
|
||||
after := qtyBefore - it.Quantity
|
||||
if after < 0 {
|
||||
after = 0
|
||||
}
|
||||
s.db.Create(&model.InventoryLog{
|
||||
ShopID: s.shopID, WarehouseID: whID, ProductID: it.ProductID,
|
||||
Direction: "out", Quantity: it.Quantity, QtyBefore: qtyBefore, QtyAfter: after,
|
||||
RefType: "stock_out", RefID: r.order.ID, OperatorID: &opID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── 通用 upsert(主数据) ────────────────────────────────
|
||||
|
||||
// upsert 通用:查不到就调 factory 建,返回具体值(interface{})
|
||||
func upsert(db *gorm.DB, dest any, cond string, args ...any) any {
|
||||
@@ -534,16 +788,6 @@ func upsert(db *gorm.DB, dest any, cond string, args ...any) any {
|
||||
}
|
||||
fmt.Printf("⏭ 分类:%s\n", v.Name)
|
||||
return *v
|
||||
case *model.Product:
|
||||
found = db.Where(cond, queryArgs...).First(v).Error == nil
|
||||
if !found {
|
||||
obj := factory().(*model.Product)
|
||||
db.Create(obj)
|
||||
fmt.Printf("✅ 商品:%s(%s)\n", obj.Name, obj.Code)
|
||||
return *obj
|
||||
}
|
||||
fmt.Printf("⏭ 商品:%s\n", v.Name)
|
||||
return *v
|
||||
case *model.Partner:
|
||||
found = db.Where(cond, queryArgs...).First(v).Error == nil
|
||||
if !found {
|
||||
@@ -558,179 +802,6 @@ func upsert(db *gorm.DB, dest any, cond string, args ...any) any {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── 入库单 ────────────────────────────────────────────
|
||||
|
||||
func createStockInOrder(
|
||||
db *gorm.DB, shopID, whID, opID, partnerID uint64,
|
||||
orderNo string, date model.Date, status, orderType, remark string,
|
||||
items []inItemSeed,
|
||||
reviewerID uint64, reviewedAt *time.Time,
|
||||
) stockInResult {
|
||||
var o model.StockInOrder
|
||||
if db.Where("shop_id = ? AND order_no = ?", shopID, orderNo).First(&o).Error != nil {
|
||||
o = model.StockInOrder{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
OrderNo: orderNo,
|
||||
Type: orderType,
|
||||
WarehouseID: whID,
|
||||
PartnerID: &partnerID,
|
||||
OperatorID: opID,
|
||||
Status: status,
|
||||
OrderDate: date,
|
||||
Remark: remark,
|
||||
}
|
||||
if reviewerID > 0 {
|
||||
o.ReviewerID = &reviewerID
|
||||
o.ReviewedAt = reviewedAt
|
||||
}
|
||||
db.Create(&o)
|
||||
|
||||
var total float64
|
||||
for _, it := range items {
|
||||
item := model.StockInItem{
|
||||
OrderID: o.ID,
|
||||
ShopID: shopID,
|
||||
ProductID: it.productID,
|
||||
Quantity: it.qty,
|
||||
UnitPrice: it.price,
|
||||
TotalPrice: it.qty * it.price,
|
||||
BatchNo: it.batchNo,
|
||||
Remark: it.remark,
|
||||
}
|
||||
db.Create(&item)
|
||||
total += item.TotalPrice
|
||||
}
|
||||
db.Model(&o).Update("total_amount", total)
|
||||
o.TotalAmount = total
|
||||
|
||||
fmt.Printf("✅ 入库单:%s [%s] 供应商ID=%d 仓库ID=%d 金额=%.0f\n",
|
||||
orderNo, status, partnerID, whID, total)
|
||||
} else {
|
||||
fmt.Printf("⏭ 入库单:%s\n", orderNo)
|
||||
}
|
||||
return stockInResult{order: o, items: items}
|
||||
}
|
||||
|
||||
// ── 出库单 ────────────────────────────────────────────
|
||||
|
||||
func createStockOutOrder(
|
||||
db *gorm.DB, shopID, whID, opID, partnerID uint64,
|
||||
orderNo string, date model.Date, status, orderType, remark string,
|
||||
items []outItemSeed,
|
||||
reviewerID uint64, reviewedAt *time.Time,
|
||||
) stockOutResult {
|
||||
var o model.StockOutOrder
|
||||
if db.Where("shop_id = ? AND order_no = ?", shopID, orderNo).First(&o).Error != nil {
|
||||
o = model.StockOutOrder{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
OrderNo: orderNo,
|
||||
Type: orderType,
|
||||
WarehouseID: whID,
|
||||
PartnerID: &partnerID,
|
||||
OperatorID: opID,
|
||||
Status: status,
|
||||
OrderDate: date,
|
||||
Remark: remark,
|
||||
}
|
||||
if reviewerID > 0 {
|
||||
o.ReviewerID = &reviewerID
|
||||
o.ReviewedAt = reviewedAt
|
||||
}
|
||||
db.Create(&o)
|
||||
|
||||
var total float64
|
||||
for _, it := range items {
|
||||
item := model.StockOutItem{
|
||||
OrderID: o.ID,
|
||||
ShopID: shopID,
|
||||
ProductID: it.productID,
|
||||
Quantity: it.qty,
|
||||
UnitPrice: it.price,
|
||||
TotalPrice: it.qty * it.price,
|
||||
Remark: it.remark,
|
||||
}
|
||||
db.Create(&item)
|
||||
total += item.TotalPrice
|
||||
}
|
||||
db.Model(&o).Update("total_amount", total)
|
||||
o.TotalAmount = total
|
||||
|
||||
fmt.Printf("✅ 出库单:%s [%s] 客户ID=%d 仓库ID=%d 金额=%.0f\n",
|
||||
orderNo, status, partnerID, whID, total)
|
||||
} else {
|
||||
fmt.Printf("⏭ 出库单:%s\n", orderNo)
|
||||
}
|
||||
return stockOutResult{order: o, items: items}
|
||||
}
|
||||
|
||||
// ── 库存 ────────────────────────────────────────────
|
||||
|
||||
func updateInventoryBatch(db *gorm.DB, shopID, whID, opID uint64, r stockInResult) {
|
||||
for _, it := range r.items {
|
||||
var qtyBefore float64
|
||||
db.Model(&model.Inventory{}).
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", shopID, whID, it.productID).
|
||||
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
|
||||
|
||||
productIDCopy := it.productID
|
||||
whIDCopy := whID
|
||||
inv := model.Inventory{
|
||||
ShopID: shopID,
|
||||
WarehouseID: &whIDCopy,
|
||||
ProductID: &productIDCopy,
|
||||
Quantity: it.qty,
|
||||
}
|
||||
db.Create(&inv)
|
||||
|
||||
db.Create(&model.InventoryLog{
|
||||
ShopID: shopID, WarehouseID: whID, ProductID: it.productID,
|
||||
Direction: "in", Quantity: it.qty, QtyBefore: qtyBefore, QtyAfter: qtyBefore + it.qty,
|
||||
RefType: "stock_in", RefID: r.order.ID, OperatorID: &opID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func deductInventoryBatch(db *gorm.DB, shopID, whID, opID uint64, r stockOutResult) {
|
||||
now := time.Now()
|
||||
for _, it := range r.items {
|
||||
var qtyBefore float64
|
||||
db.Model(&model.Inventory{}).
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", shopID, whID, it.productID).
|
||||
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
|
||||
|
||||
// FIFO deduction
|
||||
var batches []model.Inventory
|
||||
db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
|
||||
shopID, whID, it.productID).
|
||||
Order("created_at ASC").Find(&batches)
|
||||
|
||||
remaining := it.qty
|
||||
for i := range batches {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
b := &batches[i]
|
||||
if b.Quantity <= remaining {
|
||||
remaining -= b.Quantity
|
||||
db.Model(b).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now})
|
||||
} else {
|
||||
db.Model(b).Update("quantity", gorm.Expr("quantity - ?", remaining))
|
||||
remaining = 0
|
||||
}
|
||||
}
|
||||
|
||||
after := qtyBefore - it.qty
|
||||
if after < 0 {
|
||||
after = 0
|
||||
}
|
||||
db.Create(&model.InventoryLog{
|
||||
ShopID: shopID, WarehouseID: whID, ProductID: it.productID,
|
||||
Direction: "out", Quantity: it.qty, QtyBefore: qtyBefore, QtyAfter: after,
|
||||
RefType: "stock_out", RefID: r.order.ID, OperatorID: &opID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── 财务记录 ────────────────────────────────────────────
|
||||
|
||||
func createFinanceRecord(db *gorm.DB, shopID, partnerID, opID uint64,
|
||||
|
||||
@@ -54,9 +54,13 @@ func (h *InventoryHandler) List(c *gin.Context) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 500 {
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
// 超出上限钳到上限(此前是回退默认 20,导致出库选货请求大 page_size 被打回只剩 20 条)
|
||||
if pageSize > 5000 {
|
||||
pageSize = 5000
|
||||
}
|
||||
|
||||
baseWhere := "inv.shop_id = ? AND inv.deleted_at IS NULL"
|
||||
args := []interface{}{shopID}
|
||||
|
||||
@@ -172,6 +172,7 @@ func (h *StockInHandler) Update(c *gin.Context) {
|
||||
it.ProductID = prod.ID
|
||||
it.ProductCode = prod.Code
|
||||
it.TotalPrice = it.Quantity * it.UnitPrice
|
||||
total += it.TotalPrice
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"warehouse_id": req.WarehouseID,
|
||||
|
||||
@@ -272,6 +272,44 @@ func TestStockInHandler_TotalAmount(t *testing.T) {
|
||||
assert.Equal(t, float64(110), data["total_amount"])
|
||||
}
|
||||
|
||||
// 回归:编辑草稿入库单后 total_amount 必须按新明细重算(此前 Update 漏了累加,编辑后被清成 0)。
|
||||
func TestStockInHandler_UpdateRecomputesTotal(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SI_UPD")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
|
||||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||||
r := setupProtectedRouter(db)
|
||||
|
||||
// 建草稿:1 行 10×5 = 50
|
||||
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
|
||||
"warehouse_id": warehouse.ID,
|
||||
"order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{
|
||||
{"product_name": "茅台", "series": "飞天", "spec": "500ml", "quantity": 10.0, "unit_price": 5.0},
|
||||
},
|
||||
})
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
orderID := extractID(w)
|
||||
|
||||
// 编辑:改成 2 行 12×3100 + 1×4500 = 41700
|
||||
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d", orderID), token, map[string]interface{}{
|
||||
"warehouse_id": warehouse.ID,
|
||||
"order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{
|
||||
{"product_name": "茅台2009", "series": "大件", "spec": "500ml", "quantity": 12.0, "unit_price": 3100.0},
|
||||
{"product_name": "茅台十五年", "series": "大件", "spec": "500ml", "quantity": 1.0, "unit_price": 4500.0},
|
||||
},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// 取详情核对 total_amount 已重算(不是 0)
|
||||
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", orderID), token, nil)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
data := parseResponse(w)["data"].(map[string]interface{})
|
||||
assert.Equal(t, float64(41700), data["total_amount"], "编辑后金额应按新明细重算")
|
||||
}
|
||||
|
||||
func TestStockInHandler_NoAuth(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
r := setupProtectedRouter(db)
|
||||
|
||||
@@ -50,6 +50,13 @@ func (h *StockOutHandler) List(c *gin.Context) {
|
||||
like, shopID, like,
|
||||
)
|
||||
}
|
||||
// 按商品编码反查单据:精确匹配,走 idx_so_items_shop_code(shop_id, product_code) 索引
|
||||
if code := strings.TrimSpace(c.Query("product_code")); code != "" {
|
||||
query = query.Where(
|
||||
"id IN (SELECT order_id FROM stock_out_items WHERE shop_id = ? AND product_code = ? AND deleted_at IS NULL)",
|
||||
shopID, code,
|
||||
)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
@@ -110,8 +117,9 @@ func (h *StockOutHandler) Create(c *gin.Context) {
|
||||
var total float64
|
||||
for i := range req.Items {
|
||||
req.Items[i].ShopID = shopID
|
||||
// TotalPrice 记成本小计;应收(TotalAmount)按售价×数量
|
||||
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
|
||||
total += req.Items[i].TotalPrice
|
||||
total += req.Items[i].Quantity * req.Items[i].SalePrice
|
||||
}
|
||||
req.TotalAmount = total
|
||||
|
||||
@@ -147,8 +155,9 @@ func (h *StockOutHandler) Update(c *gin.Context) {
|
||||
for i := range req.Items {
|
||||
req.Items[i].ShopID = shopID
|
||||
req.Items[i].OrderID = order.ID
|
||||
// TotalPrice 记成本小计;应收(TotalAmount)按售价×数量
|
||||
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
|
||||
total += req.Items[i].TotalPrice
|
||||
total += req.Items[i].Quantity * req.Items[i].SalePrice
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"warehouse_id": req.WarehouseID,
|
||||
|
||||
@@ -133,6 +133,40 @@ func TestStockOutHandler_List(t *testing.T) {
|
||||
assert.Equal(t, float64(2), resp["total"].(float64))
|
||||
}
|
||||
|
||||
// 按商品编码精确反查出库单(明细 product_code = ?)
|
||||
func TestStockOutHandler_List_FilterByProductCode(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SO_CODE")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
|
||||
product := testutil.CreateTestProduct(db, shop.ID, "Beer")
|
||||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||||
r := setupProtectedRouter(db)
|
||||
|
||||
makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
|
||||
"warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{
|
||||
{"product_id": product.ID, "product_code": "ZXZ-FIND", "quantity": 1.0},
|
||||
},
|
||||
})
|
||||
makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
|
||||
"warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{
|
||||
{"product_id": product.ID, "product_code": "OTHER-CODE", "quantity": 1.0},
|
||||
},
|
||||
})
|
||||
|
||||
// 精确命中 1 单
|
||||
w := makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=ZXZ-FIND", token, nil)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
|
||||
|
||||
// 不存在的编码 → 0;前缀不算精确(不命中)
|
||||
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=ZXZ", token, nil)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, float64(0), parseResponse(w)["total"].(float64))
|
||||
}
|
||||
|
||||
func TestStockOutHandler_List_FilterByKeyword(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SO011")
|
||||
|
||||
@@ -27,13 +27,21 @@ func TestStockReturn_StockOut_OwnAllowed(t *testing.T) {
|
||||
// 操作员建出库单(本人) → 提交 → 审核
|
||||
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", opToken, map[string]interface{}{
|
||||
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{{"product_id": prod.ID, "quantity": 15.0, "unit_price": 20.0}},
|
||||
"items": []map[string]interface{}{{"product_id": prod.ID, "quantity": 15.0, "unit_price": 20.0, "sale_price": 25.0}},
|
||||
})
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
orderID := extractID(w)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), opToken, nil)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), opToken, nil)
|
||||
|
||||
// 应收按售价口径:TotalAmount = 15 × 25 = 375(不是成本 15×20)
|
||||
var soOrder model.StockOutOrder
|
||||
require.NoError(t, db.First(&soOrder, orderID).Error)
|
||||
assert.Equal(t, float64(375), soOrder.TotalAmount)
|
||||
var recv model.FinanceRecord
|
||||
require.NoError(t, db.Where("shop_id = ? AND type = 'receivable' AND ref_type = 'stock_out'", shop.ID).First(&recv).Error)
|
||||
assert.Equal(t, float64(375), recv.Amount, "应收应按售价×数量")
|
||||
|
||||
var item model.StockOutItem
|
||||
require.NoError(t, db.Where("order_id = ?", orderID).First(&item).Error)
|
||||
|
||||
@@ -75,12 +83,11 @@ func TestStockReturn_StockOut_OwnAllowed(t *testing.T) {
|
||||
db.First(&order, orderID)
|
||||
assert.Equal(t, "full", order.ReturnState)
|
||||
|
||||
// 应收冲减:存在一条负向 receivable
|
||||
var neg int64
|
||||
db.Model(&model.FinanceRecord{}).
|
||||
Where("shop_id = ? AND type = 'receivable' AND amount < 0 AND ref_type = 'stock_out_return'", shop.ID).
|
||||
Count(&neg)
|
||||
assert.Equal(t, int64(1), neg)
|
||||
// 应收冲减:存在一条负向 receivable,金额按售价口径 = -375
|
||||
var negRec model.FinanceRecord
|
||||
require.NoError(t, db.Where("shop_id = ? AND type = 'receivable' AND amount < 0 AND ref_type = 'stock_out_return'", shop.ID).
|
||||
First(&negRec).Error)
|
||||
assert.Equal(t, float64(-375), negRec.Amount, "退单冲应收应按售价×数量")
|
||||
}
|
||||
|
||||
func TestStockReturn_StockIn_AdminOnly_AndRemovesInventory(t *testing.T) {
|
||||
|
||||
@@ -60,6 +60,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
|
||||
stockIn.GET("/orders", stockInH.List)
|
||||
stockIn.GET("/orders/:id", stockInH.Get)
|
||||
stockIn.POST("/orders", stockInH.Create)
|
||||
stockIn.PUT("/orders/:id", stockInH.Update)
|
||||
stockIn.PUT("/orders/:id/submit", stockInH.Submit)
|
||||
stockIn.PUT("/orders/:id/approve", stockInH.Approve)
|
||||
stockIn.PUT("/orders/:id/reject", stockInH.Reject)
|
||||
@@ -71,6 +72,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
|
||||
stockOut.GET("/orders", stockOutH.List)
|
||||
stockOut.GET("/orders/:id", stockOutH.Get)
|
||||
stockOut.POST("/orders", stockOutH.Create)
|
||||
stockOut.PUT("/orders/:id", stockOutH.Update)
|
||||
stockOut.PUT("/orders/:id/submit", stockOutH.Submit)
|
||||
stockOut.PUT("/orders/:id/approve", stockOutH.Approve)
|
||||
stockOut.PUT("/orders/:id/reject", stockOutH.Reject)
|
||||
|
||||
@@ -94,6 +94,8 @@ type StockOutItem struct {
|
||||
Spec string `gorm:"size:100" json:"spec"`
|
||||
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
|
||||
UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"`
|
||||
// 售价:出库实际销售单价(可编辑),应收账款按 售价×数量 计;与 UnitPrice(入库成本) 区分
|
||||
SalePrice float64 `gorm:"type:decimal(16,2);default:0" json:"sale_price"`
|
||||
TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"`
|
||||
// 已退数量:0=未退,=Quantity 表示整行已退单
|
||||
ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"`
|
||||
|
||||
@@ -416,7 +416,8 @@ func (s *StockService) ReturnStockOut(shopID, orderID, userID uint64, role strin
|
||||
return err
|
||||
}
|
||||
it.ReturnedQuantity = it.Quantity
|
||||
returnedAmount += it.TotalPrice
|
||||
// 冲应收按售价口径(与审核建应收 TotalAmount=Σ售价×数量 一致)
|
||||
returnedAmount += it.Quantity * it.SalePrice
|
||||
}
|
||||
|
||||
// 冲减应收(负向调整记录)
|
||||
|
||||
@@ -144,5 +144,12 @@ func autoMigrate(db *gorm.DB) {
|
||||
log.Fatalf("create unique index uk_shop_code failed: %v", err)
|
||||
}
|
||||
}
|
||||
// stock_out_items(shop_id, product_code) 复合索引:支撑出库列表「按商品编码反查单据」,
|
||||
// 避免在 2.6 万+ 明细行上全表扫。ShopID 在嵌入字段上无法用 struct tag 表达,故显式幂等建。
|
||||
if !db.Migrator().HasIndex(&model.StockOutItem{}, "idx_so_items_shop_code") {
|
||||
if err := db.Exec("CREATE INDEX idx_so_items_shop_code ON stock_out_items (shop_id, product_code)").Error; err != nil {
|
||||
log.Fatalf("create index idx_so_items_shop_code failed: %v", err)
|
||||
}
|
||||
}
|
||||
log.Println("AutoMigrate completed")
|
||||
}
|
||||
|
||||
@@ -360,15 +360,17 @@ CREATE TABLE IF NOT EXISTS `stock_out_items` (
|
||||
`series` VARCHAR(100) DEFAULT NULL COMMENT '系列(历史导入快照)',
|
||||
`spec` VARCHAR(100) DEFAULT NULL COMMENT '规格(历史导入快照)',
|
||||
`quantity` DECIMAL(12,3) NOT NULL,
|
||||
`unit_price` DECIMAL(16,2) NOT NULL DEFAULT 0,
|
||||
`total_price` DECIMAL(16,2) NOT NULL DEFAULT 0,
|
||||
`unit_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '成本单价(入库成本快照)',
|
||||
`sale_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '售价(出库实际销售单价,应收按售价×数量)',
|
||||
`total_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '成本小计(unit_price×quantity)',
|
||||
`batch_no` VARCHAR(50) DEFAULT NULL COMMENT '批次号',
|
||||
`production_date` DATE DEFAULT NULL COMMENT '生产日期',
|
||||
`custom_fields` JSON DEFAULT NULL,
|
||||
`remark` VARCHAR(255) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_order_id` (`order_id`),
|
||||
KEY `idx_shop_product` (`shop_id`, `product_id`)
|
||||
KEY `idx_shop_product` (`shop_id`, `product_id`),
|
||||
KEY `idx_so_items_shop_code` (`shop_id`, `product_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库单明细';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
@@ -389,6 +389,7 @@ func SetupTestDB() *gorm.DB {
|
||||
spec TEXT,
|
||||
quantity REAL NOT NULL,
|
||||
unit_price REAL DEFAULT 0,
|
||||
sale_price REAL DEFAULT 0,
|
||||
total_price REAL DEFAULT 0,
|
||||
returned_quantity REAL DEFAULT 0,
|
||||
batch_no TEXT,
|
||||
|
||||
@@ -4,6 +4,7 @@ class StockOutItem {
|
||||
final int productId;
|
||||
final double quantity;
|
||||
final double unitPrice;
|
||||
final double salePrice;
|
||||
final double totalPrice;
|
||||
final double returnedQuantity;
|
||||
final String? productName;
|
||||
@@ -11,6 +12,8 @@ class StockOutItem {
|
||||
final String? productSeries;
|
||||
final String? productSpec;
|
||||
final String? productUnit;
|
||||
final String? batchNo;
|
||||
final String? productionDate;
|
||||
|
||||
bool get isReturned => returnedQuantity >= quantity && quantity > 0;
|
||||
|
||||
@@ -20,6 +23,7 @@ class StockOutItem {
|
||||
required this.productId,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
this.salePrice = 0,
|
||||
required this.totalPrice,
|
||||
this.returnedQuantity = 0,
|
||||
this.productName,
|
||||
@@ -27,6 +31,8 @@ class StockOutItem {
|
||||
this.productSeries,
|
||||
this.productSpec,
|
||||
this.productUnit,
|
||||
this.batchNo,
|
||||
this.productionDate,
|
||||
});
|
||||
|
||||
factory StockOutItem.fromJson(Map<String, dynamic> json) {
|
||||
@@ -47,6 +53,7 @@ class StockOutItem {
|
||||
productId: (json['product_id'] as num).toInt(),
|
||||
quantity: (json['quantity'] as num).toDouble(),
|
||||
unitPrice: (json['unit_price'] as num).toDouble(),
|
||||
salePrice: (json['sale_price'] as num?)?.toDouble() ?? 0,
|
||||
totalPrice: (json['total_price'] as num).toDouble(),
|
||||
returnedQuantity: (json['returned_quantity'] as num?)?.toDouble() ?? 0,
|
||||
productName: lineOrProduct('product_name', 'name'),
|
||||
@@ -54,6 +61,8 @@ class StockOutItem {
|
||||
productSeries: lineOrProduct('series', 'series'),
|
||||
productSpec: lineOrProduct('spec', 'spec'),
|
||||
productUnit: (json['product'] as Map<String, dynamic>?)?['unit'] as String?,
|
||||
batchNo: json['batch_no'] as String?,
|
||||
productionDate: json['production_date'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
|
||||
String? _startDate;
|
||||
String? _endDate;
|
||||
String _keyword = '';
|
||||
String _productCode = '';
|
||||
PageResult<StockOutOrder>? _cache;
|
||||
|
||||
@override
|
||||
@@ -46,6 +47,7 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
|
||||
startDate: _startDate,
|
||||
endDate: _endDate,
|
||||
keyword: _keyword.isEmpty ? null : _keyword,
|
||||
productCode: _productCode.isEmpty ? null : _productCode,
|
||||
page: _page,
|
||||
pageSize: _pageSize,
|
||||
);
|
||||
@@ -84,6 +86,13 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
|
||||
reload();
|
||||
}
|
||||
|
||||
/// 按商品编码精确反查(独立于 keyword)
|
||||
void setProductCode(String code) {
|
||||
_productCode = code;
|
||||
_page = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then((result) {
|
||||
|
||||
@@ -14,6 +14,7 @@ class StockOutRepository {
|
||||
String? startDate,
|
||||
String? endDate,
|
||||
String? keyword,
|
||||
String? productCode,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
}) async {
|
||||
@@ -25,6 +26,8 @@ class StockOutRepository {
|
||||
if (startDate != null) 'start_date': startDate,
|
||||
if (endDate != null) 'end_date': endDate,
|
||||
if (keyword != null && keyword.isNotEmpty) 'keyword': keyword,
|
||||
if (productCode != null && productCode.isNotEmpty)
|
||||
'product_code': productCode,
|
||||
};
|
||||
final resp = await _client.get('/stock-out/orders', params: params);
|
||||
return PageResult.fromJson(
|
||||
|
||||
@@ -51,7 +51,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
ColDef('series', '系列'),
|
||||
ColDef('batch', '批次号'),
|
||||
ColDef('warehouse', '仓库'),
|
||||
ColDef('qty', '库存量'),
|
||||
// 库存量列已移除:序列号模型下每件独立,原数量列含历史装箱数脏数据易误导
|
||||
ColDef('price', '单价'),
|
||||
ColDef('prodDate', '生产日期'),
|
||||
ColDef('inTime', '入库时间'),
|
||||
@@ -311,15 +311,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
'batch' => DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
|
||||
'warehouse' => DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)),
|
||||
'qty' => DataCell(Text(
|
||||
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: item.quantity == 0
|
||||
? AppTheme.danger
|
||||
: (item.minStock != null && item.quantity < item.minStock!)
|
||||
? AppTheme.accent
|
||||
: AppTheme.textPrimary))),
|
||||
'price' => DataCell(Text(
|
||||
item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '-')),
|
||||
'prodDate' => DataCell(Text(item.productionDate ?? '-')),
|
||||
@@ -367,19 +358,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
if (item.series.isNotEmpty) MobileCardField('系列', item.series),
|
||||
if (item.batchNo.isNotEmpty) MobileCardField('批次', item.batchNo),
|
||||
MobileCardField('仓库', item.warehouseName.isEmpty ? '-' : item.warehouseName),
|
||||
MobileCardField('库存', null,
|
||||
valueWidget: Text(
|
||||
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: item.quantity == 0
|
||||
? AppTheme.danger
|
||||
: (item.minStock != null && item.quantity < item.minStock!)
|
||||
? AppTheme.accent
|
||||
: AppTheme.textPrimary,
|
||||
),
|
||||
)),
|
||||
if (item.unitPrice != null)
|
||||
MobileCardField('单价', '¥${item.unitPrice!.toStringAsFixed(2)}'),
|
||||
if (item.supplierName.isNotEmpty)
|
||||
@@ -763,7 +741,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
};
|
||||
return DataColumn(
|
||||
label: label,
|
||||
numeric: c.key == 'qty' || c.key == 'price',
|
||||
numeric: c.key == 'price',
|
||||
);
|
||||
}).toList(),
|
||||
rows: items.isEmpty
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import 'dart:async';
|
||||
import '../../core/utils/dialog_util.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/config/app_constants.dart';
|
||||
import '../../core/config/app_info.dart';
|
||||
import '../../core/config/license_copy.dart';
|
||||
import '../../core/responsive/responsive.dart';
|
||||
@@ -46,8 +43,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 6,
|
||||
initialIndex: widget.initialTab,
|
||||
length: 5,
|
||||
initialIndex: widget.initialTab >= 5 ? 0 : widget.initialTab,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
@@ -66,7 +63,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
Tab(text: '编号规则'),
|
||||
Tab(text: '系统参数'),
|
||||
Tab(text: '授权'),
|
||||
Tab(text: '数据管理'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -79,7 +75,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
_buildNumberRulesTab(),
|
||||
_buildSystemParamsTab(),
|
||||
_buildLicenseTab(),
|
||||
_buildImportTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -823,31 +818,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 数据导入 Tab ──────────────────────────────────────────
|
||||
Widget _buildImportTab() {
|
||||
if (WriteGuard.isReadonly(ref)) {
|
||||
return const Center(
|
||||
child: Text('只读账号无导入权限',
|
||||
style: TextStyle(color: AppTheme.textSecondary)),
|
||||
);
|
||||
}
|
||||
if (WriteGuard.licenseBlocked(ref)) {
|
||||
final lic = ref.watch(licenseProvider).valueOrNull;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
lic == null ? '授权已过期,暂时无法导入' : LicenseCopy.writeBlockedToast(lic),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.textSecondary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final currentUser = ref.watch(authStateProvider).user;
|
||||
final isSuperAdmin = currentUser?.role == 'superadmin';
|
||||
return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
|
||||
}
|
||||
|
||||
void _showEditParamDialog(
|
||||
String label, String current, ValueChanged<String> onSave) {
|
||||
@@ -1213,685 +1183,6 @@ class _ParamRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 批量数据导入 ───────────────────────────────────────────
|
||||
|
||||
class _ImportSlot {
|
||||
final String title;
|
||||
final String endpoint;
|
||||
final String hint;
|
||||
PlatformFile? file;
|
||||
// null = 未运行;true = 成功;false = 失败
|
||||
bool? success;
|
||||
String? error;
|
||||
int total = 0;
|
||||
int imported = 0;
|
||||
int skipped = 0;
|
||||
int updated = 0;
|
||||
// 进度
|
||||
int uploadPercent = 0;
|
||||
bool isProcessing = false;
|
||||
int processingSeconds = 0;
|
||||
|
||||
_ImportSlot(this.title, this.endpoint, this.hint);
|
||||
|
||||
bool get hasResult => success != null;
|
||||
|
||||
void resetProgress() {
|
||||
uploadPercent = 0;
|
||||
isProcessing = false;
|
||||
processingSeconds = 0;
|
||||
success = null;
|
||||
error = null;
|
||||
}
|
||||
}
|
||||
|
||||
class _BatchImportWidget extends ConsumerStatefulWidget {
|
||||
final bool isSuperAdmin;
|
||||
const _BatchImportWidget({required this.isSuperAdmin});
|
||||
|
||||
@override
|
||||
ConsumerState<_BatchImportWidget> createState() => _BatchImportWidgetState();
|
||||
}
|
||||
|
||||
class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
||||
bool _loading = false;
|
||||
String? _lastDir;
|
||||
OverlayEntry? _importBarrier;
|
||||
|
||||
static const _prefKey = 'import_last_dir';
|
||||
|
||||
late final List<_ImportSlot> _slots = [
|
||||
_ImportSlot('往来单位', '/import/partners',
|
||||
'格式:编号 | 类型 | 状态 | 名称 | 电话 | 卡号 | 初始金额 | 单位 | 地址 | 备注'),
|
||||
_ImportSlot('商品名称', '/import/product-names',
|
||||
'格式:选项编号 | 选项名称 | 备注'),
|
||||
_ImportSlot('商品系列', '/import/product-series',
|
||||
'格式:选项编号 | 选项名称 | 备注'),
|
||||
_ImportSlot('商品规格', '/import/product-specs',
|
||||
'格式:选项编号 | 选项名称 | 单品数量 | 备注'),
|
||||
_ImportSlot('库存', '/import/inventory',
|
||||
'格式:商品编号|商品名称|系列|规格|单位|库存数量|单价|金额|生产日期|批次|分类|所在仓库|入库日期|供应商|上次盘点|备注'),
|
||||
_ImportSlot('入库单', '/import/stock-in',
|
||||
'老系统入库单打印格式:每个文件一张单(含单据号/往来单位/日期/明细)'),
|
||||
_ImportSlot('出库单', '/import/stock-out',
|
||||
'老系统出库单打印格式:每个文件一张单(含单据号/往来单位/日期/明细)'),
|
||||
];
|
||||
|
||||
final Set<String> _selectedTables = {};
|
||||
bool _clearing = false;
|
||||
|
||||
static const _clearOptions = [
|
||||
(key: 'stock_in', label: '入库单'),
|
||||
(key: 'stock_out', label: '出库单'),
|
||||
(key: 'inventory', label: '库存管理'),
|
||||
(key: 'products', label: '商品详情'),
|
||||
(key: 'partners', label: '往来单位'),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
final dir = prefs.getString(_prefKey);
|
||||
if (dir != null && mounted) setState(() => _lastDir = dir);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickFile(int index) async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['xls', 'xlsx'],
|
||||
withData: true,
|
||||
initialDirectory: kIsWeb ? null : _lastDir,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
|
||||
// 保存目录供下次使用(仅非 web 平台)
|
||||
if (!kIsWeb) {
|
||||
final path = result.files.first.path;
|
||||
if (path != null) {
|
||||
final dir = path.contains('/') ? path.substring(0, path.lastIndexOf('/')) : null;
|
||||
if (dir != null) {
|
||||
_lastDir = dir;
|
||||
SharedPreferences.getInstance().then((p) => p.setString(_prefKey, dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_slots[index].file = result.files.first;
|
||||
_slots[index].success = null;
|
||||
_slots[index].error = null;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('选择文件失败:$e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showBarrier() {
|
||||
_importBarrier = OverlayEntry(
|
||||
builder: (_) => Stack(children: [
|
||||
const ModalBarrier(dismissible: false, color: Colors.transparent),
|
||||
Positioned(
|
||||
bottom: 24, left: 0, right: 0,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
SizedBox(width: 14, height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)),
|
||||
SizedBox(width: 10),
|
||||
Text('导入中,请勿切换页面…',
|
||||
style: TextStyle(color: Colors.white, fontSize: 13)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
Overlay.of(context, rootOverlay: true).insert(_importBarrier!);
|
||||
}
|
||||
|
||||
void _removeBarrier() {
|
||||
_importBarrier?.remove();
|
||||
_importBarrier = null;
|
||||
}
|
||||
|
||||
Future<void> _runImport() async {
|
||||
final token = ref.read(authStateProvider).user?.accessToken ?? '';
|
||||
if (!_slots.any((s) => s.file != null)) return;
|
||||
|
||||
_showBarrier();
|
||||
setState(() {
|
||||
_loading = true;
|
||||
for (final s in _slots) {
|
||||
if (s.file != null) s.resetProgress();
|
||||
}
|
||||
});
|
||||
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
sendTimeout: AppConstants.importSendTimeout,
|
||||
receiveTimeout: AppConstants.importReceiveTimeout,
|
||||
));
|
||||
dio.options.headers['Authorization'] = 'Bearer $token';
|
||||
|
||||
for (final slot in _slots) {
|
||||
if (slot.file == null) continue;
|
||||
final bytes = slot.file!.bytes;
|
||||
if (bytes == null) {
|
||||
if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; });
|
||||
continue;
|
||||
}
|
||||
|
||||
Timer? processingTimer;
|
||||
bool uploadDone = false;
|
||||
|
||||
try {
|
||||
final formData = FormData.fromMap({
|
||||
'file': MultipartFile.fromBytes(bytes, filename: slot.file!.name),
|
||||
});
|
||||
final resp = await dio.post(
|
||||
slot.endpoint,
|
||||
data: formData,
|
||||
onSendProgress: (sent, total) {
|
||||
if (total <= 0 || !mounted) return;
|
||||
if (sent >= total && !uploadDone) {
|
||||
uploadDone = true;
|
||||
setState(() { slot.isProcessing = true; slot.processingSeconds = 0; });
|
||||
processingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => slot.processingSeconds++);
|
||||
});
|
||||
} else if (!uploadDone) {
|
||||
final pct = (sent / total * 100).round().clamp(0, 99);
|
||||
if (mounted) setState(() => slot.uploadPercent = pct);
|
||||
}
|
||||
},
|
||||
);
|
||||
processingTimer?.cancel();
|
||||
final data = (resp.data is Map) ? resp.data as Map<String, dynamic> : <String, dynamic>{};
|
||||
if (mounted) setState(() {
|
||||
slot.success = true;
|
||||
if (data.containsKey('order_no')) {
|
||||
// 单据类导入(入库单/出库单):每个文件一张单,
|
||||
// 已存在时后端返回 {skipped: true}(布尔),不能按 int 解析。
|
||||
final skippedOne = data['skipped'] == true;
|
||||
slot.imported = skippedOne ? 0 : 1;
|
||||
slot.skipped = skippedOne ? 1 : 0;
|
||||
slot.updated = 0;
|
||||
slot.total = 1;
|
||||
} else {
|
||||
slot.imported = (data['imported'] ?? 0) as int;
|
||||
slot.skipped = (data['skipped'] ?? 0) as int;
|
||||
slot.updated = (data['updated'] ?? 0) as int;
|
||||
slot.total = (data['total'] ?? slot.imported + slot.skipped + slot.updated) as int;
|
||||
}
|
||||
slot.isProcessing = false;
|
||||
});
|
||||
} on DioException catch (e) {
|
||||
processingTimer?.cancel();
|
||||
final raw = e.response?.data;
|
||||
final msg = (raw is Map ? raw['error'] : null) ?? e.message ?? '未知错误';
|
||||
if (mounted) setState(() {
|
||||
slot.success = false;
|
||||
slot.error = msg.toString();
|
||||
slot.isProcessing = false;
|
||||
});
|
||||
} catch (e) {
|
||||
processingTimer?.cancel();
|
||||
if (mounted) setState(() {
|
||||
slot.success = false;
|
||||
slot.error = e.toString();
|
||||
slot.isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
_removeBarrier();
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasAnyFile = _slots.any((s) => s.file != null);
|
||||
final ran = _slots.where((s) => s.file != null && s.hasResult).toList();
|
||||
final allDone = !_loading && ran.length == _slots.where((s) => s.file != null).length && ran.isNotEmpty;
|
||||
final failCount = ran.where((s) => s.success == false).length;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('数据导入',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'选择对应的 Excel 文件后点击「全部导入」,系统将依次导入,按名称去重(已存在的数据不重复导入)。',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < _slots.length; i++) ...[
|
||||
_buildSlotRow(i),
|
||||
if (i < _slots.length - 1)
|
||||
const Divider(height: 1),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: (_loading || !hasAnyFile) ? null : _runImport,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 14, height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.upload_rounded, size: 16),
|
||||
label: Text(_loading ? '导入中...' : '全部导入'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
if (allDone) ...[
|
||||
Icon(
|
||||
failCount == 0
|
||||
? Icons.check_circle_outline
|
||||
: Icons.warning_amber_rounded,
|
||||
size: 16,
|
||||
color: failCount == 0 ? AppTheme.success : Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
failCount == 0 ? '全部导入完成' : '$failCount 项失败,请检查错误信息',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: failCount == 0 ? AppTheme.success : Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (widget.isSuperAdmin) ...[
|
||||
const SizedBox(height: 32),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
_buildClearDataSection(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildClearDataSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题行
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.delete_forever, color: AppTheme.danger, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text('危险操作 — 数据清空',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.danger)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 红色警告框
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3F3),
|
||||
border: Border.all(color: AppTheme.danger.withOpacity(0.5)),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
const Icon(Icons.warning_amber_rounded,
|
||||
color: AppTheme.danger, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
const Text('警告:数据一旦清空,无法恢复!',
|
||||
style: TextStyle(
|
||||
color: AppTheme.danger,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13)),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'• 清空操作仅删除当前门店的数据,不影响其他门店\n'
|
||||
'• 操作不可撤销,请务必提前备份数据\n'
|
||||
'• 仅超级管理员可执行此操作',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFFB71C1C), height: 1.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// 勾选框
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: _clearOptions.map((opt) {
|
||||
final selected = _selectedTables.contains(opt.key);
|
||||
return FilterChip(
|
||||
label: Text(opt.label),
|
||||
selected: selected,
|
||||
selectedColor: AppTheme.danger.withOpacity(0.15),
|
||||
checkmarkColor: AppTheme.danger,
|
||||
labelStyle: TextStyle(
|
||||
color: selected ? AppTheme.danger : AppTheme.textPrimary,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
side: BorderSide(
|
||||
color: selected ? AppTheme.danger : Colors.grey.shade300,
|
||||
),
|
||||
onSelected: _clearing
|
||||
? null
|
||||
: (v) => setState(() {
|
||||
if (v) {
|
||||
_selectedTables.add(opt.key);
|
||||
} else {
|
||||
_selectedTables.remove(opt.key);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// 清空按钮
|
||||
ElevatedButton.icon(
|
||||
onPressed: (_clearing || _selectedTables.isEmpty)
|
||||
? null
|
||||
: () => _confirmClear(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.danger,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: Colors.grey.shade300,
|
||||
),
|
||||
icon: _clearing
|
||||
? const SizedBox(
|
||||
width: 14, height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.delete_sweep_outlined, size: 18),
|
||||
label: Text(_clearing ? '清空中...' : '清空选中数据'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmClear() async {
|
||||
final tables = List<String>.from(_selectedTables);
|
||||
final labels = _clearOptions
|
||||
.where((o) => tables.contains(o.key))
|
||||
.map((o) => o.label)
|
||||
.toList();
|
||||
|
||||
final ctrl = TextEditingController();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setS) => AlertDialog(
|
||||
title: Row(children: [
|
||||
const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22),
|
||||
const SizedBox(width: 8),
|
||||
const Text('确认清空数据', style: TextStyle(color: Colors.red)),
|
||||
]),
|
||||
content: SizedBox(
|
||||
width: context.dialogWidth(400),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('即将清空以下数据表:',
|
||||
style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 8),
|
||||
...labels.map((l) => Padding(
|
||||
padding: const EdgeInsets.only(left: 12, bottom: 4),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.remove_circle,
|
||||
color: Colors.red, size: 14),
|
||||
const SizedBox(width: 6),
|
||||
Text(l,
|
||||
style: const TextStyle(
|
||||
color: Colors.red, fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
)),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3F3),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
),
|
||||
child: const Text(
|
||||
'⚠️ 此操作不可撤销,数据清空后无法恢复!',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('请输入 "确认清空" 以继续:',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: ctrl,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '确认清空',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (_) => setS(() {}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: ctrl.text == '确认清空'
|
||||
? () => Navigator.of(ctx).pop(true)
|
||||
: null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('确认清空'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
ctrl.dispose();
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
setState(() => _clearing = true);
|
||||
try {
|
||||
final token = ref.read(authStateProvider).user?.accessToken ?? '';
|
||||
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
|
||||
dio.options.headers['Authorization'] = 'Bearer $token';
|
||||
await dio.post('/admin/clear-data', data: {'tables': tables});
|
||||
if (!mounted) return;
|
||||
setState(() => _selectedTables.clear());
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('已清空:${labels.join('、')}'),
|
||||
backgroundColor: AppTheme.success,
|
||||
),
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
final msg = (e.response?.data is Map ? e.response!.data['error'] : null) ??
|
||||
e.message ?? '未知错误';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('清空失败:$msg'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('清空失败:$e'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _clearing = false);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSlotRow(int index) {
|
||||
final slot = _slots[index];
|
||||
|
||||
Widget? statusWidget;
|
||||
if (slot.hasResult) {
|
||||
if (slot.success == true) {
|
||||
final duplicate = slot.skipped + slot.updated;
|
||||
final parts = <String>[
|
||||
'共 ${slot.total} 条',
|
||||
'新增 ${slot.imported} 条',
|
||||
if (duplicate > 0) '重复 $duplicate 条',
|
||||
];
|
||||
statusWidget = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline, size: 15, color: AppTheme.success),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
parts.join(','),
|
||||
style: const TextStyle(fontSize: 13, color: AppTheme.success),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
statusWidget = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 15, color: AppTheme.danger),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
slot.error ?? '未知错误',
|
||||
style: const TextStyle(fontSize: 13, color: AppTheme.danger),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
} else if (_loading && slot.file != null) {
|
||||
if (slot.isProcessing) {
|
||||
statusWidget = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12, height: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'导入数据${slot.processingSeconds > 0 ? "(${slot.processingSeconds}秒)" : ""}',
|
||||
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
statusWidget = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: LinearProgressIndicator(
|
||||
value: slot.uploadPercent / 100,
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'上传 ${slot.uploadPercent}%',
|
||||
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 68,
|
||||
child: Text(slot.title,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _loading ? null : () => _pickFile(index),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text('选择文件', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
slot.file?.name ?? '未选择',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: slot.file != null ? AppTheme.textPrimary : AppTheme.textSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (statusWidget != null) statusWidget,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 80),
|
||||
child: Text(
|
||||
slot.hint,
|
||||
style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 门店 Logo 预览 ────────────────────────────────────────
|
||||
class _ShopLogoPreview extends StatelessWidget {
|
||||
|
||||
@@ -1064,16 +1064,18 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FlexColumnWidth(1.2),
|
||||
2: FlexColumnWidth(2.2),
|
||||
3: FlexColumnWidth(1.5),
|
||||
4: FlexColumnWidth(1.5),
|
||||
5: FlexColumnWidth(1.2),
|
||||
6: FlexColumnWidth(1.2),
|
||||
7: FlexColumnWidth(1.2),
|
||||
3: FlexColumnWidth(1.3),
|
||||
4: FlexColumnWidth(1.3),
|
||||
5: FlexColumnWidth(1.3),
|
||||
6: FlexColumnWidth(1.3),
|
||||
7: FlexColumnWidth(1.0),
|
||||
8: FlexColumnWidth(1.1),
|
||||
9: FlexColumnWidth(1.1),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
||||
children: ['序号', '商品编码', '名称', '系列', '规格', '数量', '单价', '金额']
|
||||
children: ['序号', '商品编码', '名称', '系列', '规格', '生产日期', '批次号', '数量', '单价', '金额']
|
||||
.map((h) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 10),
|
||||
@@ -1122,6 +1124,12 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
|
||||
),
|
||||
_TableCell(item.productSeries ?? '-'),
|
||||
_TableCell(item.productSpec ?? '-'),
|
||||
_TableCell((item.productionDate != null &&
|
||||
item.productionDate!.length >= 10)
|
||||
? item.productionDate!.substring(0, 10)
|
||||
: (item.productionDate ?? '-')),
|
||||
_TableCell(
|
||||
(item.batchNo?.isNotEmpty ?? false) ? item.batchNo! : '-'),
|
||||
_TableCell(item.quantity.toStringAsFixed(3)),
|
||||
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
|
||||
_TableCell('¥${item.totalPrice.toStringAsFixed(2)}'),
|
||||
|
||||
@@ -83,6 +83,7 @@ class _ItemRow {
|
||||
final String spec;
|
||||
final double? unitPrice;
|
||||
final TextEditingController qtyCtrl;
|
||||
final TextEditingController salePriceCtrl;
|
||||
|
||||
_ItemRow({
|
||||
this.productId,
|
||||
@@ -91,9 +92,19 @@ class _ItemRow {
|
||||
this.series = '',
|
||||
this.spec = '',
|
||||
this.unitPrice,
|
||||
}) : qtyCtrl = TextEditingController(text: '1');
|
||||
double? salePrice,
|
||||
}) : qtyCtrl = TextEditingController(text: '1'),
|
||||
// 售价默认带出成本单价(无历史售价时),可手工改成实际卖价
|
||||
salePriceCtrl = TextEditingController(
|
||||
text: ((salePrice != null && salePrice > 0)
|
||||
? salePrice
|
||||
: (unitPrice ?? 0))
|
||||
.toStringAsFixed(2));
|
||||
|
||||
void dispose() => qtyCtrl.dispose();
|
||||
void dispose() {
|
||||
qtyCtrl.dispose();
|
||||
salePriceCtrl.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class StockOutFormScreen extends ConsumerStatefulWidget {
|
||||
@@ -152,6 +163,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
series: item.productSeries ?? '',
|
||||
spec: item.productSpec ?? '',
|
||||
unitPrice: item.unitPrice,
|
||||
salePrice: item.salePrice,
|
||||
);
|
||||
row.qtyCtrl.text = item.quantity.toStringAsFixed(0);
|
||||
_items.add(row);
|
||||
@@ -178,11 +190,13 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 出库单总金额 = 应收 = Σ(售价×数量)
|
||||
double get _totalAmount {
|
||||
double total = 0;
|
||||
for (final item in _items) {
|
||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||
total += qty * (item.unitPrice ?? 0);
|
||||
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
|
||||
total += qty * sale;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
@@ -280,10 +294,12 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
final itemsData = _items.map((item) {
|
||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||
final price = item.unitPrice ?? 0;
|
||||
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
|
||||
return {
|
||||
'product_id': item.productId ?? 0,
|
||||
'quantity': qty,
|
||||
'unit_price': price,
|
||||
'sale_price': sale,
|
||||
'total_price': qty * price,
|
||||
};
|
||||
}).toList();
|
||||
@@ -647,10 +663,11 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
2: FlexColumnWidth(2.0), // 商品名称
|
||||
3: FlexColumnWidth(1.2), // 系列
|
||||
4: FlexColumnWidth(1.2), // 规格
|
||||
5: FlexColumnWidth(1.0), // 单价
|
||||
6: FlexColumnWidth(0.8), // 数量
|
||||
7: FlexColumnWidth(1.0), // 金额
|
||||
8: FixedColumnWidth(48), // 操作
|
||||
5: FlexColumnWidth(1.0), // 成本价
|
||||
6: FlexColumnWidth(1.0), // 售价
|
||||
7: FlexColumnWidth(0.8), // 数量
|
||||
8: FlexColumnWidth(1.0), // 金额
|
||||
9: FixedColumnWidth(48), // 操作
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
@@ -662,7 +679,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
'商品名称',
|
||||
'系列',
|
||||
'规格',
|
||||
'单价',
|
||||
'成本价',
|
||||
'售价',
|
||||
'数量',
|
||||
'金额',
|
||||
'操作',
|
||||
@@ -734,7 +752,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
final item = _items[index];
|
||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||
final price = item.unitPrice ?? 0;
|
||||
final amount = qty * price;
|
||||
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
|
||||
final amount = qty * sale;
|
||||
|
||||
return TableRow(
|
||||
decoration: BoxDecoration(
|
||||
@@ -761,12 +780,16 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
_cell(Text(item.spec,
|
||||
style:
|
||||
const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
|
||||
// 单价
|
||||
// 成本价
|
||||
_cell(Text(price > 0 ? '¥${price.toStringAsFixed(2)}' : '-',
|
||||
style: const TextStyle(fontSize: 13))),
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary))),
|
||||
// 售价(可编辑)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4), child: _salePriceField(item)),
|
||||
// 数量
|
||||
Padding(padding: const EdgeInsets.all(4), child: _qtyField(item)),
|
||||
// 金额
|
||||
// 金额(=售价×数量)
|
||||
_cell(Text('¥${amount.toStringAsFixed(2)}',
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))),
|
||||
// 操作
|
||||
@@ -803,12 +826,27 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// 售价输入:可编辑,金额(应收)按 售价×数量 联动
|
||||
Widget _salePriceField(_ItemRow item) {
|
||||
return TextFormField(
|
||||
controller: item.salePriceCtrl,
|
||||
decoration: const InputDecoration(hintText: '0.00', isDense: true),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
|
||||
],
|
||||
onChanged: (_) => setState(() {}),
|
||||
);
|
||||
}
|
||||
|
||||
/// 窄屏(手机):每个明细行渲染为一张卡片,字段竖排,避免表格横向溢出。
|
||||
Widget _buildItemCard(int index) {
|
||||
final item = _items[index];
|
||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||
final price = item.unitPrice ?? 0;
|
||||
final amount = qty * price;
|
||||
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
|
||||
final amount = qty * sale;
|
||||
|
||||
return MobileListCard(
|
||||
title: Text(item.productName),
|
||||
@@ -824,7 +862,9 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
fields: [
|
||||
if (item.series.isNotEmpty) MobileCardField('系列', item.series),
|
||||
if (item.spec.isNotEmpty) MobileCardField('规格', item.spec),
|
||||
MobileCardField('单价', price > 0 ? '¥${price.toStringAsFixed(2)}' : '-'),
|
||||
MobileCardField(
|
||||
'成本价', price > 0 ? '¥${price.toStringAsFixed(2)}' : '-'),
|
||||
MobileCardField('售价', null, valueWidget: _salePriceField(item)),
|
||||
MobileCardField('数量', null, valueWidget: _qtyField(item)),
|
||||
MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'),
|
||||
],
|
||||
@@ -921,6 +961,27 @@ class _InventoryPickerDialogState
|
||||
};
|
||||
bool _loading = false;
|
||||
Timer? _debounce;
|
||||
// 服务端分页:默认第 1 页 20 条;_total 为当前筛选下的库存总数
|
||||
static const int _pageSize = 20;
|
||||
int _page = 1;
|
||||
int _total = 0;
|
||||
int get _totalPages => _total <= 0 ? 1 : ((_total + _pageSize - 1) ~/ _pageSize);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 打开即拉取第 1 页(默认前 20 条),不依赖调用方预加载,避免默认空白。
|
||||
// 用首帧后回调,避开 initState 同步段调用 setState。
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _fetch();
|
||||
});
|
||||
}
|
||||
|
||||
void _goPage(int p) {
|
||||
if (p < 1 || p > _totalPages || _loading) return;
|
||||
setState(() => _page = p);
|
||||
_fetch();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -930,7 +991,11 @@ class _InventoryPickerDialogState
|
||||
}
|
||||
|
||||
void _onSearchChanged(String v) {
|
||||
setState(() => _search = v);
|
||||
// 改搜索词回到第 1 页
|
||||
setState(() {
|
||||
_search = v;
|
||||
_page = 1;
|
||||
});
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 300), _fetch);
|
||||
}
|
||||
@@ -942,13 +1007,19 @@ class _InventoryPickerDialogState
|
||||
final result = await ref.read(inventoryRepositoryProvider).listInventory(
|
||||
warehouseId: widget.warehouseId,
|
||||
keyword: kw.isEmpty ? null : kw,
|
||||
pageSize: AppConstants.stockOutPickerPageSize,
|
||||
page: _page,
|
||||
pageSize: _pageSize,
|
||||
);
|
||||
final items = _aggregatePickerItems(result.data);
|
||||
for (final it in items) {
|
||||
_known[it.productId] = it;
|
||||
}
|
||||
if (mounted) setState(() => _results = items);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_results = items;
|
||||
_total = result.total;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// 忽略:保留上次结果
|
||||
} finally {
|
||||
@@ -1121,6 +1192,39 @@ class _InventoryPickerDialogState
|
||||
}),
|
||||
child: Text(allSelected ? '取消全选' : '全选当前'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 库存总数汇总(无搜索=整仓总数,搜索时=匹配总数)
|
||||
Text(
|
||||
_loading ? '加载中…' : '共 $_total 项库存商品',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 翻页
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left, size: 20),
|
||||
onPressed: (_page > 1 && !_loading)
|
||||
? () => _goPage(_page - 1)
|
||||
: null,
|
||||
tooltip: '上一页',
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
),
|
||||
Text('第 $_page / $_totalPages 页',
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right, size: 20),
|
||||
onPressed: (_page < _totalPages && !_loading)
|
||||
? () => _goPage(_page + 1)
|
||||
: null,
|
||||
tooltip: '下一页',
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
),
|
||||
const Spacer(),
|
||||
OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
||||
@@ -42,6 +42,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
Set<String> _filterCustomer = {};
|
||||
Set<String>? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认)
|
||||
final _searchCtrl = TextEditingController();
|
||||
final _codeSearchCtrl = TextEditingController();
|
||||
|
||||
static const _screenId = 'stock_out_list';
|
||||
|
||||
@@ -77,6 +78,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
_codeSearchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -84,6 +86,10 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
.read(stockOutListProvider.notifier)
|
||||
.setKeyword(_searchCtrl.text.trim());
|
||||
|
||||
void _triggerCodeSearch() => ref
|
||||
.read(stockOutListProvider.notifier)
|
||||
.setProductCode(_codeSearchCtrl.text.trim());
|
||||
|
||||
String? get _startDate => _dateRange != null
|
||||
? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}'
|
||||
: null;
|
||||
@@ -384,6 +390,22 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
onSubmitted: (_) => _triggerSearch(),
|
||||
);
|
||||
|
||||
// 商品编码精确反查(独立搜索框)
|
||||
final codeSearchField = TextField(
|
||||
controller: _codeSearchCtrl,
|
||||
decoration: InputDecoration(
|
||||
hintText: '商品编码,回车精确查',
|
||||
prefixIcon: const Icon(Icons.qr_code, size: 16),
|
||||
hintStyle: const TextStyle(fontSize: 12),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.search, size: 16),
|
||||
tooltip: '按商品编码查',
|
||||
onPressed: _triggerCodeSearch,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _triggerCodeSearch(),
|
||||
);
|
||||
|
||||
final refreshBtn = IconButton(
|
||||
tooltip: '刷新',
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
@@ -417,6 +439,8 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
children: [
|
||||
searchField,
|
||||
const SizedBox(height: 8),
|
||||
codeSearchField,
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
@@ -450,6 +474,8 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(width: 220, child: searchField),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 180, child: codeSearchField),
|
||||
const SizedBox(width: 12),
|
||||
if (newBtn != null) newBtn,
|
||||
const Spacer(),
|
||||
@@ -1003,16 +1029,18 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FlexColumnWidth(1.2),
|
||||
2: FlexColumnWidth(2.2),
|
||||
3: FlexColumnWidth(1.5),
|
||||
4: FlexColumnWidth(1.5),
|
||||
5: FlexColumnWidth(1.2),
|
||||
6: FlexColumnWidth(1.2),
|
||||
7: FlexColumnWidth(1.2),
|
||||
3: FlexColumnWidth(1.3),
|
||||
4: FlexColumnWidth(1.3),
|
||||
5: FlexColumnWidth(1.3),
|
||||
6: FlexColumnWidth(1.3),
|
||||
7: FlexColumnWidth(1.0),
|
||||
8: FlexColumnWidth(1.1),
|
||||
9: FlexColumnWidth(1.1),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
||||
children: ['序号', '商品编码', '名称', '系列', '规格', '数量', '单价', '金额']
|
||||
children: ['序号', '商品编码', '名称', '系列', '规格', '生产日期', '批次号', '数量', '单价', '金额']
|
||||
.map((h) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(h,
|
||||
@@ -1035,6 +1063,12 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
|
||||
_TableCell(item.productName ?? '-'),
|
||||
_TableCell(item.productSeries ?? '-'),
|
||||
_TableCell(item.productSpec ?? '-'),
|
||||
_TableCell((item.productionDate != null &&
|
||||
item.productionDate!.length >= 10)
|
||||
? item.productionDate!.substring(0, 10)
|
||||
: (item.productionDate ?? '-')),
|
||||
_TableCell(
|
||||
(item.batchNo?.isNotEmpty ?? false) ? item.batchNo! : '-'),
|
||||
_TableCell(item.quantity.toStringAsFixed(3)),
|
||||
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
|
||||
_TableCell('¥${item.totalPrice.toStringAsFixed(2)}'),
|
||||
|
||||
@@ -35,16 +35,36 @@ List<Widget> buildOrderRowActions({
|
||||
onPressed: onPressed,
|
||||
child: Text(text, style: TextStyle(fontSize: 12, color: color)),
|
||||
);
|
||||
// 实心强调按钮(用于「退单」这类需突出的危险操作,与原型一致)。
|
||||
Widget filledBtn(String text, Color color, VoidCallback onPressed, {Key? key}) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: ElevatedButton(
|
||||
key: key,
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
visualDensity: VisualDensity.compact,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
minimumSize: const Size(0, 30),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
child: Text(text),
|
||||
),
|
||||
);
|
||||
return [
|
||||
btn('详情', AppTheme.primary, onDetail),
|
||||
btn('打印', AppTheme.primary, onPrint),
|
||||
...afterPrint,
|
||||
if (!readonly && status == 'approved')
|
||||
WriteGuard(child: btn('结清', AppTheme.accent, onSettle)),
|
||||
// 退单(已审核单退货):error 样式,按权限显示
|
||||
// 退单(已审核单退货):红色实心按钮突出(与原型一致),按权限显示
|
||||
if (!readonly && status == 'approved' && canReturn)
|
||||
WriteGuard(
|
||||
child: btn('退单', AppTheme.danger, onReturn,
|
||||
child: filledBtn('退单', AppTheme.danger, onReturn,
|
||||
key: Key('btn_return_$orderId'))),
|
||||
if (!readonly && status == 'draft') ...[
|
||||
WriteGuard(child: btn('修改', AppTheme.primary, onEdit)),
|
||||
|
||||
Reference in New Issue
Block a user