diff --git a/.gitignore b/.gitignore index 5721e2532..71567d478 100644 --- a/.gitignore +++ b/.gitignore @@ -115,4 +115,5 @@ api_key # personal-dev:begin /.local/agent/ +/.local/secrets/ # personal-dev:end diff --git a/backend/app/models/pl_models.py b/backend/app/models/pl_models.py index 6f05ca0e8..f433f39c6 100644 --- a/backend/app/models/pl_models.py +++ b/backend/app/models/pl_models.py @@ -536,6 +536,7 @@ class PlPlanLoad(Base): notes = Column(String(500), nullable=True, comment="注意事项") generate_dibang = Column(SmallInteger, nullable=False, default=0, comment="是否生成地磅单 0-否 1-是") dibang_direction = Column(String(20), nullable=True, comment="地磅方向 outbound-出库 inbound-入库 none-无") + same_city = Column(SmallInteger, nullable=False, default=0, comment="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证") tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号") order_num = Column(Integer, nullable=True, comment="排序号") remark = Column(String(512), nullable=True, comment="备注") @@ -599,6 +600,7 @@ class PlPlanDelivery(Base): notes = Column(String(500), nullable=True, comment="注意事项") generate_dibang = Column(SmallInteger, nullable=False, default=0, comment="是否生成地磅单 0-否 1-是") dibang_direction = Column(String(20), nullable=True, comment="地磅方向 outbound-出库 inbound-入库 none-无") + same_city = Column(SmallInteger, nullable=False, default=0, comment="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证") tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号") order_num = Column(Integer, nullable=True, comment="排序号") remark = Column(String(512), nullable=True, comment="备注") diff --git a/backend/app/schemas/pl/plan.py b/backend/app/schemas/pl/plan.py index e00630c5a..a57941891 100644 --- a/backend/app/schemas/pl/plan.py +++ b/backend/app/schemas/pl/plan.py @@ -182,6 +182,7 @@ class PlanLoadBody(BaseModel): dibang_direction: Optional[str] = Field( default=None, alias="dibangDirection", description="地磅方向 outbound-出库 inbound-入库 none-无" ) + same_city: int = Field(default=0, alias="sameCity", description="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证") products: list[PlanStopProductBody] = Field(default_factory=list, description="装货段产品明细") contracts: Optional[list["PlanContractLink"]] = Field(default=None, description="装货段合同列表") remark: Optional[str] = Field(default=None, max_length=512, description="备注") @@ -203,6 +204,7 @@ class PlanDeliveryBody(BaseModel): dibang_direction: Optional[str] = Field( default=None, alias="dibangDirection", description="地磅方向 outbound-出库 inbound-入库 none-无" ) + same_city: int = Field(default=0, alias="sameCity", description="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证") products: list[PlanStopProductBody] = Field(default_factory=list, description="送货段产品明细") contracts: Optional[list["PlanContractLink"]] = Field(default=None, description="送货段合同列表") remark: Optional[str] = Field(default=None, max_length=512, description="备注") diff --git a/backend/app/schemas/pl/quota_usage.py b/backend/app/schemas/pl/quota_usage.py index b56354ac5..46a69c450 100644 --- a/backend/app/schemas/pl/quota_usage.py +++ b/backend/app/schemas/pl/quota_usage.py @@ -20,6 +20,7 @@ class UsageSource(InternalModel): related_stop_products: List[str] = Field(default_factory=list) fingerprint: str snapshot: dict = Field(default_factory=dict) + requires_transport: bool = True @property def source_id(self): diff --git a/backend/app/services/pl_plan_stop_service.py b/backend/app/services/pl_plan_stop_service.py index f48824747..fabddfdf8 100644 --- a/backend/app/services/pl_plan_stop_service.py +++ b/backend/app/services/pl_plan_stop_service.py @@ -1152,6 +1152,7 @@ def _resolve_or_create_plan_load( existing.notes = load.notes existing.generate_dibang = generate_dibang existing.dibang_direction = dibang_direction + existing.same_city = 1 if int(load.same_city or 0) == 1 else 0 existing.remark = load.remark existing.del_flag = 0 existing.update_by = user_id @@ -1173,6 +1174,7 @@ def _resolve_or_create_plan_load( notes=load.notes, generate_dibang=generate_dibang, dibang_direction=dibang_direction, + same_city=1 if int(load.same_city or 0) == 1 else 0, tenant_no=tenant_no, remark=load.remark, create_by=user_id, @@ -1439,6 +1441,7 @@ def replace_plan_stops( stop.seq = body.seq or 1 stop.generate_dibang, stop.dibang_direction = normalize_stop_dibang_fields( stop.customer_name, kind, body.generate_dibang, body.dibang_direction) + stop.same_city = 1 if int(getattr(body, "same_city", 0) or 0) == 1 else 0 stop.del_flag, stop.update_by, stop.update_time = 0, user_id, now model = PlPlanLoadProduct if kind == 'load' else PlPlanDeliveryProduct for idx, (row, product, linked) in enumerate(product_jobs or []): diff --git a/backend/app/services/pl_quota_allocation.py b/backend/app/services/pl_quota_allocation.py index 65544d24b..1300857b7 100644 --- a/backend/app/services/pl_quota_allocation.py +++ b/backend/app/services/pl_quota_allocation.py @@ -37,6 +37,7 @@ class SourceDemand: product_id: str quantity: Decimal unit: str + requires_transport: bool = True @dataclass(frozen=True) @@ -231,6 +232,8 @@ def allocate_plan(demands: Sequence[SourceDemand], purchases: Sequence[PermitCan key = (row.permit_kind, row.source_id, row.permit_id) s = sources.get(row.source_id) p = groups.get(row.permit_kind, {}).get(row.permit_id) + if s is not None and not s.requires_transport and row.permit_kind == 'transport': + raise AllocationInputError('同市来源不接受运输证分配') sq, pq = _quantity(row.source_quantity), _quantity(row.permit_quantity) if key in manual or s is None or p is None or s.product_id not in p.product_ids: raise AllocationInputError('人工分配标识重复或不适用') @@ -249,12 +252,19 @@ def allocate_plan(demands: Sequence[SourceDemand], purchases: Sequence[PermitCan routes = [] for sid, s in sorted(sources.items()): - for gid, g in sorted(groups['purchase'].items()): - for tid, t in sorted(groups['transport'].items()): - if gid in t.purchase_permit_ids and eligible('purchase', s, g) and eligible('transport', s, t): - routes.append((sid, gid, tid)) - if len(routes) > limits.max_variables: - raise AllocationIncomplete('分配计算未完成:匹配变量超限') + if s.requires_transport: + pairs = ( + (gid, tid) + for gid, g in sorted(groups['purchase'].items()) + for tid, t in sorted(groups['transport'].items()) + if gid in t.purchase_permit_ids and eligible('purchase', s, g) and eligible('transport', s, t) + ) + else: + pairs = ((gid, None) for gid, g in sorted(groups['purchase'].items()) if eligible('purchase', s, g)) + for gid, tid in pairs: + routes.append((sid, gid, tid)) + if len(routes) > limits.max_variables: + raise AllocationIncomplete('分配计算未完成:匹配变量超限') a, b = [], [] def constrain(coefficients, bound, equal=False): @@ -405,6 +415,7 @@ def validate_allocation_pairing( if len(demands) > limits.max_sources or len(transports) > limits.max_permits: raise AllocationIncomplete('匹配校验未完成:输入规模超限') sources = _unique(demands, 'source_id') + purchase_only = {sid for sid, row in sources.items() if not row.requires_transport} transport_map = _unique(transports, 'permit_id') for s in sources.values(): _quantity(s.quantity) @@ -434,13 +445,16 @@ def validate_allocation_pairing( groups[kind][key] = row for sid, s in sources.items(): total = sum(F(r.source_quantity) for (source_id, _), r in groups[kind].items() if source_id == sid) - if total != F(s.quantity): + expected = F(0) if kind == 'transport' and sid in purchase_only else F(s.quantity) + if total != expected: raise AllocationPairingError('分配未覆盖实际来源量,需要人工调整;保留原占用') purchase_ids = {pid for _, pid in groups['purchase']} if len(purchase_ids) + len(transport_map) > limits.max_permits: raise AllocationIncomplete('匹配校验未完成:证件数量超限') routes = [] for sid, gid in sorted(groups['purchase']): + if sid in purchase_only: + continue for source_id, tid in sorted(groups['transport']): if sid == source_id and gid in transport_map[tid].purchase_permit_ids: routes.append((sid, gid, tid)) @@ -450,9 +464,13 @@ def validate_allocation_pairing( for kind, rows in groups.items(): position = 1 if kind == 'purchase' else 2 for (sid, pid), row in sorted(rows.items()): + if sid in purchase_only: + continue v = [int(r[0] == sid and r[position] == pid) for r in routes] a.extend([v, [-x for x in v]]) b.extend([F(row.source_quantity), -F(row.source_quantity)]) + if not a: + return try: _solve(a, b, [0] * len(routes), budget) except InsufficientQuota as exc: diff --git a/backend/app/services/pl_quota_diagnostics.py b/backend/app/services/pl_quota_diagnostics.py index be7e81627..46b1f9e90 100644 --- a/backend/app/services/pl_quota_diagnostics.py +++ b/backend/app/services/pl_quota_diagnostics.py @@ -21,7 +21,13 @@ def allocation_diagnostics(demands, purchases, transports): grouped.append((code, source.unit)) for kind, permits in [('purchase', purchases), ('transport', transports)]: for code, unit in grouped: - sources = [s for s in demands if canonical_quantity_unit(s.unit) == code] + sources = [ + s for s in demands + if canonical_quantity_unit(s.unit) == code + and (kind == 'purchase' or getattr(s, 'requires_transport', True)) + ] + if not sources: + continue products = {s.product_id for s in sources} eligible = [p for p in permits if p.product_ids & products] demand = sum((s.quantity for s in sources), Decimal(0)) @@ -67,6 +73,8 @@ def missing_purchase_link_message(demands, purchases, transports, cert_nos): notices = [] seen = set() for source in demands: + if not getattr(source, 'requires_transport', True): + continue buys = [p for p in purchases if source.product_id in p.product_ids and same_quantity_unit(p.unit, source.unit)] moves = [t for t in transports if source.product_id in t.product_ids and same_quantity_unit(t.unit, source.unit)] if not buys or not moves: @@ -93,16 +101,17 @@ def foreign_unit_message(demands, purchases, transports, cert_nos): for source in demands: buys = [p for p in purchases if source.product_id in p.product_ids] moves = [t for t in transports if source.product_id in t.product_ids] + needs_transport = getattr(source, 'requires_transport', True) same_buys = [p for p in buys if _directly_usable(p, source.unit)] same_moves = [t for t in moves if _directly_usable(t, source.unit)] - if same_buys and same_moves: + if same_buys and (same_moves or not needs_transport): continue parts = [] if not same_buys: text = _cert_unit_text('purchase', [p for p in buys if not _directly_usable(p, source.unit)], cert_nos) if text: parts.append(f'购买证 {text}') - if not same_moves: + if needs_transport and not same_moves: text = _cert_unit_text('transport', [t for t in moves if not _directly_usable(t, source.unit)], cert_nos) if text: parts.append(f'运输证 {text}') diff --git a/backend/app/services/pl_quota_planning.py b/backend/app/services/pl_quota_planning.py index 7d85daa3c..4225ec486 100644 --- a/backend/app/services/pl_quota_planning.py +++ b/backend/app/services/pl_quota_planning.py @@ -35,8 +35,9 @@ def plan_fingerprint(sources): def classify_contract_cert_bindings(sources, bindings_by_contract, contract_labels): - """按合同产品的可用绑定选出购买证、运输证。缺任一证时拒绝安排。""" - purchase_ids, transport_ids, missing = [], [], [] + """按合同产品的可用绑定选出购买证、运输证。同市段只要求购买证。""" + purchase_ids, transport_ids = [], [] + missing_both, missing_purchase = [], [] for source in sources: found = {'purchase': [], 'transport': []} for binding in bindings_by_contract.get(source.contract_id) or []: @@ -52,13 +53,22 @@ def classify_contract_cert_bindings(sources, bindings_by_contract, contract_labe if source.contract_product_id not in product_ids or kind not in found or not cert_id: continue found[kind].append(cert_id) - if not found['purchase'] or not found['transport']: - missing.append(contract_labels.get(source.contract_id) or '未编号合同') + needs_transport = getattr(source, 'requires_transport', True) + label = contract_labels.get(source.contract_id) or '未编号合同' + if not found['purchase'] or (needs_transport and not found['transport']): + (missing_both if needs_transport else missing_purchase).append(label) purchase_ids.extend(found['purchase']) - transport_ids.extend(found['transport']) - if missing: - names = '、'.join(dict.fromkeys(missing)) - raise HTTPException(409, f'合同{names}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输') + if needs_transport: + transport_ids.extend(found['transport']) + messages = [] + if missing_both: + names = '、'.join(dict.fromkeys(missing_both)) + messages.append(f'合同{names}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输') + if missing_purchase: + names = '、'.join(dict.fromkeys(missing_purchase)) + messages.append(f'合同{names}的同市段尚未绑定购买证,请先到合同中绑定后再安排运输') + if messages: + raise HTTPException(409, ';'.join(messages)) def unique(values): return list(dict.fromkeys(values)) @@ -279,6 +289,7 @@ def get_plan_quota(db, user, plan_id): product = products[row.plan_product_id] stop = stops[side][getattr(row, 'load_id' if side == 'load' else 'delivery_id')] item = {'sourceStopId': stop.id, 'stopId': stop.id, + 'sameCity': int(getattr(stop, 'same_city', 0) or 0) == 1, 'stopOrder': getattr(stop, 'seq', None) or stop.order_num or 1, 'productId': row.product_id, 'productNo': row.product_no, 'sourceStopType': side, 'sourceStopProductId': row.id, diff --git a/backend/app/services/pl_quota_sources.py b/backend/app/services/pl_quota_sources.py index 36e25e34a..bc984ae2f 100644 --- a/backend/app/services/pl_quota_sources.py +++ b/backend/app/services/pl_quota_sources.py @@ -156,8 +156,10 @@ def extract_sources(db, user, plan_id, mapping=None, *, actual=False, lock=False 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')}) + same_city = int(getattr(stop, 'same_city', 0) or 0) == 1 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} + 'vehicle': plan.license_plate, 'trailer': plan.trailer_no, 'planType': plan.plan_type, + 'sameCity': 1 if same_city else 0} if measurement_version == 'per_stop_v2': snapshot['measurementVersion'] = measurement_version if preview_vehicle is not None: @@ -168,7 +170,7 @@ def extract_sources(db, user, plan_id, mapping=None, *, actual=False, lock=False 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) + fingerprint=fingerprint, snapshot=snapshot, requires_transport=not same_city) sources.append(source) if actual: quantity = getattr(row, actual_field) diff --git a/backend/app/services/pl_quota_usage_service.py b/backend/app/services/pl_quota_usage_service.py index dbe5b8e98..c5ffb5356 100644 --- a/backend/app/services/pl_quota_usage_service.py +++ b/backend/app/services/pl_quota_usage_service.py @@ -197,6 +197,7 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False, product=query(CrmContractProduct,id=s.contract_product_id,contract_id=s.contract_id,del_flag=0).first() if product is None or product.product_id!=s.product_id: raise ValueError('合同产品归属不符') scopes={}; candidates={'purchase':[],'transport':[]} + skipped_for_vehicle=False for k in sorted(selected): kind,pid=k; c=certs[k] if not query(m.PlCertQtyLog,cert_type=kind,cert_id=pid,event_type='opening',del_flag=0).first(): raise ValueError('证件尚未确认期初') @@ -206,7 +207,10 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False, if not c.valid_from <= r.on_date <= c.valid_to: raise ValueError('证件不在有效日期内') policy=usage_policy(c.valid_use_text) if policy=='needs_confirmation': raise ValueError('有效次数待确认') - if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])): raise ValueError('车号不在运输证范围') + # 合同上常有多张运输证。车号对不上的证不参与本次分配,只要还有一张覆盖该主车号即可安排。 + if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])): + skipped_for_vehicle=True + continue if policy=='once': previous=query(m.PlPlanCertUsage,cert_type=kind,cert_id=pid,del_flag=0).all() if c.opening_single_use_consumed or any(u.plan_id!=plan.id and (u.usage_status in ('reserved','in_transit','settled','exception') or (u.confirmation_snapshot or {}).get('executed')) for u in previous): @@ -220,11 +224,13 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False, refs=frozenset(x.purchase_cert_id for x in query(m.PlTransportCertPurchaseRef,transport_cert_id=pid,del_flag=0).all() if x.purchase_cert_id) if kind=='transport' else frozenset() own=usages[k].reserved_qty if k in usages else ZERO candidates[kind].append(PermitCandidate(pid,c.total_qty-c.used_qty-c.reserved_qty+own,c.quantity_unit,c.valid_to,frozenset(eligible),refs)) + if skipped_for_vehicle and not candidates['transport']: + raise ValueError('车号不在运输证范围') return sources,scopes,candidates def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_preview=False): sources,scopes,candidates=_candidate_context(db,user,r,plan,certs,usages,items,selected,preview,lock_preview) - demands=[SourceDemand(s.source_id,s.source_id,s.quantity,s.unit) for s in sources.values()] + demands=[SourceDemand(s.source_id,s.source_id,s.quantity,s.unit,s.requires_transport) for s in sources.values()] try: allocation=allocate_plan(demands,candidates['purchase'],candidates['transport'],manual_allocations=_manual(r.manual_allocations)) except InsufficientQuota as error: @@ -244,6 +250,15 @@ def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_previ # 零量也占用一次资格;纯计算器省略零流,账本补存一对真实适用证件。 for sid,s in sorted(sources.items()): if s.quantity != 0: continue + if not s.requires_transport: + buys=[p for p in candidates['purchase'] if sid in p.product_ids and ( + same_quantity_unit(p.unit, s.unit) or standard_conversion_ratio(s.unit, p.unit) is not None + or any(a.permit_kind=='purchase' and a.source_id==sid and a.permit_id==p.permit_id for a in r.manual_allocations))] + if not buys: + raise ValueError('零量同市来源仍须有适用购买证') + chosen=min(buys, key=lambda item: (item.expires_on, item.permit_id)) + rows.append(Allocation('purchase', sid, chosen.permit_id, ZERO, ZERO, s.unit, chosen.unit, 1)) + continue pairs=[(p,t) for p in candidates['purchase'] for t in candidates['transport'] if sid in p.product_ids and sid in t.product_ids and p.permit_id in t.purchase_permit_ids and (same_quantity_unit(p.unit, s.unit) or standard_conversion_ratio(s.unit, p.unit) is not None or any(a.permit_kind=='purchase' and a.source_id==sid and a.permit_id==p.permit_id for a in r.manual_allocations)) @@ -430,12 +445,13 @@ def _settle(db,user,r,certs,usages,items,plan): if any(u.usage_status!='settled' if adjusting else u.usage_status not in ('reserved','in_transit','exception') for k,u in usages.items() if k in active): raise ValueError('用证状态不允许当前结转动作') groups={'purchase':[],'transport':[]}; item_map={}; demands={} + transport_required={s.source_id: s.requires_transport for s in r.sources} for k,rows in active.items(): for i in rows: sid=_key(i) a=Allocation(k[0],sid,k[1],i.allocated_source_qty,i.planned_cert_qty,i.source_unit,usages[k].quantity_unit,i.allocation_seq) groups[k[0]].append(a); item_map[(k[0],sid,k[1])]=i - demands[sid]=SourceDemand(sid,sid,r.actual_by_source.get(sid),i.source_unit) + demands[sid]=SourceDemand(sid,sid,r.actual_by_source.get(sid),i.source_unit,transport_required.get(sid, True)) if not r.manual_actuals: groups, generated, conversion_error = _prepare_saved_conversion(groups, r.actual_by_source, certs) if conversion_error: diff --git a/backend/tests/test_pl_quota_allocation.py b/backend/tests/test_pl_quota_allocation.py index d79465bb2..89b8c307a 100644 --- a/backend/tests/test_pl_quota_allocation.py +++ b/backend/tests/test_pl_quota_allocation.py @@ -3,8 +3,8 @@ from decimal import Decimal as D import pytest from app.services.pl_quota_allocation import (SourceDemand, PermitCandidate, ManualAllocation, AllocationLimits, AllocationInputError, AllocationIncomplete, InsufficientQuota, SupplementalAllocationRequired, allocate_plan, settle_allocations) -def source(id='s', product='A', quantity='30', unit='t'): - return SourceDemand(id, product, D(quantity), unit) +def source(id='s', product='A', quantity='30', unit='t', requires_transport=True): + return SourceDemand(id, product, D(quantity), unit, requires_transport) def permit(id, quantity='30', products=('A',), refs=(), unit='t', expiry=1): return PermitCandidate(id, D(quantity), unit, date(2027, 1, expiry), frozenset(products), frozenset(refs)) @@ -153,6 +153,40 @@ def test_zero_demand_and_empty_plan(): assert allocate_plan([source(quantity='0')], [], []).transport_allocations == () +def test_same_city_demand_uses_purchase_certificate_only(): + result = allocate_plan([source('city', quantity='10', requires_transport=False)], [permit('g', '10')], []) + assert quantities(result.purchase_allocations) == [('g', D(10), D(10))] + assert result.transport_allocations == () + + +def test_same_city_shares_purchase_balance_and_skips_transport(): + city = source('city', 'A', '10', requires_transport=False) + cross = source('cross', 'B', '10') + result = allocate_plan( + [city, cross], + [permit('g', '30', ('A', 'B'))], + [permit('t', '10', ('A', 'B'), ('g',))], + ) + assert sum(row.source_quantity for row in result.purchase_allocations) == D(20) + assert {(row.source_id, row.permit_id) for row in result.transport_allocations} == {('cross', 't')} + with pytest.raises(InsufficientQuota): + allocate_plan( + [source('city', 'A', '10', requires_transport=False), source('cross', 'A', '10')], + [permit('g', '15')], + [permit('t', '10', refs=('g',))], + ) + + +def test_same_city_rejects_a_transport_manual_row(): + with pytest.raises(AllocationInputError, match='同市'): + allocate_plan( + [source(requires_transport=False)], + [permit('g', '30')], + [permit('t', '30', refs=('g',))], + manual_allocations=[ManualAllocation('transport', 's', 't', D(30), D(30))], + ) + + def test_one_purchase_and_two_transport_certificates(): result = allocate_plan( [source()], [permit('g', '30')], @@ -208,6 +242,13 @@ def test_settlement_pairing_29_consistent_passes(): assert validate_allocation_pairing([source(quantity='29')], actual_rows(plan.purchase_allocations), actual_rows(plan.transport_allocations), transports) is None +def test_same_city_settlement_does_not_require_transport_pairing(): + from app.services.pl_quota_allocation import Allocation, validate_allocation_pairing + demand = source('city', quantity='10', requires_transport=False) + purchase = Allocation('purchase', 'city', 'g', D(10), D(10), 't', 't', 1) + assert validate_allocation_pairing([demand], [purchase], [], []) is None + + @pytest.mark.parametrize('manual_values', [(8,10,0),(18,0,0)]) def test_zero_capacity_foreign_row_cannot_unlock_manual_reordering(manual_values): from app.services.pl_quota_allocation import Allocation diff --git a/backend/tests/test_pl_quota_diagnostics.py b/backend/tests/test_pl_quota_diagnostics.py index b8dc99f46..669315eb2 100644 --- a/backend/tests/test_pl_quota_diagnostics.py +++ b/backend/tests/test_pl_quota_diagnostics.py @@ -35,6 +35,17 @@ def test_sufficient_totals_do_not_claim_joint_feasibility(): assert all('引用' in row['reason'] for row in rows) +def test_same_city_shortfall_does_not_demand_a_transport_certificate(): + from app.services.pl_quota_diagnostics import allocation_diagnostics, insufficient_quantity_message + rows = allocation_diagnostics( + [SourceDemand('a', 'a', D('30'), '吨', False)], + [permit('p', '10', ['a'])], + [], + ) + assert [row['certType'] for row in rows] == ['purchase'] + assert insufficient_quantity_message(rows) == '购买证剩余10.000吨,本次需要30.000吨。证件余量不足,不能安排运输,请先调整到有余量后再安排' + + def test_missing_purchase_link_names_both_certificates(): from app.services.pl_quota_diagnostics import missing_purchase_link_message source = SourceDemand('a', 'a', D('30'), '吨') diff --git a/backend/tests/test_pl_quota_planning.py b/backend/tests/test_pl_quota_planning.py index 04071f3b9..75ff53624 100644 --- a/backend/tests/test_pl_quota_planning.py +++ b/backend/tests/test_pl_quota_planning.py @@ -69,6 +69,21 @@ def test_arrange_uses_active_contract_cert_bindings_and_names_the_gap(): ) +def test_same_city_stop_binds_purchase_certificate_only(): + from app.services.pl_quota_planning import classify_contract_cert_bindings + source = SimpleNamespace(contract_id='c1', contract_product_id='line-1', requires_transport=False) + purchase = { + 'certType': 'purchase', 'certId': 'buy-1', 'linkStatus': 'active', + 'products': [{'contractProductId': 'line-1', 'scopeStatus': 'active'}], + } + labels = {'c1': 'HT-1'} + assert classify_contract_cert_bindings([source], {'c1': [purchase]}, labels) == (['buy-1'], []) + with pytest.raises(HTTPException) as error: + classify_contract_cert_bindings([source], {'c1': []}, labels) + assert error.value.status_code == 409 + assert error.value.detail == '合同HT-1的同市段尚未绑定购买证,请先到合同中绑定后再安排运输' + + def test_supplement_is_explicit_and_archive_public_schema_preserves_intent(): from app.schemas.pl.plan import PlanArchiveBody from app.schemas.pl.quota_planning import PlanQuotaOperation diff --git a/backend/tests/test_pl_quota_usage.py b/backend/tests/test_pl_quota_usage.py index a0eecc9c9..80e0cc76a 100644 --- a/backend/tests/test_pl_quota_usage.py +++ b/backend/tests/test_pl_quota_usage.py @@ -245,7 +245,25 @@ def test_opening_consumed_and_expiry_vehicle_block(case): p.opening_single_use_consumed=0; p.valid_to=date(2000,1,1); c.db.flush() with pytest.raises(ValueError): run(c) p.valid_to=date(2099,12,31); c.db.flush() - with pytest.raises(ValueError): run(c,vehicle_no='unknown') + with pytest.raises(ValueError, match='车号不在运输证范围'): run(c,vehicle_no='unknown') + + +def test_reserve_uses_transport_cert_that_lists_the_vehicle(case): + """合同上多张运输证时,只用登记了本次主车号的证,不因其他证没有该车号而拒绝安排。""" + c = case + _second_pair(c) + c.db.get(c.m.PlPurchaseCert, 'p').total_qty = 100 + c.db.get(c.m.PlTransportCert, 't').total_qty = 100 + c.db.get(c.m.PlTransportCert, 't2').vehicle_numbers_json = ['OTHER'] + c.db.flush() + run(c, purchase_cert_ids=['p', 'p2'], transport_cert_ids=['t', 't2']) + assert c.db.get(c.m.PlTransportCert, 't').reserved_qty == 29 + assert c.db.get(c.m.PlTransportCert, 't2').reserved_qty == 0 + assert c.db.get(c.m.PlPurchaseCert, 'p2').reserved_qty == 0 + transport_usages = c.db.query(c.m.PlPlanCertUsage).filter_by(cert_type='transport', del_flag=0).all() + assert [u.cert_id for u in transport_usages if u.usage_status == 'reserved'] == ['t'] + with pytest.raises(ValueError, match='车号不在运输证范围'): + run(c, operation_id='none', vehicle_no='NONE', purchase_cert_ids=['p', 'p2'], transport_cert_ids=['t', 't2']) def test_no_cert_operation_is_still_idempotent(case): c=case diff --git a/docs/sql/migrations/2026-11-09-plan-stop-same-city.sql b/docs/sql/migrations/2026-11-09-plan-stop-same-city.sql new file mode 100644 index 000000000..60573837b --- /dev/null +++ b/docs/sql/migrations/2026-11-09-plan-stop-same-city.sql @@ -0,0 +1,31 @@ +-- 装货段、送货段增加「是否同市」。同市的易制毒段只校验、扣减购买证。 +-- MySQL 5.7。可重复执行。历史数据默认 0,仍按购买证和运输证同时校验。 +SET NAMES utf8mb4; + +SET @schema_name = DATABASE(); + +SET @load_exists = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'pl_plan_load' AND COLUMN_NAME = 'same_city' +); +SET @load_sql = IF( + @load_exists = 0, + 'ALTER TABLE pl_plan_load ADD COLUMN same_city tinyint NOT NULL DEFAULT 0 COMMENT ''是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证'' AFTER dibang_direction', + 'SELECT 1' +); +PREPARE load_stmt FROM @load_sql; +EXECUTE load_stmt; +DEALLOCATE PREPARE load_stmt; + +SET @delivery_exists = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'pl_plan_delivery' AND COLUMN_NAME = 'same_city' +); +SET @delivery_sql = IF( + @delivery_exists = 0, + 'ALTER TABLE pl_plan_delivery ADD COLUMN same_city tinyint NOT NULL DEFAULT 0 COMMENT ''是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证'' AFTER dibang_direction', + 'SELECT 1' +); +PREPARE delivery_stmt FROM @delivery_sql; +EXECUTE delivery_stmt; +DEALLOCATE PREPARE delivery_stmt; diff --git a/docs/sql/shanxia2.0.sql b/docs/sql/shanxia2.0.sql index 9443cb1f5..1c0c2507b 100644 --- a/docs/sql/shanxia2.0.sql +++ b/docs/sql/shanxia2.0.sql @@ -26711,6 +26711,7 @@ CREATE TABLE `pl_plan_delivery` ( `notes` varchar(500) DEFAULT NULL COMMENT '注意事项', `generate_dibang` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否生成地磅单 0-否 1-是', `dibang_direction` varchar(20) DEFAULT NULL COMMENT '地磅方向 outbound-出库 inbound-入库 none-无', + `same_city` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证', `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号', `order_num` int(11) DEFAULT NULL COMMENT '排序号', `remark` varchar(512) DEFAULT NULL COMMENT '备注', @@ -26794,6 +26795,7 @@ CREATE TABLE `pl_plan_load` ( `notes` varchar(500) DEFAULT NULL COMMENT '注意事项', `generate_dibang` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否生成地磅单 0-否 1-是', `dibang_direction` varchar(20) DEFAULT NULL COMMENT '地磅方向 outbound-出库 inbound-入库 none-无', + `same_city` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证', `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号', `order_num` int(11) DEFAULT NULL COMMENT '排序号', `remark` varchar(512) DEFAULT NULL COMMENT '备注', diff --git a/web-admin/scripts/cert-list-face.test.ts b/web-admin/scripts/cert-list-face.test.ts index 773f57faf..af4a1c549 100644 --- a/web-admin/scripts/cert-list-face.test.ts +++ b/web-admin/scripts/cert-list-face.test.ts @@ -3,6 +3,9 @@ import test from 'node:test' import { buyPartyName, certFaceFields, + certKindLabel, + certKindTagType, + compareCertUploadTime, expiryWarning, salePartyName } from '../src/views/pl/quota-cert/cert-list-face' @@ -49,3 +52,19 @@ test('only certificates expiring within seven days show a red warning label', () assert.equal(expiryWarning('2026-11-12', today), null) assert.equal(expiryWarning('', today), null) }) + +test('merged certificate lists follow upload time, then id, descending', () => { + assert.equal(certKindLabel('transport'), '运输证') + assert.equal(certKindLabel('purchase'), '购买证') + assert.equal(certKindTagType('transport'), 'warning') + assert.equal(certKindTagType('purchase'), 'success') + const rows = [ + { id: 'b', createTime: '2026-11-01 10:00:00' }, + { id: 'a', createTime: '2026-11-05 10:00:00' }, + { id: 'c', createTime: '2026-11-05 10:00:00' } + ].sort(compareCertUploadTime) + assert.deepEqual( + rows.map((row) => row.id), + ['c', 'a', 'b'] + ) +}) diff --git a/web-admin/src/api/pl/index.ts b/web-admin/src/api/pl/index.ts index dc5180ed7..edce7d8ed 100644 --- a/web-admin/src/api/pl/index.ts +++ b/web-admin/src/api/pl/index.ts @@ -480,6 +480,8 @@ export interface PlanLoad { notes?: string generateDibang?: number dibangDirection?: string + /** 1 时本段易制毒只校验购买证 */ + sameCity?: number remark?: string products?: PlanStopProduct[] contracts?: PlanContractLink[] @@ -502,6 +504,8 @@ export interface PlanDelivery { notes?: string generateDibang?: number dibangDirection?: string + /** 1 时本段易制毒只校验购买证 */ + sameCity?: number remark?: string deliveryMethod?: string products?: PlanStopProduct[] diff --git a/web-admin/src/api/pl/quota-cert.ts b/web-admin/src/api/pl/quota-cert.ts index 45ebcba1f..5113e318b 100644 --- a/web-admin/src/api/pl/quota-cert.ts +++ b/web-admin/src/api/pl/quota-cert.ts @@ -285,6 +285,8 @@ export interface QuotaSourceOption { contractId: string contractProductId: string partyName?: string + /** 本段勾选同市时只要求购买证 */ + sameCity?: boolean } export interface QuotaSourcePair { sourceStopType: 'load' | 'delivery' diff --git a/web-admin/src/components/business/pl-plan-delivery-section/index.vue b/web-admin/src/components/business/pl-plan-delivery-section/index.vue index 35b90cdf5..e78d39e60 100644 --- a/web-admin/src/components/business/pl-plan-delivery-section/index.vue +++ b/web-admin/src/components/business/pl-plan-delivery-section/index.vue @@ -162,6 +162,15 @@ @update:model-value="patchDelivery(index, { notes: $event })" /> + + + 同市只需购买证 + +
() - for (const row of rows) { - if (isPlInternalCustomerName(row.partyName)) continue - const certificates = row.certificates || [] - const ready = - certificates.some((cert) => cert.certType === 'purchase') && - certificates.some((cert) => cert.certType === 'transport') - if (ready) continue - const key = row.contractId || row.contractNo || row.productName || row.sourceStopProductId - missing.set(key, row.contractNo || '未编号合同') - } - if (!missing.size) return '' - return `合同${[...missing.values()].join('、')}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输` + return toxicStopCertBindingMessage(rows) } async function performArrangeAfterSave( diff --git a/web-admin/src/components/business/pl-plan-load-section/index.vue b/web-admin/src/components/business/pl-plan-load-section/index.vue index 1a417de51..8407b260c 100644 --- a/web-admin/src/components/business/pl-plan-load-section/index.vue +++ b/web-admin/src/components/business/pl-plan-load-section/index.vue @@ -110,6 +110,15 @@ @update:model-value="patch({ notes: $event })" /> + + + 同市只需购买证 + +

关联证件证面

- + +

没有读到这张证的证面档案。