补齐证书齐备、证件折算、移动用证和合同绑定,并带上报销批量发放、付款备注以及个人开发流程升级,便于继续讨论已安排计划的调整规则。 Co-authored-by: Cursor <cursoragent@cursor.com>
179 lines
9.9 KiB
Python
179 lines
9.9 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
|
||
from app.services.pl_internal_customer import is_internal_customer_name
|
||
|
||
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 _internal_stop(stops, side, row):
|
||
_, _, parent_field, _ = SIDE[side]
|
||
stop = stops[side][getattr(row, parent_field)]
|
||
return is_internal_customer_name(getattr(stop, 'customer_name', None))
|
||
|
||
|
||
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, '装送来源配对未覆盖全部对侧段行')
|
||
# 山峡、云栋是本公司,该段可以不关联合同,也不单独占用购买证和运输证。
|
||
chosen = [item for item in chosen if not _internal_stop(stops, item[0], item[1])]
|
||
sources, actuals = [], {}
|
||
for side, row, related in sorted(chosen, key=lambda v: (v[0], v[1].id)):
|
||
missing = []
|
||
if row.plan_qty is None or row.plan_qty < 0:
|
||
missing.append('计划数量')
|
||
if not row.unit:
|
||
missing.append('单位')
|
||
contract_missing = not row.contract_id or not row.contract_product_id
|
||
if contract_missing:
|
||
missing.append('关联合同')
|
||
if missing:
|
||
_, _, parent_field, _ = SIDE[side]
|
||
stop = stops[side][getattr(row, parent_field)]
|
||
place = '装货' if side == 'load' else '送货'
|
||
party = (getattr(stop, 'customer_name', None) or '').strip()
|
||
where = f'{place}({party})' if party else place
|
||
name = getattr(row, 'product_name', None) or '产品'
|
||
if missing == ['关联合同']:
|
||
detail = f'{where}的「{name}」已经有产品和计划数量,但这一段还没有关联合同。请先选择合同后再安排运输。'
|
||
else:
|
||
detail = f'{where}的「{name}」还没有{"、".join(missing)},请补全后再安排运输。'
|
||
raise HTTPException(409, detail)
|
||
_, _, 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
|