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

342 lines
18 KiB
Python

"""购买证/运输证档案服务。由调用方统一提交/回滚事务。"""
from datetime import datetime
from decimal import Decimal
import hashlib
import json
from uuid import uuid4
from fastapi import HTTPException
from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
from app.models.pl_quota_models import (
PlPurchaseCert, PlTransportCert, PlTransportCertPurchaseRef,
PlCertQtyLog, PlPlanCertUsage, PlContractCertLink, PlCertOperation,
)
from app.schemas.pl.quota_cert import (
PurchaseCertCreate, PurchaseCertUpdate, TransportCertCreate,
TransportCertUpdate, CertConfirmBody, CertVerifyBody, camel,
)
from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
MODELS = {'purchase': PlPurchaseCert, 'transport': PlTransportCert}
SCHEMAS = {'purchase': (PurchaseCertCreate, PurchaseCertUpdate), 'transport': (TransportCertCreate, TransportCertUpdate)}
def _model(cert_type):
if cert_type not in MODELS:
raise HTTPException(400, '未知证种')
return MODELS[cert_type]
def _get_row(db, user, cert_type, cert_id, lock=False):
model = _model(cert_type)
query = db.query(model).filter(model.id == cert_id, model.tenant_no == user.tenant_no, model.del_flag == 0)
if lock:
query = query.with_for_update().populate_existing()
row = query.first()
if row is None:
raise HTTPException(404, '证件不存在')
assert_pl_row_in_scope(db, row, model, user, user.tenant_no)
return row
def usage_policy(valid_use_text):
"""仅识别明确的证面原文,不从未知文字猜测次数。"""
return {'一次有效': 'once', '多次有效': 'multiple'}.get((valid_use_text or '').strip(), 'needs_confirmation')
def _assert_known_usage_policy(row):
if usage_policy(row.valid_use_text) == 'needs_confirmation':
raise HTTPException(400, '有效次数原文待确认,明确一次或多次有效后才能启用')
def _row_to_dict(row):
return {camel(c.name): getattr(row, c.name) for c in row.__table__.columns}
def _refs(db, user, cert_id, lock=False):
query = db.query(PlTransportCertPurchaseRef).filter_by(tenant_no=user.tenant_no, transport_cert_id=cert_id, del_flag=0)
if lock:
query = query.with_for_update().populate_existing()
return query.order_by(PlTransportCertPurchaseRef.create_time.desc()).all()
def get_cert(db, user, cert_type, cert_id, current=False):
row = _get_row(db, user, cert_type, cert_id, lock=current)
result = _row_to_dict(row)
result['usagePolicy'] = usage_policy(row.valid_use_text)
opening_query = db.query(PlCertQtyLog.id).filter_by(
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, event_type='opening', del_flag=0,
)
if current:
opening_query = opening_query.with_for_update()
result['openingConfirmed'] = opening_query.first() is not None
result['availableQty'] = row.total_qty - row.used_qty - row.reserved_qty
if cert_type == 'transport':
result['purchaseRefs'] = [_row_to_dict(r) for r in _refs(db, user, cert_id, lock=current)]
return result
def list_certs(db, user, cert_type, keyword=None, status=None):
model = _model(cert_type)
query = db.query(model).filter(model.tenant_no == user.tenant_no, model.del_flag == 0)
query = apply_pl_scope(query, model, user, db, user.tenant_no)
if keyword:
query = query.filter(model.cert_no.contains(keyword))
if status:
query = query.filter(model.status == status)
return [get_cert(db, user, cert_type, r.id) for r in query.order_by(model.create_time.desc()).all()]
def _audit(user):
return dict(id=str(uuid4()), tenant_no=user.tenant_no, create_by=user.id, create_dept_id=user.dept_id or '', update_by=user.id)
def _has_history(db, user, cert_type, cert_id):
return any(db.query(model.id).filter_by(tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id).with_for_update().first() is not None for model in (PlCertQtyLog, PlPlanCertUsage))
def _validate_refs(db, user, cert_id, submitted, historical, purchases, current=False):
if submitted is None:
raise HTTPException(400, '引用集合不可为null;清空请提交[]')
old = {r.id: r for r in _refs(db, user, cert_id, lock=current)}
ids, numbers = set(), set()
pending = []
for item in submitted:
rid = item.get('id')
if rid and (rid not in old or rid in ids):
raise HTTPException(400, '引用明细ID重复或不属于当前证件')
if item['reference_no'] in numbers:
raise HTTPException(400, '引用证号重复')
ids.add(rid); numbers.add(item['reference_no'])
previous = old.get(rid)
duplicate_query = db.query(PlTransportCertPurchaseRef).filter_by(
tenant_no=user.tenant_no, transport_cert_id=cert_id,
reference_no=item['reference_no'],
)
if current:
duplicate_query = duplicate_query.with_for_update().populate_existing()
duplicate = duplicate_query.first()
if duplicate and duplicate.id != rid:
# 此纯关联表以租户+运输证+引用证号为稳定业务键。
# 无ID可恢复唯一软删关系;客户端显式提交已删除ID仍在上方拒绝。
if rid is None and duplicate.del_flag == 1:
previous = duplicate
else:
raise HTTPException(400, '引用证号已存在;请使用原明细ID')
# 验证更新后的完整身份,但写入仍只采用本次显式提交的字段。
purchase_cert_id = item.get('purchase_cert_id', previous.purchase_cert_id if previous else None)
if purchase_cert_id:
purchase = purchases.get(purchase_cert_id)
if purchase is None:
raise HTTPException(409, '证面引用已变更,请刷新后重新保存')
if purchase.cert_no != item['reference_no']:
raise HTTPException(400, '引用证号与购买证不一致')
if historical and (not previous or previous.del_flag == 1 or any(getattr(previous,k) != v for k,v in item.items() if k in ('reference_type','reference_no','purchase_cert_id'))):
raise HTTPException(400, '已有历史用量,不能改变证面引用身份')
pending.append((previous, item))
removed = [r for rid,r in old.items() if rid not in ids]
if historical and removed:
raise HTTPException(400, '已有历史用量,不能移除证面引用')
return pending, removed
def prepare_cert(db, user, cert_type, data, cert_id=None, *, locked=None, effective_id=None):
model = _model(cert_type)
payload = data.model_dump(exclude_unset=True) if isinstance(data, BaseModel) else data
body = SCHEMAS[cert_type][1 if cert_id else 0](**payload)
values = body.model_dump(exclude_unset=True)
if not cert_id and cert_type == 'transport':
values.setdefault('vehicle_numbers_json', [])
refs_present = 'purchase_refs' in values
refs = values.pop('purchase_refs', None)
purchases = {}
if cert_type == 'transport' and refs_present:
previous = db.query(PlTransportCertPurchaseRef).filter_by(tenant_no=user.tenant_no, transport_cert_id=cert_id).all() if cert_id else []
restored = {item['reference_no']: item for item in (refs or [])}
purchase_ids = {r.purchase_cert_id for r in previous if r.purchase_cert_id and (
not r.del_flag or (r.reference_no in restored and 'purchase_cert_id' not in restored[r.reference_no]))}
purchase_ids.update(r['purchase_cert_id'] for r in (refs or []) if r.get('purchase_cert_id'))
# 与账本统一购买证→运输证顺序;当前引用若出现未锁证件,拒绝而不倒序补锁。
purchases = {pid: (locked.get(('purchase', pid)) if locked is not None else
_get_row(db, user, 'purchase', pid, lock=True)) for pid in sorted(purchase_ids)}
if any(row is None for row in purchases.values()):
raise HTTPException(409, '证面引用已变更,请重试')
row = (locked[(cert_type, cert_id)] if locked is not None else
_get_row(db, user, cert_type, cert_id, lock=True)) if cert_id else None
historical = bool(row and _has_history(db,user,cert_type,cert_id))
for key, value in values.items():
column = model.__table__.columns[key]
if value is None and not column.nullable:
raise HTTPException(400, '必填字段不可清空: ' + key)
if row and key == 'entry_mode' and value != row.entry_mode:
raise HTTPException(400, '首次建档方式不能修改')
if row and historical and key in ('cert_no','quantity_unit','total_qty','valid_use_text','product_name_on_cert','vehicle_numbers_json') and value != getattr(row,key):
raise HTTPException(400, '已有历史记录,身份和核定量变更须走有依据的调整流程')
if row and cert_type == 'purchase' and values.get('cert_no', row.cert_no) != row.cert_no:
referenced = db.query(PlTransportCertPurchaseRef.id).filter_by(
tenant_no=user.tenant_no, purchase_cert_id=row.id, del_flag=0,
).with_for_update().first()
if referenced:
raise HTTPException(400, '购买证已被运输证引用,不能修改证号破坏引用身份')
if row and row.status == 'void' and values.get('status', 'void') != 'void':
raise HTTPException(400, '作废证件不可恢复')
start = values.get('valid_from', row.valid_from if row else None)
end = values.get('valid_to', row.valid_to if row else None)
if start and end and start > end:
raise HTTPException(400, '有效期起始不能晚于截止')
no = values.get('cert_no', row.cert_no if row else '')
duplicate = db.query(model).filter_by(tenant_no=user.tenant_no, cert_no=no).first()
if duplicate and duplicate.id != cert_id:
raise HTTPException(400, '证号已存在,删除后也不得重建额度池')
effective_id = cert_id or effective_id or str(uuid4())
pending, removed = _validate_refs(db,user,effective_id,refs,historical,purchases,current=bool(cert_id)) if refs_present else ([],[])
changed = {key for key, value in values.items() if row is None or getattr(row, key) != value}
refs_changed = bool(removed) or any(previous is None or previous.del_flag == 1 or any(getattr(previous, key) != value for key, value in item.items() if key != 'id') for previous, item in pending)
return dict(model=model, row=row, values=values, cert_id=cert_id, effective_id=effective_id,
pending=pending, removed=removed, changed=changed, refs_changed=refs_changed)
def apply_cert(db, user, prepared):
model, row, values, cert_id, effective_id, pending, removed, changed, refs_changed = (
prepared[key] for key in ('model', 'row', 'values', 'cert_id', 'effective_id',
'pending', 'removed', 'changed', 'refs_changed'))
# 整批校验通过后才开始修改。
if not row:
audit = _audit(user); audit['id'] = effective_id
row = model(**audit)
db.add(row)
for key,value in values.items():
setattr(row,key,value)
row.update_by = user.id
if cert_id and (changed or refs_changed):
row.revision += 1
if any(k not in ('attachment_urls','remark','order_num','status') for k in changed) or refs_changed:
row.verified_by = None
row.verified_time = None
if row.status == 'active': row.status = 'suspended'
for previous,item in pending:
if previous is None:
previous = PlTransportCertPurchaseRef(**_audit(user), transport_cert_id=row.id)
db.add(previous)
for key,value in item.items():
if key != 'id': setattr(previous,key,value)
previous.del_flag = 0
previous.update_by = user.id
for previous in removed:
previous.del_flag = 1
previous.update_by = user.id
db.flush()
return row
def save_cert(db, user, cert_type, data, cert_id=None):
row = apply_cert(db, user, prepare_cert(db, user, cert_type, data, cert_id))
return get_cert(db, user, cert_type, row.id)
def delete_cert(db, user, cert_type, cert_id):
row = _get_row(db,user,cert_type,cert_id,lock=True)
if _has_history(db,user,cert_type,cert_id) or row.reserved_qty or row.used_qty:
raise HTTPException(400, '已有用量或流水的证件只能停用或作废')
if db.query(PlContractCertLink.id).filter_by(tenant_no=user.tenant_no,cert_type=cert_type,cert_id=cert_id,del_flag=0).with_for_update().first():
raise HTTPException(400, '证件已绑定合同')
if cert_type == 'purchase' and db.query(PlTransportCertPurchaseRef.id).filter_by(tenant_no=user.tenant_no,purchase_cert_id=cert_id,del_flag=0).with_for_update().first():
raise HTTPException(400, '证件被运输证引用')
row.del_flag = 1
row.update_by = user.id
if cert_type == 'transport':
for ref in _refs(db,user,cert_id,lock=True):
ref.del_flag = 1
ref.update_by = user.id
db.flush()
def _claim_operation(db, user, action, operation_id, digest):
"""与账本相同:先唯一INSERT仲裁操作号,再锁证;不存在的键不作间隙锁读。"""
query = db.query(PlCertOperation).filter_by(tenant_no=user.tenant_no, operation_id=operation_id)
operation = query.first()
if operation is None:
try:
with db.begin_nested():
operation = PlCertOperation(**_audit(user), operation_id=operation_id,
action=action, request_hash=digest)
db.add(operation)
db.flush()
return operation, False
except IntegrityError:
operation = query.with_for_update().populate_existing().one()
if operation.action != action or operation.request_hash != digest:
raise HTTPException(409, '操作号已用于不同请求')
return operation, True
def confirm_cert(db, user, cert_type, cert_id, data):
with db.begin_nested():
return _confirm_cert(db, user, cert_type, cert_id, data)
def _confirm_cert(db, user, cert_type, cert_id, data):
payload = data.model_dump() if isinstance(data,BaseModel) else data
body = CertConfirmBody(**payload)
_get_row(db,user,cert_type,cert_id)
digest = hashlib.sha256(json.dumps({'type':cert_type,'id':cert_id,**body.model_dump(mode='json')},sort_keys=True).encode()).hexdigest()
operation, replay = _claim_operation(db, user, 'opening', body.operation_id, digest)
if replay:
return get_cert(db,user,cert_type,cert_id,current=True)
row = _get_row(db,user,cert_type,cert_id,lock=True)
_assert_known_usage_policy(row)
if row.status == 'void': raise HTTPException(400, '作废证件不能启用')
if row.verified_time or _has_history(db,user,cert_type,cert_id):
raise HTTPException(400, '期初已确认,不能重复初始化额度')
if body.used_qty > row.total_qty:
raise HTTPException(400, '期初已用量不能超过核定量')
if not body.reason.strip():
raise HTTPException(400, '期初核对说明必填')
if (body.used_qty > 0 or body.single_use_consumed) and not ((body.evidence_urls or '').strip() or (body.source_note or '').strip()):
raise HTTPException(400, '历史期初须填写可追溯来源说明或提供凭据')
if usage_policy(row.valid_use_text) == 'once' and body.used_qty > 0 and not body.single_use_consumed:
raise HTTPException(400, '一次有效证已有用量时必须确认已使用')
db.add(PlCertQtyLog(**_audit(user), operation_id=body.operation_id,request_hash=digest,cert_type=cert_type,cert_id=cert_id,event_type='opening',quantity_unit=row.quantity_unit,total_delta=Decimal(0),reserved_delta=Decimal(0),used_delta=body.used_qty,before_total_qty=row.total_qty,after_total_qty=row.total_qty,before_reserved_qty=row.reserved_qty,after_reserved_qty=row.reserved_qty,before_used_qty=row.used_qty,after_used_qty=body.used_qty,reason=body.reason,evidence_urls=body.evidence_urls,event_snapshot={'singleUseConsumed':body.single_use_consumed,'sourceNote':body.source_note}))
row.used_qty = body.used_qty
row.opening_single_use_consumed = int(body.single_use_consumed)
row.status = 'active'
row.verified_by = user.id
row.verified_time = datetime.now()
row.update_by = user.id
operation.result_json = {'certType': cert_type, 'certId': cert_id}
db.flush()
return get_cert(db,user,cert_type,cert_id)
def verify_cert(db, user, cert_type, cert_id, data):
with db.begin_nested():
return _verify_cert(db, user, cert_type, cert_id, data)
def _verify_cert(db, user, cert_type, cert_id, data):
"""更正后复核/恢复,保留期初、当前余额和次数资格。"""
payload = data.model_dump() if isinstance(data, BaseModel) else data
body = CertVerifyBody(**payload)
_get_row(db, user, cert_type, cert_id)
request = {'type': cert_type, 'id': cert_id, **body.model_dump(mode='json')}
digest = hashlib.sha256(json.dumps(request, sort_keys=True).encode()).hexdigest()
operation, replay = _claim_operation(db, user, 'verify', body.operation_id, digest)
if replay:
return get_cert(db,user,cert_type,cert_id,current=True)
row = _get_row(db, user, cert_type, cert_id, lock=True)
_assert_known_usage_policy(row)
if row.status == 'void':
raise HTTPException(400, '作废证件不可恢复')
if not db.query(PlCertQtyLog.id).filter_by(tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, event_type='opening').with_for_update().first():
raise HTTPException(400, '必须先完成期初确认')
if not body.reason.strip():
raise HTTPException(400, '复核说明必填')
row.status = 'active'
row.verified_by = user.id
row.verified_time = datetime.now()
row.update_by = user.id
operation.result_json = {**request, 'revision': row.revision, 'verifiedBy': user.id, 'verifiedTime': row.verified_time.isoformat()}
db.flush()
return get_cert(db, user, cert_type, cert_id)