feat(client): 财务应收应付汇总增加往来单位搜索(两端,原型同步)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-14 12:02:44 +08:00
parent 6069b390df
commit edb85c88da
14 changed files with 762 additions and 360 deletions
+324 -134
View File
@@ -27,6 +27,7 @@ import '../../widgets/ds/ds_bar_chart.dart';
import '../../widgets/ds/ds_kpi.dart'; import '../../widgets/ds/ds_kpi.dart';
import '../../widgets/ds/ds_table.dart'; import '../../widgets/ds/ds_table.dart';
import '../../widgets/ds/m_kpi_grid.dart'; import '../../widgets/ds/m_kpi_grid.dart';
import '../../widgets/ds/m_search_row.dart';
import '../../widgets/ds/m_sheet.dart'; import '../../widgets/ds/m_sheet.dart';
import '../../widgets/ds/status_icon_map.dart'; import '../../widgets/ds/status_icon_map.dart';
import '../../widgets/finance_entry_dialog.dart'; import '../../widgets/finance_entry_dialog.dart';
@@ -56,6 +57,10 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
DateTimeRange? _customRange; DateTimeRange? _customRange;
// 窄屏三分段(原型 m-finance .seg:应收 / 应付 / 流水) // 窄屏三分段(原型 m-finance .seg:应收 / 应付 / 流水)
int _mSeg = 0; int _mSeg = 0;
// 应收/应付汇总卡「往来单位」搜索(本地即时过滤——financePartnerRowsProvider
// 全量加载非分页,搜索口径=显示口径;桌面 DsSearchBox / 移动 MSearchRow 共用)
final _summaryKwCtrl = TextEditingController();
String _summaryKw = '';
static const _flowChips = ['全部', '应收', '应付', '收款', '付款']; static const _flowChips = ['全部', '应收', '应付', '收款', '付款'];
static const _chipToType = { static const _chipToType = {
@@ -102,7 +107,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
case '自定义': case '自定义':
final picked = await showDateRangeDropdown( final picked = await showDateRangeDropdown(
anchorContext, anchorContext,
initial: _customRange ?? initial:
_customRange ??
DateTimeRange(start: DateTime(now.year, now.month, 1), end: now), DateTimeRange(start: DateTime(now.year, now.month, 1), end: now),
); );
if (picked == null) return; if (picked == null) return;
@@ -121,6 +127,21 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
ref.read(financeListProvider.notifier).setPartner(partnerId); ref.read(financeListProvider.notifier).setPartner(partnerId);
} }
void _setSummaryKw(String kw) => setState(() => _summaryKw = kw);
/// 汇总行本地过滤(往来单位名 contains,大小写不敏感)。
List<PartnerFinanceRow> _filterSummaryRows(List<PartnerFinanceRow> rows) {
final kw = _summaryKw.trim().toLowerCase();
if (kw.isEmpty) return rows;
return rows.where((r) => r.name.toLowerCase().contains(kw)).toList();
}
@override
void dispose() {
_summaryKwCtrl.dispose();
super.dispose();
}
void _reload() { void _reload() {
ref.read(financeListProvider.notifier).reload(); ref.read(financeListProvider.notifier).reload();
ref.invalidate(financePartnerRowsProvider); ref.invalidate(financePartnerRowsProvider);
@@ -130,9 +151,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
} }
// ── 金额格式 ── // ── 金额格式 ──
String _yuan(double v) => '¥${NumberFormat.decimalPattern().format( String _yuan(double v) =>
v == v.roundToDouble() ? v.round() : v, '¥${NumberFormat.decimalPattern().format(v == v.roundToDouble() ? v.round() : v)}';
)}';
/// KPI 大数:¥186万 / ¥48.6万(<1万显示整数元)。 /// KPI 大数:¥186万 / ¥48.6万(<1万显示整数元)。
String _yuanWan(double v) { String _yuanWan(double v) {
@@ -179,19 +199,32 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
child: DsSeg( child: DsSeg(
items: const ['应收', '应付', '流水'], items: const ['应收', '应付', '流水'],
index: _mSeg, index: _mSeg,
onChanged: (i) => setState(() => _mSeg = i)), onChanged: (i) => setState(() => _mSeg = i),
), ),
), ),
),
// 应收/应付分段头部往来单位搜索(同口径本地过滤);「流水」分段
// 沿用上方时间行「往来单位」chip → sheet 单选,不重复放置。
if (_mSeg != 2) ...[
const SizedBox(height: 12),
MSearchRow(
controller: _summaryKwCtrl,
hint: '搜索往来单位',
onChanged: _setSummaryKw,
),
],
..._mSegBody(t, flows), ..._mSegBody(t, flows),
], ],
), ),
), ),
); );
return Stack(children: [ return Stack(
children: [
content, content,
if (flowsAsync.isLoading) if (flowsAsync.isLoading)
const Positioned.fill(child: DsLoadingScrim()), const Positioned.fill(child: DsLoadingScrim()),
]); ],
);
} }
final content = Container( final content = Container(
@@ -209,7 +242,17 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
_secHead(t, '收支趋势', '近 6 个月 · 单位 万元'), _secHead(t, '收支趋势', '近 6 个月 · 单位 万元'),
_trendChart(), _trendChart(),
const SizedBox(height: 22), const SizedBox(height: 22),
_secHead(t, '应收 / 应付 汇总', '按往来单位 · 点击行查看流水'), _secHead(
t,
'应收 / 应付 汇总',
'按往来单位 · 点击行查看流水',
trailing: DsSearchBox(
controller: _summaryKwCtrl,
hint: '搜索往来单位',
width: 220,
onChanged: _setSummaryKw,
),
),
_summaryTable(t, mobile), _summaryTable(t, mobile),
const SizedBox(height: 22), const SizedBox(height: 22),
_secHead(t, '收支流水', _flowSub(flows)), _secHead(t, '收支流水', _flowSub(flows)),
@@ -218,10 +261,13 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
), ),
), ),
); );
return Stack(children: [ return Stack(
children: [
content, content,
if (flowsAsync.isLoading) const Positioned.fill(child: DsLoadingScrim()), if (flowsAsync.isLoading)
]); const Positioned.fill(child: DsLoadingScrim()),
],
);
} }
// ── head ── // ── head ──
@@ -231,32 +277,42 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text('财务管理', Text(
'财务管理',
style: TextStyle( style: TextStyle(
fontSize: AppDims.fsH1, fontSize: AppDims.fsH1,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: t.heading)), color: t.heading,
),
),
const SizedBox(width: AppDims.sp3), const SizedBox(width: AppDims.sp3),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),
child: Text('应收/应付概览 · $_rangeLabel', child: Text(
'应收/应付概览 · $_rangeLabel',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), style: TextStyle(fontSize: AppDims.fsSm, color: t.muted),
),
), ),
), ),
if (!mobile) ...[ if (!mobile) ...[
DsButton('刷新', icon: LucideIcons.refreshCw, onPressed: _reload), DsButton('刷新', icon: LucideIcons.refreshCw, onPressed: _reload),
const SizedBox(width: 10), const SizedBox(width: 10),
DsButton('导出报表', DsButton(
icon: LucideIcons.download, onPressed: () => _export(flows)), '导出报表',
icon: LucideIcons.download,
onPressed: () => _export(flows),
),
const SizedBox(width: 10), const SizedBox(width: 10),
WriteGuard( WriteGuard(
child: DsButton('登记收支', child: DsButton(
'登记收支',
icon: LucideIcons.plus, icon: LucideIcons.plus,
variant: DsBtnVariant.primary, variant: DsBtnVariant.primary,
onPressed: () => showFinanceEntryDialog(context)), onPressed: () => showFinanceEntryDialog(context),
),
), ),
], ],
], ],
@@ -269,7 +325,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
filename: '财务流水', filename: '财务流水',
headers: ['日期', '类型', '往来单位', '金额', '关联单据', '状态', '备注'], headers: ['日期', '类型', '往来单位', '金额', '关联单据', '状态', '备注'],
rows: flows rows: flows
.map((r) => [ .map(
(r) => [
(r.recordDate ?? '').split('T').first, (r.recordDate ?? '').split('T').first,
r.typeLabel, r.typeLabel,
r.partnerName ?? '', r.partnerName ?? '',
@@ -277,7 +334,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
r.refType != null ? '${r.docTitle}#${r.refId ?? ''}' : '', r.refType != null ? '${r.docTitle}#${r.refId ?? ''}' : '',
r.status == 'open' ? '未结清' : '已结清', r.status == 'open' ? '未结清' : '已结清',
r.remark ?? '', r.remark ?? '',
]) ],
)
.toList(), .toList(),
); );
} }
@@ -289,18 +347,23 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
final partnerName = mobile final partnerName = mobile
? _partnerNameById( ? _partnerNameById(
ref.watch(allPartnersProvider).valueOrNull?.data ?? const [], ref.watch(allPartnersProvider).valueOrNull?.data ?? const [],
_partnerId) _partnerId,
)
: null; : null;
final children = <Widget>[ final children = <Widget>[
Text('时间范围', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), Text(
'时间范围',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted),
),
const SizedBox(width: 12), const SizedBox(width: 12),
for (final r in const ['本月', '本季']) ...[ for (final r in const ['本月', '本季']) ...[
DsChip( DsChip(
label: r, label: r,
selected: _range == r, selected: _range == r,
caret: false, caret: false,
onTap: () => _applyRange(r, context)), onTap: () => _applyRange(r, context),
),
const SizedBox(width: 10), const SizedBox(width: 10),
], ],
Builder( Builder(
@@ -309,7 +372,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
builder: (anchorCtx) => DsChip( builder: (anchorCtx) => DsChip(
label: '自定义', label: '自定义',
selected: _range == '自定义', selected: _range == '自定义',
onTap: () => _applyRange('自定义', anchorCtx)), onTap: () => _applyRange('自定义', anchorCtx),
),
), ),
if (mobile) ...[ if (mobile) ...[
const SizedBox(width: 10), const SizedBox(width: 10),
@@ -317,7 +381,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
label: '往来单位', label: '往来单位',
value: partnerName, value: partnerName,
onTap: _openPartnerSheet, onTap: _openPartnerSheet,
onClear: () => _setPartner(null)), onClear: () => _setPartner(null),
),
], ],
]; ];
@@ -325,11 +390,13 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
children.addAll([ children.addAll([
const SizedBox(width: 10), const SizedBox(width: 10),
WriteGuard( WriteGuard(
child: DsButton('登记', child: DsButton(
'登记',
small: true, small: true,
icon: LucideIcons.plus, icon: LucideIcons.plus,
variant: DsBtnVariant.primary, variant: DsBtnVariant.primary,
onPressed: () => showFinanceEntryDialog(context)), onPressed: () => showFinanceEntryDialog(context),
),
), ),
]); ]);
// 窄屏内容(label+4 chip+登记)常超宽:整行横向可滚(照 _flowTable 移动 // 窄屏内容(label+4 chip+登记)常超宽:整行横向可滚(照 _flowTable 移动
@@ -337,7 +404,9 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 18), padding: const EdgeInsets.only(bottom: 18),
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, child: Row(children: children)), scrollDirection: Axis.horizontal,
child: Row(children: children),
),
); );
} }
@@ -357,14 +426,17 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
/// 移动端「往来单位」chip → 底部 sheet 可搜索单选(D6,对齐桌面 ComboSearchField)。 /// 移动端「往来单位」chip → 底部 sheet 可搜索单选(D6,对齐桌面 ComboSearchField)。
Future<void> _openPartnerSheet() async { Future<void> _openPartnerSheet() async {
final partners = ref.read(allPartnersProvider).valueOrNull?.data ?? const []; final partners =
ref.read(allPartnersProvider).valueOrNull?.data ?? const [];
final options = [ final options = [
for (final p in partners) OptionItem(id: p.id, name: p.name, code: p.code), for (final p in partners)
OptionItem(id: p.id, name: p.name, code: p.code),
]; ];
final sel = await showMSheet<int?>( final sel = await showMSheet<int?>(
context, context,
title: '往来单位', title: '往来单位',
builder: (_) => _PartnerSheetBody(options: options, selectedId: _partnerId), builder: (_) =>
_PartnerSheetBody(options: options, selectedId: _partnerId),
); );
if (sel == null || !mounted) return; if (sel == null || !mounted) return;
// -1 = 清除选择(照 SearchableOptionField 的既有约定) // -1 = 清除选择(照 SearchableOptionField 的既有约定)
@@ -430,12 +502,14 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: IntrinsicHeight( child: IntrinsicHeight(
child: Row(children: [ child: Row(
children: [
for (var i = 0; i < cards.length; i++) ...[ for (var i = 0; i < cards.length; i++) ...[
if (i > 0) const SizedBox(width: AppDims.sp3), if (i > 0) const SizedBox(width: AppDims.sp3),
SizedBox(width: 170, child: cards[i]), SizedBox(width: 170, child: cards[i]),
], ],
]), ],
),
), ),
), ),
); );
@@ -457,22 +531,30 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
} }
/// 原型 .sec-headh2 17/700 + 副标 fs-xs muted,下距 12。 /// 原型 .sec-headh2 17/700 + 副标 fs-xs muted,下距 12。
Widget _secHead(dynamic t, String title, String sub) => Padding( /// [trailing] 靠右附加控件(原型 .sec-head .sum-search,如汇总卡的往来单位搜索)。
Widget _secHead(dynamic t, String title, String sub, {Widget? trailing}) =>
Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text(title, Text(
title,
style: TextStyle( style: TextStyle(
fontSize: AppDims.fsH2, fontSize: AppDims.fsH2,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: t.heading)), color: t.heading,
),
),
const SizedBox(width: 10), const SizedBox(width: 10),
Padding( Padding(
padding: const EdgeInsets.only(bottom: 1), padding: const EdgeInsets.only(bottom: 1),
child: Text(sub, child: Text(
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), sub,
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted),
), ),
),
if (trailing != null) ...[const Spacer(), trailing],
], ],
), ),
); );
@@ -492,12 +574,19 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
child: child, child: child,
); );
return trend.when( return trend.when(
loading: () => placeholder(const SizedBox( loading: () => placeholder(
const SizedBox(
width: 20, width: 20,
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2))), child: CircularProgressIndicator(strokeWidth: 2),
error: (e, _) => placeholder(Text('趋势加载失败', ),
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm))), ),
error: (e, _) => placeholder(
Text(
'趋势加载失败',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm),
),
),
data: (points) => DsBarChart( data: (points) => DsBarChart(
groups: [ groups: [
for (final p in points) for (final p in points)
@@ -522,35 +611,49 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
return async.when( return async.when(
loading: () => const Padding( loading: () => const Padding(
padding: EdgeInsets.all(24), padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator())), child: Center(child: CircularProgressIndicator()),
error: (e, _) => Row(children: [ ),
Text('汇总加载失败', error: (e, _) => Row(
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)), children: [
Text(
'汇总加载失败',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm),
),
const SizedBox(width: 10), const SizedBox(width: 10),
DsButton('重试', DsButton(
'重试',
small: true, small: true,
onPressed: () => ref.invalidate(financePartnerRowsProvider)), onPressed: () => ref.invalidate(financePartnerRowsProvider),
]), ),
data: (rows) => DsTable( ],
),
data: (allRows) {
final rows = _filterSummaryRows(allRows);
return DsTable(
shrinkWrap: true, shrinkWrap: true,
emptyText: '暂无未结清的应收 / 应付', emptyText: _summaryKw.trim().isEmpty ? '暂无未结清的应收 / 应付' : '没有匹配的往来单位',
mobileCards: rows mobileCards: rows
.map((r) => MobileListCard( .map(
(r) => MobileListCard(
title: Text(r.name), title: Text(r.name),
trailing: Text(signedYuan(r.net), trailing: Text(
signedYuan(r.net),
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontFamily: AppFonts.mono, fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback, fontFamilyFallback: AppFonts.monoFallback,
color: r.net > 0 color: r.net > 0
? t.success ? t.success
: (r.net < 0 ? t.danger : t.text))), : (r.net < 0 ? t.danger : t.text),
),
),
fields: [ fields: [
MobileCardField('应收', r.recv > 0 ? _yuan(r.recv) : ''), MobileCardField('应收', r.recv > 0 ? _yuan(r.recv) : ''),
MobileCardField('应付', r.pay > 0 ? _yuan(r.pay) : ''), MobileCardField('应付', r.pay > 0 ? _yuan(r.pay) : ''),
], ],
onTap: () => showFinancePartnerDrawer(context, row: r), onTap: () => showFinancePartnerDrawer(context, row: r),
)) ),
)
.toList(), .toList(),
columns: const [ columns: const [
DsColumn('name', '往来单位'), DsColumn('name', '往来单位'),
@@ -563,37 +666,54 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
return DsRow( return DsRow(
onTap: () => showFinancePartnerDrawer(context, row: r), onTap: () => showFinancePartnerDrawer(context, row: r),
cells: [ cells: [
Text(r.name, Text(
style: r.name,
TextStyle(fontWeight: FontWeight.w600, color: t.heading)), style: TextStyle(
Text(r.recv > 0 ? _yuan(r.recv) : '', fontWeight: FontWeight.w600,
color: t.heading,
),
),
Text(
r.recv > 0 ? _yuan(r.recv) : '',
style: const TextStyle( style: const TextStyle(
fontFamily: AppFonts.mono, fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)), fontFamilyFallback: AppFonts.monoFallback,
Text(r.pay > 0 ? _yuan(r.pay) : '', ),
),
Text(
r.pay > 0 ? _yuan(r.pay) : '',
style: const TextStyle( style: const TextStyle(
fontFamily: AppFonts.mono, fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)), fontFamilyFallback: AppFonts.monoFallback,
Text(signedYuan(r.net), ),
),
Text(
signedYuan(r.net),
style: TextStyle( style: TextStyle(
fontFamily: AppFonts.mono, fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback, fontFamilyFallback: AppFonts.monoFallback,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: r.net > 0 color: r.net > 0
? t.success ? t.success
: (r.net < 0 ? t.danger : t.text))), : (r.net < 0 ? t.danger : t.text),
),
),
InkWell( InkWell(
onTap: () => showFinancePartnerDrawer(context, row: r), onTap: () => showFinancePartnerDrawer(context, row: r),
child: Text('查看', child: Text(
'查看',
style: TextStyle( style: TextStyle(
fontSize: AppDims.fsBody, fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: t.primary)), color: t.primary,
),
),
), ),
], ],
); );
}).toList(), }).toList(),
), );
},
); );
} }
@@ -605,23 +725,33 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
// ── 收支流水表 ── // ── 收支流水表 ──
Widget _flowTable( Widget _flowTable(
dynamic t, bool mobile, List<FinanceRecord> flows, int total) { dynamic t,
bool mobile,
List<FinanceRecord> flows,
int total,
) {
final chips = [ final chips = [
for (final c in _flowChips) ...[ for (final c in _flowChips) ...[
DsChip( DsChip(
label: c, label: c,
selected: _flowChip == c, selected: _flowChip == c,
caret: false, caret: false,
onTap: () => _setFlowChip(c)), onTap: () => _setFlowChip(c),
),
const SizedBox(width: 10), const SizedBox(width: 10),
], ],
]; ];
final toolbar = mobile final toolbar = mobile
? SingleChildScrollView( ? SingleChildScrollView(
scrollDirection: Axis.horizontal, child: Row(children: chips)) scrollDirection: Axis.horizontal,
: Row(children: [ child: Row(children: chips),
Text('类型', )
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), : Row(
children: [
Text(
'类型',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted),
),
const SizedBox(width: 12), const SizedBox(width: 12),
...chips, ...chips,
const SizedBox(width: 10), const SizedBox(width: 10),
@@ -629,9 +759,13 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
// stock_out_list_screen.dart 客户 ComboSearchField 写法)。仅桌面 // stock_out_list_screen.dart 客户 ComboSearchField 写法)。仅桌面
// 工具栏放置,移动端对应筛选在 _timeRow 尾部的「往来单位」chip → sheet。 // 工具栏放置,移动端对应筛选在 _timeRow 尾部的「往来单位」chip → sheet。
ComboSearchField( ComboSearchField(
options: (ref.watch(allPartnersProvider).valueOrNull?.data ?? options:
(ref.watch(allPartnersProvider).valueOrNull?.data ??
const []) const [])
.map((p) => OptionItem(id: p.id, name: p.name, code: p.code)) .map(
(p) =>
OptionItem(id: p.id, name: p.name, code: p.code),
)
.toList(), .toList(),
selectedId: _partnerId, selectedId: _partnerId,
hint: '往来单位', hint: '往来单位',
@@ -639,13 +773,17 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
onChanged: _setPartner, onChanged: _setPartner,
), ),
const Spacer(), const Spacer(),
Text('$total', Text(
'$total',
style: TextStyle( style: TextStyle(
fontSize: AppDims.fsSm, fontSize: AppDims.fsSm,
color: t.muted, color: t.muted,
fontFamily: AppFonts.mono, fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)), fontFamilyFallback: AppFonts.monoFallback,
]); ),
),
],
);
return DsTable( return DsTable(
shrinkWrap: true, shrinkWrap: true,
@@ -669,20 +807,27 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
], ],
rows: flows.map((r) { rows: flows.map((r) {
final isIn = r.type == 'receipt' || r.type == 'receivable'; final isIn = r.type == 'receipt' || r.type == 'receivable';
return DsRow(cells: [ return DsRow(
Text((r.recordDate ?? '').split('T').first, cells: [
Text(
(r.recordDate ?? '').split('T').first,
style: const TextStyle( style: const TextStyle(
fontFamily: AppFonts.mono, fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)), fontFamilyFallback: AppFonts.monoFallback,
),
),
financeTypeBadge(r), financeTypeBadge(r),
Text((r.partnerName?.isNotEmpty == true) ? r.partnerName! : ''), Text((r.partnerName?.isNotEmpty == true) ? r.partnerName! : ''),
// 原型 .qty+/- 前缀、恒 heading 色(不上正负色) // 原型 .qty+/- 前缀、恒 heading 色(不上正负色)
Text('${isIn ? '+' : '-'}${_yuan(r.amount.abs())}', Text(
'${isIn ? '+' : '-'}${_yuan(r.amount.abs())}',
style: TextStyle( style: TextStyle(
fontFamily: AppFonts.mono, fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback, fontFamilyFallback: AppFonts.monoFallback,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: t.heading)), color: t.heading,
),
),
Text( Text(
r.refType != null && r.refId != null r.refType != null && r.refId != null
? '${r.docTitle} #${r.refId}' ? '${r.docTitle} #${r.refId}'
@@ -691,14 +836,19 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
fontFamily: AppFonts.mono, fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback, fontFamilyFallback: AppFonts.monoFallback,
fontSize: AppDims.fsSm, fontSize: AppDims.fsSm,
color: t.muted)), color: t.muted,
Text((r.remark?.isNotEmpty == true) ? r.remark! : '', ),
style: TextStyle(color: t.muted)), ),
Text(
(r.remark?.isNotEmpty == true) ? r.remark! : '',
style: TextStyle(color: t.muted),
),
r.status == 'open' r.status == 'open'
? const DsBadge('未结清', tone: DsBadgeTone.warn) ? const DsBadge('未结清', tone: DsBadgeTone.warn)
: const DsBadge('已结清', tone: DsBadgeTone.muted), : const DsBadge('已结清', tone: DsBadgeTone.muted),
_closeAction(t, r), _closeAction(t, r),
]); ],
);
}).toList(), }).toList(),
); );
} }
@@ -715,11 +865,14 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
return WriteGuard( return WriteGuard(
child: InkWell( child: InkWell(
onTap: () => _confirmClose(r), onTap: () => _confirmClose(r),
child: Text('结清', child: Text(
'结清',
style: TextStyle( style: TextStyle(
fontSize: AppDims.fsBody, fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: t.primary)), color: t.primary,
),
),
), ),
); );
} }
@@ -729,13 +882,17 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('确认结清'), title: const Text('确认结清'),
content: Text('确认将「${r.partnerName ?? ''} ${r.typeLabel} ' content: Text(
'${_yuan(r.amount.abs())}」标记为已结清?'), '确认将「${r.partnerName ?? ''} ${r.typeLabel} '
'${_yuan(r.amount.abs())}」标记为已结清?',
),
actions: [ actions: [
DsButton('取消', onPressed: () => Navigator.of(ctx).pop(false)), DsButton('取消', onPressed: () => Navigator.of(ctx).pop(false)),
DsButton('结清', DsButton(
'结清',
variant: DsBtnVariant.primary, variant: DsBtnVariant.primary,
onPressed: () => Navigator.of(ctx).pop(true)), onPressed: () => Navigator.of(ctx).pop(true),
),
], ],
), ),
); );
@@ -758,7 +915,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
final isIn = r.type == 'receipt' || r.type == 'receivable'; final isIn = r.type == 'receipt' || r.type == 'receivable';
return MobileListCard( return MobileListCard(
title: Text( title: Text(
(r.partnerName?.isNotEmpty == true) ? r.partnerName! : r.typeLabel), (r.partnerName?.isNotEmpty == true) ? r.partnerName! : r.typeLabel,
),
subtitle: Text((r.recordDate ?? '').split('T').first), subtitle: Text((r.recordDate ?? '').split('T').first),
trailing: financeTypeBadge(r), trailing: financeTypeBadge(r),
fields: [ fields: [
@@ -766,7 +924,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
MobileCardField('状态', r.status == 'open' ? '未结清' : '已结清'), MobileCardField('状态', r.status == 'open' ? '未结清' : '已结清'),
if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark), if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark),
], ],
actions: (r.status == 'open' && actions:
(r.status == 'open' &&
(r.type == 'receivable' || r.type == 'payable')) (r.type == 'receivable' || r.type == 'payable'))
? [ ? [
WriteGuard( WriteGuard(
@@ -799,7 +958,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
Widget _mKpis(dynamic t) { Widget _mKpis(dynamic t) {
final outSum = ref.watch(stockOutSummaryProvider).valueOrNull; final outSum = ref.watch(stockOutSummaryProvider).valueOrNull;
final inSum = ref.watch(stockInSummaryProvider).valueOrNull; final inSum = ref.watch(stockInSummaryProvider).valueOrNull;
final rows = ref.watch(financePartnerRowsProvider).valueOrNull ?? final rows =
ref.watch(financePartnerRowsProvider).valueOrNull ??
const <PartnerFinanceRow>[]; const <PartnerFinanceRow>[];
var recv = 0.0, pay = 0.0, openCount = 0; var recv = 0.0, pay = 0.0, openCount = 0;
for (final r in rows) { for (final r in rows) {
@@ -813,7 +973,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
final (buyDelta, buyTone) = inSum != null final (buyDelta, buyTone) = inSum != null
? _momDelta(inSum.monthAmount, inSum.lastMonthAmount) ? _momDelta(inSum.monthAmount, inSum.lastMonthAmount)
: ('较上月 —', DsKpiDelta.neutral); : ('较上月 —', DsKpiDelta.neutral);
return MKpiGrid(items: [ return MKpiGrid(
items: [
MKpiItem( MKpiItem(
label: '本月收入', label: '本月收入',
value: outSum != null ? _yuanWan(outSum.monthAmount) : '', value: outSum != null ? _yuanWan(outSum.monthAmount) : '',
@@ -839,25 +1000,32 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
delta: '按到期及时结清', delta: '按到期及时结清',
deltaTone: MKpiDeltaTone.normal, deltaTone: MKpiDeltaTone.normal,
), ),
]); ],
);
} }
/// 三分段列表体:m-section 计数标题 + 卡片流(应收/应付=往来汇总,流水=收支记录)。 /// 三分段列表体:m-section 计数标题 + 卡片流(应收/应付=往来汇总,流水=收支记录)。
List<Widget> _mSegBody(dynamic t, List<FinanceRecord> flows) { List<Widget> _mSegBody(dynamic t, List<FinanceRecord> flows) {
Widget section(String title, int count) => Padding( Widget section(String title, int count) => Padding(
padding: const EdgeInsets.fromLTRB(2, 16, 2, 8), padding: const EdgeInsets.fromLTRB(2, 16, 2, 8),
child: Text('$title · 共 $count', child: Text(
'$title · 共 $count',
style: TextStyle( style: TextStyle(
fontSize: AppDims.fsSm, fontSize: AppDims.fsSm,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: .4, letterSpacing: .4,
color: t.muted)), color: t.muted,
),
),
); );
Widget empty() => Padding( Widget empty([String msg = '暂无数据']) => Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Center( child: Center(
child: Text('暂无数据', child: Text(
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm))), msg,
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm),
),
),
); );
List<Widget> stacked(List<Widget> cards) => [ List<Widget> stacked(List<Widget> cards) => [
for (var i = 0; i < cards.length; i++) ...[ for (var i = 0; i < cards.length; i++) ...[
@@ -880,26 +1048,31 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
loading: () => [ loading: () => [
const Padding( const Padding(
padding: EdgeInsets.all(24), padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator())), child: Center(child: CircularProgressIndicator()),
),
], ],
error: (e, _) => [ error: (e, _) => [
Padding( Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Center( child: Center(
child: Text('汇总加载失败', child: Text(
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm))), '汇总加载失败',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm),
),
),
), ),
], ],
data: (rows) { data: (rows) {
final ar = _mSeg == 0; final ar = _mSeg == 0;
final list = rows.where((r) => ar ? r.recv > 0 : r.pay > 0).toList(); final list = _filterSummaryRows(
rows.where((r) => ar ? r.recv > 0 : r.pay > 0).toList(),
);
return [ return [
section(ar ? '应收账款' : '应付账款', list.length), section(ar ? '应收账款' : '应付账款', list.length),
if (list.isEmpty) if (list.isEmpty)
empty() empty(_summaryKw.trim().isEmpty ? '暂无数据' : '没有匹配的往来单位')
else else
...stacked( ...stacked([for (final r in list) _mPartnerCard(t, r, ar: ar)]),
[for (final r in list) _mPartnerCard(t, r, ar: ar)]),
]; ];
}, },
); );
@@ -914,16 +1087,18 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
trailing: Column( trailing: Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
DsBadge('未结清', DsBadge('未结清', tone: DsBadgeTone.warn, icon: statusIcon('未结清')),
tone: DsBadgeTone.warn, icon: statusIcon('未结清')),
const SizedBox(height: 6), const SizedBox(height: 6),
Text(_yuan(ar ? r.recv : r.pay), Text(
_yuan(ar ? r.recv : r.pay),
style: TextStyle( style: TextStyle(
fontFamily: AppFonts.mono, fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback, fontFamilyFallback: AppFonts.monoFallback,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 13, fontSize: 13,
color: t.heading)), color: t.heading,
),
),
], ],
), ),
); );
@@ -934,27 +1109,33 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
final isIn = r.type == 'receipt' || r.type == 'receivable'; final isIn = r.type == 'receipt' || r.type == 'receivable';
return MobileListCard( return MobileListCard(
title: Text( title: Text(
(r.partnerName?.isNotEmpty == true) ? r.partnerName! : r.typeLabel), (r.partnerName?.isNotEmpty == true) ? r.partnerName! : r.typeLabel,
),
subtitle: Text((r.recordDate ?? '').split('T').first), subtitle: Text((r.recordDate ?? '').split('T').first),
trailing: Column( trailing: Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
DsBadge(r.typeLabel, DsBadge(
r.typeLabel,
tone: switch (r.type) { tone: switch (r.type) {
'receipt' => DsBadgeTone.ok, 'receipt' => DsBadgeTone.ok,
'payment' => DsBadgeTone.danger, 'payment' => DsBadgeTone.danger,
'receivable' => DsBadgeTone.warn, 'receivable' => DsBadgeTone.warn,
_ => DsBadgeTone.info, _ => DsBadgeTone.info,
}, },
icon: statusIcon(r.typeLabel)), icon: statusIcon(r.typeLabel),
),
const SizedBox(height: 6), const SizedBox(height: 6),
Text('${isIn ? '+' : '-'}${_yuan(r.amount.abs())}', Text(
'${isIn ? '+' : '-'}${_yuan(r.amount.abs())}',
style: TextStyle( style: TextStyle(
fontFamily: AppFonts.mono, fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback, fontFamilyFallback: AppFonts.monoFallback,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 13, fontSize: 13,
color: isIn ? t.success : t.danger)), color: isIn ? t.success : t.danger,
),
),
], ],
), ),
); );
@@ -988,29 +1169,32 @@ class _PartnerSheetBodyState extends State<_PartnerSheetBody> {
required String name, required String name,
required bool selected, required bool selected,
required VoidCallback onTap, required VoidCallback onTap,
}) => }) => InkWell(
InkWell(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 4), padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: t.borderSubtle)), border: Border(bottom: BorderSide(color: t.borderSubtle)),
), ),
child: Row(children: [ child: Row(
children: [
Expanded( Expanded(
child: Text(name, child: Text(
name,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: AppDims.fsBody, fontSize: AppDims.fsBody,
fontWeight: fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
selected ? FontWeight.w600 : FontWeight.w400, color: selected ? t.primary : t.text,
color: selected ? t.primary : t.text)), ),
),
), ),
if (selected) ...[ if (selected) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Icon(LucideIcons.check, size: 18, color: t.primary), Icon(LucideIcons.check, size: 18, color: t.primary),
], ],
]), ],
),
), ),
); );
@@ -1023,7 +1207,8 @@ class _PartnerSheetBodyState extends State<_PartnerSheetBody> {
color: t.bg, color: t.bg,
borderRadius: BorderRadius.circular(AppDims.rMd), borderRadius: BorderRadius.circular(AppDims.rMd),
), ),
child: Row(children: [ child: Row(
children: [
Icon(LucideIcons.search, size: 15, color: t.faint), Icon(LucideIcons.search, size: 15, color: t.faint),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
@@ -1034,13 +1219,16 @@ class _PartnerSheetBodyState extends State<_PartnerSheetBody> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
border: InputBorder.none, border: InputBorder.none,
hintText: '搜索往来单位…', hintText: '搜索往来单位…',
hintStyle: hintStyle: TextStyle(
TextStyle(fontSize: AppDims.fsBody, color: t.faint), fontSize: AppDims.fsBody,
color: t.faint,
),
), ),
onChanged: (v) => setState(() => _kw = v), onChanged: (v) => setState(() => _kw = v),
), ),
), ),
]), ],
),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
optRow( optRow(
@@ -1051,8 +1239,10 @@ class _PartnerSheetBodyState extends State<_PartnerSheetBody> {
if (hits.isEmpty) if (hits.isEmpty)
Padding( Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Text('无匹配结果', child: Text(
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)), '无匹配结果',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm),
),
) )
else else
for (final o in hits) for (final o in hits)
@@ -0,0 +1,194 @@
// test/finance_summary_search_test.dart — 财务「应收/应付 汇总」往来单位搜索回归。
//
// financePartnerRowsProviderGET /finance/summary)不分页、一次性全量加载,
// 搜索口径=显示口径:仅需验证本地 contains 过滤在已加载行上生效——桌面
// _summaryTable 工具头 DsSearchBox / 移动 应收·应付 分段头 MSearchRow 均覆盖:
// 1. 输入关键字 → 汇总行按往来单位名过滤,未命中不显示;
// 2. 关键字不匹配任何行 → 展示「没有匹配的往来单位」空态;
// 3. 清空关键字 → 恢复全量展示。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:jiu_client/core/api/api_client.dart';
import 'package:jiu_client/core/auth/auth_state.dart';
import 'package:jiu_client/core/models/page_result.dart';
import 'package:jiu_client/core/theme/themes.dart';
import 'package:jiu_client/models/finance.dart';
import 'package:jiu_client/models/partner.dart';
import 'package:jiu_client/models/stock_summary.dart';
import 'package:jiu_client/providers/finance_provider.dart';
import 'package:jiu_client/providers/partner_provider.dart';
import 'package:jiu_client/providers/stock_in_provider.dart';
import 'package:jiu_client/providers/stock_out_provider.dart';
import 'package:jiu_client/repositories/finance_repository.dart';
import 'package:jiu_client/screens/finance/finance_screen.dart';
import 'package:jiu_client/widgets/ds/ds_atoms.dart' show DsSearchBox;
import 'package:jiu_client/widgets/ds/m_search_row.dart' show MSearchRow;
/// 不打真实网络:仅用于满足 FinanceRepository 构造签名,方法全部被覆写。
class _DummyApiClient extends ApiClient {
_DummyApiClient() : super(token: 'test-token');
}
/// 固定 3 条往来单位汇总(2 应收 + 1 应付),流水列表恒空(本测试不关心流水)。
class _FakeFinanceRepository extends FinanceRepository {
_FakeFinanceRepository() : super(_DummyApiClient());
@override
Future<PageResult<FinanceRecord>> listRecords({
String? type,
String? month,
String? startDate,
String? endDate,
int? partnerId,
int page = 1,
int pageSize = 50,
}) async =>
const PageResult(data: [], total: 0, page: 1, pageSize: 50);
@override
Future<List<PartnerFinanceSummary>> partnerSummary() async => const [
PartnerFinanceSummary(
partnerId: 1,
partnerName: '华东酒类批发',
type: 'receivable',
recordCount: 2,
totalAmount: 128400),
PartnerFinanceSummary(
partnerId: 2,
partnerName: '金樽商贸',
type: 'receivable',
recordCount: 1,
totalAmount: 86200),
PartnerFinanceSummary(
partnerId: 3,
partnerName: '茅台华南经销',
type: 'payable',
recordCount: 1,
totalAmount: 142800),
];
@override
Future<List<TrendPoint>> trend({int months = 6}) async => const [];
}
class _FakeAllPartners extends PartnerListNotifier {
@override
Future<PageResult<Partner>> build() async => const PageResult(
data: [
Partner(id: 1, name: '华东酒类批发', type: 'customer'),
Partner(id: 2, name: '金樽商贸', type: 'customer'),
Partner(id: 3, name: '茅台华南经销', type: 'supplier'),
],
total: 3,
page: 1,
pageSize: 1000,
);
}
List<Override> _overrides() => [
financeRepositoryProvider.overrideWith((ref) => _FakeFinanceRepository()),
allPartnersProvider.overrideWith(() => _FakeAllPartners()),
stockOutSummaryProvider.overrideWith(
(ref) async => const StockSummary(monthAmount: 0, lastMonthAmount: 0)),
stockInSummaryProvider.overrideWith(
(ref) async => const StockSummary(monthAmount: 0, lastMonthAmount: 0)),
isReadonlyProvider.overrideWithValue(false),
];
Future<void> _pumpFinance(
WidgetTester tester, {
required Size size,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(ProviderScope(
overrides: _overrides(),
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: buildTheme('a'),
home: const Scaffold(body: FinanceScreen()),
),
));
await tester.pumpAndSettle();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
group('桌面汇总卡往来单位搜索', () {
testWidgets('输入关键字过滤汇总行,无匹配报空态,清空恢复全量', (tester) async {
await _pumpFinance(tester, size: const Size(1280, 900));
// 初始:3 条汇总行全部展示。
expect(find.text('华东酒类批发'), findsOneWidget);
expect(find.text('金樽商贸'), findsOneWidget);
expect(find.text('茅台华南经销'), findsOneWidget);
final searchField = find.descendant(
of: find.byType(DsSearchBox),
matching: find.byType(TextField),
);
expect(searchField, findsOneWidget);
// 输入「金樽」:只剩「金樽商贸」。
await tester.enterText(searchField, '金樽');
await tester.pumpAndSettle();
expect(find.text('金樽商贸'), findsOneWidget);
expect(find.text('华东酒类批发'), findsNothing);
expect(find.text('茅台华南经销'), findsNothing);
// 输入不存在的关键字:空态提示。
await tester.enterText(searchField, '不存在的单位名');
await tester.pumpAndSettle();
expect(find.text('没有匹配的往来单位'), findsOneWidget);
expect(find.text('金樽商贸'), findsNothing);
// 清空:恢复全量 3 行。
await tester.enterText(searchField, '');
await tester.pumpAndSettle();
expect(find.text('华东酒类批发'), findsOneWidget);
expect(find.text('金樽商贸'), findsOneWidget);
expect(find.text('茅台华南经销'), findsOneWidget);
});
});
group('移动应收/应付分段往来单位搜索', () {
testWidgets('应收分段输入关键字过滤卡片,清空恢复全量', (tester) async {
await _pumpFinance(tester, size: const Size(390, 1600));
// 默认「应收」分段:仅展示 recv>0 的两条(华东/金樽),不含应付的茅台。
expect(find.text('华东酒类批发'), findsOneWidget);
expect(find.text('金樽商贸'), findsOneWidget);
expect(find.text('茅台华南经销'), findsNothing);
final searchField = find.descendant(
of: find.byType(MSearchRow),
matching: find.byType(TextField),
);
expect(searchField, findsOneWidget);
await tester.enterText(searchField, '金樽');
await tester.pumpAndSettle();
expect(find.text('金樽商贸'), findsOneWidget);
expect(find.text('华东酒类批发'), findsNothing);
// 无匹配 → 分段空态。
await tester.enterText(searchField, '不存在的单位名');
await tester.pumpAndSettle();
expect(find.text('没有匹配的往来单位'), findsOneWidget);
// 清空恢复。
await tester.enterText(searchField, '');
await tester.pumpAndSettle();
expect(find.text('华东酒类批发'), findsOneWidget);
expect(find.text('金樽商贸'), findsOneWidget);
});
});
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 108 KiB

+1 -1
View File
@@ -105,7 +105,7 @@ AppTokens 目前**仅颜色**。原型还驱动:
| 基础数据(移动) | `m-products.html` | 同上窄屏(卡片流) | 同步 | ✅ golden 自比 | ✅ golden 自比 | ✅ golden 自比 | | 基础数据(移动) | `m-products.html` | 同上窄屏(卡片流) | 同步 | ✅ golden 自比 | ✅ golden 自比 | ✅ golden 自比 |
| 入库列表(桌/移) | `stock-in-list.html` | `stock_in_list_screen.dart` | 同步 | ✅ 副标 + 共享 StatusPill(草稿/待审/已审/已拒)**2026-07-14 六项优化**:详情抽屉明细增加生产日期+批次号两列(抽屉加宽 660)、状态筛选改多选 chip 且默认勾选「草稿+待审核」、新建单默认填充默认仓库(原型 stock-in-list.html/stock-in.js 同步);fidelity a 3.323% / b 2.319% / c 3.633%(均 ≤ 阈值 6%),移动 golden 自比全绿 | ✅ golden | ✅ golden | | 入库列表(桌/移) | `stock-in-list.html` | `stock_in_list_screen.dart` | 同步 | ✅ 副标 + 共享 StatusPill(草稿/待审/已审/已拒)**2026-07-14 六项优化**:详情抽屉明细增加生产日期+批次号两列(抽屉加宽 660)、状态筛选改多选 chip 且默认勾选「草稿+待审核」、新建单默认填充默认仓库(原型 stock-in-list.html/stock-in.js 同步);fidelity a 3.323% / b 2.319% / c 3.633%(均 ≤ 阈值 6%),移动 golden 自比全绿 | ✅ golden | ✅ golden |
| 出库列表(桌/移) | `stock-out-list.html` | `stock_out_list_screen.dart` | 同步 | ✅ 副标 + 共享 StatusPill**2026-07-14 六项优化**:详情抽屉明细同增生产日期+批次号两列(抽屉 660)、状态筛选改多选 chip 且默认「草稿+待审核」、新建单默认填充默认仓库(原型 stock-out-list.html 同步);fidelity a 3.228% / b 2.217% / c 3.538%(均 ≤ 阈值 6%),移动 golden 自比全绿 | ✅ golden | ✅ golden | | 出库列表(桌/移) | `stock-out-list.html` | `stock_out_list_screen.dart` | 同步 | ✅ 副标 + 共享 StatusPill**2026-07-14 六项优化**:详情抽屉明细同增生产日期+批次号两列(抽屉 660)、状态筛选改多选 chip 且默认「草稿+待审核」、新建单默认填充默认仓库(原型 stock-out-list.html 同步);fidelity a 3.228% / b 2.217% / c 3.538%(均 ≤ 阈值 6%),移动 golden 自比全绿 | ✅ golden | ✅ golden |
| 财务(桌/移) | `finance.html` | `finance_screen.dart` | 同步 | ✅ **Phase2 重建**:时间范围 chips(真实过滤)+KPI 4 卡(stock summary 环比)+收支趋势柱状图(DsBarChart, /finance/trend)+应收应付汇总(/finance/summary)+流水表+往来抽屉+登记收支(补往来单位);**2026-07-14 六项优化**:流水表筛选新增「往来单位」下拉、时间范围统一改用通用自定义时间组件(原型 finance.html/m-finance.html 同步);fidelity a 4.036% / b 3.131% / c 4.340%(均 ≤ 阈值 6%),移动 golden 自比全绿 | ✅ fidelity | ✅ fidelity | | 财务(桌/移) | `finance.html` | `finance_screen.dart` | 同步 | ✅ **Phase2 重建**:时间范围 chips(真实过滤)+KPI 4 卡(stock summary 环比)+收支趋势柱状图(DsBarChart, /finance/trend)+应收应付汇总(/finance/summary)+流水表+往来抽屉+登记收支(补往来单位);**2026-07-14 六项优化**:流水表筛选新增「往来单位」下拉、时间范围统一改用通用自定义时间组件(原型 finance.html/m-finance.html 同步);fidelity a 4.036% / b 3.131% / c 4.340%(均 ≤ 阈值 6%),移动 golden 自比全绿**2026-07-14 追加**:应收/应付汇总卡头部新增「往来单位」搜索——本地即时过滤(`/finance/summary` 全量非分页,搜索口径=显示口径),桌面 `DsSearchBox``_secHead` trailing)/移动 应收·应付 分段头 `MSearchRow`(流水分段沿用既有 chip→sheet 单选,不重复),原型 finance.html `.sum-search` / m-finance.html 应收应付分段 `.m-search` 同步,finance 相关 6 个整屏 golden + m_finance 3 个 golden 已按新布局 `--update-goldens` | ✅ fidelity | ✅ fidelity |
| 设备管理(桌/移) | `devices.html` | `device_management_screen.dart` | 同步 | ✅ **Phase2 重建**:会话表(DsTable)+外设卡网格(custom_fields.peripherals 本地存档)+打印模板;fidelity 2.43.0%≤8% | ✅ fidelity | ✅ fidelity | | 设备管理(桌/移) | `devices.html` | `device_management_screen.dart` | 同步 | ✅ **Phase2 重建**:会话表(DsTable)+外设卡网格(custom_fields.peripherals 本地存档)+打印模板;fidelity 2.43.0%≤8% | ✅ fidelity | ✅ fidelity |
| 系统设置(桌/移) | `settings.html` | `settings_screen.dart` | 同步 | ✅ **Phase2 重建**;假「系统参数」已删;fidelity 1.72.3%≤8%**2026-07-10 授权管理面板迁出**(提升为独立一级屏 `license.html`),subnav 剩 门店信息/用户管理/偏好——**真实端已同步完成**(`settings_screen.dart` 子导航同步摘除授权管理项,golden 已按新态重生成,见授权管理两行) | ✅ fidelity | ✅ fidelity | | 系统设置(桌/移) | `settings.html` | `settings_screen.dart` | 同步 | ✅ **Phase2 重建**;假「系统参数」已删;fidelity 1.72.3%≤8%**2026-07-10 授权管理面板迁出**(提升为独立一级屏 `license.html`),subnav 剩 门店信息/用户管理/偏好——**真实端已同步完成**(`settings_screen.dart` 子导航同步摘除授权管理项,golden 已按新态重生成,见授权管理两行) | ✅ fidelity | ✅ fidelity |
| 用户管理(桌/移) | `users.html` | `users_screen.dart``/settings/users` | 同步 | ✅ **Phase2 新独立页**KPI 4 卡+搜索/角色筛选+rcard 弹窗,角色四级拉平;fidelity 1.32.1%≤8% | ✅ fidelity | ✅ fidelity | | 用户管理(桌/移) | `users.html` | `users_screen.dart``/settings/users` | 同步 | ✅ **Phase2 新独立页**KPI 4 卡+搜索/角色筛选+rcard 弹窗,角色四级拉平;fidelity 1.32.1%≤8% | ✅ fidelity | ✅ fidelity |
+13 -5
View File
@@ -28,6 +28,8 @@
.sec-head{display:flex; align-items:baseline; gap:10px; margin:0 0 12px;} .sec-head{display:flex; align-items:baseline; gap:10px; margin:0 0 12px;}
.sec-head h2{margin:0; font-size:var(--fs-h2); color:var(--heading); font-weight:700;} .sec-head h2{margin:0; font-size:var(--fs-h2); color:var(--heading); font-weight:700;}
.sec-head .sh-sub{font-size:var(--fs-xs); color:var(--muted);} .sec-head .sh-sub{font-size:var(--fs-xs); color:var(--muted);}
/* 汇总卡头部往来单位搜索(即时本地过滤,非 combo 单选) */
.sec-head .sum-search{margin-left:auto; width:220px; align-self:center;}
.block{margin-bottom:22px;} .block{margin-bottom:22px;}
/* ====== 新增原子:条形图 .barchart / .bar ====== */ /* ====== 新增原子:条形图 .barchart / .bar ====== */
@@ -108,7 +110,9 @@
<!-- 4. 应收/应付 汇总表 --> <!-- 4. 应收/应付 汇总表 -->
<div class="block"> <div class="block">
<div class="sec-head"><h2>应收 / 应付 汇总</h2><span class="sh-sub">按往来单位 · 点击行查看流水</span></div> <div class="sec-head"><h2>应收 / 应付 汇总</h2><span class="sh-sub">按往来单位 · 点击行查看流水</span>
<div class="searchbox sum-search"><svg viewBox="0 0 24 24"><use href="#i-search"/></svg><input id="sumSearch" placeholder="搜索往来单位…" oninput="sumKw=this.value; buildSummary();"></div>
</div>
<div class="table"><table> <div class="table"><table>
<thead><tr><th>往来单位</th><th class="num">应收</th><th class="num">应付</th><th class="num">净额</th><th class="act">操作</th></tr></thead> <thead><tr><th>往来单位</th><th class="num">应收</th><th class="num">应付</th><th class="num">净额</th><th class="act">操作</th></tr></thead>
<tbody id="sumBody"></tbody> <tbody id="sumBody"></tbody>
@@ -181,19 +185,23 @@ const SUMMARY=[
{name:'川渝糖酒集团',recv:0,pay:60400} {name:'川渝糖酒集团',recv:0,pay:60400}
]; ];
function yuan(n){ return '¥'+n.toLocaleString(); } function yuan(n){ return '¥'+n.toLocaleString(); }
// 汇总卡「往来单位」搜索:本地即时过滤(数据已全量加载,非服务端分页,搜索口径=显示口径)
let sumKw='';
function buildSummary(){ function buildSummary(){
document.getElementById('sumBody').innerHTML=SUMMARY.map((s,i)=>{ const q=sumKw.trim().toLowerCase();
const rows=SUMMARY.map((s,i)=>({...s,i})).filter(s=>!q||s.name.toLowerCase().includes(q));
document.getElementById('sumBody').innerHTML=rows.length?rows.map(s=>{
const net=s.recv-s.pay; const net=s.recv-s.pay;
const netCls=net>0?'net-pos':(net<0?'net-neg':''); const netCls=net>0?'net-pos':(net<0?'net-neg':'');
const netTxt=(net>0?'+':'')+yuan(net); const netTxt=(net>0?'+':'')+yuan(net);
return `<tr class="click" onclick="openPartner(${i})"> return `<tr class="click" onclick="openPartner(${s.i})">
<td class="pname">${s.name}</td> <td class="pname">${s.name}</td>
<td class="num">${s.recv?yuan(s.recv):'—'}</td> <td class="num">${s.recv?yuan(s.recv):'—'}</td>
<td class="num">${s.pay?yuan(s.pay):'—'}</td> <td class="num">${s.pay?yuan(s.pay):'—'}</td>
<td class="num ${netCls}">${netTxt}</td> <td class="num ${netCls}">${netTxt}</td>
<td class="act" onclick="event.stopPropagation()"><span class="link" onclick="openPartner(${i})">查看</span></td> <td class="act" onclick="event.stopPropagation()"><span class="link" onclick="openPartner(${s.i})">查看</span></td>
</tr>`; </tr>`;
}).join(''); }).join(''):`<tr><td colspan="5"><div class="empty">没有匹配的往来单位</div></td></tr>`;
} }
/* ---- 5. 收支流水 ---- */ /* ---- 5. 收支流水 ---- */
+13 -3
View File
@@ -38,6 +38,9 @@
<div class="k"><div class="kl">应付合计</div><div class="kv">¥9.6万</div><div class="kd warn">5 笔未结</div></div> <div class="k"><div class="kl">应付合计</div><div class="kv">¥9.6万</div><div class="kd warn">5 笔未结</div></div>
</div> </div>
<div class="seg" style="width:100%; margin-top:14px"><button class="on" style="flex:1" onclick="setTab('ar')">应收</button><button style="flex:1" onclick="setTab('ap')">应付</button><button style="flex:1" onclick="setTab('flow')">流水</button></div> <div class="seg" style="width:100%; margin-top:14px"><button class="on" style="flex:1" onclick="setTab('ar')">应收</button><button style="flex:1" onclick="setTab('ap')">应付</button><button style="flex:1" onclick="setTab('flow')">流水</button></div>
<!-- 应收/应付分段头部往来单位搜索(同口径本地过滤,仅 ar/ap 显示;流水分段
沿用上方时间行「往来单位」chip → sheet 单选,不重复) -->
<div class="m-search" id="sumSearchRow" style="margin-top:12px"><svg viewBox="0 0 24 24"><use href="#i-search"/></svg><input id="sumKwInput" placeholder="搜索往来单位…" oninput="sumKw=this.value; render();"></div>
<div class="m-section" id="cnt">应收账款</div> <div class="m-section" id="cnt">应收账款</div>
<div id="list"></div> <div id="list"></div>
</div> </div>
@@ -100,15 +103,22 @@ function drawPartnerOpts(kw){
} }
function setFlowParty(v){ flowParty=v; closeSheet(); render(); } function setFlowParty(v){ flowParty=v; closeSheet(); render(); }
// 应收/应付分段「往来单位」搜索:本地即时过滤(数据已全量加载,搜索口径=显示口径)
let sumKw='';
function render(){ function render(){
const cfg={ar:['应收账款',AR],ap:['应付账款',AP],flow:['资金流水',FLOW]}[tab]; const cfg={ar:['应收账款',AR],ap:['应付账款',AP],flow:['资金流水',FLOW]}[tab];
// 往来单位筛选仅作用于「流水」分段(对应真实端 financeListProvider.setPartner 只过滤流水表) // 往来单位筛选仅作用于「流水」分段(对应真实端 financeListProvider.setPartner 只过滤流水表)
const rows=tab==='flow'?cfg[1].filter(r=>flowParty==='全部'||r.party.split(' · ')[0]===flowParty):cfg[1]; let rows=tab==='flow'?cfg[1].filter(r=>flowParty==='全部'||r.party.split(' · ')[0]===flowParty):cfg[1];
document.getElementById('sumSearchRow').style.display=tab==='flow'?'none':'flex';
if(tab!=='flow'){
const q=sumKw.trim().toLowerCase();
if(q) rows=rows.filter(r=>r.party.toLowerCase().includes(q));
}
document.getElementById('cnt').textContent=`${cfg[0]} · 共 ${rows.length}`; document.getElementById('cnt').textContent=`${cfg[0]} · 共 ${rows.length}`;
document.getElementById('list').innerHTML=rows.map(r=>`<div class="m-card"><div class="m-row"> document.getElementById('list').innerHTML=rows.length?rows.map(r=>`<div class="m-card"><div class="m-row">
<div class="mc-main"><div class="mc-nm">${r.party}</div><div class="mc-sub">${r.date}</div></div> <div class="mc-main"><div class="mc-nm">${r.party}</div><div class="mc-sub">${r.date}</div></div>
<div class="mc-right"><span class="badge b-${r.status}">${r.status}</span><span class="mc-amt">${r.amt}</span></div> <div class="mc-right"><span class="badge b-${r.status}">${r.status}</span><span class="mc-amt">${r.amt}</span></div>
</div></div>`).join(''); </div></div>`).join(''):`<div class="m-empty"><svg viewBox="0 0 24 24"><use href="#i-box"/></svg>${tab==='flow'?'暂无数据':'没有匹配的往来单位'}</div>`;
document.getElementById('partyVal').textContent=flowParty==='全部'?'':flowParty; document.getElementById('partyVal').textContent=flowParty==='全部'?'':flowParty;
document.getElementById('chipParty').classList.toggle('on',flowParty!=='全部'); document.getElementById('chipParty').classList.toggle('on',flowParty!=='全部');
} }