feat(client): 入库确认进价入口与「待定价」标注

入库单详情弹窗为已审核+含 0 价明细+管理员时显示「确认进价」,
逐行补填真实进价调后端;库存列表与入库明细 0 价/NULL 显示
「待定价」而非 ¥0.00。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-23 20:07:50 +08:00
parent 3625240839
commit f395920a8c
3 changed files with 137 additions and 10 deletions
@@ -142,4 +142,17 @@ class StockInRepository {
);
}
}
/// 确认进价:把指定明细的暂估 0 价补成真实进价(前向补偿成本与应付)。
/// items 形如 [{'item_id': 1, 'unit_price': 50.0}]。
Future<void> confirmCost(int id, List<Map<String, dynamic>> items) async {
try {
await _client.post('/stock-in/orders/$id/confirm-cost', data: {'items': items});
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '确认进价失败',
statusCode: e.response?.statusCode,
);
}
}
}
@@ -312,7 +312,10 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
'warehouse' => DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)),
'price' => DataCell(Text(
item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '-')),
item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '待定价',
style: item.unitPrice == null
? const TextStyle(color: AppTheme.warning)
: null)),
'prodDate' => DataCell(Text(item.productionDate ?? '-')),
'inTime' => DataCell(Text(
item.createdAt != null && item.createdAt!.length >= 10
@@ -358,8 +361,8 @@ 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),
if (item.unitPrice != null)
MobileCardField('单价', '¥${item.unitPrice!.toStringAsFixed(2)}'),
MobileCardField('单价',
item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '待定价'),
if (item.supplierName.isNotEmpty)
MobileCardField('供应商', item.supplierName),
],
@@ -1,5 +1,6 @@
import '../../core/utils/dialog_util.dart';
import '../../core/errors/error_reporter.dart';
import '../../core/exceptions.dart';
import '../../core/utils/print_util.dart' show safePrint, printStockInOrder, LabelData;
import '../../widgets/label_preview_dialog.dart';
import 'package:flutter/material.dart';
@@ -897,7 +898,11 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
@override
void initState() {
super.initState();
_future = widget.repository.get(widget.orderId).then((order) async {
_future = _load();
}
Future<StockInOrder> _load() {
return widget.repository.get(widget.orderId).then((order) async {
if (mounted) setState(() => _loadedOrder = order);
try {
final result = await ref
@@ -915,6 +920,94 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
});
}
/// 确认进价:为 0 价(待定)明细补填真实进价,调后端前向补偿后刷新。
Future<void> _confirmCost(StockInOrder o) async {
final pending =
o.items.where((it) => it.unitPrice == 0 && it.id != null).toList();
if (pending.isEmpty) return;
final controllers = {
for (final it in pending) it.id!: TextEditingController()
};
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认进价'),
content: SizedBox(
width: context.dialogWidth(440),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: pending.map((it) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Text(
'${it.productName ?? ''} ${it.productSpec ?? ''} ×${it.quantity.toStringAsFixed(0)}',
style: const TextStyle(fontSize: 13),
),
),
const SizedBox(width: 8),
SizedBox(
width: 120,
child: TextField(
controller: controllers[it.id],
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
decoration: const InputDecoration(
prefixText: '¥',
hintText: '进价',
isDense: true),
),
),
],
),
);
}).toList(),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消')),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('确认')),
],
),
);
final items = <Map<String, dynamic>>[];
if (ok == true) {
for (final it in pending) {
final v = double.tryParse(controllers[it.id]!.text.trim());
if (v != null && v > 0) {
items.add({'item_id': it.id, 'unit_price': v});
}
}
}
for (final c in controllers.values) {
c.dispose();
}
if (ok != true || items.isEmpty) return;
try {
await widget.repository.confirmCost(o.id, items);
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('进价已确认')));
setState(() => _future = _load());
ref.invalidate(stockInListProvider);
} on AppException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('确认失败:${e.message}')));
}
}
@override
Widget build(BuildContext context) {
return Dialog(
@@ -1034,10 +1127,10 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
MobileCardField('系列', item.productSeries!),
MobileCardField('规格', item.productSpec ?? '-'),
MobileCardField('数量', item.quantity.toStringAsFixed(3)),
MobileCardField(
'', '¥${item.unitPrice.toStringAsFixed(2)}'),
MobileCardField(
'金额', '¥${item.totalPrice.toStringAsFixed(2)}'),
MobileCardField('单价',
item.unitPrice == 0 ? '待定' : '¥${item.unitPrice.toStringAsFixed(2)}'),
MobileCardField('金额',
item.totalPrice == 0 ? '待定' : '¥${item.totalPrice.toStringAsFixed(2)}'),
if (item.batchNo?.isNotEmpty ?? false)
MobileCardField('批次号', item.batchNo!),
if (item.productionDate?.isNotEmpty ?? false)
@@ -1131,13 +1224,31 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
_TableCell(
(item.batchNo?.isNotEmpty ?? false) ? item.batchNo! : '-'),
_TableCell(item.quantity.toStringAsFixed(3)),
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
_TableCell('¥${item.totalPrice.toStringAsFixed(2)}'),
_TableCell(item.unitPrice == 0 ? '待定价' : '¥${item.unitPrice.toStringAsFixed(2)}'),
_TableCell(item.totalPrice == 0 ? '待定' : '¥${item.totalPrice.toStringAsFixed(2)}'),
],
);
}),
],
),
if (o.status == 'approved' &&
ref.watch(isAdminProvider) &&
o.items.any((it) => it.unitPrice == 0)) ...[
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: ElevatedButton.icon(
onPressed: () => _confirmCost(o),
icon: const Icon(Icons.price_check, size: 18),
label: const Text('确认进价'),
),
),
const SizedBox(height: 4),
const Text(
'该单含「待定价」明细:价格确定后补填真实进价,将自动回填库存成本、已出库成本快照与应付差额(不影响售价/应收)。',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
],
),
);