配套
prd/v0.10.md/preview/v0.10/dashboard-cashflow.html。决策编号续 v0.9(末为 112)→ 本版 114–120(113 已用于 v0.9.3 表单条件必填)。 总基调:最大化复用现成口径,最小化新增计算与 schema。三个核心数(ΔNW / 人赚 / 钱赚)已在KpiSnapshot,本版主要是「装配 + 展示 + 护栏」。
KpiSnapshot(factview/KpiSnapshot.java)已含:netWorthDelta(ΔNW)、lastNetInflow(人赚 · v0.5.3 · PMC 优先 · 已 viewCurrency 换算)、monthlyPnlAmount(钱赚 · 剔除外部现金流的纯投资变动 · 首期为 null)。FactViewServiceImpl.pmcFirstNetInflow(slice, periodId):人赚口径唯一真源 —— 该期 PMC(period_member_cashflow,成员两框收支,按家庭本位币存)有人填(filledMembers>0)则(Σincome − Σexpense) × baseToViewFactor(slice);否则回退 accountcash_flow(incomeBase − expenseBase,已 view 口径)。monthlyPnlAmount = ΔNW − 人赚,由构造保证 人赚 + 钱赚 = ΔNW(FactViewServiceImpl:357-362)。HouseholdCashflowService.findRecentAggregates(familyId, n)→ 每期FamilyPeriodAggregate{ filledMembers, totalIncome, totalExpense }(本位币 · 仅有 PMC 行的期)。DashboardController已装载kpis / cashflowFilled / cashflowTotal / savingsRate / avgMonthlyIncome / avgMonthlyExpense / waterfall(收支数据其实早在 model 里,只是没在 dashboard 模板渲染)。- 指标开关:
MetricPrefsService内FAMILY/ACCOUNT两个MetricDef(key,label,defaultOn,...)目录;metricPrefsService.enabled(prefs,"family")出可见集;admin 勾选页admin/metrics.html。 - 长文目录:
dashboard/index.html行 11 手工tocItems内联列表 →fragments/_toc.html渲染 →static/js/toc.js自动接[data-toc-nav]。section 需带id+scroll-margin-top:80px。 - 币种不变性护栏:
CurrencyInvarianceTest(v0.8 起)。
- 背景:拆解卡要 ΔNW / 人赚 / 钱赚 三数 + 本期毛收入/支出 + 完整度。
- 备选 A · 新建一套 CashflowService 重算:独立算净流入/投资损益。否 —— 与
factview口径分叉,易与 KPI「本月资产收益·剔除收入」打架(那个 KPI 减的就是lastNetInflow),且重复 PMC 优先/三角换算逻辑,违 DRY 与币种不变性纪律。 - 备选 B · 前端用已有 model 变量拼:模板里凑。否 —— 业务口径(毛收支、完整度三态、首期 null)塞进 Thymeleaf 不可测、易碎。
- 选定 C · 复用
KpiSnapshot三数 +(决策 115)新增「本期毛收支」一个方法,控制器装配成CashflowSplitView视图模型:ΔNW/人赚/钱赚直接取kpis,毛收支取新方法;控制器把它们 + 完整度三态判定打包成不可变视图记录,model.addAttribute("cashflowSplit", ...)。展示层只读不算。 - why 选 C:单一真源(factview),增量最小(KPI 早算好,只缺"毛收支拆分"一项),可单测视图模型组装。
- 背景:人赚是「收入 − 支出」的净额;卡片要把它拆成毛收入、毛支出两个数显示,且必须满足「毛收入 − 毛支出 = 人赚」,否则用户会发现对不上。
- 备选 A · 直接取
FamilyPeriodAggregate.totalIncome/totalExpense:findRecentAggregates现成。否 —— ① 那是本位币(PMC 原值),与卡片其余数(view)币种不一致;② 只覆盖填了 PMC 的期,不含 account cash_flow 回退口径,会和lastNetInflow的 PMC-优先-否则-回退口径不一致 → 毛收支减出来 ≠ 人赚。 - 备选 B · 新增
FactViewService.cashflowBreakdown(slice, periodId),复刻pmcFirstNetInflow的分支但返回两分量(均 ×baseToViewFactor换到 view;回退分支取periodIncome/periodExpense):与人赚同一棵判断树,只是返回{income, expense}而非income−expense。选定。 - why 选 B:口径与
lastNetInflow同源同分支 → 恒等「income − expense == lastNetInflow」天然成立(同一换算因子、同一回退规则);view 币种统一;新增面极小(一个方法,与既有私有助手并排)。 - 落点:方法加在
FactViewServiceImpl(它独占slice / baseToViewFactor / periodIncome / periodExpense / periodMemberCashflowMapper),经FactViewService接口暴露;pmcFirstNetInflow可改为调用它再相减,消除重复(可选重构,保守起见先并存 + 单测锁恒等)。
- 背景(评审暴露):人赚、钱赚各自可正可负,四象限
(+,+)/(+,−)/(−,+)/(−,−)。preview 初版的「比例堆叠条(和=100%)」只在 (+,+) 成立,混合/双负即崩。 - 备选 A · 比例堆叠条:原方案。否 —— 无法表达异号/双负,数学上不存在 100% 分割。
- 备选 B · Chart.js 浮动条 / 瀑布:floating bar 表 0→人赚→ΔNW 的瀑布。否 —— 为一个静态两步小图引入 Chart 实例 + datalabels 配置过重;响应式/手机高度难控;一个三行小条不值当。
- 选定 C · 纯 CSS 有符号双向条:零基线居中,每条
width = |值| ÷ max(|人赚|,|钱赚|,|ΔNW|) × 50%,正left:50%(forest)、负right:50%(rust);人赚/钱赚/ΔNW 各一条,ΔNW 条天然 = 前两条代数和。颜色只编码正负(对齐全站num-pos/num-neg),身份靠行标签。四象限统一一套画法、零特判。 - why 选 C:零依赖、
prefers-reduced-motion无关、手机不破;符号语义一眼可读(右增左减);与既有色彩系统一致。一句话文案按象限四选一(模板th:switch或控制器给splitNarrative)。 - 首期:钱赚 / ΔNW 为 null → 只渲染人赚条 + 文案「首期无对比,暂不拆投资损益」。
- 背景:FR-167 要近 6 期收入/支出/净流入,且含进行中的本月(这是它区别于
/reports已关账快照的全部意义)。 - 备选 A · 复用
/reports储蓄区的收支序列:。否 —— reports 锚「最近已关账账期」(v0.5.5 FR-94),故意不含本月,正是要补的缺口。 - 备选 B ·
findRecentAggregates直用:。否 —— 本位币、仅 PMC 期、与卡片人赚口径不一致(同决策 115 的理由)。 - 选定 C · 新增
FactViewService.cashflowSeries(slice, n):对最近 n 期(含当前 OPEN)逐期跑(决策 115 的)毛收支 + 人赚,view 币种;返回[{periodLabel, income, expense, netInflow, live(boolean)}],live标进行中期(前端最右浅色/描边)。 - why 选 C:与卡片同源(趋势的净流入线 == 卡片人赚,不会自相矛盾);view 币种统一(满足币种不变性);
live标位让"含本月"显式。 - 渲染:复用
_region.html既有 Chart.js +chartjs-plugin-datalabels(净资产图已在用),收入/支出柱 + 净流入线,datalabels 浮于柱顶/点上(承feedback_chart_datalabels);近 6 期固定(决策默认),不随顶部时间范围联动(简单稳、手机友好)。
- 背景:收支选填,半填会误导。
- 选定:完整度 = 该期 PMC
filledMembers÷ 家庭成员数(PMC 本就是成员级两框,最贴"谁填了收支")。三态:- 空态(filled=0):整卡换引导态 + 去填报 CTA;钱赚侧若 ΔNW 可算仍单独显示(投资损益不依赖收支填报,只依赖余额)。
- 半填态(0<filled<total):正常显示 + 琥珀 pill「已填 N/M · 收支可能不全」+「人赚是下限(仅含已填成员),钱赚不受影响」。
- 全填:完整展示。
- 备选(否)账户级 / 月份级完整度:账户级与 PMC(成员级)口径错位;月份级(
filledMonthRatio)是趋势用的,不是"本期谁填了"。
- 开关 → 决定不挂 metric-pref(开发中修订):原计划加 family 指标
cashflow_split默认开。落地时发现两点:① dashboard 的整段 section(趋势/分布/列表)本就不受指标开关控制,只有 KPI 豆腐块/头部受控,新 section 加门反而不一致;②MetricPrefsService.enabled的defaultOn仅在「整份 prefs 为 null」时生效(chosen==null ? defaultOn : chosen.contains(key))——已自定义过指标的老家庭(prod 家庭大概率如此)保存的集合里没有新 key,即使defaultOn=true也判为关,升级后反而看不到新卡(验收会以为坏了)。故 section 无条件渲染,与既有 section 一致、零兼容坑、零迁移。日后若确需隐藏开关,应改enabled的「新 key 默认纳入」语义(需给 prefs 加版本),不在本版做。 - 放置:新
<section id="dash-cashflow" style="scroll-margin-top:80px">,位于 5-KPI 带(+ 应急金 banner)之后、dash-trend之前。 - 目录同步(承
feedback_toc_sync):dashboard/index.html的tocItems在「资产洞察」与「净资产趋势」之间插{label:'人赚 vs 钱赚',href:'#dash-cashflow'},与 section 顺序一致。 - KPI 呼应:「本月资产收益 · 剔除收入」KPI 链接/文案指向本卡(被剔除的收入在此可见)。
- 背景:币种切换已三次出 bug(v0.2/v0.5/v0.8),纪律是「属性级护栏网住整类」(
feedback_currency_invariance)。本卡全是金额,必须纳管。 - 选定:扩
CurrencyInvarianceTest加断言(同一家庭、CNY/USD/HKD 三视图):人赚 + 钱赚 == ΔNW(各币种内恒等,容舍入);- 比例
人赚 / ΔNW、钱赚 / ΔNW三币种完全相等(比值币种无关); - 金额(人赚/钱赚/毛收入/毛支出)按 fx 因子
k缩放(USD/CNY≈0.14等,容 0.5%); - 「毛收入 − 毛支出 == 人赚」(决策 115 同源恒等)。
- 首期:单测覆盖
monthlyPnlAmount==null→ 视图模型标firstPeriod=true,模板只渲人赚、不抛 NPE。 - qa-run:加
v10-CASHFLOW-1(section + TOC 锚点同步)/-2(三态文案钩子:空态 CTA、半填琥珀 pill、首期、双向条)/-3(趋势 canvas + series + datalabels)/-4(控制器装配 + 钱赚=ΔNW−人赚 同源恒等),黑盒计数随加。
src/main/java/.../factview/FactViewService.java + cashflowBreakdown(slice,periodId) + cashflowSeries(slice,n) 接口
src/main/java/.../factview/FactViewServiceImpl.java 实现两方法(复用 baseToViewFactor/periodIncome/periodExpense/pmc 分支)
src/main/java/.../web/dashboard/CashflowSplitView.java 新视图模型 record(ΔNW/人赚/钱赚/毛收入/毛支出/filled/total/firstPeriod/narrative)
src/main/java/.../web/dashboard/DashboardController.java 装配 cashflowSplit + cashflowSeries → model(view 币种)
src/main/java/.../service/HouseholdCashflowService.java + filledMembersForPeriod(periodId)(完整度 N/M)
src/main/resources/templates/dashboard/_region.html 新 <section id="dash-cashflow">(双向条 + 子卡 + 三态 + 趋势 canvas)
src/main/resources/templates/dashboard/index.html tocItems 插 {label:'人赚 vs 钱赚',href:'#dash-cashflow'}
src/test/java/.../CurrencyInvarianceTest.java + 拆解恒等/比例不变/缩放/同源/首期 断言
src/test/java/.../factview/CashflowBreakdownTest.java 新单测:毛收支−=人赚、PMC优先/回退、view 换算、首期 null
scripts/qa-run.sh + v10-CASHFLOW-1..4 · 黑盒计数同步
prd/v0.10 · 本文件 · docs/qa-cases · CHANGELOG · README 文档同步(承 feedback_doc_sync)
- 零 schema:不新增/改表;无 metric-pref key、无迁移(见决策 119:section 无条件渲染,老家庭升级即见)。
- 纯新增展示 section + 两个只读 service 方法 + 一条指标目录项;不动既有 KPI 算法、不动
/reports快照语义。 - prod 升级 0 风险:无 DDL、无既有数据改动。
mvn test(含CashflowBreakdownTest+ 扩展后的CurrencyInvarianceTest)全绿。- beta 部署,走用户路径:首页 KPI 带下出现「人赚 vs 钱赚」卡 + 实时收支趋势;切 CNY/USD/HKD 三币种,ΔNW=人赚+钱赚 恒成立、比例不变、金额按 fx 缩放(承
feedback_verify_user_path)。 - 造数据验四象限 + 空/半/全三态 + 首期;手机端不破版。
/admin/metrics见「人赚 vs 钱赚拆解」可勾选、默认勾;关掉后 section 消失。- 长文目录(PC 栏 + 手机 sheet)新增「人赚 vs 钱赚」锚点可跳、scrollspy 高亮正确。
/reports储蓄区不受影响、仍已关账快照。bash scripts/qa-run.sh含v10-CASHFLOW-*全绿;README/landing/v07-CLEAN-2 黑盒计数同步。
- 背景:币种切换 bug 第 4 次复发([[feedback_currency_invariance]] 记录 v0.2/v0.5/v0.8)。这次根因是系统性的:
FactMapper的 fx 三角换算 joinfx_*.period_id = p.id,即每期用各自的历史汇率。单期金额各按当期汇率缩放正确,但 ΔNW=期末−期初 这种跨期差额,被减数用期末汇率、减数用期初汇率,两期汇率不同 → 多币种大额家庭上差额切币种不按单一汇率缩放(prod 实测偏 ~17%)。而 PMC 侧(人赚/月均支出)v0.8 已改用锚点单一汇率 → 代码两套口径并存,补一边爆另一边。 - 备选 A(否)· 继续逐指标补:每爆一个差额指标补一个。历史已证明治标不治本(4 次)。
- 备选 B(否)· 每期历史汇率 + 在多币种家庭隐藏差额类指标:牺牲功能,且违背用户反复确认的"币种=显示镜头"原则。
- 选定 C · 全局单一镜头:fx 三角换算的两行汇率(base→view、base→acct)改取锚点期(窗口内
period_start ≤ rangeEnd的最新一期)的行,对所有账期用同一组汇率。则金额、差额、比值切币种全部按同一汇率均匀缩放;净资产侧与 PMC 侧口径统一,根除"两套口径"矛盾。- 为何对:
fx(acct→view)=rate(base→view)/rate(base→acct)用同一期的两个 rate → 三角恒等;所有账户同期同镜头 →ΔNW_view = ΔNW_base × (base→view)精确成立。 - 代价:外币视图下净资产趋势 = 本位币趋势 × 常数(不再含历史汇率波动)——这是"显示镜头"的应有之义,用户已确认接受。
- 向后兼容:view==base 时 fx 因子恒为 1(与历史汇率无关)→ 本位币视图结果完全不变;仅多币种外币视图的差额类数值变正确。零 schema。
- 为何对:
- 实现:
FactMapper.xml的fx_bv/fx_bajoin 条件由= p.id改为子查询「取 ≤ rangeEnd 的最新一期 fx_rate」。其余不动。 - 防回归(关键教训):
CurrencyInvarianceTest是单元 + 单一 mock 汇率(所有期同 k),把"多期不同历史汇率"这个真实场景抹平了,所以永远绿、测不出 SQL 层的每期汇率 bug。真正的护栏必须是端到端:qa-run v10-CCY-LENS-1/2登录后打真/dashboard?currency=CNY/USD、跑真 SQL + 多期不同汇率,断言净资产趋势/收支趋势各期按同一汇率均匀缩放。需 family 有非 base 账户 + 多期变动汇率(beta diwa 家满足)。
- 问题:
daysLeft ≤ leadDays→ 窗口[0, leadDays]共 leadDays+1 天,多发 1 天。 - 选定:
daysLeft < leadDays→[0, leadDays-1]共 leadDays 天(截止日当天 + 前 leadDays-1 天)。抽inReminderWindow(daysLeft,leadDays)纯静态方法,ReportReminderWindowTest单测锁死。 - 备选(否) 改成「截止日不发、只发前 leadDays 天」:与用户预期(含截止日当天)不符——用户明确说 lead=2 要发 6.29+6.30。
- 背景:用户在 prod 主页看到三个 −3% 上下的"收益"挨着、口径不一致:① 环比 MoM −3.00%(净资产总变化,含人赚)② AI 真实收益 −3.20%(扣CPI)③ 本月资产收益 −3.54%(纯投资,剔人赚)。已用 prod 数据复算闭合:③=①−人赚/期初、②=①扣CPI。问题不在算错,在"扣CPI后的真实收益"被当标准收益摆进洞察,误导。
- 选定:洞察/体检/财富水位的收益数一律用名义(
nominalGrowthPct,净资产名义增长);通胀/社会财富只作图上参照线(CPI 购买力线 / M2 社会财富线保留——用户要的是"感受自家收益率 vs CPI 的对比",而不是替他从收益里扣)。 - 备选(否) 保留"真实收益(扣CPI)"作主收益:违用户明确口径("标准收益不该特地扣通胀");三个负数挨着继续误导。
- 备选(否) 连 CPI/M2 对比线一起删:用户明确要保留对比线(财富水位的核心体验)。
- 实现:
WaterLevelService.WaterLevel加nominalGrowthPct(compute 已算,直接暴露);AssetInsight.LowRate加nominalGrowthPct;3 处模板换显示 + 改标签;realReturnPct/relativeReturnPct(剔M2)计算保留(M2 非通胀,相对社会收益仍展示)。零 schema、本位币视图不变(向后兼容)。qa-run v10-NOMINAL-1防回归。
- 背景:账户级指标目录 15 项,但全站唯一消费方是 dashboard 账户列表,且只渲染 6 列;
AccountPerformance其实 v0.8 已把大部分指标算出来(注释"扩成账户级指标全集,端到 dashboard 列表+手机卡片"),只是模板列没补完 → 用户勾了 9 项不显示。 - 选定(用户定:补齐列):① PC 表补有数据的 6 列(net_principal/period_return(latestPnl)/return_base/max_drawdown/months_held/plan_actual);② 无 per-account 数据的 twr(仅家庭级)/yoy/risk 从目录移除(不超卖,日后接数据再加);③ 列多的展示——账户名列
position:sticky左固定,指标列超过最佳展示数 N=7 时容器overflow-x-auto横滑;④ 内联指标筛选 chips(data-mchip,点选即时切data-mcol列显隐,纯前端 + localStorage 记住隐藏集,htmx:load重绑;默认=管理页「指标设置·账户级」勾选集 = 全集来源)。 - 备选(否) 独立账户簿大表 / 收敛目录:用户选"补齐列";手机端卡片本就可展开看全集,不改。
- 向后兼容:
enabled()由 catalog 驱动,已存 prefs 里的 twr 等失效 key 自动忽略,无需迁移;零 schema。MetricPrefsServiceTest把 twr 样例换 max_drawdown。qa-run v10-ACCT-COLS-1防回归。
- 背景(用户分析师视角点出):账户
xirr = annualizedOrCumulative——满 12 期年化、不足 12 期为累计(刻意不外推,避免短期年化噪声);但预实/reportsvs基准/家庭vs基准都拿它减年化预期/基准。短账户「几个月累计 2%」被错判「跑输年化 8%(−6pp)」,而年化看 ≈27% 其实跑赢。checkup 账户页早已 gate「满 12 期才给年化对照」,但 dashboard/reports 没挡 → 不一致 + 错。 - 选定(用户定:缩放到窗口):实际用「持有窗口累计回报」,预期按持有月数缩放到同窗口
(1+年化)^(月数/12)−1,like-for-like 比;不外推年化。跑赢/输阈值同窗口缩放(年化 ±2% 折到 N 月)。三处统一BenchmarkAggregator.windowDiffPercentPoints/beatStatusWindow;实际累计 = 账户cumPnl/净投入、家庭累计PnL/累计净投入。 - 备选(否) gate≥12 才显(向 checkup 看齐):正确但短账户一年内不显;用户要早期也能比。
- 备选(否) 外推年化(月度×12):放大短期噪声,违既有「不外推」原则。
- 标签诚实化:
<12 期为累计的列动态标注——账户 收益率/本位币年化 per-cell「累」角标、家庭 资产年化→「资产累计」、预实标「近 N 月」。 - 梳理留存(无需改):本月资产收益(本月)、净资产环比/同比(月/年)、金额类(收入/支出/人赚/钱赚/累计损益/本期损益)口径本就清楚。
- 纯计算口径 + 展示,零 schema;
BenchmarkAggregatorTest+3 锁死(含用户的 2%/8% 例子)。