342 lines
18 KiB
Python
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)
|