Files
shanxia2.0/backend/app/services/pl_quota_allocation.py
T

443 lines
19 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.
"""证件额度纯计算。调用者负责租户、有效期、权限及数据库事务。
所有量限制六位小数;内部使用 Fraction,输出不能精确表示时明确返回计算未完成。
运输引用通过 (来源, 购买证, 运输证) 流量约束匹配,而非仅验证证号存在。
人工行固定该来源/证件的来源量与扣证量,不推导任何跨单位换算率。
"""
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from fractions import Fraction as F
from math import isfinite
from time import monotonic
from typing import FrozenSet, Mapping, Sequence, Tuple
class AllocationInputError(ValueError):
pass
class AllocationIncomplete(RuntimeError):
"""计算预算耗尽或精度无法表示;不能据此认定额度不足。"""
class InsufficientQuota(ValueError):
"""完整约束经精确求解不可行。"""
class SupplementalAllocationRequired(ValueError):
"""实际量超出原分配,必须先补充分配。"""
@dataclass(frozen=True)
class SourceDemand:
source_id: str
product_id: str
quantity: Decimal
unit: str
@dataclass(frozen=True)
class PermitCandidate:
permit_id: str
available: Decimal
unit: str
expires_on: date
product_ids: FrozenSet[str]
purchase_permit_ids: FrozenSet[str] = frozenset()
@dataclass(frozen=True)
class ManualAllocation:
permit_kind: str
source_id: str
permit_id: str
source_quantity: Decimal
permit_quantity: Decimal
@dataclass(frozen=True)
class Allocation:
permit_kind: str
source_id: str
permit_id: str
source_quantity: Decimal
permit_quantity: Decimal
source_unit: str
permit_unit: str
allocation_seq: int
@dataclass(frozen=True)
class AllocationPlan:
purchase_allocations: Tuple[Allocation, ...]
transport_allocations: Tuple[Allocation, ...]
@dataclass(frozen=True)
class Settlement:
allocation: Allocation
actual_source_quantity: Decimal
actual_permit_quantity: Decimal
release_permit_quantity: Decimal
@dataclass(frozen=True)
class AllocationLimits:
max_sources: int = 32
max_permits: int = 64
max_variables: int = 128
max_constraints: int = 512
max_pivots: int = 5000
max_seconds: float = 5.0
def _quantity(value):
if not isinstance(value, Decimal) or not value.is_finite() or value < 0:
raise AllocationInputError('数量必须为有限非负 Decimal')
if value.as_tuple().exponent < -6 or value.adjusted() > 17:
raise AllocationInputError('数量最多六位小数、十八位整数')
return F(value)
def _decimal(value):
scaled = value * 1000000
if scaled.denominator != 1:
raise AllocationIncomplete('精确解超过六位小数,需要人工分配')
# 构造字符串,不依赖调用者 Decimal context 的精度与舍入设置。
n = scaled.numerator
return Decimal(f'{n // 1000000}.{n % 1000000:06d}')
def _unique(items, field):
result = {}
for item in items:
key = getattr(item, field)
if not isinstance(key, str) or not key.strip() or key in result:
raise AllocationInputError('标识不能为空或重复')
result[key] = item
return result
class _Budget:
def __init__(self, limits):
for name in ('max_sources', 'max_permits', 'max_variables', 'max_constraints', 'max_pivots'):
value = getattr(limits, name)
if type(value) is not int or value < 0:
raise AllocationInputError('计算规模和次数限制必须为非负整数')
if not isinstance(limits.max_seconds, (int, float)) or not isfinite(limits.max_seconds) or limits.max_seconds < 0:
raise AllocationInputError('计算时间限制必须为有限非负数')
self.limits = limits
self.deadline = monotonic() + limits.max_seconds
self.pivots = 0
def check(self):
if monotonic() >= self.deadline or self.pivots >= self.limits.max_pivots:
raise AllocationIncomplete('分配计算未完成:达到计算预算')
def _solve(a, b, c, budget):
"""精确两阶段单纯形,max c*x, A*x <= b, x >= 0;Bland 规则防循环。"""
m, n = len(b), len(c)
if m > budget.limits.max_constraints:
raise AllocationIncomplete('分配计算未完成:约束数量超限')
budget.check()
basis, nonbasis = list(range(n, n + m)), list(range(n)) + [-1]
d = [[F(0) for _ in range(n + 2)] for _ in range(m + 2)]
for i in range(m):
d[i][:n], d[i][n], d[i][n + 1] = list(map(F, a[i])), F(-1), F(b[i])
d[m][:n] = [-F(v) for v in c]
d[m + 1][n] = F(1)
def pivot(r, s):
budget.check()
budget.pivots += 1
inv = 1 / d[r][s]
for i in range(m + 2):
if i != r:
factor = d[i][s] * inv
if factor:
for j in range(n + 2):
if j != s:
d[i][j] -= d[r][j] * factor
for j in range(n + 2):
if j != s:
d[r][j] *= inv
for i in range(m + 2):
if i != r:
d[i][s] *= -inv
d[r][s] = inv
basis[r], nonbasis[s] = nonbasis[s], basis[r]
def simplex(phase):
objective = m + 1 if phase == 1 else m
while True:
budget.check()
entering = [j for j in range(n + 1) if d[objective][j] < 0 and (phase == 1 or nonbasis[j] != -1)]
if not entering:
return True
s = min(entering, key=lambda j: nonbasis[j])
leaving = [i for i in range(m) if d[i][s] > 0]
if not leaving:
return False
r = min(leaving, key=lambda i: (d[i][n + 1] / d[i][s], basis[i]))
pivot(r, s)
if m:
r = min(range(m), key=lambda i: d[i][n + 1])
if d[r][n + 1] < 0:
pivot(r, n)
if not simplex(1) or d[m + 1][n + 1] != 0:
raise InsufficientQuota('证件额度或引用关系无法覆盖全部来源需求')
if -1 in basis:
r = basis.index(-1)
choices = [j for j in range(n + 1) if d[r][j] != 0]
if choices:
pivot(r, min(choices, key=lambda j: nonbasis[j]))
if not simplex(2):
raise AllocationIncomplete('分配模型出现无界约束')
x = [F(0)] * n
for i in range(m):
if 0 <= basis[i] < n:
x[basis[i]] = d[i][n + 1]
return x
def allocate_plan(demands: Sequence[SourceDemand], purchases: Sequence[PermitCandidate],
transports: Sequence[PermitCandidate], *, manual_allocations=(),
limits=AllocationLimits()) -> AllocationPlan:
"""全部来源联合分配;按来源 ID,再购买/运输类别、到期日、同单位余量、证件 ID 优先。
返回两类独立分配行。候选须由调用者预先按业务适用条件筛选;此处仍校验产品范围。
无异单位人工行的候选不能承担该来源。预算失败不返回部分方案。
"""
budget = _Budget(limits)
if len(demands) > limits.max_sources or len(purchases) + len(transports) > limits.max_permits:
raise AllocationIncomplete('分配计算未完成:输入规模超限')
sources = _unique(demands, 'source_id')
groups = {'purchase': _unique(purchases, 'permit_id'), 'transport': _unique(transports, 'permit_id')}
for s in demands:
_quantity(s.quantity)
if not s.unit or not s.product_id:
raise AllocationInputError('来源单位和产品不能为空')
for p in list(purchases) + list(transports):
_quantity(p.available)
if not p.unit or not isinstance(p.expires_on, date):
raise AllocationInputError('证件单位和到期日无效')
manual = {}
for row in manual_allocations:
key = (row.permit_kind, row.source_id, row.permit_id)
s = sources.get(row.source_id)
p = groups.get(row.permit_kind, {}).get(row.permit_id)
sq, pq = _quantity(row.source_quantity), _quantity(row.permit_quantity)
if key in manual or s is None or p is None or s.product_id not in p.product_ids:
raise AllocationInputError('人工分配标识重复或不适用')
if (sq == 0) != (pq == 0) or (s.unit == p.unit and sq != pq):
raise AllocationInputError('同单位数量必须相等,零数量必须对应')
manual[key] = row
def eligible(kind, s, p):
return s.product_id in p.product_ids and (s.unit == p.unit or (kind, s.source_id, p.permit_id) in manual)
routes = []
for sid, s in sorted(sources.items()):
for gid, g in sorted(groups['purchase'].items()):
for tid, t in sorted(groups['transport'].items()):
if gid in t.purchase_permit_ids and eligible('purchase', s, g) and eligible('transport', s, t):
routes.append((sid, gid, tid))
if len(routes) > limits.max_variables:
raise AllocationIncomplete('分配计算未完成:匹配变量超限')
a, b = [], []
def constrain(coefficients, bound, equal=False):
a.append(coefficients)
b.append(F(bound))
if equal:
a.append([-v for v in coefficients])
b.append(-F(bound))
def vector(kind, sid, pid):
position = 1 if kind == 'purchase' else 2
return [int(r[0] == sid and r[position] == pid) for r in routes]
for sid, s in sources.items():
constrain([int(r[0] == sid) for r in routes], F(s.quantity), True)
for kind, permits in groups.items():
for pid, p in permits.items():
coefficients = [0] * len(routes)
fixed = F(0)
for sid, s in sources.items():
key = (kind, sid, pid)
v = vector(kind, sid, pid)
if key in manual:
row = manual[key]
constrain(v, F(row.source_quantity), True)
if s.unit == p.unit:
coefficients = [x + y for x, y in zip(coefficients, v)]
elif key in manual:
fixed += F(manual[key].permit_quantity)
constrain(coefficients, F(p.available) - fixed)
x = _solve(a, b, [0] * len(routes), budget)
# 连续精确优化每个优先项,并锁定最优值;不会因局部贪心破坏全局可行性。
objectives = []
for sid, s in sorted(sources.items()):
for kind, permits in groups.items():
ordered = sorted(permits.values(), key=lambda p: (p.expires_on, p.unit != s.unit, F(p.available) if p.unit == s.unit else F(0), p.permit_id))
for p in ordered:
v = vector(kind, sid, p.permit_id)
if any(v):
objectives.append((kind, sid, p, v))
for kind, sid, p, v in objectives:
x = _solve(a, b, v, budget)
optimum = sum(value * coefficient for value, coefficient in zip(x, v))
constrain(v, optimum, True)
output = {'purchase': [], 'transport': []}
sequences = {}
for kind, sid, p, v in objectives:
amount = sum(value * coefficient for value, coefficient in zip(x, v))
if not amount:
continue
key = (kind, sid, p.permit_id)
pq = F(manual[key].permit_quantity) if key in manual else 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]))
return AllocationPlan(tuple(output['purchase']), tuple(output['transport']))
def settle_allocations(reserved: Sequence[Allocation], actual_by_source: Mapping[str, Decimal], *, manual_actuals=()) -> Tuple[Settlement, ...]:
"""按原 allocation_seq 消耗,释放每行全部预占。一次只接受一类证件。
异单位来源须逐行重新确认全部实际量(含零量行),不按预占比例推算。
超来源量或超任一证件预占量均要求先补充分配。
"""
keys, seqs, grouped = set(), set(), {}
kinds = {r.permit_kind for r in reserved}
if len(kinds) > 1 or kinds - {'purchase', 'transport'}:
raise AllocationInputError('一次结转必须使用同一证件类型')
for row in reserved:
_quantity(row.source_quantity)
_quantity(row.permit_quantity)
key, seq = (row.permit_kind, row.source_id, row.permit_id), (row.source_id, row.allocation_seq)
if key in keys or seq in seqs or row.allocation_seq < 1:
raise AllocationInputError('预占行或顺序重复无效')
if row.source_unit == row.permit_unit and row.source_quantity != row.permit_quantity:
raise AllocationInputError('同单位预占数量不一致')
keys.add(key)
seqs.add(seq)
grouped.setdefault(row.source_id, []).append(row)
if set(actual_by_source) != set(grouped):
raise AllocationInputError('必须明确提交每个来源的实际量')
manual = {}
for row in manual_actuals:
key = (row.permit_kind, row.source_id, row.permit_id)
_quantity(row.source_quantity)
_quantity(row.permit_quantity)
if key not in keys or key in manual:
raise AllocationInputError('实际人工分配行无效或重复')
manual[key] = row
result = []
for sid, rows in sorted(grouped.items()):
remaining = _quantity(actual_by_source[sid])
if remaining > sum(F(r.source_quantity) for r in rows):
raise SupplementalAllocationRequired('实际来源量超预占,请先补充分配')
foreign = any(r.source_quantity > 0 and r.source_unit != r.permit_unit for r in rows)
zero_foreign = any(r.source_quantity == 0 and r.source_unit != r.permit_unit for r in rows)
for row in sorted(rows, key=lambda r: r.allocation_seq):
key = (row.permit_kind, sid, row.permit_id)
if foreign or row.source_unit != row.permit_unit:
if key not in manual:
raise AllocationInputError('异单位来源必须重新逐行人工确认实际量')
sq, pq = F(manual[key].source_quantity), F(manual[key].permit_quantity)
if (sq == 0) != (pq == 0) or (row.source_unit == row.permit_unit and sq != pq):
raise AllocationInputError('人工实际量单位或零值不一致')
else:
sq = pq = min(remaining, F(row.source_quantity))
if key in manual:
# 历史异单位零容量行仍确认0;同组的同单位输入仅可确认自动原序结果。
if (not zero_foreign or F(manual[key].source_quantity) != sq
or F(manual[key].permit_quantity) != pq):
raise AllocationInputError('同单位结转按原分配顺序,不接受人工覆盖')
if sq > F(row.source_quantity) or pq > F(row.permit_quantity):
raise SupplementalAllocationRequired('实际证件用量超预占,请先补充分配')
remaining -= sq
result.append(Settlement(row, _decimal(sq), _decimal(pq), row.permit_quantity))
if remaining != 0:
raise AllocationInputError('人工实际来源量合计不等于实运量')
return tuple(result)
class AllocationPairingError(ValueError):
"""两类实际分配不能匹配;应保留事实与原占用,等待人工调整。"""
def validate_allocation_pairing(
demands: Sequence[SourceDemand],
purchase_allocations: Sequence[Allocation],
transport_allocations: Sequence[Allocation],
transports: Sequence[PermitCandidate],
*, limits=AllocationLimits(),
) -> None:
"""校验固定分配量能否在同来源内严格匹配运输引用;不重排、不改量。
结转调用者先计算两类 Settlement,以实际量替换 Allocation 的两种数量后
调用本函数,demands 为实际来源量。成功才允许同事务落账;异常保留原占用。
此处只检验固定量之间的引用匹配,不重新申请或验证候选剩余额度。
"""
budget = _Budget(limits)
if len(demands) > limits.max_sources or len(transports) > limits.max_permits:
raise AllocationIncomplete('匹配校验未完成:输入规模超限')
sources = _unique(demands, 'source_id')
transport_map = _unique(transports, 'permit_id')
for s in sources.values():
_quantity(s.quantity)
if not s.unit or not s.product_id:
raise AllocationInputError('来源单位和产品不能为空')
groups = {'purchase': {}, 'transport': {}}
for kind, rows in (('purchase', purchase_allocations), ('transport', transport_allocations)):
if len(rows) > limits.max_sources * limits.max_permits:
raise AllocationIncomplete('匹配校验未完成:分配行数量超限')
for row in rows:
key = (row.source_id, row.permit_id)
s = sources.get(row.source_id)
_quantity(row.source_quantity)
_quantity(row.permit_quantity)
if s is None or key in groups[kind] or row.permit_kind != kind or not row.permit_id:
raise AllocationInputError('匹配校验分配行身份无效或重复')
if row.source_unit != s.unit or not row.permit_unit:
raise AllocationInputError('匹配校验分配单位无效')
if row.source_unit == row.permit_unit and row.source_quantity != row.permit_quantity:
raise AllocationInputError('同单位分配数量必须相等')
if (row.source_quantity == 0) != (row.permit_quantity == 0):
raise AllocationInputError('分配零数量必须对应')
if kind == 'transport':
t = transport_map.get(row.permit_id)
if t is None or t.unit != row.permit_unit or s.product_id not in t.product_ids:
raise AllocationInputError('运输证不存在、单位不符或产品不适用')
groups[kind][key] = row
for sid, s in sources.items():
total = sum(F(r.source_quantity) for (source_id, _), r in groups[kind].items() if source_id == sid)
if total != F(s.quantity):
raise AllocationPairingError('分配未覆盖实际来源量,需要人工调整;保留原占用')
purchase_ids = {pid for _, pid in groups['purchase']}
if len(purchase_ids) + len(transport_map) > limits.max_permits:
raise AllocationIncomplete('匹配校验未完成:证件数量超限')
routes = []
for sid, gid in sorted(groups['purchase']):
for source_id, tid in sorted(groups['transport']):
if sid == source_id and gid in transport_map[tid].purchase_permit_ids:
routes.append((sid, gid, tid))
if len(routes) > limits.max_variables:
raise AllocationIncomplete('匹配校验未完成:匹配变量超限')
a, b = [], []
for kind, rows in groups.items():
position = 1 if kind == 'purchase' else 2
for (sid, pid), row in sorted(rows.items()):
v = [int(r[0] == sid and r[position] == pid) for r in routes]
a.extend([v, [-x for x in v]])
b.extend([F(row.source_quantity), -F(row.source_quantity)])
try:
_solve(a, b, [0] * len(routes), budget)
except InsufficientQuota as exc:
raise AllocationPairingError('实际双证引用数量无法对应,需要人工调整;保留原占用') from exc