feat(pl): 吨千克克和升毫升按固定比例折算用证量。

同质量或体积单位不再要求手填系数,普通 http 页面也能生成幂等操作号。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-11-05 21:14:15 +08:00
1 parent e7f942b399
commit da3ec62719
21 files changed
+277 -43

No files matched your search

+2 -2
View File
@@ -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='按实际来源量自动分配或人工确认的实际用证量,证件单位;不可用预占量替代')
+19 -4
View File
@@ -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]))
+6 -2
View File
@@ -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):
+23 -8
View File
@@ -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:
+18
View File
@@ -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
+12 -4
View File
@@ -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)
+38
View File
@@ -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):
+9 -3
View File
@@ -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(
+16 -6
View File
@@ -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
+19 -3
View File
@@ -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)
+26 -4
View File
@@ -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', () => {
@@ -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)
})
@@ -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 },
@@ -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,
@@ -36,6 +36,22 @@ export function sameCertUnit(left?: string | null, right?: string | null) {
return normalizeProductUnitValue(planUnit) === normalizeProductUnitValue(certUnit)
}
const MASS_IN_GRAMS: Record<string, number> = { g: 1, kg: 1000, dun: 1000000 }
const VOLUME_IN_MILLILITRES: Record<string, number> = { 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,
@@ -78,6 +78,7 @@ export const DEFAULT_PRODUCT_UNIT_VALUE = 'dun'
/** 展示文案 → 字典值;避免「吨」与合同行 dun 混用导致保存校验失败 */
const PRODUCT_UNIT_LABEL_TO_VALUE: Record<string, string> = {
克: 'g',
千克: 'kg',
吨: 'dun',
升: 'sheng',
+19
View File
@@ -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)}`
}
@@ -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 = ''
@@ -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 })
@@ -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 })
+2 -1
View File
@@ -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: '',