feat: 财务结清、酒行信息、库存备注编辑、标签溯源、入库必填校验

后端
- 新增 shop handler:GET/PUT /shop/info(管理员权限)
- 新增 finance CloseByRef:按单据 ref_type+ref_id 结清账款
- 新增 inventory UpdateRemark:PUT /inventory/:id/remark
- 入库/出库审批自动生成财务应付/应收记录(去除金额>0限制)
- 种子数据 S001-S003 补充真实门店信息

前端
- 设置页新增「酒行信息」Tab,管理员可编辑门店名称/地址/电话/负责人
- 入库单列表新增结清按钮(含确认弹窗),出库单同步
- 入库表单:规格、系列、生产日期、供应商、商品名称改为提交必填
- 入库/出库列表新增入库时间、出库时间、创建时间列
- 商品标签标题改为读取 shop 表门店名,扫码文案改为「扫码溯源 · TRACE」
- 标签页脚显示门店地址和电话(从 API 读取,不再依赖编译时 dart-define)
- 库存备注支持点击编辑,超4字截断显示+Hover展示全文
- ApiClient 新增 patch() 方法(已改用 PUT 规避 CORS)

文档
- 新增 docs/user-manual.md 完整用户操作手册(12章)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-05-23 14:05:41 +08:00
parent c77e1c1490
commit c1ed81dfab
60 changed files with 4664 additions and 1572 deletions
@@ -8,11 +8,51 @@ import '../../core/utils/print_util.dart';
import '../../models/stock_out.dart';
import '../../providers/inventory_provider.dart';
import '../../providers/partner_provider.dart';
import '../../providers/product_option_provider.dart';
import '../../providers/product_provider.dart';
import '../../providers/stock_out_provider.dart';
import '../../providers/warehouse_provider.dart';
import '../../widgets/searchable_option_field.dart';
// Aggregated per-product inventory item for the picker dialog
class _PickerItem {
final int productId;
final String productCode;
final String productName;
final String series;
final String spec;
final String unit;
final double? unitPrice;
final double availableQty;
const _PickerItem({
required this.productId,
required this.productCode,
required this.productName,
required this.series,
required this.spec,
required this.unit,
this.unitPrice,
required this.availableQty,
});
}
class _ItemRow {
int? productId;
final String productCode;
final String productName;
final String series;
final String spec;
final double? unitPrice;
final TextEditingController qtyCtrl;
_ItemRow({
this.productId,
this.productCode = '',
this.productName = '',
this.series = '',
this.spec = '',
this.unitPrice,
}) : qtyCtrl = TextEditingController(text: '1');
void dispose() => qtyCtrl.dispose();
}
class StockOutFormScreen extends ConsumerStatefulWidget {
final int? editOrderId;
@@ -31,6 +71,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
bool _submitting = false;
bool _loadingEdit = false;
Map<int, double> _inventoryMap = {};
List<_PickerItem> _inventoryPickerItems = [];
StockOutOrder? _loadedOrder;
final List<_ItemRow> _items = [];
@@ -42,18 +83,14 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
super.initState();
if (_isEdit) {
_loadEditOrder();
} else {
_items.add(_ItemRow());
}
// New orders start with empty list; user adds via dialog
}
Future<void> _loadEditOrder() async {
setState(() => _loadingEdit = true);
try {
final order = await ref.read(stockOutRepositoryProvider).get(widget.editOrderId!);
final nameOpts = await ref.read(productNameListProvider.future);
final seriesOpts = await ref.read(productSeriesListProvider.future);
final specOpts = await ref.read(productSpecListProvider.future);
setState(() {
_loadedOrder = order;
@@ -65,16 +102,17 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
_remarkCtrl.text = order.remark ?? '';
_items.clear();
for (final item in order.items) {
final row = _ItemRow();
row.productId = item.productId;
final row = _ItemRow(
productId: item.productId,
productCode: item.productCode ?? '',
productName: item.productName ?? '',
series: item.productSeries ?? '',
spec: item.productSpec ?? '',
unitPrice: item.unitPrice,
);
row.qtyCtrl.text = item.quantity.toStringAsFixed(0);
row.priceCtrl.text = item.unitPrice.toStringAsFixed(2);
row.selectedNameId = nameOpts.where((o) => o.name == item.productName).firstOrNull?.id;
row.selectedSeriesId = seriesOpts.where((o) => o.name == item.productSeries).firstOrNull?.id;
row.selectedSpecId = specOpts.where((o) => o.name == item.productSpec).firstOrNull?.id;
_items.add(row);
}
if (_items.isEmpty) _items.add(_ItemRow());
});
if (_warehouseId != null) await _loadInventory(_warehouseId!);
} catch (e) {
@@ -101,8 +139,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
double total = 0;
for (final item in _items) {
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = double.tryParse(item.priceCtrl.text) ?? 0;
total += qty * price;
total += qty * (item.unitPrice ?? 0);
}
return total;
}
@@ -111,15 +148,69 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
try {
final result = await ref
.read(inventoryRepositoryProvider)
.listInventory(warehouseId: warehouseId, pageSize: 500);
.listInventory(warehouseId: warehouseId, pageSize: 1000);
final Map<int, _PickerItem> productMap = {};
for (final inv in result.data.where((inv) => inv.productId != null)) {
final pid = inv.productId!;
if (productMap.containsKey(pid)) {
final existing = productMap[pid]!;
productMap[pid] = _PickerItem(
productId: pid,
productCode: existing.productCode,
productName: existing.productName,
series: existing.series,
spec: existing.spec,
unit: existing.unit,
unitPrice: existing.unitPrice ?? inv.unitPrice,
availableQty: existing.availableQty + inv.quantity,
);
} else {
productMap[pid] = _PickerItem(
productId: pid,
productCode: inv.productCode,
productName: inv.productName,
series: inv.series,
spec: inv.spec,
unit: inv.unit,
unitPrice: inv.unitPrice,
availableQty: inv.quantity,
);
}
}
setState(() {
_inventoryMap = {for (final inv in result.data) inv.productId: inv.quantity};
_inventoryPickerItems = productMap.values.toList();
_inventoryMap = {
for (final item in _inventoryPickerItems) item.productId: item.availableQty
};
});
} catch (_) {}
}
void _addItem() {
setState(() => _items.add(_ItemRow()));
Future<void> _addItem() async {
if (_warehouseId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请先选择出库仓库'), backgroundColor: AppTheme.danger),
);
return;
}
final selected = await showDialog<List<_PickerItem>>(
context: context,
builder: (_) => _InventoryPickerDialog(items: _inventoryPickerItems),
);
if (selected == null || selected.isEmpty) return;
setState(() {
for (final item in selected) {
if (_items.any((row) => row.productId == item.productId)) continue;
_items.add(_ItemRow(
productId: item.productId,
productCode: item.productCode,
productName: item.productName,
series: item.series,
spec: item.spec,
unitPrice: item.unitPrice,
));
}
});
}
void _removeItem(int index) {
@@ -148,45 +239,23 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
);
return;
}
final invalidQtyIndex = _items.indexWhere(
(item) => (double.tryParse(item.qtyCtrl.text) ?? 0) <= 0);
if (invalidQtyIndex >= 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${invalidQtyIndex + 1} 行数量必须大于 0'),
backgroundColor: AppTheme.danger,
),
);
return;
}
setState(() => _submitting = true);
final nameOpts = ref.read(productNameListProvider).valueOrNull ?? [];
final seriesOpts = ref.read(productSeriesListProvider).valueOrNull ?? [];
final specOpts = ref.read(productSpecListProvider).valueOrNull ?? [];
for (final item in _items) {
if (item.productId == null) {
final name = nameOpts.where((o) => o.id == item.selectedNameId).firstOrNull?.name ?? '';
final series = seriesOpts.where((o) => o.id == item.selectedSeriesId).firstOrNull?.name ?? '';
final spec = specOpts.where((o) => o.id == item.selectedSpecId).firstOrNull?.name ?? '';
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请选择商品名称'), backgroundColor: AppTheme.danger),
);
setState(() => _submitting = false);
return;
}
try {
final product = await ref
.read(productRepositoryProvider)
.findOrCreate(name: name, series: series, spec: spec);
item.productId = product.id;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('商品查找失败:$e'), backgroundColor: AppTheme.danger),
);
setState(() => _submitting = false);
}
return;
}
}
}
final itemsData = _items.map((item) {
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = double.tryParse(item.priceCtrl.text) ?? 0;
final price = item.unitPrice ?? 0;
return {
'product_id': item.productId ?? 0,
'quantity': qty,
@@ -439,22 +508,21 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
const SizedBox(height: 12),
Table(
columnWidths: const {
0: FixedColumnWidth(36),
1: FlexColumnWidth(2.2),
2: FlexColumnWidth(1.3),
3: FlexColumnWidth(1.3),
4: FlexColumnWidth(0.9),
5: FlexColumnWidth(1.0),
6: FlexColumnWidth(1.0),
7: FlexColumnWidth(1.0),
8: FlexColumnWidth(1.0),
9: FixedColumnWidth(60),
0: FixedColumnWidth(36), // 序号
1: FlexColumnWidth(1.2), // 商品编码
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), // 操作
},
children: [
TableRow(
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
children: [
'序号', '名称', '系列', '规格', '单品数量', '数量', '单价', '金额', '当前库存', '操作',
'序号', '商品编码', '商品名称', '系列', '规格', '单价', '数量', '金额', '操作',
]
.map((h) => Padding(
padding: const EdgeInsets.symmetric(
@@ -470,6 +538,12 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
...List.generate(_items.length, (i) => _buildItemRow(i)),
],
),
if (_items.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: Text('暂无商品,点击"添加商品"从库存中选择',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13))),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.only(top: 12),
@@ -505,16 +579,10 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
TableRow _buildItemRow(int index) {
final item = _items[index];
final asyncNames = ref.watch(productNameListProvider);
final asyncSeries = ref.watch(productSeriesListProvider);
final asyncSpecs = ref.watch(productSpecListProvider);
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = double.tryParse(item.priceCtrl.text) ?? 0;
final price = item.unitPrice ?? 0;
final amount = qty * price;
final specQty = asyncSpecs.valueOrNull
?.where((o) => o.id == item.selectedSpecId)
.firstOrNull
?.quantity ?? 0;
final available = _inventoryMap[item.productId];
return TableRow(
decoration: BoxDecoration(
@@ -522,84 +590,17 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
),
children: [
// 序号
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Text('${index + 1}',
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
),
// 名称
Padding(
padding: const EdgeInsets.all(4),
child: asyncNames.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (names) => SearchableOptionField(
options: names
.map((o) => OptionItem(id: o.id, name: o.name, code: o.code))
.toList(),
selectedId: item.selectedNameId,
hint: '选择名称',
dialogTitle: '选择商品名称',
isRequired: true,
onChanged: (v) => setState(() {
item.selectedNameId = v;
item.productId = null;
}),
),
),
),
_cell(Text('${index + 1}', style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary))),
// 商品编码
_cell(Text(item.productCode, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
// 商品名称
_cell(Text(item.productName, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis)),
// 系列
Padding(
padding: const EdgeInsets.all(4),
child: asyncSeries.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (series) => SearchableOptionField(
options: series
.map((o) => OptionItem(id: o.id, name: o.name, code: o.code))
.toList(),
selectedId: item.selectedSeriesId,
hint: '选择系列',
dialogTitle: '选择系列',
onChanged: (v) => setState(() {
item.selectedSeriesId = v;
item.productId = null;
}),
),
),
),
_cell(Text(item.series, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
// 规格
Padding(
padding: const EdgeInsets.all(4),
child: asyncSpecs.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (specs) => SearchableOptionField(
options: specs
.map((o) => OptionItem(id: o.id, name: o.name, code: o.code))
.toList(),
selectedId: item.selectedSpecId,
hint: '选择规格',
dialogTitle: '选择规格',
isRequired: true,
onChanged: (v) => setState(() {
item.selectedSpecId = v;
item.productId = null;
}),
),
),
),
// 单品数量
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Text(
specQty > 0 ? '$specQty' : '-',
style: TextStyle(
fontSize: 13,
color: specQty > 0 ? Colors.black87 : AppTheme.textSecondary,
),
),
),
_cell(Text(item.spec, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
// 单价
_cell(Text(price > 0 ? '¥${price.toStringAsFixed(2)}' : '-', style: const TextStyle(fontSize: 13))),
// 数量
Padding(
padding: const EdgeInsets.all(4),
@@ -608,29 +609,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
decoration: const InputDecoration(hintText: '0', isDense: true),
style: const TextStyle(fontSize: 13),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
],
onChanged: (_) => setState(() {}),
validator: (v) {
if (v == null || v.isEmpty) return '不能为空';
if ((double.tryParse(v) ?? 0) <= 0) return '>0';
return null;
},
),
),
// 单价
Padding(
padding: const EdgeInsets.all(4),
child: TextFormField(
controller: item.priceCtrl,
decoration: const InputDecoration(
hintText: '0.00', prefixText: '¥', isDense: true),
style: const TextStyle(fontSize: 13),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
],
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
onChanged: (_) => setState(() {}),
validator: (v) {
if (v == null || v.isEmpty) return '不能为空';
@@ -640,21 +619,13 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
),
),
// 金额
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Text(
'¥${amount.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
),
),
// 当前库存
_buildInventoryCell(item.productId),
_cell(Text('¥${amount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))),
// 操作
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: IconButton(
icon: const Icon(Icons.delete_outline, size: 18, color: AppTheme.danger),
onPressed: _items.length > 1 ? () => _removeItem(index) : null,
onPressed: () => _removeItem(index),
tooltip: '删除',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
@@ -664,14 +635,16 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
);
}
Widget _buildInventoryCell(int? productId) {
if (productId == null) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Text('-', style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
Widget _cell(Widget child) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: child,
);
Widget _buildInventoryCell(int? productId, [double? available]) {
if (productId == null) {
return _cell(const Text('-', style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)));
}
final qty = _inventoryMap[productId];
final qty = available ?? _inventoryMap[productId];
final text = qty != null
? qty.toStringAsFixed(0)
: (_warehouseId == null ? '选仓库后显示' : '-');
@@ -680,11 +653,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
: qty <= 0
? AppTheme.danger
: AppTheme.primary;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Text(text,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: color)),
);
return _cell(Text(text, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: color)));
}
Future<void> _pickDate() async {
@@ -693,25 +662,12 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
initialDate: _orderDate,
firstDate: DateTime(2020),
lastDate: DateTime(2030),
locale: const Locale('zh', 'CN'),
);
if (date != null) setState(() => _orderDate = date);
}
}
class _ItemRow {
int? productId;
int? selectedNameId;
int? selectedSeriesId;
int? selectedSpecId;
final TextEditingController qtyCtrl = TextEditingController();
final TextEditingController priceCtrl = TextEditingController();
void dispose() {
qtyCtrl.dispose();
priceCtrl.dispose();
}
}
class _FormField extends StatelessWidget {
final String label;
final Widget child;
@@ -747,3 +703,233 @@ class _FormField extends StatelessWidget {
);
}
}
class _InventoryPickerDialog extends StatefulWidget {
final List<_PickerItem> items;
const _InventoryPickerDialog({required this.items});
@override
State<_InventoryPickerDialog> createState() => _InventoryPickerDialogState();
}
class _InventoryPickerDialogState extends State<_InventoryPickerDialog> {
final _searchCtrl = TextEditingController();
String _search = '';
final Set<int> _selected = {};
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
List<_PickerItem> get _filtered {
if (_search.isEmpty) return widget.items;
final q = _search.toLowerCase();
return widget.items
.where((item) =>
item.productName.toLowerCase().contains(q) ||
item.productCode.toLowerCase().contains(q) ||
item.series.toLowerCase().contains(q))
.toList();
}
@override
Widget build(BuildContext context) {
final filtered = _filtered;
final allSelected = filtered.isNotEmpty && filtered.every((e) => _selected.contains(e.productId));
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: SizedBox(
width: 820,
height: 580,
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
const Text('选择商品',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Text('已选 ${_selected.length}',
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.pop(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
],
),
),
const Divider(height: 1),
// Search bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: TextField(
controller: _searchCtrl,
decoration: const InputDecoration(
hintText: '搜索商品编码、名称或系列',
prefixIcon: Icon(Icons.search, size: 18),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
onChanged: (v) => setState(() => _search = v),
),
),
// Table header
Container(
color: const Color(0xFFF0F4FF),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const SizedBox(width: 44),
_headerCell('商品编码', 110),
_headerCell('商品名称', 200),
_headerCell('系列', 110),
_headerCell('规格', 130),
_headerCell('单价', 90),
_headerCell('库存', 90),
],
),
),
const Divider(height: 1),
// List
Expanded(
child: filtered.isEmpty
? const Center(
child: Text('没有匹配的商品',
style: TextStyle(color: AppTheme.textSecondary)),
)
: ListView.separated(
itemCount: filtered.length,
separatorBuilder: (_, __) =>
const Divider(height: 1, indent: 16, endIndent: 16),
itemBuilder: (context, i) {
final item = filtered[i];
final sel = _selected.contains(item.productId);
return InkWell(
onTap: () => setState(() {
if (sel) {
_selected.remove(item.productId);
} else {
_selected.add(item.productId);
}
}),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
child: Row(
children: [
SizedBox(
width: 44,
child: Checkbox(
value: sel,
onChanged: (v) => setState(() {
if (v == true) {
_selected.add(item.productId);
} else {
_selected.remove(item.productId);
}
}),
),
),
_dataCell(item.productCode, 110,
color: AppTheme.textSecondary),
_dataCell(item.productName, 200, bold: true),
_dataCell(item.series, 110,
color: AppTheme.textSecondary),
_dataCell(item.spec, 130,
color: AppTheme.textSecondary),
_dataCell(
item.unitPrice != null
? '¥${item.unitPrice!.toStringAsFixed(2)}'
: '-',
90,
),
_dataCell(
item.availableQty.toStringAsFixed(0),
90,
color: item.availableQty <= 0
? AppTheme.danger
: AppTheme.primary,
bold: true,
),
],
),
),
);
},
),
),
const Divider(height: 1),
// Footer
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
TextButton(
onPressed: () => setState(() {
if (allSelected) {
for (final e in filtered) {
_selected.remove(e.productId);
}
} else {
_selected.addAll(filtered.map((e) => e.productId));
}
}),
child: Text(allSelected ? '取消全选' : '全选当前'),
),
const Spacer(),
OutlinedButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _selected.isEmpty
? null
: () {
final result = widget.items
.where((item) => _selected.contains(item.productId))
.toList();
Navigator.pop(context, result);
},
child: Text('确定添加(${_selected.length}'),
),
],
),
),
],
),
),
);
}
Widget _headerCell(String text, double width) => SizedBox(
width: width,
child: Text(text,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
);
Widget _dataCell(String text, double width, {Color? color, bool bold = false}) =>
SizedBox(
width: width,
child: Text(
text,
style: TextStyle(
fontSize: 13,
color: color,
fontWeight: bold ? FontWeight.w600 : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
);
}
@@ -1,3 +1,5 @@
import '../../repositories/product_repository.dart';
import '../../core/utils/dialog_util.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -10,7 +12,11 @@ import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButto
import '../../widgets/page_scaffold.dart';
import '../../widgets/status_badge.dart';
import '../../core/utils/export_util.dart';
import '../../core/utils/print_util.dart';
import '../../providers/inventory_provider.dart';
import '../../providers/tab_state_provider.dart';
import '../../providers/product_provider.dart';
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
class StockOutListScreen extends ConsumerStatefulWidget {
const StockOutListScreen({super.key});
@@ -34,6 +40,8 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
ColDef('amount', '金额', minWidth: 800),
ColDef('status', '状态'),
ColDef('date', '日期', minWidth: 900),
ColDef('reviewed_at', '出库时间', minWidth: 900),
ColDef('created_at', '创建时间', minWidth: 900),
ColDef('operator', '出库员', minWidth: 1100),
ColDef('reviewer', '审核员', minWidth: 1100),
ColDef('actions', '操作', required: true),
@@ -64,13 +72,15 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
Widget build(BuildContext context) {
return PageScaffold(
title: '出库管理',
initialTab: ref.read(stockOutTabProvider),
onTabChanged: (i) => ref.read(stockOutTabProvider.notifier).state = i,
tabs: const [
Tab(text: '出库审核'),
Tab(text: '出库单'),
Tab(text: '出库审核'),
],
tabViews: [
_buildListTab(filterStatus: 'pending', showNewButton: true),
_buildListTab(filterStatus: 'exclude_pending', showNewButton: false),
_buildListTab(filterStatus: 'pending', showNewButton: true),
],
);
}
@@ -212,6 +222,18 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
return DataCell(StatusBadge(_apiStatusToEnum(o.status)));
case 'date':
return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-'));
case 'reviewed_at':
return DataCell(Text(o.reviewedAt != null
? o.reviewedAt!.length >= 16
? o.reviewedAt!.substring(0, 16)
: o.reviewedAt!.substring(0, 10)
: '-'));
case 'created_at':
return DataCell(Text(o.createdAt != null
? o.createdAt!.length >= 16
? o.createdAt!.substring(0, 16)
: o.createdAt!.substring(0, 10)
: '-'));
case 'operator':
return DataCell(Text(o.operatorName ?? '-',
style: const TextStyle(fontSize: 13)));
@@ -219,7 +241,9 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
return DataCell(Text(o.reviewerName ?? '-',
style: const TextStyle(fontSize: 13)));
case 'actions':
return DataCell(Row(
return DataCell(SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
@@ -228,6 +252,21 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
style:
TextStyle(fontSize: 12, color: AppTheme.primary)),
),
TextButton(
onPressed: () async {
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
await printStockOutOrder(order);
},
child: const Text('打印',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
),
if (o.status == 'approved') ...[
TextButton(
onPressed: () => _confirmSettle(context, o.id, 'stock_out'),
child: const Text('结清',
style: TextStyle(fontSize: 12, color: AppTheme.accent)),
),
],
if (o.status == 'draft') ...[
TextButton(
onPressed: () => context.go('/stock-out/edit/${o.id}'),
@@ -265,7 +304,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
),
],
],
));
)));
default:
return const DataCell(SizedBox());
}
@@ -363,7 +402,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
}
Future<void> _showDetail(BuildContext context, int orderId) async {
showDialog(
showAppDialog(
context: context,
builder: (ctx) => _StockOutDetailDialog(
orderId: orderId,
@@ -387,6 +426,38 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
}
}
Future<void> _confirmSettle(BuildContext context, int orderId, String refType) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('结清确认'),
content: const Text('确认将该单据的账款标记为已结清?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('确认结清', style: TextStyle(color: AppTheme.accent)),
),
],
),
);
if (confirmed != true || !context.mounted) return;
try {
await ref.read(financeRepositoryProvider).closeByRef(refType, orderId);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已结清'), backgroundColor: AppTheme.success),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString()), backgroundColor: AppTheme.danger),
);
}
}
}
Future<void> _confirmDelete(BuildContext context, StockOutOrder o) async {
final confirmed = await showDialog<bool>(
context: context,
@@ -558,11 +629,13 @@ class _StockOutDetailDialog extends ConsumerStatefulWidget {
class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
late Future<StockOutOrder> _future;
Map<int, double> _inventoryMap = {};
StockOutOrder? _loadedOrder;
@override
void initState() {
super.initState();
_future = widget.repository.get(widget.orderId).then((order) async {
if (mounted) setState(() => _loadedOrder = order);
try {
final result = await ref
.read(inventoryRepositoryProvider)
@@ -570,7 +643,7 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
if (mounted) {
setState(() {
_inventoryMap = {
for (final inv in result.data) inv.productId: inv.quantity
for (final inv in result.data.where((inv) => inv.productId != null)) inv.productId!: inv.quantity
};
});
}
@@ -605,6 +678,12 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
fontWeight: FontWeight.w600,
color: Colors.white)),
const Spacer(),
if (_loadedOrder != null)
IconButton(
icon: const Icon(Icons.print_outlined, color: Colors.white),
tooltip: '打印',
onPressed: () => printStockOutOrder(_loadedOrder!),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
@@ -678,82 +757,43 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
border: TableBorder.all(color: AppTheme.border, width: 0.5),
columnWidths: const {
0: FixedColumnWidth(36),
1: FlexColumnWidth(2.5),
2: FlexColumnWidth(1.5),
1: FlexColumnWidth(1.2),
2: FlexColumnWidth(2.2),
3: FlexColumnWidth(1.5),
4: FlexColumnWidth(1.5),
5: FlexColumnWidth(1.5),
6: FlexColumnWidth(1.5),
5: FlexColumnWidth(1.2),
6: FlexColumnWidth(1.2),
7: FlexColumnWidth(1.2),
},
children: [
TableRow(
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
children: ['序号', '名称', '系列', '规格', '数量', '单价', '金额', '当前库存']
.asMap()
.entries
.map((e) {
final i = e.key;
final h = e.value;
// 第一个格子用 Padding 提供行高参照
if (i == 0) {
return Padding(
children: ['序号', '商品编码', '名称', '系列', '规格', '数量', '单价', '金额']
.map((h) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(h,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
);
}
return TableCell(
verticalAlignment: TableCellVerticalAlignment.fill,
child: Container(
color: h == '当前库存' ? const Color(0xFFCFE2FF) : Colors.transparent,
child: Center(
child: Text(h,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: h == '当前库存' ? AppTheme.primary : AppTheme.primaryDark)),
),
),
);
})
))
.toList(),
),
...o.items.asMap().entries.map((e) {
final i = e.key;
final item = e.value;
final qty = item.productId != null ? _inventoryMap[item.productId] : null;
final invColor = qty == null
? AppTheme.textSecondary
: qty <= 0
? AppTheme.danger
: AppTheme.primary;
return TableRow(
decoration: BoxDecoration(
color: i.isEven ? Colors.white : const Color(0xFFFAFAFA)),
children: [
_TableCell('${i + 1}'),
_TableCell(item.productCode ?? '-'),
_TableCell(item.productName ?? '-'),
_TableCell(item.productSeries ?? '-'),
_TableCell(item.productSpec ?? '-'),
_TableCell(item.quantity.toStringAsFixed(3)),
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
_TableCell('¥${item.totalPrice.toStringAsFixed(2)}'),
TableCell(
verticalAlignment: TableCellVerticalAlignment.fill,
child: Container(
color: const Color(0xFFEBF3FF),
child: Center(
child: Text(
qty != null ? qty.toStringAsFixed(0) : '-',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: invColor),
),
),
),
),
],
);
}),
@@ -852,3 +892,151 @@ class _StatusFilterDropdown extends StatelessWidget {
);
}
}
class _LabelPrintDialog extends StatefulWidget {
final StockOutOrder order;
final ProductRepository productRepo;
final String shopName;
final String shopAddress;
final String shopPhone;
const _LabelPrintDialog({
required this.order,
required this.productRepo,
this.shopName = '',
this.shopAddress = '',
this.shopPhone = '',
});
@override
State<_LabelPrintDialog> createState() => _LabelPrintDialogState();
}
class _LabelPrintDialogState extends State<_LabelPrintDialog> {
late final List<bool> _selected;
bool _printing = false;
String _status = '';
@override
void initState() {
super.initState();
_selected = List.filled(widget.order.items.length, true);
}
Future<void> _print() async {
setState(() { _printing = true; _status = '正在打印...'; });
int done = 0;
for (int i = 0; i < widget.order.items.length; i++) {
if (!_selected[i]) continue;
final item = widget.order.items[i];
try {
final qrBytes = await widget.productRepo.getQRCodeBytes(item.productId);
await printProductLabel(
qrBytes: qrBytes,
name: item.productName ?? '',
code: item.productCode ?? '',
series: item.productSeries,
spec: item.productSpec,
shopName: widget.shopName,
shopAddress: widget.shopAddress,
shopPhone: widget.shopPhone,
);
done++;
if (mounted) setState(() => _status = '已打印 $done 张...');
} catch (e) {
if (mounted) {
setState(() => _status = '${i + 1}行打印失败:$e');
}
}
}
if (mounted) {
setState(() { _printing = false; _status = '完成,共打印 $done'; });
}
}
@override
Widget build(BuildContext context) {
final items = widget.order.items;
return Dialog(
child: Container(
width: 520,
constraints: const BoxConstraints(maxHeight: 520),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: const BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
const Text('打印商品标签',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final item = items[i];
return CheckboxListTile(
value: _selected[i],
onChanged: _printing
? null
: (v) => setState(() => _selected[i] = v ?? false),
title: Text(
'${item.productCode ?? ''} ${item.productName ?? ''}',
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
'${item.productSeries ?? ''} ${item.productSpec ?? ''}',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
);
},
),
),
if (_status.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(_status,
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: (_printing || !_selected.contains(true)) ? null : _print,
icon: const Icon(Icons.print_outlined, size: 16),
label: Text(_printing ? '打印中...' : '打印选中'),
),
],
),
),
],
),
),
);
}
}