feat: 商品追踪页面 + 出库提交库存校验
后端: - feat(backend): 重命名 Batches→Products 接口,新增库存状态(在售/已卖出)和买家信息 - feat(backend): 出库单创建/提交时校验仓库库存,不足则返回明确错误信息 - fix(backend): 出库创建 status 判断逻辑修复(空值默认 draft) 前端: - feat(client): 批次追踪改为商品追踪,新增状态列(在售/已卖出)和买家/时间列 - fix(client): 无批次号时显示"无批次"而非空 - refactor(client): BatchRecord → ProductTrackingRecord,repository 接口更新 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -69,13 +70,21 @@ func (h *InventoryHandler) Logs(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
|
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batches GET /api/v1/inventory/batches — 批次追踪:已审核入库单的明细行
|
// productTrackingItem is the response shape for GET /api/v1/inventory/products
|
||||||
func (h *InventoryHandler) Batches(c *gin.Context) {
|
type productTrackingItem struct {
|
||||||
|
model.StockInItem
|
||||||
|
CurrentQty float64 `json:"current_qty"`
|
||||||
|
Status string `json:"status"` // in_stock | sold_out
|
||||||
|
BuyerName string `json:"buyer_name,omitempty"`
|
||||||
|
SoldAt string `json:"sold_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Products GET /api/v1/inventory/products — 商品追踪:已审核入库单的明细行,附库存状态和买家
|
||||||
|
func (h *InventoryHandler) Products(c *gin.Context) {
|
||||||
shopID := middleware.GetShopID(c)
|
shopID := middleware.GetShopID(c)
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
// 查询已审核的入库单明细,关联商品和仓库信息
|
|
||||||
query := h.db.Model(&model.StockInItem{}).
|
query := h.db.Model(&model.StockInItem{}).
|
||||||
Joins("JOIN stock_in_orders ON stock_in_orders.id = stock_in_items.order_id").
|
Joins("JOIN stock_in_orders ON stock_in_orders.id = stock_in_items.order_id").
|
||||||
Where("stock_in_orders.shop_id = ? AND stock_in_orders.status = 'approved'", shopID)
|
Where("stock_in_orders.shop_id = ? AND stock_in_orders.status = 'approved'", shopID)
|
||||||
@@ -98,10 +107,76 @@ func (h *InventoryHandler) Batches(c *gin.Context) {
|
|||||||
}).
|
}).
|
||||||
Select("stock_in_items.*").
|
Select("stock_in_items.*").
|
||||||
Order("stock_in_orders.order_date DESC, stock_in_items.id DESC").
|
Order("stock_in_orders.order_date DESC, stock_in_items.id DESC").
|
||||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
Offset((page-1)*pageSize).Limit(pageSize).
|
||||||
Find(&items)
|
Find(&items)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"data": items, "total": total, "page": page, "page_size": pageSize})
|
// 一次查出所有库存,构建 product+warehouse → qty 的 map
|
||||||
|
var inventories []model.Inventory
|
||||||
|
h.db.Where("shop_id = ?", shopID).Find(&inventories)
|
||||||
|
invMap := make(map[string]float64, len(inventories))
|
||||||
|
for _, inv := range inventories {
|
||||||
|
key := fmt.Sprintf("%d:%d", inv.ProductID, inv.WarehouseID)
|
||||||
|
invMap[key] = inv.Quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询每个 product+warehouse 最新的出库买家信息
|
||||||
|
type soldRow struct {
|
||||||
|
ProductID uint64 `gorm:"column:product_id"`
|
||||||
|
WarehouseID uint64 `gorm:"column:warehouse_id"`
|
||||||
|
BuyerName string `gorm:"column:buyer_name"`
|
||||||
|
SoldAt string `gorm:"column:sold_at"`
|
||||||
|
}
|
||||||
|
var soldRows []soldRow
|
||||||
|
h.db.Raw(`
|
||||||
|
SELECT sooi.product_id, soo.warehouse_id,
|
||||||
|
COALESCE(p.name, '') AS buyer_name,
|
||||||
|
CAST(MAX(soo.order_date) AS CHAR) AS sold_at
|
||||||
|
FROM stock_out_orders soo
|
||||||
|
JOIN stock_out_items sooi ON sooi.order_id = soo.id
|
||||||
|
LEFT JOIN partners p ON p.id = soo.partner_id AND p.shop_id = ?
|
||||||
|
WHERE soo.shop_id = ? AND soo.status = 'approved'
|
||||||
|
GROUP BY sooi.product_id, soo.warehouse_id
|
||||||
|
`, shopID, shopID).Scan(&soldRows)
|
||||||
|
soldMap := make(map[string]soldRow, len(soldRows))
|
||||||
|
for _, r := range soldRows {
|
||||||
|
key := fmt.Sprintf("%d:%d", r.ProductID, r.WarehouseID)
|
||||||
|
soldMap[key] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]productTrackingItem, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
var warehouseID uint64
|
||||||
|
if item.Order != nil {
|
||||||
|
warehouseID = item.Order.WarehouseID
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("%d:%d", item.ProductID, warehouseID)
|
||||||
|
currentQty := invMap[key]
|
||||||
|
|
||||||
|
status := "in_stock"
|
||||||
|
buyerName := ""
|
||||||
|
soldAt := ""
|
||||||
|
if currentQty <= 0 {
|
||||||
|
status = "sold_out"
|
||||||
|
if si, ok := soldMap[key]; ok {
|
||||||
|
buyerName = si.BuyerName
|
||||||
|
if len(si.SoldAt) > 10 {
|
||||||
|
soldAt = si.SoldAt[:10]
|
||||||
|
} else {
|
||||||
|
soldAt = si.SoldAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, productTrackingItem{
|
||||||
|
StockInItem: item,
|
||||||
|
CurrentQty: currentQty,
|
||||||
|
Status: status,
|
||||||
|
BuyerName: buyerName,
|
||||||
|
SoldAt: soldAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": result, "total": total, "page": page, "page_size": pageSize})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCheck POST /api/v1/inventory/checks
|
// CreateCheck POST /api/v1/inventory/checks
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -64,6 +65,31 @@ func (h *StockOutHandler) Get(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"data": order})
|
c.JSON(http.StatusOK, gin.H{"data": order})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkInventory validates that warehouse has enough stock for each item.
|
||||||
|
// warehouseID is the order's warehouse; items are the stock-out line items.
|
||||||
|
func (h *StockOutHandler) checkInventory(shopID, warehouseID uint64, items []model.StockOutItem) error {
|
||||||
|
for _, item := range items {
|
||||||
|
var inv model.Inventory
|
||||||
|
err := h.db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||||
|
shopID, warehouseID, item.ProductID).First(&inv).Error
|
||||||
|
if err != nil || inv.Quantity < item.Quantity {
|
||||||
|
// Try to get product name for a clearer error message
|
||||||
|
var p model.Product
|
||||||
|
h.db.Where("id = ?", item.ProductID).First(&p)
|
||||||
|
name := p.Name
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("商品ID %d", item.ProductID)
|
||||||
|
}
|
||||||
|
have := 0.0
|
||||||
|
if err == nil {
|
||||||
|
have = inv.Quantity
|
||||||
|
}
|
||||||
|
return fmt.Errorf("库存不足:%s 当前库存 %.0f,需要 %.0f", name, have, item.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Create POST /api/v1/stock-out/orders
|
// Create POST /api/v1/stock-out/orders
|
||||||
func (h *StockOutHandler) Create(c *gin.Context) {
|
func (h *StockOutHandler) Create(c *gin.Context) {
|
||||||
shopID := middleware.GetShopID(c)
|
shopID := middleware.GetShopID(c)
|
||||||
@@ -77,7 +103,16 @@ func (h *StockOutHandler) Create(c *gin.Context) {
|
|||||||
|
|
||||||
req.ShopID = shopID
|
req.ShopID = shopID
|
||||||
req.OperatorID = operatorID
|
req.OperatorID = operatorID
|
||||||
req.Status = "draft"
|
|
||||||
|
// 状态只允许 draft 或 pending;直接提交审核时校验库存
|
||||||
|
if req.Status == "pending" {
|
||||||
|
if err := h.checkInventory(shopID, req.WarehouseID, req.Items); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
req.Status = "draft"
|
||||||
|
}
|
||||||
|
|
||||||
orderNo, err := h.stockSvc.GenerateOrderNo(shopID, "stock_out")
|
orderNo, err := h.stockSvc.GenerateOrderNo(shopID, "stock_out")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -104,13 +139,21 @@ func (h *StockOutHandler) Create(c *gin.Context) {
|
|||||||
// Submit PUT /api/v1/stock-out/orders/:id/submit
|
// Submit PUT /api/v1/stock-out/orders/:id/submit
|
||||||
func (h *StockOutHandler) Submit(c *gin.Context) {
|
func (h *StockOutHandler) Submit(c *gin.Context) {
|
||||||
shopID := middleware.GetShopID(c)
|
shopID := middleware.GetShopID(c)
|
||||||
result := h.db.Model(&model.StockOutOrder{}).
|
|
||||||
|
// 加载订单及明细,校验库存后再改状态
|
||||||
|
var order model.StockOutOrder
|
||||||
|
if err := h.db.Preload("Items").
|
||||||
Where("id = ? AND shop_id = ? AND status = 'draft'", c.Param("id"), shopID).
|
Where("id = ? AND shop_id = ? AND status = 'draft'", c.Param("id"), shopID).
|
||||||
Update("status", "pending")
|
First(&order).Error; err != nil {
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in draft status"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in draft status"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := h.checkInventory(shopID, order.WarehouseID, order.Items); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.db.Model(&order).Update("status", "pending")
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "submitted"})
|
c.JSON(http.StatusOK, gin.H{"message": "submitted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
{
|
{
|
||||||
inventory.GET("", inventoryH.List)
|
inventory.GET("", inventoryH.List)
|
||||||
inventory.GET("/logs", inventoryH.Logs)
|
inventory.GET("/logs", inventoryH.Logs)
|
||||||
inventory.GET("/batches", inventoryH.Batches)
|
inventory.GET("/products", inventoryH.Products)
|
||||||
inventory.POST("/checks", inventoryH.CreateCheck)
|
inventory.POST("/checks", inventoryH.CreateCheck)
|
||||||
inventory.GET("/checks/:id", inventoryH.GetCheck)
|
inventory.GET("/checks/:id", inventoryH.GetCheck)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ class Inventory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批次追踪:已审核入库单的明细行
|
// 商品追踪:已审核入库单的明细行,含库存状态和买家信息
|
||||||
class BatchRecord {
|
class ProductTrackingRecord {
|
||||||
final int id;
|
final int id;
|
||||||
final int productId;
|
final int productId;
|
||||||
final String? productName;
|
final String? productName;
|
||||||
@@ -58,8 +58,13 @@ class BatchRecord {
|
|||||||
final String? orderDate;
|
final String? orderDate;
|
||||||
final String? warehouseName;
|
final String? warehouseName;
|
||||||
final String? supplierName;
|
final String? supplierName;
|
||||||
|
// 库存状态
|
||||||
|
final double currentQty;
|
||||||
|
final String status; // in_stock | sold_out
|
||||||
|
final String? buyerName;
|
||||||
|
final String? soldAt;
|
||||||
|
|
||||||
const BatchRecord({
|
const ProductTrackingRecord({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.productId,
|
required this.productId,
|
||||||
this.productName,
|
this.productName,
|
||||||
@@ -73,14 +78,20 @@ class BatchRecord {
|
|||||||
this.orderDate,
|
this.orderDate,
|
||||||
this.warehouseName,
|
this.warehouseName,
|
||||||
this.supplierName,
|
this.supplierName,
|
||||||
|
required this.currentQty,
|
||||||
|
required this.status,
|
||||||
|
this.buyerName,
|
||||||
|
this.soldAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory BatchRecord.fromJson(Map<String, dynamic> json) {
|
bool get isSoldOut => status == 'sold_out';
|
||||||
|
|
||||||
|
factory ProductTrackingRecord.fromJson(Map<String, dynamic> json) {
|
||||||
final product = json['product'] as Map<String, dynamic>?;
|
final product = json['product'] as Map<String, dynamic>?;
|
||||||
final order = json['order'] as Map<String, dynamic>?;
|
final order = json['order'] as Map<String, dynamic>?;
|
||||||
final warehouse = order?['warehouse'] as Map<String, dynamic>?;
|
final warehouse = order?['warehouse'] as Map<String, dynamic>?;
|
||||||
final partner = order?['partner'] as Map<String, dynamic>?;
|
final partner = order?['partner'] as Map<String, dynamic>?;
|
||||||
return BatchRecord(
|
return ProductTrackingRecord(
|
||||||
id: (json['id'] as num).toInt(),
|
id: (json['id'] as num).toInt(),
|
||||||
productId: (json['product_id'] as num).toInt(),
|
productId: (json['product_id'] as num).toInt(),
|
||||||
productName: product?['name'] as String?,
|
productName: product?['name'] as String?,
|
||||||
@@ -94,6 +105,12 @@ class BatchRecord {
|
|||||||
orderDate: order?['order_date'] as String?,
|
orderDate: order?['order_date'] as String?,
|
||||||
warehouseName: warehouse?['name'] as String?,
|
warehouseName: warehouse?['name'] as String?,
|
||||||
supplierName: partner?['name'] as String?,
|
supplierName: partner?['name'] as String?,
|
||||||
|
currentQty: json['current_qty'] != null
|
||||||
|
? (json['current_qty'] as num).toDouble()
|
||||||
|
: 0,
|
||||||
|
status: json['status'] as String? ?? 'in_stock',
|
||||||
|
buyerName: json['buyer_name'] as String?,
|
||||||
|
soldAt: json['sold_at'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class InventoryRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<PageResult<BatchRecord>> listBatches({
|
Future<PageResult<ProductTrackingRecord>> listProducts({
|
||||||
int? productId,
|
int? productId,
|
||||||
int? warehouseId,
|
int? warehouseId,
|
||||||
int page = 1,
|
int page = 1,
|
||||||
@@ -60,14 +60,14 @@ class InventoryRepository {
|
|||||||
if (productId != null) 'product_id': productId,
|
if (productId != null) 'product_id': productId,
|
||||||
if (warehouseId != null) 'warehouse_id': warehouseId,
|
if (warehouseId != null) 'warehouse_id': warehouseId,
|
||||||
};
|
};
|
||||||
final resp = await _client.get('/inventory/batches', params: params);
|
final resp = await _client.get('/inventory/products', params: params);
|
||||||
return PageResult.fromJson(
|
return PageResult.fromJson(
|
||||||
resp.data as Map<String, dynamic>,
|
resp.data as Map<String, dynamic>,
|
||||||
BatchRecord.fromJson,
|
ProductTrackingRecord.fromJson,
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
throw AppException(
|
throw AppException(
|
||||||
e.response?.data?['error'] as String? ?? '获取批次数据失败',
|
e.response?.data?['error'] as String? ?? '获取商品追踪数据失败',
|
||||||
statusCode: e.response?.statusCode,
|
statusCode: e.response?.statusCode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class BatchTrackingScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||||
int _page = 1;
|
int _page = 1;
|
||||||
late Future<List<BatchRecord>> _future;
|
int _total = 0;
|
||||||
|
late Future<List<ProductTrackingRecord>> _future;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -26,17 +27,18 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
|||||||
void _fetch() {
|
void _fetch() {
|
||||||
_future = ref
|
_future = ref
|
||||||
.read(inventoryRepositoryProvider)
|
.read(inventoryRepositoryProvider)
|
||||||
.listBatches(page: _page, pageSize: 20)
|
.listProducts(page: _page, pageSize: 20)
|
||||||
.then((r) => r.data);
|
.then((r) {
|
||||||
|
_total = r.total;
|
||||||
|
return r.data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _refetch() {
|
void _refetch() => setState(() => _fetch());
|
||||||
setState(() => _fetch());
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FutureBuilder<List<BatchRecord>>(
|
return FutureBuilder<List<ProductTrackingRecord>>(
|
||||||
future: _future,
|
future: _future,
|
||||||
builder: (context, snap) {
|
builder: (context, snap) {
|
||||||
if (snap.connectionState == ConnectionState.waiting) {
|
if (snap.connectionState == ConnectionState.waiting) {
|
||||||
@@ -61,30 +63,26 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTable(List<BatchRecord> records) {
|
Widget _buildTable(List<ProductTrackingRecord> records) {
|
||||||
return DataTableCard(
|
return DataTableCard(
|
||||||
totalCount: records.length,
|
totalCount: _total,
|
||||||
page: _page,
|
page: _page,
|
||||||
onPageChanged: (p) {
|
onPageChanged: (p) => setState(() {
|
||||||
setState(() {
|
_page = p;
|
||||||
_page = p;
|
_fetch();
|
||||||
_fetch();
|
}),
|
||||||
});
|
|
||||||
},
|
|
||||||
toolbar: Row(
|
toolbar: Row(
|
||||||
children: [
|
children: [
|
||||||
const Text('已审核入库批次',
|
const Text('已审核入库商品(含库存与销售状态)',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13, color: AppTheme.textSecondary)),
|
fontSize: 13, color: AppTheme.textSecondary)),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh, size: 18),
|
icon: const Icon(Icons.refresh, size: 18),
|
||||||
onPressed: () {
|
onPressed: () => setState(() {
|
||||||
setState(() {
|
_page = 1;
|
||||||
_page = 1;
|
_fetch();
|
||||||
_fetch();
|
}),
|
||||||
});
|
|
||||||
},
|
|
||||||
tooltip: '刷新',
|
tooltip: '刷新',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -99,12 +97,14 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
|||||||
DataColumn(label: Text('入库日期')),
|
DataColumn(label: Text('入库日期')),
|
||||||
DataColumn(label: Text('数量'), numeric: true),
|
DataColumn(label: Text('数量'), numeric: true),
|
||||||
DataColumn(label: Text('单价'), numeric: true),
|
DataColumn(label: Text('单价'), numeric: true),
|
||||||
|
DataColumn(label: Text('状态')),
|
||||||
|
DataColumn(label: Text('买家/时间')),
|
||||||
],
|
],
|
||||||
rows: records.isEmpty
|
rows: records.isEmpty
|
||||||
? [
|
? [
|
||||||
const DataRow(cells: [
|
const DataRow(cells: [
|
||||||
DataCell(SizedBox()),
|
DataCell(SizedBox()),
|
||||||
DataCell(Text('暂无批次记录',
|
DataCell(Text('暂无记录',
|
||||||
style: TextStyle(color: AppTheme.textSecondary))),
|
style: TextStyle(color: AppTheme.textSecondary))),
|
||||||
DataCell(SizedBox()),
|
DataCell(SizedBox()),
|
||||||
DataCell(SizedBox()),
|
DataCell(SizedBox()),
|
||||||
@@ -113,33 +113,34 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
|||||||
DataCell(SizedBox()),
|
DataCell(SizedBox()),
|
||||||
DataCell(SizedBox()),
|
DataCell(SizedBox()),
|
||||||
DataCell(SizedBox()),
|
DataCell(SizedBox()),
|
||||||
|
DataCell(SizedBox()),
|
||||||
|
DataCell(SizedBox()),
|
||||||
])
|
])
|
||||||
]
|
]
|
||||||
: records.map((r) {
|
: records.map((r) {
|
||||||
final hasBatch =
|
final batchText =
|
||||||
r.batchNo != null && r.batchNo!.isNotEmpty;
|
(r.batchNo != null && r.batchNo!.isNotEmpty)
|
||||||
|
? r.batchNo!
|
||||||
|
: null;
|
||||||
return DataRow(cells: [
|
return DataRow(cells: [
|
||||||
DataCell(
|
DataCell(Column(
|
||||||
Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
children: [
|
||||||
children: [
|
Text(r.productName ?? '-',
|
||||||
Text(r.productName ?? '-',
|
style: const TextStyle(
|
||||||
|
fontSize: 13, fontWeight: FontWeight.w500)),
|
||||||
|
if (r.productCode != null)
|
||||||
|
Text(r.productCode!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w500)),
|
color: AppTheme.textSecondary,
|
||||||
if (r.productCode != null)
|
fontFamily: 'monospace')),
|
||||||
Text(r.productCode!,
|
],
|
||||||
style: const TextStyle(
|
)),
|
||||||
fontSize: 11,
|
|
||||||
color: AppTheme.textSecondary,
|
|
||||||
fontFamily: 'monospace')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
DataCell(Text(r.productSpec ?? '-',
|
DataCell(Text(r.productSpec ?? '-',
|
||||||
style: const TextStyle(fontSize: 12))),
|
style: const TextStyle(fontSize: 12))),
|
||||||
DataCell(hasBatch
|
DataCell(batchText != null
|
||||||
? Container(
|
? Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 6, vertical: 2),
|
horizontal: 6, vertical: 2),
|
||||||
@@ -147,18 +148,19 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
|||||||
color: AppTheme.primary.withOpacity(0.08),
|
color: AppTheme.primary.withOpacity(0.08),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
),
|
),
|
||||||
child: Text(r.batchNo!,
|
child: Text(batchText,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppTheme.primary,
|
color: AppTheme.primary,
|
||||||
fontFamily: 'monospace')),
|
fontFamily: 'monospace')),
|
||||||
)
|
)
|
||||||
: const Text('-',
|
: const Text('无批次',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
color: AppTheme.textSecondary))),
|
color: AppTheme.textSecondary))),
|
||||||
DataCell(Text(r.orderNo ?? '-',
|
DataCell(Text(r.orderNo ?? '-',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
color: AppTheme.primary,
|
color: AppTheme.primary,
|
||||||
fontFamily: 'monospace'))),
|
fontFamily: 'monospace'))),
|
||||||
DataCell(Text(r.supplierName ?? '-',
|
DataCell(Text(r.supplierName ?? '-',
|
||||||
@@ -174,8 +176,60 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
|||||||
DataCell(Text(
|
DataCell(Text(
|
||||||
'¥${r.unitPrice.toStringAsFixed(2)}',
|
'¥${r.unitPrice.toStringAsFixed(2)}',
|
||||||
style: const TextStyle(fontSize: 13))),
|
style: const TextStyle(fontSize: 13))),
|
||||||
|
DataCell(_StatusBadge(r.isSoldOut)),
|
||||||
|
DataCell(r.isSoldOut
|
||||||
|
? Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
r.buyerName?.isNotEmpty == true
|
||||||
|
? r.buyerName!
|
||||||
|
: '未知买家',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
if (r.soldAt != null)
|
||||||
|
Text(r.soldAt!.length > 10
|
||||||
|
? r.soldAt!.substring(0, 10)
|
||||||
|
: r.soldAt!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppTheme.textSecondary)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const Text('-',
|
||||||
|
style:
|
||||||
|
TextStyle(color: AppTheme.textSecondary))),
|
||||||
]);
|
]);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _StatusBadge extends StatelessWidget {
|
||||||
|
final bool isSoldOut;
|
||||||
|
const _StatusBadge(this.isSoldOut);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSoldOut
|
||||||
|
? AppTheme.textSecondary.withOpacity(0.12)
|
||||||
|
: AppTheme.success.withOpacity(0.12),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isSoldOut ? '已卖出' : '在售',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isSoldOut ? AppTheme.textSecondary : AppTheme.success,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
_NavItem(icon: Icons.input, label: '入库管理', path: '/stock-in'),
|
_NavItem(icon: Icons.input, label: '入库管理', path: '/stock-in'),
|
||||||
_NavItem(icon: Icons.output, label: '出库管理', path: '/stock-out'),
|
_NavItem(icon: Icons.output, label: '出库管理', path: '/stock-out'),
|
||||||
_NavItem(icon: Icons.inventory_2, label: '库存管理', path: '/inventory'),
|
_NavItem(icon: Icons.inventory_2, label: '库存管理', path: '/inventory'),
|
||||||
_NavItem(icon: Icons.track_changes, label: '批次追踪', path: '/batches'),
|
_NavItem(icon: Icons.track_changes, label: '商品追踪', path: '/batches'),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: Icons.account_balance_wallet,
|
icon: Icons.account_balance_wallet,
|
||||||
label: '财务管理',
|
label: '财务管理',
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Flutter 布局 Bug 报告
|
||||||
|
|
||||||
|
## BUG-001
|
||||||
|
|
||||||
|
**严重程度**:高
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
`DataTableCard` 将 toolbar 包裹在 `SingleChildScrollView(scrollDirection: Axis.horizontal)` 中,
|
||||||
|
而 toolbar 内使用了 `Spacer`(`Flexible` 组件)。`Spacer` 在无界宽度约束下无法布局,导致运行时
|
||||||
|
抛出 Flutter assertion:
|
||||||
|
|
||||||
|
```
|
||||||
|
RenderFlex children have non-zero flex but incoming width constraints are unbounded.
|
||||||
|
```
|
||||||
|
|
||||||
|
受影响页面:
|
||||||
|
- `ProductsScreen`(`products_screen.dart:139`)
|
||||||
|
- `PartnersScreen`(`partners_screen.dart`)
|
||||||
|
- `StockInListScreen`(`stock_in_list_screen.dart`)
|
||||||
|
|
||||||
|
**复现步骤**:
|
||||||
|
1. 打开包含 `DataTableCard` 的任意页面(商品、往来单位、入库单等)
|
||||||
|
2. 由于 `SingleChildScrollView` 提供无界宽度,toolbar 中的 `Spacer` 无法展开
|
||||||
|
3. Flutter 抛出断言错误,页面渲染失败(测试中表现为所有 Widget 测试失败)
|
||||||
|
|
||||||
|
**失败的测试用例**:
|
||||||
|
所有 `products_screen_test.dart`、`partners_screen_test.dart`、`stock_in_screen_test.dart` 中的
|
||||||
|
测试均失败,错误来自 `data_table_card.dart:35` 的布局计算。
|
||||||
|
|
||||||
|
**根因分析**:
|
||||||
|
`client/lib/widgets/data_table_card.dart` 第 33-46 行:
|
||||||
|
```dart
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
minWidth: (MediaQuery.of(context).size.width - 212 - 24).clamp(0.0, double.infinity),
|
||||||
|
),
|
||||||
|
child: toolbar!, // toolbar 中含有 Spacer,而父容器宽度无界
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
`ConstrainedBox` 设置了 `minWidth`,但 `SingleChildScrollView` 为子组件提供的是
|
||||||
|
`BoxConstraints(minWidth, Infinity)` — 最大宽度无界。`Spacer` 尝试填充剩余空间
|
||||||
|
(即无限空间),触发 Flutter 的约束断言。
|
||||||
|
|
||||||
|
**修复建议**:
|
||||||
|
方案 A:将 `ConstrainedBox` 改为固定宽度的 `SizedBox`,让 toolbar 占满 `minWidth`,
|
||||||
|
并把 toolbar 的 `Spacer` 替换为 `MainAxisAlignment.spaceBetween` 或
|
||||||
|
`SizedBox(width: X)` 固定间距:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// data_table_card.dart - 去掉 SingleChildScrollView 横向滚动,
|
||||||
|
// 改用固定宽度容器,或让 toolbar Row 使用 mainAxisSize: MainAxisSize.min
|
||||||
|
SizedBox(
|
||||||
|
width: (MediaQuery.of(context).size.width - 212 - 24).clamp(0.0, double.infinity),
|
||||||
|
child: toolbar!,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
方案 B:在各 Screen 的 toolbar Row 中将 `Spacer()` 替换为 `SizedBox(width: 8)`,
|
||||||
|
并在 `DataTableCard` 中改用非横向滚动的固定宽度容器。
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# 入库/出库单 Bug 报告
|
||||||
|
|
||||||
|
## BUG-001
|
||||||
|
|
||||||
|
**严重程度**:中
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
创建入库单(`POST /api/v1/stock-in/orders`)和出库单(`POST /api/v1/stock-out/orders`)时,缺少对 `warehouse_id` 的 `binding:"required"` 验证。当客户端不传 `warehouse_id` 或传入 `0`,后端会静默接受并创建一条 `warehouse_id=0` 的无效单据,而不是返回 400 Bad Request。
|
||||||
|
|
||||||
|
**复现步骤**:
|
||||||
|
1. 调用 `POST /api/v1/stock-in/orders`,请求体中省略 `warehouse_id` 字段
|
||||||
|
2. 期望返回 400,实际返回 201,并创建了一条 `warehouse_id=0` 的单据
|
||||||
|
|
||||||
|
等效对出库单同样适用。
|
||||||
|
|
||||||
|
**失败的测试用例**:
|
||||||
|
```go
|
||||||
|
func TestStockInHandler_Create_MissingWarehouse(t *testing.T) {
|
||||||
|
// 缺少 warehouse_id,应该返回 400
|
||||||
|
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
|
||||||
|
"order_date": time.Now().Format(time.RFC3339),
|
||||||
|
"items": []map[string]interface{}{},
|
||||||
|
})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code) // 实际返回 201
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**根因分析**:
|
||||||
|
- `backend/internal/model/stock.go` 的 `StockInOrder` 和 `StockOutOrder` 结构体中,`WarehouseID` 字段缺少 `binding:"required"` 标签
|
||||||
|
- 当前定义:`WarehouseID uint64 \`gorm:"not null" json:"warehouse_id"\``
|
||||||
|
- 缺少:`binding:"required"`
|
||||||
|
|
||||||
|
**修复建议**:
|
||||||
|
在 `StockInOrder` 和 `StockOutOrder` 的 `WarehouseID` 字段上添加 `binding:"required"` 标签:
|
||||||
|
```go
|
||||||
|
WarehouseID uint64 `gorm:"not null" json:"warehouse_id" binding:"required"`
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:Go 的 `binding:"required"` 对 `uint64` 类型的判断是零值(即 0)视为未填写,因此可正确拦截缺失或为 0 的情况。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUG-002
|
||||||
|
|
||||||
|
**严重程度**:低
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
`WarehouseHandler.Delete` 在资源不存在时返回 200 而不是 404。当用户删除一个不存在的仓库 ID 时,业务逻辑没有检查 `RowsAffected`,始终返回 200 OK。
|
||||||
|
|
||||||
|
**复现步骤**:
|
||||||
|
1. 调用 `DELETE /api/v1/warehouses/99999`(不存在的 ID)
|
||||||
|
2. 期望返回 404,实际返回 200
|
||||||
|
|
||||||
|
**根因分析**:
|
||||||
|
`backend/internal/handler/warehouse.go` 的 `Delete` 方法未检查 `result.RowsAffected`,与 `ProductHandler.Delete` 的实现不一致。
|
||||||
|
|
||||||
|
**修复建议**:
|
||||||
|
参考 `ProductHandler.Delete` 的实现,添加 `RowsAffected == 0` 检查:
|
||||||
|
```go
|
||||||
|
func (h *WarehouseHandler) Delete(c *gin.Context) {
|
||||||
|
shopID := middleware.GetShopID(c)
|
||||||
|
now := timeNow()
|
||||||
|
result := h.db.Model(&model.Warehouse{}).
|
||||||
|
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", c.Param("id"), shopID).
|
||||||
|
Update("deleted_at", now)
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# 参数校验 Bug 报告
|
||||||
|
|
||||||
|
## BUG-001
|
||||||
|
|
||||||
|
**严重程度**:高
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
`POST /api/v1/partners` 接口缺少对 `name` 字段的必填校验。传入空 `name` 时,handler 成功通过 `ShouldBindJSON` 绑定并返回 201,而不是期望的 400。
|
||||||
|
|
||||||
|
**复现步骤**:
|
||||||
|
1. 调用 `POST /api/v1/partners`,body 为 `{"type":"supplier"}`(不含 name)
|
||||||
|
2. 期望返回 400,实际返回 201
|
||||||
|
|
||||||
|
**失败的测试用例**:
|
||||||
|
```go
|
||||||
|
func TestPartnerHandler_Create_MissingName(t *testing.T) {
|
||||||
|
// 这个测试会失败,揭示了 partner handler 缺少 name 必填校验
|
||||||
|
body := `{"type":"supplier"}`
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/partners", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
// 预期 400,实际得到 201
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**根因分析**:
|
||||||
|
`backend/internal/model/partner.go` 第 6 行:
|
||||||
|
```go
|
||||||
|
Name string `gorm:"size:200;not null" json:"name"`
|
||||||
|
```
|
||||||
|
结构体 `Name` 字段只有 GORM 的 `not null` 约束,没有 `binding:"required"` 标签。
|
||||||
|
`c.ShouldBindJSON` 只校验 `binding` 标签,不校验 GORM 标签,导致空 name 可以通过校验。
|
||||||
|
|
||||||
|
SQLite in-memory 不严格强制 NOT NULL 约束(与 MySQL 行为不同),所以数据库层面也未报错。
|
||||||
|
|
||||||
|
**修复建议**:
|
||||||
|
在 `Partner` model 的 `Name` 字段添加 `binding:"required"` 标签:
|
||||||
|
```go
|
||||||
|
Name string `gorm:"size:200;not null" json:"name" binding:"required"`
|
||||||
|
```
|
||||||
|
或者在 handler 中手动校验:
|
||||||
|
```go
|
||||||
|
if req.Name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUG-002
|
||||||
|
|
||||||
|
**严重程度**:高
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
`POST /api/v1/stock-in/orders` 接口缺少对 `warehouse_id` 字段的必填校验。传入 `warehouse_id: 0`(JSON 中省略该字段时默认为 0)时,handler 返回 201,而不是期望的 400。
|
||||||
|
|
||||||
|
**复现步骤**:
|
||||||
|
1. 调用 `POST /api/v1/stock-in/orders`,body 为 `{"items":[]}`(不含 warehouse_id)
|
||||||
|
2. 期望返回 400,实际返回 201
|
||||||
|
|
||||||
|
**失败的测试用例**:
|
||||||
|
```go
|
||||||
|
func TestStockInHandler_Create_MissingWarehouse(t *testing.T) {
|
||||||
|
body := `{"items":[]}`
|
||||||
|
// 预期 400,实际得到 201
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**根因分析**:
|
||||||
|
`backend/internal/model/stock.go` 第 10 行:
|
||||||
|
```go
|
||||||
|
WarehouseID uint64 `gorm:"not null" json:"warehouse_id"`
|
||||||
|
```
|
||||||
|
同 BUG-001,结构体只有 GORM 约束,缺少 `binding:"required"`。
|
||||||
|
对于 `uint64` 类型,JSON 省略时默认值为 0,`ShouldBindJSON` 不会报错。
|
||||||
|
|
||||||
|
**修复建议**:
|
||||||
|
在 `StockInOrder.WarehouseID` 添加 `binding:"required,min=1"` 标签:
|
||||||
|
```go
|
||||||
|
WarehouseID uint64 `gorm:"not null" json:"warehouse_id" binding:"required,min=1"`
|
||||||
|
```
|
||||||
|
或在 handler `Create` 函数中(`backend/internal/handler/stock_in.go`,约第 82 行)手动校验:
|
||||||
|
```go
|
||||||
|
if req.WarehouseID == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "warehouse_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUG-003
|
||||||
|
|
||||||
|
**严重程度**:高
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
`POST /api/v1/stock-out/orders` 接口缺少对 `warehouse_id` 字段的必填校验,与 BUG-002 问题相同。
|
||||||
|
|
||||||
|
**复现步骤**:
|
||||||
|
1. 调用 `POST /api/v1/stock-out/orders`,body 为 `{"items":[]}`(不含 warehouse_id)
|
||||||
|
2. 期望返回 400,实际返回 201
|
||||||
|
|
||||||
|
**失败的测试用例**:
|
||||||
|
```go
|
||||||
|
func TestStockOutHandler_Create_MissingWarehouse(t *testing.T) {
|
||||||
|
body := `{"items":[]}`
|
||||||
|
// 预期 400,实际得到 201
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**根因分析**:
|
||||||
|
同 BUG-002,`backend/internal/model/stock.go` 的 `StockOutOrder.WarehouseID` 缺少 `binding` 校验标签。
|
||||||
|
|
||||||
|
**修复建议**:
|
||||||
|
在 `StockOutOrder.WarehouseID` 添加 `binding:"required,min=1"` 标签,或在 `backend/internal/handler/stock_out.go` 的 `Create` 函数中手动校验。
|
||||||
@@ -1 +1 @@
|
|||||||
d24579c900c93402148db8060972a2177db2dbb3
|
662d366e0b673b1ee9d89aaf074e0a3975fd5ce4
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
13163
|
21505
|
||||||
|
|||||||
Reference in New Issue
Block a user