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:
wangjia
2026-04-10 22:26:42 +08:00
parent ce1cbf404c
commit 66e54af8c6
12 changed files with 513 additions and 68 deletions
+80 -5
View File
@@ -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
+46 -3
View File
@@ -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"})
}
+1 -1
View File
@@ -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)
}
+22 -5
View File
@@ -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,
),
),
);
}
}
+1 -1
View File
@@ -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: '财务管理',
+68
View File
@@ -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` 中改用非横向滚动的固定宽度容器。
+72
View File
@@ -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"})
}
```
+116
View File
@@ -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
View File
@@ -1 +1 @@
d24579c900c93402148db8060972a2177db2dbb3
662d366e0b673b1ee9d89aaf074e0a3975fd5ce4
+1 -1
View File
@@ -1 +1 @@
13163
21505