diff --git a/client/lib/core/utils/print_util_stub.dart b/client/lib/core/utils/print_util_stub.dart index 2d8d7c7..301af18 100644 --- a/client/lib/core/utils/print_util_stub.dart +++ b/client/lib/core/utils/print_util_stub.dart @@ -1045,7 +1045,7 @@ Future buildStockInOrderPdfImpl( final rows = >[]; for (final it in order.items) { totalQty += it.quantity; - totalAmt += it.totalPrice; + totalAmt += it.costAmount; rows.add([ it.productCode ?? '', it.productName ?? '', @@ -1054,8 +1054,8 @@ Future buildStockInOrderPdfImpl( it.batchNo ?? '', _d10(it.productionDate), _qtyStr(it.quantity), - it.unitPrice.toStringAsFixed(2), - it.totalPrice.toStringAsFixed(2), + it.costPrice.toStringAsFixed(2), + it.costAmount.toStringAsFixed(2), '', ]); } @@ -1112,9 +1112,9 @@ Future buildStockOutOrderPdfImpl( final rows = >[]; for (final it in order.items) { // 出库单「单价」= 售价:App 单据存 sale_price;历史导入单 sale_price=0、 - // 实际售价存在 unit_price/total_price,故 sale_price 为空时回退到 unit_price。 - final unit = it.salePrice > 0 ? it.salePrice : it.unitPrice; - final amt = it.salePrice > 0 ? it.salePrice * it.quantity : it.totalPrice; + // 实际售价存在 cost_price/cost_amount(源列直存),故售价为空时回退成本列。 + final unit = it.salePrice > 0 ? it.salePrice : it.costPrice; + final amt = it.salePrice > 0 ? it.saleAmount : it.costAmount; totalQty += it.quantity; totalAmt += amt; rows.add([ diff --git a/client/lib/core/utils/print_util_web.dart b/client/lib/core/utils/print_util_web.dart index fa906b9..b7f23c5 100644 --- a/client/lib/core/utils/print_util_web.dart +++ b/client/lib/core/utils/print_util_web.dart @@ -240,7 +240,7 @@ Future printStockInOrderImpl( double totalAmt = 0; for (final item in order.items) { totalQty += item.quantity; - totalAmt += item.totalPrice; + totalAmt += item.costAmount; rows.write(''' ${item.productCode ?? ''} ${item.productName ?? ''} @@ -249,8 +249,8 @@ Future printStockInOrderImpl( ${item.batchNo ?? ''} ${_d10(item.productionDate)} ${item.quantity % 1 == 0 ? item.quantity.toStringAsFixed(0) : item.quantity.toStringAsFixed(3)} - ${item.unitPrice.toStringAsFixed(2)} - ${item.totalPrice.toStringAsFixed(2)} + ${item.costPrice.toStringAsFixed(2)} + ${item.costAmount.toStringAsFixed(2)} '''); } @@ -324,10 +324,9 @@ Future printStockOutOrderImpl( double totalAmt = 0; for (final item in order.items) { // 出库单「单价」= 售价:App 单据存 sale_price;历史导入单 sale_price=0、 - // 实际售价存在 unit_price/total_price,故 sale_price 为空时回退到 unit_price。 - final unit = item.salePrice > 0 ? item.salePrice : item.unitPrice; - final amt = - item.salePrice > 0 ? item.salePrice * item.quantity : item.totalPrice; + // 实际售价存在 cost_price/cost_amount(源列直存),故售价为空时回退成本列。 + final unit = item.salePrice > 0 ? item.salePrice : item.costPrice; + final amt = item.salePrice > 0 ? item.saleAmount : item.costAmount; totalQty += item.quantity; totalAmt += amt; rows.write(''' diff --git a/client/lib/models/stock_in.dart b/client/lib/models/stock_in.dart index 27040a0..b9202a1 100644 --- a/client/lib/models/stock_in.dart +++ b/client/lib/models/stock_in.dart @@ -3,8 +3,8 @@ class StockInItem { final int? orderId; final int productId; final double quantity; - final double unitPrice; - final double totalPrice; + final double costPrice; // 进价(单瓶) + final double costAmount; // 总进价 = quantity × costPrice final double returnedQuantity; // 0=未退;>=quantity 表示整行已退单 final String? batchNo; final String? productionDate; @@ -26,8 +26,8 @@ class StockInItem { this.orderId, required this.productId, required this.quantity, - required this.unitPrice, - required this.totalPrice, + required this.costPrice, + required this.costAmount, this.returnedQuantity = 0, this.batchNo, this.productionDate, @@ -57,8 +57,8 @@ class StockInItem { json['order_id'] != null ? (json['order_id'] as num).toInt() : null, productId: (json['product_id'] as num).toInt(), quantity: (json['quantity'] as num).toDouble(), - unitPrice: (json['unit_price'] as num).toDouble(), - totalPrice: (json['total_price'] as num).toDouble(), + costPrice: (json['cost_price'] as num?)?.toDouble() ?? 0, + costAmount: (json['cost_amount'] as num?)?.toDouble() ?? 0, returnedQuantity: (json['returned_quantity'] as num?)?.toDouble() ?? 0, batchNo: json['batch_no'] as String?, productionDate: json['production_date'] as String?, @@ -81,8 +81,8 @@ class StockInItem { Map toJson() => { 'product_id': productId, 'quantity': quantity, - 'unit_price': unitPrice, - 'total_price': totalPrice, + 'cost_price': costPrice, + 'cost_amount': costAmount, if (batchNo != null) 'batch_no': batchNo, if (productionDate != null) 'production_date': productionDate, }; @@ -104,7 +104,7 @@ class StockInOrder { final String returnState; // none | partial | full(退单状态) final String? orderDate; final String? reviewedAt; - final double? totalAmount; + final double? costTotal; // 应付合计 = Σ 总进价 final String? remark; final List items; @@ -124,7 +124,7 @@ class StockInOrder { this.returnState = 'none', this.orderDate, this.reviewedAt, - this.totalAmount, + this.costTotal, this.remark, this.items = const [], }); @@ -155,8 +155,8 @@ class StockInOrder { returnState: json['return_state'] as String? ?? 'none', orderDate: json['order_date'] as String?, reviewedAt: json['reviewed_at'] as String?, - totalAmount: json['total_amount'] != null - ? (json['total_amount'] as num).toDouble() + costTotal: json['cost_total'] != null + ? (json['cost_total'] as num).toDouble() : null, remark: json['remark'] as String?, items: json['items'] != null diff --git a/client/lib/models/stock_out.dart b/client/lib/models/stock_out.dart index 95b5763..086c013 100644 --- a/client/lib/models/stock_out.dart +++ b/client/lib/models/stock_out.dart @@ -3,9 +3,10 @@ class StockOutItem { final int? orderId; final int productId; final double quantity; - final double unitPrice; - final double salePrice; - final double totalPrice; + final double costPrice; // 成本单价(入库成本快照,仅管理员可见——operator 响应被服务端抹零) + final double salePrice; // 销售单价 + final double costAmount; // 成本小计 = quantity × costPrice + final double saleAmount; // 售价小计 = quantity × salePrice(待定价=0) final double returnedQuantity; final String? productName; final String? productCode; @@ -22,9 +23,10 @@ class StockOutItem { this.orderId, required this.productId, required this.quantity, - required this.unitPrice, + this.costPrice = 0, this.salePrice = 0, - required this.totalPrice, + this.costAmount = 0, + this.saleAmount = 0, this.returnedQuantity = 0, this.productName, this.productCode, @@ -51,9 +53,10 @@ class StockOutItem { json['order_id'] != null ? (json['order_id'] as num).toInt() : null, productId: (json['product_id'] as num).toInt(), quantity: (json['quantity'] as num).toDouble(), - unitPrice: (json['unit_price'] as num).toDouble(), + costPrice: (json['cost_price'] as num?)?.toDouble() ?? 0, salePrice: (json['sale_price'] as num?)?.toDouble() ?? 0, - totalPrice: (json['total_price'] as num).toDouble(), + costAmount: (json['cost_amount'] as num?)?.toDouble() ?? 0, + saleAmount: (json['sale_amount'] as num?)?.toDouble() ?? 0, returnedQuantity: (json['returned_quantity'] as num?)?.toDouble() ?? 0, productName: lineOrProduct('product_name', 'name'), productCode: lineOrProduct('product_code', 'code'), @@ -85,7 +88,8 @@ class StockOutOrder { final String? orderDate; final String? reviewedAt; final String? createdAt; - final double? totalAmount; + final double? saleTotal; // 应收合计 = Σ 售价小计 + final double profitTotal; // 总利润(仅管理员可见,operator 被抹零) final String? remark; final List items; @@ -106,7 +110,8 @@ class StockOutOrder { this.orderDate, this.reviewedAt, this.createdAt, - this.totalAmount, + this.saleTotal, + this.profitTotal = 0, this.remark, this.items = const [], }); @@ -138,9 +143,10 @@ class StockOutOrder { orderDate: json['order_date'] as String?, reviewedAt: json['reviewed_at'] as String?, createdAt: json['created_at'] as String?, - totalAmount: json['total_amount'] != null - ? (json['total_amount'] as num).toDouble() + saleTotal: json['sale_total'] != null + ? (json['sale_total'] as num).toDouble() : null, + profitTotal: (json['profit_total'] as num?)?.toDouble() ?? 0, remark: json['remark'] as String?, items: json['items'] != null ? (json['items'] as List) diff --git a/client/lib/screens/shared/order_form_shell.dart b/client/lib/screens/shared/order_form_shell.dart index 94142ab..2a2c988 100644 --- a/client/lib/screens/shared/order_form_shell.dart +++ b/client/lib/screens/shared/order_form_shell.dart @@ -32,6 +32,7 @@ class OrderFormShell extends StatelessWidget { final Widget detail; final String rowsLabel; // 明细 N 行 final String totalText; // 已格式化 ¥ + final String? profitText; // 合计利润(出库·仅管理员;null 不渲染) final bool loading; const OrderFormShell({ @@ -45,6 +46,7 @@ class OrderFormShell extends StatelessWidget { required this.detail, required this.rowsLabel, required this.totalText, + this.profitText, this.statusBadge, this.notice, this.loading = false, @@ -123,6 +125,20 @@ class OrderFormShell extends StatelessWidget { Text(rowsLabel, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), const Spacer(), + if (profitText != null) ...[ + Text('合计利润 ', + style: + TextStyle(fontSize: AppDims.fsBody, color: t.muted)), + Text(profitText!, + style: TextStyle( + fontSize: AppDims.fsTitle, + color: + profitText!.contains('-') ? t.danger : t.success, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontWeight: FontWeight.w700)), + const SizedBox(width: 18), + ], Text('合计金额 ', style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)), Text(totalText, diff --git a/client/lib/screens/stock_in/stock_in_form_screen.dart b/client/lib/screens/stock_in/stock_in_form_screen.dart index 5d1249c..82789de 100644 --- a/client/lib/screens/stock_in/stock_in_form_screen.dart +++ b/client/lib/screens/stock_in/stock_in_form_screen.dart @@ -153,7 +153,7 @@ class _StockInFormScreenState extends ConsumerState { final row = _ItemRow(); row.productId = item.productId; row.qtyCtrl.text = item.quantity.toStringAsFixed(0); - row.priceCtrl.text = item.unitPrice.toStringAsFixed(2); + row.priceCtrl.text = item.costPrice.toStringAsFixed(2); row.selectedNameId = nameOpts.where((o) => o.name == item.productName).firstOrNull?.id; row.selectedSeriesId = seriesOpts @@ -375,9 +375,8 @@ class _StockInFormScreenState extends ConsumerState { 'series': optName(seriesOpts, it.selectedSeriesId), 'spec': optName(specOpts, it.selectedSpecId), 'quantity': qty, - 'unit_price': price, + 'cost_price': price, if (sale > 0) 'sale_price': sale, - 'total_price': qty * price, if (batchNo.isNotEmpty) 'batch_no': batchNo, if (productionDate.isNotEmpty) 'production_date': productionDate, }; @@ -649,9 +648,9 @@ class _StockInFormScreenState extends ConsumerState { GridCol('prodDate', '生产日期', width: 150, req: true), GridCol('batch', '批次号', width: 116, req: true), GridCol('qty', '数量', width: 82, num: true, req: true), - GridCol('price', '进价', width: 100, num: true), - GridCol('sale', '售价', width: 108, num: true, req: true), - GridCol('amount', '金额', width: 110, num: true), + GridCol('price', '进价(单瓶)', width: 108, num: true), + GridCol('sale', '参考售价', width: 108, num: true, req: true), + GridCol('amount', '总进价', width: 110, num: true), GridCol('act', '', width: 78), ]; @@ -912,14 +911,14 @@ class _StockInFormScreenState extends ConsumerState { num: true, hint: '0', onChanged: (_) => setState(() {}))), - MobileCardField('进价(可留空/0)', null, + MobileCardField('进价·单瓶(可留空/0)', null, valueWidget: GciField( controller: item.priceCtrl, num: true, money: true, hint: '留空=待定价', onChanged: (_) => setState(() {}))), - MobileCardField('售价', null, + MobileCardField('参考售价', null, valueWidget: GciField( controller: item.saleCtrl, num: true, @@ -927,7 +926,7 @@ class _StockInFormScreenState extends ConsumerState { hint: '0.00', onChanged: (_) => setState(() {}))), MobileCardField( - '金额', item.pending ? '待定价' : '¥${item.amount.toStringAsFixed(2)}'), + '总进价', item.pending ? '待定价' : '¥${item.amount.toStringAsFixed(2)}'), MobileCardField('', null, valueWidget: TextButton.icon( onPressed: () => setState(() => item.expanded = !item.expanded), diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index 19980f8..74d2ea2 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -688,7 +688,7 @@ class _StockInListScreenState extends ConsumerState { o.partnerName ?? '', o.status, o.orderDate, - o.totalAmount, + o.costTotal, ]) .toList(), ); @@ -712,8 +712,8 @@ class _StockInListScreenState extends ConsumerState { return Text(o.warehouseName ?? '-'); case 'amount': return Text( - o.totalAmount != null - ? '¥${NumberFormat('#,##0').format(o.totalAmount)}' + o.costTotal != null + ? '¥${NumberFormat('#,##0').format(o.costTotal)}' : '—', style: const TextStyle( fontFamily: AppFonts.mono, @@ -828,11 +828,8 @@ class _StockInListScreenState extends ConsumerState { fields: [ MobileCardField('供应商', o.partnerName ?? '-'), MobileCardField('仓库', o.warehouseName ?? '-'), - MobileCardField( - '合计金额', - o.totalAmount != null - ? '¥${o.totalAmount!.toStringAsFixed(2)}' - : '-'), + MobileCardField('合计金额', + o.costTotal != null ? '¥${o.costTotal!.toStringAsFixed(2)}' : '-'), MobileCardField('入库员', o.operatorName ?? '-'), MobileCardField('审核员', o.reviewerName ?? '-'), ], @@ -899,13 +896,13 @@ class _StockInListScreenState extends ConsumerState { series: it.productSeries ?? '—', spec: it.productSpec ?? '', qty: _fmtQty(it.quantity), - price: it.unitPrice == 0 ? '待定价' : _money.format(it.unitPrice), + price: it.costPrice == 0 ? '待定价' : _money.format(it.costPrice), amount: - it.totalPrice == 0 ? '待定价' : _money.format(it.totalPrice), - pending: it.unitPrice == 0, + it.costAmount == 0 ? '待定价' : _money.format(it.costAmount), + pending: it.costPrice == 0, )) .toList(), - totalText: o.totalAmount != null ? _money.format(o.totalAmount) : '—', + totalText: o.costTotal != null ? _money.format(o.costTotal) : '—', actionGroups: _detailActionGroups(o), ); } @@ -971,7 +968,7 @@ class _StockInListScreenState extends ConsumerState { } if (audit.isNotEmpty) groups.add(DrawerActionGroup('单据审核', audit)); - final pending = o.items.where((it) => it.unitPrice == 0).length; + final pending = o.items.where((it) => it.costPrice == 0).length; if (o.status == 'approved' && isAdmin && pending > 0) { groups.add(DrawerActionGroup('成本 · $pending 项待定价', [ b('确认进价', () { @@ -1025,7 +1022,7 @@ class _StockInListScreenState extends ConsumerState { /// 确认进价:为 0 价(待定)明细补填真实进价,调后端前向补偿后刷新列表。 Future _confirmCost(StockInOrder o) async { final pending = - o.items.where((it) => it.unitPrice == 0 && it.id != null).toList(); + o.items.where((it) => it.costPrice == 0 && it.id != null).toList(); if (pending.isEmpty) return; final controllers = { for (final it in pending) it.id!: TextEditingController() @@ -1303,8 +1300,8 @@ class _StockInListScreenState extends ConsumerState { series: it.productSeries ?? '', spec: it.productSpec ?? '', quantity: it.quantity, - unitPrice: it.unitPrice, - totalPrice: it.totalPrice, + unitPrice: it.costPrice, + totalPrice: it.costAmount, alreadyReturned: it.isReturned, )) .toList(); diff --git a/client/lib/screens/stock_out/stock_out_form_screen.dart b/client/lib/screens/stock_out/stock_out_form_screen.dart index 19ce2bb..7c49018 100644 --- a/client/lib/screens/stock_out/stock_out_form_screen.dart +++ b/client/lib/screens/stock_out/stock_out_form_screen.dart @@ -10,6 +10,7 @@ import '../../core/auth/auth_state.dart'; import '../../core/config/app_constants.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_dims.g.dart'; +import '../../core/theme/app_fonts.dart'; import '../../core/theme/context_tokens.dart'; import '../../core/utils/date_util.dart'; import '../../models/inventory.dart'; @@ -34,7 +35,7 @@ class _PickerItem { final String series; final String spec; final String unit; - final double? unitPrice; + final double? costPrice; // 进价(成本,仅管理员可见列) final double availableQty; const _PickerItem({ required this.productId, @@ -43,7 +44,7 @@ class _PickerItem { required this.series, required this.spec, required this.unit, - this.unitPrice, + this.costPrice, required this.availableQty, }); } @@ -62,7 +63,7 @@ List<_PickerItem> _aggregatePickerItems(List rows) { series: existing.series, spec: existing.spec, unit: existing.unit, - unitPrice: existing.unitPrice ?? inv.unitPrice, + costPrice: existing.costPrice ?? inv.unitPrice, availableQty: existing.availableQty + inv.quantity, ); } else { @@ -73,7 +74,7 @@ List<_PickerItem> _aggregatePickerItems(List rows) { series: inv.series, spec: inv.spec, unit: inv.unit, - unitPrice: inv.unitPrice, + costPrice: inv.unitPrice, availableQty: inv.quantity, ); } @@ -87,7 +88,7 @@ class _ItemRow { final String productName; final String series; final String spec; - final double? unitPrice; + final double? costPrice; // 进价(成本) final double? availableQty; final TextEditingController qtyCtrl; final TextEditingController salePriceCtrl; @@ -100,7 +101,7 @@ class _ItemRow { this.productName = '', this.series = '', this.spec = '', - this.unitPrice, + this.costPrice, this.availableQty, double? salePrice, }) : qtyCtrl = TextEditingController(text: '1'), @@ -108,7 +109,7 @@ class _ItemRow { salePriceCtrl = TextEditingController( text: ((salePrice != null && salePrice > 0) ? salePrice - : (unitPrice ?? 0)) + : (costPrice ?? 0)) .toStringAsFixed(2)); FocusNode focusNode(String field) => field == 'sale' ? saleFocus : qtyFocus; @@ -118,6 +119,13 @@ class _ItemRow { (double.tryParse(qtyCtrl.text) ?? 0) * (double.tryParse(salePriceCtrl.text) ?? 0); + /// 行利润 =(售价 − 进价)× 数量;售价未填(≤0)视为待定不计 + double get profit { + final sale = double.tryParse(salePriceCtrl.text) ?? 0; + if (sale <= 0) return 0; + return (sale - (costPrice ?? 0)) * (double.tryParse(qtyCtrl.text) ?? 0); + } + void dispose() { qtyCtrl.dispose(); salePriceCtrl.dispose(); @@ -183,7 +191,7 @@ class _StockOutFormScreenState extends ConsumerState { productName: item.productName ?? '', series: item.productSeries ?? '', spec: item.productSpec ?? '', - unitPrice: item.unitPrice, + costPrice: item.costPrice, salePrice: item.salePrice, ); row.qtyCtrl.text = item.quantity.toStringAsFixed(0); @@ -217,6 +225,14 @@ class _StockOutFormScreenState extends ConsumerState { return total; } + double get _totalProfit { + double total = 0; + for (final item in _items) { + total += item.profit; + } + return total; + } + Future _loadInventory(int warehouseId) async { try { final result = await ref.read(inventoryRepositoryProvider).listInventory( @@ -254,7 +270,7 @@ class _StockOutFormScreenState extends ConsumerState { productName: item.productName, series: item.series, spec: item.spec, - unitPrice: item.unitPrice, + costPrice: item.costPrice, availableQty: item.availableQty, )); } @@ -276,7 +292,7 @@ class _StockOutFormScreenState extends ConsumerState { productName: s.productName, series: s.series, spec: s.spec, - unitPrice: s.unitPrice, + costPrice: s.costPrice, availableQty: s.availableQty, ); r.salePriceCtrl.text = s.salePriceCtrl.text; @@ -355,14 +371,14 @@ class _StockOutFormScreenState extends ConsumerState { final itemsData = _items.map((item) { final qty = double.tryParse(item.qtyCtrl.text) ?? 0; - final price = item.unitPrice ?? 0; + final price = item.costPrice ?? 0; final sale = double.tryParse(item.salePriceCtrl.text) ?? 0; + // 小计/合计/利润由服务端按同口径重算落库,这里只传两侧单价 return { 'product_id': item.productId ?? 0, 'quantity': qty, - 'unit_price': price, + 'cost_price': price, 'sale_price': sale, - 'total_price': qty * price, }; }).toList(); @@ -516,6 +532,9 @@ class _StockOutFormScreenState extends ConsumerState { mobileActions: mobileActions, rowsLabel: '明细 ${_items.length} 行', totalText: '¥${_totalAmount.toStringAsFixed(2)}', + profitText: ref.watch(isAdminProvider) + ? '¥${_totalProfit.toStringAsFixed(2)}' + : null, docHead: _buildDocHead(currentUser?.realName ?? '-'), detailHead: DetailHead(actions: [ DsButton('从库存选择', @@ -628,18 +647,23 @@ class _StockOutFormScreenState extends ConsumerState { } // ── 桌面:内联网格 ───────────────────────────────────────────────────────── - static const _columns = [ - GridCol('idx', '#', width: 38), - GridCol('name', '商品名称'), - GridCol('series', '系列', width: 116), - GridCol('spec', '规格', width: 128), - GridCol('avail', '可用', width: 92, num: true), - GridCol('qty', '数量', width: 82, num: true, req: true), - GridCol('price', '进价', width: 100, num: true), - GridCol('sale', '售价', width: 108, num: true, req: true), - GridCol('amount', '金额', width: 110, num: true), - GridCol('act', '', width: 62), - ]; + // 进价/利润列仅管理员可见(2026-07 定价重设计;operator 由服务端抹零+前端隐藏双保险); + // 原「金额」列(=售价×数量,qty=1 时与售价重复)改为「利润」实时列。 + List get _columns { + final admin = ref.read(isAdminProvider); + return [ + const GridCol('idx', '#', width: 38), + const GridCol('name', '商品名称'), + const GridCol('series', '系列', width: 116), + const GridCol('spec', '规格', width: 128), + const GridCol('avail', '可用', width: 92, num: true), + const GridCol('qty', '数量', width: 82, num: true, req: true), + if (admin) const GridCol('price', '进价', width: 100, num: true), + const GridCol('sale', '售价', width: 108, num: true, req: true), + if (admin) const GridCol('profit', '利润', width: 110, num: true), + const GridCol('act', '', width: 62), + ]; + } Widget _buildGrid() { if (_items.isEmpty) { @@ -687,7 +711,21 @@ class _StockOutFormScreenState extends ConsumerState { style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ); case 'name': - return RoCell(item.productName, color: t.heading); + // 名称 + 编码两行;编码字体样式与系列列一致(RoCell 默认 muted) + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(item.productName, + style: TextStyle(fontSize: AppDims.fsBody, color: t.heading)), + if (item.productCode.isNotEmpty) + Text(item.productCode, + style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)), + ], + ), + ); case 'series': return RoCell(item.series); case 'spec': @@ -711,8 +749,8 @@ class _StockOutFormScreenState extends ConsumerState { ); case 'price': return RoCell( - (item.unitPrice ?? 0) > 0 - ? '¥${item.unitPrice!.toStringAsFixed(2)}' + (item.costPrice ?? 0) > 0 + ? '¥${item.costPrice!.toStringAsFixed(2)}' : '—', num: true, ); @@ -727,8 +765,10 @@ class _StockOutFormScreenState extends ConsumerState { onEnter: () => _advance(i, 'sale'), onTab: ({required backward}) => _onTab(i, 'sale', backward: backward), ); - case 'amount': - return AmountCell(amount: item.amount); + case 'profit': + return _ProfitCell( + profit: item.profit, + pending: (double.tryParse(item.salePriceCtrl.text) ?? 0) <= 0); case 'act': return RowActions( onCopy: () => _copyRow(i), @@ -772,11 +812,12 @@ class _StockOutFormScreenState extends ConsumerState { if (item.series.isNotEmpty) MobileCardField('系列', item.series), if (item.spec.isNotEmpty) MobileCardField('规格', item.spec), MobileCardField('可用', avail != null ? avail.toStringAsFixed(0) : '—'), - MobileCardField( - '进价', - (item.unitPrice ?? 0) > 0 - ? '¥${item.unitPrice!.toStringAsFixed(2)}' - : '—'), + if (ref.read(isAdminProvider)) + MobileCardField( + '进价', + (item.costPrice ?? 0) > 0 + ? '¥${item.costPrice!.toStringAsFixed(2)}' + : '—'), MobileCardField('数量', null, valueWidget: GciField( controller: item.qtyCtrl, @@ -790,12 +831,36 @@ class _StockOutFormScreenState extends ConsumerState { money: true, hint: '0.00', onChanged: (_) => setState(() {}))), - MobileCardField('金额', '¥${item.amount.toStringAsFixed(2)}'), + if (ref.read(isAdminProvider)) + MobileCardField('利润', '¥${item.profit.toStringAsFixed(2)}'), ], ); } } +/// 利润单元格:正=success 负=danger 等宽粗体;售价未填显示 — +class _ProfitCell extends StatelessWidget { + final double profit; + final bool pending; + const _ProfitCell({required this.profit, required this.pending}); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text(pending ? '—' : '¥${profit.toStringAsFixed(2)}', + textAlign: TextAlign.right, + style: TextStyle( + fontSize: AppDims.fsBody, + color: pending ? t.faint : (profit < 0 ? t.danger : t.success), + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontWeight: FontWeight.w600)), + ); + } +} + // ── 意图(键盘快捷键)──────────────────────────────────────────────────────── class _SubmitIntent extends Intent { const _SubmitIntent(); @@ -1012,8 +1077,8 @@ class _InventoryPickerDialogState _dataCell(item.spec, 130, color: context.tokens.muted), _dataCell( - item.unitPrice != null - ? '¥${item.unitPrice!.toStringAsFixed(2)}' + item.costPrice != null + ? '¥${item.costPrice!.toStringAsFixed(2)}' : '-', 90, ), diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index 217c803..721924b 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -364,8 +364,8 @@ class _StockOutListScreenState extends ConsumerState { Text(o.partnerName ?? '-'), Text(o.warehouseName ?? '-'), Text( - o.totalAmount != null - ? '¥${NumberFormat('#,##0').format(o.totalAmount)}' + o.saleTotal != null + ? '¥${NumberFormat('#,##0').format(o.saleTotal)}' : '—', style: const TextStyle( fontFamily: AppFonts.mono, @@ -725,7 +725,7 @@ class _StockOutListScreenState extends ConsumerState { o.partnerName ?? '', o.status, o.orderDate, - o.totalAmount, + o.saleTotal, ]) .toList(), ); @@ -813,11 +813,8 @@ class _StockOutListScreenState extends ConsumerState { fields: [ MobileCardField('客户', o.partnerName ?? '-'), MobileCardField('仓库', o.warehouseName ?? '-'), - MobileCardField( - '金额', - o.totalAmount != null - ? '¥${o.totalAmount!.toStringAsFixed(2)}' - : '-'), + MobileCardField('金额', + o.saleTotal != null ? '¥${o.saleTotal!.toStringAsFixed(2)}' : '-'), MobileCardField('出库时间', o.orderDate?.substring(0, 10) ?? '-'), ], actions: _orderActions(context, o), @@ -876,20 +873,30 @@ class _StockOutListScreenState extends ConsumerState { ), ], linesLabel: '出库明细', - lines: o.items - .map((it) => OrderLine( - name: it.productName ?? '—', - code: it.productCode ?? '—', - series: it.productSeries ?? '—', - spec: it.productSpec ?? '', - qty: _fmtQty(it.quantity), - price: it.salePrice <= 0 ? '待定价' : _money.format(it.salePrice), - amount: - it.totalPrice == 0 ? '待定价' : _money.format(it.totalPrice), - pending: it.salePrice <= 0, - )) - .toList(), - totalText: o.totalAmount != null ? _money.format(o.totalAmount) : '—', + // 管理员:成本价/售价/利润 三列 + 合计利润;operator:售价/小计 + //(成本与利润由服务端按角色抹零,前端同时不渲染对应列——双保险) + lines: o.items.map((it) { + final pending = it.salePrice <= 0; + final admin = ref.read(isAdminProvider); + final profit = (it.salePrice - it.costPrice) * it.quantity; + return OrderLine( + name: it.productName ?? '—', + code: it.productCode ?? '—', + series: it.productSeries ?? '—', + spec: it.productSpec ?? '', + qty: _fmtQty(it.quantity), + price: pending ? '待定价' : _money.format(it.salePrice), + amount: pending ? '待定价' : _money.format(it.saleAmount), + cost: admin ? _money.format(it.costPrice) : null, + profit: admin ? (pending ? '待定价' : _money.format(profit)) : null, + pending: pending, + ); + }).toList(), + totalText: o.saleTotal != null ? _money.format(o.saleTotal) : '—', + profitText: + ref.read(isAdminProvider) ? _money.format(o.profitTotal) : null, + priceLabel: '售价', + amountLabel: '小计', actionGroups: _detailActionGroups(o), ); } @@ -1298,8 +1305,9 @@ class _StockOutListScreenState extends ConsumerState { series: it.productSeries ?? '', spec: it.productSpec ?? '', quantity: it.quantity, - unitPrice: it.unitPrice, - totalPrice: it.totalPrice, + // 退单冲应收是售价口径:展示与之一致(待定价单回退成本) + unitPrice: it.salePrice > 0 ? it.salePrice : it.costPrice, + totalPrice: it.salePrice > 0 ? it.saleAmount : it.costAmount, alreadyReturned: it.isReturned, )) .toList(); diff --git a/client/lib/widgets/order_detail_drawer.dart b/client/lib/widgets/order_detail_drawer.dart index 27cd6c3..87dc7cf 100644 --- a/client/lib/widgets/order_detail_drawer.dart +++ b/client/lib/widgets/order_detail_drawer.dart @@ -55,6 +55,10 @@ class OrderLine { final String qty; final String price; // 已算好文案(或「待定价」) final String amount; + // 出库管理员 6 列形态(2026-07 定价重设计):成本价 + 利润; + // 两者非空时明细表渲染 数量/成本价/售价(price)/利润 四数值列,amount 不用。 + final String? cost; + final String? profit; final bool pending; // 单价<=0:单价/金额显示待定价样式 const OrderLine({ required this.name, @@ -63,7 +67,9 @@ class OrderLine { required this.spec, required this.qty, required this.price, - required this.amount, + this.amount = '', + this.cost, + this.profit, this.pending = false, }); } @@ -83,6 +89,9 @@ class OrderDetailDrawer extends StatelessWidget { final String linesLabel; // 入库明细 / 出库明细 final List lines; final String totalText; // 合计金额(已格式化) + final String? profitText; // 合计利润(仅出库·管理员传入;null 不渲染) + final String priceLabel; // 单价列头(入库=进价(单瓶)/出库=售价) + final String amountLabel; // 金额列头(入库=总进价/出库 operator=小计) final List actionGroups; const OrderDetailDrawer({ @@ -92,6 +101,9 @@ class OrderDetailDrawer extends StatelessWidget { required this.linesLabel, required this.lines, required this.totalText, + this.profitText, + this.priceLabel = '单价', + this.amountLabel = '金额', required this.actionGroups, }); @@ -138,7 +150,10 @@ class OrderDetailDrawer extends StatelessWidget { child: Text(linesLabel, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ), - _LinesTable(lines: lines), + _LinesTable( + lines: lines, + priceLabel: priceLabel, + amountLabel: amountLabel), // ── 合计(.ltotal)── Padding( padding: const EdgeInsets.fromLTRB(4, 8, 4, 0), @@ -159,6 +174,28 @@ class OrderDetailDrawer extends StatelessWidget { ], ), ), + if (profitText != null) + Padding( + padding: const EdgeInsets.fromLTRB(4, 6, 4, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text('合计利润 ', + style: TextStyle( + fontSize: AppDims.fsBody, color: t.muted)), + const SizedBox(width: 6), + Text(profitText!, + style: TextStyle( + fontSize: AppDims.fsTitle, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontWeight: FontWeight.w700, + color: profitText!.startsWith('-') + ? t.danger + : t.success)), + ], + ), + ), // ── 操作组(.dactions)── if (actionGroups.isNotEmpty) const SizedBox(height: 18), for (var i = 0; i < actionGroups.length; i++) ...[ @@ -207,7 +244,15 @@ class _DrawerRow extends StatelessWidget { class _LinesTable extends StatelessWidget { final List lines; - const _LinesTable({required this.lines}); + final String priceLabel; + final String amountLabel; + const _LinesTable( + {required this.lines, + required this.priceLabel, + required this.amountLabel}); + + // 任一行带成本/利润 → 出库管理员 6 列形态 + bool get _withProfit => lines.any((l) => l.cost != null || l.profit != null); @override Widget build(BuildContext context) { @@ -233,8 +278,17 @@ class _LinesTable extends StatelessWidget { style: _hStyle(t), overflow: TextOverflow.ellipsis), c2: Text('系列 / 规格', style: _hStyle(t)), c3: Text('数量', textAlign: TextAlign.right, style: _hStyle(t)), - c4: Text('单价', textAlign: TextAlign.right, style: _hStyle(t)), - c5: Text('金额', textAlign: TextAlign.right, style: _hStyle(t)), + c4: _withProfit + ? Text('成本价', textAlign: TextAlign.right, style: _hStyle(t)) + : Text(priceLabel, + textAlign: TextAlign.right, style: _hStyle(t)), + c5: _withProfit + ? Text('售价', textAlign: TextAlign.right, style: _hStyle(t)) + : Text(amountLabel, + textAlign: TextAlign.right, style: _hStyle(t)), + c6: _withProfit + ? Text('利润', textAlign: TextAlign.right, style: _hStyle(t)) + : null, ), ), for (var i = 0; i < lines.length; i++) @@ -281,9 +335,18 @@ class _LinesTable extends StatelessWidget { style: const TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), - c4: _priceCell(context, lines[i].price, lines[i].pending), - c5: _priceCell(context, lines[i].amount, lines[i].pending, - accent: true), + c4: _withProfit + ? _priceCell( + context, lines[i].cost ?? '—', lines[i].pending) + : _priceCell(context, lines[i].price, lines[i].pending), + c5: _withProfit + ? _priceCell(context, lines[i].price, lines[i].pending) + : _priceCell(context, lines[i].amount, lines[i].pending, + accent: true), + c6: _withProfit + ? _profitCell( + context, lines[i].profit ?? '—', lines[i].pending) + : null, ), ), ], @@ -314,28 +377,56 @@ class _LinesTable extends StatelessWidget { color: accent ? t.text : t.text)); } - // 5 列布局(对齐原型 grid 1fr 124 40 64 78)。 + // 5 列布局(对齐原型 grid 1fr 124 40 64 78);c6 非空时为出库 6 列形态 + // (数量/成本价/售价/利润,数值列等宽收窄)。 Widget _lineRow(BuildContext context, {required Widget c1, required Widget c2, required Widget c3, required Widget c4, - required Widget c5}) { + required Widget c5, + Widget? c6}) { + final six = c6 != null; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: c1), const SizedBox(width: 8), - SizedBox(width: 96, child: c2), + SizedBox(width: six ? 84 : 96, child: c2), const SizedBox(width: 8), SizedBox(width: 34, child: c3), const SizedBox(width: 8), - SizedBox(width: 60, child: c4), + SizedBox(width: six ? 56 : 60, child: c4), const SizedBox(width: 8), - SizedBox(width: 72, child: c5), + SizedBox(width: six ? 56 : 72, child: c5), + if (six) ...[ + const SizedBox(width: 8), + SizedBox(width: 56, child: c6), + ], ], ); } + + // 利润列:负数染 danger,正常 success;待定价沿 warn 样式 + Widget _profitCell(BuildContext context, String text, bool pending) { + final t = context.tokens; + if (pending) { + return Text(text, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: AppDims.fsXs, + color: t.warn, + fontWeight: FontWeight.w600)); + } + return Text(text, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: AppDims.fsBody, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontWeight: FontWeight.w600, + color: text.startsWith('-') ? t.danger : t.success)); + } } class _ActionGroup extends StatelessWidget { diff --git a/client/test/golden/goldens/stock_in_form_a.png b/client/test/golden/goldens/stock_in_form_a.png index 4f6d4cb..fcf85b7 100644 Binary files a/client/test/golden/goldens/stock_in_form_a.png and b/client/test/golden/goldens/stock_in_form_a.png differ diff --git a/client/test/golden/goldens/stock_in_form_b.png b/client/test/golden/goldens/stock_in_form_b.png index c25ea0f..c1c1b35 100644 Binary files a/client/test/golden/goldens/stock_in_form_b.png and b/client/test/golden/goldens/stock_in_form_b.png differ diff --git a/client/test/golden/goldens/stock_in_form_c.png b/client/test/golden/goldens/stock_in_form_c.png index d9a2ebe..ed54b52 100644 Binary files a/client/test/golden/goldens/stock_in_form_c.png and b/client/test/golden/goldens/stock_in_form_c.png differ diff --git a/client/test/golden/goldens/stock_in_form_mobile_a.png b/client/test/golden/goldens/stock_in_form_mobile_a.png index 86e9db1..d6ee821 100644 Binary files a/client/test/golden/goldens/stock_in_form_mobile_a.png and b/client/test/golden/goldens/stock_in_form_mobile_a.png differ diff --git a/client/test/golden/goldens/stock_in_form_mobile_b.png b/client/test/golden/goldens/stock_in_form_mobile_b.png index 01902f9..bee4192 100644 Binary files a/client/test/golden/goldens/stock_in_form_mobile_b.png and b/client/test/golden/goldens/stock_in_form_mobile_b.png differ diff --git a/client/test/golden/goldens/stock_in_form_mobile_c.png b/client/test/golden/goldens/stock_in_form_mobile_c.png index 0871ed2..f6cc8b5 100644 Binary files a/client/test/golden/goldens/stock_in_form_mobile_c.png and b/client/test/golden/goldens/stock_in_form_mobile_c.png differ diff --git a/client/test/golden/stock_in_list_golden_test.dart b/client/test/golden/stock_in_list_golden_test.dart index 6ffe5bf..1856258 100644 --- a/client/test/golden/stock_in_list_golden_test.dart +++ b/client/test/golden/stock_in_list_golden_test.dart @@ -26,7 +26,7 @@ const _orders = [ reviewerName: '张管理', status: 'approved', orderDate: '2026-06-20', - totalAmount: 34320), + costTotal: 34320), StockInOrder( id: 2, orderNo: 'RK-20260618-009', @@ -36,7 +36,7 @@ const _orders = [ operatorName: '李销售', status: 'pending', orderDate: '2026-06-18', - totalAmount: 6700), + costTotal: 6700), StockInOrder( id: 3, orderNo: 'RK-20260615-003', @@ -46,7 +46,7 @@ const _orders = [ operatorName: '王经理', status: 'draft', orderDate: '2026-06-15', - totalAmount: 5000), + costTotal: 5000), StockInOrder( id: 4, orderNo: 'RK-20260612-001', @@ -57,7 +57,7 @@ const _orders = [ reviewerName: '张管理', status: 'rejected', orderDate: '2026-06-12', - totalAmount: 3500), + costTotal: 3500), ]; class _FakeStockInNotifier extends StockInListNotifier { diff --git a/client/test/golden/stock_out_list_golden_test.dart b/client/test/golden/stock_out_list_golden_test.dart index 25491dd..6721157 100644 --- a/client/test/golden/stock_out_list_golden_test.dart +++ b/client/test/golden/stock_out_list_golden_test.dart @@ -26,7 +26,7 @@ const _orders = [ reviewerName: '张管理', status: 'approved', orderDate: '2026-06-20', - totalAmount: 12800), + saleTotal: 12800), StockOutOrder( id: 2, orderNo: 'CK-20260618-022', @@ -36,7 +36,7 @@ const _orders = [ operatorName: '张前台', status: 'pending', orderDate: '2026-06-18', - totalAmount: 5400), + saleTotal: 5400), StockOutOrder( id: 3, orderNo: 'CK-20260615-017', @@ -46,7 +46,7 @@ const _orders = [ operatorName: '李销售', status: 'draft', orderDate: '2026-06-15', - totalAmount: 3200), + saleTotal: 3200), StockOutOrder( id: 4, orderNo: 'CK-20260612-009', @@ -57,7 +57,7 @@ const _orders = [ reviewerName: '张管理', status: 'rejected', orderDate: '2026-06-12', - totalAmount: 1960), + saleTotal: 1960), ]; class _FakeStockOutNotifier extends StockOutListNotifier { diff --git a/client/test/order_detail_drawer_columns_test.dart b/client/test/order_detail_drawer_columns_test.dart new file mode 100644 index 0000000..3052166 --- /dev/null +++ b/client/test/order_detail_drawer_columns_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiu_client/core/theme/themes.dart'; +import 'package:jiu_client/widgets/order_detail_drawer.dart'; + +/// 2026-07 定价重设计:出库详情按角色两种形态—— +/// 管理员(行带 cost/profit)= 成本价/售价/利润 三数值列 + 合计利润; +/// operator(不带)= 售价/小计两列,无成本无利润。 +void main() { + Widget host(OrderDetailDrawer drawer) => MaterialApp( + theme: buildTheme('a'), + home: Scaffold(body: drawer), + ); + + const line6 = OrderLine( + name: '汾酒青花20年', + code: 'ZXZ027232', + series: '普通/53度', + spec: '500ml*6/件', + qty: '2', + price: '¥400.00', + cost: '¥355.00', + profit: '¥90.00', + ); + + const line4 = OrderLine( + name: '汾酒青花20年', + code: 'ZXZ027232', + series: '普通/53度', + spec: '500ml*6/件', + qty: '2', + price: '¥400.00', + amount: '¥800.00', + ); + + testWidgets('管理员形态:成本价/售价/利润列 + 合计利润', (tester) async { + await tester.pumpWidget(host(OrderDetailDrawer( + title: 'CK001', + infoRows: const [], + linesLabel: '出库明细', + lines: const [line6], + totalText: '¥800.00', + profitText: '¥90.00', + priceLabel: '售价', + amountLabel: '小计', + actionGroups: const [], + ))); + expect(find.text('成本价'), findsOneWidget); + expect(find.text('售价'), findsOneWidget); + expect(find.text('利润'), findsOneWidget); + expect(find.text('¥355.00'), findsOneWidget); + expect(find.text('¥90.00'), findsNWidgets(2)); // 行利润 + 合计利润 + expect(find.text('合计利润 '), findsOneWidget); + }); + + testWidgets('operator 形态:售价/小计,无成本无利润', (tester) async { + await tester.pumpWidget(host(OrderDetailDrawer( + title: 'CK001', + infoRows: const [], + linesLabel: '出库明细', + lines: const [line4], + totalText: '¥800.00', + priceLabel: '售价', + amountLabel: '小计', + actionGroups: const [], + ))); + expect(find.text('售价'), findsOneWidget); + expect(find.text('小计'), findsOneWidget); + expect(find.text('成本价'), findsNothing); + expect(find.text('利润'), findsNothing); + expect(find.text('合计利润 '), findsNothing); + expect(find.text('¥800.00'), findsNWidgets(2)); // 行小计 + 合计金额 + }); +} diff --git a/client/test/stock_in_screen_test.dart b/client/test/stock_in_screen_test.dart index 8bd0664..5a68b31 100644 --- a/client/test/stock_in_screen_test.dart +++ b/client/test/stock_in_screen_test.dart @@ -136,7 +136,7 @@ StockInOrder _makeOrder({ warehouseName: warehouseName, partnerName: partnerName, status: status, - totalAmount: 1000.0, + costTotal: 1000.0, orderDate: '2024-01-10', );