Files
shanxia2.0/backend/app/services/pl_quota_planning.py
T
wuzilongandCursor 522b4be705 feat(pl): 装送货段增加同市标记,并同步额度证件与计划表单。
同市易制毒段只校验购买证;同步 Web/H5 表单、额度统计展示与相关回归测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 16:34:58 +08:00

323 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""用证配置与内部账本命令组装。配置不等于额度授权,每次业务动作必须重读。"""
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