PayrollManagementSystem/backend/app/services/product_completion.py

264 lines
10 KiB
Python
Raw Normal View History

"""业务提取产品与最近有效发货参照;预览指纹防止源变化后执行旧操作。"""
from collections import defaultdict
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import Business, BusinessItem, Product
from app.services import audit_service
from app.services.audit_service import AuditRecord
from app.services.tplus.snapshots import source_records, source_statement
from app.services.tplus.workbench import WorkbenchError, digest
def recent_trades(session, source, ids):
if not ids:
return {}
try:
lines = list(
session.scalars(
source_statement(session, source, "shipment_items").where(
source_statement_inventory_filter(ids)
)
)
)
parent_ids = {
str(r.payload_json["source_parent_id"])
for r in lines
if r.payload_json.get("source_parent_id") is not None
}
heads = {
r.source_id: r for r in source_records(session, source, "shipments", list(parent_ids))
}
except WorkbenchError:
return {}
latest = {}
for record in lines:
raw = record.payload_json
head = heads.get(str(raw.get("source_parent_id")))
if head is None:
continue
value = head.payload_json
if str(value.get("voucherState")) != "189" or value.get("isCounteractDelivery") not in (
None,
0,
False,
"0",
):
continue
day = str(value.get("voucherdate") or "")[:10]
try:
quantity = Decimal(str(raw.get("quantity")))
price = (
Decimal(str(raw["taxPrice"]))
if raw.get("taxPrice") is not None
else Decimal(str(raw["taxAmount"])) / quantity
)
if not quantity.is_finite() or quantity <= 0 or not price.is_finite() or price < 0:
continue
except (ValueError, ArithmeticError, TypeError, KeyError):
continue
key = str(raw.get("idinventory"))
order = day, int(head.source_id), int(record.source_id)
if not day or (key in latest and order <= latest[key][0]):
continue
if (
quantity > Decimal("999999999999.99")
or quantity.normalize().as_tuple().exponent < -2
or price > Decimal("9999999999.9999")
):
# 无法以当前产品档案精度无损保存的记录保持待核对,不截断数量。
continue
latest[key] = (
order,
{
"quantity": str(quantity),
"price": str(price.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)),
"date": day,
"source_no": head.source_no,
"source_id": head.source_id,
"line_id": record.source_id,
"source_database": source,
},
)
return {key: value[1] for key, value in latest.items()}
def source_statement_inventory_filter(ids):
from app.models import TPlusSyncRecord
return TPlusSyncRecord.payload_json["idinventory"].as_integer().in_([int(v) for v in ids])
def candidates(session, keyword="", page_num=1, page_size=20):
condition = BusinessItem.product_id.is_(None)
if keyword:
condition &= or_(
BusinessItem.product_name.contains(keyword, autoescape=True),
Business.business_no.contains(keyword, autoescape=True),
)
rows = session.execute(
select(BusinessItem, Business.business_no, Business.business_date)
.join(Business, Business.id == BusinessItem.business_id)
.where(condition)
.order_by(BusinessItem.id.desc())
.offset((page_num - 1) * page_size)
.limit(page_size + 1)
).all()
return {
"rows": [
{
"id": row.id,
"business_no": number,
"business_date": str(day) if day else None,
"product_name": row.product_name,
"spec_model": row.spec_model,
"unit": row.unit,
"product_category": row.product_category,
"quantity": str(row.quantity),
"sale_price": str(row.sale_price) if row.sale_price is not None else None,
}
for row, number, day in rows[:page_size]
],
"has_more": len(rows) > page_size,
"page_num": page_num,
}
def extraction_plan(session, ids, *, lock=False):
if len(ids) != len(set(ids)):
raise BadRequestError("不能重复选择同一业务明细")
statement = (
select(BusinessItem, Business.business_date)
.join(Business, Business.id == BusinessItem.business_id)
.where(BusinessItem.id.in_(ids))
.order_by(BusinessItem.id)
)
if lock:
statement = statement.with_for_update().execution_options(populate_existing=True)
source = session.execute(statement).all()
if len(source) != len(ids):
raise BadRequestError("所选业务明细不存在或已变化")
groups = defaultdict(list)
for item, day in source:
groups[(item.product_name.strip(), (item.spec_model or "").strip())].append((item, day))
rows = []
for (name, spec), members in groups.items():
target_statement = select(Product).where(
Product.product_name == name, Product.spec_model == spec
)
if lock:
target_statement = target_statement.with_for_update().execution_options(
populate_existing=True
)
target = session.scalar(target_statement)
units = {m.unit or "" for m, _ in members}
categories = {m.product_category for m, _ in members}
conflict = "所选明细单位或核算分类不一致" if len(units) > 1 or len(categories) > 1 else None
if target and (
target.product_category not in categories or (target.unit or "") not in units
):
conflict = "同名同规格档案的单位或核算分类不同"
newest, day = max(
members, key=lambda pair: (str(pair[1] or ""), pair[0].business_id, pair[0].id)
)
rows.append(
{
"product_name": name,
"spec_model": spec,
"unit": newest.unit,
"product_category": newest.product_category,
"quantity": str(newest.quantity),
"sale_price": str(newest.sale_price) if newest.sale_price is not None else None,
"item_ids": [m.id for m, _ in members],
"target_id": target.id if target else None,
"action": "CONFLICT" if conflict else "LINK" if target else "CREATE",
"reason": conflict,
"already_linked": [m.product_id for m, _ in members],
"target_fingerprint": digest(
{c.name: getattr(target, c.name) for c in target.__table__.columns}
)
if target
else None,
"date": str(day) if day else None,
}
)
return {"rows": rows, "fingerprint": digest(rows)}
def extract(session, payload, actor, meta=None):
with audit_service.audit(
session,
operator=actor,
record=AuditRecord(
module="产品管理",
action="提取产品",
summary=f"从{len(payload.item_ids)}条业务明细提取产品",
request_data=payload.model_dump(),
),
meta=meta,
):
plan = extraction_plan(session, payload.item_ids, lock=True)
if payload.fingerprint != plan["fingerprint"]:
raise BadRequestError("业务明细或产品档案已变化,请重新预览")
for row in plan["rows"]:
if row["action"] == "CONFLICT":
continue
try:
with session.begin_nested():
target = (
session.get(Product, row["target_id"])
if row["target_id"]
else Product(
product_name=row["product_name"],
spec_model=row["spec_model"],
unit=row["unit"],
product_category=row["product_category"],
stock_quantity=Decimal(row["quantity"]),
sale_price=Decimal(row["sale_price"])
if row["sale_price"] is not None
else None,
status=1,
quantity_price_manual=1,
)
)
session.add(target)
session.flush()
row["target_id"] = target.id
for item_id in row["item_ids"]:
item = session.get(BusinessItem, item_id)
# 仅建立档案关联,不更改历史冗余名称、金额或冻结核算资料。
if item.product_id is None:
item.product_id = target.id
elif item.product_id != target.id:
raise BadRequestError("业务明细已关联不同产品,请重新核对")
session.flush()
except IntegrityError:
row["action"], row["reason"] = "CONFLICT", "并发建立了同名档案,请重新预览"
session.commit()
return plan
def restore_source(session, product_id, actor, meta=None):
product = session.get(Product, product_id, with_for_update=True)
if product is None:
raise NotFoundError("产品不存在")
if not product.source_trade_json:
raise BadRequestError("没有可恢复的有效发货来源")
with audit_service.audit(
session,
operator=actor,
record=AuditRecord(
module="产品管理", action="恢复源值", summary=f"产品#{product_id}恢复最近有效发货参照"
),
meta=meta,
):
product.quantity_price_manual = 0
product.stock_quantity = Decimal(product.source_trade_json["quantity"])
product.sale_price = Decimal(product.source_trade_json["price"])
session.commit()
return {"id": product_id, "restored": True}