diff --git a/backend/internal/handler/inventory.go b/backend/internal/handler/inventory.go index 589b620..267139c 100644 --- a/backend/internal/handler/inventory.go +++ b/backend/internal/handler/inventory.go @@ -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 diff --git a/backend/internal/handler/stock_out.go b/backend/internal/handler/stock_out.go index 419e562..9d34076 100644 --- a/backend/internal/handler/stock_out.go +++ b/backend/internal/handler/stock_out.go @@ -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 - 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") 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"}) } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 7fb39c6..8581baa 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) } diff --git a/client/lib/models/inventory.dart b/client/lib/models/inventory.dart index 578e8b9..bee39e3 100644 --- a/client/lib/models/inventory.dart +++ b/client/lib/models/inventory.dart @@ -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 json) { + bool get isSoldOut => status == 'sold_out'; + + factory ProductTrackingRecord.fromJson(Map json) { final product = json['product'] as Map?; final order = json['order'] as Map?; final warehouse = order?['warehouse'] as Map?; final partner = order?['partner'] as Map?; - 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?, ); } } diff --git a/client/lib/repositories/inventory_repository.dart b/client/lib/repositories/inventory_repository.dart index 0c6252c..3421c5b 100644 --- a/client/lib/repositories/inventory_repository.dart +++ b/client/lib/repositories/inventory_repository.dart @@ -47,7 +47,7 @@ class InventoryRepository { } } - Future> listBatches({ + Future> 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, - 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, ); } diff --git a/client/lib/screens/inventory/batch_tracking_screen.dart b/client/lib/screens/inventory/batch_tracking_screen.dart index 67bb03b..13ca3b9 100644 --- a/client/lib/screens/inventory/batch_tracking_screen.dart +++ b/client/lib/screens/inventory/batch_tracking_screen.dart @@ -15,7 +15,8 @@ class BatchTrackingScreen extends ConsumerStatefulWidget { class _BatchTrackingScreenState extends ConsumerState { int _page = 1; - late Future> _future; + int _total = 0; + late Future> _future; @override void initState() { @@ -26,17 +27,18 @@ class _BatchTrackingScreenState extends ConsumerState { 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>( + return FutureBuilder>( future: _future, builder: (context, snap) { if (snap.connectionState == ConnectionState.waiting) { @@ -61,30 +63,26 @@ class _BatchTrackingScreenState extends ConsumerState { ); } - Widget _buildTable(List records) { + Widget _buildTable(List records) { return DataTableCard( - totalCount: records.length, + totalCount: _total, page: _page, - onPageChanged: (p) { - setState(() { - _page = p; - _fetch(); - }); - }, + 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(() { - _page = 1; - _fetch(); - }); - }, + onPressed: () => setState(() { + _page = 1; + _fetch(); + }), tooltip: '刷新', ), ], @@ -99,12 +97,14 @@ class _BatchTrackingScreenState extends ConsumerState { 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,33 +113,34 @@ class _BatchTrackingScreenState extends ConsumerState { 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( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(r.productName ?? '-', + DataCell(Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(r.productName ?? '-', + style: const TextStyle( + fontSize: 13, fontWeight: FontWeight.w500)), + if (r.productCode != null) + Text(r.productCode!, style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500)), - if (r.productCode != null) - Text(r.productCode!, - style: const TextStyle( - fontSize: 11, - color: AppTheme.textSecondary, - fontFamily: 'monospace')), - ], - ), - ), + fontSize: 11, + 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 { 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 { 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, + ), + ), + ); + } +} diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index 5e08a47..9e6bd33 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -23,7 +23,7 @@ class _AppShellState extends ConsumerState { _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: '财务管理', diff --git a/docs/review/flutter-layout-bugs.md b/docs/review/flutter-layout-bugs.md new file mode 100644 index 0000000..e4b2b52 --- /dev/null +++ b/docs/review/flutter-layout-bugs.md @@ -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` 中改用非横向滚动的固定宽度容器。 diff --git a/docs/review/stock-order-bugs.md b/docs/review/stock-order-bugs.md new file mode 100644 index 0000000..9e0c5e8 --- /dev/null +++ b/docs/review/stock-order-bugs.md @@ -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"}) +} +``` diff --git a/docs/review/validation-bugs.md b/docs/review/validation-bugs.md new file mode 100644 index 0000000..f2b6e19 --- /dev/null +++ b/docs/review/validation-bugs.md @@ -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` 函数中手动校验。 diff --git a/scripts/.logs/backend.hash b/scripts/.logs/backend.hash index 9bd3ed6..3280c2e 100644 --- a/scripts/.logs/backend.hash +++ b/scripts/.logs/backend.hash @@ -1 +1 @@ -d24579c900c93402148db8060972a2177db2dbb3 +662d366e0b673b1ee9d89aaf074e0a3975fd5ce4 diff --git a/scripts/.logs/backend.pid b/scripts/.logs/backend.pid index 750a0a2..6e12148 100644 --- a/scripts/.logs/backend.pid +++ b/scripts/.logs/backend.pid @@ -1 +1 @@ -13163 +21505