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