152 lines
8.6 KiB
Python
152 lines
8.6 KiB
Python
"""从真实装送段提取用证来源;保留历史单侧口径。"""
|
|
import hashlib
|
|
import json
|
|
from decimal import Decimal
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from app.models.pl_models import (
|
|
PlPlan, PlPlanProduct, PlPlanLoad, PlPlanLoadProduct, PlPlanDelivery, PlPlanDeliveryProduct,
|
|
)
|
|
from app.schemas.pl.quota_usage import UsageSource
|
|
from app.services.pl_data_scope import assert_pl_row_in_scope
|
|
|
|
SIDE = {'load': (PlPlanLoad, PlPlanLoadProduct, 'load_id', 'inbound_qty'),
|
|
'delivery': (PlPlanDelivery, PlPlanDeliveryProduct, 'delivery_id', 'outbound_qty')}
|
|
|
|
|
|
def _rows(db, user, model, lock, **filters):
|
|
query = db.query(model).filter_by(tenant_no=user.tenant_no, del_flag=0, **filters)
|
|
if lock:
|
|
query = query.with_for_update().populate_existing()
|
|
return query.all()
|
|
|
|
|
|
def _facts(row):
|
|
return {key: (str(getattr(row, key)) if getattr(row, key) is not None else None)
|
|
for key in ('id', 'plan_product_id', 'product_id', 'unit', 'plan_qty', 'contract_id', 'contract_product_id')}
|
|
|
|
|
|
def source_context(db, user, plan_id, lock=False):
|
|
with db.no_autoflush:
|
|
return _source_context(db, user, plan_id, lock)
|
|
|
|
|
|
def _source_context(db, user, plan_id, lock=False):
|
|
plans = _rows(db, user, PlPlan, lock, id=plan_id)
|
|
if not plans:
|
|
raise HTTPException(404, '计划不存在')
|
|
plan = plans[0]
|
|
assert_pl_row_in_scope(db, plan, PlPlan, user, user.tenant_no)
|
|
products = {r.id: r for r in _rows(db, user, PlPlanProduct, lock, plan_id=plan_id)}
|
|
rows, stops = {}, {}
|
|
for side, (stop_model, item_model, parent_field, _) in SIDE.items():
|
|
stops[side] = {r.id: r for r in _rows(db, user, stop_model, lock, plan_id=plan_id)}
|
|
rows[side] = {r.id: r for r in _rows(db, user, item_model, lock, plan_id=plan_id)}
|
|
for row in rows[side].values():
|
|
if getattr(row, parent_field) not in stops[side]:
|
|
raise HTTPException(409, '存在未删除产品行关联无效装送段,请先修复计划来源')
|
|
product = products.get(row.plan_product_id)
|
|
if product is None or row.product_id != product.product_id:
|
|
raise HTTPException(409, '装送产品与计划明细身份不一致,请先完善计划')
|
|
return plan, products, rows, stops
|
|
|
|
|
|
def extract_sources(db, user, plan_id, mapping=None, *, actual=False, lock=False, preview_vehicle=None, measurement_version="legacy_single_side"):
|
|
"""mapping 仅选择真实段行及对侧追溯ID;数量/单位/合同/指纹全部由数据库生成。
|
|
|
|
per_stop_v2 自动提取每条装送段行,忽略旧客户端的单侧选择。
|
|
legacy_single_side 保留显式配对和唯一计量侧,避免历史账本静默重计。
|
|
"""
|
|
plan, products, rows, stops = source_context(db, user, plan_id, lock)
|
|
toxic = {pid for pid, row in products.items() if row.is_toxic == 1}
|
|
if measurement_version not in ("legacy_single_side", "per_stop_v2"):
|
|
raise HTTPException(400, "未知用证计量版本")
|
|
if not toxic:
|
|
if mapping and measurement_version == "legacy_single_side":
|
|
raise HTTPException(400, '该计划无需本次两证计量配置')
|
|
return [], {}
|
|
groups = {pid: {side: [r for r in values.values() if r.plan_product_id == pid]
|
|
for side, values in rows.items()} for pid in toxic}
|
|
if measurement_version == 'per_stop_v2':
|
|
chosen = [(side, row, []) for side, values in rows.items()
|
|
for row in values.values() if row.plan_product_id in toxic]
|
|
if {row.plan_product_id for _, row, _ in chosen} != toxic:
|
|
raise HTTPException(409, '用证计量来源未覆盖全部计划产品段行')
|
|
else:
|
|
if mapping is None:
|
|
mapping = []
|
|
for group in groups.values():
|
|
available = [side for side, values in group.items() if values]
|
|
if len(available) != 1:
|
|
raise HTTPException(409, '请明确装送产品配对及唯一用证计量侧')
|
|
side = available[0]
|
|
mapping.extend({'sourceStopType': side, 'sourceStopProductId': r.id,
|
|
'relatedStopProductIds': []} for r in group[side])
|
|
chosen, seen, measured_sides = [], set(), {}
|
|
covered_opposite = {pid: set() for pid in toxic}
|
|
for entry in mapping:
|
|
side, rid = entry.get('sourceStopType'), entry.get('sourceStopProductId')
|
|
if side not in SIDE or (side, rid) in seen:
|
|
raise HTTPException(400, '计量来源无效或重复')
|
|
seen.add((side, rid))
|
|
row = rows[side].get(rid)
|
|
if row is None or row.plan_product_id not in toxic:
|
|
raise HTTPException(400, '计量来源不属于当前计划易制毒产品')
|
|
pid = row.plan_product_id
|
|
if pid in measured_sides and measured_sides[pid] != side:
|
|
raise HTTPException(400, '同一物理产品不能同时按装送两侧计量')
|
|
measured_sides[pid] = side
|
|
opposite = 'delivery' if side == 'load' else 'load'
|
|
ids = entry.get('relatedStopProductIds', [])
|
|
if not isinstance(ids, list) or len(ids) != len(set(ids)):
|
|
raise HTTPException(400, '对侧来源关联无效或重复')
|
|
related = []
|
|
for other_id in ids:
|
|
other = rows[opposite].get(other_id)
|
|
if other is None or other.plan_product_id != pid:
|
|
raise HTTPException(400, '对侧产品不属于当前计划产品明细')
|
|
related.append(other)
|
|
if groups[pid][opposite] and not related:
|
|
raise HTTPException(400, '存在对侧装送产品,请明确配对')
|
|
covered_opposite[pid].update(ids)
|
|
chosen.append((side, row, sorted(related, key=lambda r: r.id)))
|
|
for pid, group in groups.items():
|
|
side = measured_sides.get(pid)
|
|
if side is None or {r.id for r in group[side]} != {r.id for s, r, _ in chosen if s == side and r.plan_product_id == pid}:
|
|
raise HTTPException(400, '用证计量来源未覆盖全部计划产品段行')
|
|
other_side = 'delivery' if side == 'load' else 'load'
|
|
if covered_opposite[pid] != {r.id for r in group[other_side]}:
|
|
raise HTTPException(400, '装送来源配对未覆盖全部对侧段行')
|
|
sources, actuals = [], {}
|
|
for side, row, related in sorted(chosen, key=lambda v: (v[0], v[1].id)):
|
|
if row.plan_qty is None or row.plan_qty < 0 or not row.unit or not row.contract_id or not row.contract_product_id:
|
|
raise HTTPException(409, '请先完善计量来源的数量、单位及合同产品关联')
|
|
_, _, parent_field, actual_field = SIDE[side]
|
|
stop = stops[side][getattr(row, parent_field)]
|
|
other_side = 'delivery' if side == 'load' else 'load'
|
|
stop_facts = []
|
|
for stop_side, value in [(side, row)] + [(other_side, r) for r in related]:
|
|
parent = stops[stop_side][getattr(value, SIDE[stop_side][2])]
|
|
stop_facts.append({key: getattr(parent, key, None) for key in ('id', 'supplier_id', 'supplier_name', 'customer_id', 'customer_name', 'address')})
|
|
snapshot = {'source': _facts(row), 'related': [_facts(r) for r in related], 'stops': stop_facts,
|
|
'vehicle': plan.license_plate, 'trailer': plan.trailer_no, 'planType': plan.plan_type}
|
|
if measurement_version == 'per_stop_v2':
|
|
snapshot['measurementVersion'] = measurement_version
|
|
if preview_vehicle is not None:
|
|
snapshot['vehicle'] = preview_vehicle.get('licensePlate', plan.license_plate)
|
|
snapshot['trailer'] = preview_vehicle.get('trailerNo', plan.trailer_no)
|
|
fingerprint = hashlib.sha256(json.dumps(snapshot, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
source = UsageSource(source_stop_type=side, source_stop_id=stop.id, source_stop_product_id=row.id,
|
|
plan_product_id=row.plan_product_id, contract_id=row.contract_id,
|
|
contract_product_id=row.contract_product_id, product_id=row.product_id,
|
|
quantity=row.plan_qty, unit=row.unit, related_stop_products=[r.id for r in related],
|
|
fingerprint=fingerprint, snapshot=snapshot)
|
|
sources.append(source)
|
|
if actual:
|
|
quantity = getattr(row, actual_field)
|
|
if quantity is None or quantity < 0:
|
|
raise HTTPException(409, '计量来源实际数量缺失或无效,请先保存真实数量')
|
|
actuals[source.source_id] = Decimal(quantity)
|
|
return sources, actuals
|