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:
@@ -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` 函数中手动校验。
|
||||
Reference in New Issue
Block a user