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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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})
|
||||
}
|
||||
|
||||
// Batches GET /api/v1/inventory/batches — 批次追踪:已审核入库单的明细行
|
||||
func (h *InventoryHandler) Batches(c *gin.Context) {
|
||||
// productTrackingItem is the response shape for GET /api/v1/inventory/products
|
||||
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)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
// 查询已审核的入库单明细,关联商品和仓库信息
|
||||
query := h.db.Model(&model.StockInItem{}).
|
||||
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)
|
||||
@@ -98,10 +107,76 @@ func (h *InventoryHandler) Batches(c *gin.Context) {
|
||||
}).
|
||||
Select("stock_in_items.*").
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -64,6 +65,31 @@ func (h *StockOutHandler) Get(c *gin.Context) {
|
||||
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
|
||||
func (h *StockOutHandler) Create(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
@@ -77,7 +103,16 @@ func (h *StockOutHandler) Create(c *gin.Context) {
|
||||
|
||||
req.ShopID = shopID
|
||||
req.OperatorID = operatorID
|
||||
|
||||
// 状态只允许 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")
|
||||
if err != nil {
|
||||
@@ -104,13 +139,21 @@ func (h *StockOutHandler) Create(c *gin.Context) {
|
||||
// Submit PUT /api/v1/stock-out/orders/:id/submit
|
||||
func (h *StockOutHandler) Submit(c *gin.Context) {
|
||||
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).
|
||||
Update("status", "pending")
|
||||
if result.RowsAffected == 0 {
|
||||
First(&order).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in draft status"})
|
||||
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"})
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
{
|
||||
inventory.GET("", inventoryH.List)
|
||||
inventory.GET("/logs", inventoryH.Logs)
|
||||
inventory.GET("/batches", inventoryH.Batches)
|
||||
inventory.GET("/products", inventoryH.Products)
|
||||
inventory.POST("/checks", inventoryH.CreateCheck)
|
||||
inventory.GET("/checks/:id", inventoryH.GetCheck)
|
||||
}
|
||||
|
||||
@@ -43,8 +43,8 @@ class Inventory {
|
||||
}
|
||||
}
|
||||
|
||||
// 批次追踪:已审核入库单的明细行
|
||||
class BatchRecord {
|
||||
// 商品追踪:已审核入库单的明细行,含库存状态和买家信息
|
||||
class ProductTrackingRecord {
|
||||
final int id;
|
||||
final int productId;
|
||||
final String? productName;
|
||||
@@ -58,8 +58,13 @@ class BatchRecord {
|
||||
final String? orderDate;
|
||||
final String? warehouseName;
|
||||
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.productId,
|
||||
this.productName,
|
||||
@@ -73,14 +78,20 @@ class BatchRecord {
|
||||
this.orderDate,
|
||||
this.warehouseName,
|
||||
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 order = json['order'] as Map<String, dynamic>?;
|
||||
final warehouse = order?['warehouse'] as Map<String, dynamic>?;
|
||||
final partner = order?['partner'] as Map<String, dynamic>?;
|
||||
return BatchRecord(
|
||||
return ProductTrackingRecord(
|
||||
id: (json['id'] as num).toInt(),
|
||||
productId: (json['product_id'] as num).toInt(),
|
||||
productName: product?['name'] as String?,
|
||||
@@ -94,6 +105,12 @@ class BatchRecord {
|
||||
orderDate: order?['order_date'] as String?,
|
||||
warehouseName: warehouse?['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? warehouseId,
|
||||
int page = 1,
|
||||
@@ -60,14 +60,14 @@ class InventoryRepository {
|
||||
if (productId != null) 'product_id': productId,
|
||||
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(
|
||||
resp.data as Map<String, dynamic>,
|
||||
BatchRecord.fromJson,
|
||||
ProductTrackingRecord.fromJson,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '获取批次数据失败',
|
||||
e.response?.data?['error'] as String? ?? '获取商品追踪数据失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class BatchTrackingScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||
int _page = 1;
|
||||
late Future<List<BatchRecord>> _future;
|
||||
int _total = 0;
|
||||
late Future<List<ProductTrackingRecord>> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -26,17 +27,18 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||
void _fetch() {
|
||||
_future = ref
|
||||
.read(inventoryRepositoryProvider)
|
||||
.listBatches(page: _page, pageSize: 20)
|
||||
.then((r) => r.data);
|
||||
.listProducts(page: _page, pageSize: 20)
|
||||
.then((r) {
|
||||
_total = r.total;
|
||||
return r.data;
|
||||
});
|
||||
}
|
||||
|
||||
void _refetch() {
|
||||
setState(() => _fetch());
|
||||
}
|
||||
void _refetch() => setState(() => _fetch());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<BatchRecord>>(
|
||||
return FutureBuilder<List<ProductTrackingRecord>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
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(
|
||||
totalCount: records.length,
|
||||
totalCount: _total,
|
||||
page: _page,
|
||||
onPageChanged: (p) {
|
||||
setState(() {
|
||||
onPageChanged: (p) => setState(() {
|
||||
_page = p;
|
||||
_fetch();
|
||||
});
|
||||
},
|
||||
}),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
const Text('已审核入库批次',
|
||||
const Text('已审核入库商品(含库存与销售状态)',
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
onPressed: () => setState(() {
|
||||
_page = 1;
|
||||
_fetch();
|
||||
});
|
||||
},
|
||||
}),
|
||||
tooltip: '刷新',
|
||||
),
|
||||
],
|
||||
@@ -99,12 +97,14 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||
DataColumn(label: Text('入库日期')),
|
||||
DataColumn(label: Text('数量'), numeric: true),
|
||||
DataColumn(label: Text('单价'), numeric: true),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('买家/时间')),
|
||||
],
|
||||
rows: records.isEmpty
|
||||
? [
|
||||
const DataRow(cells: [
|
||||
DataCell(SizedBox()),
|
||||
DataCell(Text('暂无批次记录',
|
||||
DataCell(Text('暂无记录',
|
||||
style: TextStyle(color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
@@ -113,21 +113,23 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: records.map((r) {
|
||||
final hasBatch =
|
||||
r.batchNo != null && r.batchNo!.isNotEmpty;
|
||||
final batchText =
|
||||
(r.batchNo != null && r.batchNo!.isNotEmpty)
|
||||
? r.batchNo!
|
||||
: null;
|
||||
return DataRow(cells: [
|
||||
DataCell(
|
||||
Column(
|
||||
DataCell(Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(r.productName ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500)),
|
||||
fontSize: 13, fontWeight: FontWeight.w500)),
|
||||
if (r.productCode != null)
|
||||
Text(r.productCode!,
|
||||
style: const TextStyle(
|
||||
@@ -135,11 +137,10 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||
color: AppTheme.textSecondary,
|
||||
fontFamily: 'monospace')),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
DataCell(Text(r.productSpec ?? '-',
|
||||
style: const TextStyle(fontSize: 12))),
|
||||
DataCell(hasBatch
|
||||
DataCell(batchText != null
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
@@ -147,18 +148,19 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||
color: AppTheme.primary.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(r.batchNo!,
|
||||
child: Text(batchText,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace')),
|
||||
)
|
||||
: const Text('-',
|
||||
: const Text('无批次',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(Text(r.orderNo ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace'))),
|
||||
DataCell(Text(r.supplierName ?? '-',
|
||||
@@ -174,8 +176,60 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||
DataCell(Text(
|
||||
'¥${r.unitPrice.toStringAsFixed(2)}',
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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.output, label: '出库管理', path: '/stock-out'),
|
||||
_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(
|
||||
icon: Icons.account_balance_wallet,
|
||||
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