"""销售订单服务层。 负责销售订单的全生命周期管理,包括创建、编辑、提交审核、审批、取消、 发厂文案生成、状态流转等核心业务逻辑。 依赖多个 Repository 进行数据持久化,依赖 audit_service 记录操作审计日志, 依赖 arrears_service 同步欠款数据,依赖 ReminderRepository 发送状态变更通知。 """ from datetime import datetime import json from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from backend.app.core.cache import cache_delete, cache_delete_pattern, cache_get, cache_set, make_cache_key from backend.app.core.error_codes import ErrorCode from backend.app.core.exceptions import AppException from backend.app.repositories.customer_repository import CustomerRepository from backend.app.repositories.config_repository import ConfigRepository from backend.app.repositories.file_repository import FileRepository from backend.app.repositories.logistics_repository import LogisticsRepository from backend.app.repositories.order_approve_log_repository import OrderApproveLogRepository from backend.app.repositories.order_repository import OrderRepository from backend.app.repositories.reminder_repository import ReminderRepository from backend.app.services.arrears_service import arrears_service from backend.app.services.audit_service import audit_service from backend.app.services.event_bus import event_bus class OrderService: """订单服务,封装销售订单的完整业务流程。 依赖: OrderRepository - 订单数据访问 CustomerRepository - 客户数据访问(订单录入时自动创建客户) ConfigRepository - 系统配置读取 OrderApproveLogRepository - 审批日志 LogisticsRepository - 物流任务和轨迹 FileRepository - 附件查询 ReminderRepository - 状态变更提醒通知 arrears_service - 欠款同步 audit_service - 操作审计日志 """ def __init__(self) -> None: self.order_repository = OrderRepository() self.customer_repository = CustomerRepository() self.config_repository = ConfigRepository() self.order_approve_log_repository = OrderApproveLogRepository() self.logistics_repository = LogisticsRepository() self.file_repository = FileRepository() self.reminder_repository = ReminderRepository() def list_orders(self, session: Session | None = None, filters: dict | None = None, current_user: dict | None = None) -> dict: """查询订单列表。 根据过滤条件查询订单,并根据用户角色过滤敏感字段(如利润、佣金对业务员不可见)。 Args: session: 数据库会话 filters: 查询过滤条件 current_user: 当前登录用户信息,用于角色级数据过滤 Returns: 包含 total 和 list 的字典 被调用路由: orders.py - GET /orders, salesman.py - GET /salesman/orders """ if session is not None: # 读缓存 cache_key = make_cache_key("order:list", user_id=(current_user or {}).get("user_id"), **(filters or {})) cached = cache_get(cache_key) if cached is not None: return cached try: result = self.order_repository.list_orders_by_filters(session, filters or {}) total = result["total"] or 0 orders = result["list"] role_code = (current_user or {}).get("role_code") rows = [] for item in orders: # 获取订单明细摘要 order_items = self.order_repository.list_order_items(session, item.id) items_summary = "" if order_items: summary_parts = [f"{oi.product_name}×{int(oi.quantity or 0)}" for oi in order_items[:2]] items_summary = "、".join(summary_parts) if len(order_items) > 2: items_summary += "..." # 获取物流信息 task = self.logistics_repository.get_task_by_order_id(session, item.id) logistics_info = "" if task: logistics_info = task.express_company + " " + (task.tracking_number or "") if task.express_company else task.status or "" row = self._filter_order_row( { "order_id": item.id, "order_no": item.order_no, "customer_name": item.customer_name, "customer_mobile": item.customer_mobile, "salesman_name": "", "order_status": item.order_status, "order_source": item.order_source, "sale_price_total": float(item.sale_price_total or 0), "profit_total": float(item.profit_total or 0), "commission_amount": float(item.commission_amount or 0), "created_at": item.created_at.strftime("%Y-%m-%d %H:%M:%S") if item.created_at else "", "items_summary": items_summary, "logistics_info": logistics_info, }, role_code, ) rows.append(row) # 写缓存 result_data = {"total": total, "list": rows} cache_set(cache_key, result_data, ttl=30) return result_data except SQLAlchemyError as exc: raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc return {"total": 0, "list": []} def get_order( self, order_id: int, session: Session | None = None, current_user: dict | None = None, ) -> dict | None: """查询单个订单详情。 聚合订单主档、明细、审批日志、物流任务、物流轨迹、附件、计算明细等信息。 根据用户角色过滤敏感字段。 Args: order_id: 订单 ID session: 数据库会话 current_user: 当前登录用户信息 Returns: 订单完整详情字典,订单不存在时返回 None 被调用路由: orders.py - GET /orders/{order_id} """ if session is not None: # 读缓存 cache_key = f"order:detail:{order_id}" cached = cache_get(cache_key) if cached is not None: return self._filter_order_detail_by_role(cached, current_user) try: order = self.order_repository.get_order(session, order_id) if order is None: return None self._ensure_order_access(order, current_user) supplier = self.order_repository.get_supplier(session, order.factory_id) items = self.order_repository.list_order_items(session, order.id) approve_logs = self.order_approve_log_repository.list_by_order_id(session, order.id) task = self.logistics_repository.get_task_by_order_id(session, order.id) traces = self.logistics_repository.list_traces_by_order_id(session, order.id) order_attachments = self.file_repository.list_attachments(session, "sales_order", order.id) task_attachments = self.file_repository.list_attachments(session, "logistics_task", task.id) if task else [] result = { "order_id": order.id, "order_no": order.order_no, "customer_id": order.customer_id, "customer_name": order.customer_name, "customer_mobile": order.customer_mobile, "customer_address": order.customer_address, "salesman_id": order.salesman_id, "salesman_name": "", "order_source": order.order_source, "order_status": order.order_status, "delivery_type": order.delivery_type, "factory_id": order.factory_id, "factory_name": supplier.supplier_name if supplier else "", "sale_price_total": float(order.sale_price_total or 0), "cost_price_total": float(order.cost_price_total or 0), "rebate_total": float(order.rebate_total or 0), "freight_total": float(order.freight_total or 0), "tax_total": float(order.tax_total or 0), "other_fee_total": float(order.other_fee_total or 0), "profit_total": float(order.profit_total or 0), "profit_rate": float(order.profit_rate or 0), "commission_amount": float(order.commission_amount or 0), "payment_method": order.payment_method, "tax_amount": float(order.tax_amount or 0), "items": [ { "item_id": item.id, "product_id": item.product_id, "product_name": item.product_name, "specification": item.specification, "unit": item.unit, "quantity": float(item.quantity or 0), "sale_price": float(item.sale_price or 0), "cost_price": float(item.cost_price or 0), "rebate_amount": float(item.rebate_amount or 0), "freight_amount": float(item.freight_amount or 0), "tax_amount": float(item.tax_amount or 0), "other_fee_amount": float(item.other_fee_amount or 0), "remark": item.remark, "pricing_type": getattr(item, "pricing_type", None), "length_m": float(item.length_m) if getattr(item, "length_m", None) else None, "width_m": float(item.width_m) if getattr(item, "width_m", None) else None, "area_sqm": float(item.area_sqm) if getattr(item, "area_sqm", None) else None, "surcharge_detail": getattr(item, "surcharge_detail", None), "processing_detail": getattr(item, "processing_detail", None), "supplier_id": getattr(item, "supplier_id", None), "supplier_model": getattr(item, "supplier_model", None), "price_tier": getattr(item, "price_tier", None), } for item in items ], "approve_logs": [ { "log_id": log.id, "approve_type": log.approve_type, "approve_result": log.approve_result, "before_status": log.before_status, "after_status": log.after_status, "approve_opinion": log.approve_opinion, "operator_id": log.operator_id, "approve_time": log.created_at.strftime("%Y-%m-%d %H:%M:%S") if log.created_at else "", } for log in approve_logs ], "logistics_info": { "task": self._build_task_summary(task), "trace_list": [ { "task_id": item.task_id, "node_time": item.node_time.strftime("%Y-%m-%d %H:%M:%S") if item.node_time else "", "node_desc": item.node_desc, "node_type": item.node_type, "source_platform": item.source_platform, "remark": item.remark, } for item in traces ], }, "attachments": [ *[self._build_attachment_row(item) for item in order_attachments], *[self._build_attachment_row(item) for item in task_attachments], ], "calculation_detail": self._build_calculation_detail(order, items), "profit_alert_threshold": float(self._get_config_value(session, "profit_alert_threshold", "0")), "remark": order.remark, } # 写缓存(缓存完整结果,角色过滤在读取后执行) cache_set(cache_key, result, ttl=60) return self._filter_order_detail_by_role(result, current_user) except SQLAlchemyError as exc: raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc return None def update_order( self, order_id: int, payload: dict, session: Session | None = None, current_user: dict | None = None, ) -> dict | None: """编辑订单。 仅草稿和已退回状态的订单允许编辑。编辑时若客户不存在会自动创建。 重新计算销售总额、成本总额、利润等汇总数据。 Args: order_id: 订单 ID payload: 包含订单主档和明细的请求体 session: 数据库会话 current_user: 当前登录用户信息 Returns: 更新结果字典,包含订单汇总数据 被调用路由: orders.py - PUT /orders/{order_id} """ if session is not None: try: order = self.order_repository.get_order(session, order_id) if order is None: return None self._ensure_order_access(order, current_user, owner_only=True) self._ensure_status(order.order_status, {"draft", "rejected", "pending_approve"}, "当前状态不允许编辑订单") if not payload["customer_name"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="客户姓名不能为空", status_code=400) if not payload["customer_mobile"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="客户手机号不能为空", status_code=400) customer = self.customer_repository.find_by_name_and_mobile( session, payload["customer_name"], payload["customer_mobile"], ) if customer is None: customer = self.customer_repository.create_customer( session, { "customer_name": payload["customer_name"], "mobile": payload["customer_mobile"], "address": payload.get("customer_address"), "settlement_type": None, "settlement_days": 0, "customer_type": None, "salesman_id": current_user.get("user_id") if current_user else payload.get("salesman_id"), "credit_limit": 0, "remark": "订单编辑时自动创建", }, ) items = payload["items"] if not items: raise AppException(code=ErrorCode.PARAM_ERROR, message="订单明细不能为空", status_code=400) sale_total = sum(item["quantity"] * item["sale_price"] for item in items) cost_total = sum(item["quantity"] * item["cost_price"] for item in items) profit_total = sale_total - cost_total - payload["rebate_total"] - payload["freight_total"] - payload["tax_total"] - payload["other_fee_total"] profit_rate = round((profit_total / sale_total) * 100, 2) if sale_total else 0 before_status = order.order_status self.order_repository.update_order_with_items( session, order, { "customer_id": customer.id, "customer_name": payload["customer_name"], "customer_mobile": payload["customer_mobile"], "customer_address": payload.get("customer_address"), "salesman_id": current_user.get("user_id") if current_user else payload.get("salesman_id"), "order_source": payload.get("order_source"), "delivery_type": payload.get("delivery_type"), "factory_id": payload.get("factory_id"), "sale_price_total": sale_total, "cost_price_total": cost_total, "rebate_total": payload["rebate_total"], "freight_total": payload["freight_total"], "tax_total": payload["tax_total"], "other_fee_total": payload["other_fee_total"], "profit_total": profit_total, "profit_rate": profit_rate, "commission_amount": payload["commission_amount"], "payment_method": payload.get("payment_method"), "tax_amount": payload.get("tax_amount", 0), "remark": payload.get("remark"), }, items, ) audit_service.write_log( session, { "operate_type": "order_update", "biz_type": "sales_order", "biz_id": order.id, "before_value": {"order_status": before_status}, "after_value": { "order_no": order.order_no, "order_status": order.order_status, "sale_price_total": round(sale_total, 2), "cost_price_total": round(cost_total, 2), "profit_total": round(profit_total, 2), "profit_rate": profit_rate, "commission_amount": payload["commission_amount"], }, "remark": f"更新订单 {order.order_no}", }, ) session.commit() self._invalidate_order_cache(order.id) return { "order_id": order.id, "order_no": order.order_no, "order_status": order.order_status, "sale_price_total": round(sale_total, 2), "cost_price_total": round(cost_total, 2), "profit_total": round(profit_total, 2), "profit_rate": profit_rate, "commission_amount": payload["commission_amount"], "payment_method": payload.get("payment_method"), "tax_amount": payload.get("tax_amount", 0), } except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500) def submit_order( self, order_id: int, session: Session | None = None, current_user: dict | None = None, ) -> dict | None: """提交订单审核。 仅草稿和已退回状态的订单允许提交。提交后状态变为 pending_approve, 并向经理角色发送审批提醒通知。 Args: order_id: 订单 ID session: 数据库会话 current_user: 当前登录用户信息 Returns: 提交结果字典 被调用路由: orders.py - POST /orders/{order_id}/submit """ if session is not None: try: order = self.order_repository.get_order(session, order_id) if order is None: return None self._ensure_order_access(order, current_user, owner_only=True) self._ensure_status(order.order_status, {"draft", "rejected", "pending_approve"}, "当前状态不允许提交审核") before_status = order.order_status self.order_repository.update_order_status(session, order, "pending_approve") audit_service.write_log( session, { "operate_type": "order_submit", "biz_type": "sales_order", "biz_id": order.id, "before_value": {"order_status": before_status}, "after_value": {"order_status": order.order_status}, "remark": f"提交订单审核 {order.order_no}", }, ) self._notify_status_change(session, order, before_status, order.order_status, current_user.get("user_id") if current_user else None) session.commit() self._invalidate_order_cache(order.id) return { "order_id": order.id, "order_status": order.order_status, "submitted_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc return None def cancel_order( self, order_id: int, payload: dict, session: Session | None = None, current_user: dict | None = None, ) -> dict | None: """发起订单取消。 根据订单当前状态决定取消方式:草稿/待审核可直接取消, 已通过/履约中的订单需走取消审批流程。必须填写取消原因。 Args: order_id: 订单 ID payload: 包含 cancel_reason、cancel_opinion 的请求体 session: 数据库会话 current_user: 当前登录用户信息 Returns: 取消结果字典,包含状态变更信息 被调用路由: orders.py - POST /orders/{order_id}/cancel """ self._validate_cancel_payload(payload) if session is not None: try: order = self.order_repository.get_order(session, order_id) if order is None: return None self._ensure_order_access(order, current_user, owner_only=True) if order.order_status in {"canceled", "settled"}: raise AppException(code=ErrorCode.INVALID_STATUS, message="当前状态不允许取消订单", status_code=400) allow_cancel_config = self._get_config_value(session, "allow_cancel_statuses", "draft,pending_approve,approved,pending_factory") allow_cancel_statuses = {s.strip() for s in allow_cancel_config.split(",") if s.strip()} if order.order_status not in allow_cancel_statuses: raise AppException(code=ErrorCode.INVALID_STATUS, message=f"当前状态「{order.order_status}」不允许取消", status_code=400) _DIRECT_CANCEL_STATUSES = {"draft", "pending_approve"} _APPROVED_CANCEL_STATUSES = {"approved"} _FULFILLMENT_CANCEL_STATUSES = {"pending_factory", "pending_driver", "accepted", "picked_up", "delivered", "production", "shipped"} if order.order_status in _DIRECT_CANCEL_STATUSES: next_status = "canceled" elif order.order_status in _APPROVED_CANCEL_STATUSES: next_status = "cancel_pending" elif order.order_status in _FULFILLMENT_CANCEL_STATUSES: next_status = "cancel_fulfillment_pending" else: next_status = "cancel_pending" previous_status = order.order_status self.order_repository.update_cancel_fields( session, order, { "order_status": next_status, "cancel_requested_by": current_user.get("user_id") if current_user else None, "cancel_requested_at": datetime.now(), "cancel_reason": payload["cancel_reason"].strip(), "cancel_opinion": payload.get("cancel_opinion"), "cancel_previous_status": None if next_status == "canceled" else previous_status, }, ) if next_status == "canceled": self.order_repository.clear_cancel_request(session, order) self.order_repository.update_order_status(session, order, "canceled") audit_service.write_log( session, { "operate_type": "order_cancel", "biz_type": "sales_order", "biz_id": order.id, "before_value": {"order_status": previous_status}, "after_value": { "order_status": order.order_status, "cancel_reason": order.cancel_reason, "cancel_previous_status": order.cancel_previous_status, "cancel_requested_by": order.cancel_requested_by, }, "remark": f"发起订单取消 {order.order_no}", }, ) self._notify_status_change(session, order, previous_status, order.order_status, current_user.get("user_id") if current_user else None) session.commit() self._invalidate_order_cache(order.id) return { "order_id": order.id, "order_status": order.order_status, "previous_status": previous_status, "canceled_at": order.cancel_requested_at.strftime("%Y-%m-%d %H:%M:%S") if order.cancel_requested_at else None, } except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc return None def approve_order( self, order_id: int, payload: dict, session: Session | None = None, current_user: dict | None = None, ) -> dict | None: """审批订单(通过或退回)。 仅待审核状态的订单允许审批。通过后进入待下发工厂状态, 退回后状态恢复为 rejected。驳回时必须填写审批意见。 自动记录审批日志并通知业务员。 Args: order_id: 订单 ID payload: 包含 approve_result(pass/reject)和 approve_opinion 的请求体 session: 数据库会话 current_user: 当前登录用户信息 Returns: 审批结果字典 被调用路由: orders.py - POST /orders/{order_id}/approve """ self._validate_approve_payload(payload) if session is not None: try: order = self.order_repository.get_order(session, order_id) if order is None: return None self._ensure_order_access(order, current_user) self._ensure_status(order.order_status, {"pending_approve"}, "当前状态不允许审批") previous_status = order.order_status target_status = "approved" if payload["approve_result"] == "pass" else "rejected" self.order_repository.update_order_status(session, order, target_status) self.order_approve_log_repository.create_log( session, { "order_id": order.id, "approve_type": "order", "approve_result": payload["approve_result"], "before_status": previous_status, "after_status": target_status, "approve_opinion": payload.get("approve_opinion"), "operator_id": current_user.get("user_id") if current_user else None, }, ) audit_service.write_log( session, { "operate_type": "order_approve", "biz_type": "sales_order", "biz_id": order.id, "before_value": {"order_status": previous_status}, "after_value": { "order_status": order.order_status, "approve_result": payload["approve_result"], "approve_opinion": payload.get("approve_opinion"), }, "remark": f"订单审批 {order.order_no}", }, ) self._notify_status_change(session, order, previous_status, order.order_status, current_user.get("user_id") if current_user else None, {"approve_opinion": payload.get("approve_opinion")}) session.commit() # 触发审批事件,通知业务员 if payload["approve_result"] == "pass": event_bus.emit("order_approved", { "order_id": order.id, "order_no": order.order_no, "salesman_id": order.salesman_id, "biz_type": "sales_order", "biz_id": order.id, }, session) else: event_bus.emit("order_rejected", { "order_id": order.id, "order_no": order.order_no, "salesman_id": order.salesman_id, "reason": payload.get("approve_opinion", ""), "biz_type": "sales_order", "biz_id": order.id, }, session) self._invalidate_order_cache(order.id) return { "order_id": order.id, "order_status": order.order_status, "profit_total": float(order.profit_total or 0), "profit_rate": float(order.profit_rate or 0), "approve_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "approve_result": payload["approve_result"], "approve_opinion": payload.get("approve_opinion"), } except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc return None def cancel_approve_order( self, order_id: int, payload: dict, session: Session | None = None, current_user: dict | None = None, ) -> dict | None: """审批订单取消申请(通过或驳回)。 仅取消审批中或履约取消审批中的订单允许操作。 通过则订单变为已取消,驳回则恢复为取消前的状态。 驳回时必须填写审批意见。 Args: order_id: 订单 ID payload: 包含 approve_result(pass/refuse)和 approve_opinion 的请求体 session: 数据库会话 current_user: 当前登录用户信息 Returns: 审批结果字典 被调用路由: orders.py - POST /orders/{order_id}/cancel-approve """ self._validate_cancel_approve_payload(payload) if session is not None: try: order = self.order_repository.get_order(session, order_id) if order is None: return None self._ensure_order_access(order, current_user) self._ensure_status(order.order_status, {"cancel_pending", "cancel_fulfillment_pending"}, "当前状态不允许取消审批") previous_status = order.order_status target_status = "canceled" if payload["approve_result"] == "pass" else (order.cancel_previous_status or "approved") self.order_repository.update_order_status(session, order, target_status) if target_status != "canceled": self.order_repository.clear_cancel_request(session, order) self.order_approve_log_repository.create_log( session, { "order_id": order.id, "approve_type": "cancel_order", "approve_result": payload["approve_result"], "before_status": previous_status, "after_status": target_status, "approve_opinion": payload.get("approve_opinion"), "operator_id": current_user.get("user_id") if current_user else None, }, ) audit_service.write_log( session, { "operate_type": "order_cancel_approve", "biz_type": "sales_order", "biz_id": order.id, "before_value": { "order_status": previous_status, "cancel_previous_status": order.cancel_previous_status, }, "after_value": { "order_status": order.order_status, "approve_result": payload["approve_result"], "approve_opinion": payload.get("approve_opinion"), }, "remark": f"订单取消审批 {order.order_no}", }, ) self._notify_status_change(session, order, previous_status, order.order_status, current_user.get("user_id") if current_user else None) session.commit() self._invalidate_order_cache(order.id) return { "order_id": order.id, "order_status": order.order_status, "approve_result": payload["approve_result"], "approve_opinion": payload.get("approve_opinion"), } except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc return None def get_supplier_text( self, order_id: int, payload: dict, session: Session | None = None, current_user: dict | None = None, ) -> dict | None: """生成发厂文案。 根据订单明细和供应商模板类型生成文本,仅已通过状态的订单允许操作。 Args: order_id: 订单 ID payload: 包含 supplier_id 的请求体(可选) session: 数据库会话 current_user: 当前登录用户信息 Returns: 包含 text_content 的发厂文案字典 被调用路由: orders.py - POST /orders/{order_id}/supplier-text """ if session is not None: try: order = self.order_repository.get_order(session, order_id) if order is None: return None self._ensure_order_access(order, current_user) self._ensure_status(order.order_status, {"approved"}, "当前状态不允许生成发厂文案") supplier_id = payload.get("supplier_id") or order.factory_id if not supplier_id: raise AppException(code=ErrorCode.NOT_FOUND, message="订单未绑定工厂", status_code=404) supplier = self.order_repository.get_supplier(session, supplier_id) if supplier is None: raise AppException(code=ErrorCode.NOT_FOUND, message="工厂不存在", status_code=404) items = self.order_repository.list_order_items(session, order.id) template_type = supplier.template_type or "default" text_content = self._build_supplier_text(order, items, template_type) return { "order_id": order.id, "supplier_id": supplier.id, "template_type": template_type, "text_content": text_content, } except SQLAlchemyError as exc: raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc return None def confirm_supplier_text( self, order_id: int, payload: dict, session: Session | None = None, current_user: dict | None = None, ) -> dict | None: """确认发厂文案。 确认后订单状态从 approved 变更为 pending_factory,进入履约阶段。 自动记录发厂文案日志和审计日志。 Args: order_id: 订单 ID payload: 包含 text_content、supplier_id 等的请求体 session: 数据库会话 current_user: 当前登录用户信息 Returns: 确认结果字典 被调用路由: orders.py - POST /orders/{order_id}/confirm-supplier-text """ if not (payload.get("text_content") or "").strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="发厂文案不能为空", status_code=400) if session is not None: try: order = self.order_repository.get_order(session, order_id) if order is None: return None self._ensure_order_access(order, current_user) self._ensure_status(order.order_status, {"approved"}, "当前状态不允许确认发厂") supplier_id = payload.get("supplier_id") or order.factory_id if not supplier_id: raise AppException(code=ErrorCode.NOT_FOUND, message="订单未绑定工厂", status_code=404) supplier = self.order_repository.get_supplier(session, supplier_id) if supplier is None: raise AppException(code=ErrorCode.NOT_FOUND, message="工厂不存在", status_code=404) before_status = order.order_status self.order_repository.update_supplier_text_confirm(session, order, current_user.get("user_id") if current_user else None) self._record_supplier_text_log(session, order, {**payload, "supplier_id": supplier_id}, current_user) audit_service.write_log( session, { "operate_type": "order_supplier_text_confirm", "biz_type": "sales_order", "biz_id": order.id, "before_value": {"order_status": before_status}, "after_value": { "order_status": order.order_status, "supplier_id": supplier_id, "text_content": payload["text_content"], "remark": payload.get("remark"), }, "remark": f"确认发厂文案 {order.order_no}", }, ) session.commit() # 触发订单发厂事件,通知业务员 event_bus.emit("order_shipped", { "order_id": order.id, "order_no": order.order_no, "salesman_id": order.salesman_id, "supplier_name": supplier.supplier_name if supplier else "", "biz_type": "sales_order", "biz_id": order.id, }, session) self._invalidate_order_cache(order.id) return { "order_id": order.id, "order_status": order.order_status, "confirmed_at": order.supplier_text_confirmed_at.strftime("%Y-%m-%d %H:%M:%S") if order.supplier_text_confirmed_at else datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc return None def change_order_status( self, order_id: int, payload: dict, session: Session | None = None, current_user: dict | None = None, ) -> dict | None: """手动变更订单状态。 校验状态流转合法性(基于 _VALID_STATUS_TRANSITIONS),同步欠款数据。 Args: order_id: 订单 ID payload: 包含 target_status 的请求体 session: 数据库会话 current_user: 当前登录用户信息 Returns: 状态变更结果字典 被调用路由: orders.py - POST /orders/{order_id}/status """ if session is not None: try: order = self.order_repository.get_order(session, order_id) if order is None: return None self._ensure_order_access(order, current_user) target_status = (payload.get("target_status") or "").strip() if not target_status: raise AppException(code=ErrorCode.PARAM_ERROR, message="目标状态不能为空", status_code=400) allowed_statuses = { "approved", "pending_factory", "pending_driver", "accepted", "picked_up", "delivered", "production", "shipped", "completed", "settled", "canceled", } if target_status not in allowed_statuses: raise AppException(code=ErrorCode.PARAM_ERROR, message="目标状态不支持", status_code=400) self._validate_status_transition(order.order_status, target_status) before_status = order.order_status self.order_repository.update_order_status(session, order, target_status) arrears_service.sync_order_arrears(session, order) audit_service.write_log( session, { "operate_type": "order_status_change", "biz_type": "sales_order", "biz_id": order.id, "before_value": {"order_status": before_status}, "after_value": {"order_status": order.order_status}, "remark": payload.get("remark") or f"订单状态变更为 {target_status}", }, ) self._notify_status_change(session, order, before_status, order.order_status, current_user.get("user_id") if current_user else None) session.commit() self._invalidate_order_cache(order.id) return { "order_id": order.id, "order_status": order.order_status, } except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc return None def create_order( self, payload: dict, session: Session | None = None, current_user: dict | None = None, ) -> dict: """创建新订单。 若客户不存在会自动创建(基于姓名+手机号匹配)。 生成唯一订单号(SO + 时间戳),计算销售/成本/利润汇总。 初始状态为 pending_approve,直接进入审批流程。 Args: payload: 包含客户信息、订单明细、费用等的请求体 session: 数据库会话 current_user: 当前登录用户信息 Returns: 创建结果字典,包含订单号、状态和汇总金额 被调用路由: orders.py - POST /orders """ if session is not None: try: if not payload["customer_name"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="客户姓名不能为空", status_code=400) if not payload["customer_mobile"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="客户手机号不能为空", status_code=400) customer = self.customer_repository.find_by_name_and_mobile( session, payload["customer_name"], payload["customer_mobile"], ) if customer is None: customer = self.customer_repository.create_customer( session, { "customer_name": payload["customer_name"], "mobile": payload["customer_mobile"], "address": payload.get("customer_address"), "settlement_type": None, "settlement_days": 0, "customer_type": None, "salesman_id": current_user.get("user_id") if current_user else payload.get("salesman_id"), "credit_limit": 0, "remark": "订单录入时自动创建", }, ) items = payload["items"] if not items: raise AppException(code=ErrorCode.PARAM_ERROR, message="订单明细不能为空", status_code=400) sale_total = sum(item["quantity"] * item["sale_price"] for item in items) cost_total = sum(item["quantity"] * item["cost_price"] for item in items) profit_total = ( sale_total - cost_total - payload["rebate_total"] - payload["freight_total"] - payload["tax_total"] - payload["other_fee_total"] ) profit_rate = round((profit_total / sale_total) * 100, 2) if sale_total else 0 order = self.order_repository.create_order( session, { "order_no": f"SO{datetime.now().strftime('%Y%m%d%H%M%S')}", "customer_id": customer.id, "customer_name": payload["customer_name"], "customer_mobile": payload["customer_mobile"], "customer_address": payload.get("customer_address"), "salesman_id": current_user.get("user_id") if current_user else payload.get("salesman_id"), "order_status": "pending_approve", "order_source": payload.get("order_source"), "delivery_type": payload.get("delivery_type"), "factory_id": payload.get("factory_id"), "sale_price_total": sale_total, "cost_price_total": cost_total, "rebate_total": payload["rebate_total"], "freight_total": payload["freight_total"], "tax_total": payload["tax_total"], "other_fee_total": payload["other_fee_total"], "profit_total": profit_total, "profit_rate": profit_rate, "commission_amount": payload["commission_amount"], "payment_method": payload.get("payment_method"), "tax_amount": payload.get("tax_amount", 0), "remark": payload.get("remark"), }, items, ) audit_service.write_log( session, { "operate_type": "order_create", "biz_type": "sales_order", "biz_id": order.id, "before_value": None, "after_value": { "order_no": order.order_no, "order_status": order.order_status, "sale_price_total": round(sale_total, 2), "cost_price_total": round(cost_total, 2), "profit_total": round(profit_total, 2), "profit_rate": profit_rate, "commission_amount": payload["commission_amount"], }, "remark": f"创建订单 {order.order_no}", }, ) session.commit() # 触发订单创建事件,通知管理层审批 salesman_name = "" if current_user: salesman_name = current_user.get("real_name", "") event_bus.emit("order_created", { "order_id": order.id, "order_no": order.order_no, "salesman_id": order.salesman_id, "salesman_name": salesman_name, "customer_name": payload["customer_name"], "amount": round(sale_total, 2), "biz_type": "sales_order", "biz_id": order.id, }, session) self._invalidate_order_cache(order.id) return { "order_id": order.id, "order_no": order.order_no, "order_status": order.order_status, "sale_price_total": round(sale_total, 2), "cost_price_total": round(cost_total, 2), "profit_total": round(profit_total, 2), "profit_rate": profit_rate, "commission_amount": payload["commission_amount"], "payment_method": payload.get("payment_method"), "tax_amount": payload.get("tax_amount", 0), } except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500) def normalize_list_filters(self, filters: dict | None, current_user: dict | None = None) -> dict: """标准化订单查询过滤条件。 对业务员角色自动注入 salesman_id,确保只能查看自己的订单。 Args: filters: 原始过滤条件 current_user: 当前登录用户信息 Returns: 标准化后的过滤条件字典 被调用路由: orders.py - GET /orders """ normalized = dict(filters or {}) if not current_user: return normalized if current_user.get("role_code") == "salesman": normalized["salesman_id"] = current_user.get("user_id") return normalized _STATUS_LABELS = { "draft": "草稿", "pending_approve": "待审核", "approved": "已通过", "rejected": "已退回", "pending_factory": "待下发工厂", "pending_driver": "待司机接单", "accepted": "已接单", "picked_up": "已揽货", "delivered": "已送达", "canceled": "已取消", "cancel_pending": "取消审批中", "cancel_fulfillment_pending": "履约取消审批中", "production": "生产中", "shipped": "已发货", "completed": "已完成", "settled": "已结算", } def _notify_status_change(self, session: Session, order, before_status: str, after_status: str, operator_id: int | None, extra_info: dict | None = None) -> None: """订单状态变更时生成提醒通知。 向业务员发送状态变更通知,待审核/取消审批类状态额外通知所有经理。 Args: session: 数据库会话 order: 订单 ORM 对象 before_status: 变更前状态 after_status: 变更后状态 operator_id: 操作人 ID extra_info: 额外信息(如审批意见) """ before_label = self._STATUS_LABELS.get(before_status, before_status) after_label = self._STATUS_LABELS.get(after_status, after_status) _NOTIFY_MANAGER_STATUSES = {"pending_approve", "cancel_pending", "cancel_fulfillment_pending"} _NOTIFY_SALESMAN_STATUSES = {"approved", "rejected", "canceled", "delivered", "completed", "settled"} receiver = order.salesman_id or 0 if receiver > 0 and after_status in _NOTIFY_SALESMAN_STATUSES | _NOTIFY_MANAGER_STATUSES: reminder_content = f"订单 {order.order_no} 状态由「{before_label}」变更为「{after_label}」。" if after_status == "rejected" and extra_info and extra_info.get("approve_opinion"): reminder_content += f" 退回原因:{extra_info['approve_opinion']}" self.reminder_repository.create_reminder( session, { "reminder_type": "order_status_change", "biz_type": "sales_order", "biz_id": order.id, "receiver_user_id": receiver, "reminder_title": f"订单状态变更 - {order.order_no}", "reminder_content": reminder_content, "status": "pending", "sent_at": datetime.now(), }, ) if after_status in _NOTIFY_MANAGER_STATUSES: managers = self._find_manager_user_ids(session) for manager_id in managers: self.reminder_repository.create_reminder( session, { "reminder_type": "order_approval_needed", "biz_type": "sales_order", "biz_id": order.id, "receiver_user_id": manager_id, "reminder_title": f"订单待审批 - {order.order_no}", "reminder_content": f"订单 {order.order_no}(客户:{order.customer_name})需要审批处理。", "status": "pending", "sent_at": datetime.now(), }, ) # 推送 WebSocket 实时通知 event_bus.emit("order_status_changed", { "order_no": order.order_no, "salesman_id": order.salesman_id or 0, "operator_id": operator_id or 0, }) def _find_manager_user_ids(self, session: Session) -> list[int]: """查询所有经理角色的用户 ID 列表。 Args: session: 数据库会话 Returns: 经理用户 ID 列表,查询失败时返回空列表 """ try: from backend.app.models.system import SysUser, SysRole from sqlalchemy import select stmt = ( select(SysUser.id) .join(SysRole, SysRole.id == SysUser.role_id) .where(SysRole.role_code == "manager", SysUser.status == 1, SysUser.deleted == 0) ) return list(session.execute(stmt).scalars()) except Exception: return [] def _ensure_order_access(self, order: object, current_user: dict | None, owner_only: bool = False) -> None: """校验当前用户是否有权访问指定订单。 管理员和经理可访问所有订单,业务员只能访问自己负责的订单。 Args: order: 订单 ORM 对象 current_user: 当前登录用户信息 owner_only: 是否仅允许所有者访问(当前未使用) Raises: AppException: 无权限时抛出 FORBIDDEN """ if not current_user: return role_code = current_user.get("role_code") if role_code in {"admin", "manager"}: return if role_code == "salesman": if order.salesman_id != current_user.get("user_id"): raise AppException(code=ErrorCode.FORBIDDEN, message="无权限访问他人订单", status_code=403) return raise AppException(code=ErrorCode.FORBIDDEN, message="无权限访问", status_code=403) def _filter_order_detail_by_role(self, order: dict, current_user: dict | None) -> dict: """根据用户角色过滤订单详情中的敏感字段。 司机只能看到物流相关信息,业务员看不到成本和利润信息。 Args: order: 订单详情字典 current_user: 当前登录用户信息 Returns: 过滤后的订单详情字典 """ if not current_user: return order role_code = current_user.get("role_code") if role_code == "driver": filtered = dict(order) filtered["customer_name"] = None filtered["customer_mobile"] = None filtered["customer_address"] = None filtered["salesman_id"] = None filtered["salesman_name"] = "" filtered["sale_price_total"] = None filtered["cost_price_total"] = None filtered["rebate_total"] = None filtered["freight_total"] = None filtered["tax_total"] = None filtered["other_fee_total"] = None filtered["profit_total"] = None filtered["profit_rate"] = None filtered["commission_amount"] = None filtered["order_source"] = None filtered["items"] = [] filtered["approve_logs"] = [] filtered["remark"] = None return filtered if role_code == "salesman": filtered = dict(order) filtered["cost_price_total"] = None filtered["rebate_total"] = None filtered["profit_total"] = None filtered["profit_rate"] = None filtered["calculation_detail"] = None filtered["items"] = [ { **item, "cost_price": None, "rebate_amount": None, } for item in order.get("items", []) ] return filtered return order def _invalidate_order_cache(self, order_id: int | None = None) -> None: """清除订单相关缓存。""" if order_id: cache_delete(f"order:detail:{order_id}") cache_delete_pattern("order:list:*") cache_delete_pattern("dashboard:*") def _filter_order_row(self, row: dict, role_code: str | None) -> dict: """根据用户角色过滤订单列表行中的敏感字段。 业务员看不到利润和佣金字段。 Args: row: 订单行数据字典 role_code: 用户角色编码 Returns: 过滤后的订单行数据字典 """ if role_code == "salesman": filtered = dict(row) filtered["profit_total"] = None filtered["commission_amount"] = None return filtered return row # 需求 6.1 定义的合法状态流转路径 _VALID_STATUS_TRANSITIONS = { "draft": {"pending_approve", "canceled"}, "pending_approve": {"approved", "rejected", "canceled"}, "rejected": {"draft", "pending_approve"}, "approved": {"pending_factory", "cancel_pending", "canceled"}, "pending_factory": {"pending_driver", "production", "cancel_fulfillment_pending", "cancel_pending"}, "pending_driver": {"accepted", "cancel_fulfillment_pending"}, "accepted": {"picked_up", "cancel_fulfillment_pending"}, "picked_up": {"delivered", "cancel_fulfillment_pending"}, "delivered": {"completed", "cancel_fulfillment_pending"}, "production": {"shipped", "cancel_fulfillment_pending"}, "shipped": {"delivered", "completed", "cancel_fulfillment_pending"}, "completed": {"settled"}, "settled": set(), "canceled": set(), "cancel_pending": {"canceled"}, "cancel_fulfillment_pending": {"canceled"}, } def _validate_status_transition(self, from_status: str, to_status: str) -> None: """校验订单状态流转是否合法。 基于 _VALID_STATUS_TRANSITIONS 白名单校验。 Args: from_status: 当前状态 to_status: 目标状态 Raises: AppException: 非法流转时抛出 INVALID_STATUS """ allowed = self._VALID_STATUS_TRANSITIONS.get(from_status, set()) if to_status not in allowed: raise AppException( code=ErrorCode.INVALID_STATUS, message=f"不允许从「{from_status}」变更为「{to_status}」", status_code=400, ) def _ensure_status(self, current_status: str, allowed_statuses: set[str], message: str) -> None: """校验订单当前状态是否在允许的操作状态集合中。 Args: current_status: 当前订单状态 allowed_statuses: 允许的操作状态集合 message: 不满足时的错误提示信息 Raises: AppException: 状态不匹配时抛出 INVALID_STATUS """ if current_status not in allowed_statuses: raise AppException(code=ErrorCode.INVALID_STATUS, message=message, status_code=400) def _get_config_value(self, session: Session, config_key: str, default: str) -> str: """从系统配置表中读取配置值。 Args: session: 数据库会话 config_key: 配置键名 default: 默认值 Returns: 配置值或默认值 """ config = self.config_repository.get_by_key(session, config_key) return config.config_value if config is not None and config.config_value else default def _validate_cancel_payload(self, payload: dict) -> None: """校验取消订单的请求参数。 Args: payload: 请求参数 Raises: AppException: 取消原因为空时抛出 PARAM_ERROR """ if not (payload.get("cancel_reason") or "").strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="取消原因不能为空", status_code=400) def _validate_approve_payload(self, payload: dict) -> None: """校验订单审批的请求参数。 审批结果仅支持 pass 或 reject,驳回时必须填写意见。 Args: payload: 请求参数 Raises: AppException: 参数不合法时抛出 PARAM_ERROR """ approve_result = (payload.get("approve_result") or "").strip() if approve_result not in {"pass", "reject"}: raise AppException(code=ErrorCode.PARAM_ERROR, message="审批结果仅支持 pass 或 reject", status_code=400) if approve_result == "reject" and not (payload.get("approve_opinion") or "").strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="审批驳回时必须填写意见", status_code=400) def _validate_cancel_approve_payload(self, payload: dict) -> None: """校验取消审批的请求参数。 审批结果仅支持 pass 或 refuse,驳回时必须填写意见。 Args: payload: 请求参数 Raises: AppException: 参数不合法时抛出 PARAM_ERROR """ approve_result = (payload.get("approve_result") or "").strip() if approve_result not in {"pass", "refuse"}: raise AppException(code=ErrorCode.PARAM_ERROR, message="取消审批结果仅支持 pass 或 refuse", status_code=400) if approve_result == "refuse" and not (payload.get("approve_opinion") or "").strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="取消审批驳回时必须填写意见", status_code=400) def _build_calculation_detail(self, order, items) -> dict: """构建订单利润计算明细。 包含公式说明、各费用汇总和每条明细的金额拆分,支持报价引擎快照数据。 Args: order: 订单 ORM 对象 items: 订单明细 ORM 对象列表 Returns: 包含计算公式、汇总金额和明细的字典 """ sale_total = float(order.sale_price_total or 0) cost_total = float(order.cost_price_total or 0) rebate_total = float(order.rebate_total or 0) freight_total = float(order.freight_total or 0) tax_total = float(order.tax_total or 0) other_fee_total = float(order.other_fee_total or 0) profit_total = float(order.profit_total or 0) profit_rate = float(order.profit_rate or 0) item_details = [] for item in items: qty = float(item.quantity or 0) sale_price = float(item.sale_price or 0) cost_price = float(item.cost_price or 0) item_sale_amount = round(qty * sale_price, 2) item_cost_amount = round(qty * cost_price, 2) detail_row = { "product_name": item.product_name, "quantity": qty, "sale_price": sale_price, "cost_price": cost_price, "sale_amount": item_sale_amount, "cost_amount": item_cost_amount, "tax_amount": float(item.tax_amount or 0), } # 报价引擎快照 pricing_type = getattr(item, "pricing_type", None) if pricing_type: detail_row["pricing_type"] = pricing_type detail_row["length_m"] = float(item.length_m) if getattr(item, "length_m", None) else None detail_row["width_m"] = float(item.width_m) if getattr(item, "width_m", None) else None detail_row["area_sqm"] = float(item.area_sqm) if getattr(item, "area_sqm", None) else None surcharge_detail = getattr(item, "surcharge_detail", None) if surcharge_detail: try: detail_row["surcharge_items"] = json.loads(surcharge_detail) except (json.JSONDecodeError, TypeError): detail_row["surcharge_items"] = [] detail_row["supplier_model"] = getattr(item, "supplier_model", None) detail_row["price_tier"] = getattr(item, "price_tier", None) item_details.append(detail_row) # 税计算说明 tax_detail = None if tax_total > 0: # 反推税率:tax_total / (sale_total - tax_total) 近似 base_for_tax = sale_total - tax_total if sale_total > tax_total else sale_total approx_rate = round(tax_total / base_for_tax * 100, 2) if base_for_tax else 0 tax_detail = { "tax_total": tax_total, "approx_tax_rate": approx_rate, "desc": f"税费 ¥{tax_total:.2f}(约占销售额 {approx_rate}%)", } return { "formula": "利润 = 销售价 - 成本价 - 回扣 - 运费 - 税费 - 其他费用", "rate_formula": "利润率 = 利润 / 销售价 x 100%", "sale_total": sale_total, "cost_total": cost_total, "rebate_total": rebate_total, "freight_total": freight_total, "tax_total": tax_total, "tax_detail": tax_detail, "other_fee_total": other_fee_total, "profit_total": profit_total, "profit_rate": profit_rate, "item_details": item_details, } def _build_task_summary(self, task) -> dict: """构建物流任务摘要字典。 Args: task: 物流任务 ORM 对象,可为 None Returns: 任务摘要字典,task 为 None 时返回空字典 """ if task is None: return {} return { "task_id": task.id, "task_no": task.task_no, "driver_id": task.driver_id, "factory_id": task.factory_id, "pickup_address": task.pickup_address, "delivery_address": task.delivery_address, "pickup_content": task.pickup_content, "quantity": float(task.quantity or 0), "status": task.status, "remark": task.remark, "created_at": task.created_at.strftime("%Y-%m-%d %H:%M:%S") if task.created_at else "", } def _build_attachment_row(self, attachment) -> dict: """构建附件行数据字典。 Args: attachment: 附件 ORM 对象 Returns: 附件摘要字典 """ return { "attachment_id": attachment.id, "biz_type": attachment.biz_type, "biz_id": attachment.biz_id, "file_name": attachment.file_name, "file_url": attachment.file_url, "file_type": attachment.file_type, "file_size": attachment.file_size, "file_category": attachment.file_category, "object_key": attachment.object_key, "created_at": attachment.created_at.strftime("%Y-%m-%d %H:%M:%S") if attachment.created_at else "", } def _record_supplier_text_log(self, session: Session, order, payload: dict, current_user: dict | None) -> None: """记录发厂文案确认日志。 Args: session: 数据库会话 order: 订单 ORM 对象 payload: 包含 supplier_id、text_content 等的请求体 current_user: 当前登录用户信息 """ self.order_repository.create_supplier_text_log( session, { "order_id": order.id, "supplier_id": payload["supplier_id"], "template_type": payload.get("template_type") or "default", "text_content": payload["text_content"], "confirmed": 1, "confirmed_by": current_user.get("user_id") if current_user else None, "confirmed_at": datetime.now(), "created_by": current_user.get("user_id") if current_user else None, }, ) def _build_supplier_text(self, order, items, template_type: str) -> str: """根据模板类型生成发厂文案文本。 支持 default(完整模板)和 brief(简略模板)两种格式。 Args: order: 订单 ORM 对象 items: 订单明细 ORM 对象列表 template_type: 模板类型(default 或 brief) Returns: 格式化后的发厂文案字符串 """ item_lines = [] for item in items: qty = float(item.quantity or 0) spec = item.specification or "" unit = item.unit or "" item_lines.append(f" {item.product_name} {spec} {qty}{unit}") items_text = "\n".join(item_lines) if item_lines else " (无明细)" if template_type == "brief": total_qty = sum(float(item.quantity or 0) for item in items) return f"订单 {order.order_no}\n客户:{order.customer_name}\n总数量:{total_qty}\n请安排发货。" return ( f"订单编号:{order.order_no}\n" f"客户:{order.customer_name}\n" f"发货方式:{order.delivery_type or '待定'}\n" f"产品明细:\n{items_text}\n" f"备注:{order.remark or '无'}\n" f"请安排生产与发货。" ) order_service = OrderService()