"""C11 构造合同复算。Python 3.10+,仅标准库;直接运行写同目录 JSON。 金额输入用人民币元十进制字符串;内部 Decimal;每笔赔款四舍五入至分。 天数用整数,日期为公历;计入院日、不计出院日。此处不判断真实保险责任。 """ from datetime import date, timedelta from decimal import Decimal, ROUND_HALF_UP from pathlib import Path import json D = Decimal ZERO = D('0') def money(v): return format(D(v).quantize(D('.01'), rounding=ROUND_HALF_UP), '.2f') def require(v, field): if v is None: raise ValueError(field + '未知,拒绝计算') return v def amount(v): x = D(require(v, '金额')) if x < 0: raise ValueError('金额不能为负') return x def table(headers, rows): return {'columns': headers, 'rows': rows} def annual(events, deductible='10000', ratio='.8', cap='100000'): remaining, limit = amount(deductible), amount(cap) rows = [] for when, value in events: e = amount(value) begin = remaining used = min(e, remaining) remaining -= used base = e - used pay = min(D(money(base * D(ratio))), limit) limit -= pay rows.append([when, money(e), money(begin), money(used), money(base), money(pay), money(remaining)]) assert begin == used + remaining and e == used + base return rows def offset(unit_can=True, unit='4000', other='2000'): require(unit_can, '单位补偿抵免赔资格') e, social, u, o, deductible = map(D, ['30000', '12000', unit, other, '10000']) remaining_cost = e-social-u-o if remaining_cost < 0: raise ValueError('他方补偿大于对应费用') credit = min(deductible, (u if unit_can else ZERO)+o) after_credit = deductible-credit self_used = min(remaining_cost, after_credit) base = remaining_cost-self_used pay = min(base*D('.8'), remaining_cost) assert deductible == credit+self_used+max(ZERO, after_credit-self_used) return [money(x) for x in [e, social, u, o, remaining_cost, credit, after_credit, self_used, base, pay]] def bed(values, ceiling='500'): cap=amount(ceiling) rows=[] for i,v in enumerate(values): e=amount(v); allowed=min(e,cap) rows.append([(date(2026,8,1)+timedelta(days=i)).isoformat(), money(e), money(cap), money(allowed), money(e-allowed)]) return rows def visit(values, annual_limit='2200', per='600', ratio='.8'): remain=amount(annual_limit); single=amount(per); rows=[] for i,v in enumerate(values): e=amount(v); calculated=D(money(e*D(ratio))); local=min(calculated,single) begin=remain; pay=min(local,begin); remain-=pay rows.append([(date(2026,1,5)+timedelta(days=30*i)).isoformat(),money(e),money(calculated),money(single),money(calculated-local),money(local),money(begin),money(local-pay),money(pay),money(remain)]) assert calculated == calculated-local + local-pay + pay return rows def pools(events, mutually_exclusive=False, total_limit='16000'): if mutually_exclusive: raise ValueError('同一事件互斥责任不得并列提交') remain={'住院':D('12000'),'门诊手术':D('7000'),'康复':D('5000')} total=amount(total_limit); rows=[] for when,kind,v in events: require(kind,'责任归属') if kind not in remain: raise ValueError('未知责任') e=amount(v); child=remain[kind]; begin=total after_child=min(e,child); pay=min(after_child,total) remain[kind]-=pay;total-=pay binding='费用' if pay==e else ('总池' if total==0 and begin=0 and all(x>=0 for x in remain.values()) return rows def path(social,ratio,cap='15000'): eligible=D('28000'); s=amount(social); left=eligible-s if left<0:raise ValueError('补偿超过合格费用') used=min(left,D('3000')); base=left-used; pre=D(money(base*D(ratio))) pay=min(pre,amount(cap)); self_cost=D('30000')-s-pay assert self_cost+s+pay==D('30000') return [money(x) for x in [D('30000'),D('2000'),eligible,s,left,used,base]]+[str(D(ratio))]+[money(x) for x in [pre,amount(cap),pay,self_cost]] def stay_days(start,end): a=date.fromisoformat(require(start,'入院日期')); b=date.fromisoformat(require(end,'出院日期')) if be for x in covered),'年度期初余额':budget,'给付天数':len(paid),'年度期末余额':budget-len(paid),'末次给付日':paid[-1].isoformat() if paid else None,'给付金额':money(len(paid)*100),'日历':calendar} def allowance(events,deductible=3,per=20,annual_limit=25,daily='120',annual_deductible=False): remain=annual_limit; deductible_left=deductible; rows=[] for start,end in events: raw=stay_days(start,end);d=min(raw,deductible_left if annual_deductible else deductible) if annual_deductible:deductible_left-=d after=raw-d;local=min(after,per);begin=remain;pay=min(local,remain);remain-=pay rows.append([start,end,raw,d,after,per,local,begin,pay,remain,money(D(pay)*D(daily)),raw-pay]) assert raw == d+(after-local)+(local-pay)+pay return rows def regroup(events,threshold=30,ignore_cause=False,annual_limit=100): rows=[]; group=0; previous=None; remaining=0; group_paid=0; annual_remaining=annual_limit for start,end,same in events: days=stay_days(start,end);a=date.fromisoformat(start) gap=None if previous is None else (a-previous).days if previous is not None:require(same,'同因标志') merge=previous is not None and gap<=threshold and (same or ignore_cause) if not merge:group+=1;remaining=5;group_paid=0 begin=remaining;used=min(days,remaining);remaining-=used after=days-used; annual_begin=annual_remaining pay=min(after,20-group_paid,annual_remaining);group_paid+=pay;annual_remaining-=pay rows.append([start,end,days,gap,same,'G'+str(group),begin,used,remaining,group_paid,pay,money(pay*100),annual_begin,annual_remaining]) previous=date.fromisoformat(end) return rows def allocation(events, mode='发生日'): """明确指定费用归年;未知规则拒绝,不能按理赔日期替代事实。""" if mode not in ['发生日','全新年','全旧年']: raise ValueError('责任年度归属规则未知,拒绝计算') states={'旧年':[D('2000'),D('4000')],'新年':[D('5000'),D('10000')]} rows=[] for when,v in events: require(when,'费用日期') date.fromisoformat(when) year=('旧年' if when<'2026-07-01' else '新年') if mode=='发生日' else ('新年' if mode=='全新年' else '旧年') d,cap=states[year];e=amount(v);u=min(d,e);base=e-u;pay=min(base,cap);states[year]=[d-u,cap-pay] rows.append([when,money(e),year,money(d),money(u),money(base),money(cap),money(pay),money(d-u),money(cap-pay)]) assert d==u+states[year][0] and cap==pay+states[year][1] return rows def rejected(fn,*args,**kwargs): try:fn(*args,**kwargs) except ValueError as exc:return str(exc) raise AssertionError('未知值未拦截') def build(): out={} def record(n,contract,inputs,tables,checks): out['DJ3-'+str(n)]={'构造合同':contract,'输入':inputs,'单位与精度':'金额人民币元,Decimal十进制,每笔四舍五入至分;天数整数。日期计入院日、不计出院日。所有模型独立,不拼接真实权利。','表格':tables,'边界自检':checks} h111=['发生日期','合格净费用','期初免赔余额','当笔抵免赔','比例前基数','支付','期末免赔余额'] ev111=[('2026-02-10','4200'),('2026-06-12','7800'),('2026-10-10','6500')] r111=annual(ev111) wrong111=[annual([event])[0] for event in ev111] record(111,'2026-01-01至12-31;一般住院合格净费用累计抵10000年度免赔,80%支付,年度赔款上限100000、此前未用;无其他抵扣,无跨年、子限额。',ev111,{'年度台账':table(h111,r111),'每次重扣反例':table(h111,wrong111)}, {'零费用':annual([('2026-01-01','0')]),'恰到免赔':annual([('2026-01-01','10000')]),'缺金额':rejected(annual,[('2026-01-01',None)])}) rows=[];states={'旧年':[D('2000'),D('4000')],'新年':[D('5000'),D('10000')]} inp112=[('2026-06-29','3000'),('2026-06-30','2000'),('2026-07-01','4000'),('2026-07-02','5000')] for when,v in inp112: year='旧年' if when<'2026-07-01' else '新年';d,cap=states[year];e=D(v);u=min(d,e);base=e-u;pay=min(base,cap);states[year]=[d-u,cap-pay] rows.append([when,money(e),year,money(d),money(u),money(base),money(cap),money(pay),money(d-u),money(cap-pay)]) h112=['发生日','费用','责任年','期初免赔','抵免赔','基数','期初赔款限额','支付','期末免赔','期末赔款限额'] assert rows==allocation(inp112) wrong_new=allocation(inp112,'全新年');wrong_old=allocation(inp112,'全旧年') summaries=[] for label,data in [('按发生日',rows),('全按到账年',wrong_new),('全按入院年',wrong_old)]: costs=[sum(D(x[1]) for x in data if x[2]==year) for year in ['旧年','新年']] pays=[sum(D(x[7]) for x in data if x[2]==year) for year in ['旧年','新年']] summaries.append([label]+[money(x) for x in costs+pays+[sum(pays)]]) avg=allocation([('2026-06-30','7000'),('2026-07-01','7000')]) record(112,'旧年2025-07-01至2026-06-30;新年2026-07-01至2027-06-30;按发生日归年,每年总免赔5000。旧年已抵3000、赔款限额剩4000,新年未抵、限额剩10000;100%支付。跨期住院资格假设已分别确认,不自动套旧单延续。',{'明细':inp112,'入院':'2026-06-29','出院':'2026-07-03','提交日':'2026-07-20','到账日':'2026-08-02'}, {'归年台账':table(h112,rows),'错误归档':table(['方法','旧年费用','新年费用','旧年支付','新年支付','合计'],summaries),'全按到账年逐笔':table(h112,wrong_new),'全按入院年逐笔':table(h112,wrong_old),'按日数均分错法':table(h112,avg)},{'周年当日':allocation([('2026-07-01','1')]),'缺归属':rejected(allocation,inp112,None),'零费用':allocation([('2026-06-30','0'),('2026-07-01','0')])}) record(113,'2026年度合格费用30000;医保补偿减费用但不抵免赔,单位与他保补偿均减费用且可抵年免赔10000;抵扣不超免赔余额。剩余自负再抵免赔,基数80%支付,赔款限额100000此前未用。',{'日期':'2026-05-10','费用':'30000','医保':'12000','单位':'4000','他保':'2000'}, {'资格矩阵':table(['资金项','金额','减待补费用','可抵免赔'],[['合格费用','30000.00','原始费用','非他方补偿'],['医保','12000.00','是','否'],['单位','4000.00','是','是'],['他保','2000.00','是','是']]),'两路径':table(['路径','合格费用','医保','单位','他保','未补费用','第三方抵免赔','剩余免赔','自负抵免赔','基数','支付'],[['顺序法']+offset(),['单位不可抵']+offset(False)]),'等价核对':table(['方法','医保后费用','原免赔','比例前基数','支付'],[['限第三方抵额未溢出的化简法','18000.00','10000.00','8000.00','6400.00']])},{'第三方零额':offset(unit='0',other='0'),'第三方超免赔':offset(unit='11000',other='2000'),'缺资格':rejected(require,None,'补偿抵扣资格')}) items=[('诊疗','8000',True,True),('药品','5000',True,False),('器械','4000',False,True),('膳食','1000',False,False)] rows=[] for name,v,ca,cb in items: cost=D(v);pa=cost*D('.8') if ca else ZERO;left=cost-pa;pb=left if cb else ZERO assert pa+pb<=cost rows.append([name,money(cost),'是' if ca else '否','是' if cb else '否',money(pa),money(left),money(pb),money(left-pb)]) record(114,'2026年度两份构造费用补偿合同;四项费用已排除医保/其他补偿且确认合理必要。A只保诊疗药品、80%;B只保诊疗器械、100%;均零免赔、独立年赔款限额100000未用,无逐次限额;确认顺序A后B,B只对本项残余支付。',{'日期':'2026-04-12','项目':items}, {'费用覆盖残余':table(['项目','成本','A覆盖','B覆盖','A已赔','A后残余','B后赔','最终自负'],rows),'总量错误法':table(['方法','A后全部残余','B支付','误多付'],[['忽略B项目范围','7600.00','7600.00','2000.00'],['逐项核对','7600.00','5600.00','0.00']])},{'零项':'成本0时两家均0','仅B覆盖':'器械4000仅B付4000','缺先赔分项':rejected(require,None,'A已赔分项')}) vals115=['200','800','600','0','350','900','450'];rows=bed(vals115);cost=sum(map(D,vals115));allowed=sum(D(x[3]) for x in rows) record(115,'2026-08-01至08-07每日合格床位费;每日费用上限500,不调剂;100%支付、零免赔、年度赔款限额100000未用,无其他补偿。普通床位/特需资格预先确认;不含陪床和ICU。',vals115,{'逐日裁剪':table(['日期','原额','每日限额','允许额及支付','超额自负'],rows),'平均法对照':table(['日集合','原额合计','逐日支付','平均后统一裁剪','误多付'],[['七日',money(cost),money(allowed),money(min(cost,D(7)*500)),money(min(cost,D(7)*500)-allowed)],['删去最后一日',money(cost-D('450')),money(allowed-D('450')),money(min(cost-D('450'),D(6)*500)),'800.00']])},{'零费日':bed(['0']),'恰等日限':bed(['500']),'少住一天':bed(vals115[:-1])}) vals116=['300','900','700','1200','600','800'];r116=visit(vals116) record(116,'2026年度六次门诊;每次定义为同日同院同科,六日期不同。输入为已确认责任内净费用;零免赔、无其他补偿;先乘80%,再以单次赔款600截顶,再按年度赔款2200余额支付。600针对本构造门诊全部合格费用,不取自真实产品子篮。',vals116,{'双限额台账':table(['日期','净费用','比例计算额','单次赔款限额','单次截去','单次后条件额','年度期初','年度截去','支付','年度期末'],r116),'反例':table(['方法','支付合计'],[['正确两层','2200.00'],['只单次不管年度','3080.00'],['只年度不管单次','2200.00']])},{'零次':visit(['0']),'单次恰到600':visit(['750']),'年度归零后':visit(['800'],'0')}) ev117=[('2026-01-10','住院','8000'),('2026-03-10','门诊手术','5000'),('2026-05-10','康复','2000'),('2026-07-10','住院','6000'),('2026-09-10','门诊手术','3000')] r117=pools(ev117) record(117,'2026年度三种互不重叠责任:住院12000、门诊手术7000、康复5000,各子限额均含于共同赔款16000总池;100%、零免赔、无其他补偿/逐次限额;每笔仅归一个责任,按事件顺序支付且同时扣子池和总池。',ev117,{'池定义':table(['池','初始赔款限额','关系'],[['住院','12000.00','含于总池'],['门诊手术','7000.00','含于总池'],['康复','5000.00','含于总池'],['共用总池','16000.00','三子池实际支付合计受此限制']]),'五笔池状态':table(['日期','责任','费用','所属子池期初','总池期初','子池截去','总池截去','支付','住院期末','手术期末','康复期末','总池期末','绑定约束'],r117),'错法':table(['方法','总支付','超共用总额'],[['不设总池','21000.00','5000.00'],['正确同时扣池','16000.00','0.00']])},{'零事件':pools([('2026-01-01','住院','0')]),'缺责任':rejected(pools,[('2026-01-01',None,'100')]),'互斥并报':rejected(pools,ev117,mutually_exclusive=True)}) record(118,'2026-09-10同一账单30000,其中2000不合格、28000合格。以有医保身份投保,已结算路径医保14000、100%;未结算路径医保0、60%,无例外。免赔余额3000,赔款限额余额15000;无其他补偿,先净费用抵免赔再乘比例截限。两路径为互斥情景。',{'账单':'30000','不合格':'2000','已结算医保':'14000','未结算医保':'0'}, {'双路径':table(['路径','总费','不合格','合格','医保补偿','未补合格','免赔使用','比例前基数','比例','比例后','限额余额','商保支付','个人自负'],[['已结算']+path('14000','1'),['未结算']+path('0','.6')]),'只改比例反例':table(['未结算方法','基数','60%所得','相对正确差额'],[['沿用已结算基数','11000.00','6600.00','-8400.00'],['重建净费用','25000.00','15000.00','0.00']])},{'年度限额零':path('0','.6','0'),'缺结算状态':rejected(require,None,'医保结算状态'),'例外未知':'拒绝选择100%或60%'}) inputs119=[['甲','2026-06-25','2026-07-10','2026-06-30',8],['乙','2026-08-28','2026-10-10','2026-08-31',20],['丙','2026-12-31','2027-02-02','2026-12-31',39]] results={x[0]:extension(*x[1:]) for x in inputs119};rows=[];segments=[] for name,a,b,e,used in inputs119: r=results[name];rows.append([name,a,b,e,r['延续最晚日'],r['截止日'],r['期内覆盖天数'],r['期后覆盖天数'],r['覆盖天数'],used,r['年度期初余额'],r['给付天数'],r['年度期末余额'],r['末次给付日'],r['给付金额']]) cal=r['日历'];start=0 for i in range(1,len(cal)+1): if i==len(cal) or cal[i][1:]!=cal[start][1:]: segments.append([name,cal[start][0],cal[i-1][0],i-start,cal[start][1],cal[start][2],cal[start][3]]);start=i record(119,'三名不同被保险人的三份独立构造合同,均从各自到期日前一年次日生效;住院在期内开始且资格已确认。到期次日起最多延续30个日历日、含第30日;不含出院日。无免赔/单次日限,年度给付日限40、日额100;给付按最早覆盖日占原合同余额,新单不自动承接。',inputs119,{'覆盖与额度':table(['案例','入院','出院不含','合同期末','延续最晚日含','责任截止日含','期内日','期后日','覆盖日','此前已用','期初余日','给付日','期末余日','最后给付日','给付元'],rows),'日历分段':table(['案例','首日含','末日含','日数','阶段','责任覆盖','额度支持给付'],segments),'完整逐日日历':results},{'到期次日入院':extension('2026-07-01','2026-07-03','2026-06-30',0),'到期日出院':extension('2026-06-29','2026-06-30','2026-06-30',0),'零余日':extension('2026-06-29','2026-07-02','2026-06-30',40)}) ev120=[('2026-01-01','2026-01-03'),('2026-02-01','2026-02-13'),('2026-04-01','2026-05-01')];h120=['入院','出院不含','原日数','免赔占日','免赔后','单次日限','单次后','年度期初','给付日','年度期末','给付元','未覆盖日'] record(120,'2026年度三个独立住院事件,不合并且资格已确认。计入院、不计出院;每次免赔3天,免赔后每次给付最多20天,年度给付最多25天,此前未用;日额120元,无费用补偿抵扣。',ev120,{'逐次日额':table(h120,allowance(ev120)),'错按年度免赔':table(h120,allowance(ev120,annual_deductible=True)),'无年度限制对照':table(h120,allowance(ev120,annual_limit=100))},{'零日':allowance([('2026-01-01','2026-01-01')]),'恰免赔':allowance([('2026-01-01','2026-01-04')]),'零年度余额':allowance([('2026-01-01','2026-01-10')],annual_limit=0)}) ev121=[('2026-01-01','2026-01-04',None),('2026-01-20','2026-01-25',True),('2026-03-01','2026-03-08',True),('2026-03-20','2026-03-24',False)];h121=['入院','出院不含','住院日','距前次出院日差','已确认同因','组ID','组免赔期初','本次占免赔','组免赔期末','组已给付日','本次给付日','本次给付元'] record(121,'2026年度构造事件归并合同:相邻住院同因已确认且日期差不超过30天合并,按相邻关系传递;不同因或超过30天新组。每组免赔5个实际住院日、给付限20日,年度限100日此前未用,日额100;出院日不计、间隔不计住院。此条完整人为设定,不拼接真实产品。',ev121,{'分组免赔':table(h121,regroup(ev121)),'忽略同因错法':table(h121,regroup(ev121,ignore_cause=True)),'每次重扣错法':table(h121,regroup(ev121,threshold=-1))},{'间隔30边界':regroup([('2026-01-01','2026-01-04',None),('2026-02-03','2026-02-10',True)]),'间隔31边界':regroup([('2026-01-01','2026-01-04',None),('2026-02-04','2026-02-11',True)]),'未知同因':rejected(regroup,[('2026-01-01','2026-01-04',None),('2026-01-20','2026-01-25',None)])}) assert set(out)=={'DJ3-'+str(i) for i in range(111,122)} assert sum(D(x[5]) for x in r111)==D('6800') assert sum(D(x[8]) for x in r116)==D('2200') assert sum(D(x[7]) for x in r117)==D('16000') assert sum(x[10] for x in regroup(ev121))==5 # 额外条件变化应生成完整中间状态,而非只记录一句通过。 out['DJ3-113']['表格']['第三方超过免赔']=table(out['DJ3-113']['表格']['两路径']['columns'],[['单位11000']+offset(unit='11000')]) out['DJ3-113']['边界自检']['未知抵扣资格']=rejected(offset,None) out['DJ3-114']['表格']['B不扣先赔错法']=table(['项目','成本','A支付','B按原额错付','两者合计','超本项成本'],[[name,money(cost),money(pa),money(pb),money(pa+pb),money(max(ZERO,pa+pb-cost))] for name,cost,pa,pb in [(x[0],D(x[1]),D(x[1])*D('.8') if x[2] else ZERO,D(x[1]) if x[3] else ZERO) for x in items]]) out['DJ3-115']['表格']['平均法对照']['rows'].append(['删除零费日',money(cost115:=sum(map(D,vals115))),money(sum(D(x[3]) for x in bed([v for v in vals115 if D(v)!=0]))),money(min(cost115,D('3000'))),money(min(cost115,D('3000'))-D('2500'))]) h116=out['DJ3-116']['表格']['双限额台账']['columns'] out['DJ3-116']['表格']['仅单次逐笔']=table(h116,visit(vals116,annual_limit='100000')) out['DJ3-116']['表格']['仅年度逐笔']=table(h116,visit(vals116,per='100000')) out['DJ3-117']['表格']['不设共用总池逐笔']=table(out['DJ3-117']['表格']['五笔池状态']['columns'],pools(ev117,total_limit='100000')) out['DJ3-117']['边界自检']['子池先耗尽']=pools([('2026-01-01','住院','13000')]) for t in out['DJ3-121']['表格'].values(): t['columns']=t['columns']+['年度期初余日','年度期末余日'] out['DJ3-121']['边界自检']['年度余日零']=regroup(ev121,annual_limit=0) assert money(D('1.005'))=='1.01' return out if __name__=='__main__': target=Path(__file__).resolve().with_suffix('.json') with target.open('w',encoding='utf-8',newline='\n') as stream: stream.write(json.dumps(build(),ensure_ascii=False,indent=2)+'\n') print('已生成 '+target.name+':11个独立构造模型;自检通过。')