执行中的运输计划不再随意改单;库存流水记到装卸产品明细;手工派车日历区分酸碱并记录数量。同时收紧合同附件与子表的差量保存,并清理已废弃的 bak 备份目录。 Co-authored-by: Cursor <cursoragent@cursor.com>
378 lines
19 KiB
Python
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="")
|