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:
@@ -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 ? '打印中...' : '打印选中'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user