feat(pl): 吨千克克和升毫升按固定比例折算用证量。
同质量或体积单位不再要求手填系数,普通 http 页面也能生成幂等操作号。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
e7f942b399
commit
da3ec62719
21 files changed
+277
-43
No files matched your search
@@ -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='按实际来源量自动分配或人工确认的实际用证量,证件单位;不可用预占量替代')
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
Reference in new issue
Block a user