Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20f9e3a410 | |||
| 2e941fdb5f | |||
| e36de528b0 | |||
| 6faadf8670 | |||
| dcb1d7c44c | |||
| 9d5bc18de1 | |||
| 13a6777a85 | |||
| 381588826c | |||
| 7a78448a25 |
@@ -5,6 +5,24 @@ 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
|
||||
|
||||
### 改进
|
||||
|
||||
@@ -5,6 +5,24 @@
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.74] - 2026-06-21
|
||||
|
||||
### 新功能
|
||||
- 已审核的入库单/出库单支持「退单」:可整单退或按明细行退货。出库单退单的商品数量加回库存、入库单退单对应库存从库存删除,并自动冲减对应的应付/应收账款。权限:入库退单限管理员/超管;出库退单管理员/超管或本人。已被出库消耗的入库明细禁止退单。
|
||||
|
||||
### 修复
|
||||
- 库存导入改为按商品编号匹配商品、不再按名称合并,避免再次导入产生重复编号
|
||||
|
||||
## [1.0.73] - 2026-06-21
|
||||
|
||||
### 改进
|
||||
- 撤回审核中单据的权限放宽:管理员/超管可撤回任意单据,普通操作员可撤回本人提交的单据(此前仅管理员)
|
||||
|
||||
## [1.0.72] - 2026-06-21
|
||||
|
||||
### 新功能
|
||||
- 审核中(待审核)的入库单/出库单,管理员可「撤回」为草稿,修改后重新提交审核;已审核单据仍只读不可撤回
|
||||
|
||||
## [1.0.71] - 2026-06-21
|
||||
|
||||
### 改进
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 不应建 product:before=%d after=%d", before, after)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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_return,85→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")
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 库存
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -327,6 +327,7 @@ func SetupTestDB() *gorm.DB {
|
||||
order_date DATETIME,
|
||||
total_amount REAL DEFAULT 0,
|
||||
reviewed_at DATETIME,
|
||||
return_state TEXT DEFAULT 'none',
|
||||
custom_fields TEXT,
|
||||
remark TEXT
|
||||
)`,
|
||||
@@ -346,6 +347,7 @@ func SetupTestDB() *gorm.DB {
|
||||
quantity REAL NOT NULL,
|
||||
unit_price REAL DEFAULT 0,
|
||||
total_price REAL DEFAULT 0,
|
||||
returned_quantity REAL DEFAULT 0,
|
||||
batch_no TEXT,
|
||||
production_date DATETIME,
|
||||
custom_fields TEXT,
|
||||
@@ -368,6 +370,7 @@ func SetupTestDB() *gorm.DB {
|
||||
order_date DATETIME,
|
||||
total_amount REAL DEFAULT 0,
|
||||
reviewed_at DATETIME,
|
||||
return_state TEXT DEFAULT 'none',
|
||||
custom_fields TEXT,
|
||||
remark TEXT
|
||||
)`,
|
||||
@@ -387,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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1271,6 +1271,10 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
||||
'格式:选项编号 | 选项名称 | 单品数量 | 备注'),
|
||||
_ImportSlot('库存', '/import/inventory',
|
||||
'格式:商品编号|商品名称|系列|规格|单位|库存数量|单价|金额|生产日期|批次|分类|所在仓库|入库日期|供应商|上次盘点|备注'),
|
||||
_ImportSlot('入库单', '/import/stock-in',
|
||||
'老系统入库单打印格式:每个文件一张单(含单据号/往来单位/日期/明细)'),
|
||||
_ImportSlot('出库单', '/import/stock-out',
|
||||
'老系统出库单打印格式:每个文件一张单(含单据号/往来单位/日期/明细)'),
|
||||
];
|
||||
|
||||
final Set<String> _selectedTables = {};
|
||||
@@ -1418,10 +1422,20 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
||||
final data = (resp.data is Map) ? resp.data as Map<String, dynamic> : <String, dynamic>{};
|
||||
if (mounted) setState(() {
|
||||
slot.success = true;
|
||||
slot.imported = (data['imported'] ?? 0) as int;
|
||||
slot.skipped = (data['skipped'] ?? 0) as int;
|
||||
slot.updated = (data['updated'] ?? 0) as int;
|
||||
slot.total = (data['total'] ?? slot.imported + slot.skipped + slot.updated) as int;
|
||||
if (data.containsKey('order_no')) {
|
||||
// 单据类导入(入库单/出库单):每个文件一张单,
|
||||
// 已存在时后端返回 {skipped: true}(布尔),不能按 int 解析。
|
||||
final skippedOne = data['skipped'] == true;
|
||||
slot.imported = skippedOne ? 0 : 1;
|
||||
slot.skipped = skippedOne ? 1 : 0;
|
||||
slot.updated = 0;
|
||||
slot.total = 1;
|
||||
} else {
|
||||
slot.imported = (data['imported'] ?? 0) as int;
|
||||
slot.skipped = (data['skipped'] ?? 0) as int;
|
||||
slot.updated = (data['updated'] ?? 0) as int;
|
||||
slot.total = (data['total'] ?? slot.imported + slot.skipped + slot.updated) as int;
|
||||
}
|
||||
slot.isProcessing = false;
|
||||
});
|
||||
} on DioException catch (e) {
|
||||
|
||||
@@ -300,6 +300,14 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (item.batchNoCtrl.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('第 ${i + 1} 行请填写批次号'),
|
||||
backgroundColor: AppTheme.danger),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,12 +880,13 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
return TextFormField(
|
||||
controller: item.batchNoCtrl,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '选填',
|
||||
labelText: '批次号',
|
||||
hintText: '必填',
|
||||
isDense: true,
|
||||
),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
onChanged: (_) => setState(() {}),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? '不能为空' : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -993,7 +1002,8 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
Expanded(flex: 18, child: th('名称')),
|
||||
Expanded(flex: 12, child: th('系列')),
|
||||
Expanded(flex: 12, child: th('规格')),
|
||||
Expanded(flex: 24, child: th('生产日期')),
|
||||
Expanded(flex: 20, child: th('生产日期')),
|
||||
Expanded(flex: 12, child: th('批次号')),
|
||||
Expanded(flex: 10, child: th('数量')),
|
||||
Expanded(flex: 10, child: th('单价')),
|
||||
Expanded(flex: 10, child: th('金额')),
|
||||
@@ -1017,8 +1027,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
style: TextStyle(fontSize: 13, color: color, fontWeight: weight)),
|
||||
);
|
||||
|
||||
final hasOptional = item.batchNoCtrl.text.isNotEmpty ||
|
||||
item.selectedOriginId != null ||
|
||||
final hasOptional = item.selectedOriginId != null ||
|
||||
item.selectedShelfLifeId != null ||
|
||||
item.selectedStorageId != null ||
|
||||
item.selectedDescriptionDocId != null;
|
||||
@@ -1050,10 +1059,15 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: _specField(item))),
|
||||
Expanded(
|
||||
flex: 24,
|
||||
flex: 20,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: _dateField(item))),
|
||||
Expanded(
|
||||
flex: 12,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: _batchField(item))),
|
||||
Expanded(
|
||||
flex: 10,
|
||||
child: Padding(
|
||||
@@ -1071,7 +1085,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
SizedBox(
|
||||
width: 32,
|
||||
child: Tooltip(
|
||||
message: item.expanded ? '收起选填' : '展开选填(批次/产地/保质期等)',
|
||||
message: item.expanded ? '收起选填' : '展开选填(产地/保质期等)',
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
item.expanded ? Icons.expand_less : Icons.expand_more,
|
||||
@@ -1131,8 +1145,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
spacing: 16,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_OptionalField(
|
||||
label: '批次号', width: 180, child: _batchField(item)),
|
||||
_OptionalField(
|
||||
label: '产地', width: 180, child: _originField(item)),
|
||||
_OptionalField(
|
||||
@@ -1170,6 +1182,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
MobileCardField('系列', null, valueWidget: _seriesField(item)),
|
||||
MobileCardField('规格', null, valueWidget: _specField(item)),
|
||||
MobileCardField('生产日期', null, valueWidget: _dateField(item)),
|
||||
MobileCardField('批次号', null, valueWidget: _batchField(item)),
|
||||
MobileCardField('数量', null, valueWidget: _qtyField(item)),
|
||||
MobileCardField('单价', null, valueWidget: _priceField(item)),
|
||||
MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'),
|
||||
@@ -1178,15 +1191,13 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
onPressed: () => setState(() => item.expanded = !item.expanded),
|
||||
icon: Icon(item.expanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 16),
|
||||
label: Text(item.expanded ? '收起选填项' : '展开选填项(批次/产地/保质期…)'),
|
||||
label: Text(item.expanded ? '收起选填项' : '展开选填项(产地/保质期…)'),
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(0, 32),
|
||||
foregroundColor: AppTheme.textSecondary,
|
||||
),
|
||||
)),
|
||||
if (item.expanded)
|
||||
MobileCardField('批次号', null, valueWidget: _batchField(item)),
|
||||
if (item.expanded)
|
||||
MobileCardField('产地', null, valueWidget: _originField(item)),
|
||||
if (item.expanded)
|
||||
|
||||
@@ -25,6 +25,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});
|
||||
@@ -478,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),
|
||||
@@ -525,6 +530,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
onSubmit: () => _confirmSubmit(context, o),
|
||||
onApprove: () => _confirmApprove(context, o),
|
||||
onReject: () => _confirmReject(context, o),
|
||||
onWithdraw: () => _confirmWithdraw(context, o),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -754,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
|
||||
|
||||
@@ -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});
|
||||
@@ -484,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),
|
||||
@@ -499,6 +504,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
onSubmit: () => _confirmSubmit(context, o),
|
||||
onApprove: () => _confirmApprove(context, o),
|
||||
onReject: () => _confirmReject(context, o),
|
||||
onWithdraw: () => _confirmWithdraw(context, o),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -732,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
|
||||
|
||||
@@ -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'))),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
+103
-75
@@ -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,''), 快照列)`——**优先 product,product 为空/被删才回退快照**。前端 `lineOrProduct` 同理。历史导入的出入库明细 `product_id` 多指向一个占位 product(`HIST-PLACEHOLDER`),真实信息存快照列。
|
||||
|
||||
### 其它主表
|
||||
|
||||
```yaml
|
||||
shops: # 门店(租户根节点)
|
||||
users: # 用户(含 shop_id,role: 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_id,role: 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→push;CI(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 runner:mac 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 错指)。
|
||||
|
||||
## 文档索引
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user