Files
shanxia2.0/backend/app/services/pl_quota_sources.py
T
wuzilongandCursor 8e9536332a feat: 保存运输证件、报销付款和开发流程的当前实现。
补齐证书齐备、证件折算、移动用证和合同绑定,并带上报销批量发放、付款备注以及个人开发流程升级,便于继续讨论已安排计划的调整规则。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 21:42:33 +08:00

179 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""从真实装送段提取用证来源;保留历史单侧口径。"""
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