Files
shanxia2.0/backend/app/services/pl_inventory_service.py
T
wuzilongandCursor bde19c0644 feat: 锁定执行中计划并按装卸明细差量归档库存,补齐派车日历数量与酸碱类型。
执行中的运输计划不再随意改单;库存流水记到装卸产品明细;手工派车日历区分酸碱并记录数量。同时收紧合同附件与子表的差量保存,并清理已废弃的 bak 备份目录。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 07:59:53 +08:00

378 lines
19 KiB
Python

"""归档库存按计划差量记账。
按产品归档时,同一产品、品牌、方向仍是一条流水。
按装卸段归档时,每条装货或送货产品明细单独一条流水,并用明细编号对应原流水。
原流水创建时间保留;修订反映在原入账期间,不把无变更保存变成新入账。
"""
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="")