同质量或体积单位不再要求手填系数,普通 http 页面也能生成幂等操作号。 Co-authored-by: Cursor <cursoragent@cursor.com>
212 lines
9.8 KiB
Python
212 lines
9.8 KiB
Python
"""不可行分配的容量说明;容量上界不能替代产品范围和双证引用的联合求解。"""
|
||
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) + '。证件余量不足,不能安排运输,请先调整到有余量后再安排'
|