Compare commits

...

7 Commits

Author SHA1 Message Date
wangjia 13a6777a85 chore: release server-v1.0.73
Deploy Server / release-deploy-server (push) Successful in 56s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 19:46:30 +08:00
wangjia 381588826c chore: release client-v1.0.69
Deploy Client / build-client-web (push) Successful in 40s
Deploy Client / build-macos (push) Successful in 2m13s
Deploy Client / build-android (push) Successful in 1m6s
Deploy Client / build-windows (push) Successful in 1m56s
Deploy Client / build-ios (push) Successful in 2m41s
Deploy Client / release-deploy-client (push) Successful in 1m24s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 19:09:47 +08:00
wangjia 7a78448a25 chore: release server-v1.0.72
Deploy Server / release-deploy-server (push) Successful in 56s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 19:09:30 +08:00
wangjia fcc0fd988b chore: release client-v1.0.68
Deploy Client / build-client-web (push) Successful in 38s
Deploy Client / build-windows (push) Successful in 1m53s
Deploy Client / build-macos (push) Successful in 2m7s
Deploy Client / build-android (push) Successful in 59s
Deploy Client / build-ios (push) Successful in 2m36s
Deploy Client / release-deploy-client (push) Successful in 1m23s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 18:14:54 +08:00
wangjia 203cc7ce21 chore: release server-v1.0.71
Deploy Server / release-deploy-server (push) Successful in 56s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 18:14:27 +08:00
wangjia 6d70bd3e37 chore: release client-v1.0.67
Deploy Client / build-client-web (push) Successful in 41s
Deploy Client / build-windows (push) Successful in 1m52s
Deploy Client / build-macos (push) Successful in 2m33s
Deploy Client / build-android (push) Successful in 1m6s
Deploy Client / build-ios (push) Successful in 2m42s
Deploy Client / release-deploy-client (push) Successful in 1m27s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 17:33:32 +08:00
wangjia 7b67466a3e chore: release server-v1.0.70
Deploy Server / release-deploy-server (push) Successful in 56s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 17:32:55 +08:00
24 changed files with 791 additions and 75 deletions
+18
View File
@@ -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.69] - 2026-06-21
### 新功能
- 入库/出库审核列表:管理员可「撤回」审核中的单据,单据回到草稿,修改后重新提交审核
### 改进
- 新建入库单:批次号移到「生产日期」右侧、与各列对齐填写;生产日期输入框收窄,不再占用过宽
## [1.0.68] - 2026-06-21
### 改进
- 店铺公开商品页:每个商品卡片显示「在库 N」数量,且只展示有货商品
## [1.0.67] - 2026-06-21
### 改进
- 选择器改为服务端全量搜索:选供应商/客户时按关键词查全部往来单位(不再只在已加载的首页里找);出库「选择商品」弹窗的搜索也改为实时查全部库存,输入即按编码/名称/系列匹配,不再漏掉未加载的商品
## [1.0.66] - 2026-06-21
### 改进
+20
View File
@@ -5,6 +5,26 @@
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.73] - 2026-06-21
### 改进
- 撤回审核中单据的权限放宽:管理员/超管可撤回任意单据,普通操作员可撤回本人提交的单据(此前仅管理员)
## [1.0.72] - 2026-06-21
### 新功能
- 审核中(待审核)的入库单/出库单,管理员可「撤回」为草稿,修改后重新提交审核;已审核单据仍只读不可撤回
## [1.0.71] - 2026-06-21
### 改进
- 公开店铺商品页只展示「当前有库存」的商品,并返回每个商品的在库数量与商品编码(序列号),无货商品不再露出
## [1.0.70] - 2026-06-21
### 改进
- 商品名称/系列/规格选择接口支持关键词搜索(按名称或编码),为客户端选择器的服务端全量搜索提供支撑
## [1.0.69] - 2026-06-21
### 改进
+19 -3
View File
@@ -2,6 +2,7 @@ package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -24,7 +25,12 @@ func NewProductOptionHandler(db *gorm.DB) *ProductOptionHandler {
func (h *ProductOptionHandler) ListNames(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductNameOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
q := h.db.Where("shop_id = ?", shopID)
if kw := strings.TrimSpace(c.Query("keyword")); kw != "" {
like := "%" + kw + "%"
q = q.Where("name LIKE ? OR code LIKE ?", like, like)
}
q.Order("id ASC").Find(&items)
util.RespondSuccess(c, items)
}
@@ -85,7 +91,12 @@ func (h *ProductOptionHandler) DeleteName(c *gin.Context) {
func (h *ProductOptionHandler) ListSeries(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductSeriesOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
q := h.db.Where("shop_id = ?", shopID)
if kw := strings.TrimSpace(c.Query("keyword")); kw != "" {
like := "%" + kw + "%"
q = q.Where("name LIKE ? OR code LIKE ?", like, like)
}
q.Order("id ASC").Find(&items)
util.RespondSuccess(c, items)
}
@@ -146,7 +157,12 @@ func (h *ProductOptionHandler) DeleteSeries(c *gin.Context) {
func (h *ProductOptionHandler) ListSpecs(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductSpecOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
q := h.db.Where("shop_id = ?", shopID)
if kw := strings.TrimSpace(c.Query("keyword")); kw != "" {
like := "%" + kw + "%"
q = q.Where("name LIKE ? OR code LIKE ?", like, like)
}
q.Order("id ASC").Find(&items)
util.RespondSuccess(c, items)
}
@@ -0,0 +1,86 @@
package handler
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/testutil"
)
// setupOptionRouter 注册 product-options 名称路由(含 JWT)。
func setupOptionRouter(db *gorm.DB) *gin.Engine {
h := NewProductOptionHandler(db)
r := gin.New()
r.Use(gin.Recovery())
api := r.Group("/api/v1")
api.Use(middleware.JWT(db))
names := api.Group("/product-options/names")
names.GET("", h.ListNames)
return r
}
func TestProductOptionHandler_ListNamesKeyword(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "OPT001")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupOptionRouter(db)
db.Create(&model.ProductNameOption{
TenantBase: model.TenantBase{ShopID: shop.ID}, Code: "P001", Name: "茅台",
})
db.Create(&model.ProductNameOption{
TenantBase: model.TenantBase{ShopID: shop.ID}, Code: "P002", Name: "五粮液",
})
// 1. 无 keyword → 返回全部
w := makeRequest(r, "GET", "/api/v1/product-options/names", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
data := parseResponse(w)["data"].([]interface{})
assert.Len(t, data, 2)
// 2. keyword 命中名称
w = makeRequest(r, "GET", "/api/v1/product-options/names?keyword=五粮", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
data = parseResponse(w)["data"].([]interface{})
assert.Len(t, data, 1)
assert.Equal(t, "五粮液", data[0].(map[string]interface{})["name"])
// 3. keyword 命中编码
w = makeRequest(r, "GET", "/api/v1/product-options/names?keyword=P001", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
data = parseResponse(w)["data"].([]interface{})
assert.Len(t, data, 1)
assert.Equal(t, "茅台", data[0].(map[string]interface{})["name"])
// 4. keyword 无命中 → 空
w = makeRequest(r, "GET", "/api/v1/product-options/names?keyword=不存在", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
data = parseResponse(w)["data"].([]interface{})
assert.Len(t, data, 0)
}
func TestProductOptionHandler_ListNamesIsolation(t *testing.T) {
db := testutil.SetupTestDB()
shopA := testutil.CreateTestShop(db, "OPT_A")
userA := testutil.CreateTestUser(db, shopA.ID, "adminA", "pass", "admin")
tokenA := getAuthToken(userA.ID, shopA.ID, "admin")
shopB := testutil.CreateTestShop(db, "OPT_B")
r := setupOptionRouter(db)
db.Create(&model.ProductNameOption{
TenantBase: model.TenantBase{ShopID: shopB.ID}, Name: "他店商品",
})
// A 店即使 keyword 命中 B 店数据也查不到(shop_id 隔离)
w := makeRequest(r, "GET", "/api/v1/product-options/names?keyword=他店", tokenA, nil)
assert.Equal(t, http.StatusOK, w.Code)
data := parseResponse(w)["data"].([]interface{})
assert.Len(t, data, 0)
}
+34 -2
View File
@@ -202,12 +202,14 @@ type publicProductImage struct {
type publicProductResp struct {
ID uint64 `json:"id"`
PublicID string `json:"public_id"`
Code string `json:"code"`
Name string `json:"name"`
Series string `json:"series"`
Spec string `json:"spec"`
Brand string `json:"brand"`
Unit string `json:"unit"`
SalePrice float64 `json:"sale_price"`
Quantity float64 `json:"quantity"`
Images []publicProductImage `json:"images"`
}
@@ -234,8 +236,15 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
pageSize = 20
}
// 仅列「有库存」的商品:JOIN 库存按 product 聚合(数量>0)的子查询。
stockSub := h.db.Model(&model.Inventory{}).
Select("product_id, SUM(quantity) AS qty").
Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0", shop.ID).
Group("product_id")
query := h.db.Model(&model.Product{}).
Where("shop_id = ? AND public_id IS NOT NULL AND public_id != '' AND deleted_at IS NULL", shop.ID)
Joins("JOIN (?) AS stk ON stk.product_id = products.id", stockSub).
Where("products.shop_id = ? AND products.public_id IS NOT NULL AND products.public_id != '' AND products.deleted_at IS NULL", shop.ID)
var total int64
if err := query.Count(&total).Error; err != nil {
@@ -248,12 +257,33 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
if err := query.Preload("Images").
Offset(offset).
Limit(pageSize).
Order("id DESC").
Order("products.id DESC").
Find(&products).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 取本页商品的在库总量(IN 限定在本页 ≤pageSize 个 id,开销小)
qtyMap := make(map[uint64]float64, len(products))
if len(products) > 0 {
pageIDs := make([]uint64, len(products))
for i, p := range products {
pageIDs[i] = p.ID
}
var stockRows []struct {
ProductID uint64
Qty float64
}
h.db.Model(&model.Inventory{}).
Select("product_id, SUM(quantity) AS qty").
Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0 AND product_id IN ?", shop.ID, pageIDs).
Group("product_id").
Scan(&stockRows)
for _, s := range stockRows {
qtyMap[s.ProductID] = s.Qty
}
}
listData := make([]publicProductResp, len(products))
for i, p := range products {
imgs := make([]publicProductImage, len(p.Images))
@@ -263,12 +293,14 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
listData[i] = publicProductResp{
ID: p.ID,
PublicID: p.PublicID,
Code: p.Code,
Name: p.Name,
Series: p.Series,
Spec: p.Spec,
Brand: p.Brand,
Unit: p.Unit,
SalePrice: p.SalePrice,
Quantity: qtyMap[p.ID],
Images: imgs,
}
}
+125
View File
@@ -0,0 +1,125 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/testutil"
)
func setupPublicRouter(db *gorm.DB) *gin.Engine {
h := NewPublicHandler(db)
r := gin.New()
r.Use(gin.Recovery())
r.GET("/api/v1/public/shops/:shop_code/products", h.ListShopProducts)
return r
}
// 给商品补 public_idCreateTestProduct 默认不设)
func setPublicID(db *gorm.DB, productID uint64, pub string) {
require := func(err error) {
if err != nil {
panic(err)
}
}
require(db.Model(&model.Product{}).Where("id = ?", productID).Update("public_id", pub).Error)
}
func addInventory(db *gorm.DB, shopID, warehouseID, productID uint64, qty float64) {
wid := warehouseID
pid := productID
if err := db.Create(&model.Inventory{
ShopID: shopID,
WarehouseID: &wid,
ProductID: &pid,
Quantity: qty,
}).Error; err != nil {
panic(err)
}
}
func TestPublicHandler_ListShopProducts_InStockOnly(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PUB001")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
r := setupPublicRouter(db)
// A:有库存 5 → 应出现,quantity=5
pa := testutil.CreateTestProduct(db, shop.ID, "茅台A")
setPublicID(db, pa.ID, "pub-a")
addInventory(db, shop.ID, wh.ID, pa.ID, 5)
// B:有 public_id 但无库存 → 不出现
pb := testutil.CreateTestProduct(db, shop.ID, "五粮液B")
setPublicID(db, pb.ID, "pub-b")
// C:两条库存 3+2 → 出现,quantity=5
pc := testutil.CreateTestProduct(db, shop.ID, "汾酒C")
setPublicID(db, pc.ID, "pub-c")
addInventory(db, shop.ID, wh.ID, pc.ID, 3)
addInventory(db, shop.ID, wh.ID, pc.ID, 2)
// D:有库存但无 public_id → 不出现
pd := testutil.CreateTestProduct(db, shop.ID, "无公开D")
addInventory(db, shop.ID, wh.ID, pd.ID, 9)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/public/shops/PUB001/products", nil)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
// 只 A、C 两个有库存 + 有 public_id
assert.Equal(t, float64(2), resp["total"])
data := resp["data"].([]interface{})
require.Len(t, data, 2)
byName := map[string]map[string]interface{}{}
for _, it := range data {
m := it.(map[string]interface{})
byName[m["name"].(string)] = m
}
require.Contains(t, byName, "茅台A")
require.Contains(t, byName, "汾酒C")
assert.NotContains(t, byName, "五粮液B") // 无库存
assert.NotContains(t, byName, "无公开D") // 无 public_id
// 数量正确(C 聚合 3+2=5
assert.Equal(t, float64(5), byName["茅台A"]["quantity"])
assert.Equal(t, float64(5), byName["汾酒C"]["quantity"])
// 带上了序列号 code
assert.Equal(t, "P-茅台A", byName["茅台A"]["code"])
}
func TestPublicHandler_ListShopProducts_Isolation(t *testing.T) {
db := testutil.SetupTestDB()
shopA := testutil.CreateTestShop(db, "PUBA")
shopB := testutil.CreateTestShop(db, "PUBB")
whB := testutil.CreateTestWarehouse(db, shopB.ID, "仓B")
r := setupPublicRouter(db)
// B 店有个有库存商品
pb := testutil.CreateTestProduct(db, shopB.ID, "他店商品")
setPublicID(db, pb.ID, "pub-x")
addInventory(db, shopB.ID, whB.ID, pb.ID, 7)
// 查 A 店:看不到 B 店商品
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/public/shops/"+shopA.Code+"/products", nil)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, float64(0), resp["total"])
}
+28
View File
@@ -255,3 +255,31 @@ 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"})
}
+56
View File
@@ -182,6 +182,62 @@ func TestStockInHandler_Reject(t *testing.T) {
assert.Equal(t, "rejected", detailData["status"])
}
func TestStockInHandler_Withdraw(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SI006")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
operator := testutil.CreateTestUser(db, shop.ID, "op", "pass", "operator")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product := testutil.CreateTestProduct(db, shop.ID, "Tequila")
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
opToken := getAuthToken(operator.ID, shop.ID, "operator")
r := setupProtectedRouter(db)
// 创建并提交(进入 pending)
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", adminToken, map[string]interface{}{
"warehouse_id": warehouse.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{
{"product_id": product.ID, "quantity": 5.0},
},
})
orderID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderID), adminToken, nil)
// 1. 操作员撤回「他人(管理员)」的单 → 403
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/withdraw", orderID), opToken, nil)
assert.Equal(t, http.StatusForbidden, w.Code)
// 2. 管理员撤回任意单 → 200,状态回到 draft
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/withdraw", orderID), adminToken, nil)
assert.Equal(t, http.StatusOK, w.Code)
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", orderID), adminToken, nil)
assert.Equal(t, "draft", parseResponse(w)["data"].(map[string]interface{})["status"])
// 3. 撤回后已是 draft,再撤回 → 400(仅 pending 可撤回)
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/withdraw", orderID), adminToken, nil)
assert.Equal(t, http.StatusBadRequest, w.Code)
// 4. 撤回为 draft 后可再次修改并提交
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderID), adminToken, nil)
assert.Equal(t, http.StatusOK, w.Code)
// 5. 操作员撤回「本人」提交的单 → 200(自己发的肯定能撤)
w = makeRequest(r, "POST", "/api/v1/stock-in/orders", opToken, map[string]interface{}{
"warehouse_id": warehouse.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{
{"product_id": product.ID, "quantity": 3.0},
},
})
ownID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", ownID), opToken, nil)
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/withdraw", ownID), opToken, nil)
assert.Equal(t, http.StatusOK, w.Code)
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", ownID), opToken, nil)
assert.Equal(t, "draft", parseResponse(w)["data"].(map[string]interface{})["status"])
}
func TestStockInHandler_GetNotFound(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SI006")
+28
View File
@@ -240,3 +240,31 @@ 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"})
}
@@ -63,6 +63,7 @@ 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)
// 出库路由
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/approve", stockOutH.Approve)
stockOut.PUT("/orders/:id/reject", stockOutH.Reject)
stockOut.PUT("/orders/:id/withdraw", stockOutH.Withdraw)
// 库存路由
inv := api.Group("/inventory")
+4
View File
@@ -153,6 +153,8 @@ 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)
}
// 出库
@@ -166,6 +168,8 @@ 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)
}
// 库存
+39
View File
@@ -241,6 +241,45 @@ func SetupTestDB() *gorm.DB {
content TEXT,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS product_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
shop_id INTEGER NOT NULL,
url TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
created_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS product_name_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS product_series_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS product_spec_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
quantity INTEGER DEFAULT 0,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS warehouses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
+7
View File
@@ -194,3 +194,10 @@ final isReadonlyProvider = Provider<bool>((ref) {
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
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);
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();
}
}
@@ -119,4 +119,15 @@ class StockInRepository {
);
}
}
Future<void> withdraw(int id) async {
try {
await _client.put('/stock-in/orders/$id/withdraw');
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '撤回失败',
statusCode: e.response?.statusCode,
);
}
}
}
@@ -119,4 +119,15 @@ class StockOutRepository {
);
}
}
Future<void> withdraw(int id) async {
try {
await _client.put('/stock-out/orders/$id/withdraw');
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '撤回失败',
statusCode: e.response?.statusCode,
);
}
}
}
@@ -269,6 +269,12 @@ class _ProductCard extends StatelessWidget {
final name = item['name'] as String? ?? '';
final series = item['series'] as String? ?? '';
final spec = item['spec'] as String? ?? '';
final unit = item['unit'] as String? ?? '';
final qtyNum = (item['quantity'] as num?)?.toDouble() ?? 0;
// 去掉整数的 .0 尾巴:5.0 → 51.5 → 1.5
final qtyStr = qtyNum == qtyNum.roundToDouble()
? qtyNum.toInt().toString()
: qtyNum.toString();
final images = (item['images'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
final imageUrl = images.isNotEmpty
@@ -339,7 +345,18 @@ class _ProductCard extends StatelessWidget {
const Spacer(),
Row(
children: [
const Spacer(),
Flexible(
child: Text(
'在库 $qtyStr$unit',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: _kBurgundy,
fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
@@ -604,6 +604,20 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
hint: '请选择供应商',
dialogTitle: '选择供应商',
isRequired: true,
onSearch: (kw) async {
final res = await ref
.read(partnerRepositoryProvider)
.list(
type: 'supplier',
keyword: kw,
pageSize: 50);
return res.data
.map((p) => OptionItem(
id: p.id,
name: p.name,
code: p.code))
.toList();
},
onChanged: (v) =>
setState(() => _partnerId = v),
),
@@ -859,7 +873,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
controller: item.batchNoCtrl,
decoration: const InputDecoration(
hintText: '选填',
labelText: '批次号',
isDense: true,
),
style: const TextStyle(fontSize: 13),
@@ -979,7 +992,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: 14, child: th('生产日期')),
Expanded(flex: 12, child: th('批次号')),
Expanded(flex: 10, child: th('数量')),
Expanded(flex: 10, child: th('单价')),
Expanded(flex: 10, child: th('金额')),
@@ -1003,8 +1017,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;
@@ -1036,10 +1049,15 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
padding: const EdgeInsets.all(4),
child: _specField(item))),
Expanded(
flex: 24,
flex: 14,
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(
@@ -1057,7 +1075,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,
@@ -1117,8 +1135,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(
@@ -1156,6 +1172,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)}'),
@@ -1164,15 +1181,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,7 @@ 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;
class StockInListScreen extends ConsumerStatefulWidget {
const StockInListScreen({super.key});
@@ -478,6 +479,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
List<Widget> _orderActions(BuildContext context, StockInOrder o) {
return buildOrderRowActions(
readonly: WriteGuard.isReadonly(ref),
isAdmin: ref.watch(isAdminProvider),
status: o.status,
orderId: o.id,
onDetail: () => _showDetail(context, o.id),
@@ -525,6 +527,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
onSubmit: () => _confirmSubmit(context, o),
onApprove: () => _confirmApprove(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
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -7,6 +9,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/auth/auth_state.dart';
import '../../core/utils/print_util.dart';
import '../../core/utils/date_util.dart';
import '../../models/inventory.dart';
import '../../models/stock_out.dart';
import '../../widgets/date_picker_field.dart';
import '../../widgets/searchable_option_field.dart';
@@ -39,6 +42,39 @@ class _PickerItem {
});
}
/// 把库存行按 product 聚合为选择器条目(同一 product 的多行数量相加)。
List<_PickerItem> _aggregatePickerItems(List<Inventory> rows) {
final Map<int, _PickerItem> map = {};
for (final inv in rows.where((inv) => inv.productId != null)) {
final pid = inv.productId!;
final existing = map[pid];
if (existing != null) {
map[pid] = _PickerItem(
productId: pid,
productCode: existing.productCode,
productName: existing.productName,
series: existing.series,
spec: existing.spec,
unit: existing.unit,
unitPrice: existing.unitPrice ?? inv.unitPrice,
availableQty: existing.availableQty + inv.quantity,
);
} else {
map[pid] = _PickerItem(
productId: pid,
productCode: inv.productCode,
productName: inv.productName,
series: inv.series,
spec: inv.spec,
unit: inv.unit,
unitPrice: inv.unitPrice,
availableQty: inv.quantity,
);
}
}
return map.values.toList();
}
class _ItemRow {
int? productId;
final String productCode;
@@ -158,36 +194,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
.listInventory(
warehouseId: warehouseId,
pageSize: AppConstants.stockOutPickerPageSize);
final Map<int, _PickerItem> productMap = {};
for (final inv in result.data.where((inv) => inv.productId != null)) {
final pid = inv.productId!;
if (productMap.containsKey(pid)) {
final existing = productMap[pid]!;
productMap[pid] = _PickerItem(
productId: pid,
productCode: existing.productCode,
productName: existing.productName,
series: existing.series,
spec: existing.spec,
unit: existing.unit,
unitPrice: existing.unitPrice ?? inv.unitPrice,
availableQty: existing.availableQty + inv.quantity,
);
} else {
productMap[pid] = _PickerItem(
productId: pid,
productCode: inv.productCode,
productName: inv.productName,
series: inv.series,
spec: inv.spec,
unit: inv.unit,
unitPrice: inv.unitPrice,
availableQty: inv.quantity,
);
}
}
setState(() {
_inventoryPickerItems = productMap.values.toList();
_inventoryPickerItems = _aggregatePickerItems(result.data);
_inventoryMap = {
for (final item in _inventoryPickerItems)
item.productId: item.availableQty
@@ -206,7 +214,10 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
}
final selected = await showDialog<List<_PickerItem>>(
context: context,
builder: (_) => _InventoryPickerDialog(items: _inventoryPickerItems),
builder: (_) => _InventoryPickerDialog(
items: _inventoryPickerItems,
warehouseId: _warehouseId,
),
);
if (selected == null || selected.isEmpty) return;
setState(() {
@@ -532,6 +543,20 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
selectedId: _partnerId,
hint: '请选择客户',
dialogTitle: '选择客户',
onSearch: (kw) async {
final res = await ref
.read(partnerRepositoryProvider)
.list(
type: 'customer',
keyword: kw,
pageSize: 50);
return res.data
.map((p) => OptionItem(
id: p.id,
name: p.name,
code: p.code))
.toList();
},
onChanged: (v) =>
setState(() => _partnerId = v),
),
@@ -873,39 +898,67 @@ class _FormField extends StatelessWidget {
}
}
class _InventoryPickerDialog extends StatefulWidget {
class _InventoryPickerDialog extends ConsumerStatefulWidget {
final List<_PickerItem> items;
const _InventoryPickerDialog({required this.items});
final int? warehouseId;
const _InventoryPickerDialog({required this.items, this.warehouseId});
@override
State<_InventoryPickerDialog> createState() => _InventoryPickerDialogState();
ConsumerState<_InventoryPickerDialog> createState() =>
_InventoryPickerDialogState();
}
class _InventoryPickerDialogState extends State<_InventoryPickerDialog> {
class _InventoryPickerDialogState
extends ConsumerState<_InventoryPickerDialog> {
final _searchCtrl = TextEditingController();
String _search = '';
final Set<int> _selected = {};
// 服务端搜索结果(替代一次性拉全部本地过滤);初始用调用方已加载的首屏
late List<_PickerItem> _results = widget.items;
// 已见过的条目(跨多次搜索),用于确认时按 productId 还原所选
late final Map<int, _PickerItem> _known = {
for (final it in widget.items) it.productId: it
};
bool _loading = false;
Timer? _debounce;
@override
void dispose() {
_debounce?.cancel();
_searchCtrl.dispose();
super.dispose();
}
List<_PickerItem> get _filtered {
if (_search.isEmpty) return widget.items;
final q = _search.toLowerCase();
return widget.items
.where((item) =>
item.productName.toLowerCase().contains(q) ||
item.productCode.toLowerCase().contains(q) ||
item.series.toLowerCase().contains(q))
.toList();
void _onSearchChanged(String v) {
setState(() => _search = v);
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), _fetch);
}
Future<void> _fetch() async {
setState(() => _loading = true);
try {
final kw = _search.trim();
final result = await ref.read(inventoryRepositoryProvider).listInventory(
warehouseId: widget.warehouseId,
keyword: kw.isEmpty ? null : kw,
pageSize: AppConstants.stockOutPickerPageSize,
);
final items = _aggregatePickerItems(result.data);
for (final it in items) {
_known[it.productId] = it;
}
if (mounted) setState(() => _results = items);
} catch (_) {
// 忽略:保留上次结果
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final filtered = _filtered;
final filtered = _results;
final allSelected = filtered.isNotEmpty &&
filtered.every((e) => _selected.contains(e.productId));
@@ -945,14 +998,24 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: TextField(
controller: _searchCtrl,
decoration: const InputDecoration(
decoration: InputDecoration(
hintText: '搜索商品编码、名称或系列',
prefixIcon: Icon(Icons.search, size: 18),
prefixIcon: const Icon(Icons.search, size: 18),
suffixIcon: _loading
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: null,
isDense: true,
contentPadding:
EdgeInsets.symmetric(horizontal: 12, vertical: 10),
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
onChanged: (v) => setState(() => _search = v),
onChanged: _onSearchChanged,
),
),
// Table header
@@ -1068,9 +1131,9 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> {
onPressed: _selected.isEmpty
? null
: () {
final result = widget.items
.where((item) =>
_selected.contains(item.productId))
final result = _selected
.map((id) => _known[id])
.whereType<_PickerItem>()
.toList();
Navigator.pop(context, result);
},
@@ -23,6 +23,7 @@ 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;
class StockOutListScreen extends ConsumerStatefulWidget {
const StockOutListScreen({super.key});
@@ -484,6 +485,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
List<Widget> _orderActions(BuildContext context, StockOutOrder o) {
return buildOrderRowActions(
readonly: WriteGuard.isReadonly(ref),
isAdmin: ref.watch(isAdminProvider),
status: o.status,
orderId: o.id,
onDetail: () => _showDetail(context, o.id),
@@ -499,6 +501,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
onSubmit: () => _confirmSubmit(context, o),
onApprove: () => _confirmApprove(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
@@ -23,6 +23,8 @@ List<Widget> buildOrderRowActions({
required VoidCallback onSubmit,
required VoidCallback onApprove,
required VoidCallback onReject,
required VoidCallback onWithdraw,
bool isAdmin = 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'))),
// 撤回(审核中→草稿)仅管理员可见
if (isAdmin)
WriteGuard(
child: btn('撤回', AppTheme.accent, onWithdraw,
key: Key('btn_withdraw_$orderId'))),
],
];
}
@@ -1,3 +1,5 @@
import 'dart:async';
import '../core/utils/dialog_util.dart';
import 'package:flutter/material.dart';
import '../core/responsive/responsive.dart';
@@ -41,6 +43,9 @@ class SearchableOptionField extends StatelessWidget {
/// 创建成功返回新选项 id(自动选中),失败/取消返回 null。为空则不显示新增入口。
final Future<int?> Function(String keyword)? onCreate;
/// 可选:服务端搜索。提供时搜索框输入会 debounce 调它取结果(替代本地过滤)。
final Future<List<OptionItem>> Function(String keyword)? onSearch;
const SearchableOptionField({
super.key,
required this.options,
@@ -51,6 +56,7 @@ class SearchableOptionField extends StatelessWidget {
this.isRequired = false,
this.isDense = true,
this.onCreate,
this.onSearch,
});
String get _displayText {
@@ -66,6 +72,7 @@ class SearchableOptionField extends StatelessWidget {
options: options,
selectedId: selectedId,
onCreate: onCreate,
onSearch: onSearch,
),
);
// result == -1 means "clear selection"
@@ -120,11 +127,13 @@ class _SearchDialog extends StatefulWidget {
final List<OptionItem> options;
final int? selectedId;
final Future<int?> Function(String keyword)? onCreate;
final Future<List<OptionItem>> Function(String keyword)? onSearch;
const _SearchDialog({
required this.title,
required this.options,
this.selectedId,
this.onCreate,
this.onSearch,
});
@override
@@ -135,9 +144,28 @@ class _SearchDialogState extends State<_SearchDialog> {
final _ctrl = TextEditingController();
String _keyword = '';
bool _creating = false;
List<OptionItem>? _serverItems; // 服务端搜索结果(null=尚未搜索,用 widget.options
bool _searching = false;
Timer? _debounce;
void _onSearchChanged(String v) {
setState(() => _keyword = v);
if (widget.onSearch == null) return;
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () async {
setState(() => _searching = true);
try {
final res = await widget.onSearch!(v.trim());
if (mounted) setState(() => _serverItems = res);
} finally {
if (mounted) setState(() => _searching = false);
}
});
}
@override
void dispose() {
_debounce?.cancel();
_ctrl.dispose();
super.dispose();
}
@@ -197,12 +225,14 @@ class _SearchDialogState extends State<_SearchDialog> {
@override
Widget build(BuildContext context) {
final filtered = _keyword.isEmpty
? widget.options
: widget.options.where((o) => o.matches(_keyword)).toList();
final filtered = widget.onSearch != null
? (_serverItems ?? widget.options)
: (_keyword.isEmpty
? widget.options
: widget.options.where((o) => o.matches(_keyword)).toList());
final kw = _keyword.trim();
final hasExact =
widget.options.any((o) => o.name.toLowerCase() == kw.toLowerCase());
filtered.any((o) => o.name.toLowerCase() == kw.toLowerCase());
final canCreate = widget.onCreate != null && kw.isNotEmpty && !hasExact;
return AlertDialog(
@@ -216,12 +246,22 @@ class _SearchDialogState extends State<_SearchDialog> {
TextField(
controller: _ctrl,
autofocus: true,
decoration: const InputDecoration(
decoration: InputDecoration(
hintText: '搜索...',
prefixIcon: Icon(Icons.search, size: 18),
prefixIcon: const Icon(Icons.search, size: 18),
suffixIcon: _searching
? const Padding(
padding: EdgeInsets.all(10),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: null,
isDense: true,
),
onChanged: (v) => setState(() => _keyword = v),
onChanged: _onSearchChanged,
),
const SizedBox(height: 8),
Expanded(