Compare commits

..

14 Commits

Author SHA1 Message Date
wangjia 2fac1aff66 chore: release client-v1.0.73
Deploy Client / build-client-web (push) Successful in 40s
Deploy Client / build-windows (push) Successful in 2m7s
Deploy Client / build-macos (push) Successful in 2m11s
Deploy Client / build-android (push) Successful in 1m8s
Deploy Client / build-ios (push) Successful in 2m48s
Deploy Client / release-deploy-client (push) Successful in 1m26s
出库选货弹窗改为分页加载(默认20条+库存总数+翻页)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-22 14:26:23 +08:00
wangjia 6b0b2645ce chore: release client-v1.0.72
Deploy Client / build-client-web (push) Successful in 41s
Deploy Client / build-windows (push) Successful in 2m34s
Deploy Client / build-macos (push) Successful in 2m17s
Deploy Client / build-android (push) Successful in 1m26s
Deploy Client / build-ios (push) Successful in 2m42s
Deploy Client / release-deploy-client (push) Successful in 1m35s
出库售价可编辑列(应收按售价) + 退单按钮红色实心 + 库存列表隐藏数量列

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-22 13:02:29 +08:00
wangjia 3641f1cf16 chore: release server-v1.0.75
Deploy Server / release-deploy-server (push) Successful in 1m7s
出库售价(应收按售价) + 库存选择器分页上限修复 + seed 改为序列号模型

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-22 13:01:25 +08:00
wangjia 6c43800354 fix(client): 退单按钮改红色实心样式,与原型一致突出危险操作
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 22:51:40 +08:00
wangjia 51acee2705 chore: release client-v1.0.71
Deploy Client / build-client-web (push) Successful in 47s
Deploy Client / build-macos (push) Successful in 2m11s
Deploy Client / build-android (push) Successful in 1m11s
Deploy Client / build-ios (push) Successful in 2m29s
Deploy Client / build-windows (push) Successful in 2m27s
Deploy Client / release-deploy-client (push) Successful in 1m27s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 22:17:50 +08:00
wangjia 20f9e3a410 chore: release server-v1.0.74
Deploy Server / release-deploy-server (push) Successful in 2m4s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 22:17:24 +08:00
wangjia 2e941fdb5f docs: 退单功能交互原型(order-return-prototype.html)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 22:17:02 +08:00
wangjia e36de528b0 fix(client): 数据导入页新增入库单/出库单导入槽
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 21:25:01 +08:00
wangjia 6faadf8670 fix(backend): 库存导入按商品编号匹配不再按名称合并 + 新增 fix-inventory-products 修复工具
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 21:24:53 +08:00
wangjia dcb1d7c44c docs: 更新项目总览(product=序列号模型/API/三条发版线) + CLAUDE.md 数据模型铁律
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 21:24:41 +08:00
wangjia 9d5bc18de1 chore: release client-v1.0.70
Deploy Client / build-client-web (push) Successful in 38s
Deploy Client / build-macos (push) Successful in 2m10s
Deploy Client / build-android (push) Successful in 56s
Deploy Client / build-ios (push) Successful in 2m35s
Deploy Client / build-windows (push) Successful in 1m49s
Deploy Client / release-deploy-client (push) Successful in 1m23s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 19:46:49 +08:00
wangjia 13a6777a85 chore: release server-v1.0.73
Deploy Server / release-deploy-server (push) Successful in 56s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 19:46:30 +08:00
wangjia 381588826c chore: release client-v1.0.69
Deploy Client / build-client-web (push) Successful in 40s
Deploy Client / build-macos (push) Successful in 2m13s
Deploy Client / build-android (push) Successful in 1m6s
Deploy Client / build-windows (push) Successful in 1m56s
Deploy Client / build-ios (push) Successful in 2m41s
Deploy Client / release-deploy-client (push) Successful in 1m24s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 19:09:47 +08:00
wangjia 7a78448a25 chore: release server-v1.0.72
Deploy Server / release-deploy-server (push) Successful in 56s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 19:09:30 +08:00
37 changed files with 2699 additions and 461 deletions
+39
View File
@@ -5,6 +5,45 @@ 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.73] - 2026-06-22
### 改进
- 出库「添加商品」选货弹窗改为分页加载:默认显示前 20 条,底部显示库存总数并支持上一页/下一页翻页;弹窗打开即加载、按编码/名称/系列搜索时即时分页,避免一次拉取过多导致加载缓慢或看不到商品
## [1.0.72] - 2026-06-22
### 新功能
- 出库单录入新增「售价」可编辑列:可按实际销售价录入,金额与单据应收按「售价 × 数量」实时计算,同时保留成本价一栏便于对比
### 改进
- 「退单」按钮改为红色实心样式,更醒目地突出该危险操作(与原型一致)
- 库存列表隐藏「库存量」列:序列号模型下每件商品独立,原数量列易受历史数据误导,故不再展示(缺货/预警逻辑不受影响)
- 出库「添加商品」可一次看到整仓商品(需配合后端 v1.0.75)
## [1.0.71] - 2026-06-21
### 新功能
- 已审核单据「退单」:入库/出库列表点红色「退单」→ 弹出退单明细窗,可逐行退或一键「全部退单」,确认框显示名称/系列/规格;已退明细以红色「已退单」醒目标注,列表状态旁显示「部分退单/已退单」徽章
- 数据导入页新增「入库单/出库单」导入(老系统单据打印格式,每个文件一张单)
## [1.0.70] - 2026-06-21
### 修复
- 修复超级管理员看不到「撤回」按钮的问题(此前仅 admin 角色显示,superadmin 被漏掉)
- 入库单批次号改为必填项,未填写会拦截提交
### 改进
- 「撤回」按钮:操作员现在也能撤回本人提交的审核中单据(管理员/超管仍可撤回任意单据);按钮改用警示(amber)样式
- 新建入库单:生产日期列加宽,完整日期(如 2024-05-12)不再显示不全
## [1.0.69] - 2026-06-21
### 新功能
- 入库/出库审核列表:管理员可「撤回」审核中的单据,单据回到草稿,修改后重新提交审核
### 改进
- 新建入库单:批次号移到「生产日期」右侧、与各列对齐填写;生产日期输入框收窄,不再占用过宽
## [1.0.68] - 2026-06-21
### 改进
+26
View File
@@ -5,6 +5,32 @@
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.75] - 2026-06-22
### 新功能
- 出库单新增「售价」字段:可按实际销售价录入,应收账款改为按「售价 × 数量」计算,更真实地反映销售额(此前应收按成本价计)。
### 修复
- 修复出库单「添加商品」选择器只能看到一小部分商品的问题:库存查询接口的分页上限此前会把超额请求打回,导致选货时只显示约 20 条,现已放宽,可一次加载整仓商品。
## [1.0.74] - 2026-06-21
### 新功能
- 已审核的入库单/出库单支持「退单」:可整单退或按明细行退货。出库单退单的商品数量加回库存、入库单退单对应库存从库存删除,并自动冲减对应的应付/应收账款。权限:入库退单限管理员/超管;出库退单管理员/超管或本人。已被出库消耗的入库明细禁止退单。
### 修复
- 库存导入改为按商品编号匹配商品、不再按名称合并,避免再次导入产生重复编号
## [1.0.73] - 2026-06-21
### 改进
- 撤回审核中单据的权限放宽:管理员/超管可撤回任意单据,普通操作员可撤回本人提交的单据(此前仅管理员)
## [1.0.72] - 2026-06-21
### 新功能
- 审核中(待审核)的入库单/出库单,管理员可「撤回」为草稿,修改后重新提交审核;已审核单据仍只读不可撤回
## [1.0.71] - 2026-06-21
### 改进
+7
View File
@@ -206,11 +206,18 @@ cd client && flutter test
- **绝不**从请求参数、URL 或请求体中读取 `shop_id`
- 所有数据库查询必须带 `WHERE shop_id = ?` 条件
### 数据模型:product = 特有产品/序列号(核心铁律)
- `products` 每行 = 一个**特有产品/序列号**(`code`=商品编号,同店唯一 `uk_shop_code`),**不是 SKU**;语义:`name`=品牌、`series`=型号、`spec`=版本。product 是商品信息的**单一来源**(序列号/品牌型号版本/生产日期 `production_date`/批次 `batch_no`/进价/图片/`public_id`)。
- **入库每录一行 = 新建一个独立 product**`createIndependentProduct` + `nextProductCode` max+1),**绝不**按名称/系列/规格复用(`findOrCreate` 复用已废弃)。同理任何"导入/建库存"逻辑:有商品编号时按编号匹配/建 product,**禁止**按名称合并(历史 bug 根因)。
- 基础数据字典(品牌/型号/版本 + 产地/保质期/储存/介绍)在「基础数据」页管理,入库选文本后据此建 product。
- 库存(`inventories`)/出入库明细 = 指向 product 的引用 + **快照列**`product_code/name/series/spec/...`,导入/审核时拷贝,作历史保真+显示兜底)。显示优先 product、回退快照(`COALESCE(p.*, 快照)` / 前端 `lineOrProduct`)。**快照列保留,勿擅自删**。
### 库存变更
- 入库/出库审核通过时,必须在同一事务中同时完成:
1. 更新 `inventories` 表数量
2. 写入 `inventory_logs` 流水记录
- 出库前必须校验库存充足,不足时返回错误并回滚
- 审核中(pending)单据支持**撤回**(`withdraw`)回 draft:管理员/超管任意单、操作员限本人单(`OperatorID==本人`handler 内判权,非 AdminOnly 中间件)
### Schema 管理
- 表结构变更:修改 `backend/schema/schema.sql` + `backend/internal/model/` 对应 model
+197
View File
@@ -0,0 +1,197 @@
// fix-inventory-products —— 一次性修复「历史库存导入把不同编号合并到同一 product」的数据。
//
// 背景:旧的 ImportInventory 用「名称|系列|规格」匹配 product,忽略商品编号,
// 导致很多本应各自独立(不同 ZXZ 序列号)的库存行共用了同一个 product,屏幕上
// 显示成重复编号。每行真实的商品编号 + 名称/系列/规格/单位/生产日期/批次/进价
// 都完整保存在该库存行自身的快照列里(inventories.product_code 等),只是外键
// product_id 指错了。
//
// 修复(就地、不删数据、幂等):遍历目标门店活跃库存行,对每行:
// 1. 按 (shop_id, code=inv.product_code) 找 product
// 2. 找不到就用该行快照新建一个独立 product(保留原始编号);
// 3. 把 inv.product_id 重指到它。
//
// 用法(在 backend/ 目录下):
//
// go run ./cmd/fix-inventory-products --shop-id 8 --dry-run
// go run ./cmd/fix-inventory-products --shop-id 8
// # 远端:交叉编译后 scp--env-file 读 production.env 的 DATABASE_DSN
package main
import (
"flag"
"log"
"os"
"strings"
"github.com/google/uuid"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
func main() {
var (
shopID = flag.Uint64("shop-id", 0, "目标门店 id")
dryRun = flag.Bool("dry-run", false, "只统计不写库")
envFile = flag.String("env-file", "", "可选:systemd 风格 KEY=VALUE 环境文件(远端读 DATABASE_DSN")
)
flag.Parse()
if *envFile != "" {
loadEnvFile(*envFile)
}
config.Load()
db, err := gorm.Open(mysql.Open(config.C.Database.DSN), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("连接数据库失败: %v", err)
}
if *shopID == 0 {
log.Fatal("必须提供 --shop-id")
}
var shop model.Shop
if err := db.First(&shop, *shopID).Error; err != nil {
log.Fatalf("门店 id=%d 不存在: %v", *shopID, err)
}
log.Printf("目标门店: %s (code=%s, id=%d) dry-run=%v", shop.Name, shop.Code, shop.ID, *dryRun)
if err := run(db, shop.ID, *dryRun); err != nil {
log.Fatalf("修复失败: %v", err)
}
}
// run 执行修复(导出供测试)。
func run(db *gorm.DB, shopID uint64, dryRun bool) error {
// 预加载该店所有未删 product,建 code→product 索引(避免逐行查询)。
var products []model.Product
if err := db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&products).Error; err != nil {
return err
}
byCode := make(map[string]*model.Product, len(products))
for i := range products {
p := &products[i]
if p.Code != "" {
byCode[p.Code] = p
}
}
// 遍历活跃库存行(按 id 升序,稳定可复现)。
var invs []model.Inventory
if err := db.Where("shop_id = ? AND deleted_at IS NULL", shopID).
Order("id ASC").Find(&invs).Error; err != nil {
return err
}
var (
created int // 新建的 product 数
repointed int // 重指 product_id 的库存行数
skipped int // 已正确(product_id 已指向同编号 product
noCode int // 快照无编号,跳过(理论上为 0
)
for i := range invs {
inv := &invs[i]
code := strings.TrimSpace(inv.ProductCode)
if code == "" {
noCode++
continue
}
// 1. 找/建该编号的 product
prod := byCode[code]
if prod == nil {
np := buildProductFromInv(shopID, inv)
if dryRun {
// dry-run 不写库,但要在缓存里占位,避免同编号重复计入 created
np.Code = code
byCode[code] = &np
created++
} else {
if err := db.Create(&np).Error; err != nil {
return err
}
byCode[code] = &np
created++
}
prod = byCode[code]
}
// 2. 重指 product_id(已正确则跳过)
if inv.ProductID != nil && *inv.ProductID == prod.ID {
skipped++
continue
}
repointed++
if !dryRun {
if err := db.Model(&model.Inventory{}).
Where("id = ?", inv.ID).
Update("product_id", prod.ID).Error; err != nil {
return err
}
}
}
log.Printf("库存行总数=%d | 新建 product=%d | 重指 product_id=%d | 已正确跳过=%d | 无编号跳过=%d",
len(invs), created, repointed, skipped, noCode)
if dryRun {
log.Printf("[dry-run] 未写库")
}
return nil
}
// buildProductFromInv 用库存行快照构造一个独立 product(保留原始编号)。
func buildProductFromInv(shopID uint64, inv *model.Inventory) model.Product {
full, initials := util.ToPinyin(inv.ProductName)
p := model.Product{
TenantBase: model.TenantBase{ShopID: shopID},
PublicID: uuid.New().String(),
Code: inv.ProductCode,
Name: inv.ProductName,
Series: inv.Series,
Spec: inv.Spec,
Unit: inv.Unit,
BatchNo: inv.BatchNo,
NamePinyin: full,
NameInitials: initials,
}
if inv.ProductionDate != nil {
d := *inv.ProductionDate
p.ProductionDate = &d
}
if inv.UnitPrice != nil {
p.PurchasePrice = *inv.UnitPrice
}
return p
}
// loadEnvFile 解析 systemd EnvironmentFile 风格 KEY=VALUE,值原样保留(不做 shell 解引号),
// 安全处理 DSN 中的 & ( ) ? 等特殊字符。config.Load() 随后经 AutomaticEnv 读 DATABASE_DSN。
func loadEnvFile(path string) {
data, err := os.ReadFile(path)
if err != nil {
log.Fatalf("读取 env 文件 %s 失败: %v", path, err)
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
eq := strings.IndexByte(line, '=')
if eq <= 0 {
continue
}
key := strings.TrimSpace(line[:eq])
val := strings.TrimSpace(line[eq+1:])
if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0] {
val = val[1 : len(val)-1]
}
_ = os.Setenv(key, val)
}
}
@@ -0,0 +1,101 @@
package main
import (
"testing"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/testutil"
)
// 模拟 bug:两条不同编号(ZXZ001/ZXZ002)、同名同系列同规格的库存行,
// 因旧导入按「名称|系列|规格」合并,都错指向同一个 product(ZXZ001)。
// 修复后:每行应指向「编号与自己一致」的独立 product。
func TestRun_RebuildsPerCodeProduct(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "FIX001")
// 已有一个 product,编号 ZXZ001(合并目标)
wrong := model.Product{
TenantBase: model.TenantBase{ShopID: shop.ID},
Code: "ZXZ001", Name: "茅台", Series: "飞天", Spec: "500ml", Unit: "瓶",
}
if err := db.Create(&wrong).Error; err != nil {
t.Fatalf("建 product 失败: %v", err)
}
wrongID := wrong.ID
// 两条库存行:各自带真实编号,但都错指 wrong
price := 1314.0
mk := func(code string) {
if err := db.Create(&model.Inventory{
ShopID: shop.ID, ProductID: &wrongID, Quantity: 1,
ProductCode: code, ProductName: "茅台", Series: "飞天", Spec: "500ml",
Unit: "瓶", BatchNo: "B" + code, UnitPrice: &price,
}).Error; err != nil {
t.Fatalf("建库存失败: %v", err)
}
}
mk("ZXZ001")
mk("ZXZ002")
if err := run(db, shop.ID, false); err != nil {
t.Fatalf("run 失败: %v", err)
}
// 每行的 product.code 应等于该行快照编号
var invs []model.Inventory
db.Where("shop_id = ?", shop.ID).Order("id ASC").Find(&invs)
if len(invs) != 2 {
t.Fatalf("库存行数=%d 应为 2", len(invs))
}
seen := map[uint64]bool{}
for _, inv := range invs {
if inv.ProductID == nil {
t.Fatalf("行 %d product_id 为空", inv.ID)
}
var p model.Product
if err := db.First(&p, *inv.ProductID).Error; err != nil {
t.Fatalf("查 product 失败: %v", err)
}
if p.Code != inv.ProductCode {
t.Errorf("行 %d: product.code=%q 应=%q", inv.ID, p.Code, inv.ProductCode)
}
// 新建的 product 应带上快照信息 + 拼音
if p.Code == "ZXZ002" {
if p.BatchNo != "BZXZ002" || p.PurchasePrice != price || p.NamePinyin == "" {
t.Errorf("新建 product 信息不全: %+v", p)
}
}
seen[*inv.ProductID] = true
}
if len(seen) != 2 {
t.Errorf("两行应指向 2 个不同 product,实际 %d", len(seen))
}
}
// dry-run 不写库
func TestRun_DryRunNoWrite(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "FIX002")
wrong := model.Product{
TenantBase: model.TenantBase{ShopID: shop.ID},
Code: "ZXZ001", Name: "茅台", Series: "飞天", Spec: "500ml",
}
db.Create(&wrong)
wrongID := wrong.ID
db.Create(&model.Inventory{
ShopID: shop.ID, ProductID: &wrongID, Quantity: 1,
ProductCode: "ZXZ999", ProductName: "茅台", Series: "飞天", Spec: "500ml",
})
var before int64
db.Model(&model.Product{}).Where("shop_id = ?", shop.ID).Count(&before)
if err := run(db, shop.ID, true); err != nil {
t.Fatalf("dry-run 失败: %v", err)
}
var after int64
db.Model(&model.Product{}).Where("shop_id = ?", shop.ID).Count(&after)
if before != after {
t.Errorf("dry-run 不应建 productbefore=%d after=%d", before, after)
}
}
+381 -310
View File
@@ -5,6 +5,12 @@
// go run cmd/seed/main.go # 写入数据(已存在则跳过)
// go run cmd/seed/main.go --reset # 删表重建 + 写入数据
// go run cmd/seed/main.go --clear # 清空业务数据 + 重新写入(保留表结构)
//
// 数据遵循「序列号模型」规范(见 CLAUDE.md 数据模型铁律):
// - 入库每录一行 = 新建一个独立 product(唯一商品编号),绝不按名称复用;
// - 入库/出库明细、库存均带快照列(product_code/name/series/spec…);
// - 审核生成库存时关联 stock_in_item_id、拷贝 unit_price/批次/生产日期快照;
// - 出库记录 sale_price(售价),应收(total_amount)按 售价×数量。
package main
import (
@@ -13,6 +19,7 @@ import (
"os"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/mysql"
"gorm.io/gorm"
@@ -20,6 +27,7 @@ import (
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
var allModels = []any{
@@ -111,6 +119,7 @@ func main() {
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
d := func(daysAgo int) model.Date { return model.Date{Time: today.AddDate(0, 0, -daysAgo)} }
dptr := func(daysAgo int) *model.Date { dd := model.Date{Time: today.AddDate(0, 0, -daysAgo)}; return &dd }
ptrT := func(t time.Time) *time.Time { return &t }
// ═══════════════════════════════════════════════════
@@ -180,41 +189,8 @@ func main() {
}).(model.ProductCategory)
// ═══════════════════════════════════════════════════
// 商品
// ═══════════════════════════════════════════════════
type prodSeed struct {
code, barcode, name, series, spec, unit, brand string
catID *uint64
purchase, sale float64
minStock int
remark string
}
prodSeeds := []prodSeed{
{"MT-001", "6901234567890", "飞天茅台 53度", "茅台", "500ml/瓶", "瓶", "贵州茅台", &catBaijiu.ID, 2350, 2800, 10, "酱香型白酒,53度,飞天系列"},
{"WLY-001", "6902345678901", "五粮液 52度", "五粮液", "500ml/瓶", "瓶", "宜宾五粮液", &catBaijiu.ID, 950, 1200, 6, "浓香型白酒,52度,普五系列"},
{"YH-001", "6903456789012", "洋河梦之蓝 M6", "洋河", "500ml/瓶", "瓶", "江苏洋河", &catBaijiu.ID, 560, 680, 6, "浓香型,绵柔苏酒代表"},
{"LZ-001", "6904567890123", "泸州老窖 特曲", "泸州老窖", "500ml/瓶", "瓶", "泸州老窖", &catBaijiu.ID, 420, 520, 6, "浓香鼻祖,特曲系列"},
{"JNC-001", "6905678901234", "剑南春 水晶剑", "剑南春", "500ml/瓶", "瓶", "剑南春", &catBaijiu.ID, 390, 480, 6, "浓香型,绵竹名酒"},
{"LJ-001", "6906789012345", "郎酒 红花郎10", "郎酒", "500ml/瓶", "瓶", "古蔺郎酒", &catBaijiu.ID, 320, 420, 6, "酱香型,赤水河畔酿造"},
{"LF-001", "3760093550058", "拉菲古堡 2018", "波尔多", "750ml/瓶", "瓶", "Château Lafite", &catImport.ID, 3800, 5200, 3, "波尔多一级名庄,2018年份"},
{"RTM-001", "3021691010008", "人头马 VSOP", "人头马", "700ml/瓶", "瓶", "Rémy Martin", &catImport.ID, 480, 680, 3, "法国干邑,VSOP级别"},
}
var prods []model.Product
for _, s := range prodSeeds {
p := upsert(db, &model.Product{}, "shop_id = ? AND code = ?", shop.ID, s.code, func() any {
return &model.Product{
TenantBase: model.TenantBase{ShopID: shop.ID},
Code: s.code, Barcode: s.barcode, Name: s.name,
Series: s.series, Spec: s.spec, Unit: s.unit, Brand: s.brand,
CategoryID: s.catID, PurchasePrice: s.purchase, SalePrice: s.sale,
MinStock: s.minStock, Remark: s.remark,
}
}).(model.Product)
prods = append(prods, p)
}
// ═══════════════════════════════════════════════════
// 商品名称/系列/规格选项
// 商品基础数据字典(品牌/型号/版本 = 名称/系列/规格选项)
// 序列号模型下:product 由入库逐行生成,字典只是录入时的可选项来源。
// ═══════════════════════════════════════════════════
type dimSeed struct{ code, name string }
@@ -243,7 +219,10 @@ func main() {
}
}
for _, s := range []struct{ code, name string; quantity int }{
for _, s := range []struct {
code, name string
quantity int
}{
{"GG001", "500ml/瓶", 1}, {"GG002", "750ml/瓶", 1}, {"GG003", "700ml/瓶", 1},
} {
var opt model.ProductSpecOption
@@ -259,6 +238,22 @@ func main() {
}
}
// 商品模板:仅作为入库逐行生成 product 的来源,本身不直接落 products 表。
cat := func(c *model.ProductCategory) *uint64 { id := c.ID; return &id }
catalog := []goods{
{"飞天茅台 53度", "茅台", "500ml/瓶", "瓶", "贵州茅台", cat(&catBaijiu), 2350, 2800, 10},
{"五粮液 52度", "五粮液", "500ml/瓶", "瓶", "宜宾五粮液", cat(&catBaijiu), 950, 1200, 6},
{"洋河梦之蓝 M6", "洋河", "500ml/瓶", "瓶", "江苏洋河", cat(&catBaijiu), 560, 680, 6},
{"泸州老窖 特曲", "泸州老窖", "500ml/瓶", "瓶", "泸州老窖", cat(&catBaijiu), 420, 520, 6},
{"剑南春 水晶剑", "剑南春", "500ml/瓶", "瓶", "剑南春", cat(&catBaijiu), 390, 480, 6},
{"郎酒 红花郎10", "郎酒", "500ml/瓶", "瓶", "古蔺郎酒", cat(&catBaijiu), 320, 420, 6},
{"拉菲古堡 2018", "波尔多", "750ml/瓶", "瓶", "Château Lafite", cat(&catImport), 3800, 5200, 3},
{"人头马 VSOP", "人头马", "700ml/瓶", "瓶", "Rémy Martin", cat(&catImport), 480, 680, 3},
}
// 商品编码顺序生成器(序列号:每入库一行发一个唯一编号 P0001、P0002…)
codeSeq := 0
nextCode := func() string { codeSeq++; return fmt.Sprintf("P%04d", codeSeq) }
// ═══════════════════════════════════════════════════
// 往来单位
// ═══════════════════════════════════════════════════
@@ -317,110 +312,100 @@ func main() {
upsertNumberRule(db, shop.ID, "stock_out", "CK", 3)
upsertNumberRule(db, shop.ID, "inventory_check", "PD", 1)
mk := func(db *gorm.DB) *seeder {
return &seeder{db: db, shopID: shop.ID, catalog: catalog, nextCode: nextCode, supplier: partners}
}
sd := mk(db)
// ═══════════════════════════════════════════════════
// 入库单
// 入库单(每行新建独立 product + 快照)
// ═══════════════════════════════════════════════════
// #1 已审核:茅台+五粮液,主仓库,茅台供应商
in1 := createStockInOrder(db, shop.ID, wh1.ID, admin.ID, partners["SUP001"].ID,
"RK20260401001", d(6), "approved", "purchase",
"茅台系列首批入库,含飞天茅台及五粮液",
[]inItemSeed{
{prods[0].ID, 120, 2350, "BATCH-MT-2024001", "飞天茅台 2024年第一批次"},
{prods[1].ID, 60, 950, "BATCH-WLY-2024001", "五粮液普五 2024年批次"},
in1 := sd.createStockIn(wh1.ID, wh1.Name, admin.ID, partners["SUP001"].ID,
"RK20260401001", d(6), "approved", "purchase", "茅台系列首批入库,含飞天茅台及五粮液",
[]inLine{
{0, 120, 2350, "BATCH-MT-2024001", dptr(40), "飞天茅台 2024年第一批次"},
{1, 60, 950, "BATCH-WLY-2024001", dptr(50), "五粮液普五 2024年批次"},
}, admin.ID, ptrT(today.AddDate(0, 0, -5)))
// #2 已审核:洋河+泸州+剑南春,主仓库,洋河供应商
in2 := createStockInOrder(db, shop.ID, wh1.ID, operator.ID, partners["SUP002"].ID,
"RK20260403001", d(4), "approved", "purchase",
"浓香型白酒补货入库",
[]inItemSeed{
{prods[2].ID, 48, 560, "BATCH-YH-2024003", "洋河梦之蓝 M6 3月批次"},
{prods[3].ID, 36, 420, "BATCH-LZ-2024002", "泸州老窖特曲 春季批次"},
{prods[4].ID, 24, 390, "BATCH-JNC-2024001", "剑南春水晶剑 首批"},
in2 := sd.createStockIn(wh1.ID, wh1.Name, operator.ID, partners["SUP002"].ID,
"RK20260403001", d(4), "approved", "purchase", "浓香型白酒补货入库",
[]inLine{
{2, 48, 560, "BATCH-YH-2024003", dptr(60), "洋河梦之蓝 M6 3月批次"},
{3, 36, 420, "BATCH-LZ-2024002", dptr(70), "泸州老窖特曲 春季批次"},
{4, 24, 390, "BATCH-JNC-2024001", dptr(45), "剑南春水晶剑 首批"},
}, admin.ID, ptrT(today.AddDate(0, 0, -3)))
// #3 已审核:进口酒,进口酒专库,茅台供应商(代理进口)
in3 := createStockInOrder(db, shop.ID, wh2.ID, operator.ID, partners["SUP004"].ID,
"RK20260404001", d(3), "approved", "purchase",
"进口烈酒专库入库,含拉菲及人头马",
[]inItemSeed{
{prods[6].ID, 24, 3800, "BATCH-LF-2018001", "拉菲古堡2018,原箱"},
{prods[7].ID, 36, 480, "BATCH-RTM-2024001", "人头马VSOP2024年进口"},
// #3 已审核:进口酒,进口酒专库,郎酒供应商(代理进口)
in3 := sd.createStockIn(wh2.ID, wh2.Name, operator.ID, partners["SUP004"].ID,
"RK20260404001", d(3), "approved", "purchase", "进口烈酒专库入库,含拉菲及人头马",
[]inLine{
{6, 24, 3800, "BATCH-LF-2018001", dptr(400), "拉菲古堡2018,原箱"},
{7, 36, 480, "BATCH-RTM-2024001", dptr(90), "人头马VSOP2024年进口"},
}, admin.ID, ptrT(today.AddDate(0, 0, -2)))
// #4 待审核:郎酒补货
in4 := createStockInOrder(db, shop.ID, wh1.ID, operator.ID, partners["SUP004"].ID,
"RK20260406001", d(1), "pending", "purchase",
"郎酒红花郎补货,待仓库管理员审核",
[]inItemSeed{
{prods[5].ID, 60, 320, "BATCH-LJ-2024002", "郎酒红花郎10 第二批次"},
// #4 待审核:郎酒补货(仅建单+建 product,不生成库存)
in4 := sd.createStockIn(wh1.ID, wh1.Name, operator.ID, partners["SUP004"].ID,
"RK20260406001", d(1), "pending", "purchase", "郎酒红花郎补货,待仓库管理员审核",
[]inLine{
{5, 60, 320, "BATCH-LJ-2024002", dptr(30), "郎酒红花郎10 第二批次"},
}, 0, nil)
// #5 草稿:追加茅台
in5 := createStockInOrder(db, shop.ID, wh1.ID, operator.ID, partners["SUP001"].ID,
"RK20260407001", d(0), "draft", "purchase",
"茅台追加订货,草稿中",
[]inItemSeed{
{prods[0].ID, 60, 2380, "BATCH-MT-2024002", "飞天茅台 第二批次"},
sd.createStockIn(wh1.ID, wh1.Name, operator.ID, partners["SUP001"].ID,
"RK20260407001", d(0), "draft", "purchase", "茅台追加订货,草稿中",
[]inLine{
{0, 60, 2380, "BATCH-MT-2024002", dptr(20), "飞天茅台 第二批次"},
}, 0, nil)
_ = in4
_ = in5
// 根据已审核入库单更新库存
updateInventoryBatch(db, shop.ID, wh1.ID, admin.ID, in1)
updateInventoryBatch(db, shop.ID, wh1.ID, admin.ID, in2)
updateInventoryBatch(db, shop.ID, wh2.ID, admin.ID, in3)
// 已审核入库单 → 生成库存(带快照 + 关联 stock_in_item_id
sd.applyStockInToInventory(in1)
sd.applyStockInToInventory(in2)
sd.applyStockInToInventory(in3)
// ═══════════════════════════════════════════════════
// 出库单
// 出库单(引用入库已建 product,记 sale_price,应收按售价)
// ═══════════════════════════════════════════════════
// #1 已审核:北京君悦大酒店,主仓库
out1 := createStockOutOrder(db, shop.ID, wh1.ID, operator.ID, partners["CUS001"].ID,
"CK20260404001", d(3), "approved", "sale",
"北京君悦大酒店 4月份定期供货",
[]outItemSeed{
{prods[0].ID, 12, 2800, "飞天茅台 12瓶,整箱出库"},
{prods[1].ID, 6, 1200, "五粮液普五 6瓶"},
{prods[2].ID, 12, 680, "洋河梦之蓝 M6 12瓶"},
out1 := sd.createStockOut(wh1.ID, operator.ID, partners["CUS001"].ID,
"CK20260404001", d(3), "approved", "sale", "北京君悦大酒店 4月份定期供货",
[]outLine{
{in1.products[0], 12, 2800, "飞天茅台 12瓶,整箱出库"},
{in1.products[1], 6, 1200, "五粮液普五 6瓶"},
{in2.products[0], 12, 680, "洋河梦之蓝 M6 12瓶"},
}, admin.ID, ptrT(today.AddDate(0, 0, -2)))
// #2 已审核:上海外滩华尔道夫,进口酒专库
out2 := createStockOutOrder(db, shop.ID, wh2.ID, operator.ID, partners["CUS002"].ID,
"CK20260405001", d(2), "approved", "sale",
"上海外滩华尔道夫 进口酒专属采购",
[]outItemSeed{
{prods[6].ID, 6, 5200, "拉菲古堡2018 6瓶"},
{prods[7].ID, 12, 680, "人头马VSOP 12瓶"},
out2 := sd.createStockOut(wh2.ID, operator.ID, partners["CUS002"].ID,
"CK20260405001", d(2), "approved", "sale", "上海外滩华尔道夫 进口酒专属采购",
[]outLine{
{in3.products[0], 6, 5200, "拉菲古堡2018 6瓶"},
{in3.products[1], 12, 680, "人头马VSOP 12瓶"},
}, admin.ID, ptrT(today.AddDate(0, 0, -1)))
// #3 待审核:广州白天鹅宾馆
out3 := createStockOutOrder(db, shop.ID, wh1.ID, operator.ID, partners["CUS003"].ID,
"CK20260406001", d(1), "pending", "sale",
"广州白天鹅宾馆 月度供货申请",
[]outItemSeed{
{prods[3].ID, 24, 520, "泸州老窖特曲 24瓶"},
{prods[4].ID, 12, 480, "剑南春水晶剑 12瓶"},
{prods[5].ID, 12, 420, "郎酒红花郎10 12瓶"},
sd.createStockOut(wh1.ID, operator.ID, partners["CUS003"].ID,
"CK20260406001", d(1), "pending", "sale", "广州白天鹅宾馆 月度供货申请",
[]outLine{
{in2.products[1], 24, 520, "泸州老窖特曲 24瓶"},
{in2.products[2], 12, 480, "剑南春水晶剑 12瓶"},
{in4.products[0], 12, 420, "郎酒红花郎10 12瓶"},
}, 0, nil)
// #4 草稿:君悦追加
out4 := createStockOutOrder(db, shop.ID, wh1.ID, operator.ID, partners["CUS001"].ID,
"CK20260407001", d(0), "draft", "sale",
"北京君悦追加订单,草稿中",
[]outItemSeed{
{prods[0].ID, 6, 2800, "飞天茅台 追加 6瓶"},
sd.createStockOut(wh1.ID, operator.ID, partners["CUS001"].ID,
"CK20260407001", d(0), "draft", "sale", "北京君悦追加订单,草稿中",
[]outLine{
{in1.products[0], 6, 2800, "飞天茅台 追加 6瓶"},
}, 0, nil)
_ = out3
_ = out4
// 根据已审核出库单减库存
deductInventoryBatch(db, shop.ID, wh1.ID, admin.ID, out1)
deductInventoryBatch(db, shop.ID, wh2.ID, admin.ID, out2)
// 已审核出库单 → 扣减库存(FIFO)
sd.deductStockOut(wh1.ID, admin.ID, out1)
sd.deductStockOut(wh2.ID, admin.ID, out2)
// ═══════════════════════════════════════════════════
// 财务记录(与已审核出库单对应的应收款)
// 财务记录(与已审核出库单对应的应收款,按售价口径
// ═══════════════════════════════════════════════════
createFinanceRecord(db, shop.ID, partners["CUS001"].ID, admin.ID,
"receivable", out1.order.TotalAmount, out1.order.TotalAmount, "stock_out", out1.order.ID,
@@ -446,44 +431,313 @@ func main() {
fmt.Println(" operator / password123 (操作员)")
fmt.Println(" test / password123 (只读)")
fmt.Println()
fmt.Println(" 数据概览:")
fmt.Println(" 数据概览(序列号模型:入库逐行建独立 product)")
fmt.Printf(" 仓库:%s / %s\n", wh1.Name, wh2.Name)
fmt.Printf(" 商品分类:白酒 / 进口烈酒,共 %d 种商品\n", len(prods))
fmt.Printf(" 入库逐行生成 product:%d 个独立商品编号\n", codeSeq)
fmt.Println(" 往来单位:4 供应商 + 3 客户")
fmt.Println(" 入库单:5 张(2已审核 / 1待审核 / 1草稿 / 1进口专库已审核")
fmt.Println(" 出库单:4 张(2已审核 / 1待审核 / 1草稿)")
fmt.Println(" 入库单:5 张(3已审核 / 1待审核 / 1草稿)")
fmt.Println(" 出库单:4 张(2已审核 / 1待审核 / 1草稿),记 sale_price,应收按售价")
fmt.Println(" 财务记录:2 应收 + 1 收款")
fmt.Println("═══════════════════════════════════════════")
}
// ── 辅助类型 ────────────────────────────────────────────
// ── 数据模板 ────────────────────────────────────────────
type inItemSeed struct {
productID uint64
qty float64
price float64
batchNo string
remark string
// goods 商品模板:入库逐行据此 new 独立 product,模板本身不落库。
type goods struct {
name, series, spec, unit, brand string
cat *uint64
purchase, sale float64
minStock int
}
type outItemSeed struct {
productID uint64
qty float64
price float64
// inLine 入库行:g=catalog 下标,qty/price,批次/生产日期/备注。
type inLine struct {
g int
qty, price float64
batch string
prodDate *model.Date
remark string
}
// outLine 出库行:引用入库已建 productsalePrice=售价。
type outLine struct {
product model.Product
qty, sale float64
remark string
}
type stockInResult struct {
order model.StockInOrder
items []inItemSeed
order model.StockInOrder
items []model.StockInItem
products []model.Product
}
type stockOutResult struct {
order model.StockOutOrder
items []outItemSeed
items []model.StockOutItem
}
// ── 核心 upsert ────────────────────────────────────────────
// seeder 收敛公共上下文(db/shop/商品模板/编码序列/供应商)。
type seeder struct {
db *gorm.DB
shopID uint64
catalog []goods
nextCode func() string
supplier map[string]model.Partner
}
// ── 入库单(每行新建独立 product + 写快照) ───────────────
func (s *seeder) createStockIn(
whID uint64, whName string, opID, partnerID uint64,
orderNo string, date model.Date, status, orderType, remark string,
lines []inLine, reviewerID uint64, reviewedAt *time.Time,
) stockInResult {
var o model.StockInOrder
if s.db.Where("shop_id = ? AND order_no = ?", s.shopID, orderNo).First(&o).Error == nil {
fmt.Printf("⏭ 入库单:%s\n", orderNo)
return s.loadStockIn(o)
}
o = model.StockInOrder{
TenantBase: model.TenantBase{ShopID: s.shopID},
OrderNo: orderNo,
Type: orderType,
WarehouseID: whID,
PartnerID: &partnerID,
OperatorID: opID,
Status: status,
OrderDate: date,
Remark: remark,
}
if reviewerID > 0 {
o.ReviewerID = &reviewerID
o.ReviewedAt = reviewedAt
}
s.db.Create(&o)
var (
total float64
items []model.StockInItem
prods []model.Product
)
for _, ln := range lines {
g := s.catalog[ln.g]
code := s.nextCode()
full, initials := util.ToPinyin(g.name)
// 铁律:入库每行 = 新建一个独立 product(唯一编号),不按名称复用
p := model.Product{
TenantBase: model.TenantBase{ShopID: s.shopID},
PublicID: uuid.New().String(),
Code: code,
Name: g.name,
Series: g.series,
Spec: g.spec,
Unit: g.unit,
Brand: g.brand,
CategoryID: g.cat,
PurchasePrice: ln.price,
SalePrice: g.sale,
MinStock: g.minStock,
BatchNo: ln.batch,
ProductionDate: ln.prodDate,
NamePinyin: full,
NameInitials: initials,
}
s.db.Create(&p)
item := model.StockInItem{
OrderID: o.ID,
ShopID: s.shopID,
ProductID: p.ID,
ProductCode: code,
ProductName: g.name,
Series: g.series,
Spec: g.spec,
Quantity: ln.qty,
UnitPrice: ln.price,
TotalPrice: ln.qty * ln.price,
BatchNo: ln.batch,
ProductionDate: ln.prodDate,
Remark: ln.remark,
}
s.db.Create(&item)
total += item.TotalPrice
items = append(items, item)
prods = append(prods, p)
}
s.db.Model(&o).Update("total_amount", total)
o.TotalAmount = total
fmt.Printf("✅ 入库单:%s [%s] 供应商ID=%d 仓库=%s 行数=%d 金额=%.0f\n",
orderNo, status, partnerID, whName, len(lines), total)
return stockInResult{order: o, items: items, products: prods}
}
// loadStockIn 单据已存在时,回读其明细与 product,供出库行引用(保证重复执行可用)。
func (s *seeder) loadStockIn(o model.StockInOrder) stockInResult {
var items []model.StockInItem
s.db.Where("order_id = ?", o.ID).Order("id ASC").Find(&items)
prods := make([]model.Product, 0, len(items))
for _, it := range items {
var p model.Product
s.db.First(&p, it.ProductID)
prods = append(prods, p)
}
return stockInResult{order: o, items: items, products: prods}
}
// applyStockInToInventory 已审核入库单 → 逐明细生成库存行(带快照 + 关联 stock_in_item_id)。
func (s *seeder) applyStockInToInventory(r stockInResult) {
for i := range r.items {
it := r.items[i]
var unit string
if i < len(r.products) {
unit = r.products[i].Unit
}
var qtyBefore float64
s.db.Model(&model.Inventory{}).
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", s.shopID, r.order.WarehouseID, it.ProductID).
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
whIDCopy := r.order.WarehouseID
pidCopy := it.ProductID
itemIDCopy := it.ID
price := it.UnitPrice
inv := model.Inventory{
ShopID: s.shopID,
WarehouseID: &whIDCopy,
ProductID: &pidCopy,
StockInItemID: &itemIDCopy,
Quantity: it.Quantity,
ProductCode: it.ProductCode,
ProductName: it.ProductName,
Series: it.Series,
Spec: it.Spec,
Unit: unit,
UnitPrice: &price,
BatchNo: it.BatchNo,
ProductionDate: it.ProductionDate,
}
s.db.Create(&inv)
opID := r.order.OperatorID
s.db.Create(&model.InventoryLog{
ShopID: s.shopID, WarehouseID: r.order.WarehouseID, ProductID: it.ProductID,
Direction: "in", Quantity: it.Quantity, QtyBefore: qtyBefore, QtyAfter: qtyBefore + it.Quantity,
RefType: "stock_in", RefID: r.order.ID, OperatorID: &opID,
})
}
}
// ── 出库单(记 sale_price,应收按售价) ───────────────────
func (s *seeder) createStockOut(
whID, opID, partnerID uint64,
orderNo string, date model.Date, status, orderType, remark string,
lines []outLine, reviewerID uint64, reviewedAt *time.Time,
) stockOutResult {
var o model.StockOutOrder
if s.db.Where("shop_id = ? AND order_no = ?", s.shopID, orderNo).First(&o).Error == nil {
fmt.Printf("⏭ 出库单:%s\n", orderNo)
var items []model.StockOutItem
s.db.Where("order_id = ?", o.ID).Order("id ASC").Find(&items)
return stockOutResult{order: o, items: items}
}
o = model.StockOutOrder{
TenantBase: model.TenantBase{ShopID: s.shopID},
OrderNo: orderNo,
Type: orderType,
WarehouseID: whID,
PartnerID: &partnerID,
OperatorID: opID,
Status: status,
OrderDate: date,
Remark: remark,
}
if reviewerID > 0 {
o.ReviewerID = &reviewerID
o.ReviewedAt = reviewedAt
}
s.db.Create(&o)
var (
total float64 // 应收 = Σ 售价×数量
items []model.StockOutItem
)
for _, ln := range lines {
p := ln.product
item := model.StockOutItem{
OrderID: o.ID,
ShopID: s.shopID,
ProductID: p.ID,
ProductCode: p.Code,
ProductName: p.Name,
Series: p.Series,
Spec: p.Spec,
Quantity: ln.qty,
UnitPrice: p.PurchasePrice, // 成本单价(快照)
SalePrice: ln.sale, // 售价
TotalPrice: ln.qty * p.PurchasePrice, // 成本小计
BatchNo: p.BatchNo,
Remark: ln.remark,
}
s.db.Create(&item)
total += ln.qty * ln.sale
items = append(items, item)
}
s.db.Model(&o).Update("total_amount", total)
o.TotalAmount = total
fmt.Printf("✅ 出库单:%s [%s] 客户ID=%d 仓库ID=%d 行数=%d 应收=%.0f\n",
orderNo, status, partnerID, whID, len(lines), total)
return stockOutResult{order: o, items: items}
}
// deductStockOut 已审核出库单 → FIFO 扣减库存 + 写流水。
func (s *seeder) deductStockOut(whID, opID uint64, r stockOutResult) {
now := time.Now()
for _, it := range r.items {
var qtyBefore float64
s.db.Model(&model.Inventory{}).
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", s.shopID, whID, it.ProductID).
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
var batches []model.Inventory
s.db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
s.shopID, whID, it.ProductID).
Order("created_at ASC").Find(&batches)
remaining := it.Quantity
for i := range batches {
if remaining <= 0 {
break
}
b := &batches[i]
if b.Quantity <= remaining {
remaining -= b.Quantity
s.db.Model(b).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now})
} else {
s.db.Model(b).Update("quantity", gorm.Expr("quantity - ?", remaining))
remaining = 0
}
}
after := qtyBefore - it.Quantity
if after < 0 {
after = 0
}
s.db.Create(&model.InventoryLog{
ShopID: s.shopID, WarehouseID: whID, ProductID: it.ProductID,
Direction: "out", Quantity: it.Quantity, QtyBefore: qtyBefore, QtyAfter: after,
RefType: "stock_out", RefID: r.order.ID, OperatorID: &opID,
})
}
}
// ── 通用 upsert(主数据) ────────────────────────────────
// upsert 通用:查不到就调 factory 建,返回具体值(interface{}
func upsert(db *gorm.DB, dest any, cond string, args ...any) any {
@@ -534,16 +788,6 @@ func upsert(db *gorm.DB, dest any, cond string, args ...any) any {
}
fmt.Printf("⏭ 分类:%s\n", v.Name)
return *v
case *model.Product:
found = db.Where(cond, queryArgs...).First(v).Error == nil
if !found {
obj := factory().(*model.Product)
db.Create(obj)
fmt.Printf("✅ 商品:%s%s\n", obj.Name, obj.Code)
return *obj
}
fmt.Printf("⏭ 商品:%s\n", v.Name)
return *v
case *model.Partner:
found = db.Where(cond, queryArgs...).First(v).Error == nil
if !found {
@@ -558,179 +802,6 @@ func upsert(db *gorm.DB, dest any, cond string, args ...any) any {
return nil
}
// ── 入库单 ────────────────────────────────────────────
func createStockInOrder(
db *gorm.DB, shopID, whID, opID, partnerID uint64,
orderNo string, date model.Date, status, orderType, remark string,
items []inItemSeed,
reviewerID uint64, reviewedAt *time.Time,
) stockInResult {
var o model.StockInOrder
if db.Where("shop_id = ? AND order_no = ?", shopID, orderNo).First(&o).Error != nil {
o = model.StockInOrder{
TenantBase: model.TenantBase{ShopID: shopID},
OrderNo: orderNo,
Type: orderType,
WarehouseID: whID,
PartnerID: &partnerID,
OperatorID: opID,
Status: status,
OrderDate: date,
Remark: remark,
}
if reviewerID > 0 {
o.ReviewerID = &reviewerID
o.ReviewedAt = reviewedAt
}
db.Create(&o)
var total float64
for _, it := range items {
item := model.StockInItem{
OrderID: o.ID,
ShopID: shopID,
ProductID: it.productID,
Quantity: it.qty,
UnitPrice: it.price,
TotalPrice: it.qty * it.price,
BatchNo: it.batchNo,
Remark: it.remark,
}
db.Create(&item)
total += item.TotalPrice
}
db.Model(&o).Update("total_amount", total)
o.TotalAmount = total
fmt.Printf("✅ 入库单:%s [%s] 供应商ID=%d 仓库ID=%d 金额=%.0f\n",
orderNo, status, partnerID, whID, total)
} else {
fmt.Printf("⏭ 入库单:%s\n", orderNo)
}
return stockInResult{order: o, items: items}
}
// ── 出库单 ────────────────────────────────────────────
func createStockOutOrder(
db *gorm.DB, shopID, whID, opID, partnerID uint64,
orderNo string, date model.Date, status, orderType, remark string,
items []outItemSeed,
reviewerID uint64, reviewedAt *time.Time,
) stockOutResult {
var o model.StockOutOrder
if db.Where("shop_id = ? AND order_no = ?", shopID, orderNo).First(&o).Error != nil {
o = model.StockOutOrder{
TenantBase: model.TenantBase{ShopID: shopID},
OrderNo: orderNo,
Type: orderType,
WarehouseID: whID,
PartnerID: &partnerID,
OperatorID: opID,
Status: status,
OrderDate: date,
Remark: remark,
}
if reviewerID > 0 {
o.ReviewerID = &reviewerID
o.ReviewedAt = reviewedAt
}
db.Create(&o)
var total float64
for _, it := range items {
item := model.StockOutItem{
OrderID: o.ID,
ShopID: shopID,
ProductID: it.productID,
Quantity: it.qty,
UnitPrice: it.price,
TotalPrice: it.qty * it.price,
Remark: it.remark,
}
db.Create(&item)
total += item.TotalPrice
}
db.Model(&o).Update("total_amount", total)
o.TotalAmount = total
fmt.Printf("✅ 出库单:%s [%s] 客户ID=%d 仓库ID=%d 金额=%.0f\n",
orderNo, status, partnerID, whID, total)
} else {
fmt.Printf("⏭ 出库单:%s\n", orderNo)
}
return stockOutResult{order: o, items: items}
}
// ── 库存 ────────────────────────────────────────────
func updateInventoryBatch(db *gorm.DB, shopID, whID, opID uint64, r stockInResult) {
for _, it := range r.items {
var qtyBefore float64
db.Model(&model.Inventory{}).
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", shopID, whID, it.productID).
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
productIDCopy := it.productID
whIDCopy := whID
inv := model.Inventory{
ShopID: shopID,
WarehouseID: &whIDCopy,
ProductID: &productIDCopy,
Quantity: it.qty,
}
db.Create(&inv)
db.Create(&model.InventoryLog{
ShopID: shopID, WarehouseID: whID, ProductID: it.productID,
Direction: "in", Quantity: it.qty, QtyBefore: qtyBefore, QtyAfter: qtyBefore + it.qty,
RefType: "stock_in", RefID: r.order.ID, OperatorID: &opID,
})
}
}
func deductInventoryBatch(db *gorm.DB, shopID, whID, opID uint64, r stockOutResult) {
now := time.Now()
for _, it := range r.items {
var qtyBefore float64
db.Model(&model.Inventory{}).
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", shopID, whID, it.productID).
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
// FIFO deduction
var batches []model.Inventory
db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
shopID, whID, it.productID).
Order("created_at ASC").Find(&batches)
remaining := it.qty
for i := range batches {
if remaining <= 0 {
break
}
b := &batches[i]
if b.Quantity <= remaining {
remaining -= b.Quantity
db.Model(b).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now})
} else {
db.Model(b).Update("quantity", gorm.Expr("quantity - ?", remaining))
remaining = 0
}
}
after := qtyBefore - it.qty
if after < 0 {
after = 0
}
db.Create(&model.InventoryLog{
ShopID: shopID, WarehouseID: whID, ProductID: it.productID,
Direction: "out", Quantity: it.qty, QtyBefore: qtyBefore, QtyAfter: after,
RefType: "stock_out", RefID: r.order.ID, OperatorID: &opID,
})
}
}
// ── 财务记录 ────────────────────────────────────────────
func createFinanceRecord(db *gorm.DB, shopID, partnerID, opID uint64,
+15 -10
View File
@@ -633,12 +633,13 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
res.total++ // 有商品名称的行才计入总数
// 从缓存查商品,找不到才创建
// 从缓存查商品,找不到才创建
// 有商品编号时:只按编号匹配,绝不回退「名称|系列|规格」合并——同名不同编号
// 是不同的特有产品/序列号,回退名称会把它们错并到同一 product(历史 bug 根因)。
var prod *model.Product
if productCode != "" {
prod = productByCode[productCode]
}
if prod == nil {
} else {
prod = productByNSS[productName+"|"+series+"|"+spec]
}
if prod == nil {
@@ -912,14 +913,18 @@ func parseUploadedExcel(c *gin.Context) ([][]string, error) {
func findOrCreateProductFn(db *gorm.DB, shopID uint64, code, name, series, spec string) (model.Product, error) {
var p model.Product
if db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL",
shopID, name, series, spec).First(&p).Error == nil {
// 若现有商品没有编码,补填
if p.Code == "" && code != "" {
db.Model(&p).Update("code", code)
p.Code = code
if code != "" {
// 有编号:按「编号」唯一匹配/创建(编号即特有产品序列号),
// 绝不按「名称|系列|规格」合并——同名不同编号是不同商品。
if db.Where("shop_id = ? AND code = ? AND deleted_at IS NULL", shopID, code).First(&p).Error == nil {
return p, nil
}
} else {
// 无编号:退回「名称|系列|规格」匹配(供 ImportStockIn/Out 等无编号场景)。
if db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL",
shopID, name, series, spec).First(&p).Error == nil {
return p, nil
}
return p, nil
}
p = model.Product{
TenantBase: model.TenantBase{ShopID: shopID},
+5 -1
View File
@@ -54,9 +54,13 @@ func (h *InventoryHandler) List(c *gin.Context) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 500 {
if pageSize < 1 {
pageSize = 20
}
// 超出上限钳到上限(此前是回退默认 20,导致出库选货请求大 page_size 被打回只剩 20 条)
if pageSize > 5000 {
pageSize = 5000
}
baseWhere := "inv.shop_id = ? AND inv.deleted_at IS NULL"
args := []interface{}{shopID}
+59
View File
@@ -1,6 +1,7 @@
package handler
import (
"errors"
"fmt"
"net/http"
"strconv"
@@ -255,3 +256,61 @@ func (h *StockInHandler) Reject(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"message": "rejected"})
}
// Withdraw PUT /api/v1/stock-in/orders/:id/withdraw
// 审核中(pending)撤回为草稿(draft),修改后可重新提交。仅 pending 可撤回;
// 已审核(approved)单据只读、库存已变动,不可撤回。
// 权限:管理员/超管可撤回任意单;普通操作员只能撤回本人提交的单(operator_id 为本人)。
func (h *StockInHandler) Withdraw(c *gin.Context) {
shopID := middleware.GetShopID(c)
userID := middleware.GetUserID(c)
role := middleware.GetRole(c)
var order model.StockInOrder
if err := h.db.Where("id = ? AND shop_id = ? AND status = 'pending'", c.Param("id"), shopID).
First(&order).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in pending status"})
return
}
isAdmin := role == "admin" || role == "superadmin"
if !isAdmin && order.OperatorID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "只能撤回本人提交的单据"})
return
}
h.db.Model(&model.StockInOrder{}).
Where("id = ? AND shop_id = ?", order.ID, shopID).
Update("status", "draft")
c.JSON(http.StatusOK, gin.H{"message": "withdrawn"})
}
// Return POST /api/v1/stock-in/orders/:id/return
// 入库退单:退掉指定明细,对应库存从库存删除、冲减应付。仅 approved 单、管理员/超管。
func (h *StockInHandler) Return(c *gin.Context) {
shopID := middleware.GetShopID(c)
userID := middleware.GetUserID(c)
role := middleware.GetRole(c)
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
ItemIDs []uint64 `json:"item_ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.ItemIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "未选择退单明细"})
return
}
if err := h.stockSvc.ReturnStockIn(shopID, id, userID, role, req.ItemIDs); err != nil {
if errors.Is(err, service.ErrForbidden) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权退单"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "returned"})
}
+56
View File
@@ -182,6 +182,62 @@ func TestStockInHandler_Reject(t *testing.T) {
assert.Equal(t, "rejected", detailData["status"])
}
func TestStockInHandler_Withdraw(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SI006")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
operator := testutil.CreateTestUser(db, shop.ID, "op", "pass", "operator")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product := testutil.CreateTestProduct(db, shop.ID, "Tequila")
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
opToken := getAuthToken(operator.ID, shop.ID, "operator")
r := setupProtectedRouter(db)
// 创建并提交(进入 pending)
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", adminToken, map[string]interface{}{
"warehouse_id": warehouse.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{
{"product_id": product.ID, "quantity": 5.0},
},
})
orderID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderID), adminToken, nil)
// 1. 操作员撤回「他人(管理员)」的单 → 403
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/withdraw", orderID), opToken, nil)
assert.Equal(t, http.StatusForbidden, w.Code)
// 2. 管理员撤回任意单 → 200,状态回到 draft
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/withdraw", orderID), adminToken, nil)
assert.Equal(t, http.StatusOK, w.Code)
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", orderID), adminToken, nil)
assert.Equal(t, "draft", parseResponse(w)["data"].(map[string]interface{})["status"])
// 3. 撤回后已是 draft,再撤回 → 400(仅 pending 可撤回)
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/withdraw", orderID), adminToken, nil)
assert.Equal(t, http.StatusBadRequest, w.Code)
// 4. 撤回为 draft 后可再次修改并提交
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderID), adminToken, nil)
assert.Equal(t, http.StatusOK, w.Code)
// 5. 操作员撤回「本人」提交的单 → 200(自己发的肯定能撤)
w = makeRequest(r, "POST", "/api/v1/stock-in/orders", opToken, map[string]interface{}{
"warehouse_id": warehouse.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{
{"product_id": product.ID, "quantity": 3.0},
},
})
ownID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", ownID), opToken, nil)
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/withdraw", ownID), opToken, nil)
assert.Equal(t, http.StatusOK, w.Code)
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", ownID), opToken, nil)
assert.Equal(t, "draft", parseResponse(w)["data"].(map[string]interface{})["status"])
}
func TestStockInHandler_GetNotFound(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SI006")
+63 -2
View File
@@ -1,6 +1,7 @@
package handler
import (
"errors"
"net/http"
"strconv"
"strings"
@@ -109,8 +110,9 @@ func (h *StockOutHandler) Create(c *gin.Context) {
var total float64
for i := range req.Items {
req.Items[i].ShopID = shopID
// TotalPrice 记成本小计;应收(TotalAmount)按售价×数量
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
total += req.Items[i].TotalPrice
total += req.Items[i].Quantity * req.Items[i].SalePrice
}
req.TotalAmount = total
@@ -146,8 +148,9 @@ func (h *StockOutHandler) Update(c *gin.Context) {
for i := range req.Items {
req.Items[i].ShopID = shopID
req.Items[i].OrderID = order.ID
// TotalPrice 记成本小计;应收(TotalAmount)按售价×数量
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
total += req.Items[i].TotalPrice
total += req.Items[i].Quantity * req.Items[i].SalePrice
}
updates := map[string]interface{}{
"warehouse_id": req.WarehouseID,
@@ -240,3 +243,61 @@ func (h *StockOutHandler) Reject(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"message": "rejected"})
}
// Withdraw PUT /api/v1/stock-out/orders/:id/withdraw
// 审核中(pending)撤回为草稿(draft),修改后可重新提交。仅 pending 可撤回;
// 已审核(approved)单据只读、库存已变动,不可撤回。
// 权限:管理员/超管可撤回任意单;普通操作员只能撤回本人提交的单(operator_id 为本人)。
func (h *StockOutHandler) Withdraw(c *gin.Context) {
shopID := middleware.GetShopID(c)
userID := middleware.GetUserID(c)
role := middleware.GetRole(c)
var order model.StockOutOrder
if err := h.db.Where("id = ? AND shop_id = ? AND status = 'pending'", c.Param("id"), shopID).
First(&order).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in pending status"})
return
}
isAdmin := role == "admin" || role == "superadmin"
if !isAdmin && order.OperatorID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "只能撤回本人提交的单据"})
return
}
h.db.Model(&model.StockOutOrder{}).
Where("id = ? AND shop_id = ?", order.ID, shopID).
Update("status", "draft")
c.JSON(http.StatusOK, gin.H{"message": "withdrawn"})
}
// Return POST /api/v1/stock-out/orders/:id/return
// 出库退单:退掉指定明细,数量加回库存、冲减应收。仅 approved 单;管理员/超管 或 本人。
func (h *StockOutHandler) Return(c *gin.Context) {
shopID := middleware.GetShopID(c)
userID := middleware.GetUserID(c)
role := middleware.GetRole(c)
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
ItemIDs []uint64 `json:"item_ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.ItemIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "未选择退单明细"})
return
}
if err := h.stockSvc.ReturnStockOut(shopID, id, userID, role, req.ItemIDs); err != nil {
if errors.Is(err, service.ErrForbidden) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权退单"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "returned"})
}
@@ -0,0 +1,197 @@
package handler
import (
"fmt"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/testutil"
)
func TestStockReturn_StockOut_OwnAllowed(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "RET_SO")
op := testutil.CreateTestUser(db, shop.ID, "op", "pass", "operator")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
prod := testutil.CreateTestProduct(db, shop.ID, "茅台")
opToken := getAuthToken(op.ID, shop.ID, "operator")
r := setupProtectedRouter(db)
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &prod.ID, Quantity: 100})
// 操作员建出库单(本人) → 提交 → 审核
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, "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)
// 库存已扣到 85
var sum float64
db.Model(&model.Inventory{}).Where("shop_id = ? AND product_id = ? AND deleted_at IS NULL", shop.ID, prod.ID).
Select("COALESCE(SUM(quantity),0)").Scan(&sum)
require.Equal(t, float64(85), sum)
// 本人退单 → 200
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-out/orders/%d/return", orderID), opToken,
map[string]interface{}{"item_ids": []uint64{item.ID}})
assert.Equal(t, http.StatusOK, w.Code)
// 库存加回到 100
db.Model(&model.Inventory{}).Where("shop_id = ? AND product_id = ? AND deleted_at IS NULL", shop.ID, prod.ID).
Select("COALESCE(SUM(quantity),0)").Scan(&sum)
assert.Equal(t, float64(100), sum)
// 加回是「新建一条库存行」(数量=退单数量 15)
var addBack int64
db.Model(&model.Inventory{}).
Where("shop_id = ? AND product_id = ? AND quantity = 15 AND deleted_at IS NULL", shop.ID, prod.ID).
Count(&addBack)
assert.Equal(t, int64(1), addBack, "应新建一条数量=15 的加回库存行")
// 库存流水:一条 in / stock_out_return85→100
var lg model.InventoryLog
require.NoError(t, db.Where("shop_id = ? AND ref_type = 'stock_out_return' AND direction = 'in'", shop.ID).
First(&lg).Error)
assert.Equal(t, float64(15), lg.Quantity)
assert.Equal(t, float64(85), lg.QtyBefore)
assert.Equal(t, float64(100), lg.QtyAfter)
// 明细标记已退 + 单据 return_state=full
db.First(&item, item.ID)
assert.Equal(t, item.Quantity, item.ReturnedQuantity)
var order model.StockOutOrder
db.First(&order, orderID)
assert.Equal(t, "full", order.ReturnState)
// 应收冲减:存在一条负向 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) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "RET_SI")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
op := testutil.CreateTestUser(db, shop.ID, "op", "pass", "operator")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
prod := testutil.CreateTestProduct(db, shop.ID, "五粮液")
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
opToken := getAuthToken(op.ID, shop.ID, "operator")
r := setupProtectedRouter(db)
// 入库单 → 审核 → 建库存
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", adminToken, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_id": prod.ID, "quantity": 10.0, "unit_price": 50.0}},
})
require.Equal(t, http.StatusCreated, w.Code)
orderID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderID), adminToken, nil)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", orderID), adminToken, nil)
var item model.StockInItem
require.NoError(t, db.Where("order_id = ?", orderID).First(&item).Error)
pid := item.ProductID // 序列号模型:入库为每行新建独立 product
var sum float64
db.Model(&model.Inventory{}).Where("shop_id = ? AND product_id = ? AND deleted_at IS NULL", shop.ID, pid).
Select("COALESCE(SUM(quantity),0)").Scan(&sum)
require.Equal(t, float64(10), sum)
// 操作员退入库单 → 403
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/return", orderID), opToken,
map[string]interface{}{"item_ids": []uint64{item.ID}})
assert.Equal(t, http.StatusForbidden, w.Code)
// 管理员退入库单 → 200,库存被删
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/return", orderID), adminToken,
map[string]interface{}{"item_ids": []uint64{item.ID}})
assert.Equal(t, http.StatusOK, w.Code)
db.Model(&model.Inventory{}).Where("shop_id = ? AND product_id = ? AND deleted_at IS NULL", shop.ID, pid).
Select("COALESCE(SUM(quantity),0)").Scan(&sum)
assert.Equal(t, float64(0), sum)
// 入库建的那条库存行已被软删(active 中查不到)
var active int64
db.Model(&model.Inventory{}).
Where("shop_id = ? AND stock_in_item_id = ? AND deleted_at IS NULL", shop.ID, item.ID).
Count(&active)
assert.Equal(t, int64(0), active, "入库退单后对应库存行应被删除")
// 库存流水:一条 out / stock_in_return,数量=10
var lg model.InventoryLog
require.NoError(t, db.Where("shop_id = ? AND ref_type = 'stock_in_return' AND direction = 'out'", shop.ID).
First(&lg).Error)
assert.Equal(t, float64(10), lg.Quantity)
db.First(&item, item.ID)
assert.Equal(t, item.Quantity, item.ReturnedQuantity)
var order model.StockInOrder
db.First(&order, orderID)
assert.Equal(t, "full", order.ReturnState)
// 应付冲减
var neg int64
db.Model(&model.FinanceRecord{}).
Where("shop_id = ? AND type = 'payable' AND amount < 0 AND ref_type = 'stock_in_return'", shop.ID).
Count(&neg)
assert.Equal(t, int64(1), neg)
}
func TestStockReturn_StockIn_BlockedWhenSold(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "RET_BLK")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
prod := testutil.CreateTestProduct(db, shop.ID, "汾酒")
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
// 入库 10 → 审核
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", adminToken, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_id": prod.ID, "quantity": 10.0, "unit_price": 50.0}},
})
inID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", inID), adminToken, nil)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", inID), adminToken, nil)
var inItem model.StockInItem
db.Where("order_id = ?", inID).First(&inItem)
// 出库 4(消耗入库新建的那个 product)→ 审核
w = makeRequest(r, "POST", "/api/v1/stock-out/orders", adminToken, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_id": inItem.ProductID, "quantity": 4.0, "unit_price": 80.0}},
})
outID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", outID), adminToken, nil)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", outID), adminToken, nil)
// 退入库单 → 400(库存已部分出库)
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/return", inID), adminToken,
map[string]interface{}{"item_ids": []uint64{inItem.ID}})
assert.Equal(t, http.StatusBadRequest, w.Code)
}
@@ -63,6 +63,8 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
stockIn.PUT("/orders/:id/submit", stockInH.Submit)
stockIn.PUT("/orders/:id/approve", stockInH.Approve)
stockIn.PUT("/orders/:id/reject", stockInH.Reject)
stockIn.PUT("/orders/:id/withdraw", stockInH.Withdraw)
stockIn.POST("/orders/:id/return", stockInH.Return)
// 出库路由
stockOut := api.Group("/stock-out")
@@ -72,6 +74,8 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
stockOut.PUT("/orders/:id/submit", stockOutH.Submit)
stockOut.PUT("/orders/:id/approve", stockOutH.Approve)
stockOut.PUT("/orders/:id/reject", stockOutH.Reject)
stockOut.PUT("/orders/:id/withdraw", stockOutH.Withdraw)
stockOut.POST("/orders/:id/return", stockOutH.Return)
// 库存路由
inv := api.Group("/inventory")
+10
View File
@@ -17,6 +17,8 @@ type StockInOrder struct {
OrderDate Date `gorm:"type:date" json:"order_date"`
TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"`
ReviewedAt *time.Time `json:"reviewed_at"`
// 退单状态:none=无 / partial=部分退单 / full=已全退(仅 approved 单可退)
ReturnState string `gorm:"size:20;default:'none'" json:"return_state"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
Remark string `gorm:"size:500" json:"remark"`
@@ -41,6 +43,8 @@ type StockInItem struct {
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_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"`
BatchNo string `gorm:"size:50" json:"batch_no"`
ProductionDate *Date `gorm:"type:date" json:"production_date"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
@@ -65,6 +69,8 @@ type StockOutOrder struct {
OrderDate Date `gorm:"type:date" json:"order_date"`
TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"`
ReviewedAt *time.Time `json:"reviewed_at"`
// 退单状态:none / partial / full
ReturnState string `gorm:"size:20;default:'none'" json:"return_state"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
Remark string `gorm:"size:500" json:"remark"`
@@ -88,7 +94,11 @@ 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"`
BatchNo string `gorm:"size:50" json:"batch_no"`
ProductionDate *Date `gorm:"type:date" json:"production_date"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
+8
View File
@@ -153,6 +153,10 @@ func Setup(r *gin.Engine, db *gorm.DB) {
stockIn.PUT("/orders/:id/submit", stockInH.Submit)
stockIn.PUT("/orders/:id/approve", stockInH.Approve)
stockIn.PUT("/orders/:id/reject", stockInH.Reject)
// 撤回(审核中→草稿):管理员/超管任意单,操作员限本人单(handler 内判权)
stockIn.PUT("/orders/:id/withdraw", stockInH.Withdraw)
// 退单(已审核单退货,库存删除+冲应付):仅管理员/超管(service 内判权)
stockIn.POST("/orders/:id/return", stockInH.Return)
}
// 出库
@@ -166,6 +170,10 @@ func Setup(r *gin.Engine, db *gorm.DB) {
stockOut.PUT("/orders/:id/submit", stockOutH.Submit)
stockOut.PUT("/orders/:id/approve", stockOutH.Approve)
stockOut.PUT("/orders/:id/reject", stockOutH.Reject)
// 撤回(审核中→草稿):管理员/超管任意单,操作员限本人单(handler 内判权)
stockOut.PUT("/orders/:id/withdraw", stockOutH.Withdraw)
// 退单(已审核单退货,库存加回+冲应收):管理员/超管 或 本人(service 内判权)
stockOut.POST("/orders/:id/return", stockOutH.Return)
}
// 库存
+222
View File
@@ -13,6 +13,9 @@ import (
var ErrInsufficientStock = errors.New("insufficient stock")
// ErrForbidden 表示当前用户无权执行该操作(handler 据此返回 403)。
var ErrForbidden = errors.New("forbidden")
type StockService struct {
db *gorm.DB
}
@@ -269,6 +272,225 @@ func partnerLastBalance(tx *gorm.DB, shopID uint64, partnerID *uint64) float64 {
return last.Balance
}
// ReturnStockIn 入库单退单:把选中明细退掉,对应库存从库存中删除,并冲减应付。
// 仅 approved 单可退;权限:管理员/超管。已被出库消耗的明细禁止退(库存不足)。
func (s *StockService) ReturnStockIn(shopID, orderID, userID uint64, role string, itemIDs []uint64) error {
if role != "admin" && role != "superadmin" {
return ErrForbidden
}
return s.db.Transaction(func(tx *gorm.DB) error {
var order model.StockInOrder
if err := tx.Preload("Items").Where("id = ? AND shop_id = ?", orderID, shopID).
First(&order).Error; err != nil {
return err
}
if order.Status != "approved" {
return errors.New("只有已审核单据可退单")
}
want := idSet(itemIDs)
now := time.Now()
var returnedAmount float64
for i := range order.Items {
it := &order.Items[i]
if !want[it.ID] || it.ReturnedQuantity >= it.Quantity {
continue
}
// 找该明细审核时建的库存行(FOR UPDATE)
var inv model.Inventory
if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND stock_in_item_id = ? AND deleted_at IS NULL", shopID, it.ID).
First(&inv).Error; err != nil {
return fmt.Errorf("明细「%s」库存已出库,无法退单", itemDesc(it.ProductName, it.ProductCode))
}
// 已被部分出库(库存数量 < 入库数量)→ 禁止退
if inv.Quantity < it.Quantity {
return fmt.Errorf("明细「%s」库存已部分出库,无法退单", itemDesc(it.ProductName, it.ProductCode))
}
wid, pid := uint64(0), uint64(0)
if inv.WarehouseID != nil {
wid = *inv.WarehouseID
}
if inv.ProductID != nil {
pid = *inv.ProductID
}
// 删库存行 + 写反向流水
if err := tx.Model(&inv).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now}).Error; err != nil {
return err
}
if err := tx.Create(&model.InventoryLog{
ShopID: shopID, WarehouseID: wid, ProductID: pid,
Direction: "out", Quantity: it.Quantity, QtyBefore: inv.Quantity, QtyAfter: 0,
RefType: "stock_in_return", RefID: orderID, OperatorID: &userID,
}).Error; err != nil {
return err
}
if err := tx.Model(it).Update("returned_quantity", it.Quantity).Error; err != nil {
return err
}
it.ReturnedQuantity = it.Quantity
returnedAmount += it.TotalPrice
}
// 冲减应付(负向调整记录,滚动余额)
if returnedAmount > 0 {
bal := partnerLastBalance(tx, shopID, order.PartnerID) - returnedAmount
oid := order.ID
if err := tx.Create(&model.FinanceRecord{
ShopID: shopID, PartnerID: order.PartnerID, Type: "payable",
Amount: -returnedAmount, Balance: bal, Status: "closed",
RefType: "stock_in_return", RefID: &oid, OperatorID: userID, RecordDate: now,
}).Error; err != nil {
return err
}
}
return tx.Model(&order).Update("return_state", returnStateOf(order.Items)).Error
})
}
// ReturnStockOut 出库单退单:把选中明细退掉,数量加回库存(新建库存行),并冲减应收。
// 仅 approved 单可退;权限:管理员/超管 或 本人(operator)。
func (s *StockService) ReturnStockOut(shopID, orderID, userID uint64, role string, itemIDs []uint64) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var order model.StockOutOrder
if err := tx.Preload("Items.Product").Where("id = ? AND shop_id = ?", orderID, shopID).
First(&order).Error; err != nil {
return err
}
if role != "admin" && role != "superadmin" && order.OperatorID != userID {
return ErrForbidden
}
if order.Status != "approved" {
return errors.New("只有已审核单据可退单")
}
want := idSet(itemIDs)
now := time.Now()
warehouseID := order.WarehouseID
var returnedAmount float64
for i := range order.Items {
it := &order.Items[i]
if !want[it.ID] || it.ReturnedQuantity >= it.Quantity {
continue
}
productID := it.ProductID
var qtyBefore float64
tx.Model(&model.Inventory{}).
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL",
shopID, warehouseID, productID).
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
var unitPricePtr *float64
if it.UnitPrice != 0 {
up := it.UnitPrice
unitPricePtr = &up
}
unit := ""
if it.Product != nil {
unit = it.Product.Unit
}
// 加回库存:新建一条库存行
inv := model.Inventory{
ShopID: shopID, WarehouseID: &warehouseID, ProductID: &productID,
Quantity: it.Quantity,
ProductCode: it.ProductCode,
ProductName: it.ProductName,
Series: it.Series,
Spec: it.Spec,
Unit: unit,
UnitPrice: unitPricePtr,
ProductionDate: it.ProductionDate,
BatchNo: it.BatchNo,
}
if err := tx.Create(&inv).Error; err != nil {
return err
}
if err := tx.Create(&model.InventoryLog{
ShopID: shopID, WarehouseID: warehouseID, ProductID: productID,
Direction: "in", Quantity: it.Quantity, QtyBefore: qtyBefore, QtyAfter: qtyBefore + it.Quantity,
RefType: "stock_out_return", RefID: orderID, OperatorID: &userID,
}).Error; err != nil {
return err
}
if err := tx.Model(it).Update("returned_quantity", it.Quantity).Error; err != nil {
return err
}
it.ReturnedQuantity = it.Quantity
// 冲应收按售价口径(与审核建应收 TotalAmount=Σ售价×数量 一致)
returnedAmount += it.Quantity * it.SalePrice
}
// 冲减应收(负向调整记录)
if returnedAmount > 0 {
bal := partnerLastBalance(tx, shopID, order.PartnerID) - returnedAmount
oid := order.ID
if err := tx.Create(&model.FinanceRecord{
ShopID: shopID, PartnerID: order.PartnerID, Type: "receivable",
Amount: -returnedAmount, Balance: bal, Status: "closed",
RefType: "stock_out_return", RefID: &oid, OperatorID: userID, RecordDate: now,
}).Error; err != nil {
return err
}
}
return tx.Model(&order).Update("return_state", returnStateOfOut(order.Items)).Error
})
}
func idSet(ids []uint64) map[uint64]bool {
m := make(map[uint64]bool, len(ids))
for _, id := range ids {
m[id] = true
}
return m
}
func itemDesc(name, code string) string {
if name == "" {
return code
}
if code == "" {
return name
}
return name + " " + code
}
// returnStateOf 据明细已退情况推导入库单退单状态。
func returnStateOf(items []model.StockInItem) string {
total, returned := 0, 0
for _, it := range items {
total++
if it.ReturnedQuantity >= it.Quantity {
returned++
}
}
switch {
case returned == 0:
return "none"
case returned >= total:
return "full"
default:
return "partial"
}
}
func returnStateOfOut(items []model.StockOutItem) string {
total, returned := 0, 0
for _, it := range items {
total++
if it.ReturnedQuantity >= it.Quantity {
returned++
}
}
switch {
case returned == 0:
return "none"
case returned >= total:
return "full"
default:
return "partial"
}
}
// GenerateOrderNo 生成单号(事务安全,FOR UPDATE 防止并发重复单号)
func (s *StockService) GenerateOrderNo(shopID uint64, orderType string) (string, error) {
var no string
+3 -2
View File
@@ -360,8 +360,9 @@ CREATE TABLE IF NOT EXISTS `stock_out_items` (
`series` VARCHAR(100) DEFAULT NULL COMMENT '系列(历史导入快照)',
`spec` VARCHAR(100) DEFAULT NULL COMMENT '规格(历史导入快照)',
`quantity` DECIMAL(12,3) NOT NULL,
`unit_price` DECIMAL(16,2) NOT NULL DEFAULT 0,
`total_price` DECIMAL(16,2) NOT NULL DEFAULT 0,
`unit_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '成本单价(入库成本快照)',
`sale_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '售价(出库实际销售单价,应收按售价×数量)',
`total_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '成本小计(unit_price×quantity)',
`batch_no` VARCHAR(50) DEFAULT NULL COMMENT '批次号',
`production_date` DATE DEFAULT NULL COMMENT '生产日期',
`custom_fields` JSON DEFAULT NULL,
+5
View File
@@ -327,6 +327,7 @@ func SetupTestDB() *gorm.DB {
order_date DATETIME,
total_amount REAL DEFAULT 0,
reviewed_at DATETIME,
return_state TEXT DEFAULT 'none',
custom_fields TEXT,
remark TEXT
)`,
@@ -346,6 +347,7 @@ func SetupTestDB() *gorm.DB {
quantity REAL NOT NULL,
unit_price REAL DEFAULT 0,
total_price REAL DEFAULT 0,
returned_quantity REAL DEFAULT 0,
batch_no TEXT,
production_date DATETIME,
custom_fields TEXT,
@@ -368,6 +370,7 @@ func SetupTestDB() *gorm.DB {
order_date DATETIME,
total_amount REAL DEFAULT 0,
reviewed_at DATETIME,
return_state TEXT DEFAULT 'none',
custom_fields TEXT,
remark TEXT
)`,
@@ -386,7 +389,9 @@ 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,
production_date DATETIME,
custom_fields TEXT,
+20
View File
@@ -7,6 +7,7 @@ import '../config/app_constants.dart';
const _kAccessToken = 'access_token';
const _kRefreshToken = 'refresh_token';
const _kUserId = 'user_id';
const _kUsername = 'username';
const _kRealName = 'real_name';
const _kShopNo = 'shop_no';
@@ -16,6 +17,7 @@ const _kRole = 'role';
class AuthUser {
final String accessToken;
final String refreshToken;
final int id;
final String username;
final String realName;
final String shopNo;
@@ -25,6 +27,7 @@ class AuthUser {
const AuthUser({
required this.accessToken,
required this.refreshToken,
this.id = 0,
required this.username,
required this.realName,
required this.shopNo,
@@ -74,12 +77,14 @@ class AuthNotifier extends StateNotifier<AuthState> {
final shopIdStr = prefs.getString(_kShopId);
final role = prefs.getString(_kRole);
final userId = prefs.getInt(_kUserId) ?? 0;
if (accessToken != null && refreshToken != null && username != null) {
state = AuthState(
initialized: true,
user: AuthUser(
accessToken: accessToken,
refreshToken: refreshToken,
id: userId,
username: username,
realName: realName ?? username,
shopNo: shopNo ?? '',
@@ -103,6 +108,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kAccessToken, user.accessToken);
await prefs.setString(_kRefreshToken, user.refreshToken);
await prefs.setInt(_kUserId, user.id);
await prefs.setString(_kUsername, user.username);
await prefs.setString(_kRealName, user.realName);
await prefs.setString(_kShopNo, user.shopNo);
@@ -134,6 +140,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
user: AuthUser(
accessToken: newToken,
refreshToken: refreshToken,
id: state.user!.id,
username: state.user!.username,
realName: state.user!.realName,
shopNo: state.user!.shopNo,
@@ -167,6 +174,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kAccessToken);
await prefs.remove(_kRefreshToken);
await prefs.remove(_kUserId);
await prefs.remove(_kUsername);
await prefs.remove(_kRealName);
await prefs.remove(_kShopNo);
@@ -194,3 +202,15 @@ final isReadonlyProvider = Provider<bool>((ref) {
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
return role == 'readonly';
});
/// 当前登录用户是否具备管理员权限(admin 或 superadmin)。
/// 与后端 middleware.AdminOnly() 一致(两者皆放行),用于「撤回审核中单据」等操作。
final isAdminProvider = Provider<bool>((ref) {
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
return role == 'admin' || role == 'superadmin';
});
/// 当前登录用户 id(未登录为 0)。用于「撤回本人单据」等需要判断单据归属的场景。
final currentUserIdProvider = Provider<int>((ref) {
return ref.watch(authStateProvider.select((s) => s.user?.id)) ?? 0;
});
+2
View File
@@ -14,6 +14,8 @@ class AppTheme {
static const Color success = Color(0xFF2E8B57);
static const Color danger = Color(0xFFD14343);
/// 警示色(amber):用于「撤回」等谨慎但非破坏性的操作,文字在白底对比足够。
static const Color warning = Color(0xFFB45309);
static const Color background = Color(0xFFF5F7FA);
static const Color surface = Color(0xFFFFFFFF);
static const Color border = Color(0xFFDCE2EB);
+12
View File
@@ -1,11 +1,16 @@
class StockInItem {
final int? id;
final int? orderId;
final int productId;
final double quantity;
final double unitPrice;
final double totalPrice;
final double returnedQuantity; // 0=未退;>=quantity 表示整行已退单
final String? batchNo;
final String? productionDate;
/// 该明细是否已退单(整行已退)。
bool get isReturned => returnedQuantity >= quantity && quantity > 0;
// Denormalized for display
final String? productName;
final String? productCode;
@@ -17,11 +22,13 @@ class StockInItem {
final String? productStorage;
const StockInItem({
this.id,
this.orderId,
required this.productId,
required this.quantity,
required this.unitPrice,
required this.totalPrice,
this.returnedQuantity = 0,
this.batchNo,
this.productionDate,
this.productName,
@@ -45,6 +52,7 @@ class StockInItem {
}
return StockInItem(
id: json['id'] != null ? (json['id'] as num).toInt() : null,
orderId: json['order_id'] != null
? (json['order_id'] as num).toInt()
: null,
@@ -52,6 +60,7 @@ class StockInItem {
quantity: (json['quantity'] as num).toDouble(),
unitPrice: (json['unit_price'] as num).toDouble(),
totalPrice: (json['total_price'] as num).toDouble(),
returnedQuantity: (json['returned_quantity'] as num?)?.toDouble() ?? 0,
batchNo: json['batch_no'] as String?,
productionDate: json['production_date'] as String?,
productName: lineOrProduct('product_name', 'name'),
@@ -91,6 +100,7 @@ class StockInOrder {
final int? reviewerId;
final String? reviewerName;
final String status; // draft | pending | approved | rejected
final String returnState; // none | partial | full(退单状态)
final String? orderDate;
final String? reviewedAt;
final double? totalAmount;
@@ -110,6 +120,7 @@ class StockInOrder {
this.reviewerId,
this.reviewerName,
required this.status,
this.returnState = 'none',
this.orderDate,
this.reviewedAt,
this.totalAmount,
@@ -136,6 +147,7 @@ class StockInOrder {
: null,
reviewerName: (json['reviewer'] as Map<String, dynamic>?)?['real_name'] as String?,
status: json['status'] as String,
returnState: json['return_state'] as String? ?? 'none',
orderDate: json['order_date'] as String?,
reviewedAt: json['reviewed_at'] as String?,
totalAmount: json['total_amount'] != null
+14
View File
@@ -1,21 +1,29 @@
class StockOutItem {
final int? id;
final int? orderId;
final int productId;
final double quantity;
final double unitPrice;
final double salePrice;
final double totalPrice;
final double returnedQuantity;
final String? productName;
final String? productCode;
final String? productSeries;
final String? productSpec;
final String? productUnit;
bool get isReturned => returnedQuantity >= quantity && quantity > 0;
const StockOutItem({
this.id,
this.orderId,
required this.productId,
required this.quantity,
required this.unitPrice,
this.salePrice = 0,
required this.totalPrice,
this.returnedQuantity = 0,
this.productName,
this.productCode,
this.productSeries,
@@ -34,13 +42,16 @@ class StockOutItem {
}
return StockOutItem(
id: json['id'] != null ? (json['id'] as num).toInt() : null,
orderId: json['order_id'] != null
? (json['order_id'] as num).toInt()
: null,
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'),
productCode: lineOrProduct('product_code', 'code'),
productSeries: lineOrProduct('series', 'series'),
@@ -63,6 +74,7 @@ class StockOutOrder {
final int? reviewerId;
final String? reviewerName;
final String status; // draft | pending | approved | rejected
final String returnState; // none | partial | full
final String? orderDate;
final String? reviewedAt;
final String? createdAt;
@@ -83,6 +95,7 @@ class StockOutOrder {
this.reviewerId,
this.reviewerName,
required this.status,
this.returnState = 'none',
this.orderDate,
this.reviewedAt,
this.createdAt,
@@ -110,6 +123,7 @@ class StockOutOrder {
: null,
reviewerName: (json['reviewer'] as Map<String, dynamic>?)?['real_name'] as String?,
status: json['status'] as String,
returnState: json['return_state'] as String? ?? 'none',
orderDate: json['order_date'] as String?,
reviewedAt: json['reviewed_at'] as String?,
createdAt: json['created_at'] as String?,
@@ -123,4 +123,15 @@ class StockInListNotifier extends AsyncNotifier<PageResult<StockInOrder>> {
await ref.read(stockInRepositoryProvider).reject(id);
reload();
}
Future<void> withdrawOrder(int id) async {
await ref.read(stockInRepositoryProvider).withdraw(id);
reload();
}
Future<void> returnItems(int id, List<int> itemIds) async {
await ref.read(stockInRepositoryProvider).returnItems(id, itemIds);
reload();
ref.invalidate(inventoryListProvider);
}
}
@@ -123,4 +123,15 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
await ref.read(stockOutRepositoryProvider).reject(id);
reload();
}
Future<void> withdrawOrder(int id) async {
await ref.read(stockOutRepositoryProvider).withdraw(id);
reload();
}
Future<void> returnItems(int id, List<int> itemIds) async {
await ref.read(stockOutRepositoryProvider).returnItems(id, itemIds);
reload();
ref.invalidate(inventoryListProvider);
}
}
@@ -55,6 +55,7 @@ class AuthRepository {
return AuthUser(
accessToken: data['access_token'] as String,
refreshToken: data['refresh_token'] as String,
id: (user['id'] as num?)?.toInt() ?? 0,
username: user['username'] as String,
realName: user['real_name'] as String? ?? username,
shopNo: shopCode,
@@ -119,4 +119,27 @@ class StockInRepository {
);
}
}
Future<void> withdraw(int id) async {
try {
await _client.put('/stock-in/orders/$id/withdraw');
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '撤回失败',
statusCode: e.response?.statusCode,
);
}
}
/// 退单:退掉指定明细(item ids)。入库退单对应库存从库存删除。
Future<void> returnItems(int id, List<int> itemIds) async {
try {
await _client.post('/stock-in/orders/$id/return', data: {'item_ids': itemIds});
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '退单失败',
statusCode: e.response?.statusCode,
);
}
}
}
@@ -119,4 +119,27 @@ class StockOutRepository {
);
}
}
Future<void> withdraw(int id) async {
try {
await _client.put('/stock-out/orders/$id/withdraw');
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '撤回失败',
statusCode: e.response?.statusCode,
);
}
}
/// 退单:退掉指定明细(item ids)。出库退单数量加回库存。
Future<void> returnItems(int id, List<int> itemIds) async {
try {
await _client.post('/stock-out/orders/$id/return', data: {'item_ids': itemIds});
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '退单失败',
statusCode: e.response?.statusCode,
);
}
}
}
@@ -51,7 +51,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
ColDef('series', '系列'),
ColDef('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
@@ -1271,6 +1271,10 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
'格式:选项编号 | 选项名称 | 单品数量 | 备注'),
_ImportSlot('库存', '/import/inventory',
'格式:商品编号|商品名称|系列|规格|单位|库存数量|单价|金额|生产日期|批次|分类|所在仓库|入库日期|供应商|上次盘点|备注'),
_ImportSlot('入库单', '/import/stock-in',
'老系统入库单打印格式:每个文件一张单(含单据号/往来单位/日期/明细)'),
_ImportSlot('出库单', '/import/stock-out',
'老系统出库单打印格式:每个文件一张单(含单据号/往来单位/日期/明细)'),
];
final Set<String> _selectedTables = {};
@@ -1418,10 +1422,20 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
final data = (resp.data is Map) ? resp.data as Map<String, dynamic> : <String, dynamic>{};
if (mounted) setState(() {
slot.success = true;
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;
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) {
@@ -300,6 +300,14 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
);
return;
}
if (item.batchNoCtrl.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${i + 1} 行请填写批次号'),
backgroundColor: AppTheme.danger),
);
return;
}
}
}
@@ -872,12 +880,13 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
return TextFormField(
controller: item.batchNoCtrl,
decoration: const InputDecoration(
hintText: '',
labelText: '批次号',
hintText: '',
isDense: true,
),
style: const TextStyle(fontSize: 13),
onChanged: (_) => setState(() {}),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '不能为空' : null,
);
}
@@ -993,7 +1002,8 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
Expanded(flex: 18, child: th('名称')),
Expanded(flex: 12, child: th('系列')),
Expanded(flex: 12, child: th('规格')),
Expanded(flex: 24, child: th('生产日期')),
Expanded(flex: 20, child: th('生产日期')),
Expanded(flex: 12, child: th('批次号')),
Expanded(flex: 10, child: th('数量')),
Expanded(flex: 10, child: th('单价')),
Expanded(flex: 10, child: th('金额')),
@@ -1017,8 +1027,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
style: TextStyle(fontSize: 13, color: color, fontWeight: weight)),
);
final hasOptional = item.batchNoCtrl.text.isNotEmpty ||
item.selectedOriginId != null ||
final hasOptional = item.selectedOriginId != null ||
item.selectedShelfLifeId != null ||
item.selectedStorageId != null ||
item.selectedDescriptionDocId != null;
@@ -1050,10 +1059,15 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
padding: const EdgeInsets.all(4),
child: _specField(item))),
Expanded(
flex: 24,
flex: 20,
child: Padding(
padding: const EdgeInsets.all(4),
child: _dateField(item))),
Expanded(
flex: 12,
child: Padding(
padding: const EdgeInsets.all(4),
child: _batchField(item))),
Expanded(
flex: 10,
child: Padding(
@@ -1071,7 +1085,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
SizedBox(
width: 32,
child: Tooltip(
message: item.expanded ? '收起选填' : '展开选填(批次/产地/保质期等)',
message: item.expanded ? '收起选填' : '展开选填(产地/保质期等)',
child: IconButton(
icon: Icon(
item.expanded ? Icons.expand_less : Icons.expand_more,
@@ -1131,8 +1145,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
spacing: 16,
runSpacing: 12,
children: [
_OptionalField(
label: '批次号', width: 180, child: _batchField(item)),
_OptionalField(
label: '产地', width: 180, child: _originField(item)),
_OptionalField(
@@ -1170,6 +1182,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
MobileCardField('系列', null, valueWidget: _seriesField(item)),
MobileCardField('规格', null, valueWidget: _specField(item)),
MobileCardField('生产日期', null, valueWidget: _dateField(item)),
MobileCardField('批次号', null, valueWidget: _batchField(item)),
MobileCardField('数量', null, valueWidget: _qtyField(item)),
MobileCardField('单价', null, valueWidget: _priceField(item)),
MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'),
@@ -1178,15 +1191,13 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
onPressed: () => setState(() => item.expanded = !item.expanded),
icon: Icon(item.expanded ? Icons.expand_less : Icons.expand_more,
size: 16),
label: Text(item.expanded ? '收起选填项' : '展开选填项(批次/产地/保质期…)'),
label: Text(item.expanded ? '收起选填项' : '展开选填项(产地/保质期…)'),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 32),
foregroundColor: AppTheme.textSecondary,
),
)),
if (item.expanded)
MobileCardField('批次号', null, valueWidget: _batchField(item)),
if (item.expanded)
MobileCardField('产地', null, valueWidget: _originField(item)),
if (item.expanded)
@@ -25,6 +25,9 @@ import '../../providers/finance_provider.dart' show financeRepositoryProvider;
import '../../providers/shop_provider.dart' show shopInfoProvider;
import '../../widgets/write_guard.dart';
import '../../widgets/order_row_actions.dart';
import '../../widgets/order_return_dialog.dart';
import '../../core/auth/auth_state.dart'
show isAdminProvider, currentUserIdProvider;
class StockInListScreen extends ConsumerStatefulWidget {
const StockInListScreen({super.key});
@@ -266,7 +269,11 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
? '¥${o.totalAmount!.toStringAsFixed(2)}'
: '-'));
case 'status':
return DataCell(StatusBadge(_apiStatusToEnum(o.status)));
final rb = returnStateBadge(o.returnState);
return DataCell(Row(mainAxisSize: MainAxisSize.min, children: [
StatusBadge(_apiStatusToEnum(o.status)),
if (rb != null) ...[const SizedBox(width: 4), rb],
]));
case 'date':
return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-'));
case 'operator':
@@ -478,6 +485,9 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
List<Widget> _orderActions(BuildContext context, StockInOrder o) {
return buildOrderRowActions(
readonly: WriteGuard.isReadonly(ref),
canWithdraw: ref.watch(isAdminProvider) ||
(o.operatorId != null &&
o.operatorId == ref.watch(currentUserIdProvider)),
status: o.status,
orderId: o.id,
onDetail: () => _showDetail(context, o.id),
@@ -525,6 +535,10 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
onSubmit: () => _confirmSubmit(context, o),
onApprove: () => _confirmApprove(context, o),
onReject: () => _confirmReject(context, o),
onWithdraw: () => _confirmWithdraw(context, o),
// 退/
canReturn: ref.watch(isAdminProvider),
onReturn: () => _confirmReturn(context, o),
);
}
@@ -534,7 +548,13 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
onTap: () => _showDetail(context, o.id),
title: Text(o.orderNo,
style: const TextStyle(fontFamily: 'monospace', fontSize: 14)),
trailing: StatusBadge(_apiStatusToEnum(o.status)),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
StatusBadge(_apiStatusToEnum(o.status)),
if (returnStateBadge(o.returnState) != null) ...[
const SizedBox(width: 4),
returnStateBadge(o.returnState)!,
],
]),
fields: [
MobileCardField('供应商', o.partnerName ?? '-'),
MobileCardField('仓库', o.warehouseName ?? '-'),
@@ -754,6 +774,105 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
}
}
}
Future<void> _confirmWithdraw(BuildContext context, StockInOrder o) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('撤回确认'),
content: Text('确认撤回入库单「${o.orderNo}」?撤回后单据回到草稿,可修改后重新提交审核。'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消')),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accent,
foregroundColor: Colors.white),
child: const Text('撤回'),
),
],
),
);
if (confirmed == true && mounted) {
try {
await ref.read(stockInListProvider.notifier).withdrawOrder(o.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('已撤回为草稿'), backgroundColor: AppTheme.accent));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('撤回失败:$e'),
backgroundColor: AppTheme.danger));
}
}
}
}
Future<void> _confirmReturn(BuildContext context, StockInOrder o) async {
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('退单'),
content: Text('确认对已审核入库单「${o.orderNo}」发起退单?将打开退单明细窗口选择要退的商品。'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消')),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.danger, foregroundColor: Colors.white),
child: const Text('确认'),
),
],
),
);
if (go != true || !mounted) return;
final StockInOrder full;
try {
full = await ref.read(stockInRepositoryProvider).get(o.id);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('加载明细失败:$e'), backgroundColor: AppTheme.danger));
}
return;
}
if (!mounted) return;
final lines = full.items
.where((it) => it.id != null)
.map((it) => ReturnLine(
itemId: it.id!,
code: it.productCode ?? '',
name: it.productName ?? '',
series: it.productSeries ?? '',
spec: it.productSpec ?? '',
quantity: it.quantity,
unitPrice: it.unitPrice,
totalPrice: it.totalPrice,
alreadyReturned: it.isReturned,
))
.toList();
await showOrderReturnDialog(
context: context,
title: '入库单退单 — ${o.orderNo}',
isOut: false,
meta: [
('供应商', o.partnerName ?? '-'),
('仓库', o.warehouseName ?? '-'),
('入库日期', o.orderDate != null && o.orderDate!.length >= 10
? o.orderDate!.substring(0, 10)
: (o.orderDate ?? '-')),
],
lines: lines,
onSubmit: (ids) =>
ref.read(stockInListProvider.notifier).returnItems(o.id, ids),
);
}
}
// Detail dialog fetches full order with items
@@ -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),
@@ -23,6 +23,9 @@ import '../../providers/product_provider.dart';
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
import '../../widgets/write_guard.dart';
import '../../widgets/order_row_actions.dart';
import '../../widgets/order_return_dialog.dart';
import '../../core/auth/auth_state.dart'
show isAdminProvider, currentUserIdProvider;
class StockOutListScreen extends ConsumerStatefulWidget {
const StockOutListScreen({super.key});
@@ -267,7 +270,11 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
? '¥${o.totalAmount!.toStringAsFixed(2)}'
: '-'));
case 'status':
return DataCell(StatusBadge(_apiStatusToEnum(o.status)));
final rb = returnStateBadge(o.returnState);
return DataCell(Row(mainAxisSize: MainAxisSize.min, children: [
StatusBadge(_apiStatusToEnum(o.status)),
if (rb != null) ...[const SizedBox(width: 4), rb],
]));
case 'date':
return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-'));
case 'created_at':
@@ -484,6 +491,9 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
List<Widget> _orderActions(BuildContext context, StockOutOrder o) {
return buildOrderRowActions(
readonly: WriteGuard.isReadonly(ref),
canWithdraw: ref.watch(isAdminProvider) ||
(o.operatorId != null &&
o.operatorId == ref.watch(currentUserIdProvider)),
status: o.status,
orderId: o.id,
onDetail: () => _showDetail(context, o.id),
@@ -499,6 +509,12 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
onSubmit: () => _confirmSubmit(context, o),
onApprove: () => _confirmApprove(context, o),
onReject: () => _confirmReject(context, o),
onWithdraw: () => _confirmWithdraw(context, o),
// 退/
canReturn: ref.watch(isAdminProvider) ||
(o.operatorId != null &&
o.operatorId == ref.watch(currentUserIdProvider)),
onReturn: () => _confirmReturn(context, o),
);
}
@@ -508,7 +524,13 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
onTap: () => _showDetail(context, o.id),
title: Text(o.orderNo,
style: const TextStyle(fontFamily: 'monospace', fontSize: 14)),
trailing: StatusBadge(_apiStatusToEnum(o.status)),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
StatusBadge(_apiStatusToEnum(o.status)),
if (returnStateBadge(o.returnState) != null) ...[
const SizedBox(width: 4),
returnStateBadge(o.returnState)!,
],
]),
fields: [
MobileCardField('客户', o.partnerName ?? '-'),
MobileCardField('仓库', o.warehouseName ?? '-'),
@@ -732,6 +754,106 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
}
}
}
Future<void> _confirmWithdraw(
BuildContext context, StockOutOrder o) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('撤回确认'),
content: Text('确认撤回出库单「${o.orderNo}」?撤回后单据回到草稿,可修改后重新提交审核。'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消')),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accent,
foregroundColor: Colors.white),
child: const Text('撤回'),
),
],
),
);
if (confirmed == true && mounted) {
try {
await ref.read(stockOutListProvider.notifier).withdrawOrder(o.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('已撤回为草稿'), backgroundColor: AppTheme.accent));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('撤回失败:$e'),
backgroundColor: AppTheme.danger));
}
}
}
}
Future<void> _confirmReturn(BuildContext context, StockOutOrder o) async {
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('退单'),
content: Text('确认对已审核出库单「${o.orderNo}」发起退单?将打开退单明细窗口选择要退的商品。'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消')),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.danger, foregroundColor: Colors.white),
child: const Text('确认'),
),
],
),
);
if (go != true || !mounted) return;
final StockOutOrder full;
try {
full = await ref.read(stockOutRepositoryProvider).get(o.id);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('加载明细失败:$e'), backgroundColor: AppTheme.danger));
}
return;
}
if (!mounted) return;
final lines = full.items
.where((it) => it.id != null)
.map((it) => ReturnLine(
itemId: it.id!,
code: it.productCode ?? '',
name: it.productName ?? '',
series: it.productSeries ?? '',
spec: it.productSpec ?? '',
quantity: it.quantity,
unitPrice: it.unitPrice,
totalPrice: it.totalPrice,
alreadyReturned: it.isReturned,
))
.toList();
await showOrderReturnDialog(
context: context,
title: '出库单退单 — ${o.orderNo}',
isOut: true,
meta: [
('客户', o.partnerName ?? '-'),
('仓库', o.warehouseName ?? '-'),
('出库日期', o.orderDate != null && o.orderDate!.length >= 10
? o.orderDate!.substring(0, 10)
: (o.orderDate ?? '-')),
],
lines: lines,
onSubmit: (ids) =>
ref.read(stockOutListProvider.notifier).returnItems(o.id, ids),
);
}
}
// Detail dialog fetches full order with items
+420
View File
@@ -0,0 +1,420 @@
import 'package:flutter/material.dart';
import '../core/responsive/responsive.dart';
import '../core/theme/app_theme.dart';
import '../core/utils/dialog_util.dart';
/// 退none null partial=退(amber)full=退()
Widget? returnStateBadge(String state) {
Color? c;
String? t;
if (state == 'partial') {
c = AppTheme.warning;
t = '部分退单';
} else if (state == 'full') {
c = AppTheme.danger;
t = '已退单';
} else {
return null;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: c.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8)),
child: Text(t,
style: TextStyle(fontSize: 10, color: c, fontWeight: FontWeight.w600)),
);
}
/// 退
class ReturnLine {
final int itemId;
final String code;
final String name;
final String series;
final String spec;
final double quantity;
final double unitPrice;
final double totalPrice;
final bool alreadyReturned; // 退
const ReturnLine({
required this.itemId,
required this.code,
required this.name,
required this.series,
required this.spec,
required this.quantity,
required this.unitPrice,
required this.totalPrice,
this.alreadyReturned = false,
});
}
/// 退 true null
/// - [isOut] true=退/ false=
/// - [onSubmit] itemId
Future<bool?> showOrderReturnDialog({
required BuildContext context,
required String title,
required List<(String, String)> meta,
required bool isOut,
required List<ReturnLine> lines,
required Future<void> Function(List<int> itemIds) onSubmit,
}) {
return showAppDialog<bool>(
context: context,
builder: (_) => _OrderReturnDialog(
title: title,
meta: meta,
isOut: isOut,
lines: lines,
onSubmit: onSubmit,
),
);
}
class _OrderReturnDialog extends StatefulWidget {
final String title;
final List<(String, String)> meta;
final bool isOut;
final List<ReturnLine> lines;
final Future<void> Function(List<int> itemIds) onSubmit;
const _OrderReturnDialog({
required this.title,
required this.meta,
required this.isOut,
required this.lines,
required this.onSubmit,
});
@override
State<_OrderReturnDialog> createState() => _OrderReturnDialogState();
}
class _OrderReturnDialogState extends State<_OrderReturnDialog> {
final Set<int> _staged = {}; // 退 itemId
bool _submitting = false;
bool _isReturned(ReturnLine l) => l.alreadyReturned || _staged.contains(l.itemId);
List<ReturnLine> get _selectable =>
widget.lines.where((l) => !l.alreadyReturned).toList();
Future<void> _confirmLine(ReturnLine l) async {
final ok = await showAppDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('退单确认', style: TextStyle(fontSize: 16)),
content: RichText(
text: TextSpan(
style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary, height: 1.6),
children: [
const TextSpan(text: '确认退掉以下明细?\n'),
TextSpan(
text: l.name,
style: const TextStyle(fontWeight: FontWeight.w700)),
TextSpan(text: ' · ${l.series} · ${l.spec}\n'),
TextSpan(
text: '编码 ${l.code} · 数量 ${_q(l.quantity)}',
style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消')),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.danger, foregroundColor: Colors.white),
child: const Text('退单'),
),
],
),
);
if (ok == true) setState(() => _staged.add(l.itemId));
}
Future<void> _confirmAll() async {
final pending = _selectable.where((l) => !_staged.contains(l.itemId)).toList();
if (pending.isEmpty) return;
final ok = await showAppDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('全部退单确认', style: TextStyle(fontSize: 16)),
content: Text('确认退掉本单全部 ${pending.length} 条未退明细?',
style: const TextStyle(fontSize: 13)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消')),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.danger, foregroundColor: Colors.white),
child: const Text('全部退单'),
),
],
),
);
if (ok == true) {
setState(() => _staged.addAll(pending.map((e) => e.itemId)));
}
}
Future<void> _submit() async {
if (_staged.isEmpty) return;
final ok = await showAppDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('提交退单', style: TextStyle(fontSize: 16)),
content: Text(
'确认提交退单?${widget.isOut ? '退回库存' : '从库存删除'} 将立即生效,且不可撤销。',
style: const TextStyle(fontSize: 13)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消')),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.danger, foregroundColor: Colors.white),
child: const Text('提交退单'),
),
],
),
);
if (ok != true) return;
setState(() => _submitting = true);
try {
await widget.onSubmit(_staged.toList());
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('退单成功'), backgroundColor: AppTheme.success));
Navigator.pop(context, true);
}
} catch (e) {
if (mounted) {
setState(() => _submitting = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('退单失败:$e'), backgroundColor: AppTheme.danger));
}
}
}
@override
Widget build(BuildContext context) {
final stagedQty = widget.lines
.where((l) => _staged.contains(l.itemId))
.fold<double>(0, (s, l) => s + l.quantity);
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: SizedBox(
width: context.dialogWidth(820),
height: 560,
child: Column(
children: [
//
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 10),
child: Row(
children: [
Expanded(
child: Text(widget.title,
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis),
),
OutlinedButton(
onPressed: _submitting ? null : _confirmAll,
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.danger,
side: const BorderSide(color: AppTheme.danger),
visualDensity: VisualDensity.compact,
),
child: const Text('全部退单', style: TextStyle(fontSize: 12)),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(height: 1),
//
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Wrap(
spacing: 20,
runSpacing: 4,
children: [
for (final (k, v) in widget.meta)
Text.rich(TextSpan(
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
children: [
TextSpan(text: '$k'),
TextSpan(text: v, style: const TextStyle(color: AppTheme.textPrimary)),
],
)),
],
),
),
//
Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 2, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF0F6FF),
border: const Border(left: BorderSide(color: AppTheme.primary, width: 3)),
borderRadius: BorderRadius.circular(4),
),
child: Text(
widget.isOut
? '提交后,已退单明细的数量将「加回库存」。退单的明细不会删除,以红色「已退单」标注。'
: '提交后,已退单明细对应的库存将「从库存删除」。退单的明细不会删除,以红色「已退单」标注。',
style: const TextStyle(fontSize: 12, color: AppTheme.primaryDark),
),
),
//
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: widget.lines.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) => _lineTile(widget.lines[i], i),
),
),
const Divider(height: 1),
//
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
child: Row(
children: [
Expanded(
child: Text(
_staged.isEmpty
? '未选择退单明细'
: '已退单 ${_staged.length} 项 · ${widget.isOut ? '退回库存 +' : '从库存移除 '}${_q(stagedQty)}',
style: TextStyle(
fontSize: 12,
color: _staged.isEmpty
? AppTheme.textSecondary
: AppTheme.danger,
fontWeight: FontWeight.w600),
),
),
OutlinedButton(
onPressed: _submitting ? null : () => Navigator.pop(context),
child: const Text('取消'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: (_staged.isEmpty || _submitting) ? null : _submit,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.danger,
foregroundColor: Colors.white),
child: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Text('提交退单'),
),
],
),
),
],
),
),
);
}
Widget _lineTile(ReturnLine l, int idx) {
final returned = _isReturned(l);
final nameStyle = TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: returned ? AppTheme.danger : AppTheme.textPrimary,
decoration: returned ? TextDecoration.lineThrough : null,
);
final subStyle = TextStyle(
fontSize: 11,
color: returned ? AppTheme.danger : AppTheme.textSecondary,
decoration: returned ? TextDecoration.lineThrough : null,
);
return Container(
color: returned ? const Color(0xFFFFF4F4) : null,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 9),
child: Row(
children: [
SizedBox(
width: 24,
child: Text('${idx + 1}',
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary))),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Flexible(child: Text(l.name, style: nameStyle, overflow: TextOverflow.ellipsis)),
if (returned) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: AppTheme.danger,
borderRadius: BorderRadius.circular(8)),
child: const Text('已退单',
style: TextStyle(fontSize: 10, color: Colors.white)),
),
],
]),
const SizedBox(height: 2),
Text(
'${l.code} · ${l.series} · ${l.spec} · ${_q(l.quantity)}×¥${l.unitPrice.toStringAsFixed(2)} = ¥${l.totalPrice.toStringAsFixed(2)}',
style: subStyle,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
SizedBox(
width: 64,
child: l.alreadyReturned
? const SizedBox.shrink()
: (_staged.contains(l.itemId)
? TextButton(
onPressed: _submitting
? null
: () => setState(() => _staged.remove(l.itemId)),
child: const Text('撤销',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
)
: OutlinedButton(
onPressed: _submitting ? null : () => _confirmLine(l),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.danger,
side: const BorderSide(color: AppTheme.danger),
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: const Text('退单', style: TextStyle(fontSize: 12)),
)),
),
],
),
);
}
static String _q(double v) =>
v == v.roundToDouble() ? v.toInt().toString() : v.toString();
}
+34
View File
@@ -23,6 +23,10 @@ List<Widget> buildOrderRowActions({
required VoidCallback onSubmit,
required VoidCallback onApprove,
required VoidCallback onReject,
required VoidCallback onWithdraw,
required VoidCallback onReturn,
bool canWithdraw = false,
bool canReturn = false,
List<Widget> afterPrint = const [],
}) {
TextButton btn(String text, Color color, VoidCallback onPressed, {Key? key}) =>
@@ -31,12 +35,37 @@ 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)),
// 退退
if (!readonly && status == 'approved' && canReturn)
WriteGuard(
child: filledBtn('退单', AppTheme.danger, onReturn,
key: Key('btn_return_$orderId'))),
if (!readonly && status == 'draft') ...[
WriteGuard(child: btn('修改', AppTheme.primary, onEdit)),
WriteGuard(child: btn('删除', AppTheme.danger, onDelete)),
@@ -49,6 +78,11 @@ List<Widget> buildOrderRowActions({
WriteGuard(
child: btn('拒绝', AppTheme.danger, onReject,
key: Key('btn_reject_$orderId'))),
// 稿/ warn
if (canWithdraw)
WriteGuard(
child: btn('撤回', AppTheme.warning, onWithdraw,
key: Key('btn_withdraw_$orderId'))),
],
];
}
+103 -75
View File
@@ -81,38 +81,68 @@ jiu/
## 核心数据模型
### 关键语义:product = 「特有产品 / 序列号」(非 SKU)
**`products` 表的每一行 = 一个特有产品,以序列号(= 商品编码 `code`,如 ZXZ###### / P####)唯一标识,每个产品独一份、不复用**(像手机序列号,哪怕同款也各不相同)。一个产品的全部信息以 product 为**单一来源**
- `code` 序列号(同店唯一,`UNIQUE KEY uk_shop_code (shop_id, code)` 防并发/重复)
- `name`=**品牌**、`series`=**型号**、`spec`=**版本/规格**、`unit` 单位
- `production_date` 生产日期、`batch_no` 批次、`purchase_price` 进价、`sale_price` 售价
- `public_id`(公开页/二维码)、`product_images`(图片)、`name_pinyin`/`name_initials`(拼音搜索索引,写入时由 `util.ToPinyin` 自动生成)
**入库每录一行 = 新建一个独立 product(发新序列号)**,不按名称复用(旧 `findOrCreate` 复用逻辑已废弃);同名同型号录两次 = 两个独立序列号。`handler/product.go``nextProductCode`max+1 自增)+ `createIndependentProduct` 负责。
### 基础数据字典(「基础数据」页 `products_screen.dart` 管理)
入库选择商品时从字典选取文本,再据此建 product:
- `product_name_options`(商品名=**品牌**)、`product_series_options`(系列=**型号**)、`product_spec_options`(规格=**版本**
- 商品属性字典:`product_origin_options`(产地)、`product_shelf_life_options`(保质期)、`product_storage_options`(储存方式)、`product_description_docs`(介绍文档)——公开页展示用,product 以可空外键引用
### 库存与单据(库存/明细 = 指向 product 的引用 + 历史快照)
```yaml
inventories: # 库存批次:每行 = 某 product 在某仓库的一批(product_id + warehouse_id + quantity
# + stock_in_item_id(来源入库明细,盘盈则 inventory_check_id
# 含「快照列」product_code/product_name/series/spec/unit/unit_price/
# production_date/batch_no/supplier_name/warehouse_name —— 导入/审核时
# 从源/product 拷贝,作历史保真 + 显示兜底
stock_in_orders: # 入库单(status: draft→pending→approved/rejected;可由本人/管理员 withdraw 回 draft
stock_in_items: # 入库明细(product_id + 快照列 + quantity/unit_price/total_price/batch_no/production_date
stock_out_orders: # 出库单(同状态机)
stock_out_items: # 出库明细(同上)
inventory_logs: # 库存流水(每次变动自动记录 in/out + qty_before/after
inventory_checks / inventory_check_items: # 盘点单 / 盘点明细(FIFO 盘盈盘亏)
```
> **显示策略**:库存列表 `inventory.go``COALESCE(NULLIF(p.code,''), 快照列)`——**优先 productproduct 为空/被删才回退快照**。前端 `lineOrProduct` 同理。历史导入的出入库明细 `product_id` 多指向一个占位 product`HIST-PLACEHOLDER`),真实信息存快照列。
### 其它主表
```yaml
shops: # 门店(租户根节点)
users: # 用户(含 shop_idrole: admin/operator/readonly
licenses: # 许可证(含 device_id 绑定,type: trial/monthly/annual/lifetime
product_categories: # 商品分类
products: # 商品(含 custom_fields JSON 动态扩展)
# UNIQUE KEY uk_product_code (shop_id, code) — 防并发重复
users: # 用户(含 shop_idrole: superadmin/admin/operator/readonly
licenses / license_codes / license_devices: # 授权(时长兑换券短码 + 设备绑定
product_categories: # 商品分类(可空)
warehouses: # 仓库
partners: # 往来单位(type: supplier/customer
stock_in_orders: # 入库单(status: draft→pending→approved/rejected
stock_in_items: # 入库单明细
stock_out_orders: # 出库单
stock_out_items: # 出库单明细
inventories: # 实时库存(唯一键: shop_id+warehouse_id+product_id
inventory_logs: # 库存流水(每次变动自动记录)
inventory_checks: # 盘点单
inventory_check_items: # 盘点明细
finance_records: # 财务流水(receivable/payable/receipt/payment
number_rules: # 单号生成规则(前缀+日期+6位序号)
feedbacks: # 意见反馈(bug/suggestion + 图片)
```
## 关键业务规则
1. **多租户隔离**`shop_id` 只从 JWT 提取(`middleware.GetShopID(c)`),绝不从请求参数读取
2. **库存变更事务**:入库/出库审核时,同一事务内更新 `inventories` + 写 `inventory_logs`
3. **出库前校验**:审核出库时校验库存充足,不足返回错误并回滚
4. **单号生成**通过 `number_rules` 表事务安全生成,格式 `{前缀}{YYYYMMDD}{6位序号}`
5. **扩展字段**:所有业务主表含 `custom_fields JSON`,用于存储动态业务字段,避免频繁改表
6. **并发安全**
- `GenerateOrderNo` 在事务内用 `FOR UPDATE` 锁住 `number_rules` 行,防止并发生成重复单号
- `ApproveStockOut`/`updateInventory` 在事务内用 `FOR UPDATE` 锁住库存行,防止超卖竞态
- 商品编码 `products.code` 有 UNIQUE 约束,Create 时重试最多 5 次
2. **product = 序列号**:入库每行新建独立 product(不按名称复用);product 是商品信息单一来源,库存/明细只是指向它的引用 + 历史快照(见上节)
3. **库存变更事务**:入库/出库审核时,同一事务内更新 `inventories` + 写 `inventory_logs`
4. **出库前校验**审核出库时校验库存充足,不足返回错误并回滚
5. **单据状态机与撤回**`draft→submit→pending→approve/reject`;审核中(pending)可 **withdraw 回 draft** 再改再提交——管理员/超管撤回任意单,操作员限本人单(`OperatorID==本人`handler 内判权);已审核(approved)只读
6. **单号生成**通过 `number_rules` 表事务安全生成,格式 `{前缀}{YYYYMMDD}{6位序号}`
7. **权限角色**`superadmin`(超管,可清空数据/看反馈)> `admin`(管理员,管用户/店铺信息)> `operator`(操作员)> `readonly`(只读,所有写操作 403)。`middleware.ReadOnly()` 全局挂载、`AdminOnly()`(admin+superadmin) / `SuperAdminOnly()` 按需挂载
8. **并发安全**
- `GenerateOrderNo` 在事务内用 `FOR UPDATE` 锁住 `number_rules` 行,防并发重复单号
- `ApproveStockOut`/`updateInventory` 在事务内用 `FOR UPDATE` 锁库存行,防超卖竞态
- `products.code``(shop_id,code)` UNIQUE 约束,建 product 按最大序号+1、ErrDuplicatedKey 重试最多 5 次(不复用软删号)
## 开发脚本(scripts/dev.sh
@@ -153,68 +183,50 @@ sh scripts/dev.sh seed S001
# 服务器: mysql,用户: root,密码: password,数据库: jiu_db
```
## 已实现的 API 接口
## 已实现的 API 接口(以 `router/router.go` 为准)
```yaml
认证(无需 JWT:
POST /api/v1/auth/login
POST /api/v1/auth/refresh
无需 JWT:
GET /health /version
POST /api/v1/auth/login /api/v1/auth/refresh /api/v1/public/register
GET /api/v1/public/products/:public_id # 公开商品详情(扫码页)
GET /api/v1/public/shops/:shop_code/products # 店铺公开商品列表(仅有库存 + 数量 + code)
GET /api/v1/public/release # 版本 + download_urls
GET /product/:public_id # 注入 OG 标签的分享页
POST /api/v1/public/errors # 客户端异常上报
许可证:
POST /api/v1/license/activate
GET /api/v1/license/verify
POST /api/v1/license/deactivate
GET /api/v1/license/info # 返回当前许可证详情(含到期日、类型)
会话/许可证:
POST /api/v1/auth/logout /api/v1/auth/ping
GET /api/v1/sessions ; DELETE /api/v1/sessions/:id (AdminOnly)
GET /api/v1/license/info|verify|devices ; POST /api/v1/license/activate|deactivate
版本(无需 JWT:
GET /version # 返回最新版本信息(读取 version.yaml,缺失返回 500
GET /api/v1/public/release # 版本 + download_urls(驱动客户端更新提示与下载页)
商品(product=序列号:
GET/POST /api/v1/products ; PUT/DELETE /api/v1/products/:id
POST /api/v1/products/find-or-create
GET /api/v1/products/:id/detail /:id/qrcode
POST /api/v1/products/:id/images ; DELETE /api/v1/products/:id/images/:image_id
商品:
GET/POST /api/v1/products
PUT/DELETE /api/v1/products/:id
基础数据字典 /api/v1/product-options:
names | series | specs | origins | shelf-lives | storages | description-docs
每组 GET(支持 ?keyword 服务端搜索)/ POST / PUT/:id / DELETE/:id
仓库:
GET/POST /api/v1/warehouses
PUT/DELETE /api/v1/warehouses/:id
仓库 /warehouses · 往来单位 /partners?type&keyword: GET/POST/PUT/DELETE
往来单位:
GET/POST /api/v1/partners
PUT/DELETE /api/v1/partners/:id
入库 /stock-in/orders · 出库 /stock-out/orders:
GET(列表,?status&keyword) /POST ; GET/PUT/DELETE/:id
PUT /:id/submit | approve | reject | withdraw # withdraw=审核中撤回回草稿
库:
GET/POST /api/v1/stock-in/orders
GET /api/v1/stock-in/orders/:id
PUT /api/v1/stock-in/orders/:id/submit
PUT /api/v1/stock-in/orders/:id/approve
PUT /api/v1/stock-in/orders/:id/reject
存 /inventory:
GET ""(?warehouse_id&keyword&series&spec&in_stock) ; GET /logs
PUT /:id/remark ; POST /checks ; GET /checks/:id ; PUT /checks/:id/complete
出库:
GET/POST /api/v1/stock-out/orders
GET /api/v1/stock-out/orders/:id
PUT /api/v1/stock-out/orders/:id/submit
PUT /api/v1/stock-out/orders/:id/approve
PUT /api/v1/stock-out/orders/:id/reject
库存:
GET /api/v1/inventory
GET /api/v1/inventory/logs
GET /api/v1/inventory/batch-tracking # 批次追踪(已审核入库商品含销售状态)
POST/GET /api/v1/inventory/checks
用户管理(仅 admin 角色):
GET/POST /api/v1/users
PUT/DELETE /api/v1/users/:id
数据导入:
POST /api/v1/import/products (Excel/CSV)
POST /api/v1/import/partners (Excel/CSV)
意见反馈:
POST /api/v1/feedback # 提交反馈(bug/suggestion,文字+图片,认证)
POST /api/v1/feedback/images # 上传反馈附图(认证)
GET /api/v1/admin/feedback # 反馈列表(仅 superadmin
PATCH /api/v1/admin/feedback/:id # 标记处理状态(仅 superadmin
财务 /finance: GET /records /summary ; POST /records ; PUT /records/:id/close · /close-by-ref
用户 /users(AdminOnly): GET/POST ; PUT/:id ; PUT /:id/reset-password
店铺 /shop: GET /info ; PUT /info、POST /logo (AdminOnly)
编号规则 /number-rules: GET ""; PUT /:id
意见反馈 /feedback: POST ""、/images
数据导入 /import: products | partners | product-names|series|specs|codes | stock-in | stock-out | inventory (Excel)
超管 /admin(SuperAdminOnly): POST /clear-data ; GET /reconcile /errors /feedback ; PATCH /feedback/:id
```
## Flutter 客户端结构
@@ -386,7 +398,23 @@ DataTableCard(
| Android | 签名 `jiu-android.apk` | 下载页 `/downloads/`(见 docs/android-signing.md |
| iOS | 签名 IPA | TestFlight(见 docs/ios-signing.md |
版本清单`backend/config/version.yaml``download_urls`(含各平台下载地址);客户端 `GET /api/v1/public/release` 读取,驱动更新提示与下载页
版本清单 `version.yaml`(归 **client**,写 version/build_number/release_notes/下载链接/changelog);后端 `GET /version``/api/v1/public/release` **每请求实时读取**,client 部署后立即生效,无需重启后端、无需重建官网
### 三条独立发版流水线(互不影响,各有 tag 前缀 / CHANGELOG / workflow
| part | 范围 | tag 前缀 | CHANGELOG | workflow |
|------|------|---------|-----------|----------|
| **client** | `client/` Flutter 全平台 + `version.yaml` | `client-v*` | `CHANGELOG-client.md` | `deploy-client.yml` |
| **site** | `web/` Eleventy 营销宣传站(不含 Web 版 app) | `site-v*` | `CHANGELOG-site.md` | `deploy-site.yml` |
| **server** | `backend/` Go 服务 + 共享基建(nginx/systemd) | `server-v*` | `CHANGELOG-server.md` | `deploy-server.yml` |
`/release <part> [version]` slash command:本地 build→test→更新 CHANGELOG→commit→tag→pushCI(Forgejo) 按 tag 前缀触发对应 workflow 自动编译/测试/发 Release/部署 EC2/Telegram 通知。**测试未过禁止发版**。
### 生产环境与运维
- 生产:EC2`ec2-user@18.136.60.128`),jiu 为宿主 systemd 服务,MySQL 在容器 `jiu_mysql`(映射 127.0.0.1:3306)。配置 `/opt/jiu/config/production.env`DATABASE_DSN)。
- CI runnermac runner=开发者本机(launchd + relay 绕 Shadowrocket,有看门狗自愈)、windows runner=另一台机(nssm 服务 `forgejo-runner`)、ubuntu=NAS docker。Forgejo 在 NAS`git.51yanmei.com`)。
- 一次性数据工具:`cmd/import-history`(旧系统进销存批量导入)、`cmd/fix-inventory-products`(修复历史库存 product_id 错指)。
## 文档索引
+218
View File
@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>退单 原型 — 酒库管理系统</title>
<style>
:root{
--primary:#2563AC; --primary-dark:#154072; --danger:#D14343; --danger-bg:#FDECEC;
--success:#2E8B57; --warn:#B45309; --accent:#8B2331;
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
}
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
body{margin:0;background:var(--paper);color:var(--ink);padding:28px;}
h1{font-size:18px;margin:0 0 4px;}
.sub{color:var(--muted);font-size:13px;margin-bottom:20px;}
.toggle{display:inline-flex;border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:18px;}
.toggle button{border:0;background:#fff;padding:8px 18px;font-size:13px;cursor:pointer;color:var(--muted);}
.toggle button.on{background:var(--primary);color:#fff;font-weight:600;}
/* 入口:已审核单据行 */
.listrow{background:#fff;border:1px solid var(--border);border-radius:8px;padding:12px 16px;display:flex;align-items:center;gap:14px;max-width:780px;margin-bottom:24px;}
.mono{font-family:ui-monospace,Menlo,monospace;font-size:14px;}
.badge{font-size:11px;padding:2px 8px;border-radius:10px;}
.badge.approved{background:#E6F3EC;color:var(--success);}
.badge.returned{background:var(--danger-bg);color:var(--danger);}
.badge.partial{background:#FFF4E5;color:var(--warn);}
.spacer{flex:1;}
.btn{border:0;border-radius:6px;padding:7px 14px;font-size:12px;cursor:pointer;}
.btn.link{background:transparent;color:var(--primary);padding:6px 8px;}
.btn.error{background:var(--danger);color:#fff;}
.btn.error-o{background:#fff;border:1px solid var(--danger);color:var(--danger);}
.btn.ghost{background:#fff;border:1px solid var(--border);color:var(--ink);}
.btn.primary{background:var(--primary);color:#fff;}
.btn:disabled{opacity:.45;cursor:not-allowed;}
/* 弹窗 */
.scrim{position:fixed;inset:0;background:rgba(20,24,32,.45);display:none;align-items:center;justify-content:center;z-index:10;}
.scrim.show{display:flex;}
.dialog{background:#fff;border-radius:10px;width:880px;max-width:94vw;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 12px 40px rgba(0,0,0,.25);}
.dhead{display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--border);}
.dhead h2{font-size:15px;margin:0;}
.dmeta{display:flex;gap:22px;padding:10px 18px;background:var(--paper);font-size:12px;color:var(--muted);flex-wrap:wrap;}
.dmeta b{color:var(--ink);font-weight:600;}
.dbody{overflow:auto;padding:0 18px;}
table{width:100%;border-collapse:collapse;font-size:13px;}
th{position:sticky;top:0;background:var(--head);color:var(--primary-dark);font-weight:600;font-size:12px;text-align:left;padding:9px 10px;}
td{padding:9px 10px;border-bottom:1px solid #EEF1F5;}
td.num,th.num{text-align:right;}
tr.returned td{color:var(--danger);text-decoration:line-through;text-decoration-color:rgba(209,67,67,.6);background:repeating-linear-gradient(45deg,#FFF6F6,#FFF6F6 8px,#FFECEC 8px,#FFECEC 16px);}
tr.returned td .keep{text-decoration:none;display:inline-block;}
.rbadge{font-size:10px;background:var(--danger);color:#fff;padding:1px 7px;border-radius:8px;text-decoration:none;}
.dfoot{display:flex;align-items:center;gap:12px;padding:14px 18px;border-top:1px solid var(--border);}
.foot-note{font-size:12px;color:var(--muted);}
.foot-note .hot{color:var(--danger);font-weight:600;}
.x{margin-left:auto;border:0;background:transparent;font-size:18px;cursor:pointer;color:var(--muted);}
.hint{font-size:12px;color:var(--muted);background:#F0F6FF;border-left:3px solid var(--primary);padding:8px 12px;border-radius:4px;margin:10px 18px 0;}
/* 确认小窗 */
.confirm{z-index:20;}
.cbox{background:#fff;border-radius:10px;width:380px;max-width:92vw;padding:18px;}
.cbox h3{margin:0 0 8px;font-size:15px;}
.cbox p{font-size:13px;color:#444;margin:0 0 16px;line-height:1.6;}
.cbox .row{display:flex;justify-content:flex-end;gap:8px;}
</style>
</head>
<body>
<h1>退单 原型(已审核单据)</h1>
<div class="sub">出库单退单 → 数量加回库存;入库单退单 → 从库存删除。点按钮体验流程(纯原型,不连后端)。</div>
<div class="toggle">
<button id="tOut" class="on" onclick="setType('out')">出库单退单</button>
<button id="tIn" onclick="setType('in')">入库单退单</button>
</div>
<!-- 入口行 -->
<div class="listrow">
<span class="mono" id="entryNo">CKD20260621000012</span>
<span class="badge approved" id="entryBadge">已审核</span>
<span class="spacer"></span>
<button class="btn link">详情</button>
<button class="btn link">打印</button>
<button class="btn link" style="color:var(--accent)">结清</button>
<!-- 退单:error 样式 -->
<button class="btn error" onclick="openConfirm('entry')">退单</button>
</div>
<!-- 退单弹窗 -->
<div class="scrim" id="scrim">
<div class="dialog">
<div class="dhead">
<h2 id="dTitle">出库单退单 — CKD20260621000012</h2>
<button class="btn error-o" style="margin-left:14px" onclick="openConfirm('all')">全部退单</button>
<button class="x" onclick="cancelAll()"></button>
</div>
<div class="dmeta">
<span id="mPartnerLbl">客户:<b id="mPartner">张三烟酒行</b></span>
<span>仓库:<b>主仓</b></span>
<span id="mDateLbl">出库日期:<b>2026-06-21</b></span>
<span>状态:<b style="color:var(--success)">已审核</b></span>
</div>
<div class="hint" id="behaviorHint">提交后,已退单明细的数量将 <b>加回库存</b>(新建库存批次)。退单的明细不会删除,以红色删除线标注「已退单」。</div>
<div class="dbody">
<table>
<thead><tr>
<th>#</th><th>编码</th><th>名称</th><th>系列</th><th>规格</th>
<th class="num">数量</th><th class="num">单价</th><th class="num">金额</th><th style="width:96px">操作</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
<div class="dfoot">
<span class="foot-note" id="footNote">未选择退单明细</span>
<span class="spacer" style="flex:1"></span>
<button class="btn ghost" onclick="cancelAll()">取消</button>
<button class="btn error" id="submitBtn" disabled onclick="openConfirm('submit')">提交退单</button>
</div>
</div>
</div>
<!-- 通用确认小窗 -->
<div class="scrim confirm" id="confirm">
<div class="cbox">
<h3 id="cTitle">确认</h3>
<p id="cMsg"></p>
<div class="row">
<button class="btn ghost" onclick="closeConfirm()">取消</button>
<button class="btn error" id="cOk">确认</button>
</div>
</div>
</div>
<script>
const data = [
{code:'ZXZ010508', name:'贵州茅台酒', series:'飞天53度', spec:'500ml', qty:2, price:1499},
{code:'ZXZ027882', name:'五粮液', series:'第八代', spec:'500ml', qty:1, price:1080},
{code:'ZXZ016161', name:'国窖1573', series:'经典', spec:'500ml', qty:3, price:980},
{code:'ZXZ018666', name:'剑南春', series:'水晶剑', spec:'500ml', qty:6, price:430},
];
let type='out';
let returned = new Set(); // 已暂存退单的行 index
function setType(t){
type=t; returned.clear();
document.getElementById('tOut').classList.toggle('on',t==='out');
document.getElementById('tIn').classList.toggle('on',t==='in');
const isOut = t==='out';
document.getElementById('entryNo').textContent = isOut?'CKD20260621000012':'RKD20260621000034';
document.getElementById('dTitle').textContent = (isOut?'出库单退单 — CKD20260621000012':'入库单退单 — RKD20260621000034');
document.getElementById('mPartnerLbl').innerHTML = isOut?'客户:<b>张三烟酒行</b>':'供应商:<b>鼎晟供应链</b>';
document.getElementById('mDateLbl').innerHTML = isOut?'出库日期:<b>2026-06-21</b>':'入库日期:<b>2026-06-21</b>';
document.getElementById('behaviorHint').innerHTML = isOut
? '提交后,已退单明细的数量将 <b>加回库存</b>(新建库存批次)。退单的明细不会删除,以红色删除线标注「已退单」。'
: '提交后,已退单明细对应的库存将 <b>从库存中删除</b>。退单的明细不会删除,以红色删除线标注「已退单」。';
render();
}
function render(){
const tb=document.getElementById('rows'); tb.innerHTML='';
data.forEach((it,i)=>{
const isR=returned.has(i);
const tr=document.createElement('tr');
if(isR) tr.className='returned';
tr.innerHTML=`
<td>${i+1}</td>
<td class="mono">${it.code}</td>
<td>${it.name} ${isR?'<span class="rbadge">已退单</span>':''}</td>
<td>${it.series}</td><td>${it.spec}</td>
<td class="num">${it.qty}</td>
<td class="num">¥${it.price}</td>
<td class="num">¥${it.qty*it.price}</td>
<td><span class="keep">${ isR
? `<button class="btn link" onclick="undo(${i})">撤销</button>`
: `<button class="btn error-o" onclick="openConfirm('item',${i})">退单</button>`}</span></td>`;
tb.appendChild(tr);
});
const n=returned.size;
let sumQty=0; returned.forEach(i=>sumQty+=data[i].qty);
const fn=document.getElementById('footNote');
fn.innerHTML = n===0 ? '未选择退单明细'
: `已退单 <span class="hot">${n}</span> 项 · ${type==='out'?'退回库存':'从库存移除'} <span class="hot">${type==='out'?'+':''}${sumQty}</span> 件`;
document.getElementById('submitBtn').disabled = n===0;
// 入口徽章随暂存变化(仅示意)
const eb=document.getElementById('entryBadge');
if(n===0){eb.className='badge approved';eb.textContent='已审核';}
else if(n<data.length){eb.className='badge partial';eb.textContent='部分退单';}
else {eb.className='badge returned';eb.textContent='已退单';}
}
function undo(i){ returned.delete(i); render(); }
// 确认窗
let pending=null;
function openConfirm(kind,idx){
pending={kind,idx};
const c=document.getElementById('confirm');
const t=document.getElementById('cTitle'), m=document.getElementById('cMsg');
if(kind==='entry'){ t.textContent='退单确认'; m.textContent='确认对该已审核单据发起退单?将打开退单明细窗口。'; }
else if(kind==='item'){ const it=data[idx]; t.textContent='退单确认'; m.innerHTML=`确认退掉以下明细?<br><b>${it.name}</b> · ${it.series} · ${it.spec}<br><span style="color:var(--muted)">编码 ${it.code} · 数量 ${it.qty}</span>`; }
else if(kind==='all'){ t.textContent='全部退单确认'; m.textContent='确认退掉本单全部明细?'; }
else if(kind==='submit'){ t.textContent='提交退单'; m.innerHTML=`确认提交退单?${type==='out'?'退回库存':'从库存删除'}将立即生效,且不可撤销。`; }
c.classList.add('show');
}
function closeConfirm(){ document.getElementById('confirm').classList.remove('show'); pending=null; }
document.getElementById('cOk').onclick=()=>{
if(!pending) return;
const {kind,idx}=pending;
if(kind==='entry'){ closeConfirm(); document.getElementById('scrim').classList.add('show'); render(); return; }
if(kind==='item'){ returned.add(idx); }
if(kind==='all'){ data.forEach((_,i)=>returned.add(i)); }
if(kind==='submit'){ closeConfirm(); alert('(原型)退单已提交成功,库存已更新。'); cancelAll(); return; }
closeConfirm(); render();
};
function cancelAll(){ returned.clear(); document.getElementById('scrim').classList.remove('show'); render(); }
render();
</script>
</body>
</html>