feat(client): 财务应收应付汇总增加往来单位搜索(两端,原型同步)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@@ -27,6 +27,7 @@ import '../../widgets/ds/ds_bar_chart.dart';
|
||||
import '../../widgets/ds/ds_kpi.dart';
|
||||
import '../../widgets/ds/ds_table.dart';
|
||||
import '../../widgets/ds/m_kpi_grid.dart';
|
||||
import '../../widgets/ds/m_search_row.dart';
|
||||
import '../../widgets/ds/m_sheet.dart';
|
||||
import '../../widgets/ds/status_icon_map.dart';
|
||||
import '../../widgets/finance_entry_dialog.dart';
|
||||
@@ -56,6 +57,10 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
DateTimeRange? _customRange;
|
||||
// 窄屏三分段(原型 m-finance .seg:应收 / 应付 / 流水)
|
||||
int _mSeg = 0;
|
||||
// 应收/应付汇总卡「往来单位」搜索(本地即时过滤——financePartnerRowsProvider
|
||||
// 全量加载非分页,搜索口径=显示口径;桌面 DsSearchBox / 移动 MSearchRow 共用)
|
||||
final _summaryKwCtrl = TextEditingController();
|
||||
String _summaryKw = '';
|
||||
|
||||
static const _flowChips = ['全部', '应收', '应付', '收款', '付款'];
|
||||
static const _chipToType = {
|
||||
@@ -102,7 +107,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
case '自定义':
|
||||
final picked = await showDateRangeDropdown(
|
||||
anchorContext,
|
||||
initial: _customRange ??
|
||||
initial:
|
||||
_customRange ??
|
||||
DateTimeRange(start: DateTime(now.year, now.month, 1), end: now),
|
||||
);
|
||||
if (picked == null) return;
|
||||
@@ -121,6 +127,21 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
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() {
|
||||
ref.read(financeListProvider.notifier).reload();
|
||||
ref.invalidate(financePartnerRowsProvider);
|
||||
@@ -130,9 +151,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
}
|
||||
|
||||
// ── 金额格式 ──
|
||||
String _yuan(double v) => '¥${NumberFormat.decimalPattern().format(
|
||||
v == v.roundToDouble() ? v.round() : v,
|
||||
)}';
|
||||
String _yuan(double v) =>
|
||||
'¥${NumberFormat.decimalPattern().format(v == v.roundToDouble() ? v.round() : v)}';
|
||||
|
||||
/// KPI 大数:¥186万 / ¥48.6万(<1万显示整数元)。
|
||||
String _yuanWan(double v) {
|
||||
@@ -179,19 +199,32 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
child: DsSeg(
|
||||
items: const ['应收', '应付', '流水'],
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return Stack(children: [
|
||||
return Stack(
|
||||
children: [
|
||||
content,
|
||||
if (flowsAsync.isLoading)
|
||||
const Positioned.fill(child: DsLoadingScrim()),
|
||||
]);
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final content = Container(
|
||||
@@ -209,7 +242,17 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
_secHead(t, '收支趋势', '近 6 个月 · 单位 万元'),
|
||||
_trendChart(),
|
||||
const SizedBox(height: 22),
|
||||
_secHead(t, '应收 / 应付 汇总', '按往来单位 · 点击行查看流水'),
|
||||
_secHead(
|
||||
t,
|
||||
'应收 / 应付 汇总',
|
||||
'按往来单位 · 点击行查看流水',
|
||||
trailing: DsSearchBox(
|
||||
controller: _summaryKwCtrl,
|
||||
hint: '搜索往来单位',
|
||||
width: 220,
|
||||
onChanged: _setSummaryKw,
|
||||
),
|
||||
),
|
||||
_summaryTable(t, mobile),
|
||||
const SizedBox(height: 22),
|
||||
_secHead(t, '收支流水', _flowSub(flows)),
|
||||
@@ -218,10 +261,13 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
),
|
||||
),
|
||||
);
|
||||
return Stack(children: [
|
||||
return Stack(
|
||||
children: [
|
||||
content,
|
||||
if (flowsAsync.isLoading) const Positioned.fill(child: DsLoadingScrim()),
|
||||
]);
|
||||
if (flowsAsync.isLoading)
|
||||
const Positioned.fill(child: DsLoadingScrim()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── head ──
|
||||
@@ -231,32 +277,42 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text('财务管理',
|
||||
Text(
|
||||
'财务管理',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsH1,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: t.heading)),
|
||||
color: t.heading,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDims.sp3),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text('应收/应付概览 · $_rangeLabel',
|
||||
child: Text(
|
||||
'应收/应付概览 · $_rangeLabel',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!mobile) ...[
|
||||
DsButton('刷新', icon: LucideIcons.refreshCw, onPressed: _reload),
|
||||
const SizedBox(width: 10),
|
||||
DsButton('导出报表',
|
||||
icon: LucideIcons.download, onPressed: () => _export(flows)),
|
||||
DsButton(
|
||||
'导出报表',
|
||||
icon: LucideIcons.download,
|
||||
onPressed: () => _export(flows),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
WriteGuard(
|
||||
child: DsButton('登记收支',
|
||||
child: DsButton(
|
||||
'登记收支',
|
||||
icon: LucideIcons.plus,
|
||||
variant: DsBtnVariant.primary,
|
||||
onPressed: () => showFinanceEntryDialog(context)),
|
||||
onPressed: () => showFinanceEntryDialog(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -269,7 +325,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
filename: '财务流水',
|
||||
headers: ['日期', '类型', '往来单位', '金额', '关联单据', '状态', '备注'],
|
||||
rows: flows
|
||||
.map((r) => [
|
||||
.map(
|
||||
(r) => [
|
||||
(r.recordDate ?? '').split('T').first,
|
||||
r.typeLabel,
|
||||
r.partnerName ?? '',
|
||||
@@ -277,7 +334,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
r.refType != null ? '${r.docTitle}#${r.refId ?? ''}' : '',
|
||||
r.status == 'open' ? '未结清' : '已结清',
|
||||
r.remark ?? '',
|
||||
])
|
||||
],
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
@@ -289,18 +347,23 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
final partnerName = mobile
|
||||
? _partnerNameById(
|
||||
ref.watch(allPartnersProvider).valueOrNull?.data ?? const [],
|
||||
_partnerId)
|
||||
_partnerId,
|
||||
)
|
||||
: null;
|
||||
|
||||
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),
|
||||
for (final r in const ['本月', '本季']) ...[
|
||||
DsChip(
|
||||
label: r,
|
||||
selected: _range == r,
|
||||
caret: false,
|
||||
onTap: () => _applyRange(r, context)),
|
||||
onTap: () => _applyRange(r, context),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
Builder(
|
||||
@@ -309,7 +372,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
builder: (anchorCtx) => DsChip(
|
||||
label: '自定义',
|
||||
selected: _range == '自定义',
|
||||
onTap: () => _applyRange('自定义', anchorCtx)),
|
||||
onTap: () => _applyRange('自定义', anchorCtx),
|
||||
),
|
||||
),
|
||||
if (mobile) ...[
|
||||
const SizedBox(width: 10),
|
||||
@@ -317,7 +381,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
label: '往来单位',
|
||||
value: partnerName,
|
||||
onTap: _openPartnerSheet,
|
||||
onClear: () => _setPartner(null)),
|
||||
onClear: () => _setPartner(null),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -325,11 +390,13 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
children.addAll([
|
||||
const SizedBox(width: 10),
|
||||
WriteGuard(
|
||||
child: DsButton('登记',
|
||||
child: DsButton(
|
||||
'登记',
|
||||
small: true,
|
||||
icon: LucideIcons.plus,
|
||||
variant: DsBtnVariant.primary,
|
||||
onPressed: () => showFinanceEntryDialog(context)),
|
||||
onPressed: () => showFinanceEntryDialog(context),
|
||||
),
|
||||
),
|
||||
]);
|
||||
// 窄屏内容(label+4 chip+登记)常超宽:整行横向可滚(照 _flowTable 移动
|
||||
@@ -337,7 +404,9 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 18),
|
||||
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)。
|
||||
Future<void> _openPartnerSheet() async {
|
||||
final partners = ref.read(allPartnersProvider).valueOrNull?.data ?? const [];
|
||||
final partners =
|
||||
ref.read(allPartnersProvider).valueOrNull?.data ?? const [];
|
||||
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?>(
|
||||
context,
|
||||
title: '往来单位',
|
||||
builder: (_) => _PartnerSheetBody(options: options, selectedId: _partnerId),
|
||||
builder: (_) =>
|
||||
_PartnerSheetBody(options: options, selectedId: _partnerId),
|
||||
);
|
||||
if (sel == null || !mounted) return;
|
||||
// -1 = 清除选择(照 SearchableOptionField 的既有约定)
|
||||
@@ -430,12 +502,14 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: IntrinsicHeight(
|
||||
child: Row(children: [
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < cards.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: AppDims.sp3),
|
||||
SizedBox(width: 170, child: cards[i]),
|
||||
],
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -457,22 +531,30 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
}
|
||||
|
||||
/// 原型 .sec-head:h2 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),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(title,
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsH2,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: t.heading)),
|
||||
color: t.heading,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 1),
|
||||
child: Text(sub,
|
||||
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
|
||||
child: Text(
|
||||
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,
|
||||
);
|
||||
return trend.when(
|
||||
loading: () => placeholder(const SizedBox(
|
||||
loading: () => placeholder(
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))),
|
||||
error: (e, _) => placeholder(Text('趋势加载失败',
|
||||
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm))),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
error: (e, _) => placeholder(
|
||||
Text(
|
||||
'趋势加载失败',
|
||||
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm),
|
||||
),
|
||||
),
|
||||
data: (points) => DsBarChart(
|
||||
groups: [
|
||||
for (final p in points)
|
||||
@@ -522,35 +611,49 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
return async.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator())),
|
||||
error: (e, _) => Row(children: [
|
||||
Text('汇总加载失败',
|
||||
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => Row(
|
||||
children: [
|
||||
Text(
|
||||
'汇总加载失败',
|
||||
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
DsButton('重试',
|
||||
DsButton(
|
||||
'重试',
|
||||
small: true,
|
||||
onPressed: () => ref.invalidate(financePartnerRowsProvider)),
|
||||
]),
|
||||
data: (rows) => DsTable(
|
||||
onPressed: () => ref.invalidate(financePartnerRowsProvider),
|
||||
),
|
||||
],
|
||||
),
|
||||
data: (allRows) {
|
||||
final rows = _filterSummaryRows(allRows);
|
||||
return DsTable(
|
||||
shrinkWrap: true,
|
||||
emptyText: '暂无未结清的应收 / 应付',
|
||||
emptyText: _summaryKw.trim().isEmpty ? '暂无未结清的应收 / 应付' : '没有匹配的往来单位',
|
||||
mobileCards: rows
|
||||
.map((r) => MobileListCard(
|
||||
.map(
|
||||
(r) => MobileListCard(
|
||||
title: Text(r.name),
|
||||
trailing: Text(signedYuan(r.net),
|
||||
trailing: Text(
|
||||
signedYuan(r.net),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
color: r.net > 0
|
||||
? t.success
|
||||
: (r.net < 0 ? t.danger : t.text))),
|
||||
: (r.net < 0 ? t.danger : t.text),
|
||||
),
|
||||
),
|
||||
fields: [
|
||||
MobileCardField('应收', r.recv > 0 ? _yuan(r.recv) : '—'),
|
||||
MobileCardField('应付', r.pay > 0 ? _yuan(r.pay) : '—'),
|
||||
],
|
||||
onTap: () => showFinancePartnerDrawer(context, row: r),
|
||||
))
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
columns: const [
|
||||
DsColumn('name', '往来单位'),
|
||||
@@ -563,37 +666,54 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
return DsRow(
|
||||
onTap: () => showFinancePartnerDrawer(context, row: r),
|
||||
cells: [
|
||||
Text(r.name,
|
||||
style:
|
||||
TextStyle(fontWeight: FontWeight.w600, color: t.heading)),
|
||||
Text(r.recv > 0 ? _yuan(r.recv) : '—',
|
||||
Text(
|
||||
r.name,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: t.heading,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
r.recv > 0 ? _yuan(r.recv) : '—',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback)),
|
||||
Text(r.pay > 0 ? _yuan(r.pay) : '—',
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
r.pay > 0 ? _yuan(r.pay) : '—',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback)),
|
||||
Text(signedYuan(r.net),
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
signedYuan(r.net),
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: r.net > 0
|
||||
? t.success
|
||||
: (r.net < 0 ? t.danger : t.text))),
|
||||
: (r.net < 0 ? t.danger : t.text),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () => showFinancePartnerDrawer(context, row: r),
|
||||
child: Text('查看',
|
||||
child: Text(
|
||||
'查看',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: t.primary)),
|
||||
color: t.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -605,23 +725,33 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
|
||||
// ── 收支流水表 ──
|
||||
Widget _flowTable(
|
||||
dynamic t, bool mobile, List<FinanceRecord> flows, int total) {
|
||||
dynamic t,
|
||||
bool mobile,
|
||||
List<FinanceRecord> flows,
|
||||
int total,
|
||||
) {
|
||||
final chips = [
|
||||
for (final c in _flowChips) ...[
|
||||
DsChip(
|
||||
label: c,
|
||||
selected: _flowChip == c,
|
||||
caret: false,
|
||||
onTap: () => _setFlowChip(c)),
|
||||
onTap: () => _setFlowChip(c),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
];
|
||||
final toolbar = mobile
|
||||
? SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal, child: Row(children: chips))
|
||||
: Row(children: [
|
||||
Text('类型',
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(children: chips),
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
Text(
|
||||
'类型',
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
...chips,
|
||||
const SizedBox(width: 10),
|
||||
@@ -629,9 +759,13 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
// stock_out_list_screen.dart 客户 ComboSearchField 写法)。仅桌面
|
||||
// 工具栏放置,移动端对应筛选在 _timeRow 尾部的「往来单位」chip → sheet。
|
||||
ComboSearchField(
|
||||
options: (ref.watch(allPartnersProvider).valueOrNull?.data ??
|
||||
options:
|
||||
(ref.watch(allPartnersProvider).valueOrNull?.data ??
|
||||
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(),
|
||||
selectedId: _partnerId,
|
||||
hint: '往来单位',
|
||||
@@ -639,13 +773,17 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
onChanged: _setPartner,
|
||||
),
|
||||
const Spacer(),
|
||||
Text('共 $total 条',
|
||||
Text(
|
||||
'共 $total 条',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
color: t.muted,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback)),
|
||||
]);
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return DsTable(
|
||||
shrinkWrap: true,
|
||||
@@ -669,20 +807,27 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
],
|
||||
rows: flows.map((r) {
|
||||
final isIn = r.type == 'receipt' || r.type == 'receivable';
|
||||
return DsRow(cells: [
|
||||
Text((r.recordDate ?? '').split('T').first,
|
||||
return DsRow(
|
||||
cells: [
|
||||
Text(
|
||||
(r.recordDate ?? '').split('T').first,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback)),
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
),
|
||||
),
|
||||
financeTypeBadge(r),
|
||||
Text((r.partnerName?.isNotEmpty == true) ? r.partnerName! : '—'),
|
||||
// 原型 .qty:+/- 前缀、恒 heading 色(不上正负色)
|
||||
Text('${isIn ? '+' : '-'}${_yuan(r.amount.abs())}',
|
||||
Text(
|
||||
'${isIn ? '+' : '-'}${_yuan(r.amount.abs())}',
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: t.heading)),
|
||||
color: t.heading,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
r.refType != null && r.refId != null
|
||||
? '${r.docTitle} #${r.refId}'
|
||||
@@ -691,14 +836,19 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
fontSize: AppDims.fsSm,
|
||||
color: t.muted)),
|
||||
Text((r.remark?.isNotEmpty == true) ? r.remark! : '—',
|
||||
style: TextStyle(color: t.muted)),
|
||||
color: t.muted,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
(r.remark?.isNotEmpty == true) ? r.remark! : '—',
|
||||
style: TextStyle(color: t.muted),
|
||||
),
|
||||
r.status == 'open'
|
||||
? const DsBadge('未结清', tone: DsBadgeTone.warn)
|
||||
: const DsBadge('已结清', tone: DsBadgeTone.muted),
|
||||
_closeAction(t, r),
|
||||
]);
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
@@ -715,11 +865,14 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
return WriteGuard(
|
||||
child: InkWell(
|
||||
onTap: () => _confirmClose(r),
|
||||
child: Text('结清',
|
||||
child: Text(
|
||||
'结清',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: t.primary)),
|
||||
color: t.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -729,13 +882,17 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('确认结清'),
|
||||
content: Text('确认将「${r.partnerName ?? ''} ${r.typeLabel} '
|
||||
'${_yuan(r.amount.abs())}」标记为已结清?'),
|
||||
content: Text(
|
||||
'确认将「${r.partnerName ?? ''} ${r.typeLabel} '
|
||||
'${_yuan(r.amount.abs())}」标记为已结清?',
|
||||
),
|
||||
actions: [
|
||||
DsButton('取消', onPressed: () => Navigator.of(ctx).pop(false)),
|
||||
DsButton('结清',
|
||||
DsButton(
|
||||
'结清',
|
||||
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';
|
||||
return MobileListCard(
|
||||
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),
|
||||
trailing: financeTypeBadge(r),
|
||||
fields: [
|
||||
@@ -766,7 +924,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
MobileCardField('状态', r.status == 'open' ? '未结清' : '已结清'),
|
||||
if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark),
|
||||
],
|
||||
actions: (r.status == 'open' &&
|
||||
actions:
|
||||
(r.status == 'open' &&
|
||||
(r.type == 'receivable' || r.type == 'payable'))
|
||||
? [
|
||||
WriteGuard(
|
||||
@@ -799,7 +958,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
Widget _mKpis(dynamic t) {
|
||||
final outSum = ref.watch(stockOutSummaryProvider).valueOrNull;
|
||||
final inSum = ref.watch(stockInSummaryProvider).valueOrNull;
|
||||
final rows = ref.watch(financePartnerRowsProvider).valueOrNull ??
|
||||
final rows =
|
||||
ref.watch(financePartnerRowsProvider).valueOrNull ??
|
||||
const <PartnerFinanceRow>[];
|
||||
var recv = 0.0, pay = 0.0, openCount = 0;
|
||||
for (final r in rows) {
|
||||
@@ -813,7 +973,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
final (buyDelta, buyTone) = inSum != null
|
||||
? _momDelta(inSum.monthAmount, inSum.lastMonthAmount)
|
||||
: ('较上月 —', DsKpiDelta.neutral);
|
||||
return MKpiGrid(items: [
|
||||
return MKpiGrid(
|
||||
items: [
|
||||
MKpiItem(
|
||||
label: '本月收入',
|
||||
value: outSum != null ? _yuanWan(outSum.monthAmount) : '—',
|
||||
@@ -839,25 +1000,32 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
delta: '按到期及时结清',
|
||||
deltaTone: MKpiDeltaTone.normal,
|
||||
),
|
||||
]);
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 三分段列表体:m-section 计数标题 + 卡片流(应收/应付=往来汇总,流水=收支记录)。
|
||||
List<Widget> _mSegBody(dynamic t, List<FinanceRecord> flows) {
|
||||
Widget section(String title, int count) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(2, 16, 2, 8),
|
||||
child: Text('$title · 共 $count',
|
||||
child: Text(
|
||||
'$title · 共 $count',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: .4,
|
||||
color: t.muted)),
|
||||
color: t.muted,
|
||||
),
|
||||
),
|
||||
);
|
||||
Widget empty() => Padding(
|
||||
Widget empty([String msg = '暂无数据']) => Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text('暂无数据',
|
||||
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm))),
|
||||
child: Text(
|
||||
msg,
|
||||
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm),
|
||||
),
|
||||
),
|
||||
);
|
||||
List<Widget> stacked(List<Widget> cards) => [
|
||||
for (var i = 0; i < cards.length; i++) ...[
|
||||
@@ -880,26 +1048,31 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
loading: () => [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator())),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
error: (e, _) => [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text('汇总加载失败',
|
||||
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm))),
|
||||
child: Text(
|
||||
'汇总加载失败',
|
||||
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
data: (rows) {
|
||||
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 [
|
||||
section(ar ? '应收账款' : '应付账款', list.length),
|
||||
if (list.isEmpty)
|
||||
empty()
|
||||
empty(_summaryKw.trim().isEmpty ? '暂无数据' : '没有匹配的往来单位')
|
||||
else
|
||||
...stacked(
|
||||
[for (final r in list) _mPartnerCard(t, r, ar: ar)]),
|
||||
...stacked([for (final r in list) _mPartnerCard(t, r, ar: ar)]),
|
||||
];
|
||||
},
|
||||
);
|
||||
@@ -914,16 +1087,18 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
trailing: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
DsBadge('未结清',
|
||||
tone: DsBadgeTone.warn, icon: statusIcon('未结清')),
|
||||
DsBadge('未结清', tone: DsBadgeTone.warn, icon: statusIcon('未结清')),
|
||||
const SizedBox(height: 6),
|
||||
Text(_yuan(ar ? r.recv : r.pay),
|
||||
Text(
|
||||
_yuan(ar ? r.recv : r.pay),
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
fontWeight: FontWeight.w600,
|
||||
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';
|
||||
return MobileListCard(
|
||||
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),
|
||||
trailing: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
DsBadge(r.typeLabel,
|
||||
DsBadge(
|
||||
r.typeLabel,
|
||||
tone: switch (r.type) {
|
||||
'receipt' => DsBadgeTone.ok,
|
||||
'payment' => DsBadgeTone.danger,
|
||||
'receivable' => DsBadgeTone.warn,
|
||||
_ => DsBadgeTone.info,
|
||||
},
|
||||
icon: statusIcon(r.typeLabel)),
|
||||
icon: statusIcon(r.typeLabel),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text('${isIn ? '+' : '-'}${_yuan(r.amount.abs())}',
|
||||
Text(
|
||||
'${isIn ? '+' : '-'}${_yuan(r.amount.abs())}',
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
fontWeight: FontWeight.w600,
|
||||
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 bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) =>
|
||||
InkWell(
|
||||
}) => InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: t.borderSubtle)),
|
||||
),
|
||||
child: Row(children: [
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(name,
|
||||
child: Text(
|
||||
name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
fontWeight:
|
||||
selected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: selected ? t.primary : t.text)),
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: selected ? t.primary : t.text,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selected) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(LucideIcons.check, size: 18, color: t.primary),
|
||||
],
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1023,7 +1207,8 @@ class _PartnerSheetBodyState extends State<_PartnerSheetBody> {
|
||||
color: t.bg,
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
child: Row(children: [
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.search, size: 15, color: t.faint),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
@@ -1034,13 +1219,16 @@ class _PartnerSheetBodyState extends State<_PartnerSheetBody> {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: InputBorder.none,
|
||||
hintText: '搜索往来单位…',
|
||||
hintStyle:
|
||||
TextStyle(fontSize: AppDims.fsBody, color: t.faint),
|
||||
hintStyle: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
color: t.faint,
|
||||
),
|
||||
),
|
||||
onChanged: (v) => setState(() => _kw = v),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
optRow(
|
||||
@@ -1051,8 +1239,10 @@ class _PartnerSheetBodyState extends State<_PartnerSheetBody> {
|
||||
if (hits.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Text('无匹配结果',
|
||||
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)),
|
||||
child: Text(
|
||||
'无匹配结果',
|
||||
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm),
|
||||
),
|
||||
)
|
||||
else
|
||||
for (final o in hits)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// test/finance_summary_search_test.dart — 财务「应收/应付 汇总」往来单位搜索回归。
|
||||
//
|
||||
// financePartnerRowsProvider(GET /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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 108 KiB |
@@ -105,7 +105,7 @@ AppTokens 目前**仅颜色**。原型还驱动:
|
||||
| 基础数据(移动) | `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-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.4–3.0%≤8% | ✅ fidelity | ✅ fidelity |
|
||||
| 系统设置(桌/移) | `settings.html` | `settings_screen.dart` | 同步 | ✅ **Phase2 重建**;假「系统参数」已删;fidelity 1.7–2.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.3–2.1%≤8% | ✅ fidelity | ✅ fidelity |
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
.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 .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;}
|
||||
|
||||
/* ====== 新增原子:条形图 .barchart / .bar ====== */
|
||||
@@ -108,7 +110,9 @@
|
||||
|
||||
<!-- 4. 应收/应付 汇总表 -->
|
||||
<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>
|
||||
<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>
|
||||
@@ -181,19 +185,23 @@ const SUMMARY=[
|
||||
{name:'川渝糖酒集团',recv:0,pay:60400}
|
||||
];
|
||||
function yuan(n){ return '¥'+n.toLocaleString(); }
|
||||
// 汇总卡「往来单位」搜索:本地即时过滤(数据已全量加载,非服务端分页,搜索口径=显示口径)
|
||||
let sumKw='';
|
||||
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 netCls=net>0?'net-pos':(net<0?'net-neg':'');
|
||||
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="num">${s.recv?yuan(s.recv):'—'}</td>
|
||||
<td class="num">${s.pay?yuan(s.pay):'—'}</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>`;
|
||||
}).join('');
|
||||
}).join(''):`<tr><td colspan="5"><div class="empty">没有匹配的往来单位</div></td></tr>`;
|
||||
}
|
||||
|
||||
/* ---- 5. 收支流水 ---- */
|
||||
|
||||
@@ -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>
|
||||
<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 id="list"></div>
|
||||
</div>
|
||||
@@ -100,15 +103,22 @@ function drawPartnerOpts(kw){
|
||||
}
|
||||
function setFlowParty(v){ flowParty=v; closeSheet(); render(); }
|
||||
|
||||
// 应收/应付分段「往来单位」搜索:本地即时过滤(数据已全量加载,搜索口径=显示口径)
|
||||
let sumKw='';
|
||||
function render(){
|
||||
const cfg={ar:['应收账款',AR],ap:['应付账款',AP],flow:['资金流水',FLOW]}[tab];
|
||||
// 往来单位筛选仅作用于「流水」分段(对应真实端 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('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-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('chipParty').classList.toggle('on',flowParty!=='全部');
|
||||
}
|
||||
|
||||