Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6570dfc240 | |||
| 078fab30f0 | |||
| 08f9e20010 | |||
| 2fac1aff66 |
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.74] - 2026-06-22
|
||||
|
||||
### 改进
|
||||
- 入库单 / 出库单详情页的商品明细表新增「生产日期」「批次号」两列,便于核对货品批次信息
|
||||
|
||||
## [1.0.73] - 2026-06-22
|
||||
|
||||
### 改进
|
||||
- 出库「添加商品」选货弹窗改为分页加载:默认显示前 20 条,底部显示库存总数并支持上一页/下一页翻页;弹窗打开即加载、按编码/名称/系列搜索时即时分页,避免一次拉取过多导致加载缓慢或看不到商品
|
||||
|
||||
## [1.0.72] - 2026-06-22
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.77] - 2026-06-22
|
||||
|
||||
### 新功能
|
||||
- 出库单列表支持按「商品编码」精确反查:可查出包含某个商品编码的所有出库单。同时为出库明细新增 (门店, 商品编码) 复合索引,在数万条明细下仍保持毫秒级查询,不影响写入。
|
||||
|
||||
## [1.0.76] - 2026-06-22
|
||||
|
||||
### 修复
|
||||
- 修复「编辑入库单草稿后合计金额变成 0」的问题:此前每次编辑草稿会把单据合计金额错误清零(明细的单价/金额正常,仅单据头合计未重算),现已按明细正确重算。
|
||||
|
||||
## [1.0.75] - 2026-06-22
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -172,6 +172,7 @@ func (h *StockInHandler) Update(c *gin.Context) {
|
||||
it.ProductID = prod.ID
|
||||
it.ProductCode = prod.Code
|
||||
it.TotalPrice = it.Quantity * it.UnitPrice
|
||||
total += it.TotalPrice
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"warehouse_id": req.WarehouseID,
|
||||
|
||||
@@ -272,6 +272,44 @@ func TestStockInHandler_TotalAmount(t *testing.T) {
|
||||
assert.Equal(t, float64(110), data["total_amount"])
|
||||
}
|
||||
|
||||
// 回归:编辑草稿入库单后 total_amount 必须按新明细重算(此前 Update 漏了累加,编辑后被清成 0)。
|
||||
func TestStockInHandler_UpdateRecomputesTotal(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SI_UPD")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
|
||||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||||
r := setupProtectedRouter(db)
|
||||
|
||||
// 建草稿:1 行 10×5 = 50
|
||||
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
|
||||
"warehouse_id": warehouse.ID,
|
||||
"order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{
|
||||
{"product_name": "茅台", "series": "飞天", "spec": "500ml", "quantity": 10.0, "unit_price": 5.0},
|
||||
},
|
||||
})
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
orderID := extractID(w)
|
||||
|
||||
// 编辑:改成 2 行 12×3100 + 1×4500 = 41700
|
||||
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d", orderID), token, map[string]interface{}{
|
||||
"warehouse_id": warehouse.ID,
|
||||
"order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{
|
||||
{"product_name": "茅台2009", "series": "大件", "spec": "500ml", "quantity": 12.0, "unit_price": 3100.0},
|
||||
{"product_name": "茅台十五年", "series": "大件", "spec": "500ml", "quantity": 1.0, "unit_price": 4500.0},
|
||||
},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// 取详情核对 total_amount 已重算(不是 0)
|
||||
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", orderID), token, nil)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
data := parseResponse(w)["data"].(map[string]interface{})
|
||||
assert.Equal(t, float64(41700), data["total_amount"], "编辑后金额应按新明细重算")
|
||||
}
|
||||
|
||||
func TestStockInHandler_NoAuth(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
r := setupProtectedRouter(db)
|
||||
|
||||
@@ -50,6 +50,13 @@ func (h *StockOutHandler) List(c *gin.Context) {
|
||||
like, shopID, like,
|
||||
)
|
||||
}
|
||||
// 按商品编码反查单据:精确匹配,走 idx_so_items_shop_code(shop_id, product_code) 索引
|
||||
if code := strings.TrimSpace(c.Query("product_code")); code != "" {
|
||||
query = query.Where(
|
||||
"id IN (SELECT order_id FROM stock_out_items WHERE shop_id = ? AND product_code = ? AND deleted_at IS NULL)",
|
||||
shopID, code,
|
||||
)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
@@ -133,6 +133,40 @@ func TestStockOutHandler_List(t *testing.T) {
|
||||
assert.Equal(t, float64(2), resp["total"].(float64))
|
||||
}
|
||||
|
||||
// 按商品编码精确反查出库单(明细 product_code = ?)
|
||||
func TestStockOutHandler_List_FilterByProductCode(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SO_CODE")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
|
||||
product := testutil.CreateTestProduct(db, shop.ID, "Beer")
|
||||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||||
r := setupProtectedRouter(db)
|
||||
|
||||
makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
|
||||
"warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{
|
||||
{"product_id": product.ID, "product_code": "ZXZ-FIND", "quantity": 1.0},
|
||||
},
|
||||
})
|
||||
makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
|
||||
"warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{
|
||||
{"product_id": product.ID, "product_code": "OTHER-CODE", "quantity": 1.0},
|
||||
},
|
||||
})
|
||||
|
||||
// 精确命中 1 单
|
||||
w := makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=ZXZ-FIND", token, nil)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
|
||||
|
||||
// 不存在的编码 → 0;前缀不算精确(不命中)
|
||||
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=ZXZ", token, nil)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, float64(0), parseResponse(w)["total"].(float64))
|
||||
}
|
||||
|
||||
func TestStockOutHandler_List_FilterByKeyword(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SO011")
|
||||
|
||||
@@ -60,6 +60,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
|
||||
stockIn.GET("/orders", stockInH.List)
|
||||
stockIn.GET("/orders/:id", stockInH.Get)
|
||||
stockIn.POST("/orders", stockInH.Create)
|
||||
stockIn.PUT("/orders/:id", stockInH.Update)
|
||||
stockIn.PUT("/orders/:id/submit", stockInH.Submit)
|
||||
stockIn.PUT("/orders/:id/approve", stockInH.Approve)
|
||||
stockIn.PUT("/orders/:id/reject", stockInH.Reject)
|
||||
@@ -71,6 +72,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
|
||||
stockOut.GET("/orders", stockOutH.List)
|
||||
stockOut.GET("/orders/:id", stockOutH.Get)
|
||||
stockOut.POST("/orders", stockOutH.Create)
|
||||
stockOut.PUT("/orders/:id", stockOutH.Update)
|
||||
stockOut.PUT("/orders/:id/submit", stockOutH.Submit)
|
||||
stockOut.PUT("/orders/:id/approve", stockOutH.Approve)
|
||||
stockOut.PUT("/orders/:id/reject", stockOutH.Reject)
|
||||
|
||||
@@ -144,5 +144,12 @@ func autoMigrate(db *gorm.DB) {
|
||||
log.Fatalf("create unique index uk_shop_code failed: %v", err)
|
||||
}
|
||||
}
|
||||
// stock_out_items(shop_id, product_code) 复合索引:支撑出库列表「按商品编码反查单据」,
|
||||
// 避免在 2.6 万+ 明细行上全表扫。ShopID 在嵌入字段上无法用 struct tag 表达,故显式幂等建。
|
||||
if !db.Migrator().HasIndex(&model.StockOutItem{}, "idx_so_items_shop_code") {
|
||||
if err := db.Exec("CREATE INDEX idx_so_items_shop_code ON stock_out_items (shop_id, product_code)").Error; err != nil {
|
||||
log.Fatalf("create index idx_so_items_shop_code failed: %v", err)
|
||||
}
|
||||
}
|
||||
log.Println("AutoMigrate completed")
|
||||
}
|
||||
|
||||
@@ -369,7 +369,8 @@ CREATE TABLE IF NOT EXISTS `stock_out_items` (
|
||||
`remark` VARCHAR(255) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_order_id` (`order_id`),
|
||||
KEY `idx_shop_product` (`shop_id`, `product_id`)
|
||||
KEY `idx_shop_product` (`shop_id`, `product_id`),
|
||||
KEY `idx_so_items_shop_code` (`shop_id`, `product_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库单明细';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
@@ -12,6 +12,8 @@ class StockOutItem {
|
||||
final String? productSeries;
|
||||
final String? productSpec;
|
||||
final String? productUnit;
|
||||
final String? batchNo;
|
||||
final String? productionDate;
|
||||
|
||||
bool get isReturned => returnedQuantity >= quantity && quantity > 0;
|
||||
|
||||
@@ -29,6 +31,8 @@ class StockOutItem {
|
||||
this.productSeries,
|
||||
this.productSpec,
|
||||
this.productUnit,
|
||||
this.batchNo,
|
||||
this.productionDate,
|
||||
});
|
||||
|
||||
factory StockOutItem.fromJson(Map<String, dynamic> json) {
|
||||
@@ -57,6 +61,8 @@ class StockOutItem {
|
||||
productSeries: lineOrProduct('series', 'series'),
|
||||
productSpec: lineOrProduct('spec', 'spec'),
|
||||
productUnit: (json['product'] as Map<String, dynamic>?)?['unit'] as String?,
|
||||
batchNo: json['batch_no'] as String?,
|
||||
productionDate: json['production_date'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,16 +1064,18 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FlexColumnWidth(1.2),
|
||||
2: FlexColumnWidth(2.2),
|
||||
3: FlexColumnWidth(1.5),
|
||||
4: FlexColumnWidth(1.5),
|
||||
5: FlexColumnWidth(1.2),
|
||||
6: FlexColumnWidth(1.2),
|
||||
7: FlexColumnWidth(1.2),
|
||||
3: FlexColumnWidth(1.3),
|
||||
4: FlexColumnWidth(1.3),
|
||||
5: FlexColumnWidth(1.3),
|
||||
6: FlexColumnWidth(1.3),
|
||||
7: FlexColumnWidth(1.0),
|
||||
8: FlexColumnWidth(1.1),
|
||||
9: FlexColumnWidth(1.1),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
||||
children: ['序号', '商品编码', '名称', '系列', '规格', '数量', '单价', '金额']
|
||||
children: ['序号', '商品编码', '名称', '系列', '规格', '生产日期', '批次号', '数量', '单价', '金额']
|
||||
.map((h) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 10),
|
||||
@@ -1122,6 +1124,12 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
|
||||
),
|
||||
_TableCell(item.productSeries ?? '-'),
|
||||
_TableCell(item.productSpec ?? '-'),
|
||||
_TableCell((item.productionDate != null &&
|
||||
item.productionDate!.length >= 10)
|
||||
? item.productionDate!.substring(0, 10)
|
||||
: (item.productionDate ?? '-')),
|
||||
_TableCell(
|
||||
(item.batchNo?.isNotEmpty ?? false) ? item.batchNo! : '-'),
|
||||
_TableCell(item.quantity.toStringAsFixed(3)),
|
||||
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
|
||||
_TableCell('¥${item.totalPrice.toStringAsFixed(2)}'),
|
||||
|
||||
@@ -961,6 +961,27 @@ class _InventoryPickerDialogState
|
||||
};
|
||||
bool _loading = false;
|
||||
Timer? _debounce;
|
||||
// 服务端分页:默认第 1 页 20 条;_total 为当前筛选下的库存总数
|
||||
static const int _pageSize = 20;
|
||||
int _page = 1;
|
||||
int _total = 0;
|
||||
int get _totalPages => _total <= 0 ? 1 : ((_total + _pageSize - 1) ~/ _pageSize);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 打开即拉取第 1 页(默认前 20 条),不依赖调用方预加载,避免默认空白。
|
||||
// 用首帧后回调,避开 initState 同步段调用 setState。
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _fetch();
|
||||
});
|
||||
}
|
||||
|
||||
void _goPage(int p) {
|
||||
if (p < 1 || p > _totalPages || _loading) return;
|
||||
setState(() => _page = p);
|
||||
_fetch();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -970,7 +991,11 @@ class _InventoryPickerDialogState
|
||||
}
|
||||
|
||||
void _onSearchChanged(String v) {
|
||||
setState(() => _search = v);
|
||||
// 改搜索词回到第 1 页
|
||||
setState(() {
|
||||
_search = v;
|
||||
_page = 1;
|
||||
});
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 300), _fetch);
|
||||
}
|
||||
@@ -982,13 +1007,19 @@ class _InventoryPickerDialogState
|
||||
final result = await ref.read(inventoryRepositoryProvider).listInventory(
|
||||
warehouseId: widget.warehouseId,
|
||||
keyword: kw.isEmpty ? null : kw,
|
||||
pageSize: AppConstants.stockOutPickerPageSize,
|
||||
page: _page,
|
||||
pageSize: _pageSize,
|
||||
);
|
||||
final items = _aggregatePickerItems(result.data);
|
||||
for (final it in items) {
|
||||
_known[it.productId] = it;
|
||||
}
|
||||
if (mounted) setState(() => _results = items);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_results = items;
|
||||
_total = result.total;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// 忽略:保留上次结果
|
||||
} finally {
|
||||
@@ -1161,6 +1192,39 @@ class _InventoryPickerDialogState
|
||||
}),
|
||||
child: Text(allSelected ? '取消全选' : '全选当前'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 库存总数汇总(无搜索=整仓总数,搜索时=匹配总数)
|
||||
Text(
|
||||
_loading ? '加载中…' : '共 $_total 项库存商品',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 翻页
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left, size: 20),
|
||||
onPressed: (_page > 1 && !_loading)
|
||||
? () => _goPage(_page - 1)
|
||||
: null,
|
||||
tooltip: '上一页',
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
),
|
||||
Text('第 $_page / $_totalPages 页',
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right, size: 20),
|
||||
onPressed: (_page < _totalPages && !_loading)
|
||||
? () => _goPage(_page + 1)
|
||||
: null,
|
||||
tooltip: '下一页',
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
),
|
||||
const Spacer(),
|
||||
OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
||||
@@ -1003,16 +1003,18 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FlexColumnWidth(1.2),
|
||||
2: FlexColumnWidth(2.2),
|
||||
3: FlexColumnWidth(1.5),
|
||||
4: FlexColumnWidth(1.5),
|
||||
5: FlexColumnWidth(1.2),
|
||||
6: FlexColumnWidth(1.2),
|
||||
7: FlexColumnWidth(1.2),
|
||||
3: FlexColumnWidth(1.3),
|
||||
4: FlexColumnWidth(1.3),
|
||||
5: FlexColumnWidth(1.3),
|
||||
6: FlexColumnWidth(1.3),
|
||||
7: FlexColumnWidth(1.0),
|
||||
8: FlexColumnWidth(1.1),
|
||||
9: FlexColumnWidth(1.1),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
||||
children: ['序号', '商品编码', '名称', '系列', '规格', '数量', '单价', '金额']
|
||||
children: ['序号', '商品编码', '名称', '系列', '规格', '生产日期', '批次号', '数量', '单价', '金额']
|
||||
.map((h) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(h,
|
||||
@@ -1035,6 +1037,12 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
|
||||
_TableCell(item.productName ?? '-'),
|
||||
_TableCell(item.productSeries ?? '-'),
|
||||
_TableCell(item.productSpec ?? '-'),
|
||||
_TableCell((item.productionDate != null &&
|
||||
item.productionDate!.length >= 10)
|
||||
? item.productionDate!.substring(0, 10)
|
||||
: (item.productionDate ?? '-')),
|
||||
_TableCell(
|
||||
(item.batchNo?.isNotEmpty ?? false) ? item.batchNo! : '-'),
|
||||
_TableCell(item.quantity.toStringAsFixed(3)),
|
||||
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
|
||||
_TableCell('¥${item.totalPrice.toStringAsFixed(2)}'),
|
||||
|
||||
Reference in New Issue
Block a user