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
File diff suppressed because it is too large Load Diff
@@ -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 自比 |
| 入库列表(桌/移) | `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.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 |
| 用户管理(桌/移) | `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 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. 收支流水 ---- */
+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>
<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!=='全部');
}