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

154 lines
9.2 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 _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 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)
config = op.configuration or PlanQuotaConfiguration.model_validate(plan.cert_configuration or {})
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)
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:
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:
sources, _ = extract_sources(db, user, plan_id, mapping, preview_vehicle=preview_vehicle)
return {'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, _ = 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, '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, '已安排计划请通过调整运输在同一事务重算用证')
# 草稿允许缺少条件确认,但真实来源身份及选证权限必须有效。
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)
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 get_plan_quota(db, user, plan_id):
with db.no_autoflush:
plan, products, rows, stops = source_context(db, user, plan_id)
options = []
for side, values in rows.items():
for row in values.values():
if products[row.plan_product_id].is_toxic == 1:
options.append({'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, 'contractProductId': row.contract_product_id,
'partyName': getattr(stops[side][getattr(row, 'load_id' if side == 'load' else 'delivery_id')], 'customer_name', None)})
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()
result = {'planId': plan.id, 'planNo': plan.plan_no, 'licensePlate': plan.license_plate,
'configuration': plan.cert_configuration or {}, 'sourceOptions': options,
'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