Commit 6dd7aefa by luoqi

feat(sync/payment): 消费口径切实付 — amount=实付,应收降级 receivableAmount

业务定调(张雪反馈):一线关注真金白银,应收对免费套餐/团购客严重虚高
(胡新丽:应收¥1,270 实付¥0;张弦:应收¥77,795 实付¥57,887)。

- jvs-dw payment.yaml:amount settlement_money(实付);friday:net_receipts_this
- 新增 receivableAmount(应收)辅助字段:canonical schema 显式声明 + moneyFields
  登记同口径归一 + payment_record.content.receivable_cents
- discount-anchor 折扣率分母换 receivable_cents(旧事实 fallback amount_cents)
- 顺带修掉两个隐藏口径 bug:
  ① refund 一直是实收冲减 → 原 LTV 实为"应收收入−实收退款"混口径,现自洽
  ② 储值客户双计:充值(实付)计一次 + 扣卡消费应收又计一次,现扣卡单实付0不再重复

下游自动切换:profile.ltv(UI 累计消费)/ rfm.M 值 / lifecycle 消费档 → 实付口径。
金额守恒已对账(张弦 mode 表 17 行:实付57,887/应收77,795,无多通道重复计)。

上线步骤:部署后跑 reparse(rawPayload 完整保留源列,无需重拉 DW)→ persona 重算。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parent ae0b29c0
......@@ -21,9 +21,11 @@ field_mapping:
patientExternalId: patient_id
clinicId: organization_id
paidAt: created_gmt_at # 结算入库时间(jvs-dw 同款决策:billing_date 有预付失真)
# amount = receivable_this(应收):LTV=患者带来的业务价值,非现金流(会员卡扣费单不清零)
# FieldMapper.normalize 按 amount_unit=yuan 转 cents
amount: receivable_this
# 2026-07 业务定调(与 jvs-dw payment.yaml 同步):amount=实付(net_receipts_this),
# 与 refund_full/refund_item(net_receipts_this)同口径,LTV=实付收入−实付退款自洽;
# 应收降级 receivableAmount(折扣率分母/权益客识别)。
amount: net_receipts_this
receivableAmount: receivable_this
method: method # lookup 主导支付通道(现金/微信/刷卡/医保…原样入 fact)
doctorId: doctor_id
encounterExternalId: registration_id # 关联接诊
......
......@@ -22,13 +22,14 @@ field_mapping:
# - billing_date = 开单/收费动作时间(团购预付等失真)
# - created_date = 结算入库时间(接近实际治疗时点)
# - 100% created_date >= billing_date(9.4 万行 settlement_mode 实测,0 反向)
# ⭐ W3 末改 settlement_money → receivable_this(应收金额,不是实收)
# - settlement_money = 实际收到现金(会员卡扣费/套餐/团购/员工免费时 = 0)
# - receivable_this = 服务应收金额(反映患者带来的真实业务价值)
# - 例:王辉 5 次洁牙 settlement_money=0(扣会员卡)→ 改前 LTV=0 不合理
# receivable_this=¥760+350+200×N(应收)→ 改后真实反映"患者价值"
# - 跟 design.md 患者价值定义对齐:LTV = 患者带来的业务量,不是现金流
amount: receivable_this # FieldMapper.normalize 按 amount_unit=yuan 转 cents
# ⭐ 2026-07 业务定调:amount 回到 settlement_money(实付)—— 一线关注真金白银。
# 历史沿革:W3 末曾从 settlement_money 改为 receivable_this(应收,王辉会员卡扣费案例),
# 但一线反馈应收对免费套餐/团购客严重虚高(胡新丽:应收 ¥1,270 实付 ¥0),
# 且 refund 侧一直是 settlement_money(实收冲减)→ 原 LTV 实为"应收收入−实收退款"混口径。
# 现:amount=实付(与 refund 同口径,LTV 自洽);应收降级为 receivableAmount 辅助字段
# (折扣率分母 / 权益客识别:实付 0 + 应收>0 = 纯权益客)。
amount: settlement_money # 实付;FieldMapper.normalize 按 amount_unit=yuan 转 cents
receivableAmount: receivable_this # 应收(原价)— 折扣锚点分母/参考,同 yuan→cents
method: payment_channel # transforms.pick_first_nonzero 推断的主导支付通道
# 卡券名称(B.1.3 权益身份关键词匹配源:储值/儿牙/银行私行/商保/医保)
cardTypeName: card_type_name
......
......@@ -12,9 +12,10 @@ import type {
*
* 标签:历史最大折扣力度(最低折扣率)+ 对应结算日期/项目。销售推优惠的价格底线参考。
*
* 数据源:payment_record.content(摄入自结算):amount_cents=应收(原价)、discount_cents=折扣额(分,4渠道和)。
* ⚠️ DW 无 original_amount 字段;折扣率 = (应收−折扣)/应收 = 1 − discount_cents/amount_cents
* (discount_*_rate 实为折扣金额,非比率;储值卡 settlement_money=0 但 discount=0,口径稳)。
* 数据源:payment_record.content(摄入自结算):receivable_cents=应收(原价,2026-07 起;
* 旧事实 fallback amount_cents)、discount_cents=折扣额(分,4渠道和)。
* ⚠️ 折扣率 = (应收−折扣)/应收;amount_cents 已切实付,不能再当分母
* (discount_*_rate 实为折扣金额,非比率;储值卡实付=0 但 discount=0,口径稳)。
*
* 算法:遍历"真实治疗的部分折扣"结算,取最小折扣率(力度最大)+ 保留日期/项目。
* 无折扣记录 → 不打标签(业务:无锚点则换推增值权益而非直接降价)。
......@@ -39,7 +40,9 @@ export class DiscountAnchorFeatureExtractor implements FeatureExtractor {
for (const f of pays) {
const c = (f.content ?? {}) as Record<string, unknown>;
const amount = Number(c.amount_cents ?? 0); // 应收(原价)
// 应收(原价)= 折扣率分母。2026-07 起 amount_cents 切实付,应收在 receivable_cents;
// 旧事实(reparse 前)无 receivable_cents → fallback amount_cents(彼时 amount 即应收)。
const amount = Number(c.receivable_cents ?? c.amount_cents ?? 0);
const disc = Number(c.discount_cents ?? 0);
if (amount < DiscountAnchorFeatureExtractor.MIN_ORIGINAL_CENTS || disc <= 0) continue; // ≥¥500 真实治疗 + 有折扣
const ratio = Math.max(0, Math.min(1, (amount - disc) / amount));
......
......@@ -347,7 +347,11 @@ const OrderRecordContent = z
const PaymentRecordContent = z
.object({
payment_external_id: z.string().min(1),
/// 实付金额(分)— 2026-07 业务定调:真金白银口径(与 refund 同口径,LTV 自洽)
amount_cents: z.number().int().nonnegative(),
/// 应收金额(分,可空)— 原价参考:折扣率分母 / 权益客识别(实付0+应收>0=纯权益客);
/// 旧事实(reparse 前)无此字段,消费方需 fallback amount_cents(彼时 amount=应收)
receivable_cents: z.number().int().optional().nullable(),
channel: nullableString(),
/// 商业保险公司名(可空)— 非空 = 商保结算;喂 persona「权益身份」。保司名脏,归一在 feature 层做。
insurance_name: nullableString(),
......
......@@ -36,7 +36,10 @@ export class PaymentParser implements Parser {
return [];
}
const amount = Number(c.amount ?? 0); // cents
const amount = Number(c.amount ?? 0); // cents(实付;2026-07 业务定调,应收在 receivableAmount)
// 应收(原价,可空)— 折扣率分母 / 权益客识别(实付0+应收>0=纯权益客)
const receivableRaw = Number(c.receivableAmount ?? NaN);
const receivable = Number.isFinite(receivableRaw) ? Math.round(receivableRaw) : null;
const paidAt = c.paidAt ? new Date(c.paidAt as string) : null;
const encounterExternalId =
(c.encounterExternalId as string | undefined) ?? null;
......@@ -74,6 +77,7 @@ export class PaymentParser implements Parser {
content: {
payment_external_id: externalId,
amount_cents: amount,
receivable_cents: receivable,
channel: method,
insurance_name: insuranceName,
card_type_name: cardTypeName,
......
......@@ -233,7 +233,9 @@ export const PaymentCanonicalSchema = z
orderExternalId: z.string().optional().nullable(),
clinicId: z.string().min(1),
paidAt: isoDateTime,
amount: coerceInt, // cents
amount: coerceInt, // cents(实付;2026-07 业务定调,与 refund 同口径)
/// 应收(原价,cents,可空)— 折扣率分母 / 权益客识别;moneyFields 已登记同口径归一
receivableAmount: coerceInt.optional().nullable(),
currency: z.string().optional().default('CNY'),
method: z.string().optional().nullable(),
})
......@@ -587,7 +589,8 @@ export const CanonicalResourceMeta: Record<
payment: {
// 折扣三件套(诊所可控折扣额)跟 amount 同口径按 amountUnit 归一成分,
// 避免 parser 自己 ×100 硬编码 yuan(fen 宿主会双转)。passthrough 字段,normalizer 按 `k in out` 命中才转。
moneyFields: ['amount', 'discountDept', 'discountCompany', 'discountCard'],
// receivableAmount = 应收(原价)— 2026-07 业务定调:amount 切实付后,应收留作折扣率分母/参考。
moneyFields: ['amount', 'receivableAmount', 'discountDept', 'discountCompany', 'discountCard'],
moneyArrayFields: [],
datetimeFields: ['paidAt'],
booleanFields: [],
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment