Files
shanxia2.0/backend/app/services/pl_quota_usage_service.py
T

450 lines
32 KiB
Python

"""原子额度账本;来源真实性由内部业务调用者负责,服务复核租户/权限/合同适用范围。
调用者统一事务提交,服务只 flush。锁顺序:计划、(证种,证ID)、合同ID、范围及账本。
所有命令使用 savepoint;失败保留调用前账本,不替调用方吞掉实运事实或补造实际量。
"""
from dataclasses import replace
from types import SimpleNamespace
from datetime import datetime, date
from decimal import Decimal
import hashlib
import json
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from app.models import pl_quota_models as m
from app.models.pl_models import PlPlan
from app.models.crm_models import CrmContractProduct
from app.schemas.pl.quota_usage import UsageRequest
from app.services.pl_data_scope import assert_pl_row_in_scope
from app.services.pl_quota_cert_service import _audit, _get_row, usage_policy
from app.services.pl_quota_contract_service import _contract
from app.services.pl_quota_allocation import (SourceDemand, PermitCandidate, ManualAllocation, Allocation,
allocate_plan, settle_allocations, validate_allocation_pairing, InsufficientQuota)
from app.services.pl_quota_diagnostics import allocation_diagnostics
ZERO = Decimal('0')
def _query(db, user, model, lock=True, **filters):
query=db.query(model).filter_by(tenant_no=user.tenant_no, **filters)
return query.with_for_update().populate_existing() if lock else query
def _canonical(value):
if isinstance(value, Decimal):
return format(value.normalize(), 'f')
if isinstance(value, (datetime,date)):
return value.isoformat()
if isinstance(value, dict):
return {k:_canonical(v) for k,v in value.items()}
if isinstance(value, list):
return [_canonical(v) for v in value]
return value
def _manual(rows):
return [ManualAllocation(r.permit_kind,r.source_id,r.permit_id,r.source_quantity,r.permit_quantity) for r in rows]
def _key(item):
return item.source_stop_type + ':' + item.source_stop_product_id
def _log(db,user,r,digest,kind,cert,usage,old):
before_total,before_reserved,before_used=old
db.add(m.PlCertQtyLog(**_audit(user),operation_id=r.operation_id,request_hash=digest,
cert_type=kind,cert_id=cert.id,usage_id=usage.id if usage else None,plan_id=r.plan_id,
event_type='adjust' if r.action=='actual_adjust' else r.action,quantity_unit=cert.quantity_unit,
total_delta=cert.total_qty-before_total,reserved_delta=cert.reserved_qty-before_reserved,
used_delta=cert.used_qty-before_used,before_total_qty=before_total,after_total_qty=cert.total_qty,
before_reserved_qty=before_reserved,after_reserved_qty=cert.reserved_qty,before_used_qty=before_used,
after_used_qty=cert.used_qty,reason=r.reason or r.action,evidence_urls=r.evidence_urls,
event_snapshot={'sourceNote':r.source_note,'executed':r.executed,
'supplement': (usage.confirmation_snapshot or {}).get('supplement') if usage else None}))
def preview_usage(db,user,request):
"""纯只读候选验证与分配;不加行锁、不flush、不写入操作号或临时预占。
预览是当前读取结果,正式安排仍须重新锁定校验;不得将预览当成额度承诺。
"""
r=request if isinstance(request,UsageRequest) else UsageRequest.model_validate(request)
if r.action not in ('reserve','resize'): raise ValueError('预览仅支持预占或改派')
if any(len(ids)!=len(set(ids)) for ids in (r.purchase_cert_ids,r.transport_cert_ids)):
raise ValueError('证件选择重复')
with db.no_autoflush:
plan=_query(db,user,PlPlan,lock=False,id=r.plan_id,del_flag=0).first()
if plan is None: raise HTTPException(404,'计划不存在')
assert_pl_row_in_scope(db,plan,PlPlan,user,user.tenant_no)
usages={(u.cert_type,u.cert_id):u for u in _query(db,user,m.PlPlanCertUsage,lock=False,plan_id=plan.id).all()}
if r.executed or any(u.usage_status in ('in_transit','settled') or
(u.confirmation_snapshot or {}).get('executed') for u in usages.values()):
raise ValueError('已执行用证不得重新安排')
selected={('purchase',x) for x in r.purchase_cert_ids}|{('transport',x) for x in r.transport_cert_ids}
certs={k:_get_row(db,user,*k,lock=False) for k in sorted(selected)}
return _reserve(db,user,r,plan,certs,usages,{},selected,preview=True)
def execute_usage(db,user,request):
"""返回持久化的 operationId/usageIds;重试返回同结果,跨动作/计划/载荷冲突拒绝。"""
r=request if isinstance(request,UsageRequest) else UsageRequest.model_validate(request)
payload=r.model_dump()
for name in ('purchase_cert_ids','transport_cert_ids'):
if len(payload[name]) != len(set(payload[name])): raise ValueError('证件选择重复')
payload[name]=sorted(payload[name])
payload['sources']=sorted(payload['sources'],key=lambda s:(s['source_stop_type'],s['source_stop_product_id']))
for name in ('manual_allocations','manual_actuals'):
payload[name]=sorted(payload[name],key=lambda s:(s['permit_kind'],s['source_id'],s['permit_id']))
digest=hashlib.sha256(json.dumps(_canonical(payload),sort_keys=True,ensure_ascii=False,separators=(',',':')).encode()).hexdigest()
with db.begin_nested():
plan=None
if r.action!='total_adjust':
plan=_query(db,user,PlPlan,id=r.plan_id,del_flag=0).first()
if plan is None: raise HTTPException(404,'计划不存在')
assert_pl_row_in_scope(db,plan,PlPlan,user,user.tenant_no)
# 不锁不存在的操作号,避免 InnoDB 间隙锁与随后唯一 INSERT 的锁升级死锁。
# 初次快照读可能漏掉并发提交;下面由唯一键插入仲裁,再当前读原结果。
op=_query(db,user,m.PlCertOperation,lock=False,operation_id=r.operation_id).first()
if op:
if op.request_hash!=digest: raise ValueError('同一操作号不可用于不同请求')
return op.result_json
# 提前插入唯一键串行化跨计划的同操作号;嵌套事务避免唯一冲突污染外层。
try:
with db.begin_nested():
op=m.PlCertOperation(**_audit(user),operation_id=r.operation_id,action=r.action,plan_id=r.plan_id,request_hash=digest)
db.add(op); db.flush()
except IntegrityError:
op=_query(db,user,m.PlCertOperation,operation_id=r.operation_id).one()
if op.request_hash!=digest: raise ValueError('同一操作号不可用于不同请求')
return op.result_json
result=_execute(db,user,r,digest,plan)
op.result_json=result
db.flush()
return result
def _execute(db,user,r,digest,plan):
# 计划锁已持有;读取证件身份后按统一顺序锁证,随后重新当前锁读usage。
old_rows=[] if plan is None else db.query(m.PlPlanCertUsage).filter_by(tenant_no=user.tenant_no,plan_id=plan.id).all()
selected={('purchase',x) for x in r.purchase_cert_ids}|{('transport',x) for x in r.transport_cert_ids}
keys=selected|{(u.cert_type,u.cert_id) for u in old_rows}
if r.action=='total_adjust':
if not r.cert_type or not r.cert_id or r.total_qty is None: raise ValueError('总量调整缺少证件及数量')
keys={(r.cert_type,r.cert_id)}
certs={k:_get_row(db,user,*k,lock=True) for k in sorted(keys)}
old={k:(c.total_qty,c.reserved_qty,c.used_qty) for k,c in certs.items()}
if r.action=='total_adjust':
if not r.reason.strip() or not (r.evidence_urls.strip() or r.source_note.strip()): raise ValueError('调整必须提供理由及依据')
k=next(iter(keys)); cert=certs[k]
if r.total_qty<cert.used_qty+cert.reserved_qty: raise ValueError('总量不得低于已用与预占之和')
cert.total_qty=r.total_qty; cert.revision+=1; cert.update_by=user.id
_log(db,user,r,digest,k[0],cert,None,old[k])
return {'operationId':r.operation_id,'usageIds':[]}
usages={(u.cert_type,u.cert_id):u for u in _query(db,user,m.PlPlanCertUsage,plan_id=plan.id).all()}
if set(usages)-set(certs):
# 等待计划锁前已建立的 RR 快照可能遗漏别的事务刚提交的 usage。
# 不能倒序补锁新证件;整次请求退出,交调用方重新开始事务。
raise HTTPException(409, '计划用证已变更,请刷新并重试')
items={k:_query(db,user,m.PlPlanCertUsageItem,usage_id=u.id).all() for k,u in usages.items()}
supplement=[]
if r.action in ('reserve','resize'):
if r.executed or any(u.usage_status in ('in_transit','settled') or (u.confirmation_snapshot or {}).get('executed') for u in usages.values()):
raise ValueError('已执行用证不得重新安排')
_reserve(db,user,r,plan,certs,usages,items,selected)
elif r.action=='release':
if r.executed or any(u.usage_status in ('in_transit','settled') or
(u.confirmation_snapshot or {}).get('executed') for u in usages.values()):
raise ValueError('已执行用证不得释放预占,请先结转实际量')
for k,u in usages.items():
certs[k].reserved_qty-=u.reserved_qty; u.reserved_qty=ZERO
u.usage_status='released'
for item in items[k]:
item.reserved_qty=ZERO; item.item_status='released'
else:
supplement=_settle(db,user,r,certs,usages,items,plan)
for k,u in usages.items():
u.confirmation_snapshot = {**(u.confirmation_snapshot or {}),
'measurementVersion': (u.confirmation_snapshot or {}).get('measurementVersion',
next((s.snapshot.get('measurementVersion', 'legacy_single_side') for s in r.sources), 'legacy_single_side'))}
u.row_version=(u.row_version or 0)+1; u.update_by=user.id
if certs[k].reserved_qty<0 or certs[k].used_qty<0 or certs[k].reserved_qty+certs[k].used_qty>certs[k].total_qty:
raise ValueError('余额越界')
_log(db,user,r,digest,k[0],certs[k],u,old[k])
return {'operationId':r.operation_id,'usageIds':sorted(u.id for u in usages.values() if not u.del_flag),
'supplementCerts':supplement or []}
def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,lock_preview=False):
def query(model,**filters):
return _query(db,user,model,lock=not preview or lock_preview,**filters)
sources={s.source_id:s for s in r.sources}
if len(sources)!=len(r.sources): raise ValueError('来源重复')
if sources and not selected: raise ValueError('需要证件的来源未选择双证')
for cid in sorted({s.contract_id for s in sources.values()}):
contract=_contract(db,user,cid,lock=not preview or lock_preview)
if contract.status not in ('approved','executing') or contract.contract_type not in (
'sale','purchase','framework_sale','framework_purchase'):
raise ValueError('合同当前状态或类型不允许履行用证')
for s in sources.values():
product=query(CrmContractProduct,id=s.contract_product_id,contract_id=s.contract_id,del_flag=0).first()
if product is None or product.product_id!=s.product_id: raise ValueError('合同产品归属不符')
scopes={}; candidates={'purchase':[],'transport':[]}
for k in sorted(selected):
kind,pid=k; c=certs[k]
if not query(m.PlCertQtyLog,cert_type=kind,cert_id=pid,event_type='opening',del_flag=0).first(): raise ValueError('证件尚未确认期初')
if c.status!='active' or not c.verified_time or not c.verified_by: raise ValueError('证件尚未启用复核')
if c.valid_from is None or c.valid_to is None: raise ValueError('证件有效期起止日期必须完整')
if not c.valid_from <= r.on_date <= c.valid_to: raise ValueError('证件不在有效日期内')
policy=usage_policy(c.valid_use_text)
if policy=='needs_confirmation': raise ValueError('有效次数待确认')
if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])): raise ValueError('车号不在运输证范围')
if policy=='once':
previous=query(m.PlPlanCertUsage,cert_type=kind,cert_id=pid,del_flag=0).all()
if c.opening_single_use_consumed or any(u.plan_id!=plan.id and (u.usage_status in ('reserved','in_transit','settled','exception') or (u.confirmation_snapshot or {}).get('executed')) for u in previous):
raise ValueError('一次有效证件已使用或已占用')
eligible=set()
for sid,s in sources.items():
link=query(m.PlContractCertLink,cert_type=kind,cert_id=pid,contract_id=s.contract_id,del_flag=0,link_status='active').first()
scope=None if link is None else query(m.PlContractCertProduct,contract_cert_link_id=link.id,contract_product_id=s.contract_product_id,del_flag=0,scope_status='active').first()
if scope:
eligible.add(sid); scopes[(k,sid)]=(link,scope)
refs=frozenset(x.purchase_cert_id for x in query(m.PlTransportCertPurchaseRef,transport_cert_id=pid,del_flag=0).all() if x.purchase_cert_id) if kind=='transport' else frozenset()
own=usages[k].reserved_qty if k in usages else ZERO
candidates[kind].append(PermitCandidate(pid,c.total_qty-c.used_qty-c.reserved_qty+own,c.quantity_unit,c.valid_to,frozenset(eligible),refs))
return sources,scopes,candidates
def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_preview=False):
sources,scopes,candidates=_candidate_context(db,user,r,plan,certs,usages,items,selected,preview,lock_preview)
demands=[SourceDemand(s.source_id,s.source_id,s.quantity,s.unit) for s in sources.values()]
try:
allocation=allocate_plan(demands,candidates['purchase'],candidates['transport'],manual_allocations=_manual(r.manual_allocations))
except InsufficientQuota as error:
error.diagnostics = allocation_diagnostics(demands, candidates['purchase'], candidates['transport'])
raise
rows=list(allocation.purchase_allocations+allocation.transport_allocations)
# 零量也占用一次资格;纯计算器省略零流,账本补存一对真实适用证件。
for sid,s in sorted(sources.items()):
if s.quantity != 0: continue
pairs=[(p,t) for p in candidates['purchase'] for t in candidates['transport']
if sid in p.product_ids and sid in t.product_ids and p.permit_id in t.purchase_permit_ids
and (p.unit==s.unit or any(a.permit_kind=='purchase' and a.source_id==sid and a.permit_id==p.permit_id for a in r.manual_allocations))
and (t.unit==s.unit or any(a.permit_kind=='transport' and a.source_id==sid and a.permit_id==t.permit_id for a in r.manual_allocations))]
if not pairs: raise ValueError('零量来源仍须有适用双证及异单位人工确认')
p,t=min(pairs,key=lambda pair:(pair[0].expires_on,pair[0].permit_id,pair[1].expires_on,pair[1].permit_id))
for kind,c in [('purchase',p),('transport',t)]:
rows.append(Allocation(kind,sid,c.permit_id,ZERO,ZERO,s.unit,c.unit,1))
if preview:
result={'purchaseAllocations':[], 'transportAllocations':[]}
for a in rows:
k=(a.permit_kind,a.permit_id); link,scope=scopes[(k,a.source_id)]
result[a.permit_kind+'Allocations'].append({
'sourceId':a.source_id,'certId':a.permit_id,'sourceQuantity':_canonical(a.source_quantity),
'permitQuantity':_canonical(a.permit_quantity),'sourceUnit':a.source_unit,
'permitUnit':a.permit_unit,'allocationSeq':a.allocation_seq,
'contractCertLinkId':link.id,'contractCertProductId':scope.id,
'scopeRevision':link.scope_revision,'certRevision':certs[k].revision,
'ownReservedQty':_canonical(usages[k].reserved_qty if k in usages else ZERO),
})
return result
notes={(a.permit_kind,a.permit_id,a.source_id):a.note for a in r.manual_allocations}
by_cert={k:[] for k in selected}
for row in rows: by_cert[(row.permit_kind,row.permit_id)].append(row)
for k in sorted(set(usages)|set(by_cert)):
c=certs[k]; u=usages.get(k); allocations=by_cert.get(k,[])
if u is None and not allocations: continue
if u is None:
u=m.PlPlanCertUsage(**_audit(user),plan_id=plan.id,plan_no_snapshot=plan.plan_no,cert_type=k[0],cert_id=k[1],cert_no_snapshot=c.cert_no,cert_revision=c.revision,quantity_unit=c.quantity_unit,reserved_qty=ZERO,used_qty=ZERO,del_flag=0)
db.add(u); usages[k]=u; items[k]=[]
previous={_key(i):i for i in items[k]}
wanted={a.source_id for a in allocations}
for sid,item in previous.items():
if sid not in wanted:
if item.actual_cert_qty is not None or item.item_status in ('in_transit','settled'): raise ValueError('已执行明细不能移除')
item.del_flag=1; item.reserved_qty=ZERO; item.item_status='released'
new_qty=sum((a.permit_quantity for a in allocations),ZERO)
c.reserved_qty+=new_qty-u.reserved_qty; u.reserved_qty=new_qty; u.usage_status='reserved' if allocations else 'released'
u.cert_revision=c.revision; u.confirmed_by=user.id; u.confirmed_time=datetime.now()
u.confirmation_snapshot={'executed':False,'vehicleNo':r.vehicle_no,
'measurementVersion': next((s.snapshot.get('measurementVersion', 'legacy_single_side') for s in r.sources), 'legacy_single_side')}
for a in allocations:
s=sources[a.source_id]; link,scope=scopes[(k,a.source_id)]
item=previous.get(a.source_id)
if item is None:
item=m.PlPlanCertUsageItem(**_audit(user),usage_id=u.id); db.add(item); items[k].append(item)
values=dict(contract_cert_link_id=link.id,contract_cert_product_id=scope.id,contract_scope_revision=link.scope_revision,contract_id=s.contract_id,contract_product_id=s.contract_product_id,plan_product_id=s.plan_product_id,source_stop_type=s.source_stop_type,source_stop_id=s.source_stop_id,source_stop_product_id=s.source_stop_product_id,related_stop_products=s.related_stop_products,allocation_seq=a.allocation_seq,source_unit=s.unit,planned_source_qty=s.quantity,allocated_source_qty=a.source_quantity,planned_cert_qty=a.permit_quantity,reserved_qty=a.permit_quantity,used_qty=ZERO,item_status='reserved',source_fingerprint=s.fingerprint,source_snapshot={**s.snapshot, 'measurementVersion': s.snapshot.get('measurementVersion', 'legacy_single_side'), 'productId': s.product_id},del_flag=0,reserve_input_mode='same_unit' if s.unit==c.quantity_unit else 'manual',reserve_conversion_note=notes.get((k[0],k[1],a.source_id)),reserve_confirmed_by=user.id,reserve_confirmed_time=datetime.now(),update_by=user.id)
for name,value in values.items(): setattr(item,name,value)
def _validate_current_sources(r,active):
"""实际量独立传递;当前业务身份与安排确认快照必须一致后才能结转或纠错。"""
sources={s.source_id:s for s in r.sources}
source_ids={_key(i) for rows in active.values() for i in rows}
if len(sources)!=len(r.sources) or set(sources)!=source_ids:
raise ValueError('当前权威来源集合与预占明细不一致,请重新核对')
identity_fields=('source_stop_type','source_stop_id','source_stop_product_id',
'plan_product_id','contract_id','contract_product_id')
for rows in active.values():
for item in rows:
source=sources[_key(item)]
identity_changed=any(getattr(source,field)!=getattr(item,field) for field in identity_fields)
if (identity_changed or source.quantity!=item.planned_source_qty or source.unit!=item.source_unit
or source.fingerprint!=item.source_fingerprint
or source.product_id!=(item.source_snapshot or {}).get('productId')
or sorted(source.related_stop_products)!=sorted(item.related_stop_products or [])):
raise ValueError('当前权威来源身份、计划量、单位或指纹已变化,请重新核对用证')
def _settle(db,user,r,certs,usages,items,plan):
if not usages:
if r.sources or r.actual_by_source: raise ValueError('没有预占明细可结转')
return
adjusting=r.action=='actual_adjust'
if adjusting and (not r.reason.strip() or not (r.evidence_urls.strip() or r.source_note.strip())): raise ValueError('实际量纠错必须有理由及依据')
active={k:[i for i in rows if not i.del_flag] for k,rows in items.items()}
active={k:rows for k,rows in active.items() if rows}
_validate_current_sources(r,active)
if any(u.usage_status!='settled' if adjusting else u.usage_status not in ('reserved','in_transit','exception') for k,u in usages.items() if k in active):
raise ValueError('用证状态不允许当前结转动作')
groups={'purchase':[],'transport':[]}; item_map={}; demands={}
for k,rows in active.items():
for i in rows:
sid=_key(i)
a=Allocation(k[0],sid,k[1],i.allocated_source_qty,i.planned_cert_qty,i.source_unit,usages[k].quantity_unit,i.allocation_seq)
groups[k[0]].append(a); item_map[(k[0],sid,k[1])]=i
demands[sid]=SourceDemand(sid,sid,r.actual_by_source.get(sid),i.source_unit)
# 原始容量不随补量扩大;新增证件原始容量为零,后续纠错始终先用原分配。
base_actual=dict(r.actual_by_source)
excess={}
for sid,value in r.actual_by_source.items():
capacity=sum((a.source_quantity for a in groups['purchase'] if a.source_id==sid),ZERO)
if value>capacity:
excess[sid]=value-capacity
if r.allow_supplement: base_actual[sid]=capacity
# 人工显式补量可从当前实运来源拆出一段(如原证24、新证5),两段合计仍是29。
# 两证有一侧同单位时由联合分配器配对;异单位补量仍须完整人工输入。
if r.allow_supplement and r.manual_allocations:
explicit={}
for row in r.manual_allocations:
key=(row.permit_kind,row.source_id)
explicit[key]=explicit.get(key,ZERO)+row.source_quantity
for (_,sid),quantity in explicit.items():
if sid not in r.actual_by_source or quantity>r.actual_by_source[sid]:
raise ValueError('显式补量来源超过实运量或来源不存在')
if quantity>excess.get(sid,ZERO):
original_rows=[a for rows in groups.values() for a in rows if a.source_id==sid]
source_unit=next(source.unit for source in r.sources if source.source_id==sid)
foreign=(any(a.source_quantity>ZERO and a.source_unit!=a.permit_unit for a in original_rows)
or any(row.source_id==sid and row.source_quantity>ZERO
and (row.permit_kind,row.permit_id) in certs
and certs[(row.permit_kind,row.permit_id)].quantity_unit!=source_unit
for row in r.manual_allocations))
original_confirmation=any(
manual.source_id==sid and any(
a.permit_kind==manual.permit_kind and a.permit_id==manual.permit_id
for a in original_rows)
for manual in r.manual_actuals)
if not foreign or not original_confirmation:
raise ValueError('原预占内拆分仅支持异单位且须本次人工确认原段实际量')
excess[sid]=quantity
base_actual[sid]=r.actual_by_source[sid]-quantity
# 异单位重新称量可能只增加证面量。此增量没有新来源量,不伪造换算或来源。
permit_only=[]
manual_map={(a.permit_kind,a.source_id,a.permit_id):a for a in r.manual_actuals}
if r.allow_supplement:
for kind,rows in groups.items():
for index,row in enumerate(rows):
manual=manual_map.get((kind,row.source_id,row.permit_id))
if manual and row.source_unit!=row.permit_unit and manual.permit_quantity>row.permit_quantity:
permit_only.append((kind,{'sourceId':row.source_id,'certId':row.permit_id,
'sourceQuantity':'0','permitQuantity':_canonical(manual.permit_quantity-row.permit_quantity)}))
rows[index]=replace(row,permit_quantity=manual.permit_quantity)
if permit_only:
if not r.reason.strip() or not (r.evidence_urls.strip() or r.source_note.strip()):
raise ValueError('补分配必须有理由及依据')
selected={('purchase',x) for x in r.purchase_cert_ids}|{('transport',x) for x in r.transport_cert_ids}
increased={(kind,row['certId']) for kind,row in permit_only}
if not increased<=selected: raise ValueError('证面补量必须明确选择原证件并重新确认')
_,_,candidates=_candidate_context(db,user,r,plan,certs,usages,items,increased)
for kind,row in permit_only:
candidate=next(c for c in candidates[kind] if c.permit_id==row['certId'])
if row['sourceId'] not in candidate.product_ids: raise ValueError('证面补量来源不在当前合同适用范围')
settled={kind:settle_allocations(rows,base_actual,manual_actuals=[a for a in _manual(r.manual_actuals) if a.permit_kind==kind]) for kind,rows in groups.items()}
supplemental=[]
if excess:
if not r.reason.strip() or not (r.evidence_urls.strip() or r.source_note.strip()):
raise ValueError('补分配必须有理由及依据')
# 校验器看到的是扣除原量、返还本计划旧账后的净可用量。
base_totals={k:ZERO for k in usages}
for kind,rows in settled.items():
for row in rows: base_totals[(kind,row.allocation.permit_id)]+=row.actual_permit_quantity
credits={k:SimpleNamespace(reserved_qty=u.reserved_qty+u.used_qty-base_totals[k]) for k,u in usages.items()}
extra_request=r.model_copy(update={'sources':[source.model_copy(update={'quantity':excess[source.source_id]}) for source in r.sources if source.source_id in excess]})
selected={('purchase',x) for x in r.purchase_cert_ids}|{('transport',x) for x in r.transport_cert_ids}
result=_reserve(db,user,extra_request,plan,certs,credits,{},selected,preview=True,lock_preview=True)
for kind in ('purchase','transport'):
for row in result[kind+'Allocations']:
supplemental.append((kind,row))
_supplement_items(db,user,r,plan,certs,usages,items,active,item_map,supplemental)
actual={kind:[replace(s.allocation,source_quantity=s.actual_source_quantity,permit_quantity=s.actual_permit_quantity) for s in rows] for kind,rows in settled.items()}
for kind,row in supplemental:
sid=row['sourceId']; pid=row['certId']; i=item_map[(kind,sid,pid)]
added=Allocation(kind,sid,pid,Decimal(row['sourceQuantity']),Decimal(row['permitQuantity']),i.source_unit,usages[(kind,pid)].quantity_unit,i.allocation_seq)
existing=next((a for a in actual[kind] if a.source_id==sid and a.permit_id==pid),None)
if existing:
actual[kind].remove(existing)
added=replace(added,source_quantity=added.source_quantity+existing.source_quantity,permit_quantity=added.permit_quantity+existing.permit_quantity)
actual[kind].append(added)
transports=[]
for k,rows in active.items():
if k[0]!='transport': continue
refs=frozenset(x.purchase_cert_id for x in _query(db,user,m.PlTransportCertPurchaseRef,transport_cert_id=k[1],del_flag=0).all() if x.purchase_cert_id)
transports.append(PermitCandidate(k[1],ZERO,usages[k].quantity_unit,date.max,frozenset(_key(i) for i in rows),refs))
validate_allocation_pairing(list(demands.values()),actual['purchase'],actual['transport'],transports)
notes={(a.permit_kind,a.source_id,a.permit_id):a.note for a in r.manual_actuals}
totals={k:ZERO for k in active}
# 原量、补量均已验证,随后一次性写入聚合后的唯一明细。
for kind,rows in actual.items():
for a in rows:
k=(kind,a.permit_id); key=(kind,a.source_id,a.permit_id); i=item_map[key]
totals[k]+=a.permit_quantity
i.actual_source_qty=r.actual_by_source[a.source_id]; i.actual_allocated_source_qty=a.source_quantity
i.actual_cert_qty=a.permit_quantity; i.used_qty=a.permit_quantity; i.reserved_qty=ZERO
i.item_status='settled'; i.settle_input_mode='same_unit' if a.source_unit==a.permit_unit else 'manual'
i.settle_conversion_note=notes.get(key); i.settle_confirmed_by=user.id; i.settle_confirmed_time=datetime.now(); i.update_by=user.id
extra=next((row for kind2,row in supplemental if kind2==kind and row['sourceId']==a.source_id and row['certId']==a.permit_id),None)
i.source_snapshot={**(i.source_snapshot or {}),'supplement':extra,
'permitOnlySupplement':next((row for kind2,row in permit_only if kind2==kind and row['sourceId']==a.source_id and row['certId']==a.permit_id),None)}
for k,total in totals.items():
u=usages[k]; c=certs[k]
c.reserved_qty-=u.reserved_qty; c.used_qty+=total-u.used_qty
u.reserved_qty=ZERO; u.used_qty=total; u.usage_status='settled'; u.settled_time=datetime.now()
u.confirmation_snapshot={**(u.confirmation_snapshot or {}),'executed':True,
'supplement':{'originalActualBySource':_canonical(base_actual),'extraActualBySource':_canonical(excess),
'allocations':[row for kind,row in supplemental if (kind,row['certId'])==k],
'permitOnlyAllocations':[row for kind,row in permit_only if (kind,row['certId'])==k],
'manualAllocations':_canonical([a.model_dump() for a in r.manual_allocations]),
'manualActuals':_canonical([a.model_dump() for a in r.manual_actuals])}}
return [{'certType':kind,'certId':pid} for kind,pid in sorted({(kind,row['certId']) for kind,row in supplemental+permit_only})]
def _supplement_items(db,user,r,plan,certs,usages,items,active,item_map,supplemental):
"""原行就地聚合;新增证件只追加零原容量行,不改变原量顺序。"""
sources={s.source_id:s for s in r.sources}
for kind,row in supplemental:
sid=row['sourceId']; pid=row['certId']; k=(kind,pid); key=(kind,sid,pid)
if key in item_map: continue
source=sources[sid]; cert=certs[k]; usage=usages.get(k)
if usage is None:
usage=m.PlPlanCertUsage(**_audit(user),plan_id=plan.id,plan_no_snapshot=plan.plan_no,
cert_type=kind,cert_id=pid,cert_no_snapshot=cert.cert_no,cert_revision=cert.revision,
quantity_unit=cert.quantity_unit,reserved_qty=ZERO,used_qty=ZERO,del_flag=0,
confirmation_snapshot={'measurementVersion': source.snapshot.get('measurementVersion', 'legacy_single_side')})
db.add(usage); usages[k]=usage; items[k]=[]
# 保留曾移除行的稳定ID(唯一键包括软删除行)。
item=next((i for i in items[k] if _key(i)==sid),None)
if item is None:
item=m.PlPlanCertUsageItem(**_audit(user),usage_id=usage.id)
db.add(item); items[k].append(item)
values=dict(contract_cert_link_id=row['contractCertLinkId'],contract_cert_product_id=row['contractCertProductId'],
contract_scope_revision=row['scopeRevision'],contract_id=source.contract_id,contract_product_id=source.contract_product_id,
plan_product_id=source.plan_product_id,source_stop_type=source.source_stop_type,source_stop_id=source.source_stop_id,
source_stop_product_id=source.source_stop_product_id,related_stop_products=source.related_stop_products,
allocation_seq=max((i.allocation_seq for rows in active.values() for i in rows),default=0)+1,
source_unit=source.unit,planned_source_qty=source.quantity,allocated_source_qty=ZERO,planned_cert_qty=ZERO,
reserved_qty=ZERO,used_qty=ZERO,item_status='settled',source_fingerprint=source.fingerprint,
source_snapshot={**source.snapshot,'measurementVersion':source.snapshot.get('measurementVersion', 'legacy_single_side'),'productId':source.product_id},del_flag=0)
for name,value in values.items(): setattr(item,name,value)
active.setdefault(k,[]).append(item); item_map[key]=item