"""从真实装送段提取用证来源;保留历史单侧口径。""" 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