443 lines
19 KiB
Python
443 lines
19 KiB
Python
"""证件额度纯计算。调用者负责租户、有效期、权限及数据库事务。
|
||
|
||
所有量限制六位小数;内部使用 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
|