Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08f9e20010 | |||
| 2fac1aff66 | |||
| 6b0b2645ce |
@@ -5,6 +5,21 @@ 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.73] - 2026-06-22
|
||||
|
||||
### 改进
|
||||
- 出库「添加商品」选货弹窗改为分页加载:默认显示前 20 条,底部显示库存总数并支持上一页/下一页翻页;弹窗打开即加载、按编码/名称/系列搜索时即时分页,避免一次拉取过多导致加载缓慢或看不到商品
|
||||
|
||||
## [1.0.72] - 2026-06-22
|
||||
|
||||
### 新功能
|
||||
- 出库单录入新增「售价」可编辑列:可按实际销售价录入,金额与单据应收按「售价 × 数量」实时计算,同时保留成本价一栏便于对比
|
||||
|
||||
### 改进
|
||||
- 「退单」按钮改为红色实心样式,更醒目地突出该危险操作(与原型一致)
|
||||
- 库存列表隐藏「库存量」列:序列号模型下每件商品独立,原数量列易受历史数据误导,故不再展示(缺货/预警逻辑不受影响)
|
||||
- 出库「添加商品」可一次看到整仓商品(需配合后端 v1.0.75)
|
||||
|
||||
## [1.0.71] - 2026-06-21
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
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.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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -20,6 +21,7 @@ class StockOutItem {
|
||||
required this.productId,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
this.salePrice = 0,
|
||||
required this.totalPrice,
|
||||
this.returnedQuantity = 0,
|
||||
this.productName,
|
||||
@@ -47,6 +49,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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user