按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
264 lines
10 KiB
Python
264 lines
10 KiB
Python
"""业务提取产品与最近有效发货参照;预览指纹防止源变化后执行旧操作。"""
|
|
|
|
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}
|