Compare commits

...

5 Commits

Author SHA1 Message Date
wangjia 6570dfc240 chore: release server-v1.0.77
Deploy Server / release-deploy-server (push) Successful in 1m32s
出库列表按商品编码精确反查 + stock_out_items(shop_id,product_code) 索引

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-22 22:24:43 +08:00
wangjia 078fab30f0 chore: release client-v1.0.74
Deploy Client / build-client-web (push) Successful in 39s
Deploy Client / build-macos (push) Successful in 2m23s
Deploy Client / build-android (push) Successful in 1m37s
Deploy Client / build-ios (push) Successful in 2m51s
Deploy Client / build-windows (push) Has been cancelled
Deploy Client / release-deploy-client (push) Has been cancelled
入库/出库详情页明细新增生产日期、批次号两列

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-22 20:16:19 +08:00
wangjia 08f9e20010 chore: release server-v1.0.76
Deploy Server / release-deploy-server (push) Successful in 1m30s
修复编辑入库单草稿后合计金额被清零(Update 漏累加 total)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-22 20:15:38 +08:00
wangjia 2fac1aff66 chore: release client-v1.0.73
Deploy Client / build-client-web (push) Successful in 40s
Deploy Client / build-windows (push) Successful in 2m7s
Deploy Client / build-macos (push) Successful in 2m11s
Deploy Client / build-android (push) Successful in 1m8s
Deploy Client / build-ios (push) Successful in 2m48s
Deploy Client / release-deploy-client (push) Successful in 1m26s
出库选货弹窗改为分页加载(默认20条+库存总数+翻页)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-22 14:26:23 +08:00
wangjia 6b0b2645ce chore: release client-v1.0.72
Deploy Client / build-client-web (push) Successful in 41s
Deploy Client / build-windows (push) Successful in 2m34s
Deploy Client / build-macos (push) Successful in 2m17s
Deploy Client / build-android (push) Successful in 1m26s
Deploy Client / build-ios (push) Successful in 2m42s
Deploy Client / release-deploy-client (push) Successful in 1m35s
出库售价可编辑列(应收按售价) + 退单按钮红色实心 + 库存列表隐藏数量列

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-22 13:02:29 +08:00
14 changed files with 281 additions and 54 deletions
+20
View File
@@ -5,6 +5,26 @@ 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
### 新功能
- 出库单录入新增「售价」可编辑列:可按实际销售价录入,金额与单据应收按「售价 × 数量」实时计算,同时保留成本价一栏便于对比
### 改进
- 「退单」按钮改为红色实心样式,更醒目地突出该危险操作(与原型一致)
- 库存列表隐藏「库存量」列:序列号模型下每件商品独立,原数量列易受历史数据误导,故不再展示(缺货/预警逻辑不受影响)
- 出库「添加商品」可一次看到整仓商品(需配合后端 v1.0.75)
## [1.0.71] - 2026-06-21
### 新功能
+10
View File
@@ -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
### 新功能
+1
View File
@@ -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,
+38
View File
@@ -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)
+7
View File
@@ -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)
+7
View File
@@ -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")
}
+2 -1
View File
@@ -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='出库单明细';
-- ------------------------------------------------------------
+9
View File
@@ -4,6 +4,7 @@ class StockOutItem {
final int productId;
final double quantity;
final double unitPrice;
final double salePrice;
final double totalPrice;
final double returnedQuantity;
final String? productName;
@@ -11,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;
@@ -20,6 +23,7 @@ class StockOutItem {
required this.productId,
required this.quantity,
required this.unitPrice,
this.salePrice = 0,
required this.totalPrice,
this.returnedQuantity = 0,
this.productName,
@@ -27,6 +31,8 @@ class StockOutItem {
this.productSeries,
this.productSpec,
this.productUnit,
this.batchNo,
this.productionDate,
});
factory StockOutItem.fromJson(Map<String, dynamic> json) {
@@ -47,6 +53,7 @@ class StockOutItem {
productId: (json['product_id'] as num).toInt(),
quantity: (json['quantity'] as num).toDouble(),
unitPrice: (json['unit_price'] as num).toDouble(),
salePrice: (json['sale_price'] as num?)?.toDouble() ?? 0,
totalPrice: (json['total_price'] as num).toDouble(),
returnedQuantity: (json['returned_quantity'] as num?)?.toDouble() ?? 0,
productName: lineOrProduct('product_name', 'name'),
@@ -54,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?,
);
}
}
@@ -51,7 +51,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
ColDef('series', '系列'),
ColDef('batch', '批次号'),
ColDef('warehouse', '仓库'),
ColDef('qty', '库存量'),
// 库存量列已移除:序列号模型下每件独立,原数量列含历史装箱数脏数据易误导
ColDef('price', '单价'),
ColDef('prodDate', '生产日期'),
ColDef('inTime', '入库时间'),
@@ -311,15 +311,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
'batch' => DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
'warehouse' => DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)),
'qty' => DataCell(Text(
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
style: TextStyle(
fontWeight: FontWeight.w600,
color: item.quantity == 0
? AppTheme.danger
: (item.minStock != null && item.quantity < item.minStock!)
? AppTheme.accent
: AppTheme.textPrimary))),
'price' => DataCell(Text(
item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '-')),
'prodDate' => DataCell(Text(item.productionDate ?? '-')),
@@ -367,19 +358,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
if (item.series.isNotEmpty) MobileCardField('系列', item.series),
if (item.batchNo.isNotEmpty) MobileCardField('批次', item.batchNo),
MobileCardField('仓库', item.warehouseName.isEmpty ? '-' : item.warehouseName),
MobileCardField('库存', null,
valueWidget: Text(
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: item.quantity == 0
? AppTheme.danger
: (item.minStock != null && item.quantity < item.minStock!)
? AppTheme.accent
: AppTheme.textPrimary,
),
)),
if (item.unitPrice != null)
MobileCardField('单价', '¥${item.unitPrice!.toStringAsFixed(2)}'),
if (item.supplierName.isNotEmpty)
@@ -763,7 +741,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
};
return DataColumn(
label: label,
numeric: c.key == 'qty' || c.key == 'price',
numeric: c.key == 'price',
);
}).toList(),
rows: items.isEmpty
@@ -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)}'),
@@ -83,6 +83,7 @@ class _ItemRow {
final String spec;
final double? unitPrice;
final TextEditingController qtyCtrl;
final TextEditingController salePriceCtrl;
_ItemRow({
this.productId,
@@ -91,9 +92,19 @@ class _ItemRow {
this.series = '',
this.spec = '',
this.unitPrice,
}) : qtyCtrl = TextEditingController(text: '1');
double? salePrice,
}) : qtyCtrl = TextEditingController(text: '1'),
// 售价默认带出成本单价(无历史售价时),可手工改成实际卖价
salePriceCtrl = TextEditingController(
text: ((salePrice != null && salePrice > 0)
? salePrice
: (unitPrice ?? 0))
.toStringAsFixed(2));
void dispose() => qtyCtrl.dispose();
void dispose() {
qtyCtrl.dispose();
salePriceCtrl.dispose();
}
}
class StockOutFormScreen extends ConsumerStatefulWidget {
@@ -152,6 +163,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
series: item.productSeries ?? '',
spec: item.productSpec ?? '',
unitPrice: item.unitPrice,
salePrice: item.salePrice,
);
row.qtyCtrl.text = item.quantity.toStringAsFixed(0);
_items.add(row);
@@ -178,11 +190,13 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
super.dispose();
}
// 出库单总金额 = 应收 = Σ(售价×数量)
double get _totalAmount {
double total = 0;
for (final item in _items) {
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
total += qty * (item.unitPrice ?? 0);
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
total += qty * sale;
}
return total;
}
@@ -280,10 +294,12 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
final itemsData = _items.map((item) {
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = item.unitPrice ?? 0;
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
return {
'product_id': item.productId ?? 0,
'quantity': qty,
'unit_price': price,
'sale_price': sale,
'total_price': qty * price,
};
}).toList();
@@ -647,10 +663,11 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
2: FlexColumnWidth(2.0), // 商品名称
3: FlexColumnWidth(1.2), // 系列
4: FlexColumnWidth(1.2), // 规格
5: FlexColumnWidth(1.0), //
6: FlexColumnWidth(0.8), // 数量
7: FlexColumnWidth(1.0), // 金额
8: FixedColumnWidth(48), // 操作
5: FlexColumnWidth(1.0), // 成本
6: FlexColumnWidth(1.0), // 售价
7: FlexColumnWidth(0.8), // 数量
8: FlexColumnWidth(1.0), // 金额
9: FixedColumnWidth(48), // 操作
},
children: [
TableRow(
@@ -662,7 +679,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
'商品名称',
'系列',
'规格',
'',
'成本',
'售价',
'数量',
'金额',
'操作',
@@ -734,7 +752,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
final item = _items[index];
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = item.unitPrice ?? 0;
final amount = qty * price;
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
final amount = qty * sale;
return TableRow(
decoration: BoxDecoration(
@@ -761,12 +780,16 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
_cell(Text(item.spec,
style:
const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
//
// 成本
_cell(Text(price > 0 ? '¥${price.toStringAsFixed(2)}' : '-',
style: const TextStyle(fontSize: 13))),
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary))),
// 售价(可编辑)
Padding(
padding: const EdgeInsets.all(4), child: _salePriceField(item)),
// 数量
Padding(padding: const EdgeInsets.all(4), child: _qtyField(item)),
// 金额
// 金额=售价×数量)
_cell(Text('¥${amount.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))),
// 操作
@@ -803,12 +826,27 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
);
}
// 售价输入:可编辑,金额(应收)按 售价×数量 联动
Widget _salePriceField(_ItemRow item) {
return TextFormField(
controller: item.salePriceCtrl,
decoration: const InputDecoration(hintText: '0.00', isDense: true),
style: const TextStyle(fontSize: 13),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
],
onChanged: (_) => setState(() {}),
);
}
/// 窄屏(手机):每个明细行渲染为一张卡片,字段竖排,避免表格横向溢出。
Widget _buildItemCard(int index) {
final item = _items[index];
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = item.unitPrice ?? 0;
final amount = qty * price;
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
final amount = qty * sale;
return MobileListCard(
title: Text(item.productName),
@@ -824,7 +862,9 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
fields: [
if (item.series.isNotEmpty) MobileCardField('系列', item.series),
if (item.spec.isNotEmpty) MobileCardField('规格', item.spec),
MobileCardField('单价', price > 0 ? '¥${price.toStringAsFixed(2)}' : '-'),
MobileCardField(
'成本价', price > 0 ? '¥${price.toStringAsFixed(2)}' : '-'),
MobileCardField('售价', null, valueWidget: _salePriceField(item)),
MobileCardField('数量', null, valueWidget: _qtyField(item)),
MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'),
],
@@ -921,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() {
@@ -930,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);
}
@@ -942,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 {
@@ -1121,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)}'),