"""R06原创构造账:标准库Decimal独立复算,不导入网页引擎。 任意cwd运行均写同目录JSON。--check将正文全部Markdown表与计算表逐字比较, 并检查登记的关键数字句;不是政策审阅或浏览器功能验收。 """ from decimal import Decimal, ROUND_HALF_UP, ROUND_CEILING, getcontext from pathlib import Path import argparse import hashlib import json import re getcontext().prec = 48 HERE = Path(__file__).resolve().parent D = lambda x: Decimal(str(x)) Q = lambda x: D(x).quantize(D('.01'), rounding=ROUND_HALF_UP) C = lambda x: D(x).quantize(D('.01'), rounding=ROUND_CEILING) def fmt(x): return format(Q(x), '.2f') def table(head, rows): return '\n'.join(['| '+' | '.join(head)+' |', '| '+' | '.join(['---']*len(head))+' |']+ ['| '+' | '.join(str(v) for v in row)+' |' for row in rows]) def goal(name, target, due, dedicated=0): return {'name':name,'target':Q(target),'due':due,'dedicated':Q(dedicated), 'needed_shared':max(D(0),Q(target)-Q(dedicated))} def allocate(goals, initial, budgets, order=None): """独立账表:每期逐目标补缺,专款超额保留;到期快照不可回写。""" balances=[g['dedicated'] for g in goals] completed=[0 if balances[i]>=g['target'] else None for i,g in enumerate(goals)] deadline=[None]*len(goals) order=order or sorted(range(len(goals)),key=lambda i:(goals[i]['due'],i)) unused=D(0); shared=D(0); rows=[] for m,amount in enumerate([Q(initial)]+[Q(b) for b in budgets]): shared+=amount; unused+=amount; allocations=[D(0)]*len(goals) for i in order: take=min(unused,max(D(0),goals[i]['target']-balances[i])) balances[i]+=take; unused-=take; allocations[i]=take if completed[i] is None and balances[i]>=goals[i]['target']: completed[i]=m for i,g in enumerate(goals): if g['due']==m: deadline[i]={'balance':balances[i],'shortfall':max(D(0),g['target']-balances[i])} assert sum(balances)+unused == sum(g['dedicated'] for g in goals)+shared assert max([abs(v) for v in balances+allocations+[shared,unused,sum(balances)+unused]])<=D('1e12') rows.append({'month':m,'contribution':amount,'shared':shared,'allocations':allocations[:], 'balances':balances[:],'unused':unused,'total_goal_balance':sum(balances), 'total_sources':sum(g['dedicated'] for g in goals)+shared}) checks=[] for m in sorted(set(g['due'] for g in goals)): need=sum(max(D(0),g['target']-g['dedicated']) for g in goals if g['due']<=m) avail=Q(initial)+sum(Q(b) for b in budgets[:m]) checks.append({'month':m,'need':need,'available':avail,'difference':avail-need}) return {'goals':goals,'initial':Q(initial),'budgets':list(map(Q,budgets)),'rows':rows, 'completed':completed,'deadline':deadline,'checks':checks} def input_table(gs): return table(['目标','名义目标/元','到期/期','已有专款/元','尚需共享资金/元'], [[g['name'],fmt(g['target']),g['due'],fmt(g['dedicated']),fmt(max(D(0),g['target']-g['dedicated']))] for g in gs]) def check_table(a): return table(['到期节点/期','累计共享需求/元','累计共享可用/元','可用减需求/元'], [[r['month'],fmt(r['need']),fmt(r['available']),fmt(r['difference'])] for r in a['checks']]) def minimum_budget(a): return max(D(0),max(C((r['need']-a['initial'])/r['month']) for r in a['checks'] if r['month'])) def monthly(a, indices=None): rows=a['rows'] if indices is None else [a['rows'][i] for i in indices] return table(['期数','本期新资金/元']+[g['name']+'余额/元' for g in a['goals']]+['未分配/元'], [[r['month'],fmt(r['contribution'])]+list(map(fmt,r['balances']))+[fmt(r['unused'])] for r in rows]) def build(): out={} gs=[goal('教育金',60000,12,12000),goal('购房',180000,36,30000)] a=allocate(gs,24000,[4000]*48); b=allocate(gs,24000,[D('4833.34')]*36) out['DJ2-046']={'base':a,'increased_budget':b,'minimum_monthly':minimum_budget(a), 'tables':[input_table(gs),check_table(a),monthly(a,[0,1,6,12,24,36,44]), table(['月预算/元','教育金完成期','购房完成期','购房到期缺口/元','第36期未分配/元'], [[fmt(4000),a['completed'][0],a['completed'][1],fmt(a['deadline'][1]['shortfall']),fmt(a['rows'][36]['unused'])], [fmt('4833.34'),b['completed'][0],b['completed'][1],fmt(b['deadline'][1]['shortfall']),fmt(b['rows'][36]['unused'])]])]} gs=[goal('甲目标',24000,6),goal('乙目标',36000,12)] a=allocate(gs,6000,[5000]*12);b=allocate(gs,6000,[5000]*12,[1,0]) out['DJ2-047']={'deadline_first':a,'b_first':b,'tables':[input_table(gs), table(['期数','期限优先甲/元','期限优先乙/元','乙先甲/元','乙先乙/元'], [[m]+[fmt(v) for v in a['rows'][m]['balances']+b['rows'][m]['balances']] for m in range(13)]), check_table(a),table(['规则','甲完成期','乙完成期','甲到期缺口/元','乙到期缺口/元','第12期余款/元'], [[name]+x['completed']+[fmt(d['shortfall']) for d in x['deadline']]+[fmt(x['rows'][-1]['unused'])] for name,x in [('期限优先',a),('乙先',b)]]) ]} gs=[goal('甲专款目标',12000,2,18000),goal('乙目标',20000,4,5000)] a=allocate(gs,10000,[3000]*6) out['DJ2-048']={'base':a,'tables':[input_table(gs),monthly(a), table(['期数','原始专款合计/元','累计共享来源/元','目标余额合计/元','未分配/元','两边总额/元'], [[m,fmt(23000),fmt(a['rows'][m]['shared']),fmt(sum(a['rows'][m]['balances'])),fmt(a['rows'][m]['unused']),fmt(23000+a['rows'][m]['shared'])] for m in [0,2,4,6]])]} gs=[goal('进修',30000,12),goal('搬家',42000,24)] a=allocate(gs,0,[2000]*12+[4000]*18);b=allocate(gs,0,[3000]*30);c=allocate(gs,0,[2000]*18+[4000]*12) out['DJ2-049']={'step':a,'fixed':b,'raise_delayed':c,'tables':[input_table(gs), table(['期数','阶段投入/元','固定投入/元','阶段累计/元','固定累计/元','累计差/元','阶段进修余额/元','阶段搬家余额/元'], [[m,fmt(a['rows'][m]['contribution']),fmt(b['rows'][m]['contribution']),fmt(a['rows'][m]['shared']),fmt(b['rows'][m]['shared']),fmt(a['rows'][m]['shared']-b['rows'][m]['shared'])]+list(map(fmt,a['rows'][m]['balances'])) for m in range(1,25)]), table(['路径','进修第12期缺口/元','进修完成期','搬家第24期缺口/元','搬家完成期','第24期累计投入/元'], [[name,fmt(x['deadline'][0]['shortfall']),x['completed'][0],fmt(x['deadline'][1]['shortfall']),x['completed'][1],fmt(x['rows'][24]['shared'])] for name,x in [('固定',b),('第13期加额',a),('第19期加额',c)]]) ]} inputs=[('甲目标',D(60000),24,D(10000)),('乙目标',D(120000),60,D(20000))] rate_rows=[];results=[] for annual in [D(0),D('.03'),D('.06')]: per=[] for name,t,n,p in inputs: r=annual/12 factor=D(n) if r==0 else ((1+r)**n-1)/r c=C((t-p*(1+r)**n)/factor) per.append({'name':name,'monthly':c,'factor':factor,'terminal_at_zero':p+c*n,'zero_shortfall':max(D(0),t-p-c*n)}) results.append({'annual':annual,'goals':per}) rate_rows.append([fmt(annual*100)+'%']+[fmt(v['monthly']) for v in per]+[fmt(sum(v['monthly'] for v in per)),fmt(sum(v['monthly'] for v in results[0]['goals'])-sum(v['monthly'] for v in per))]) stress=results[1]['goals'] out['DJ2-050']={'inputs':inputs,'assumptions':results,'tables':[ table(['目标','名义目标/元','期数','独占期初本金/元'],[[name,fmt(t),n,fmt(p)] for name,t,n,p in inputs]), table(['假设名义年率','甲月投入/元','乙月投入/元','并行月投入合计/元','比零收益少投入/元'],rate_rows), table(['按3%假设设定后实际零收益','月投入/元','累计追加本金/元','到期本金总额/元','到期缺口/元'], [[v['name'],fmt(v['monthly']),fmt(v['monthly']*inputs[i][2]),fmt(v['terminal_at_zero']),fmt(v['zero_shortfall'])] for i,v in enumerate(stress)])]} gs=[goal('甲目标',18000,3,3000),goal('乙目标',24000,6,4000),goal('丙目标',30000,12,5000)] a=allocate(gs,6000,[4000]*18);b=allocate(gs,6000,[D('4833.34')]*12) out['DJ2-051']={'base':a,'raised':b,'tables':[input_table(gs),check_table(a), table(['目标','到期/期','到期余额/元','到期缺口/元','完成期'], [[g['name'],g['due'],fmt(d['balance']),fmt(d['shortfall']),m] for g,d,m in zip(gs,a['deadline'],a['completed'])]), table(['节点/期','该节点要求月预算/元','提高预算后的节点差额/元'], [[d['month'],fmt(C((d['need']-6000)/d['month'])),fmt(b['checks'][i]['difference'])] for i,d in enumerate(a['checks'])]) ]} house=Q(D(120000)*D('1.02')**3); tuition=Q(D(40000)*D('1.06')**2) uni_h=Q(D(120000)*D('1.03')**3);uni_t=Q(D(40000)*D('1.03')**2) gs=[goal('学费',tuition,24,10000),goal('首付',house,36,20000)] a=allocate(gs,10000,[4000]*36) out['DJ2-052']={'base':a,'house_target':house,'tuition_target':tuition,'unified_house':uni_h,'unified_tuition':uni_t, 'tables':[table(['目标','当前估价/元','期限/年','构造年涨幅','整段累计涨幅','到期名义金额/元'], [['首付',fmt(120000),3,'2.00%','6.1208%',fmt(house)],['学费',fmt(40000),2,'6.00%','12.3600%',fmt(tuition)]]), table(['目标','分开涨幅金额/元','统一3%金额/元','统一减分开/元'], [['首付',fmt(house),fmt(uni_h),fmt(uni_h-house)],['学费',fmt(tuition),fmt(uni_t),fmt(uni_t-tuition)],['合计',fmt(house+tuition),fmt(uni_h+uni_t),fmt(uni_h+uni_t-house-tuition)]]), input_table(gs),check_table(a),monthly(a,[0,6,7,24,33,34,36])]} gs=[goal('设备',9500,4,1500),goal('进修',26000,12,2000)] a=allocate(gs,1000,[3000]*12) wrong_bal=[D(2500),D(2000)];wrong_unused=D(0);wrong_rows=[] for m in range(1,13): amounts=[min(D(2000),max(D(0),D(9500)-wrong_bal[0])),D(1000)] wrong_bal=[v+w for v,w in zip(wrong_bal,amounts)] wrong_unused+=D(3000)-sum(amounts) assert sum(wrong_bal)+wrong_unused == D(4500)+D(3000)*m wrong_rows.append({'month':m,'balances':wrong_bal[:],'unused':wrong_unused}) proper_end=a['rows'][-1] out['DJ2-053']={'base':a,'fixed_bucket_without_reallocation':wrong_rows,'tables':[input_table(gs), table(['期数','设备本期分配/元','进修本期分配/元','设备余额/元','进修余额/元','未分配/元'], [[r['month']]+list(map(fmt,r['allocations']+r['balances']+[r['unused']])) for r in a['rows']]), table(['第12期路径','设备余额/元','进修余额/元','未分配/元','合计/元','进修缺口/元'], [['完成后转投']+list(map(fmt,proper_end['balances']))+[fmt(proper_end['unused']),fmt(sum(proper_end['balances'])+proper_end['unused']),fmt(max(D(0),D(26000)-proper_end['balances'][1]))], ['固定分桶且不重分']+list(map(fmt,wrong_bal))+[fmt(wrong_unused),fmt(sum(wrong_bal)+wrong_unused),fmt(D(26000)-wrong_bal[1])]]) ]} gs=[goal('甲目标',24000,6,4000),goal('乙目标',48000,12,8000)] a=allocate(gs,6000,[4000]*18) paths=[] for name,t,due in [('原方案',48000,12),('只延期一整期',48000,13),('延期两整期',48000,14),('到期不变降额',42000,12),('混合调整',46000,13),('延期且成本增加5%',50400,14)]: x=allocate([gs[0],goal('乙目标',t,due,8000)],6000,[4000]*18) paths.append({'name':name,'target':Q(t),'due':due,'result':x}) out['DJ2-054']={'base':a,'alternatives':paths,'tables':[input_table(gs),check_table(a), table(['方案','乙名义金额/元','乙到期/期','到期共享可用/元','两目标共享需求/元','乙到期缺口/元','乙完成期'], [[p['name'],fmt(p['target']),p['due'],fmt(p['result']['checks'][-1]['available']),fmt(p['result']['checks'][-1]['need']),fmt(p['result']['deadline'][1]['shortfall']),p['result']['completed'][1]] for p in paths]), monthly(a,[0,4,6,12,13,14])]} # 额外的闭式核对不依赖逐期分配路径,避免只让表格复述同一循环。 assert out['DJ2-046']['base']['completed'][1] == int(((D(198000)-24000)/4000).to_integral_value(rounding=ROUND_CEILING)) assert out['DJ2-047']['deadline_first']['deadline'][0]['shortfall']==0 assert out['DJ2-047']['b_first']['deadline'][0]['shortfall']==24000 assert out['DJ2-048']['base']['rows'][-1]['unused']==D(23000)+10000+6*3000-D(38000) assert out['DJ2-049']['step']['rows'][24]['shared']==12*2000+12*4000 assert out['DJ2-051']['base']['checks'][1]['difference']==D(6000)+6*4000-D(35000) assert house==D('127344.96') and tuition==D('44944.00') assert wrong_unused==D(17000) and proper_end['unused']==D(5000) assert paths[2]['result']['deadline'][1]['shortfall']==0 assert paths[3]['result']['deadline'][1]['shortfall']==0 assert paths[-1]['result']['deadline'][1]['shortfall']==D(400) # 中间差额、成本换算和反例也作为独立数值证据登记,不从正文读取数值。 out['DJ2-048']['dedicated_excess']=max(D(0),out['DJ2-048']['base']['goals'][0]['dedicated']-out['DJ2-048']['base']['goals'][0]['target']) out['DJ2-049']['monthly_cumulative_differences']=[x['shared']-y['shared'] for x,y in zip(out['DJ2-049']['step']['rows'],out['DJ2-049']['fixed']['rows'])] out['DJ2-050']['nominal_annual_percent_inputs']=[0,3,6] out['DJ2-050']['months_per_year']=12 out['DJ2-050']['formula_unit']=D(1) out['DJ2-052']['cost_inputs']={'house_current':D(120000),'house_years':3,'house_annual_percent':D(2), 'house_factor':D('1.02'),'house_total_percent':(D('1.02')**3-1)*100, 'tuition_current':D(40000),'tuition_years':2,'tuition_annual_percent':D(6), 'tuition_factor':D('1.06'),'tuition_total_percent':(D('1.06')**2-1)*100,'unified_annual_percent':D(3)} out['DJ2-052']['comparison_differences']={'house':uni_h-house,'tuition':uni_t-tuition,'total':uni_h+uni_t-house-tuition} out['DJ2-053']['uncapped_wrong_equipment_balance']=out['DJ2-053']['base']['rows'][2]['balances'][0]+3000 out['DJ2-054']['changed_cost_percent']=D(5) # 核对正文关键数字句,金额来自以上Decimal结果;其他数字逐项登记见audit。 def plain(v): s=format(D(v),'f') return s.rstrip('0').rstrip('.') if '.' in s else s out['DJ2-046']['checked_claims']=[f"每月存4000元能按期准备教育金,却会让购房目标在第36期留下{plain(out['DJ2-046']['base']['deadline'][1]['shortfall'])}元缺口",f"所需月预算为{plain(minimum_budget(out['DJ2-046']['base']))}元"] out['DJ2-047']['checked_claims']=[f"甲在乙先路径中,第6期到期仍缺{plain(out['DJ2-047']['b_first']['deadline'][0]['shortfall'])}元,直到第{out['DJ2-047']['b_first']['completed'][0]}期才备齐"] out['DJ2-048']['checked_claims']=[f"第6期两边总额{plain(D(23000)+out['DJ2-048']['base']['rows'][6]['shared'])}元,其中目标余额合计{plain(sum(out['DJ2-048']['base']['rows'][6]['balances']))}元、未分配{plain(out['DJ2-048']['base']['rows'][6]['unused'])}元"] out['DJ2-049']['checked_claims']=[f"阶段路径进修到期缺口{plain(out['DJ2-049']['step']['deadline'][0]['shortfall'])}元"] out['DJ2-050']['checked_claims']=[f"甲到期差{fmt(stress[0]['zero_shortfall'])}元,乙到期差{fmt(stress[1]['zero_shortfall'])}元"] out['DJ2-051']['checked_claims']=[f"最早冲突在第6期,共享资金比甲乙累计需求少{plain(-out['DJ2-051']['base']['checks'][1]['difference'])}元",f"固定预算至少为{plain(minimum_budget(out['DJ2-051']['base']))}元"] out['DJ2-052']['checked_claims']=[f"统一假设让首付多算{plain(uni_h-house)}元,却把学费少算{plain(tuition-uni_t)}元",f"合计反而高{plain(uni_h+uni_t-house-tuition)}元"] out['DJ2-053']['checked_claims']=[f"账上留有{plain(wrong_unused)}元未分配"] out['DJ2-054']['checked_claims']=[f"目标变为{plain(paths[-1]['target'])}元;即使延到第14期,仍差{plain(paths[-1]['result']['deadline'][1]['shortfall'])}元,需要到第{paths[-1]['result']['completed'][1]}期才补齐"] assert len(out)==9 return out def jsonable(x): if isinstance(x,Decimal):return format(x,'f') if isinstance(x,dict):return {k:jsonable(v) for k,v in x.items()} if isinstance(x,(list,tuple)):return [jsonable(v) for v in x] return x def check(data): ROOT = HERE.parents[4] # 仅仓库内正文核对需要,不让默认下载运行依赖目录深度。 plan=json.loads((HERE.parent/'content-plan.json').read_text('utf-8')) result=[] for row in [a for a in plan['articles'] if a['cluster']=='R06']: path=ROOT/'sites/daojijun.com/src/content/article'/(row['planned_path'].strip('/').split('/')[-1]+'.md') text=path.read_text('utf-8');body=text.split('---',2)[2].split('')[0] actual=re.findall(r'(?m)^\|[^\n]+(?:\n\|[^\n]+)*',body) assert actual==data[row['id']]['tables'], row['id']+'正文表格与独立计算不一致' claims=data[row['id']].get('checked_claims',[]) assert all(c in body for c in claims),row['id']+'关键数字句不一致' evidence={} def collect(x,prefix=''): if isinstance(x,(Decimal,int)): evidence.setdefault(str(D(x).normalize()),[]).append(prefix) if x<0:evidence.setdefault(str(abs(D(x)).normalize()),[]).append('abs('+prefix+')') elif isinstance(x,dict): for k,v in x.items(): if k not in ('tables','checked_claims'): collect(v,prefix+'.'+k) elif isinstance(x,(list,tuple)): for i,v in enumerate(x):collect(v,prefix+f'[{i}]') collect(data[row['id']]) prose='\n'.join(line for line in body.splitlines() if not line.startswith('|')) number_items=[] for m in re.finditer(r'\d+(?:\.\d+)?',prose): number=str(D(m.group()).normalize()) assert number in evidence,row['id']+'正文数字缺独立来源:'+m.group() number_items.append({'text':m.group(),'evidence_paths':evidence[number][:4]}) chinese_items=[] cn={'零':0,'一':1,'两':2,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9,'十':10,'十二':12} for m in re.finditer(r'(十二|[零一两二三四五六七八九十])(?:个)?(?:期|年|月|元)',prose): value=cn[m.group(1)]; key=str(D(value).normalize()) assert key in evidence,row['id']+'中文数字缺独立来源:'+m.group() chinese_items.append({'text':m.group(),'value':value,'evidence_paths':evidence[key][:4]}) old_paths=sorted(set(row['required_existing_read']+[n['path'] for n in row['near_existing_for_review']])) read_hashes=[] for old in old_paths: p=ROOT/'sites/daojijun.com/src/content'/(old.strip('/')+'.md') read_hashes.append({'path':old,'sha256':hashlib.sha256(p.read_bytes()).hexdigest(),'bytes':p.stat().st_size}) result.append({'id':row['id'],'path':path.relative_to(ROOT).as_posix(),'core_cjk':len(re.findall('[\u4e00-\u9fff]',body)), 'tables_checked':len(actual),'numeric_cells_checked':sum(len(re.findall(r'(?