450 lines
32 KiB
Python
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
|