Files
shanxia2.0/backend/app/services/pl_quota_diagnostics.py
T
wuzilongandCursor da3ec62719 feat(pl): 吨千克克和升毫升按固定比例折算用证量。
同质量或体积单位不再要求手填系数,普通 http 页面也能生成幂等操作号。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 21:14:15 +08:00

212 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""不可行分配的容量说明;容量上界不能替代产品范围和双证引用的联合求解。"""
from decimal import Decimal, ROUND_HALF_UP
from app.services.pl_quota_units import canonical_quantity_unit, same_quantity_unit, standard_conversion_ratio
_UNIT_LABELS = {
'g': '克', 'dun': '吨', 'kg': '千克', 'sheng': '升', 'ping': '瓶', 'dai': '袋',
'tong': '桶', 'hs': '毫升', 'set': '套', 'piece': '个', 'unit': '台',
}
def allocation_diagnostics(demands, purchases, transports):
rows = []
grouped = []
seen = set()
for source in demands:
code = canonical_quantity_unit(source.unit)
if code in seen:
continue
seen.add(code)
grouped.append((code, source.unit))
for kind, permits in [('purchase', purchases), ('transport', transports)]:
for code, unit in grouped:
sources = [s for s in demands if canonical_quantity_unit(s.unit) == code]
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))
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
'总余额仅为上界;请核对每个合同产品范围、运输引用关系及人工分配约束')
rows.append({'certType': kind, 'sourceIds': [s.source_id for s in sources],
'unit': unit, 'demand': format(demand, 'f'),
'availableUpperBound': None if foreign else format(available, 'f'),
'gapLowerBound': None if foreign else format(gap, 'f'), 'reason': reason})
return rows
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)
def missing_purchase_link_message(demands, purchases, transports, cert_nos):
"""运输证没有引用本次适用的购买证时,说明要补哪一张档案。"""
notices = []
seen = set()
for source in demands:
buys = [p for p in purchases if source.product_id in p.product_ids and same_quantity_unit(p.unit, source.unit)]
moves = [t for t in transports if source.product_id in t.product_ids and same_quantity_unit(t.unit, source.unit)]
if not buys or not moves:
continue
if any(p.permit_id in t.purchase_permit_ids for p in buys for t in moves):
continue
buy_text = '、'.join(cert_nos.get(('purchase', p.permit_id), p.permit_id) for p in buys)
move_text = '、'.join(cert_nos.get(('transport', t.permit_id), t.permit_id) for t in moves)
key = (move_text, buy_text)
if key in seen:
continue
seen.add(key)
notices.append(
f'运输证 {move_text} 没有引用合同正在使用的购买证 {buy_text}。'
'请先打开该运输证,在「证面引用」的「对应购买证档案」中选择这张购买证,再安排运输'
)
return ';'.join(notices)
def foreign_unit_message(demands, purchases, transports, cert_nos):
"""没有同单位证件可用时,说明计划单位和证面单位,并指出去哪里填换算。"""
notices = []
seen = set()
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 _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 _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 _directly_usable(t, source.unit)], cert_nos)
if text:
parts.append(f'运输证 {text}')
if not parts:
continue
sentence = (
f'计划产品单位是{_display_unit(source.unit)},合同上的{"、".join(parts)}和计划单位不同。'
'单位不同不能直接安排运输。请先到「计划段次用证」打开「查看用证」,填写换算系数后再安排。'
)
if sentence in seen:
continue
seen.add(sentence)
notices.append(sentence)
return ';'.join(notices)
def _cert_unit_text(kind, permits, cert_nos):
labels = []
for permit in permits:
number = cert_nos.get((kind, permit.permit_id), permit.permit_id)
labels.append(f'{number}({_display_unit(permit.unit)})')
return '、'.join(labels)
def archive_quantity_shortfall_message(diagnostics, cert_rows, actual_by_source, excess, unit_by_source):
"""归档实际数量超出预占且合同证件仍不够时,说明哪张证差多少。"""
short = []
enough = []
for row in diagnostics or []:
gap = Decimal(str(row.get('gapLowerBound') or '0'))
kind = row.get('certType')
if row.get('reason') == '余额不足,请补充该证种额度' and gap > 0:
short.append(row)
elif kind in ('purchase', 'transport') and gap <= 0 and row.get('availableUpperBound') is not None:
enough.append(kind)
if not short:
return ''
lines = ['实际数量已保存,证件没有扣减。', '']
quantities = {}
for sid, actual in (actual_by_source or {}).items():
unit = str((unit_by_source or {}).get(sid) or '')
bucket = quantities.setdefault(unit, [Decimal(0), Decimal(0)])
bucket[0] += Decimal(str(actual))
bucket[1] += Decimal(str((excess or {}).get(sid) or 0))
open_units = [(unit, actual, extra) for unit, (actual, extra) in quantities.items() if extra > 0]
for unit, actual, extra in open_units:
label = _display_unit(unit)
reserved = actual - extra
lines.append(
f'这次实际数量是 {_display_qty(actual)} {label},'
f'安排时预占了 {_display_qty(reserved)} {label},'
f'还要再扣 {_display_qty(extra)} {label}。'
)
short_names = []
for row in short:
kind = row.get('certType')
name = '购买证' if kind == 'purchase' else '运输证'
short_names.append(name)
unit = _display_unit(row.get('unit'))
need = _display_qty(row.get('demand'))
left = _display_qty(row.get('availableUpperBound'))
same = [item for item in cert_rows or [] if item.get('kind') == kind]
numbers = '、'.join(str(item.get('certNo') or '') for item in same if item.get('certNo'))
if len(same) == 1 and numbers:
cert_text = f'{name} {numbers} 还剩 {left} {unit}'
other = f'合同上没有其他{name}。'
elif numbers:
cert_text = f'{name} {numbers} 加在一起还剩 {left} {unit}'
other = ''
else:
cert_text = f'{name}还剩 {left} {unit}'
other = ''
lines.append(f'{cert_text},不够再扣 {need} {unit}。{other}'.strip())
if len(open_units) == 1:
unit, actual, extra = open_units[0]
label = _display_unit(unit)
room = min(Decimal(str(row.get('availableUpperBound') or '0')) for row in short)
lines.append(f'所以这次最多可以归档 {_display_qty(actual - extra + room)} {label}。')
enough_names = []
if 'purchase' in enough and not any(row.get('certType') == 'purchase' for row in short):
enough_names.append('购买证')
if 'transport' in enough and not any(row.get('certType') == 'transport' for row in short):
enough_names.append('运输证')
if enough_names:
lines.append(f'{"和".join(enough_names)}余量够用。')
lines.append('')
lines.append(f'请把实际数量改小到上面的数量以内,或先补充{"、".join(dict.fromkeys(short_names))},再重新归档。')
return '\n'.join(lines)
def insufficient_quantity_message(diagnostics):
"""同单位余额盖不住本次需求时,给出安排前可执行的余量说明。"""
parts = []
for row in diagnostics or []:
if row.get('reason') != '余额不足,请补充该证种额度':
continue
if Decimal(row.get('gapLowerBound') or '0') <= 0:
continue
label = '购买证' if row.get('certType') == 'purchase' else '运输证'
unit = _display_unit(row.get('unit'))
parts.append(
f"{label}剩余{_display_qty(row.get('availableUpperBound'))}{unit},本次需要{_display_qty(row.get('demand'))}{unit}"
)
if not parts:
return ''
return ';'.join(parts) + '。证件余量不足,不能安排运输,请先调整到有余量后再安排'