PayrollManagementSystem/backend/app/services/product_service.py
liuwenxin 1461a8d113 feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
2026-11-03 11:20:55 +08:00

309 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""产品管理业务编排。
产品主数据的两条硬约束(违反会算错钱):
- ``product_category`` 是**折算阈值的行级锚点**:混合业务按产品逐条计算
(对照表 #14),阈值由 M1 参数 ``product.threshold`` 按 PRODUCT_CATEGORY
维度承担。类别游离于合法值域之外时,核算按类别回溯参数会取不到阈值,
故值域校验是业务规则而非格式校验
- 数量/单价全程 ``Decimal``:schema 层已拦格式与非负(金额红线入口),
service 层负责字符串到 Decimal 的转换与落库
另:``spec_model`` 归一为**空串而非 NULL** —— 它参与 uk_products_name_spec
组合唯一键,MySQL 组合索引对 NULL 不去重,归 NULL 会放行重名同规格数据。
删除为物理删除,被业务明细引用的保护检查待业务数据表(V5)就位后补充。
"""
import logging
from decimal import Decimal
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_DISABLE,
ACTION_ENABLE,
ACTION_UPDATE,
MODULE_PRODUCT,
PRODUCT_CATEGORIES,
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.models import Product, User
from app.repositories import product_repository
from app.schemas.product import (
ProductCreateIn,
ProductOut,
ProductQuery,
ProductUpdateIn,
)
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
def _to_out(product: Product) -> ProductOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
return ProductOut(
id=product.id,
product_name=product.product_name,
spec_model=product.spec_model,
unit=product.unit,
stock_quantity=(product.source_trade_json.get("quantity") if product.source_trade_json and not product.quantity_price_manual else str(product.stock_quantity) if product.stock_quantity is not None else None),
sale_price=(product.source_trade_json.get("price") if product.source_trade_json and not product.quantity_price_manual else str(product.sale_price) if product.sale_price is not None else None),
product_category=product.product_category,
status=product.status,
remark=product.remark,
create_time=product.create_time,
update_time=product.update_time,
source_trade_json=product.source_trade_json,
quantity_price_manual=product.quantity_price_manual or 0,
)
def _to_decimal(value: str | None) -> Decimal | None:
"""数字字符串转 Decimal(可空列的空串/None 透传 None)。
入参已 str 化再进 Decimal:即使上游口径变化传入数字类型,也不经 float
二进制近似,金额红线在最后一道转换处仍然成立。schema 已保证可解析,
此处不再重复校验。
"""
if not value:
return None
return Decimal(str(value))
def _ensure_category(product_category: str) -> None:
"""类别必须 ∈ PRODUCT_CATEGORIES(前端下拉仅是展示约束,后端校验是唯一防线)。"""
if product_category not in PRODUCT_CATEGORIES:
raise BadRequestError("产品类别不合法")
def _is_duplicate_entry(exc: IntegrityError) -> bool:
"""判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、
数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。
"""
return is_duplicate_entry(exc)
def _raise_if_duplicate(exc: IntegrityError) -> None:
"""唯一键冲突统一转业务提示;其余完整性错误原样重抛。"""
if _is_duplicate_entry(exc):
raise BadRequestError("已存在同名称同规格的产品") from exc
raise exc
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
"""分页查询产品(含停用)。"""
products = product_repository.list_products(session, query)
total = product_repository.count_products(session, query)
return PageResult[ProductOut](
records=[_to_out(product) for product in products],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_product(session: Session, product_id: int) -> ProductOut:
"""产品详情。
:raises NotFoundError: 产品不存在
"""
product = product_repository.get_by_id(session, product_id)
if product is None:
raise NotFoundError("产品不存在")
return _to_out(product)
def create_product(
session: Session,
payload: ProductCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> ProductOut:
"""新增产品。类别须在合法值域内,新产品固定启用。
:raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PRODUCT,
action=ACTION_CREATE,
summary=f"新增产品 {payload.product_name}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
# 值域校验在审计上下文内:类别非法的 400 也要留痕(成功与失败都记)
_ensure_category(payload.product_category)
spec_model = payload.spec_model or "" # 归一空串:组合唯一键对 NULL 不去重
if (
product_repository.get_by_name_spec(session, payload.product_name, spec_model)
is not None
):
raise BadRequestError("已存在同名称同规格的产品")
product = Product(
quantity_price_manual=1 if payload.stock_quantity is not None or payload.sale_price is not None else 0,
product_name=payload.product_name,
spec_model=spec_model,
unit=payload.unit,
stock_quantity=_to_decimal(payload.stock_quantity),
sale_price=_to_decimal(payload.sale_price),
product_category=payload.product_category,
status=1,
remark=payload.remark,
)
try:
product_repository.create(session, product)
# 响应与后置动作全部在 commit 之前完成(见 business_role_service 同处说明)
out = _to_out(product)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发提交同名称同规格会撞 uk_products_name_spec
session.rollback()
_raise_if_duplicate(exc)
logger.info("新增产品 | product_id=%s | category=%s", product.id, payload.product_category)
return out
def update_product(
session: Session,
product_id: int,
payload: ProductUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> ProductOut:
"""编辑产品基本信息(全量替换,不含状态;启停走独立端点)。
:raises NotFoundError: 产品不存在
:raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品
"""
product = product_repository.get_by_id(session, product_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PRODUCT,
action=ACTION_UPDATE,
summary=f"修改产品 {product.product_name if product else f'#{product_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
# 值域校验在审计上下文内:类别非法的 400 也要留痕(同 create 口径)
_ensure_category(payload.product_category)
if product is None:
raise NotFoundError("产品不存在")
product.product_name = payload.product_name
product.spec_model = payload.spec_model or ""
product.unit = payload.unit
old = _to_out(product)
if _to_decimal(payload.stock_quantity) != _to_decimal(old.stock_quantity) or _to_decimal(payload.sale_price) != _to_decimal(old.sale_price):
product.quantity_price_manual = 1
product.stock_quantity = _to_decimal(payload.stock_quantity)
product.sale_price = _to_decimal(payload.sale_price)
product.product_category = payload.product_category
product.remark = payload.remark
out = _to_out(product)
try:
session.commit()
except IntegrityError as exc:
# 改名/改规格可能撞 uk_products_name_spec(本行改值与他人既有数据重合)
session.rollback()
_raise_if_duplicate(exc)
logger.info("修改产品 | product_id=%s | category=%s", product_id, payload.product_category)
return out
def set_status(
session: Session,
product_id: int,
status: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> ProductOut:
"""启用或停用产品。停用为主(模型约定),物理删除仅限误录数据。
:raises NotFoundError: 产品不存在
"""
product = product_repository.get_by_id(session, product_id)
verb = "启用" if status == 1 else "停用"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PRODUCT,
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
summary=f"{verb}产品 {product.product_name if product else f'#{product_id}'}",
request_data={"status": status},
),
meta=meta,
):
if product is None:
raise NotFoundError("产品不存在")
product.status = status
out = _to_out(product)
session.commit()
logger.info("变更产品状态 | product_id=%s | status=%s", product_id, status)
return out
def delete_product(
session: Session,
product_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""物理删除产品(仅限误录数据,常规下架用停用)。
被业务明细/发货明细引用(RESTRICT 外键)的产品禁删:先 count_refs
给出可读 400(引导改走停用),校验与删除之间的并发引用由锁行 +
IntegrityError 兜底(customer_service.delete_customer 同款结构)。
:raises NotFoundError: 产品不存在
:raises BadRequestError: 产品已被业务或发货明细引用
"""
product = product_repository.get_by_id(session, product_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PRODUCT,
action=ACTION_DELETE,
summary=f"删除产品 {product.product_name if product else f'#{product_id}'}",
),
meta=meta,
):
if product is None:
raise NotFoundError("产品不存在")
if product_repository.count_refs(session, product_id):
raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)")
product_name = product.product_name # 固化:对象随删除失效
try:
product_repository.delete(session, product)
session.commit()
except IntegrityError as exc:
# 先查后删的并发窗口:新明细挂上了该产品,FK RESTRICT 兜底为 400
session.rollback()
raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)") from exc
logger.info("删除产品 | product_name=%s", product_name)