Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 381588826c | |||
| 7a78448a25 | |||
| fcc0fd988b | |||
| 203cc7ce21 |
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.69] - 2026-06-21
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- 入库/出库审核列表:管理员可「撤回」审核中的单据,单据回到草稿,修改后重新提交审核
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
- 新建入库单:批次号移到「生产日期」右侧、与各列对齐填写;生产日期输入框收窄,不再占用过宽
|
||||||
|
|
||||||
|
## [1.0.68] - 2026-06-21
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
- 店铺公开商品页:每个商品卡片显示「在库 N」数量,且只展示有货商品
|
||||||
|
|
||||||
## [1.0.67] - 2026-06-21
|
## [1.0.67] - 2026-06-21
|
||||||
|
|
||||||
### 改进
|
### 改进
|
||||||
|
|||||||
@@ -5,6 +5,16 @@
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.72] - 2026-06-21
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- 审核中(待审核)的入库单/出库单,管理员可「撤回」为草稿,修改后重新提交审核;已审核单据仍只读不可撤回
|
||||||
|
|
||||||
|
## [1.0.71] - 2026-06-21
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
- 公开店铺商品页只展示「当前有库存」的商品,并返回每个商品的在库数量与商品编码(序列号),无货商品不再露出
|
||||||
|
|
||||||
## [1.0.70] - 2026-06-21
|
## [1.0.70] - 2026-06-21
|
||||||
|
|
||||||
### 改进
|
### 改进
|
||||||
|
|||||||
@@ -202,12 +202,14 @@ type publicProductImage struct {
|
|||||||
type publicProductResp struct {
|
type publicProductResp struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
PublicID string `json:"public_id"`
|
PublicID string `json:"public_id"`
|
||||||
|
Code string `json:"code"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Series string `json:"series"`
|
Series string `json:"series"`
|
||||||
Spec string `json:"spec"`
|
Spec string `json:"spec"`
|
||||||
Brand string `json:"brand"`
|
Brand string `json:"brand"`
|
||||||
Unit string `json:"unit"`
|
Unit string `json:"unit"`
|
||||||
SalePrice float64 `json:"sale_price"`
|
SalePrice float64 `json:"sale_price"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
Images []publicProductImage `json:"images"`
|
Images []publicProductImage `json:"images"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,8 +236,15 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
|
|||||||
pageSize = 20
|
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{}).
|
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
|
var total int64
|
||||||
if err := query.Count(&total).Error; err != nil {
|
if err := query.Count(&total).Error; err != nil {
|
||||||
@@ -248,12 +257,33 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
|
|||||||
if err := query.Preload("Images").
|
if err := query.Preload("Images").
|
||||||
Offset(offset).
|
Offset(offset).
|
||||||
Limit(pageSize).
|
Limit(pageSize).
|
||||||
Order("id DESC").
|
Order("products.id DESC").
|
||||||
Find(&products).Error; err != nil {
|
Find(&products).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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))
|
listData := make([]publicProductResp, len(products))
|
||||||
for i, p := range products {
|
for i, p := range products {
|
||||||
imgs := make([]publicProductImage, len(p.Images))
|
imgs := make([]publicProductImage, len(p.Images))
|
||||||
@@ -263,12 +293,14 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
|
|||||||
listData[i] = publicProductResp{
|
listData[i] = publicProductResp{
|
||||||
ID: p.ID,
|
ID: p.ID,
|
||||||
PublicID: p.PublicID,
|
PublicID: p.PublicID,
|
||||||
|
Code: p.Code,
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Series: p.Series,
|
Series: p.Series,
|
||||||
Spec: p.Spec,
|
Spec: p.Spec,
|
||||||
Brand: p.Brand,
|
Brand: p.Brand,
|
||||||
Unit: p.Unit,
|
Unit: p.Unit,
|
||||||
SalePrice: p.SalePrice,
|
SalePrice: p.SalePrice,
|
||||||
|
Quantity: qtyMap[p.ID],
|
||||||
Images: imgs,
|
Images: imgs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_id(CreateTestProduct 默认不设)
|
||||||
|
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"])
|
||||||
|
}
|
||||||
@@ -255,3 +255,18 @@ func (h *StockInHandler) Reject(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "rejected"})
|
c.JSON(http.StatusOK, gin.H{"message": "rejected"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Withdraw PUT /api/v1/stock-in/orders/:id/withdraw
|
||||||
|
// 审核中(pending)撤回为草稿(draft),供管理员修改后重新提交。仅 pending 可撤回;
|
||||||
|
// 已审核(approved)单据只读、库存已变动,不可撤回。
|
||||||
|
func (h *StockInHandler) Withdraw(c *gin.Context) {
|
||||||
|
shopID := middleware.GetShopID(c)
|
||||||
|
result := h.db.Model(&model.StockInOrder{}).
|
||||||
|
Where("id = ? AND shop_id = ? AND status = 'pending'", c.Param("id"), shopID).
|
||||||
|
Update("status", "draft")
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in pending status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "withdrawn"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -182,6 +182,47 @@ func TestStockInHandler_Reject(t *testing.T) {
|
|||||||
assert.Equal(t, "rejected", detailData["status"])
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
func TestStockInHandler_GetNotFound(t *testing.T) {
|
func TestStockInHandler_GetNotFound(t *testing.T) {
|
||||||
db := testutil.SetupTestDB()
|
db := testutil.SetupTestDB()
|
||||||
shop := testutil.CreateTestShop(db, "SI006")
|
shop := testutil.CreateTestShop(db, "SI006")
|
||||||
|
|||||||
@@ -240,3 +240,18 @@ func (h *StockOutHandler) Reject(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "rejected"})
|
c.JSON(http.StatusOK, gin.H{"message": "rejected"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Withdraw PUT /api/v1/stock-out/orders/:id/withdraw
|
||||||
|
// 审核中(pending)撤回为草稿(draft),供管理员修改后重新提交。仅 pending 可撤回;
|
||||||
|
// 已审核(approved)单据只读、库存已变动,不可撤回。
|
||||||
|
func (h *StockOutHandler) Withdraw(c *gin.Context) {
|
||||||
|
shopID := middleware.GetShopID(c)
|
||||||
|
result := h.db.Model(&model.StockOutOrder{}).
|
||||||
|
Where("id = ? AND shop_id = ? AND status = 'pending'", c.Param("id"), shopID).
|
||||||
|
Update("status", "draft")
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in pending status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "withdrawn"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
|
|||||||
stockIn.PUT("/orders/:id/submit", stockInH.Submit)
|
stockIn.PUT("/orders/:id/submit", stockInH.Submit)
|
||||||
stockIn.PUT("/orders/:id/approve", stockInH.Approve)
|
stockIn.PUT("/orders/:id/approve", stockInH.Approve)
|
||||||
stockIn.PUT("/orders/:id/reject", stockInH.Reject)
|
stockIn.PUT("/orders/:id/reject", stockInH.Reject)
|
||||||
|
stockIn.PUT("/orders/:id/withdraw", middleware.AdminOnly(), stockInH.Withdraw)
|
||||||
|
|
||||||
// 出库路由
|
// 出库路由
|
||||||
stockOut := api.Group("/stock-out")
|
stockOut := api.Group("/stock-out")
|
||||||
@@ -72,6 +73,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
|
|||||||
stockOut.PUT("/orders/:id/submit", stockOutH.Submit)
|
stockOut.PUT("/orders/:id/submit", stockOutH.Submit)
|
||||||
stockOut.PUT("/orders/:id/approve", stockOutH.Approve)
|
stockOut.PUT("/orders/:id/approve", stockOutH.Approve)
|
||||||
stockOut.PUT("/orders/:id/reject", stockOutH.Reject)
|
stockOut.PUT("/orders/:id/reject", stockOutH.Reject)
|
||||||
|
stockOut.PUT("/orders/:id/withdraw", middleware.AdminOnly(), stockOutH.Withdraw)
|
||||||
|
|
||||||
// 库存路由
|
// 库存路由
|
||||||
inv := api.Group("/inventory")
|
inv := api.Group("/inventory")
|
||||||
|
|||||||
@@ -153,6 +153,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
stockIn.PUT("/orders/:id/submit", stockInH.Submit)
|
stockIn.PUT("/orders/:id/submit", stockInH.Submit)
|
||||||
stockIn.PUT("/orders/:id/approve", stockInH.Approve)
|
stockIn.PUT("/orders/:id/approve", stockInH.Approve)
|
||||||
stockIn.PUT("/orders/:id/reject", stockInH.Reject)
|
stockIn.PUT("/orders/:id/reject", stockInH.Reject)
|
||||||
|
// 撤回(审核中→草稿)仅管理员
|
||||||
|
stockIn.PUT("/orders/:id/withdraw", middleware.AdminOnly(), stockInH.Withdraw)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 出库
|
// 出库
|
||||||
@@ -166,6 +168,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
stockOut.PUT("/orders/:id/submit", stockOutH.Submit)
|
stockOut.PUT("/orders/:id/submit", stockOutH.Submit)
|
||||||
stockOut.PUT("/orders/:id/approve", stockOutH.Approve)
|
stockOut.PUT("/orders/:id/approve", stockOutH.Approve)
|
||||||
stockOut.PUT("/orders/:id/reject", stockOutH.Reject)
|
stockOut.PUT("/orders/:id/reject", stockOutH.Reject)
|
||||||
|
// 撤回(审核中→草稿)仅管理员
|
||||||
|
stockOut.PUT("/orders/:id/withdraw", middleware.AdminOnly(), stockOutH.Withdraw)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 库存
|
// 库存
|
||||||
|
|||||||
@@ -241,6 +241,14 @@ func SetupTestDB() *gorm.DB {
|
|||||||
content TEXT,
|
content TEXT,
|
||||||
remark 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 (
|
`CREATE TABLE IF NOT EXISTS product_name_options (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
created_at DATETIME,
|
created_at DATETIME,
|
||||||
|
|||||||
@@ -194,3 +194,10 @@ final isReadonlyProvider = Provider<bool>((ref) {
|
|||||||
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
|
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
|
||||||
return role == 'readonly';
|
return role == 'readonly';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 当前登录用户是否为管理员(role == 'admin')。
|
||||||
|
/// 用于「撤回审核中单据」等仅管理员可用的操作;后端 middleware.AdminOnly() 兜底。
|
||||||
|
final isAdminProvider = Provider<bool>((ref) {
|
||||||
|
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
|
||||||
|
return role == 'admin';
|
||||||
|
});
|
||||||
|
|||||||
@@ -123,4 +123,9 @@ class StockInListNotifier extends AsyncNotifier<PageResult<StockInOrder>> {
|
|||||||
await ref.read(stockInRepositoryProvider).reject(id);
|
await ref.read(stockInRepositoryProvider).reject(id);
|
||||||
reload();
|
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);
|
await ref.read(stockOutRepositoryProvider).reject(id);
|
||||||
reload();
|
reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> withdrawOrder(int id) async {
|
||||||
|
await ref.read(stockOutRepositoryProvider).withdraw(id);
|
||||||
|
reload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 name = item['name'] as String? ?? '';
|
||||||
final series = item['series'] as String? ?? '';
|
final series = item['series'] as String? ?? '';
|
||||||
final spec = item['spec'] 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 → 5,1.5 → 1.5
|
||||||
|
final qtyStr = qtyNum == qtyNum.roundToDouble()
|
||||||
|
? qtyNum.toInt().toString()
|
||||||
|
: qtyNum.toString();
|
||||||
final images = (item['images'] as List<dynamic>? ?? [])
|
final images = (item['images'] as List<dynamic>? ?? [])
|
||||||
.cast<Map<String, dynamic>>();
|
.cast<Map<String, dynamic>>();
|
||||||
final imageUrl = images.isNotEmpty
|
final imageUrl = images.isNotEmpty
|
||||||
@@ -339,7 +345,18 @@ class _ProductCard extends StatelessWidget {
|
|||||||
const Spacer(),
|
const Spacer(),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
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(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 8, vertical: 3),
|
horizontal: 8, vertical: 3),
|
||||||
|
|||||||
@@ -873,7 +873,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
controller: item.batchNoCtrl,
|
controller: item.batchNoCtrl,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: '选填',
|
hintText: '选填',
|
||||||
labelText: '批次号',
|
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
style: const TextStyle(fontSize: 13),
|
style: const TextStyle(fontSize: 13),
|
||||||
@@ -993,7 +992,8 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
Expanded(flex: 18, child: th('名称')),
|
Expanded(flex: 18, child: th('名称')),
|
||||||
Expanded(flex: 12, child: th('系列')),
|
Expanded(flex: 12, child: th('系列')),
|
||||||
Expanded(flex: 12, child: th('规格')),
|
Expanded(flex: 12, child: th('规格')),
|
||||||
Expanded(flex: 24, child: th('生产日期')),
|
Expanded(flex: 14, child: th('生产日期')),
|
||||||
|
Expanded(flex: 12, child: th('批次号')),
|
||||||
Expanded(flex: 10, child: th('数量')),
|
Expanded(flex: 10, child: th('数量')),
|
||||||
Expanded(flex: 10, child: th('单价')),
|
Expanded(flex: 10, child: th('单价')),
|
||||||
Expanded(flex: 10, child: th('金额')),
|
Expanded(flex: 10, child: th('金额')),
|
||||||
@@ -1017,8 +1017,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
style: TextStyle(fontSize: 13, color: color, fontWeight: weight)),
|
style: TextStyle(fontSize: 13, color: color, fontWeight: weight)),
|
||||||
);
|
);
|
||||||
|
|
||||||
final hasOptional = item.batchNoCtrl.text.isNotEmpty ||
|
final hasOptional = item.selectedOriginId != null ||
|
||||||
item.selectedOriginId != null ||
|
|
||||||
item.selectedShelfLifeId != null ||
|
item.selectedShelfLifeId != null ||
|
||||||
item.selectedStorageId != null ||
|
item.selectedStorageId != null ||
|
||||||
item.selectedDescriptionDocId != null;
|
item.selectedDescriptionDocId != null;
|
||||||
@@ -1050,10 +1049,15 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
child: _specField(item))),
|
child: _specField(item))),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 24,
|
flex: 14,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
child: _dateField(item))),
|
child: _dateField(item))),
|
||||||
|
Expanded(
|
||||||
|
flex: 12,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
child: _batchField(item))),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 10,
|
flex: 10,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -1071,7 +1075,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: 32,
|
width: 32,
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: item.expanded ? '收起选填' : '展开选填(批次/产地/保质期等)',
|
message: item.expanded ? '收起选填' : '展开选填(产地/保质期等)',
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
item.expanded ? Icons.expand_less : Icons.expand_more,
|
item.expanded ? Icons.expand_less : Icons.expand_more,
|
||||||
@@ -1131,8 +1135,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
spacing: 16,
|
spacing: 16,
|
||||||
runSpacing: 12,
|
runSpacing: 12,
|
||||||
children: [
|
children: [
|
||||||
_OptionalField(
|
|
||||||
label: '批次号', width: 180, child: _batchField(item)),
|
|
||||||
_OptionalField(
|
_OptionalField(
|
||||||
label: '产地', width: 180, child: _originField(item)),
|
label: '产地', width: 180, child: _originField(item)),
|
||||||
_OptionalField(
|
_OptionalField(
|
||||||
@@ -1170,6 +1172,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
MobileCardField('系列', null, valueWidget: _seriesField(item)),
|
MobileCardField('系列', null, valueWidget: _seriesField(item)),
|
||||||
MobileCardField('规格', null, valueWidget: _specField(item)),
|
MobileCardField('规格', null, valueWidget: _specField(item)),
|
||||||
MobileCardField('生产日期', null, valueWidget: _dateField(item)),
|
MobileCardField('生产日期', null, valueWidget: _dateField(item)),
|
||||||
|
MobileCardField('批次号', null, valueWidget: _batchField(item)),
|
||||||
MobileCardField('数量', null, valueWidget: _qtyField(item)),
|
MobileCardField('数量', null, valueWidget: _qtyField(item)),
|
||||||
MobileCardField('单价', null, valueWidget: _priceField(item)),
|
MobileCardField('单价', null, valueWidget: _priceField(item)),
|
||||||
MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'),
|
MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'),
|
||||||
@@ -1178,15 +1181,13 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
onPressed: () => setState(() => item.expanded = !item.expanded),
|
onPressed: () => setState(() => item.expanded = !item.expanded),
|
||||||
icon: Icon(item.expanded ? Icons.expand_less : Icons.expand_more,
|
icon: Icon(item.expanded ? Icons.expand_less : Icons.expand_more,
|
||||||
size: 16),
|
size: 16),
|
||||||
label: Text(item.expanded ? '收起选填项' : '展开选填项(批次/产地/保质期…)'),
|
label: Text(item.expanded ? '收起选填项' : '展开选填项(产地/保质期…)'),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
minimumSize: const Size(0, 32),
|
minimumSize: const Size(0, 32),
|
||||||
foregroundColor: AppTheme.textSecondary,
|
foregroundColor: AppTheme.textSecondary,
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
if (item.expanded)
|
|
||||||
MobileCardField('批次号', null, valueWidget: _batchField(item)),
|
|
||||||
if (item.expanded)
|
if (item.expanded)
|
||||||
MobileCardField('产地', null, valueWidget: _originField(item)),
|
MobileCardField('产地', null, valueWidget: _originField(item)),
|
||||||
if (item.expanded)
|
if (item.expanded)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
|||||||
import '../../providers/shop_provider.dart' show shopInfoProvider;
|
import '../../providers/shop_provider.dart' show shopInfoProvider;
|
||||||
import '../../widgets/write_guard.dart';
|
import '../../widgets/write_guard.dart';
|
||||||
import '../../widgets/order_row_actions.dart';
|
import '../../widgets/order_row_actions.dart';
|
||||||
|
import '../../core/auth/auth_state.dart' show isAdminProvider;
|
||||||
|
|
||||||
class StockInListScreen extends ConsumerStatefulWidget {
|
class StockInListScreen extends ConsumerStatefulWidget {
|
||||||
const StockInListScreen({super.key});
|
const StockInListScreen({super.key});
|
||||||
@@ -478,6 +479,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
|||||||
List<Widget> _orderActions(BuildContext context, StockInOrder o) {
|
List<Widget> _orderActions(BuildContext context, StockInOrder o) {
|
||||||
return buildOrderRowActions(
|
return buildOrderRowActions(
|
||||||
readonly: WriteGuard.isReadonly(ref),
|
readonly: WriteGuard.isReadonly(ref),
|
||||||
|
isAdmin: ref.watch(isAdminProvider),
|
||||||
status: o.status,
|
status: o.status,
|
||||||
orderId: o.id,
|
orderId: o.id,
|
||||||
onDetail: () => _showDetail(context, o.id),
|
onDetail: () => _showDetail(context, o.id),
|
||||||
@@ -525,6 +527,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
|||||||
onSubmit: () => _confirmSubmit(context, o),
|
onSubmit: () => _confirmSubmit(context, o),
|
||||||
onApprove: () => _confirmApprove(context, o),
|
onApprove: () => _confirmApprove(context, o),
|
||||||
onReject: () => _confirmReject(context, o),
|
onReject: () => _confirmReject(context, o),
|
||||||
|
onWithdraw: () => _confirmWithdraw(context, o),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,6 +757,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
|
// Detail dialog — fetches full order with items
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import '../../providers/product_provider.dart';
|
|||||||
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
||||||
import '../../widgets/write_guard.dart';
|
import '../../widgets/write_guard.dart';
|
||||||
import '../../widgets/order_row_actions.dart';
|
import '../../widgets/order_row_actions.dart';
|
||||||
|
import '../../core/auth/auth_state.dart' show isAdminProvider;
|
||||||
|
|
||||||
class StockOutListScreen extends ConsumerStatefulWidget {
|
class StockOutListScreen extends ConsumerStatefulWidget {
|
||||||
const StockOutListScreen({super.key});
|
const StockOutListScreen({super.key});
|
||||||
@@ -484,6 +485,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
List<Widget> _orderActions(BuildContext context, StockOutOrder o) {
|
List<Widget> _orderActions(BuildContext context, StockOutOrder o) {
|
||||||
return buildOrderRowActions(
|
return buildOrderRowActions(
|
||||||
readonly: WriteGuard.isReadonly(ref),
|
readonly: WriteGuard.isReadonly(ref),
|
||||||
|
isAdmin: ref.watch(isAdminProvider),
|
||||||
status: o.status,
|
status: o.status,
|
||||||
orderId: o.id,
|
orderId: o.id,
|
||||||
onDetail: () => _showDetail(context, o.id),
|
onDetail: () => _showDetail(context, o.id),
|
||||||
@@ -499,6 +501,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
onSubmit: () => _confirmSubmit(context, o),
|
onSubmit: () => _confirmSubmit(context, o),
|
||||||
onApprove: () => _confirmApprove(context, o),
|
onApprove: () => _confirmApprove(context, o),
|
||||||
onReject: () => _confirmReject(context, o),
|
onReject: () => _confirmReject(context, o),
|
||||||
|
onWithdraw: () => _confirmWithdraw(context, o),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,6 +735,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
|
// Detail dialog — fetches full order with items
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ List<Widget> buildOrderRowActions({
|
|||||||
required VoidCallback onSubmit,
|
required VoidCallback onSubmit,
|
||||||
required VoidCallback onApprove,
|
required VoidCallback onApprove,
|
||||||
required VoidCallback onReject,
|
required VoidCallback onReject,
|
||||||
|
required VoidCallback onWithdraw,
|
||||||
|
bool isAdmin = false,
|
||||||
List<Widget> afterPrint = const [],
|
List<Widget> afterPrint = const [],
|
||||||
}) {
|
}) {
|
||||||
TextButton btn(String text, Color color, VoidCallback onPressed, {Key? key}) =>
|
TextButton btn(String text, Color color, VoidCallback onPressed, {Key? key}) =>
|
||||||
@@ -49,6 +51,11 @@ List<Widget> buildOrderRowActions({
|
|||||||
WriteGuard(
|
WriteGuard(
|
||||||
child: btn('拒绝', AppTheme.danger, onReject,
|
child: btn('拒绝', AppTheme.danger, onReject,
|
||||||
key: Key('btn_reject_$orderId'))),
|
key: Key('btn_reject_$orderId'))),
|
||||||
|
// 撤回(审核中→草稿)仅管理员可见
|
||||||
|
if (isAdmin)
|
||||||
|
WriteGuard(
|
||||||
|
child: btn('撤回', AppTheme.accent, onWithdraw,
|
||||||
|
key: Key('btn_withdraw_$orderId'))),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user