Compare commits

..

15 Commits

Author SHA1 Message Date
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
wangjia fcc0fd988b chore: release client-v1.0.68
Deploy Client / build-client-web (push) Successful in 38s
Deploy Client / build-windows (push) Successful in 1m53s
Deploy Client / build-macos (push) Successful in 2m7s
Deploy Client / build-android (push) Successful in 59s
Deploy Client / build-ios (push) Successful in 2m36s
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 18:14:54 +08:00
wangjia 203cc7ce21 chore: release server-v1.0.71
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 18:14:27 +08:00
wangjia 6d70bd3e37 chore: release client-v1.0.67
Deploy Client / build-client-web (push) Successful in 41s
Deploy Client / build-windows (push) Successful in 1m52s
Deploy Client / build-macos (push) Successful in 2m33s
Deploy Client / build-android (push) Successful in 1m6s
Deploy Client / build-ios (push) Successful in 2m42s
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 17:33:32 +08:00
wangjia 7b67466a3e chore: release server-v1.0.70
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 17:32:55 +08:00
wangjia 182adca282 feat(client): 列表日期列改名入库/出库时间删审核时间列 + 日期控件改可键入框带日历
Deploy Client / build-client-web (push) Successful in 41s
Deploy Client / build-windows (push) Successful in 2m34s
Deploy Client / build-macos (push) Successful in 2m20s
Deploy Client / build-android (push) Successful in 1m5s
Deploy Client / build-ios (push) Successful in 2m44s
Deploy Client / release-deploy-client (push) Successful in 1m26s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 16:39:32 +08:00
wangjia 025f3b7bbc feat(client): 入库录入去除按名称复用,明细带产品信息由后端建独立产品
Deploy Client / build-client-web (push) Successful in 40s
Deploy Client / build-macos (push) Successful in 2m14s
Deploy Client / build-android (push) Successful in 1m31s
Deploy Client / build-ios (push) Successful in 2m53s
Deploy Client / build-windows (push) Has been cancelled
Deploy Client / release-deploy-client (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 16:30:37 +08:00
38 changed files with 2152 additions and 363 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.70] - 2026-06-21
### 修复
- 修复超级管理员看不到「撤回」按钮的问题(此前仅 admin 角色显示,superadmin 被漏掉)
- 入库单批次号改为必填项,未填写会拦截提交
### 改进
- 「撤回」按钮:操作员现在也能撤回本人提交的审核中单据(管理员/超管仍可撤回任意单据);按钮改用警示(amber)样式
- 新建入库单:生产日期列加宽,完整日期(如 2024-05-12)不再显示不全
## [1.0.69] - 2026-06-21
### 新功能
- 入库/出库审核列表:管理员可「撤回」审核中的单据,单据回到草稿,修改后重新提交审核
### 改进
- 新建入库单:批次号移到「生产日期」右侧、与各列对齐填写;生产日期输入框收窄,不再占用过宽
## [1.0.68] - 2026-06-21
### 改进
- 店铺公开商品页:每个商品卡片显示「在库 N」数量,且只展示有货商品
## [1.0.67] - 2026-06-21
### 改进
- 选择器改为服务端全量搜索:选供应商/客户时按关键词查全部往来单位(不再只在已加载的首页里找);出库「选择商品」弹窗的搜索也改为实时查全部库存,输入即按编码/名称/系列匹配,不再漏掉未加载的商品
## [1.0.66] - 2026-06-21
### 改进
- 入库/出库列表「日期」列改名为「入库时间/出库时间」,移除恒为空的旧「审核时间」列
- 日期选择改为可直接键入的输入框 + 日历图标:能直接打年份(选老年份方便),支持只填到年或年月(自动补为月初)
## [1.0.65] - 2026-06-21
### 改进
- 入库录入改为「每行一个独立产品」:每条明细对应一个独立编号,同名同规格录多次也各自独立(配合后端 server-v1.0.69
## [1.0.64] - 2026-06-20
### 新功能
+28
View File
@@ -5,6 +5,34 @@
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.74] - 2026-06-21
### 新功能
- 已审核的入库单/出库单支持「退单」:可整单退或按明细行退货。出库单退单的商品数量加回库存、入库单退单对应库存从库存删除,并自动冲减对应的应付/应收账款。权限:入库退单限管理员/超管;出库退单管理员/超管或本人。已被出库消耗的入库明细禁止退单。
### 修复
- 库存导入改为按商品编号匹配商品、不再按名称合并,避免再次导入产生重复编号
## [1.0.73] - 2026-06-21
### 改进
- 撤回审核中单据的权限放宽:管理员/超管可撤回任意单据,普通操作员可撤回本人提交的单据(此前仅管理员)
## [1.0.72] - 2026-06-21
### 新功能
- 审核中(待审核)的入库单/出库单,管理员可「撤回」为草稿,修改后重新提交审核;已审核单据仍只读不可撤回
## [1.0.71] - 2026-06-21
### 改进
- 公开店铺商品页只展示「当前有库存」的商品,并返回每个商品的在库数量与商品编码(序列号),无货商品不再露出
## [1.0.70] - 2026-06-21
### 改进
- 商品名称/系列/规格选择接口支持关键词搜索(按名称或编码),为客户端选择器的服务端全量搜索提供支撑
## [1.0.69] - 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)
}
}
+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},
+19 -3
View File
@@ -2,6 +2,7 @@ package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -24,7 +25,12 @@ func NewProductOptionHandler(db *gorm.DB) *ProductOptionHandler {
func (h *ProductOptionHandler) ListNames(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductNameOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
q := h.db.Where("shop_id = ?", shopID)
if kw := strings.TrimSpace(c.Query("keyword")); kw != "" {
like := "%" + kw + "%"
q = q.Where("name LIKE ? OR code LIKE ?", like, like)
}
q.Order("id ASC").Find(&items)
util.RespondSuccess(c, items)
}
@@ -85,7 +91,12 @@ func (h *ProductOptionHandler) DeleteName(c *gin.Context) {
func (h *ProductOptionHandler) ListSeries(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductSeriesOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
q := h.db.Where("shop_id = ?", shopID)
if kw := strings.TrimSpace(c.Query("keyword")); kw != "" {
like := "%" + kw + "%"
q = q.Where("name LIKE ? OR code LIKE ?", like, like)
}
q.Order("id ASC").Find(&items)
util.RespondSuccess(c, items)
}
@@ -146,7 +157,12 @@ func (h *ProductOptionHandler) DeleteSeries(c *gin.Context) {
func (h *ProductOptionHandler) ListSpecs(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductSpecOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
q := h.db.Where("shop_id = ?", shopID)
if kw := strings.TrimSpace(c.Query("keyword")); kw != "" {
like := "%" + kw + "%"
q = q.Where("name LIKE ? OR code LIKE ?", like, like)
}
q.Order("id ASC").Find(&items)
util.RespondSuccess(c, items)
}
@@ -0,0 +1,86 @@
package handler
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/testutil"
)
// setupOptionRouter 注册 product-options 名称路由(含 JWT)。
func setupOptionRouter(db *gorm.DB) *gin.Engine {
h := NewProductOptionHandler(db)
r := gin.New()
r.Use(gin.Recovery())
api := r.Group("/api/v1")
api.Use(middleware.JWT(db))
names := api.Group("/product-options/names")
names.GET("", h.ListNames)
return r
}
func TestProductOptionHandler_ListNamesKeyword(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "OPT001")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupOptionRouter(db)
db.Create(&model.ProductNameOption{
TenantBase: model.TenantBase{ShopID: shop.ID}, Code: "P001", Name: "茅台",
})
db.Create(&model.ProductNameOption{
TenantBase: model.TenantBase{ShopID: shop.ID}, Code: "P002", Name: "五粮液",
})
// 1. 无 keyword → 返回全部
w := makeRequest(r, "GET", "/api/v1/product-options/names", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
data := parseResponse(w)["data"].([]interface{})
assert.Len(t, data, 2)
// 2. keyword 命中名称
w = makeRequest(r, "GET", "/api/v1/product-options/names?keyword=五粮", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
data = parseResponse(w)["data"].([]interface{})
assert.Len(t, data, 1)
assert.Equal(t, "五粮液", data[0].(map[string]interface{})["name"])
// 3. keyword 命中编码
w = makeRequest(r, "GET", "/api/v1/product-options/names?keyword=P001", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
data = parseResponse(w)["data"].([]interface{})
assert.Len(t, data, 1)
assert.Equal(t, "茅台", data[0].(map[string]interface{})["name"])
// 4. keyword 无命中 → 空
w = makeRequest(r, "GET", "/api/v1/product-options/names?keyword=不存在", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
data = parseResponse(w)["data"].([]interface{})
assert.Len(t, data, 0)
}
func TestProductOptionHandler_ListNamesIsolation(t *testing.T) {
db := testutil.SetupTestDB()
shopA := testutil.CreateTestShop(db, "OPT_A")
userA := testutil.CreateTestUser(db, shopA.ID, "adminA", "pass", "admin")
tokenA := getAuthToken(userA.ID, shopA.ID, "admin")
shopB := testutil.CreateTestShop(db, "OPT_B")
r := setupOptionRouter(db)
db.Create(&model.ProductNameOption{
TenantBase: model.TenantBase{ShopID: shopB.ID}, Name: "他店商品",
})
// A 店即使 keyword 命中 B 店数据也查不到(shop_id 隔离)
w := makeRequest(r, "GET", "/api/v1/product-options/names?keyword=他店", tokenA, nil)
assert.Equal(t, http.StatusOK, w.Code)
data := parseResponse(w)["data"].([]interface{})
assert.Len(t, data, 0)
}
+34 -2
View File
@@ -202,12 +202,14 @@ type publicProductImage struct {
type publicProductResp struct {
ID uint64 `json:"id"`
PublicID string `json:"public_id"`
Code string `json:"code"`
Name string `json:"name"`
Series string `json:"series"`
Spec string `json:"spec"`
Brand string `json:"brand"`
Unit string `json:"unit"`
SalePrice float64 `json:"sale_price"`
Quantity float64 `json:"quantity"`
Images []publicProductImage `json:"images"`
}
@@ -234,8 +236,15 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
pageSize = 20
}
// 仅列「有库存」的商品:JOIN 库存按 product 聚合(数量>0)的子查询。
stockSub := h.db.Model(&model.Inventory{}).
Select("product_id, SUM(quantity) AS qty").
Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0", shop.ID).
Group("product_id")
query := h.db.Model(&model.Product{}).
Where("shop_id = ? AND public_id IS NOT NULL AND public_id != '' AND deleted_at IS NULL", shop.ID)
Joins("JOIN (?) AS stk ON stk.product_id = products.id", stockSub).
Where("products.shop_id = ? AND products.public_id IS NOT NULL AND products.public_id != '' AND products.deleted_at IS NULL", shop.ID)
var total int64
if err := query.Count(&total).Error; err != nil {
@@ -248,12 +257,33 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
if err := query.Preload("Images").
Offset(offset).
Limit(pageSize).
Order("id DESC").
Order("products.id DESC").
Find(&products).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 取本页商品的在库总量(IN 限定在本页 ≤pageSize 个 id,开销小)
qtyMap := make(map[uint64]float64, len(products))
if len(products) > 0 {
pageIDs := make([]uint64, len(products))
for i, p := range products {
pageIDs[i] = p.ID
}
var stockRows []struct {
ProductID uint64
Qty float64
}
h.db.Model(&model.Inventory{}).
Select("product_id, SUM(quantity) AS qty").
Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0 AND product_id IN ?", shop.ID, pageIDs).
Group("product_id").
Scan(&stockRows)
for _, s := range stockRows {
qtyMap[s.ProductID] = s.Qty
}
}
listData := make([]publicProductResp, len(products))
for i, p := range products {
imgs := make([]publicProductImage, len(p.Images))
@@ -263,12 +293,14 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
listData[i] = publicProductResp{
ID: p.ID,
PublicID: p.PublicID,
Code: p.Code,
Name: p.Name,
Series: p.Series,
Spec: p.Spec,
Brand: p.Brand,
Unit: p.Unit,
SalePrice: p.SalePrice,
Quantity: qtyMap[p.ID],
Images: imgs,
}
}
+125
View File
@@ -0,0 +1,125 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/testutil"
)
func setupPublicRouter(db *gorm.DB) *gin.Engine {
h := NewPublicHandler(db)
r := gin.New()
r.Use(gin.Recovery())
r.GET("/api/v1/public/shops/:shop_code/products", h.ListShopProducts)
return r
}
// 给商品补 public_idCreateTestProduct 默认不设)
func setPublicID(db *gorm.DB, productID uint64, pub string) {
require := func(err error) {
if err != nil {
panic(err)
}
}
require(db.Model(&model.Product{}).Where("id = ?", productID).Update("public_id", pub).Error)
}
func addInventory(db *gorm.DB, shopID, warehouseID, productID uint64, qty float64) {
wid := warehouseID
pid := productID
if err := db.Create(&model.Inventory{
ShopID: shopID,
WarehouseID: &wid,
ProductID: &pid,
Quantity: qty,
}).Error; err != nil {
panic(err)
}
}
func TestPublicHandler_ListShopProducts_InStockOnly(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PUB001")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
r := setupPublicRouter(db)
// A:有库存 5 → 应出现,quantity=5
pa := testutil.CreateTestProduct(db, shop.ID, "茅台A")
setPublicID(db, pa.ID, "pub-a")
addInventory(db, shop.ID, wh.ID, pa.ID, 5)
// B:有 public_id 但无库存 → 不出现
pb := testutil.CreateTestProduct(db, shop.ID, "五粮液B")
setPublicID(db, pb.ID, "pub-b")
// C:两条库存 3+2 → 出现,quantity=5
pc := testutil.CreateTestProduct(db, shop.ID, "汾酒C")
setPublicID(db, pc.ID, "pub-c")
addInventory(db, shop.ID, wh.ID, pc.ID, 3)
addInventory(db, shop.ID, wh.ID, pc.ID, 2)
// D:有库存但无 public_id → 不出现
pd := testutil.CreateTestProduct(db, shop.ID, "无公开D")
addInventory(db, shop.ID, wh.ID, pd.ID, 9)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/public/shops/PUB001/products", nil)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
// 只 A、C 两个有库存 + 有 public_id
assert.Equal(t, float64(2), resp["total"])
data := resp["data"].([]interface{})
require.Len(t, data, 2)
byName := map[string]map[string]interface{}{}
for _, it := range data {
m := it.(map[string]interface{})
byName[m["name"].(string)] = m
}
require.Contains(t, byName, "茅台A")
require.Contains(t, byName, "汾酒C")
assert.NotContains(t, byName, "五粮液B") // 无库存
assert.NotContains(t, byName, "无公开D") // 无 public_id
// 数量正确(C 聚合 3+2=5
assert.Equal(t, float64(5), byName["茅台A"]["quantity"])
assert.Equal(t, float64(5), byName["汾酒C"]["quantity"])
// 带上了序列号 code
assert.Equal(t, "P-茅台A", byName["茅台A"]["code"])
}
func TestPublicHandler_ListShopProducts_Isolation(t *testing.T) {
db := testutil.SetupTestDB()
shopA := testutil.CreateTestShop(db, "PUBA")
shopB := testutil.CreateTestShop(db, "PUBB")
whB := testutil.CreateTestWarehouse(db, shopB.ID, "仓B")
r := setupPublicRouter(db)
// B 店有个有库存商品
pb := testutil.CreateTestProduct(db, shopB.ID, "他店商品")
setPublicID(db, pb.ID, "pub-x")
addInventory(db, shopB.ID, whB.ID, pb.ID, 7)
// 查 A 店:看不到 B 店商品
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/public/shops/"+shopA.Code+"/products", nil)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, float64(0), resp["total"])
}
+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")
+59
View File
@@ -1,6 +1,7 @@
package handler
import (
"errors"
"net/http"
"strconv"
"strings"
@@ -240,3 +241,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,190 @@
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}},
})
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)
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
var neg int64
db.Model(&model.FinanceRecord{}).
Where("shop_id = ? AND type = 'receivable' AND amount < 0 AND ref_type = 'stock_out_return'", shop.ID).
Count(&neg)
assert.Equal(t, int64(1), neg)
}
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")
+8
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"`
@@ -89,6 +95,8 @@ type StockOutItem 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"`
+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)
}
// 库存
+221
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,224 @@ 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
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: "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
+43
View File
@@ -241,6 +241,45 @@ func SetupTestDB() *gorm.DB {
content TEXT,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS product_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
shop_id INTEGER NOT NULL,
url TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
created_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS product_name_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS product_series_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS product_spec_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
quantity INTEGER DEFAULT 0,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS warehouses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
@@ -288,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
)`,
@@ -307,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,
@@ -329,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
)`,
@@ -348,6 +390,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,
+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);
@@ -123,4 +123,9 @@ 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();
}
}
@@ -123,4 +123,9 @@ 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();
}
}
@@ -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,15 @@ 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,
);
}
}
}
@@ -119,4 +119,15 @@ 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,
);
}
}
}
@@ -269,6 +269,12 @@ class _ProductCard extends StatelessWidget {
final name = item['name'] as String? ?? '';
final series = item['series'] as String? ?? '';
final spec = item['spec'] as String? ?? '';
final unit = item['unit'] as String? ?? '';
final qtyNum = (item['quantity'] as num?)?.toDouble() ?? 0;
// 去掉整数的 .0 尾巴:5.0 → 51.5 → 1.5
final qtyStr = qtyNum == qtyNum.roundToDouble()
? qtyNum.toInt().toString()
: qtyNum.toString();
final images = (item['images'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
final imageUrl = images.isNotEmpty
@@ -339,7 +345,18 @@ class _ProductCard extends StatelessWidget {
const Spacer(),
Row(
children: [
const Spacer(),
Flexible(
child: Text(
'在库 $qtyStr$unit',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: _kBurgundy,
fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
@@ -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) {
@@ -11,7 +11,6 @@ import '../../models/stock_in.dart';
import '../../core/config/app_constants.dart';
import '../../providers/partner_provider.dart';
import '../../providers/product_option_provider.dart';
import '../../providers/product_provider.dart';
import '../../providers/inventory_provider.dart';
import '../../providers/stock_in_provider.dart';
import '../../providers/warehouse_provider.dart';
@@ -301,63 +300,35 @@ 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;
}
}
}
setState(() => _submitting = true);
// Resolve product IDs for items where productId is null
// 入库每行 = 一个特有产品:明细只带 名称/系列/规格(字典文本) + 批次/生产日期,
// 由后端为每行新建独立产品(序列号),前端不再 findOrCreate 复用。
final nameOpts = ref.read(productNameListProvider).valueOrNull ?? [];
final seriesOpts = ref.read(productSeriesListProvider).valueOrNull ?? [];
final specOpts = ref.read(productSpecListProvider).valueOrNull ?? [];
String optName(List<dynamic> opts, int? id) =>
opts.where((o) => o.id == id).firstOrNull?.name as String? ?? '';
for (final item in _items) {
if (item.productId == null) {
final name = nameOpts
.where((o) => o.id == item.selectedNameId)
.firstOrNull
?.name ??
'';
final series = seriesOpts
.where((o) => o.id == item.selectedSeriesId)
.firstOrNull
?.name ??
'';
final spec = specOpts
.where((o) => o.id == item.selectedSpecId)
.firstOrNull
?.name ??
'';
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请选择商品名称'), backgroundColor: AppTheme.danger),
);
setState(() => _submitting = false);
return;
}
try {
final product =
await ref.read(productRepositoryProvider).findOrCreate(
name: name,
series: series,
spec: spec,
originId: item.selectedOriginId,
shelfLifeId: item.selectedShelfLifeId,
storageId: item.selectedStorageId,
descriptionDocId: item.selectedDescriptionDocId,
);
item.productId = product.id;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('商品查找失败:$e'), backgroundColor: AppTheme.danger),
);
setState(() => _submitting = false);
}
return;
}
if (optName(nameOpts, item.selectedNameId).isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请选择商品名称'), backgroundColor: AppTheme.danger),
);
setState(() => _submitting = false);
return;
}
}
@@ -367,7 +338,9 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
final batchNo = item.batchNoCtrl.text.trim();
final productionDate = item.productionDateCtrl.text.trim();
return {
'product_id': item.productId ?? 0,
'product_name': optName(nameOpts, item.selectedNameId),
'series': optName(seriesOpts, item.selectedSeriesId),
'spec': optName(specOpts, item.selectedSpecId),
'quantity': qty,
'unit_price': price,
'total_price': qty * price,
@@ -639,6 +612,20 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
hint: '请选择供应商',
dialogTitle: '选择供应商',
isRequired: true,
onSearch: (kw) async {
final res = await ref
.read(partnerRepositoryProvider)
.list(
type: 'supplier',
keyword: kw,
pageSize: 50);
return res.data
.map((p) => OptionItem(
id: p.id,
name: p.name,
code: p.code))
.toList();
},
onChanged: (v) =>
setState(() => _partnerId = v),
),
@@ -893,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,
);
}
@@ -1014,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('金额')),
@@ -1038,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;
@@ -1071,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(
@@ -1092,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,
@@ -1152,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(
@@ -1191,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)}'),
@@ -1199,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,8 @@ 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 '../../core/auth/auth_state.dart'
show isAdminProvider, currentUserIdProvider;
class StockInListScreen extends ConsumerStatefulWidget {
const StockInListScreen({super.key});
@@ -49,8 +51,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
ColDef('warehouse', '仓库'),
ColDef('amount', '金额', minWidth: 800),
ColDef('status', '状态'),
ColDef('date', '日期', minWidth: 900),
ColDef('reviewed_at', '入库时间', minWidth: 900),
ColDef('date', '入库时间', minWidth: 900),
ColDef('operator', '入库员', minWidth: 1100),
ColDef('reviewer', '审核员', minWidth: 1100),
ColDef('actions', '操作', required: true),
@@ -270,12 +271,6 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
return DataCell(StatusBadge(_apiStatusToEnum(o.status)));
case 'date':
return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-'));
case 'reviewed_at':
return DataCell(Text(o.reviewedAt != null
? o.reviewedAt!.length >= 16
? o.reviewedAt!.substring(0, 16)
: o.reviewedAt!.substring(0, 10)
: '-'));
case 'operator':
return DataCell(Text(o.operatorName ?? '-',
style: const TextStyle(fontSize: 13)));
@@ -485,6 +480,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),
@@ -532,6 +530,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
onSubmit: () => _confirmSubmit(context, o),
onApprove: () => _confirmApprove(context, o),
onReject: () => _confirmReject(context, o),
onWithdraw: () => _confirmWithdraw(context, o),
);
}
@@ -550,7 +549,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
o.totalAmount != null
? '¥${o.totalAmount!.toStringAsFixed(2)}'
: '-'),
MobileCardField('日期', o.orderDate?.substring(0, 10) ?? '-'),
MobileCardField('入库时间', o.orderDate?.substring(0, 10) ?? '-'),
],
actions: _orderActions(context, o),
);
@@ -761,6 +760,43 @@ 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));
}
}
}
}
}
// Detail dialog — fetches full order with items
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -7,6 +9,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/auth/auth_state.dart';
import '../../core/utils/print_util.dart';
import '../../core/utils/date_util.dart';
import '../../models/inventory.dart';
import '../../models/stock_out.dart';
import '../../widgets/date_picker_field.dart';
import '../../widgets/searchable_option_field.dart';
@@ -39,6 +42,39 @@ class _PickerItem {
});
}
/// product product
List<_PickerItem> _aggregatePickerItems(List<Inventory> rows) {
final Map<int, _PickerItem> map = {};
for (final inv in rows.where((inv) => inv.productId != null)) {
final pid = inv.productId!;
final existing = map[pid];
if (existing != null) {
map[pid] = _PickerItem(
productId: pid,
productCode: existing.productCode,
productName: existing.productName,
series: existing.series,
spec: existing.spec,
unit: existing.unit,
unitPrice: existing.unitPrice ?? inv.unitPrice,
availableQty: existing.availableQty + inv.quantity,
);
} else {
map[pid] = _PickerItem(
productId: pid,
productCode: inv.productCode,
productName: inv.productName,
series: inv.series,
spec: inv.spec,
unit: inv.unit,
unitPrice: inv.unitPrice,
availableQty: inv.quantity,
);
}
}
return map.values.toList();
}
class _ItemRow {
int? productId;
final String productCode;
@@ -158,36 +194,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
.listInventory(
warehouseId: warehouseId,
pageSize: AppConstants.stockOutPickerPageSize);
final Map<int, _PickerItem> productMap = {};
for (final inv in result.data.where((inv) => inv.productId != null)) {
final pid = inv.productId!;
if (productMap.containsKey(pid)) {
final existing = productMap[pid]!;
productMap[pid] = _PickerItem(
productId: pid,
productCode: existing.productCode,
productName: existing.productName,
series: existing.series,
spec: existing.spec,
unit: existing.unit,
unitPrice: existing.unitPrice ?? inv.unitPrice,
availableQty: existing.availableQty + inv.quantity,
);
} else {
productMap[pid] = _PickerItem(
productId: pid,
productCode: inv.productCode,
productName: inv.productName,
series: inv.series,
spec: inv.spec,
unit: inv.unit,
unitPrice: inv.unitPrice,
availableQty: inv.quantity,
);
}
}
setState(() {
_inventoryPickerItems = productMap.values.toList();
_inventoryPickerItems = _aggregatePickerItems(result.data);
_inventoryMap = {
for (final item in _inventoryPickerItems)
item.productId: item.availableQty
@@ -206,7 +214,10 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
}
final selected = await showDialog<List<_PickerItem>>(
context: context,
builder: (_) => _InventoryPickerDialog(items: _inventoryPickerItems),
builder: (_) => _InventoryPickerDialog(
items: _inventoryPickerItems,
warehouseId: _warehouseId,
),
);
if (selected == null || selected.isEmpty) return;
setState(() {
@@ -532,6 +543,20 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
selectedId: _partnerId,
hint: '请选择客户',
dialogTitle: '选择客户',
onSearch: (kw) async {
final res = await ref
.read(partnerRepositoryProvider)
.list(
type: 'customer',
keyword: kw,
pageSize: 50);
return res.data
.map((p) => OptionItem(
id: p.id,
name: p.name,
code: p.code))
.toList();
},
onChanged: (v) =>
setState(() => _partnerId = v),
),
@@ -873,39 +898,67 @@ class _FormField extends StatelessWidget {
}
}
class _InventoryPickerDialog extends StatefulWidget {
class _InventoryPickerDialog extends ConsumerStatefulWidget {
final List<_PickerItem> items;
const _InventoryPickerDialog({required this.items});
final int? warehouseId;
const _InventoryPickerDialog({required this.items, this.warehouseId});
@override
State<_InventoryPickerDialog> createState() => _InventoryPickerDialogState();
ConsumerState<_InventoryPickerDialog> createState() =>
_InventoryPickerDialogState();
}
class _InventoryPickerDialogState extends State<_InventoryPickerDialog> {
class _InventoryPickerDialogState
extends ConsumerState<_InventoryPickerDialog> {
final _searchCtrl = TextEditingController();
String _search = '';
final Set<int> _selected = {};
//
late List<_PickerItem> _results = widget.items;
// productId
late final Map<int, _PickerItem> _known = {
for (final it in widget.items) it.productId: it
};
bool _loading = false;
Timer? _debounce;
@override
void dispose() {
_debounce?.cancel();
_searchCtrl.dispose();
super.dispose();
}
List<_PickerItem> get _filtered {
if (_search.isEmpty) return widget.items;
final q = _search.toLowerCase();
return widget.items
.where((item) =>
item.productName.toLowerCase().contains(q) ||
item.productCode.toLowerCase().contains(q) ||
item.series.toLowerCase().contains(q))
.toList();
void _onSearchChanged(String v) {
setState(() => _search = v);
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), _fetch);
}
Future<void> _fetch() async {
setState(() => _loading = true);
try {
final kw = _search.trim();
final result = await ref.read(inventoryRepositoryProvider).listInventory(
warehouseId: widget.warehouseId,
keyword: kw.isEmpty ? null : kw,
pageSize: AppConstants.stockOutPickerPageSize,
);
final items = _aggregatePickerItems(result.data);
for (final it in items) {
_known[it.productId] = it;
}
if (mounted) setState(() => _results = items);
} catch (_) {
//
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final filtered = _filtered;
final filtered = _results;
final allSelected = filtered.isNotEmpty &&
filtered.every((e) => _selected.contains(e.productId));
@@ -945,14 +998,24 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: TextField(
controller: _searchCtrl,
decoration: const InputDecoration(
decoration: InputDecoration(
hintText: '搜索商品编码、名称或系列',
prefixIcon: Icon(Icons.search, size: 18),
prefixIcon: const Icon(Icons.search, size: 18),
suffixIcon: _loading
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: null,
isDense: true,
contentPadding:
EdgeInsets.symmetric(horizontal: 12, vertical: 10),
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
onChanged: (v) => setState(() => _search = v),
onChanged: _onSearchChanged,
),
),
// Table header
@@ -1068,9 +1131,9 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> {
onPressed: _selected.isEmpty
? null
: () {
final result = widget.items
.where((item) =>
_selected.contains(item.productId))
final result = _selected
.map((id) => _known[id])
.whereType<_PickerItem>()
.toList();
Navigator.pop(context, result);
},
@@ -23,6 +23,8 @@ import '../../providers/product_provider.dart';
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
import '../../widgets/write_guard.dart';
import '../../widgets/order_row_actions.dart';
import '../../core/auth/auth_state.dart'
show isAdminProvider, currentUserIdProvider;
class StockOutListScreen extends ConsumerStatefulWidget {
const StockOutListScreen({super.key});
@@ -64,8 +66,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
ColDef('warehouse', '仓库'),
ColDef('amount', '金额', minWidth: 800),
ColDef('status', '状态'),
ColDef('date', '日期', minWidth: 900),
ColDef('reviewed_at', '出库时间', minWidth: 900),
ColDef('date', '出库时间', minWidth: 900),
ColDef('created_at', '创建时间', minWidth: 900),
ColDef('operator', '出库员', minWidth: 1100),
ColDef('reviewer', '审核员', minWidth: 1100),
@@ -271,12 +272,6 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
return DataCell(StatusBadge(_apiStatusToEnum(o.status)));
case 'date':
return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-'));
case 'reviewed_at':
return DataCell(Text(o.reviewedAt != null
? o.reviewedAt!.length >= 16
? o.reviewedAt!.substring(0, 16)
: o.reviewedAt!.substring(0, 10)
: '-'));
case 'created_at':
return DataCell(Text(o.createdAt != null
? o.createdAt!.length >= 16
@@ -491,6 +486,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),
@@ -506,6 +504,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
onSubmit: () => _confirmSubmit(context, o),
onApprove: () => _confirmApprove(context, o),
onReject: () => _confirmReject(context, o),
onWithdraw: () => _confirmWithdraw(context, o),
);
}
@@ -524,7 +523,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
o.totalAmount != null
? '¥${o.totalAmount!.toStringAsFixed(2)}'
: '-'),
MobileCardField('日期', o.orderDate?.substring(0, 10) ?? '-'),
MobileCardField('出库时间', o.orderDate?.substring(0, 10) ?? '-'),
],
actions: _orderActions(context, o),
);
@@ -739,6 +738,44 @@ 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));
}
}
}
}
}
// Detail dialog fetches full order with items
+104 -125
View File
@@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
import '../core/theme/app_theme.dart';
import '../core/utils/date_util.dart';
/// // showDatePicker
/// + //
///
/// - Material 3 `DropdownMenu`
/// //
/// - / clamp
/// - `yyyy-MM-dd` [value] / [onChanged] null
/// - ****20242024-52024-5-1220240512 `yyyy-MM-dd`
/// / `01` 2024 2024-01-01
/// - **** Material `showDatePicker`input 便
/// - `yyyy-MM-dd` [value] / [onChanged] null
class DatePickerField extends StatefulWidget {
final String? value; // yyyy-MM-dd
final ValueChanged<String?> onChanged;
@@ -27,151 +27,130 @@ class DatePickerField extends StatefulWidget {
}
class _DatePickerFieldState extends State<DatePickerField> {
int? _year;
int? _month;
int? _day;
late final TextEditingController _ctrl;
final FocusNode _focus = FocusNode();
@override
void initState() {
super.initState();
_parse(widget.value);
_ctrl = TextEditingController(text: widget.value ?? '');
_focus.addListener(() {
// 2024 2024-01-01
if (!_focus.hasFocus) {
final n = _normalize(_ctrl.text);
if (n != null && n != _ctrl.text) {
_ctrl.text = n;
}
}
});
}
@override
void didUpdateWidget(DatePickerField old) {
super.didUpdateWidget(old);
if (old.value != widget.value) {
_parse(widget.value);
//
if (!_focus.hasFocus &&
old.value != widget.value &&
(widget.value ?? '') != _ctrl.text) {
_ctrl.text = widget.value ?? '';
}
}
void _parse(String? v) {
final d = parseYmd(v);
_year = d?.year;
_month = d?.month;
_day = d?.day;
@override
void dispose() {
_ctrl.dispose();
_focus.dispose();
super.dispose();
}
int get _daysInMonth {
final y = _year ?? DateTime.now().year;
final m = _month ?? 1;
return DateTime(y, m + 1, 0).day; // 0 =
}
String? get _composed => composeYmd(_year, _month, _day);
void _emit(FormFieldState<String> field) {
// / clamp composeYmd State
if (_day != null && _day! > _daysInMonth) {
_day = _daysInMonth;
/// `yyyy-MM-dd`/ 01 null
static String? _normalize(String raw) {
final s = raw.trim();
if (s.isEmpty) return null;
int? y, mo, d;
if (RegExp(r'^[0-9]+$').hasMatch(s)) {
// yyyymmdd / yyyymm / yyyy
if (s.length >= 8) {
y = int.tryParse(s.substring(0, 4));
mo = int.tryParse(s.substring(4, 6));
d = int.tryParse(s.substring(6, 8));
} else if (s.length == 6) {
y = int.tryParse(s.substring(0, 4));
mo = int.tryParse(s.substring(4, 6));
} else {
y = int.tryParse(s);
}
} else {
final parts =
s.split(RegExp(r'[^0-9]+')).where((e) => e.isNotEmpty).toList();
if (parts.isNotEmpty) y = int.tryParse(parts[0]);
if (parts.length >= 2) mo = int.tryParse(parts[1]);
if (parts.length >= 3) d = int.tryParse(parts[2]);
}
if (y == null || y < 1900 || y > 2200) return null;
mo ??= 1;
d ??= 1;
if (mo < 1 || mo > 12) return null;
final maxDay = DateTime(y, mo + 1, 0).day;
if (d < 1) d = 1;
if (d > maxDay) d = maxDay;
return '${y.toString().padLeft(4, '0')}-'
'${mo.toString().padLeft(2, '0')}-'
'${d.toString().padLeft(2, '0')}';
}
Future<void> _pickFromCalendar(FormFieldState<String> field) async {
final init =
parseYmd(_normalize(_ctrl.text) ?? widget.value) ?? DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: init,
firstDate: DateTime(1900),
lastDate: DateTime(2200),
initialEntryMode: DatePickerEntryMode.input,
);
if (picked != null) {
final v = formatYmd(picked);
_ctrl.text = v;
field.didChange(v);
widget.onChanged(v);
}
final v = _composed;
field.didChange(v);
widget.onChanged(v);
setState(() {});
}
@override
Widget build(BuildContext context) {
final now = DateTime.now();
final years = [for (var y = now.year - 20; y <= now.year + 5; y++) y];
final months = [for (var m = 1; m <= 12; m++) m];
final days = [for (var d = 1; d <= _daysInMonth; d++) d];
// Expanded expandedInsets:zero DropdownMenu
// trailingIcon 2026007
Widget menu({
required String label,
required int? value,
required List<int> items,
required ValueChanged<int?> onSel,
required FormFieldState<String> field,
}) {
return DropdownMenu<int>(
initialSelection: value,
label: Text(label, style: const TextStyle(fontSize: 11)),
enableFilter: true,
requestFocusOnTap: true,
textStyle: const TextStyle(fontSize: 13),
menuHeight: 280,
expandedInsets: EdgeInsets.zero,
trailingIcon: const Icon(Icons.arrow_drop_down, size: 18),
selectedTrailingIcon: const Icon(Icons.arrow_drop_up, size: 18),
inputDecorationTheme: const InputDecorationTheme(
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 6, vertical: 8),
),
dropdownMenuEntries: [
for (final i in items) DropdownMenuEntry<int>(value: i, label: '$i'),
],
onSelected: onSel,
);
}
return FormField<String>(
initialValue: widget.value,
validator: widget.isRequired
? (v) => (_composed == null ? '选择日期' : null)
? (_) => _normalize(_ctrl.text) == null ? '输入日期' : null
: null,
builder: (field) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
flex: 5,
child: menu(
label: '',
value: _year,
items: years,
field: field,
onSel: (v) {
_year = v;
_emit(field);
},
),
),
const SizedBox(width: 6),
Expanded(
flex: 4,
child: menu(
label: '',
value: _month,
items: months,
field: field,
onSel: (v) {
_month = v;
_emit(field);
},
),
),
const SizedBox(width: 6),
Expanded(
flex: 4,
child: menu(
label: '',
value: _day,
items: days,
field: field,
onSel: (v) {
_day = v;
_emit(field);
},
),
),
],
return TextField(
controller: _ctrl,
focusNode: _focus,
keyboardType: TextInputType.datetime,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
isDense: true,
hintText: '年-月-日',
hintStyle: const TextStyle(fontSize: 12),
contentPadding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
errorText: field.errorText,
suffixIcon: IconButton(
icon: const Icon(Icons.calendar_today,
size: 16, color: AppTheme.textSecondary),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
tooltip: '选择日期',
onPressed: () => _pickFromCalendar(field),
),
if (field.errorText != null)
Padding(
padding: const EdgeInsets.only(left: 12, top: 4),
child: Text(
field.errorText!,
style: const TextStyle(fontSize: 11, color: AppTheme.danger),
),
),
],
),
onChanged: (v) {
final n = _normalize(v);
field.didChange(n);
widget.onChanged(n);
},
);
},
);
@@ -23,6 +23,8 @@ List<Widget> buildOrderRowActions({
required VoidCallback onSubmit,
required VoidCallback onApprove,
required VoidCallback onReject,
required VoidCallback onWithdraw,
bool canWithdraw = false,
List<Widget> afterPrint = const [],
}) {
TextButton btn(String text, Color color, VoidCallback onPressed, {Key? key}) =>
@@ -49,6 +51,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'))),
],
];
}
@@ -1,3 +1,5 @@
import 'dart:async';
import '../core/utils/dialog_util.dart';
import 'package:flutter/material.dart';
import '../core/responsive/responsive.dart';
@@ -41,6 +43,9 @@ class SearchableOptionField extends StatelessWidget {
/// id/ null
final Future<int?> Function(String keyword)? onCreate;
/// debounce
final Future<List<OptionItem>> Function(String keyword)? onSearch;
const SearchableOptionField({
super.key,
required this.options,
@@ -51,6 +56,7 @@ class SearchableOptionField extends StatelessWidget {
this.isRequired = false,
this.isDense = true,
this.onCreate,
this.onSearch,
});
String get _displayText {
@@ -66,6 +72,7 @@ class SearchableOptionField extends StatelessWidget {
options: options,
selectedId: selectedId,
onCreate: onCreate,
onSearch: onSearch,
),
);
// result == -1 means "clear selection"
@@ -120,11 +127,13 @@ class _SearchDialog extends StatefulWidget {
final List<OptionItem> options;
final int? selectedId;
final Future<int?> Function(String keyword)? onCreate;
final Future<List<OptionItem>> Function(String keyword)? onSearch;
const _SearchDialog({
required this.title,
required this.options,
this.selectedId,
this.onCreate,
this.onSearch,
});
@override
@@ -135,9 +144,28 @@ class _SearchDialogState extends State<_SearchDialog> {
final _ctrl = TextEditingController();
String _keyword = '';
bool _creating = false;
List<OptionItem>? _serverItems; // null= widget.options
bool _searching = false;
Timer? _debounce;
void _onSearchChanged(String v) {
setState(() => _keyword = v);
if (widget.onSearch == null) return;
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () async {
setState(() => _searching = true);
try {
final res = await widget.onSearch!(v.trim());
if (mounted) setState(() => _serverItems = res);
} finally {
if (mounted) setState(() => _searching = false);
}
});
}
@override
void dispose() {
_debounce?.cancel();
_ctrl.dispose();
super.dispose();
}
@@ -197,12 +225,14 @@ class _SearchDialogState extends State<_SearchDialog> {
@override
Widget build(BuildContext context) {
final filtered = _keyword.isEmpty
? widget.options
: widget.options.where((o) => o.matches(_keyword)).toList();
final filtered = widget.onSearch != null
? (_serverItems ?? widget.options)
: (_keyword.isEmpty
? widget.options
: widget.options.where((o) => o.matches(_keyword)).toList());
final kw = _keyword.trim();
final hasExact =
widget.options.any((o) => o.name.toLowerCase() == kw.toLowerCase());
filtered.any((o) => o.name.toLowerCase() == kw.toLowerCase());
final canCreate = widget.onCreate != null && kw.isNotEmpty && !hasExact;
return AlertDialog(
@@ -216,12 +246,22 @@ class _SearchDialogState extends State<_SearchDialog> {
TextField(
controller: _ctrl,
autofocus: true,
decoration: const InputDecoration(
decoration: InputDecoration(
hintText: '搜索...',
prefixIcon: Icon(Icons.search, size: 18),
prefixIcon: const Icon(Icons.search, size: 18),
suffixIcon: _searching
? const Padding(
padding: EdgeInsets.all(10),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: null,
isDense: true,
),
onChanged: (v) => setState(() => _keyword = v),
onChanged: _onSearchChanged,
),
const SizedBox(height: 8),
Expanded(
+27 -6
View File
@@ -31,26 +31,47 @@ void main() {
});
group('DatePickerField', () {
testWidgets('渲染三个年/月/日下拉框', (tester) async {
testWidgets('渲染可键入框 + 日历图标', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: DatePickerField(value: '2026-06-19', onChanged: (_) {}),
),
));
await tester.pumpAndSettle();
expect(find.byType(DropdownMenu<int>), findsNWidgets(3));
expect(find.byType(TextField), findsOneWidget);
expect(find.byIcon(Icons.calendar_today), findsOneWidget);
});
testWidgets('初始值回显到三个下拉', (tester) async {
testWidgets('初始值回显到输入', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: DatePickerField(value: '2026-06-19', onChanged: (_) {}),
),
));
await tester.pumpAndSettle();
expect(find.widgetWithText(DropdownMenu<int>, '2026'), findsOneWidget);
expect(find.widgetWithText(DropdownMenu<int>, '6'), findsOneWidget);
expect(find.widgetWithText(DropdownMenu<int>, '19'), findsOneWidget);
expect(find.text('2026-06-19'), findsOneWidget);
});
testWidgets('只输年份归一为 yyyy-01-01', (tester) async {
String? out;
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: DatePickerField(onChanged: (v) => out = v),
),
));
await tester.enterText(find.byType(TextField), '2024');
expect(out, '2024-01-01');
});
testWidgets('年月归一为 yyyy-MM-01', (tester) async {
String? out;
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: DatePickerField(onChanged: (v) => out = v),
),
));
await tester.enterText(find.byType(TextField), '2024-5');
expect(out, '2024-05-01');
});
});
}
+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>