同市易制毒段只校验购买证;同步 Web/H5 表单、额度统计展示与相关回归测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
323 lines
18 KiB
Python
323 lines
18 KiB
Python
"""用证配置与内部账本命令组装。配置不等于额度授权,每次业务动作必须重读。"""
|
||
from datetime import date
|
||
import hashlib
|
||
import json
|
||
|
||
from fastapi import HTTPException
|
||
|
||
from app.models.pl_models import PlPlan
|
||
from app.models.pl_quota_models import PlPlanCertUsage, PlPlanCertUsageItem
|
||
from app.schemas.pl.quota_planning import PlanQuotaConfiguration, PlanQuotaOperation
|
||
from app.schemas.pl.quota_usage import UsageRequest, UsageManual
|
||
from app.services.pl_data_scope import assert_pl_row_in_scope
|
||
from app.services.pl_quota_cert_service import _get_row, _row_to_dict
|
||
from app.services.pl_quota_sources import extract_sources, source_context
|
||
from app.services.pl_plan_execution_lock import plan_execution_lock_reason
|
||
|
||
|
||
def validate_conditions(config, certs, fingerprint):
|
||
conditions = {}
|
||
for condition in config.conditions:
|
||
key = (condition.cert_type, condition.cert_id)
|
||
if key in conditions:
|
||
raise HTTPException(400, '同一证件条件确认重复')
|
||
conditions[key] = condition
|
||
for key, cert in certs.items():
|
||
condition = conditions.get(key)
|
||
if (condition is None or condition.cert_revision != cert.revision
|
||
or condition.source_fingerprint != fingerprint or not condition.parties_confirmed
|
||
or not condition.note.strip() or (key[0] == 'transport' and not condition.route_confirmed)):
|
||
raise HTTPException(409, '请逐证核对当前实际参与方、装送条件及运输路线;来源或证件变更后需重新确认')
|
||
|
||
|
||
def plan_fingerprint(sources):
|
||
return hashlib.sha256(json.dumps(sorted((s.source_id, s.fingerprint) for s in sources)).encode()).hexdigest()
|
||
|
||
|
||
def classify_contract_cert_bindings(sources, bindings_by_contract, contract_labels):
|
||
"""按合同产品的可用绑定选出购买证、运输证。同市段只要求购买证。"""
|
||
purchase_ids, transport_ids = [], []
|
||
missing_both, missing_purchase = [], []
|
||
for source in sources:
|
||
found = {'purchase': [], 'transport': []}
|
||
for binding in bindings_by_contract.get(source.contract_id) or []:
|
||
if binding.get('linkStatus') != 'active':
|
||
continue
|
||
product_ids = [
|
||
product.get('contractProductId')
|
||
for product in binding.get('products') or []
|
||
if product.get('scopeStatus') == 'active'
|
||
]
|
||
kind = binding.get('certType')
|
||
cert_id = binding.get('certId')
|
||
if source.contract_product_id not in product_ids or kind not in found or not cert_id:
|
||
continue
|
||
found[kind].append(cert_id)
|
||
needs_transport = getattr(source, 'requires_transport', True)
|
||
label = contract_labels.get(source.contract_id) or '未编号合同'
|
||
if not found['purchase'] or (needs_transport and not found['transport']):
|
||
(missing_both if needs_transport else missing_purchase).append(label)
|
||
purchase_ids.extend(found['purchase'])
|
||
if needs_transport:
|
||
transport_ids.extend(found['transport'])
|
||
messages = []
|
||
if missing_both:
|
||
names = '、'.join(dict.fromkeys(missing_both))
|
||
messages.append(f'合同{names}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输')
|
||
if missing_purchase:
|
||
names = '、'.join(dict.fromkeys(missing_purchase))
|
||
messages.append(f'合同{names}的同市段尚未绑定购买证,请先到合同中绑定后再安排运输')
|
||
if messages:
|
||
raise HTTPException(409, ';'.join(messages))
|
||
|
||
def unique(values):
|
||
return list(dict.fromkeys(values))
|
||
|
||
return unique(purchase_ids), unique(transport_ids)
|
||
|
||
|
||
def resolve_contract_bound_certs(db, user, sources):
|
||
from app.services.pl_quota_contract_service import _contract, get_contract_bindings
|
||
|
||
bindings, labels = {}, {}
|
||
for contract_id in sorted({source.contract_id for source in sources if source.contract_id}):
|
||
contract = _contract(db, user, contract_id)
|
||
labels[contract_id] = contract.contract_no or '未编号合同'
|
||
bindings[contract_id] = get_contract_bindings(db, user, contract_id)
|
||
return classify_contract_cert_bindings(sources, bindings, labels)
|
||
|
||
|
||
def _plan(db, user, plan_id, lock=False):
|
||
query = db.query(PlPlan).filter_by(id=plan_id, tenant_no=user.tenant_no, 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, PlPlan, user, user.tenant_no)
|
||
return row
|
||
|
||
|
||
def resolve_measurement_version(db, user, plan):
|
||
"""只信任持久化的服务端配置与账本,历史无版本记录保持原口径。"""
|
||
usages = db.query(PlPlanCertUsage).filter_by(tenant_no=user.tenant_no, plan_id=plan.id, del_flag=0).all()
|
||
if usages:
|
||
return ('per_stop_v2' if all((u.confirmation_snapshot or {}).get('measurementVersion') == 'per_stop_v2'
|
||
for u in usages) else 'legacy_single_side')
|
||
saved = (plan.cert_configuration or {}).get('measurementVersion')
|
||
if saved in ('per_stop_v2', 'legacy_single_side'):
|
||
return saved
|
||
if plan.transport_status == 'arranged' or plan.archive_status in ('archived', 'exception'):
|
||
return 'legacy_single_side'
|
||
return 'per_stop_v2'
|
||
|
||
|
||
def pin_measurement_version(db, user, plan):
|
||
"""安排改变状态前固化版本,与业务及额度在同一事务保存。"""
|
||
version = resolve_measurement_version(db, user, plan)
|
||
plan.cert_configuration = {**(plan.cert_configuration or {}), 'measurementVersion': version}
|
||
return version
|
||
|
||
|
||
def build_usage_request(db, user, plan_id, operation, action, *, lock=False, check_conditions=True, preview_vehicle=None):
|
||
"""调用者先flush已授权的计划/实际量变更,再组装;不能将客户端UsageRequest直接传账本。"""
|
||
op = operation if isinstance(operation, PlanQuotaOperation) else PlanQuotaOperation.model_validate(operation)
|
||
plan = _plan(db, user, plan_id, lock)
|
||
explicit = op.configuration is not None
|
||
config = op.configuration or PlanQuotaConfiguration.model_validate(plan.cert_configuration or {})
|
||
config = config.model_copy(update={"measurement_version": resolve_measurement_version(db, user, plan)})
|
||
mapping = None if config.source_mapping is None else [r.model_dump(by_alias=True) for r in config.source_mapping]
|
||
if action == 'release':
|
||
sources, actuals = [], {}
|
||
else:
|
||
sources, actuals = extract_sources(db, user, plan_id, mapping, actual=action in ('settle', 'actual_adjust'), lock=lock, preview_vehicle=preview_vehicle, measurement_version=config.measurement_version)
|
||
if action in ('reserve', 'resize') and not explicit and sources:
|
||
purchase_ids, transport_ids = resolve_contract_bound_certs(db, user, sources)
|
||
source_ids = {source.source_id for source in sources}
|
||
allowed = {('purchase', cert_id) for cert_id in purchase_ids} | {('transport', cert_id) for cert_id in transport_ids}
|
||
manual = [
|
||
row for row in config.manual_allocations
|
||
if row.source_id in source_ids and (row.permit_kind, row.permit_id) in allowed
|
||
]
|
||
config = config.model_copy(update={
|
||
'purchase_cert_ids': purchase_ids,
|
||
'transport_cert_ids': transport_ids,
|
||
'manual_allocations': manual,
|
||
'conditions': [],
|
||
})
|
||
keys = [('purchase', cid) for cid in config.purchase_cert_ids] + [('transport', cid) for cid in config.transport_cert_ids]
|
||
if len(set(keys)) != len(keys):
|
||
raise HTTPException(400, '证件选择重复')
|
||
if action in ('reserve', 'resize') or op.allow_supplement:
|
||
certs = {key: _get_row(db, user, *key, lock=False) for key in sorted(keys)}
|
||
if sources and not keys:
|
||
raise HTTPException(409, '请先在合同中绑定购买证和运输证后再安排运输')
|
||
if check_conditions and explicit:
|
||
validate_conditions(config, certs, plan_fingerprint(sources))
|
||
executed = bool(plan_execution_lock_reason(db, user.tenant_no, plan.id))
|
||
request = UsageRequest(
|
||
operation_id=op.operation_id, action=action, plan_id=plan_id, sources=sources,
|
||
allow_supplement=op.allow_supplement,
|
||
purchase_cert_ids=config.purchase_cert_ids, transport_cert_ids=config.transport_cert_ids,
|
||
vehicle_no=(preview_vehicle or {}).get('licensePlate', plan.license_plate) or '', on_date=max(date.today(), plan.plan_date or date.today()),
|
||
executed=executed, manual_allocations=[UsageManual(**r.model_dump()) for r in config.manual_allocations],
|
||
actual_by_source=actuals, manual_actuals=[UsageManual(**r.model_dump()) for r in op.manual_actuals],
|
||
reason=op.reason, evidence_urls=op.evidence_urls or '', source_note=op.source_note or '',
|
||
)
|
||
return request, config
|
||
|
||
|
||
def configuration_context(db, user, plan_id, config, preview_vehicle=None):
|
||
"""来源确认独立于额度是否充足,仅从当前可见计划读取事实。"""
|
||
config = config if isinstance(config, PlanQuotaConfiguration) else PlanQuotaConfiguration.model_validate(config)
|
||
mapping = None if config.source_mapping is None else [r.model_dump(by_alias=True) for r in config.source_mapping]
|
||
with db.no_autoflush:
|
||
config = config.model_copy(update={"measurement_version": resolve_measurement_version(db, user, _plan(db, user, plan_id))})
|
||
sources, _ = extract_sources(db, user, plan_id, mapping, preview_vehicle=preview_vehicle, measurement_version=config.measurement_version)
|
||
return {'measurementVersion': config.measurement_version, 'sourceFingerprint': plan_fingerprint(sources),
|
||
'sources': [s.model_dump(mode='json') for s in sources]}
|
||
|
||
|
||
def preview_plan(db, user, plan_id, config, preview_vehicle=None):
|
||
from app.services.pl_quota_usage_service import preview_usage
|
||
config = config if isinstance(config, PlanQuotaConfiguration) else PlanQuotaConfiguration.model_validate(config)
|
||
with db.no_autoflush:
|
||
request, config = build_usage_request(db, user, plan_id, PlanQuotaOperation(operationId='preview', configuration=config),
|
||
'resize', check_conditions=False, preview_vehicle=preview_vehicle)
|
||
result = preview_usage(db, user, request)
|
||
fingerprint = plan_fingerprint(request.sources)
|
||
certs = {key: _get_row(db, user, *key) for key in sorted(
|
||
[('purchase', cid) for cid in config.purchase_cert_ids] + [('transport', cid) for cid in config.transport_cert_ids])}
|
||
reasons = []
|
||
try:
|
||
validate_conditions(config, certs, fingerprint)
|
||
except HTTPException as error:
|
||
reasons.append(error.detail)
|
||
return {**result, 'measurementVersion': config.measurement_version, 'sourceFingerprint': fingerprint,
|
||
'sources': [s.model_dump(mode='json') for s in request.sources],
|
||
'status': 'needs_confirmation' if reasons else ('available' if request.sources else 'not_required'),
|
||
'reasons': reasons}
|
||
|
||
|
||
def save_configuration(db, user, plan_id, config):
|
||
from app.services.pl_plan_service import record_plan_change
|
||
config = config if isinstance(config, PlanQuotaConfiguration) else PlanQuotaConfiguration.model_validate(config)
|
||
plan = _plan(db, user, plan_id, lock=True)
|
||
if plan.transport_status == 'arranged' or plan.archive_status == 'archived':
|
||
raise HTTPException(409, '已安排计划请通过调整运输在同一事务重算用证')
|
||
# 草稿允许缺少条件确认,但真实来源身份及选证权限必须有效。
|
||
config = config.model_copy(update={"measurement_version": resolve_measurement_version(db, user, plan)})
|
||
mapping = None if config.source_mapping is None else [r.model_dump(by_alias=True) for r in config.source_mapping]
|
||
extract_sources(db, user, plan_id, mapping, lock=True, measurement_version=config.measurement_version)
|
||
for kind, ids in [('purchase', config.purchase_cert_ids), ('transport', config.transport_cert_ids)]:
|
||
for cid in sorted(ids):
|
||
_get_row(db, user, kind, cid)
|
||
plan.cert_configuration = config.model_dump(mode='json', by_alias=True)
|
||
plan.update_by = user.id
|
||
record_plan_change(db, tenant_no=user.tenant_no, plan_id=plan.id, change_type='cert_configuration',
|
||
change_content='更新用证来源配对、选证及条件确认', user_id=user.id,
|
||
user_name=getattr(user, 'name', None), dept_id=user.dept_id or '')
|
||
db.flush()
|
||
return plan.cert_configuration
|
||
|
||
|
||
def contract_cert_arrange_block(db, user, plan_id):
|
||
"""只读检查:易制毒来源必须能被合同上的购买证、运输证余量覆盖。"""
|
||
from app.services.pl_quota_allocation import InsufficientQuota
|
||
from app.services.pl_quota_diagnostics import insufficient_quantity_message
|
||
from app.services.pl_quota_usage_service import preview_usage
|
||
from app.services.pl_quota_waiver import assert_waived_sources, quota_cert_waived
|
||
|
||
plan = _plan(db, user, plan_id)
|
||
if quota_cert_waived(plan):
|
||
try:
|
||
assert_waived_sources(db, user, plan, lock=False)
|
||
except HTTPException as exc:
|
||
return exc.detail if isinstance(exc.detail, str) else '当前计划还不能安排运输'
|
||
return ''
|
||
|
||
try:
|
||
request, _ = build_usage_request(
|
||
db, user, plan_id, PlanQuotaOperation(operationId='arrange-check'),
|
||
'reserve', lock=False, check_conditions=False,
|
||
)
|
||
except HTTPException as exc:
|
||
return exc.detail if isinstance(exc.detail, str) else '当前计划还不能安排运输'
|
||
if not request.sources:
|
||
return ''
|
||
try:
|
||
preview_usage(db, user, request)
|
||
except InsufficientQuota as error:
|
||
return insufficient_quantity_message(getattr(error, 'diagnostics', None)) or str(error)
|
||
except ValueError as error:
|
||
return str(error)
|
||
return ''
|
||
|
||
|
||
def get_plan_quota(db, user, plan_id):
|
||
with db.no_autoflush:
|
||
plan, products, rows, stops = source_context(db, user, plan_id)
|
||
from app.services.pl_quota_contract_service import _contract, get_contract_bindings
|
||
contract_numbers, contract_bindings = {}, {}
|
||
for cid in {r.contract_id for values in rows.values() for r in values.values() if r.contract_id}:
|
||
try:
|
||
contract_numbers[cid] = _contract(db, user, cid).contract_no
|
||
bindings = get_contract_bindings(db, user, cid)
|
||
contract_bindings[cid] = []
|
||
for binding in bindings:
|
||
if binding['linkStatus'] != 'active':
|
||
continue
|
||
cert = _get_row(db, user, binding['certType'], binding['certId'])
|
||
contract_bindings[cid].append({
|
||
'certId': cert.id, 'certType': binding['certType'],
|
||
'certNo': cert.cert_no, 'quantityUnit': cert.quantity_unit,
|
||
'productIds': [p['contractProductId'] for p in binding['products']
|
||
if p['scopeStatus'] == 'active']})
|
||
except HTTPException as exc:
|
||
if exc.status_code not in (403, 404):
|
||
raise
|
||
contract_numbers[cid] = None
|
||
contract_bindings.pop(cid, None)
|
||
options, stop_products = [], []
|
||
for side in ('load', 'delivery'):
|
||
def display_order(row):
|
||
stop = stops[side][getattr(row, 'load_id' if side == 'load' else 'delivery_id')]
|
||
return (getattr(stop, 'seq', None) or stop.order_num or 1,
|
||
stop.id, row.order_num or 0, row.id)
|
||
|
||
for row in sorted(rows[side].values(), key=display_order):
|
||
product = products[row.plan_product_id]
|
||
stop = stops[side][getattr(row, 'load_id' if side == 'load' else 'delivery_id')]
|
||
item = {'sourceStopId': stop.id, 'stopId': stop.id,
|
||
'sameCity': int(getattr(stop, 'same_city', 0) or 0) == 1,
|
||
'stopOrder': getattr(stop, 'seq', None) or stop.order_num or 1,
|
||
'productId': row.product_id, 'productNo': row.product_no,
|
||
'sourceStopType': side, 'sourceStopProductId': row.id,
|
||
'planProductId': row.plan_product_id, 'productName': row.product_name,
|
||
'model': row.model, 'quantity': row.plan_qty, 'unit': row.unit,
|
||
'contractId': row.contract_id, 'contractNo': contract_numbers.get(row.contract_id),
|
||
'contractProductId': row.contract_product_id,
|
||
'partyName': stop.customer_name}
|
||
stop_products.append({**item, 'isToxic': product.is_toxic == 1,
|
||
'isExplosive': product.is_explosive == 1,
|
||
'bindingsReadable': not row.contract_id or row.contract_id in contract_bindings,
|
||
'certificates': [{k: v for k, v in cert.items() if k != 'productIds'}
|
||
for cert in contract_bindings.get(row.contract_id, [])
|
||
if row.contract_product_id in cert['productIds']]})
|
||
if product.is_toxic == 1:
|
||
options.append(item)
|
||
usages = db.query(PlPlanCertUsage).filter_by(tenant_no=user.tenant_no, plan_id=plan_id, del_flag=0).order_by(PlPlanCertUsage.create_time.desc()).all()
|
||
version = resolve_measurement_version(db, user, plan)
|
||
result = {'planId': plan.id, 'planNo': plan.plan_no, 'licensePlate': plan.license_plate,
|
||
'quotaCertWaived': int(getattr(plan, 'quota_cert_waived', 0) or 0) == 1,
|
||
'measurementVersion': version,
|
||
'configuration': {**(plan.cert_configuration or {}), 'measurementVersion': version}, 'sourceOptions': options,
|
||
'stopProducts': stop_products,
|
||
'canSaveConfiguration': plan.transport_status != 'arranged' and plan.archive_status != 'archived',
|
||
'usages': []}
|
||
for usage in usages:
|
||
_get_row(db, user, usage.cert_type, usage.cert_id)
|
||
items = db.query(PlPlanCertUsageItem).filter_by(tenant_no=user.tenant_no, usage_id=usage.id, del_flag=0).order_by(PlPlanCertUsageItem.allocation_seq).all()
|
||
result['usages'].append({**_row_to_dict(usage), 'items': [_row_to_dict(row) for row in items]})
|
||
return result
|