ce1cbf404c
后端: - fix(backend): InventoryLog 增加 Product/Warehouse 关联,Logs 接口加 Preload 修复流水记录显示"-" - feat(backend): 新增 /inventory/batches 批次追踪接口 - feat(backend): 新增财务记录、编号规则接口(finance、number_rule handler) - fix(backend): service/stock_test.go 修复 model.Date 类型错误 前端: - feat(client): 入库/出库管理 Tab 调换顺序(审核在前),审核 Tab 加新建按钮,列表 Tab 无按钮 - feat(client): 入库单/出库单增加详情弹窗,显示完整单据信息和商品明细 - feat(client): 完善出库单新建表单(StockOutFormScreen),支持选客户/仓库/商品 - fix(client): StockInItem/StockOutItem.fromJson 修复读取嵌套 product 对象而非平铺字段 - feat(client): 批次追踪页面(BatchTrackingScreen)及侧边栏入口 - feat(client): 财务管理、盘点、编号规则接入真实后端 API - fix(client): 入库/出库审核通过后 invalidate inventoryListProvider 刷新库存 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
586 lines
22 KiB
Dart
586 lines
22 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import '../../core/theme/app_theme.dart';
|
|
import '../../models/inventory.dart';
|
|
import '../../models/warehouse.dart';
|
|
import '../../providers/inventory_provider.dart';
|
|
import '../../providers/warehouse_provider.dart';
|
|
|
|
class InventoryCheckScreen extends ConsumerStatefulWidget {
|
|
const InventoryCheckScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<InventoryCheckScreen> createState() =>
|
|
_InventoryCheckScreenState();
|
|
}
|
|
|
|
class _InventoryCheckScreenState extends ConsumerState<InventoryCheckScreen> {
|
|
Warehouse? _selectedWarehouse;
|
|
String _checkType = '全盘';
|
|
bool _submitting = false;
|
|
bool _loadingItems = false;
|
|
|
|
// Editable check items built from real inventory
|
|
final List<_CheckItem> _checkItems = [];
|
|
|
|
String get _checkNo {
|
|
final now = DateTime.now();
|
|
final date =
|
|
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
|
|
return 'PD$date${_selectedWarehouse?.id.toString().padLeft(3, '0') ?? '001'}';
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final item in _checkItems) {
|
|
item.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadInventory(Warehouse wh) async {
|
|
setState(() {
|
|
_loadingItems = true;
|
|
for (final item in _checkItems) {
|
|
item.dispose();
|
|
}
|
|
_checkItems.clear();
|
|
});
|
|
try {
|
|
final result = await ref
|
|
.read(inventoryRepositoryProvider)
|
|
.listInventory(warehouseId: wh.id, pageSize: 200);
|
|
final items = result.data
|
|
.map((inv) => _CheckItem(inventory: inv))
|
|
.toList();
|
|
if (mounted) {
|
|
setState(() {
|
|
_checkItems.addAll(items);
|
|
_loadingItems = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() => _loadingItems = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('加载库存失败:$e'),
|
|
backgroundColor: AppTheme.danger),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (_selectedWarehouse == null) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(const SnackBar(content: Text('请先选择仓库')));
|
|
return;
|
|
}
|
|
setState(() => _submitting = true);
|
|
try {
|
|
final now = DateTime.now();
|
|
final dateStr =
|
|
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
|
|
|
final items = _checkItems.map((item) {
|
|
final actual =
|
|
double.tryParse(item.actualQtyCtrl.text) ?? item.inventory.quantity;
|
|
return {
|
|
'product_id': item.inventory.productId,
|
|
'actual_qty': actual,
|
|
'remark': item.remarkCtrl.text.trim(),
|
|
};
|
|
}).toList();
|
|
|
|
await ref.read(inventoryRepositoryProvider).createCheck({
|
|
'check_no': _checkNo,
|
|
'warehouse_id': _selectedWarehouse!.id,
|
|
'check_date': dateStr,
|
|
'items': items,
|
|
});
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('盘点单已提交'),
|
|
backgroundColor: AppTheme.success,
|
|
),
|
|
);
|
|
context.go('/inventory');
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('提交失败:$e'),
|
|
backgroundColor: AppTheme.danger),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _submitting = false);
|
|
}
|
|
}
|
|
|
|
int _getDiff(_CheckItem item) {
|
|
final actual =
|
|
double.tryParse(item.actualQtyCtrl.text) ?? item.inventory.quantity;
|
|
return (actual - item.inventory.quantity).round();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final asyncWarehouses = ref.watch(warehouseListProvider);
|
|
|
|
return Scaffold(
|
|
backgroundColor: AppTheme.background,
|
|
body: Column(
|
|
children: [
|
|
// Header
|
|
Container(
|
|
height: 52,
|
|
color: AppTheme.surface,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_back, size: 20),
|
|
onPressed: () => context.go('/inventory'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
const Text('库存盘点',
|
|
style: TextStyle(
|
|
fontSize: 16, fontWeight: FontWeight.w600)),
|
|
const Spacer(),
|
|
OutlinedButton(
|
|
onPressed: () => context.go('/inventory'),
|
|
child: const Text('取消'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ElevatedButton.icon(
|
|
onPressed: (_submitting || _checkItems.isEmpty)
|
|
? null
|
|
: _submit,
|
|
icon: _submitting
|
|
? const SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2, color: Colors.white))
|
|
: const Icon(Icons.check_circle_outline, size: 16),
|
|
label: const Text('提交盘点'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
// Basic info
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('盘点基本信息',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppTheme.primaryDark)),
|
|
const SizedBox(height: 16),
|
|
Wrap(
|
|
spacing: 16,
|
|
runSpacing: 16,
|
|
children: [
|
|
_InfoField(
|
|
label: '盘点单号',
|
|
child: InputDecorator(
|
|
decoration: const InputDecoration(),
|
|
child: Text(
|
|
_checkNo,
|
|
style: const TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 13),
|
|
),
|
|
),
|
|
),
|
|
_InfoField(
|
|
label: '盘点仓库',
|
|
child: asyncWarehouses.when(
|
|
loading: () =>
|
|
const LinearProgressIndicator(),
|
|
error: (e, _) => Text('$e',
|
|
style: const TextStyle(
|
|
color: AppTheme.danger,
|
|
fontSize: 12)),
|
|
data: (warehouses) =>
|
|
DropdownButtonFormField<Warehouse>(
|
|
value: _selectedWarehouse,
|
|
hint: const Text('请选择仓库'),
|
|
items: warehouses
|
|
.map((w) => DropdownMenuItem(
|
|
value: w,
|
|
child: Text(w.name,
|
|
style: const TextStyle(
|
|
fontSize: 13))))
|
|
.toList(),
|
|
onChanged: (w) {
|
|
setState(
|
|
() => _selectedWarehouse = w);
|
|
if (w != null) _loadInventory(w);
|
|
},
|
|
decoration: const InputDecoration(),
|
|
),
|
|
),
|
|
),
|
|
_InfoField(
|
|
label: '盘点类型',
|
|
child: DropdownButtonFormField<String>(
|
|
value: _checkType,
|
|
items: ['全盘', '抽盘', '循环盘点']
|
|
.map((s) => DropdownMenuItem(
|
|
value: s,
|
|
child: Text(s,
|
|
style: const TextStyle(
|
|
fontSize: 13))))
|
|
.toList(),
|
|
onChanged: (v) =>
|
|
setState(() => _checkType = v!),
|
|
decoration: const InputDecoration(),
|
|
),
|
|
),
|
|
_InfoField(
|
|
label: '盘点日期',
|
|
child: InputDecorator(
|
|
decoration: const InputDecoration(),
|
|
child: Builder(builder: (ctx) {
|
|
final now = DateTime.now();
|
|
return Text(
|
|
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}',
|
|
style:
|
|
const TextStyle(fontSize: 13),
|
|
);
|
|
}),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
// Check items table
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Text('盘点明细',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppTheme.primaryDark)),
|
|
const SizedBox(width: 12),
|
|
if (_checkItems.isNotEmpty)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color:
|
|
AppTheme.accent.withOpacity(0.1),
|
|
borderRadius:
|
|
BorderRadius.circular(3),
|
|
),
|
|
child: Text(
|
|
'差异 ${_checkItems.where((i) => _getDiff(i) != 0).length} 项',
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.accent),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (_selectedWarehouse == null)
|
|
const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: Text('请先选择盘点仓库',
|
|
style: TextStyle(
|
|
color: AppTheme.textSecondary)),
|
|
),
|
|
)
|
|
else if (_loadingItems)
|
|
const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
)
|
|
else if (_checkItems.isEmpty)
|
|
const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: Text('该仓库暂无库存记录',
|
|
style: TextStyle(
|
|
color: AppTheme.textSecondary)),
|
|
),
|
|
)
|
|
else
|
|
Table(
|
|
columnWidths: const {
|
|
0: FixedColumnWidth(36),
|
|
1: FixedColumnWidth(80),
|
|
2: FlexColumnWidth(3),
|
|
3: FixedColumnWidth(50),
|
|
4: FixedColumnWidth(80),
|
|
5: FixedColumnWidth(120),
|
|
6: FixedColumnWidth(80),
|
|
7: FlexColumnWidth(2),
|
|
},
|
|
children: [
|
|
TableRow(
|
|
decoration: const BoxDecoration(
|
|
color: Color(0xFFF0F4FF)),
|
|
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)),
|
|
))
|
|
.toList(),
|
|
),
|
|
...List.generate(
|
|
_checkItems.length,
|
|
(i) => _buildCheckRow(i)),
|
|
],
|
|
),
|
|
if (_checkItems.isNotEmpty) ...[
|
|
const Divider(height: 1),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 12),
|
|
child: Row(
|
|
children: [
|
|
_SummaryItem(
|
|
label: '盘点商品',
|
|
value: '${_checkItems.length}种',
|
|
color: AppTheme.primary,
|
|
),
|
|
const SizedBox(width: 24),
|
|
_SummaryItem(
|
|
label: '盘盈',
|
|
value:
|
|
'${_checkItems.where((i) => _getDiff(i) > 0).length}种',
|
|
color: AppTheme.success,
|
|
),
|
|
const SizedBox(width: 24),
|
|
_SummaryItem(
|
|
label: '盘亏',
|
|
value:
|
|
'${_checkItems.where((i) => _getDiff(i) < 0).length}种',
|
|
color: AppTheme.danger,
|
|
),
|
|
const SizedBox(width: 24),
|
|
_SummaryItem(
|
|
label: '相符',
|
|
value:
|
|
'${_checkItems.where((i) => _getDiff(i) == 0).length}种',
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
TableRow _buildCheckRow(int index) {
|
|
final item = _checkItems[index];
|
|
final diff = _getDiff(item);
|
|
final inv = item.inventory;
|
|
|
|
return TableRow(
|
|
decoration: BoxDecoration(
|
|
color: diff != 0
|
|
? (diff > 0
|
|
? AppTheme.success.withOpacity(0.04)
|
|
: AppTheme.danger.withOpacity(0.04))
|
|
: (index.isEven ? Colors.white : const Color(0xFFFAFAFA)),
|
|
),
|
|
children: [
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
|
child: Text('${index + 1}',
|
|
style: const TextStyle(
|
|
fontSize: 13, color: AppTheme.textSecondary)),
|
|
),
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
|
child: Text(inv.productCode ?? '-',
|
|
style: const TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
color: AppTheme.textSecondary)),
|
|
),
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
|
child: Text(inv.productName ?? '-',
|
|
style: const TextStyle(fontSize: 13),
|
|
overflow: TextOverflow.ellipsis),
|
|
),
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
|
child: Text(inv.productUnit ?? '-',
|
|
style: const TextStyle(fontSize: 13)),
|
|
),
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
|
child: Text(inv.quantity.toStringAsFixed(0),
|
|
style: const TextStyle(fontSize: 13)),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(4),
|
|
child: TextFormField(
|
|
controller: item.actualQtyCtrl,
|
|
decoration: const InputDecoration(),
|
|
style: const TextStyle(fontSize: 13),
|
|
keyboardType: TextInputType.number,
|
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
|
onChanged: (_) => setState(() {}),
|
|
),
|
|
),
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
|
child: Text(
|
|
diff == 0 ? '0' : (diff > 0 ? '+$diff' : '$diff'),
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: diff == 0
|
|
? AppTheme.textSecondary
|
|
: (diff > 0 ? AppTheme.success : AppTheme.danger),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(4),
|
|
child: TextFormField(
|
|
controller: item.remarkCtrl,
|
|
decoration: const InputDecoration(hintText: '备注'),
|
|
style: const TextStyle(fontSize: 13),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CheckItem {
|
|
final Inventory inventory;
|
|
final TextEditingController actualQtyCtrl;
|
|
final TextEditingController remarkCtrl;
|
|
|
|
_CheckItem({required this.inventory})
|
|
: actualQtyCtrl = TextEditingController(
|
|
text: inventory.quantity.toStringAsFixed(0)),
|
|
remarkCtrl = TextEditingController();
|
|
|
|
void dispose() {
|
|
actualQtyCtrl.dispose();
|
|
remarkCtrl.dispose();
|
|
}
|
|
}
|
|
|
|
class _InfoField extends StatelessWidget {
|
|
final String label;
|
|
final Widget child;
|
|
|
|
const _InfoField({required this.label, required this.child});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: 220,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label,
|
|
style: const TextStyle(
|
|
fontSize: 13, color: AppTheme.textSecondary)),
|
|
const SizedBox(height: 6),
|
|
child,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SummaryItem extends StatelessWidget {
|
|
final String label;
|
|
final String value;
|
|
final Color color;
|
|
|
|
const _SummaryItem(
|
|
{required this.label, required this.value, required this.color});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: [
|
|
Text(label,
|
|
style: const TextStyle(
|
|
fontSize: 13, color: AppTheme.textSecondary)),
|
|
const SizedBox(width: 4),
|
|
Text(value,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: color)),
|
|
],
|
|
);
|
|
}
|
|
}
|