"""归档库存按计划差量记账。 按产品归档时,同一产品、品牌、方向仍是一条流水。 按装卸段归档时,每条装货或送货产品明细单独一条流水,并用明细编号对应原流水。 原流水创建时间保留;修订反映在原入账期间,不把无变更保存变成新入账。 """ from decimal import Decimal, ROUND_HALF_UP from typing import Optional from fastapi import HTTPException from sqlalchemy.orm import Session from app.core.utils import generate_uuid from app.models.crm_models import CrmProduct from app.models.pl_models import ( PlInventory, PlInventoryTransaction, PlPlan, PlPlanProduct, PlPlanProductBrandQty, PlPlanLoad, PlPlanDelivery, PlPlanLoadProduct, PlPlanDeliveryProduct, ) EMPTY_BRAND_ID = "" _QTY = Decimal("0.001") _PRICE = Decimal("0.0001") _MONEY = Decimal("0.01") def qty_delta_for_plan(plan_type: str, qty: Decimal) -> Decimal: if plan_type == "inbound": return Decimal(str(qty)) if plan_type == "outbound": return -Decimal(str(qty)) return Decimal("0") def _decimal(value, precision=None): result = Decimal(str(value or 0)) if not result.is_finite(): raise HTTPException(409, "库存数量或金额无效,请核对归档数据") return result.quantize(precision, rounding=ROUND_HALF_UP) if precision else result def _current_postings(db, tenant_no, plan_id): return db.query(PlInventoryTransaction).filter( PlInventoryTransaction.tenant_no == tenant_no, PlInventoryTransaction.plan_id == plan_id, PlInventoryTransaction.del_flag == 0, ).order_by(PlInventoryTransaction.id).populate_existing().with_for_update().all() def lock_archive_inventory(db: Session, *, tenant_no: str, plan: PlPlan, refresh_plan: bool = False) -> None: """在归档字段修改前锁计划及共享产品;直接服务入口也必须调用。 同产品的不同计划必须共用锁,才能保护尚不存在的库存台账。 旧产品主数据缺失时锁该租户同产品计划明细,不拒绝合法历史归档。 所有查询禁用 autoflush,避免在获得锁之前写入调用者的归档修改。 """ if plan.tenant_no != tenant_no: raise HTTPException(404, "计划不存在或不属于当前租户") with db.no_autoflush: query = db.query(PlPlan if refresh_plan else PlPlan.id).filter( PlPlan.id == plan.id, PlPlan.tenant_no == tenant_no, PlPlan.del_flag == 0, ) if refresh_plan: query = query.populate_existing() found = query.with_for_update().first() if not found: raise HTTPException(404, "计划不存在或已删除") product_ids = {value for (value,) in db.query(PlPlanProduct.product_id).filter( PlPlanProduct.tenant_no == tenant_no, PlPlanProduct.plan_id == plan.id, PlPlanProduct.del_flag == 0, ).all()} # 锁定读可看见等待计划锁期间另一事务提交的流水,不依赖旧快照。 postings = _current_postings(db, tenant_no, plan.id) _validate_posting_shape(postings) product_ids.update(row.product_id for row in postings) for product_id in sorted(product_ids): master = db.query(CrmProduct.id).filter( CrmProduct.tenant_no == tenant_no, CrmProduct.id == product_id, ).with_for_update().first() if not master: db.query(PlPlanProduct.id).filter( PlPlanProduct.tenant_no == tenant_no, PlPlanProduct.product_id == product_id, ).order_by(PlPlanProduct.id).with_for_update().all() # 已存在库存也锁定;缺失台账由上述共享产品锁串行化首次创建。 db.query(PlInventory.id).filter( PlInventory.tenant_no == tenant_no, PlInventory.product_id == product_id, PlInventory.del_flag == 0, ).order_by(PlInventory.id).with_for_update().all() if refresh_plan: # API 尚未改归档字段,刷新写入所依赖的源行,避免等待锁后继续使用 # identity map 中的旧数量/价格。直接服务调用不可刷新覆盖 pending 值。 for source in (PlPlanProduct, PlPlanProductBrandQty, PlPlanLoad, PlPlanDelivery, PlPlanLoadProduct, PlPlanDeliveryProduct): db.query(source).filter( source.tenant_no == tenant_no, source.plan_id == plan.id, source.del_flag == 0, ).order_by(source.id).populate_existing().with_for_update().all() def _posting_key(row): return ( row.product_id, row.brand_id or EMPTY_BRAND_ID, row.transaction_type, getattr(row, "stop_product_id", None) or "", ) def _line_signature(qty, price): return _decimal(qty, _QTY), _decimal(price, _PRICE) def _signed_amount(row): return _decimal(row.amount, _MONEY) * (1 if row.transaction_type == "inbound" else -1) def _validate_posting_shape(rows): for row in rows: if row.transaction_type not in ("inbound", "outbound") or ( _decimal(row.qty) <= 0 if row.transaction_type == "inbound" else _decimal(row.qty) >= 0 ) or _decimal(row.amount) < 0: raise HTTPException(409, "历史库存流水方向或金额异常,请先核对") def _validate_existing(rows): _validate_posting_shape(rows) by_key = {} for row in rows: key = _posting_key(row) if key in by_key: raise HTTPException(409, "该计划存在同产品、品牌、方向的多条历史库存流水,请先核对;不能自动选择或合并ID") by_key[key] = row return by_key def _add_posting(desired, product, *, direction, qty, price, brand_id="", brand_name=None, stop_type="", stop_product_id=""): qty, price = _decimal(qty, _QTY), _decimal(price, _PRICE) if qty < 0 or price < 0: raise HTTPException(409, "归档数量或单价不能为负数") if qty == 0: return stop_product_id = stop_product_id or "" key = product.product_id, brand_id or EMPTY_BRAND_ID, direction, stop_product_id if not product.product_id or direction not in ("inbound", "outbound"): raise HTTPException(409, "归档产品或库存方向无效") if stop_product_id and key in desired: raise HTTPException(409, "同一装货或送货明细重复记账,请先核对") values = desired.setdefault(key, { "product_id": product.product_id, "product_no": product.product_no or "", "product_name": product.product_name or "", "brand_id": key[1], "brand_name": brand_name, "unit": product.unit, "model": product.model, "transaction_type": direction, "qty": Decimal("0"), "amount": Decimal("0"), "stop_type": stop_type or "", "stop_product_id": stop_product_id, }) values["qty"] += qty if direction == "inbound" else -qty values["amount"] += qty * price def _finish_postings(desired): for value in desired.values(): # 先合计各段实际金额,再取加权单价;不能用已舍入均价反算金额。 value["unit_price"] = (value["amount"] / abs(value["qty"])).quantize(_PRICE, rounding=ROUND_HALF_UP) value["amount"] = value["amount"].quantize(_MONEY, rounding=ROUND_HALF_UP) return desired def _inventory_rows(db, tenant_no, keys): result = {} for product_id, brand_id in sorted(keys): rows = db.query(PlInventory).filter( PlInventory.tenant_no == tenant_no, PlInventory.product_id == product_id, PlInventory.brand_id == brand_id, PlInventory.del_flag == 0, ).populate_existing().with_for_update().all() if len(rows) > 1: raise HTTPException(409, "产品品牌存在多条有效库存台账,请先核对") result[(product_id, brand_id)] = rows[0] if rows else None return result def _assign_stop_line(row, value): row.stop_type = value["stop_type"] row.stop_product_id = value["stop_product_id"] def _is_merged_line(row, lines): qty = sum((_decimal(value["qty"], _QTY) for value in lines), Decimal("0")) amount = sum((_decimal(value["amount"], _MONEY) for value in lines), Decimal("0")) return _decimal(row.qty, _QTY) == qty and _decimal(row.amount, _MONEY) == amount def _link_legacy_stop_lines(rows, desired): """旧流水没有明细编号时,能唯一对应的才挂上;合并后的一条不能拆开。""" if not any(value["stop_product_id"] for value in desired.values()): return grouped_rows = {} for row in rows: grouped_rows.setdefault(_posting_key(row)[:3], []).append(row) grouped_lines = {} for value in desired.values(): grouped_lines.setdefault( (value["product_id"], value["brand_id"], value["transaction_type"]), [] ).append(value) assignments = [] for key in set(grouped_rows) | set(grouped_lines): bound = {row.stop_product_id for row in grouped_rows.get(key, []) if row.stop_product_id} unbound = [row for row in grouped_rows.get(key, []) if not row.stop_product_id] open_lines = [value for value in grouped_lines.get(key, []) if value["stop_product_id"] not in bound] if not unbound or not open_lines: continue if len(unbound) == 1 and len(open_lines) == 1: assignments.append((unbound[0], open_lines[0])) continue row_by_sig, ambiguous_rows = {}, set() for row in unbound: signature = _line_signature(row.qty, row.unit_price) if signature in row_by_sig: ambiguous_rows.add(signature) else: row_by_sig[signature] = row line_by_sig, ambiguous_lines = {}, set() for value in open_lines: signature = _line_signature(value["qty"], value["unit_price"]) if signature in line_by_sig: ambiguous_lines.add(signature) else: line_by_sig[signature] = value matched_rows, matched_lines = set(), set() pending = [] for signature, row in row_by_sig.items(): value = line_by_sig.get(signature) if value is None or signature in ambiguous_rows or signature in ambiguous_lines: continue pending.append((row, value)) matched_rows.add(id(row)) matched_lines.add(id(value)) left_rows = [row for row in unbound if id(row) not in matched_rows] left_lines = [value for value in open_lines if id(value) not in matched_lines] if len(left_rows) == 1 and len(left_lines) == 1: pending.append((left_rows[0], left_lines[0])) left_rows, left_lines = [], [] if left_rows: if len(left_rows) == 1 and len(left_lines) > 1 and _is_merged_line(left_rows[0], left_lines): raise HTTPException(409, "该计划的库存流水已按产品合并,不能在保存时自动拆成多条明细") raise HTTPException(409, "该计划存在同产品、品牌、方向的多条历史库存流水,且无法按装货或送货明细对应,请先核对") assignments.extend(pending) for row, value in assignments: _assign_stop_line(row, value) def _reconcile_postings(db, *, tenant_no, plan, desired, user_id, dept_id): with db.no_autoflush: current = _current_postings(db, tenant_no, plan.id) _link_legacy_stop_lines(current, desired) existing = _validate_existing(current) keys = set(existing) | set(desired) inventories = _inventory_rows(db, tenant_no, {key[:2] for key in keys}) for key in existing: if inventories[key[:2]] is None: raise HTTPException(409, "历史库存流水对应台账缺失,请先核对") jobs = [] fields = ("product_name", "brand_name", "qty", "unit_price", "amount", "stop_type", "stop_product_id") for key in sorted(keys): old, value = existing.get(key), desired.get(key) if old is not None and value is not None and all( getattr(old, field) == value[field] for field in fields ) and old.plan_no == plan.plan_no: continue old_qty = _decimal(old.qty, _QTY) if old is not None else Decimal("0") old_amount = _signed_amount(old) if old is not None else Decimal("0") new_qty = value["qty"] if value is not None else Decimal("0") new_amount = (value["amount"] * (1 if key[2] == "inbound" else -1)) if value else Decimal("0") jobs.append((key, old, value, new_qty - old_qty, new_amount - old_amount)) # 所有身份、历史歧义和台账完整性检查通过后才开始写入。 # 使用已记录金额的净差,不按本次单价重估其他计划存量,也不追溯修复历史估值。 for key, old, value, qty_delta, amount_delta in jobs: inventory = inventories[key[:2]] if inventory is None: inventory = PlInventory( id=generate_uuid(), tenant_no=tenant_no, product_id=key[0], brand_id=key[1], brand_name=value["brand_name"], product_no=value["product_no"], product_name=value["product_name"], model=value["model"], unit=value["unit"] or "吨", stock_qty=Decimal("0"), stock_amount=Decimal("0"), create_by=user_id, create_dept_id=dept_id or "", update_by=user_id, ) inventories[key[:2]] = inventory db.add(inventory) if qty_delta or amount_delta: inventory.stock_qty = _decimal(inventory.stock_qty, _QTY) + qty_delta inventory.stock_amount = _decimal(inventory.stock_amount, _MONEY) + amount_delta inventory.update_by = user_id if value is None: old.del_flag, old.update_by = 1, user_id elif old is None: db.add(PlInventoryTransaction( id=generate_uuid(), tenant_no=tenant_no, product_id=key[0], brand_id=key[1], transaction_type=key[2], plan_id=plan.id, plan_no=plan.plan_no, stock_after=inventory.stock_qty, create_by=user_id, create_dept_id=dept_id or "", update_by=user_id, **{field: value[field] for field in fields}, )) else: for field in fields: setattr(old, field, value[field]) old.plan_no, old.update_by = plan.plan_no, user_id # 原入账时点快照仅应用本行数量修订;无变更/仅改价不改快照, # 不把后来其他计划的库存变化混入,也不批量重写其他历史快照。 if qty_delta: old.stock_after = _decimal(old.stock_after, _QTY) + qty_delta db.flush() def _product_price(product, direction=None): prices = (product.outbound_price, product.inbound_price) if direction == "outbound" else (product.inbound_price, product.outbound_price) return next((price for price in (*prices, product.tax_price) if price is not None), Decimal("0")) def apply_archive_inventory_for_plan(db: Session, *, tenant_no: str, plan: PlPlan, user_id: str, dept_id: str = "") -> None: lock_archive_inventory(db, tenant_no=tenant_no, plan=plan) desired = {} if plan.plan_type in ("inbound", "outbound"): products = db.query(PlPlanProduct).filter( PlPlanProduct.plan_id == plan.id, PlPlanProduct.tenant_no == tenant_no, PlPlanProduct.del_flag == 0, ).order_by(PlPlanProduct.id).with_for_update().all() for product in products: brands = db.query(PlPlanProductBrandQty).filter( PlPlanProductBrandQty.plan_product_id == product.id, PlPlanProductBrandQty.tenant_no == tenant_no, PlPlanProductBrandQty.del_flag == 0, ).order_by(PlPlanProductBrandQty.id).with_for_update().all() # SessionLocal 使用 autoflush=False;本次归档刚增/删的品牌行必须纳入, # 不提前 flush 主单与明细,也不把数据库筛选出的待软删行再次记账。 brands.extend(row for row in db.new if isinstance(row, PlPlanProductBrandQty) and row.plan_product_id == product.id and row.tenant_no == tenant_no) brands = [row for row in brands if row.del_flag != 1 and not row.stop_type and not row.stop_product_id] quantities = [(row.brand_id, row.brand_name, row.qty, row.unit_price if row.unit_price is not None else _product_price(product, plan.plan_type)) for row in brands] if product.brand_mode and brands else [("", None, product.actual_qty, _product_price(product, plan.plan_type))] for brand_id, brand_name, qty, price in quantities: _add_posting(desired, product, direction=plan.plan_type, qty=qty, price=price, brand_id=brand_id, brand_name=brand_name) _reconcile_postings(db, tenant_no=tenant_no, plan=plan, desired=_finish_postings(desired), user_id=user_id, dept_id=dept_id) def apply_archive_inventory_for_plan_stops(db: Session, *, tenant_no: str, plan: PlPlan, user_id: str, dept_id: str = "") -> None: from app.services.pl_plan_archive_stop_service import iter_stop_archive_inventory_lines lock_archive_inventory(db, tenant_no=tenant_no, plan=plan) products = {row.id: row for row in db.query(PlPlanProduct).filter( PlPlanProduct.plan_id == plan.id, PlPlanProduct.tenant_no == tenant_no, PlPlanProduct.del_flag == 0, ).with_for_update().all()} desired = {} for plan_product_id, qty, direction, price, stop_type, stop_product_id in iter_stop_archive_inventory_lines( db, tenant_no=tenant_no, plan_id=plan.id, ): product = products.get(plan_product_id) if product is None: raise HTTPException(409, "归档段产品未关联当前计划有效产品,请先核对") _add_posting(desired, product, direction=direction, qty=qty, price=price if price is not None else _product_price(product), stop_type=stop_type, stop_product_id=stop_product_id) _reconcile_postings(db, tenant_no=tenant_no, plan=plan, desired=_finish_postings(desired), user_id=user_id, dept_id=dept_id) def reverse_plan_inventory_postings(db: Session, *, tenant_no: str, plan_id: str, user_id: str) -> None: """显式撤销记账;普通归档保存不调用,取消业务调用方保持原语义。""" plan = db.query(PlPlan).filter(PlPlan.id == plan_id, PlPlan.tenant_no == tenant_no).first() if plan is None: raise HTTPException(404, "计划不存在或不属于当前租户") lock_archive_inventory(db, tenant_no=tenant_no, plan=plan) _reconcile_postings(db, tenant_no=tenant_no, plan=plan, desired={}, user_id=user_id, dept_id="")