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

194 lines
12 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 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)
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)
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:
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 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
contract_numbers = {}
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
except HTTPException as exc:
if exc.status_code not in (403, 404):
raise
contract_numbers[cid] = None
options = []
for side, values in rows.items():
for row in values.values():
if products[row.plan_product_id].is_toxic == 1:
stop = stops[side][getattr(row, 'load_id' if side == 'load' else 'delivery_id')]
options.append({'sourceStopId': stop.id, 'stopId': stop.id,
'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})
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,
'measurementVersion': version,
'configuration': {**(plan.cert_configuration or {}), 'measurementVersion': version}, '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