194 lines
12 KiB
Python
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
|