diff --git a/backend/app/models/pl_quota_models.py b/backend/app/models/pl_quota_models.py index 21f422fe5..c7a43d022 100644 --- a/backend/app/models/pl_quota_models.py +++ b/backend/app/models/pl_quota_models.py @@ -213,8 +213,8 @@ class PlPlanCertUsageItem(Base): actual_source_qty = Column(Numeric(18,6), nullable=True, comment='归档时来源段产品完整实际数量快照,来源单位;未知为NULL') actual_allocated_source_qty = Column(Numeric(18,6), nullable=True, comment='归档时本证分配的来源实际数量;同产品同证种各证合计须等于完整实际量;异单位重新确认') planned_cert_qty = Column(Numeric(18,6), nullable=True, comment='自动分配或人工确认的安排用证量,证件单位;草稿可空,预占前必填') - reserve_input_mode = Column(String(16), nullable=True, comment='same_unit同单位带入/manual异单位人工填写') - reserve_conversion_note = Column(String(1000), nullable=True, comment='异单位安排折算说明,manual时必填;不存自动换算系数') + reserve_input_mode = Column(String(16), nullable=True, comment='same_unit同单位带入/standard固定质量体积换算/manual异单位人工填写') + reserve_conversion_note = Column(String(1000), nullable=True, comment='异单位人工折算说明;standard固定质量体积换算不填') reserve_confirmed_by = Column(String(64), nullable=True, comment='安排折算量确认人,服务端取当前用户') reserve_confirmed_time = Column(DateTime, nullable=True, comment='安排折算量确认时间') actual_cert_qty = Column(Numeric(18,6), nullable=True, comment='按实际来源量自动分配或人工确认的实际用证量,证件单位;不可用预占量替代') diff --git a/backend/app/services/pl_quota_allocation.py b/backend/app/services/pl_quota_allocation.py index 8582ac3a6..65544d24b 100644 --- a/backend/app/services/pl_quota_allocation.py +++ b/backend/app/services/pl_quota_allocation.py @@ -2,7 +2,7 @@ 所有量限制六位小数;内部使用 Fraction,输出不能精确表示时明确返回计算未完成。 运输引用通过 (来源, 购买证, 运输证) 流量约束匹配,而非仅验证证号存在。 -人工行固定该来源/证件的来源量与扣证量,不推导任何跨单位换算率。 +吨、千克、克和升、毫升按固定比例折成证面量。其余异单位只接受人工行,不推导密度或包装换算。 """ from dataclasses import dataclass from datetime import date @@ -12,7 +12,7 @@ from math import isfinite from time import monotonic from typing import FrozenSet, Mapping, Sequence, Tuple -from app.services.pl_quota_units import same_quantity_unit +from app.services.pl_quota_units import same_quantity_unit, standard_conversion_ratio class AllocationInputError(ValueError): @@ -238,8 +238,14 @@ def allocate_plan(demands: Sequence[SourceDemand], purchases: Sequence[PermitCan raise AllocationInputError('同单位数量必须相等,零数量必须对应') manual[key] = row + def convertible(source_unit, permit_unit): + ratio = standard_conversion_ratio(source_unit, permit_unit) + return None if ratio is None else F(ratio) + def eligible(kind, s, p): - return s.product_id in p.product_ids and (same_quantity_unit(s.unit, p.unit) or (kind, s.source_id, p.permit_id) in manual) + return s.product_id in p.product_ids and ( + same_quantity_unit(s.unit, p.unit) or convertible(s.unit, p.unit) is not None + or (kind, s.source_id, p.permit_id) in manual) routes = [] for sid, s in sorted(sources.items()): @@ -274,10 +280,13 @@ def allocate_plan(demands: Sequence[SourceDemand], purchases: Sequence[PermitCan if key in manual: row = manual[key] constrain(v, F(row.source_quantity), True) + factor = convertible(s.unit, p.unit) if same_quantity_unit(s.unit, p.unit): coefficients = [x + y for x, y in zip(coefficients, v)] elif key in manual: fixed += F(manual[key].permit_quantity) + elif factor is not None: + coefficients = [x + y * factor for x, y in zip(coefficients, v)] constrain(coefficients, F(p.available) - fixed) x = _solve(a, b, [0] * len(routes), budget) # 连续精确优化每个优先项,并锁定最优值;不会因局部贪心破坏全局可行性。 @@ -300,7 +309,13 @@ def allocate_plan(demands: Sequence[SourceDemand], purchases: Sequence[PermitCan if not amount: continue key = (kind, sid, p.permit_id) - pq = F(manual[key].permit_quantity) if key in manual else amount + factor = convertible(sources[sid].unit, p.unit) + if key in manual: + pq = F(manual[key].permit_quantity) + elif factor is not None: + pq = amount * factor + else: + pq = amount seqkey = (kind, sid) sequences[seqkey] = sequences.get(seqkey, 0) + 1 output[kind].append(Allocation(kind, sid, p.permit_id, _decimal(amount), _decimal(pq), sources[sid].unit, p.unit, sequences[seqkey])) diff --git a/backend/app/services/pl_quota_conversion.py b/backend/app/services/pl_quota_conversion.py index 2ce83e2ca..3400febca 100644 --- a/backend/app/services/pl_quota_conversion.py +++ b/backend/app/services/pl_quota_conversion.py @@ -5,7 +5,7 @@ from app.models.pl_models import ( from app.models.pl_quota_models import ( PlContractCertLink, PlContractCertProduct, PlPlanCertUsage, PlPurchaseCert, PlTransportCert, ) -from app.services.pl_quota_units import same_quantity_unit +from app.services.pl_quota_units import same_quantity_unit, standard_conversion_ratio _VERSIONS = ('per_stop_v2', 'legacy_single_side') @@ -56,7 +56,11 @@ def line_needs_conversion(unit, cert_units): left = str(unit or '').strip() if not left: return False - return any(str(value or '').strip() and not same_quantity_unit(left, value) for value in cert_units) + return any( + str(value or '').strip() and not same_quantity_unit(left, value) + and standard_conversion_ratio(left, value) is None + for value in cert_units + ) def plan_needs_unit_conversion(plan, lines, cert_units, snapshots): diff --git a/backend/app/services/pl_quota_diagnostics.py b/backend/app/services/pl_quota_diagnostics.py index 2ab10ab48..be7e81627 100644 --- a/backend/app/services/pl_quota_diagnostics.py +++ b/backend/app/services/pl_quota_diagnostics.py @@ -1,10 +1,10 @@ """不可行分配的容量说明;容量上界不能替代产品范围和双证引用的联合求解。""" from decimal import Decimal, ROUND_HALF_UP -from app.services.pl_quota_units import canonical_quantity_unit, same_quantity_unit +from app.services.pl_quota_units import canonical_quantity_unit, same_quantity_unit, standard_conversion_ratio _UNIT_LABELS = { - 'dun': '吨', 'kg': '千克', 'sheng': '升', 'ping': '瓶', 'dai': '袋', + 'g': '克', 'dun': '吨', 'kg': '千克', 'sheng': '升', 'ping': '瓶', 'dai': '袋', 'tong': '桶', 'hs': '毫升', 'set': '套', 'piece': '个', 'unit': '台', } @@ -25,8 +25,9 @@ def allocation_diagnostics(demands, purchases, transports): 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)) - foreign = any(not same_quantity_unit(p.unit, code) for p in eligible) - available = sum((p.available for p in eligible if same_quantity_unit(p.unit, code)), Decimal(0)) + covered = [_available_in_source_unit(p, code) for p in eligible] + foreign = any(value is None for value in covered) + available = sum((value for value in covered if value is not None), Decimal(0)) gap = max(Decimal(0), demand - available) reason = ('存在异单位证件,请逐来源人工填写换算量;不能按证面余额推算可覆盖量' if foreign else '余额不足,请补充该证种额度' if gap else @@ -42,6 +43,20 @@ def _display_qty(value): return format(Decimal(value).quantize(Decimal('0.001'), rounding=ROUND_HALF_UP), 'f') +def _available_in_source_unit(permit, source_unit): + """把可固定换算的证面余量折回计划单位。瓶、袋等无法换算时返回 None。""" + if same_quantity_unit(permit.unit, source_unit): + return permit.available + ratio = standard_conversion_ratio(source_unit, permit.unit) + if ratio is None: + return None + return permit.available / ratio + + +def _directly_usable(permit, source_unit): + return _available_in_source_unit(permit, source_unit) is not None + + def _display_unit(unit): text = str(unit or '').strip() return _UNIT_LABELS.get(text, text) @@ -78,17 +93,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] - same_buys = [p for p in buys if same_quantity_unit(p.unit, source.unit)] - same_moves = [t for t in moves if same_quantity_unit(t.unit, source.unit)] + 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: continue parts = [] if not same_buys: - text = _cert_unit_text('purchase', [p for p in buys if not same_quantity_unit(p.unit, source.unit)], cert_nos) + 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: - text = _cert_unit_text('transport', [t for t in moves if not same_quantity_unit(t.unit, source.unit)], cert_nos) + 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}') if not parts: diff --git a/backend/app/services/pl_quota_units.py b/backend/app/services/pl_quota_units.py index 7ecd9fe45..e104047c8 100644 --- a/backend/app/services/pl_quota_units.py +++ b/backend/app/services/pl_quota_units.py @@ -1,6 +1,8 @@ """证件与计划数量单位都按字典编码比较。历史证面可能直接存了中文标签。""" +from decimal import Decimal _LABEL_TO_CODE = { + '克': 'g', '千克': 'kg', '吨': 'dun', '升': 'sheng', @@ -13,6 +15,10 @@ _LABEL_TO_CODE = { '台': 'unit', } +# 同一量纲内的固定比例。数值是该单位折成最小单位后的倍数。 +_MASS_IN_GRAMS = {'g': Decimal(1), 'kg': Decimal(1000), 'dun': Decimal(1000000)} +_VOLUME_IN_MILLILITRES = {'hs': Decimal(1), 'sheng': Decimal(1000)} + def canonical_quantity_unit(unit) -> str: text = str(unit or '').strip() @@ -30,3 +36,15 @@ def display_quantity_unit(unit) -> str: def same_quantity_unit(left, right) -> bool: return canonical_quantity_unit(left) == canonical_quantity_unit(right) + + +def standard_conversion_ratio(source_unit, permit_unit): + """1 个计划单位等于多少证面单位。同单位、空单位和跨量纲返回 None。""" + source = canonical_quantity_unit(source_unit) + permit = canonical_quantity_unit(permit_unit) + if not source or not permit or source == permit: + return None + for table in (_MASS_IN_GRAMS, _VOLUME_IN_MILLILITRES): + if source in table and permit in table: + return table[source] / table[permit] + return None diff --git a/backend/app/services/pl_quota_usage_service.py b/backend/app/services/pl_quota_usage_service.py index a2619025c..dbe5b8e98 100644 --- a/backend/app/services/pl_quota_usage_service.py +++ b/backend/app/services/pl_quota_usage_service.py @@ -21,10 +21,18 @@ from app.services.pl_quota_contract_service import _contract from app.services.pl_quota_allocation import (SourceDemand, PermitCandidate, ManualAllocation, Allocation, allocate_plan, settle_allocations, validate_allocation_pairing, InsufficientQuota) from app.services.pl_quota_diagnostics import allocation_diagnostics -from app.services.pl_quota_units import same_quantity_unit +from app.services.pl_quota_units import same_quantity_unit, standard_conversion_ratio ZERO = Decimal('0') + +def _reserve_input_mode(source_unit, cert_unit): + if same_quantity_unit(source_unit, cert_unit): + return 'same_unit' + if standard_conversion_ratio(source_unit, cert_unit) is not None: + return 'standard' + return 'manual' + def _query(db, user, model, lock=True, **filters): query=db.query(model).filter_by(tenant_no=user.tenant_no, **filters) return query.with_for_update().populate_existing() if lock else query @@ -238,8 +246,8 @@ def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_previ if s.quantity != 0: 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 any(a.permit_kind=='purchase' and a.source_id==sid and a.permit_id==p.permit_id for a in r.manual_allocations)) - and (same_quantity_unit(t.unit, s.unit) or any(a.permit_kind=='transport' and a.source_id==sid and a.permit_id==t.permit_id for a in r.manual_allocations))] + 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)) + and (same_quantity_unit(t.unit, s.unit) or standard_conversion_ratio(s.unit, t.unit) is not None or any(a.permit_kind=='transport' and a.source_id==sid and a.permit_id==t.permit_id for a in r.manual_allocations))] if not pairs: from app.services.pl_quota_diagnostics import missing_purchase_link_message cert_nos = {(kind, cert.id): cert.cert_no for (kind, _), cert in certs.items()} @@ -286,7 +294,7 @@ def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_previ item=previous.get(a.source_id) if item is None: item=m.PlPlanCertUsageItem(**_audit(user),usage_id=u.id); db.add(item); items[k].append(item) - values=dict(contract_cert_link_id=link.id,contract_cert_product_id=scope.id,contract_scope_revision=link.scope_revision,contract_id=s.contract_id,contract_product_id=s.contract_product_id,plan_product_id=s.plan_product_id,source_stop_type=s.source_stop_type,source_stop_id=s.source_stop_id,source_stop_product_id=s.source_stop_product_id,related_stop_products=s.related_stop_products,allocation_seq=a.allocation_seq,source_unit=s.unit,planned_source_qty=s.quantity,allocated_source_qty=a.source_quantity,planned_cert_qty=a.permit_quantity,reserved_qty=a.permit_quantity,used_qty=ZERO,item_status='reserved',source_fingerprint=s.fingerprint,source_snapshot={**s.snapshot, 'measurementVersion': s.snapshot.get('measurementVersion', 'legacy_single_side'), 'productId': s.product_id},del_flag=0,reserve_input_mode='same_unit' if same_quantity_unit(s.unit, c.quantity_unit) else 'manual',reserve_conversion_note=notes.get((k[0],k[1],a.source_id)),reserve_confirmed_by=user.id,reserve_confirmed_time=datetime.now(),update_by=user.id) + values=dict(contract_cert_link_id=link.id,contract_cert_product_id=scope.id,contract_scope_revision=link.scope_revision,contract_id=s.contract_id,contract_product_id=s.contract_product_id,plan_product_id=s.plan_product_id,source_stop_type=s.source_stop_type,source_stop_id=s.source_stop_id,source_stop_product_id=s.source_stop_product_id,related_stop_products=s.related_stop_products,allocation_seq=a.allocation_seq,source_unit=s.unit,planned_source_qty=s.quantity,allocated_source_qty=a.source_quantity,planned_cert_qty=a.permit_quantity,reserved_qty=a.permit_quantity,used_qty=ZERO,item_status='reserved',source_fingerprint=s.fingerprint,source_snapshot={**s.snapshot, 'measurementVersion': s.snapshot.get('measurementVersion', 'legacy_single_side'), 'productId': s.product_id},del_flag=0,reserve_input_mode=_reserve_input_mode(s.unit, c.quantity_unit),reserve_conversion_note=notes.get((k[0],k[1],a.source_id)),reserve_confirmed_by=user.id,reserve_confirmed_time=datetime.now(),update_by=user.id) for name,value in values.items(): setattr(item,name,value) diff --git a/backend/tests/test_pl_quota_allocation.py b/backend/tests/test_pl_quota_allocation.py index 99ec9feba..d79465bb2 100644 --- a/backend/tests/test_pl_quota_allocation.py +++ b/backend/tests/test_pl_quota_allocation.py @@ -26,6 +26,44 @@ def test_transport_reference_forces_purchase_reallocation(): result = allocate_plan([source(quantity='10')], [permit('g1', '10'), permit('g2', '10')], [permit('t', '10', refs=('g2',))]) assert result.purchase_allocations[0].permit_id == 'g2' +def test_standard_mass_and_volume_units_convert_without_a_manual_row(): + tons = allocate_plan( + [source(quantity='10', unit='吨')], + [permit('g', '15000', unit='kg')], + [permit('t', '10', unit='dun', refs=('g',))], + ) + assert quantities(tons.purchase_allocations) == [('g', D('10'), D('10000'))] + assert quantities(tons.transport_allocations) == [('t', D('10'), D('10'))] + with pytest.raises(InsufficientQuota): + allocate_plan( + [source(quantity='10', unit='dun')], + [permit('g', '9000', unit='千克')], + [permit('t', '10', unit='吨', refs=('g',))], + ) + volume = allocate_plan( + [source(quantity='2', unit='升')], + [permit('g', '2500', unit='hs')], + [permit('t', '2', unit='sheng', refs=('g',))], + ) + assert quantities(volume.purchase_allocations) == [('g', D('2'), D('2000'))] + grams = allocate_plan( + [source(quantity='500', unit='克')], + [permit('g', '1', unit='kg')], + [permit('t', '1', unit='千克', refs=('g',))], + ) + assert quantities(grams.purchase_allocations) == [('g', D('500'), D('0.500000'))] + assert quantities(grams.transport_allocations) == [('t', D('500'), D('0.500000'))] + + +def test_packing_units_still_need_a_manual_row(): + with pytest.raises(InsufficientQuota): + allocate_plan( + [source(quantity='10', unit='吨')], + [permit('g', '100', unit='瓶')], + [permit('t', '10', unit='吨', refs=('g',))], + ) + + def test_manual_different_units_and_auto_rejected(): args = ([source(quantity='10')], [permit('g', '100', unit='kg')], [permit('t', '10', refs=('g',))]) with pytest.raises(InsufficientQuota): diff --git a/backend/tests/test_pl_quota_conversion.py b/backend/tests/test_pl_quota_conversion.py index 929b3aa01..1c9846fa5 100644 --- a/backend/tests/test_pl_quota_conversion.py +++ b/backend/tests/test_pl_quota_conversion.py @@ -36,8 +36,10 @@ def test_same_label_and_code_do_not_need_conversion(): assert plan_needs_unit_conversion(current, [line('L', 'load', '吨')], {'cp': ['dun']}, []) is False assert plan_needs_unit_conversion(current, [line('L', 'load', '吨', toxic=False)], {'cp': ['kg']}, []) is False assert plan_needs_unit_conversion( - current, [line('L', 'load', '吨', customer='安庆市山峡化工有限公司')], {'cp': ['kg']}, [], + current, [line('L', 'load', '吨', customer='安庆市山峡化工有限公司')], {'cp': ['ping']}, [], ) is True + assert plan_needs_unit_conversion(current, [line('L', 'load', '吨')], {'cp': ['kg']}, []) is False + assert plan_needs_unit_conversion(current, [line('L', 'load', '升')], {'cp': ['毫升']}, []) is False def test_unbound_and_unselected_legacy_sides_are_display_only(): @@ -47,13 +49,17 @@ def test_unbound_and_unselected_legacy_sides_are_display_only(): chosen = plan(transport_status='arranged', configuration={'sourceMapping': [ {'sourceStopType': 'load', 'sourceStopProductId': 'L', 'relatedStopProductIds': ['D']}, ]}) - assert plan_needs_unit_conversion(chosen, both, {'cp': ['kg']}, []) is True + assert plan_needs_unit_conversion(chosen, both, {'cp': ['瓶']}, []) is True + assert plan_needs_unit_conversion(chosen, both, {'cp': ['kg']}, []) is False def test_new_plan_counts_either_stop_when_a_bound_certificate_uses_another_unit(): current = plan() assert plan_needs_unit_conversion( current, [line('L', 'load', 'dun'), line('D', 'delivery', 'dun')], {'cp': ['kg']}, [], + ) is False + assert plan_needs_unit_conversion( + current, [line('L', 'load', 'dun'), line('D', 'delivery', 'dun')], {'cp': ['ping']}, [], ) is True assert plan_needs_unit_conversion(current, [line('L', 'load', 'dun')], {}, []) is False @@ -84,7 +90,7 @@ def test_list_and_badge_count_only_plans_with_a_real_unit_mismatch(monkeypatch): product_no='SKU', product_name='硫酸', unit='吨', contract_product_id='cp', tenant_no='t', )) db.add(PlPurchaseCert( - id='buy', cert_no='BUY', product_name_on_cert='硫酸', total_qty=1, quantity_unit='kg', + id='buy', cert_no='BUY', product_name_on_cert='硫酸', total_qty=1, quantity_unit='ping', valid_use_text='多次有效', buyer_name='买方', seller_name='卖方', tenant_no='t', )) db.add(PlContractCertLink( diff --git a/backend/tests/test_pl_quota_diagnostics.py b/backend/tests/test_pl_quota_diagnostics.py index 0797f68e4..b8dc99f46 100644 --- a/backend/tests/test_pl_quota_diagnostics.py +++ b/backend/tests/test_pl_quota_diagnostics.py @@ -18,11 +18,14 @@ def test_shared_capacity_is_counted_once_per_kind_and_unit(): assert rows[1]['gapLowerBound'] == '30' -def test_foreign_unit_is_not_summed_or_implicitly_converted(): +def test_standard_units_convert_before_the_gap_and_packing_units_do_not(): from app.services.pl_quota_diagnostics import allocation_diagnostics - rows = allocation_diagnostics([SourceDemand('a', 'a', D('30'), '吨')], [permit('p', '1000', ['a'], '千克')], []) - assert rows[0]['availableUpperBound'] is None and rows[0]['gapLowerBound'] is None - assert '人工' in rows[0]['reason'] + mass = allocation_diagnostics([SourceDemand('a', 'a', D('30'), '吨')], [permit('p', '1000', ['a'], '千克')], []) + assert mass[0]['availableUpperBound'] == '1' and mass[0]['gapLowerBound'] == '29' + assert mass[0]['reason'] == '余额不足,请补充该证种额度' + packed = allocation_diagnostics([SourceDemand('a', 'a', D('30'), '吨')], [permit('p', '1000', ['a'], '瓶')], []) + assert packed[0]['availableUpperBound'] is None and packed[0]['gapLowerBound'] is None + assert '人工' in packed[0]['reason'] def test_sufficient_totals_do_not_claim_joint_feasibility(): @@ -55,14 +58,21 @@ def test_missing_purchase_link_names_both_certificates(): def test_foreign_unit_message_names_plan_unit_and_certificates(): from app.services.pl_quota_diagnostics import foreign_unit_message - source = SourceDemand('a', 'a', D('1'), 'kg') + source = SourceDemand('a', 'a', D('1'), 'ping') message = foreign_unit_message( [source], [permit('buy', '100', ['a'], 'dun')], [permit('move', '100', ['a'], '吨', refs=frozenset({'buy'}))], {('purchase', 'buy'): 'G34260485131004', ('transport', 'move'): 'Y34265019298950'}, ) - assert '计划产品单位是千克' in message + assert '计划产品单位是瓶' in message + convertible = foreign_unit_message( + [SourceDemand('a', 'a', D('1'), 'kg')], + [permit('buy', '100', ['a'], 'dun')], + [permit('move', '100', ['a'], '吨', refs=frozenset({'buy'}))], + {('purchase', 'buy'): 'G34260485131004', ('transport', 'move'): 'Y34265019298950'}, + ) + assert convertible == '' assert '购买证 G34260485131004(吨)' in message assert '运输证 Y34265019298950(吨)' in message assert '查看用证' in message diff --git a/backend/tests/test_pl_quota_usage.py b/backend/tests/test_pl_quota_usage.py index 07beb0d8a..a0eecc9c9 100644 --- a/backend/tests/test_pl_quota_usage.py +++ b/backend/tests/test_pl_quota_usage.py @@ -145,12 +145,28 @@ def test_reserve_without_transport_purchase_reference_names_the_certificates(cas assert c.db.get(c.m.PlPurchaseCert, 'p').reserved_qty == 0 +def test_standard_mass_conversion_reserves_and_archives_without_a_manual_coefficient(case): + c = case + sources = request()['sources'] + sources[0]['unit'] = 'kg' + sources[0]['quantity'] = '29' + run(c, sources=sources) + assert c.db.get(c.m.PlPurchaseCert, 'p').reserved_qty == D('0.029') + assert c.db.get(c.m.PlTransportCert, 't').reserved_qty == D('0.029') + items = c.db.query(c.m.PlPlanCertUsageItem).all() + assert items and all(item.reserve_input_mode == 'standard' for item in items) + run(c, operation_id='settle', action='settle', sources=sources, actual_by_source={'load:sp': '18'}) + assert c.db.get(c.m.PlPurchaseCert, 'p').used_qty == D('0.018') + assert c.db.get(c.m.PlTransportCert, 't').used_qty == D('0.018') + assert c.db.get(c.m.PlPurchaseCert, 'p').reserved_qty == 0 + + def test_different_unit_names_the_certificates_before_arrange(case): from app.services.pl_quota_allocation import InsufficientQuota c = case sources = request()['sources'] - sources[0]['unit'] = 'kg' - with pytest.raises(InsufficientQuota, match='计划产品单位是千克') as caught: + sources[0]['unit'] = 'ping' + with pytest.raises(InsufficientQuota, match='计划产品单位是瓶') as caught: run(c, sources=sources) assert '购买证 p(吨)' in str(caught.value) assert '运输证 t(吨)' in str(caught.value) @@ -281,7 +297,7 @@ def test_split_pairing_failure_does_not_partially_settle(case): assert all(u.used_qty==0 for u in c.db.query(c.m.PlPlanCertUsage).all()) def test_zero_foreign_unit_still_requires_manual_pair(case): - c=case; c.db.get(c.m.PlTransportCert,'t').quantity_unit='kg'; c.db.flush() + c=case; c.db.get(c.m.PlTransportCert,'t').quantity_unit='ping'; c.db.flush() sources=request()['sources']; sources[0]['quantity']='0' manual=[dict(permit_kind='transport',source_id='load:sp',permit_id='t',source_quantity='0',permit_quantity='0',note='零量人工核实')] run(c,sources=sources,manual_allocations=manual) diff --git a/web-admin/scripts/quota-stop-display.test.ts b/web-admin/scripts/quota-stop-display.test.ts index 01240284e..9424462ca 100644 --- a/web-admin/scripts/quota-stop-display.test.ts +++ b/web-admin/scripts/quota-stop-display.test.ts @@ -131,7 +131,8 @@ test('unit conversion is only required when the plan unit differs from a bound c ...load, certificates: [ { certType: 'purchase', certId: 'buy', certNo: 'BUY', quantityUnit: '吨' }, - { certType: 'transport', certId: 'move', certNo: 'MOVE', quantityUnit: '千克' } + { certType: 'transport', certId: 'move', certNo: 'MOVE', quantityUnit: '千克' }, + { certType: 'transport', certId: 'pack', certNo: 'PACK', quantityUnit: '瓶' } ] } ] @@ -141,7 +142,7 @@ test('unit conversion is only required when the plan unit differs from a bound c assert.deepEqual(same, []) assert.deepEqual( different.map((row) => row.cert.certNo), - ['MOVE'] + ['PACK'] ) const labelAndCode = unitConversions( { @@ -151,7 +152,8 @@ test('unit conversion is only required when the plan unit differs from a bound c ...load, certificates: [ { certType: 'purchase', certId: 'buy', certNo: 'BUY', quantityUnit: '吨' }, - { certType: 'transport', certId: 'move', certNo: 'MOVE', quantityUnit: '千克' } + { certType: 'transport', certId: 'move', certNo: 'MOVE', quantityUnit: '千克' }, + { certType: 'transport', certId: 'pack', certNo: 'PACK', quantityUnit: '瓶' } ] } ] @@ -160,7 +162,27 @@ test('unit conversion is only required when the plan unit differs from a bound c ) assert.deepEqual( labelAndCode.map((row) => row.cert.certNo), - ['MOVE'] + ['PACK'] + ) + const volume = unitConversions( + { + ...plan, + stopProducts: [ + { + ...load, + unit: '升', + certificates: [ + { certType: 'purchase', certId: 'ml', certNo: 'ML', quantityUnit: '毫升' }, + { certType: 'transport', certId: 'pack', certNo: 'PACK', quantityUnit: '瓶' } + ] + } + ] + }, + [{ ...load, unit: '升' }] + ) + assert.deepEqual( + volume.map((row) => row.cert.certNo), + ['PACK'] ) }) test('conversion coefficient scales the certificate quantity and can be recovered', () => { diff --git a/web-admin/scripts/random-operation-id.test.ts b/web-admin/scripts/random-operation-id.test.ts new file mode 100644 index 000000000..b0b4b0242 --- /dev/null +++ b/web-admin/scripts/random-operation-id.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { randomOperationId } from '../src/utils/random-id' + +const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +test('secure context keeps the native randomUUID value', () => { + assert.equal( + randomOperationId({ randomUUID: () => 'native-operation-id' }), + 'native-operation-id' + ) +}) + +test('insecure context still returns a uuid from getRandomValues', () => { + const insecure = { + getRandomValues(bytes: Uint8Array) { + crypto.getRandomValues(bytes) + return bytes + } + } + const first = randomOperationId(insecure) + const second = randomOperationId(insecure) + assert.match(first, uuidV4) + assert.match(second, uuidV4) + assert.notEqual(first, second) + assert.ok(first.length <= 64) +}) + +test('missing web crypto still returns a bounded operation id', () => { + const id = randomOperationId({}) + assert.match(id, uuidV4) + assert.ok(id.length <= 64) +}) diff --git a/web-admin/src/components/business/pl-plan-archive-dialog/index.vue b/web-admin/src/components/business/pl-plan-archive-dialog/index.vue index bb479130f..7adf63984 100644 --- a/web-admin/src/components/business/pl-plan-archive-dialog/index.vue +++ b/web-admin/src/components/business/pl-plan-archive-dialog/index.vue @@ -286,6 +286,7 @@ import { usePlFreightCalc, resolveFreightUnitLabel } from '@/composables/usePlFreightCalc' import { useDictStore } from '@/store/modules/dict' import { pickBxData } from '@/utils/http/bx' + import { randomOperationId } from '@/utils/random-id' import { mapDeliveriesFromApi, mapLegacyPlanToStops, @@ -1641,7 +1642,8 @@ try { const archiveBody = { ...frozenBody, confirmOverage } const signature = JSON.stringify({ planId: targetId, archiveBody }) - if (signature !== quotaArchiveAttempt.signature) quotaArchiveAttempt = { signature, id: crypto.randomUUID() } + if (signature !== quotaArchiveAttempt.signature) + quotaArchiveAttempt = { signature, id: randomOperationId() } const response = await planApi.archive( targetId, { ...archiveBody, operationId: quotaArchiveAttempt.id }, diff --git a/web-admin/src/components/business/pl-plan-form-dialog/index.vue b/web-admin/src/components/business/pl-plan-form-dialog/index.vue index 9c52ae87d..2e6be810a 100644 --- a/web-admin/src/components/business/pl-plan-form-dialog/index.vue +++ b/web-admin/src/components/business/pl-plan-form-dialog/index.vue @@ -663,6 +663,7 @@ } from '@/api/pl' import { planQuotaApi, type QuotaSourceOption } from '@/api/pl/quota-cert' import { toxicArrangeBlockMessage } from '@/utils/pl-quota-cert-waiver' + import { randomOperationId } from '@/utils/random-id' import { arrangeBlockedMessage, isExecutionStarted } from '@/utils/pl-plan-action-guards' import { usePlRemoteCarrierOptions, @@ -2720,7 +2721,7 @@ }) const signature = JSON.stringify({ planId, payload }) - if (signature !== arrangeAttempt.signature) arrangeAttempt = { signature, id: crypto.randomUUID() } + if (signature !== arrangeAttempt.signature) arrangeAttempt = { signature, id: randomOperationId() } try { await submitPlanArrange( planId, diff --git a/web-admin/src/components/business/pl-plan-quota-dialog/stop-model.ts b/web-admin/src/components/business/pl-plan-quota-dialog/stop-model.ts index 5e87fb7bb..de0461eaa 100644 --- a/web-admin/src/components/business/pl-plan-quota-dialog/stop-model.ts +++ b/web-admin/src/components/business/pl-plan-quota-dialog/stop-model.ts @@ -36,6 +36,22 @@ export function sameCertUnit(left?: string | null, right?: string | null) { return normalizeProductUnitValue(planUnit) === normalizeProductUnitValue(certUnit) } +const MASS_IN_GRAMS: Record = { g: 1, kg: 1000, dun: 1000000 } +const VOLUME_IN_MILLILITRES: Record = { hs: 1, sheng: 1000 } + +/** 与后端 standard_conversion_ratio 同一口径:吨/千克/克、升/毫升不用手填系数。 */ +export function hasStandardUnitConversion(left?: string | null, right?: string | null) { + const planUnit = String(left || '').trim() + const certUnit = String(right || '').trim() + if (!planUnit || !certUnit) return false + const source = normalizeProductUnitValue(planUnit) + const permit = normalizeProductUnitValue(certUnit) + if (!source || !permit || source === permit) return false + return [MASS_IN_GRAMS, VOLUME_IN_MILLILITRES].some( + (table) => source in table && permit in table + ) +} + export function coefficientFromQuantities(source: unknown, permit: unknown) { const sourceQty = Number(source) const permitQty = Number(permit) @@ -62,7 +78,13 @@ export function unitConversions(plan: PlanQuota | undefined, sources: QuotaSourc row.sourceStopProductId === source.sourceStopProductId )?.certificates || [] return certs - .filter((cert) => cert.quantityUnit && source.unit && !sameCertUnit(source.unit, cert.quantityUnit)) + .filter( + (cert) => + cert.quantityUnit && + source.unit && + !sameCertUnit(source.unit, cert.quantityUnit) && + !hasStandardUnitConversion(source.unit, cert.quantityUnit) + ) .map((cert) => ({ sourceId: `${source.sourceStopType}:${source.sourceStopProductId}`, source, diff --git a/web-admin/src/utils/crm-product-display.ts b/web-admin/src/utils/crm-product-display.ts index 209cee854..10b9c05a9 100644 --- a/web-admin/src/utils/crm-product-display.ts +++ b/web-admin/src/utils/crm-product-display.ts @@ -78,6 +78,7 @@ export const DEFAULT_PRODUCT_UNIT_VALUE = 'dun' /** 展示文案 → 字典值;避免「吨」与合同行 dun 混用导致保存校验失败 */ const PRODUCT_UNIT_LABEL_TO_VALUE: Record = { + 克: 'g', 千克: 'kg', 吨: 'dun', 升: 'sheng', diff --git a/web-admin/src/utils/random-id.ts b/web-admin/src/utils/random-id.ts new file mode 100644 index 000000000..e544c7aa7 --- /dev/null +++ b/web-admin/src/utils/random-id.ts @@ -0,0 +1,19 @@ +type RandomSource = { + randomUUID?: () => string + getRandomValues?: (bytes: Uint8Array) => Uint8Array +} + +/** 幂等操作号。安全上下文用 randomUUID;http 页面没有该接口时改用 getRandomValues。 */ +export function randomOperationId(source?: RandomSource): string { + const cryptoSource = source === undefined ? globalThis.crypto : source + if (typeof cryptoSource?.randomUUID === 'function') return cryptoSource.randomUUID() + const bytes = new Uint8Array(16) + if (typeof cryptoSource?.getRandomValues === 'function') cryptoSource.getRandomValues(bytes) + else { + for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256) + } + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} diff --git a/web-admin/src/views/m/pl/plan-cert/CertConfirmSheet.vue b/web-admin/src/views/m/pl/plan-cert/CertConfirmSheet.vue index ad7be8e0d..12f220b17 100644 --- a/web-admin/src/views/m/pl/plan-cert/CertConfirmSheet.vue +++ b/web-admin/src/views/m/pl/plan-cert/CertConfirmSheet.vue @@ -40,6 +40,7 @@ import { ElMessage } from 'element-plus' import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue' import { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert' + import { randomOperationId } from '@/utils/random-id' import './cert-mobile.scss' const props = defineProps<{ row?: QuotaCert | null }>() @@ -57,7 +58,7 @@ watch( () => props.row?.id, () => { - opening.operationId = crypto.randomUUID() + opening.operationId = randomOperationId() opening.usedQty = '0' opening.singleUseConsumed = false opening.reason = '' diff --git a/web-admin/src/views/m/pl/plan-cert/CertHistorySheet.vue b/web-admin/src/views/m/pl/plan-cert/CertHistorySheet.vue index 2d24658de..203c1dea7 100644 --- a/web-admin/src/views/m/pl/plan-cert/CertHistorySheet.vue +++ b/web-admin/src/views/m/pl/plan-cert/CertHistorySheet.vue @@ -55,6 +55,7 @@ } from '@/api/pl/quota-cert' import { formatCertQty } from '@/views/pl/quota-cert/model' import { getProductUnitLabel } from '@/utils/crm-product-display' + import { randomOperationId } from '@/utils/random-id' import { useDictStore } from '@/store/modules/dict' import './cert-mobile.scss' @@ -131,7 +132,7 @@ return } const signature = JSON.stringify({ id: cert.value.id, ...body }) - if (signature !== attempt.signature) attempt = { signature, id: crypto.randomUUID() } + if (signature !== attempt.signature) attempt = { signature, id: randomOperationId() } saving.value = true try { await quotaAdjustmentApi.total(props.kind, cert.value.id, { ...body, operationId: attempt.id }) diff --git a/web-admin/src/views/pl/quota-cert/QuotaHistoryDialog.vue b/web-admin/src/views/pl/quota-cert/QuotaHistoryDialog.vue index b3e6fe215..bbb16073c 100644 --- a/web-admin/src/views/pl/quota-cert/QuotaHistoryDialog.vue +++ b/web-admin/src/views/pl/quota-cert/QuotaHistoryDialog.vue @@ -75,6 +75,7 @@ import { formatCertQty } from './model' import { useDictStore } from '@/store/modules/dict' import { getProductUnitLabel } from '@/utils/crm-product-display' + import { randomOperationId } from '@/utils/random-id' function inputQty(value: unknown) { const text = formatCertQty(value) return text === '—' ? '' : text @@ -144,7 +145,7 @@ const id = cert.value.id, type = kind.value, signature = JSON.stringify({ id, type, ...body }) - if (signature !== attempt.signature) attempt = { signature, id: crypto.randomUUID() } + if (signature !== attempt.signature) attempt = { signature, id: randomOperationId() } saving.value = true try { await quotaAdjustmentApi.total(type, id, { ...body, operationId: attempt.id }) diff --git a/web-admin/src/views/pl/quota-cert/index.vue b/web-admin/src/views/pl/quota-cert/index.vue index dc5d3a010..d7cf66f01 100644 --- a/web-admin/src/views/pl/quota-cert/index.vue +++ b/web-admin/src/views/pl/quota-cert/index.vue @@ -607,6 +607,7 @@ import { useUserStore } from '@/store/modules/user' import { useDictStore } from '@/store/modules/dict' import { getProductUnitLabel } from '@/utils/crm-product-display' + import { randomOperationId } from '@/utils/random-id' import { fetchGetMenuCodes } from '@/api/auth' import { quotaCertApi, @@ -1573,7 +1574,7 @@ function openConfirm(row: QuotaCert) { confirmRow.value = row Object.assign(opening, { - operationId: crypto.randomUUID(), + operationId: randomOperationId(), usedQty: '0', singleUseConsumed: false, reason: '',