Files
shanxia2.0/backend/app/services/pl_quota_planning.py
T
wuzilongandCursor 95ecb7236e fix(pl): 计划详情用关联证件附件替换危化品证书区。
Web 与 H5 详情将关联购买证/运输证移到装送货后并以附件卡片展示;用证接口回填证件附件 URL。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 14:46:46 +08:00

362 lines
21 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)
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
customer_rule = plan_uses_customer_category(plan)
if action in ('reserve', 'resize') and not explicit and sources:
if customer_rule:
# 新规则:禁止手工选证;按客户+品类整笔自动选证
from app.services.pl_quota_customer_select import select_whole_cover_certs
vehicle = (preview_vehicle or {}).get('licensePlate', plan.license_plate) or ''
on_date = max(date.today(), plan.plan_date or date.today())
purchase_ids, transport_ids, manuals = select_whole_cover_certs(
db, user, sources, vehicle_no=vehicle, on_date=on_date,
)
config = config.model_copy(update={
'purchase_cert_ids': purchase_ids,
'transport_cert_ids': transport_ids,
'manual_allocations': manuals,
'conditions': [],
})
else:
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': [],
})
elif action in ('reserve', 'resize') and explicit and customer_rule:
raise HTTPException(409, '新规则下安排仅支持自动选证,请勿手工指定证件;选不到请标记证书齐备')
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:
msg = (
'找不到适用的购买证和运输证,请补充证件或标记证书齐备后再安排运输'
if customer_rule else
'请先在合同中绑定购买证和运输证后再安排运输'
)
raise HTTPException(409, msg)
if check_conditions and explicit and not customer_rule:
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
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
config = config if isinstance(config, PlanQuotaConfiguration) else PlanQuotaConfiguration.model_validate(config)
plan = _plan(db, user, plan_id, lock=True)
if plan_uses_customer_category(plan):
raise HTTPException(409, '新规则下安排仅自动选证,不支持手工保存用证配置')
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)
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
customer_rule = plan_uses_customer_category(plan)
result = {'planId': plan.id, 'planNo': plan.plan_no, 'licensePlate': plan.license_plate,
'quotaCertWaived': int(getattr(plan, 'quota_cert_waived', 0) or 0) == 1,
'certQuotaRuleVersion': getattr(plan, 'cert_quota_rule_version', None) or 'legacy',
'measurementVersion': version,
'configuration': {**(plan.cert_configuration or {}), 'measurementVersion': version}, 'sourceOptions': options,
'stopProducts': stop_products,
# 新规则禁止手工保存用证配置;安排仅自动选证
'canSaveConfiguration': (
not customer_rule
and plan.transport_status != 'arranged'
and plan.archive_status != 'archived'
),
'usages': []}
for usage in usages:
cert = _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()
payload = {**_row_to_dict(usage), 'items': [_row_to_dict(row) for row in items]}
# 详情展示证面附件:用量行本身无附件字段,从证件档案回填
payload['attachmentUrls'] = getattr(cert, 'attachment_urls', None) or ''
payload['certNo'] = payload.get('certNo') or getattr(cert, 'cert_no', None) or usage.cert_no_snapshot
result['usages'].append(payload)
return result