PayrollManagementSystem/backend/app/services/product_service.py

303 lines
11 KiB
Python
Raw Normal View History

"""产品管理业务编排。
产品主数据的两条硬约束(违反会算错钱):
- ``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=str(product.stock_quantity) if product.stock_quantity is not None else None,
sale_price=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,
)
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(
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
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)