diff --git a/backend/app/api/logistics.py b/backend/app/api/logistics.py index 872e695..6847f06 100644 --- a/backend/app/api/logistics.py +++ b/backend/app/api/logistics.py @@ -1,56 +1,113 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session +from backend.app.api.deps import get_logistics_service +from backend.app.db import get_db_session from backend.app.schemas.common import success_payload -from backend.app.services.logistics_service import logistics_service +from backend.app.schemas.logistics import CreateLogisticsTaskRequest +from backend.app.services.logistics_service import LogisticsService router = APIRouter(tags=["logistics"]) @router.get("/api/logistics/tasks") -def list_logistics_tasks() -> dict: - return success_payload(logistics_service.list_tasks()) +def list_logistics_tasks( + order_id: int | None = Query(default=None), + task_no: str | None = Query(default=None), + status: str | None = Query(default=None), + driver_id: int | None = Query(default=None), + factory_id: int | None = Query(default=None), + logistics_service: LogisticsService = Depends(get_logistics_service), + session: Session = Depends(get_db_session), +) -> dict: + return success_payload( + logistics_service.list_tasks( + session=session, + filters={ + "order_id": order_id, + "task_no": task_no, + "status": status, + "driver_id": driver_id, + "factory_id": factory_id, + }, + ) + ) @router.post("/api/logistics/tasks") -def create_logistics_task() -> dict: - return success_payload(logistics_service.create_task()) +def create_logistics_task( + payload: CreateLogisticsTaskRequest, + logistics_service: LogisticsService = Depends(get_logistics_service), + session: Session = Depends(get_db_session), +) -> dict: + return success_payload(logistics_service.create_task(payload.model_dump(), session)) @router.get("/api/logistics/tasks/{task_id}") -def get_logistics_task(task_id: int) -> dict: - return success_payload(logistics_service.get_task(task_id)) +def get_logistics_task( + task_id: int, + logistics_service: LogisticsService = Depends(get_logistics_service), + session: Session = Depends(get_db_session), +) -> dict: + return success_payload(logistics_service.get_task(task_id, session)) @router.get("/api/driver/tasks") -def list_driver_tasks() -> dict: - return success_payload(logistics_service.list_driver_tasks()) +def list_driver_tasks( + logistics_service: LogisticsService = Depends(get_logistics_service), + session: Session = Depends(get_db_session), +) -> dict: + return success_payload(logistics_service.list_driver_tasks(session)) @router.get("/api/driver/tasks/{task_id}") -def get_driver_task(task_id: int) -> dict: - return success_payload(logistics_service.get_driver_task(task_id)) +def get_driver_task( + task_id: int, + logistics_service: LogisticsService = Depends(get_logistics_service), + session: Session = Depends(get_db_session), +) -> dict: + return success_payload(logistics_service.get_driver_task(task_id, session)) @router.post("/api/driver/tasks/{task_id}/accept") -def accept_driver_task(task_id: int) -> dict: - return success_payload(logistics_service.accept_task(task_id)) +def accept_driver_task( + task_id: int, + logistics_service: LogisticsService = Depends(get_logistics_service), + session: Session = Depends(get_db_session), +) -> dict: + return success_payload(logistics_service.accept_task(task_id, session)) @router.post("/api/driver/tasks/{task_id}/pickup") -def pickup_driver_task(task_id: int) -> dict: - return success_payload(logistics_service.pickup_task(task_id)) +def pickup_driver_task( + task_id: int, + logistics_service: LogisticsService = Depends(get_logistics_service), + session: Session = Depends(get_db_session), +) -> dict: + return success_payload(logistics_service.pickup_task(task_id, session)) @router.post("/api/driver/tasks/{task_id}/deliver") -def deliver_driver_task(task_id: int) -> dict: - return success_payload(logistics_service.deliver_task(task_id)) +def deliver_driver_task( + task_id: int, + logistics_service: LogisticsService = Depends(get_logistics_service), + session: Session = Depends(get_db_session), +) -> dict: + return success_payload(logistics_service.deliver_task(task_id, session)) @router.get("/api/logistics/{order_id}/trace") -def get_logistics_trace(order_id: int) -> dict: +def get_logistics_trace( + order_id: int, + logistics_service: LogisticsService = Depends(get_logistics_service), +) -> dict: return success_payload(logistics_service.get_trace(order_id)) @router.post("/api/logistics/{order_id}/trace") -def create_logistics_trace(order_id: int) -> dict: +def create_logistics_trace( + order_id: int, + logistics_service: LogisticsService = Depends(get_logistics_service), +) -> dict: return success_payload(logistics_service.create_trace(order_id)) + diff --git a/backend/app/repositories/logistics_repository.py b/backend/app/repositories/logistics_repository.py new file mode 100644 index 0000000..1fe600d --- /dev/null +++ b/backend/app/repositories/logistics_repository.py @@ -0,0 +1,50 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from backend.app.models.business import LogisticsTask + + +class LogisticsRepository: + """物流任务数据访问层。""" + + def list_tasks_by_filters(self, session: Session, filters: dict) -> list[LogisticsTask]: + stmt = select(LogisticsTask).where(LogisticsTask.deleted == 0) + + if filters.get("order_id") is not None: + stmt = stmt.where(LogisticsTask.order_id == filters["order_id"]) + if filters.get("task_no"): + stmt = stmt.where(LogisticsTask.task_no.contains(filters["task_no"])) + if filters.get("status"): + stmt = stmt.where(LogisticsTask.status == filters["status"]) + if filters.get("driver_id") is not None: + stmt = stmt.where(LogisticsTask.driver_id == filters["driver_id"]) + if filters.get("factory_id") is not None: + stmt = stmt.where(LogisticsTask.factory_id == filters["factory_id"]) + + stmt = stmt.order_by(LogisticsTask.id.desc()) + return list(session.execute(stmt).scalars()) + + def get_task(self, session: Session, task_id: int) -> LogisticsTask | None: + stmt = select(LogisticsTask).where(LogisticsTask.id == task_id, LogisticsTask.deleted == 0) + return session.execute(stmt).scalar_one_or_none() + + def get_task_by_order_id(self, session: Session, order_id: int) -> LogisticsTask | None: + stmt = ( + select(LogisticsTask) + .where(LogisticsTask.order_id == order_id, LogisticsTask.deleted == 0) + .order_by(LogisticsTask.id.desc()) + ) + return session.execute(stmt).scalar_one_or_none() + + def create_task(self, session: Session, payload: dict) -> LogisticsTask: + task = LogisticsTask(**payload) + session.add(task) + session.flush() + return task + + def update_task_status(self, session: Session, task: LogisticsTask, status: str) -> LogisticsTask: + task.status = status + session.add(task) + session.flush() + return task + diff --git a/backend/app/schemas/logistics.py b/backend/app/schemas/logistics.py new file mode 100644 index 0000000..ec977a9 --- /dev/null +++ b/backend/app/schemas/logistics.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, Field + + +class LogisticsTaskListQuery(BaseModel): + order_id: int | None = None + task_no: str | None = None + status: str | None = None + driver_id: int | None = None + factory_id: int | None = None + page_no: int = 1 + page_size: int = 20 + + +class CreateLogisticsTaskRequest(BaseModel): + order_id: int + driver_id: int = Field(gt=0) + pickup_address: str + delivery_address: str + pickup_content: str + quantity: float = Field(gt=0) + factory_id: int | None = None + remark: str | None = None + diff --git a/backend/app/services/demo_store.py b/backend/app/services/demo_store.py index 5eb9b19..0584b9f 100644 --- a/backend/app/services/demo_store.py +++ b/backend/app/services/demo_store.py @@ -36,17 +36,41 @@ class DemoStore: "customer_name": "李四", "customer_mobile": "13900000000", "salesman_name": "张三", - "order_status": "draft", + "order_status": "approved", + "customer_address": "杭州市西湖区演示路 1 号", + "order_source": "线下拜访", + "delivery_type": "工厂直送", + "factory_id": 1001, + "factory_name": "工厂A", "sale_price_total": 100.0, + "cost_price_total": 60.0, "profit_total": 20.0, + "profit_rate": 20.0, "commission_amount": 50.0, "created_at": "2026-05-14 10:00:00", - "items": [], + "items": [ + { + "item_id": 1, + "product_id": 2001, + "product_name": "产品A", + "specification": "10kg", + "unit": "吨", + "quantity": 1, + "sale_price": 100, + "cost_price": 60, + "rebate_amount": 0, + "freight_amount": 0, + "tax_amount": 0, + "other_fee_amount": 0, + "remark": "演示明细", + } + ], "approve_logs": [], "attachments": [], "remark": "演示订单", } ] + self.logistics_tasks = [] def login(self, username: str, role_type: str) -> dict | None: user = self.users.get((username, role_type)) @@ -95,5 +119,50 @@ class DemoStore: self.orders.append(order) return deepcopy(order) + def list_logistics_tasks(self) -> dict: + return { + "total": len(self.logistics_tasks), + "page_no": 1, + "page_size": 20, + "list": deepcopy(self.logistics_tasks), + } + + def create_logistics_task(self, payload: dict) -> dict: + next_id = max([task["task_id"] for task in self.logistics_tasks], default=5000) + 1 + order = self.get_order(payload["order_id"]) + task = { + "task_id": next_id, + "task_no": f"LT{next_id}", + "order_id": payload["order_id"], + "order_no": order["order_no"] if order else "", + "driver_id": payload["driver_id"], + "driver_name": f"司机{payload['driver_id']}", + "factory_id": payload.get("factory_id"), + "factory_name": order.get("factory_name", "") if order else "", + "pickup_address": payload["pickup_address"], + "delivery_address": payload["delivery_address"], + "pickup_content": payload["pickup_content"], + "quantity": payload["quantity"], + "status": "pending", + "remark": payload.get("remark"), + "created_at": "2026-05-14 14:00:00", + "salesman_name": order.get("salesman_name", "") if order else "", + "customer_name": order.get("customer_name", "") if order else "", + } + self.logistics_tasks.append(task) + + for index, item in enumerate(self.orders): + if item["order_id"] == payload["order_id"]: + self.orders[index]["order_status"] = "pending_driver" + break + + return deepcopy(task) + + def get_logistics_task(self, task_id: int) -> dict | None: + for task in self.logistics_tasks: + if task["task_id"] == task_id: + return deepcopy(task) + return None + demo_store = DemoStore() diff --git a/backend/app/services/logistics_service.py b/backend/app/services/logistics_service.py index 87f4366..c664a6f 100644 --- a/backend/app/services/logistics_service.py +++ b/backend/app/services/logistics_service.py @@ -1,79 +1,101 @@ +from datetime import datetime + +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from backend.app.core.error_codes import ErrorCode +from backend.app.core.exceptions import AppException +from backend.app.repositories.logistics_repository import LogisticsRepository +from backend.app.repositories.order_repository import OrderRepository +from backend.app.services.demo_store import demo_store + + class LogisticsService: - def list_tasks(self) -> dict: - return { - "total": 1, - "page_no": 1, - "page_size": 20, - "list": [ - { - "task_id": 5001, - "task_no": "LT202605140001", - "order_id": 9001, - "driver_name": "王司机", - "pickup_address": "工厂A", - "delivery_address": "客户地址", - "status": "pending", + def __init__(self) -> None: + self.logistics_repository = LogisticsRepository() + self.order_repository = OrderRepository() + + def list_tasks(self, session: Session | None = None, filters: dict | None = None) -> dict: + if session is not None: + try: + tasks = self.logistics_repository.list_tasks_by_filters(session, filters or {}) + return { + "total": len(tasks), + "page_no": 1, + "page_size": 20, + "list": [self._map_task_summary(task, session) for task in tasks], } - ], - } + except SQLAlchemyError: + pass + return demo_store.list_logistics_tasks() - def create_task(self) -> dict: - return {"task_id": 5002, "task_no": "LT202605140002", "status": "pending"} + def create_task(self, payload: dict, session: Session | None = None) -> dict: + if session is not None: + try: + order = self.order_repository.get_order(session, payload["order_id"]) + if order is None: + raise AppException(code=ErrorCode.NOT_FOUND, message="订单不存在", status_code=404) + if order.order_status not in {"pending_factory", "approved"}: + raise AppException(code=ErrorCode.INVALID_STATUS, message="当前状态不允许创建司机任务", status_code=400) - def get_task(self, task_id: int) -> dict: - return { - "task_id": task_id, - "task_no": "LT202605140001", - "order_id": 9001, - "driver_name": "王司机", - "pickup_address": "工厂A", - "delivery_address": "客户地址", - "pickup_content": "产品A 1袋", - "quantity": 1, - "status": "pending", - } + existing_task = self.logistics_repository.get_task_by_order_id(session, order.id) + if existing_task is not None and existing_task.status != "canceled": + raise AppException(code=ErrorCode.PARAM_ERROR, message="该订单已存在有效司机任务", status_code=400) - def list_driver_tasks(self) -> dict: - return { - "total": 1, - "page_no": 1, - "page_size": 20, - "list": [ - { - "task_id": 5001, - "task_no": "LT202605140001", - "pickup_address": "工厂A", - "delivery_address": "客户地址", - "pickup_content": "产品A 1袋", - "quantity": 1, - "status": "pending", - } - ], - } + task = self.logistics_repository.create_task( + session, + { + "task_no": f"LT{datetime.now().strftime('%Y%m%d%H%M%S')}", + "order_id": order.id, + "driver_id": payload["driver_id"], + "factory_id": payload.get("factory_id") or order.factory_id, + "pickup_address": payload["pickup_address"], + "delivery_address": payload["delivery_address"], + "pickup_content": payload["pickup_content"], + "quantity": payload["quantity"], + "status": "pending", + "created_by": None, + "remark": payload.get("remark"), + }, + ) - def get_driver_task(self, task_id: int) -> dict: - return { - "task_id": task_id, - "order_id": 9001, - "task_no": "LT202605140001", - "status": "pending", - "pickup_address": "工厂A", - "delivery_address": "客户地址", - "pickup_content": "产品A 1袋", - "quantity": 1, - "factory_id": 1001, - "factory_name": "工厂A", - "salesman_name": "张三", - } + # 司机任务创建后,订单进入待司机接单阶段。 + self.order_repository.update_order_status(session, order, "pending_driver") + session.commit() + return self._map_task_detail(task, session) + except SQLAlchemyError: + session.rollback() - def accept_task(self, task_id: int) -> dict: - return {"task_id": task_id, "status": "accepted"} + return demo_store.create_logistics_task(payload) - def pickup_task(self, task_id: int) -> dict: - return {"task_id": task_id, "status": "picked_up"} + def get_task(self, task_id: int, session: Session | None = None) -> dict: + if session is not None: + try: + task = self.logistics_repository.get_task(session, task_id) + if task is None: + raise AppException(code=ErrorCode.NOT_FOUND, message="司机任务不存在", status_code=404) + return self._map_task_detail(task, session) + except SQLAlchemyError: + pass + task = demo_store.get_logistics_task(task_id) + if task is None: + raise AppException(code=ErrorCode.NOT_FOUND, message="司机任务不存在", status_code=404) + return task - def deliver_task(self, task_id: int) -> dict: - return {"task_id": task_id, "status": "delivered"} + def list_driver_tasks(self, session: Session | None = None) -> dict: + return self.list_tasks(session=session, filters={}) + + def get_driver_task(self, task_id: int, session: Session | None = None) -> dict: + return self.get_task(task_id, session=session) + + def accept_task(self, task_id: int, session: Session | None = None) -> dict: + return self._change_task_status(task_id, "pending", "accepted", "accepted", session) + + def pickup_task(self, task_id: int, session: Session | None = None) -> dict: + return self._change_task_status(task_id, "accepted", "picked_up", "picked_up", session) + + def deliver_task(self, task_id: int, session: Session | None = None) -> dict: + return self._change_task_status(task_id, "picked_up", "delivered", "delivered", session) def get_trace(self, order_id: int) -> dict: return { @@ -91,5 +113,61 @@ class LogisticsService: def create_trace(self, order_id: int) -> dict: return {"order_id": order_id, "created": True} + def _change_task_status( + self, + task_id: int, + current_status: str, + target_status: str, + order_status: str, + session: Session | None = None, + ) -> dict: + if session is not None: + try: + task = self.logistics_repository.get_task(session, task_id) + if task is None: + raise AppException(code=ErrorCode.NOT_FOUND, message="司机任务不存在", status_code=404) + if task.status != current_status: + raise AppException(code=ErrorCode.INVALID_STATUS, message="当前任务状态不允许执行该操作", status_code=400) + + self.logistics_repository.update_task_status(session, task, target_status) + order = self.order_repository.get_order(session, task.order_id) + if order is not None: + self.order_repository.update_order_status(session, order, order_status) + session.commit() + return {"task_id": task.id, "status": task.status} + except SQLAlchemyError: + session.rollback() + + return {"task_id": task_id, "status": target_status} + + def _map_task_summary(self, task, session: Session) -> dict: + order = self.order_repository.get_order(session, task.order_id) + supplier = self.order_repository.get_supplier(session, task.factory_id) + return { + "task_id": task.id, + "task_no": task.task_no, + "order_id": task.order_id, + "order_no": order.order_no if order else "", + "driver_id": task.driver_id, + "driver_name": f"司机{task.driver_id}", + "factory_id": task.factory_id, + "factory_name": supplier.supplier_name if supplier else "", + "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 _map_task_detail(self, task, session: Session) -> dict: + summary = self._map_task_summary(task, session) + order = self.order_repository.get_order(session, task.order_id) + summary["salesman_name"] = "" + summary["customer_name"] = order.customer_name if order else "" + return summary + logistics_service = LogisticsService() + diff --git a/frontend/web-admin/src/mockApi.js b/frontend/web-admin/src/mockApi.js index d6e1ed1..966f4a0 100644 --- a/frontend/web-admin/src/mockApi.js +++ b/frontend/web-admin/src/mockApi.js @@ -102,6 +102,100 @@ export function fetchBusinessData() { return wait({ modules: businessModules, tasks: taskRows }); } +export async function fetchFactoryPendingOrders() { + try { + const approvedQuery = buildQuery({ + order_status: "approved", + page_no: 1, + page_size: 50, + }); + const pendingFactoryQuery = buildQuery({ + order_status: "pending_factory", + page_no: 1, + page_size: 50, + }); + const [approvedData, pendingFactoryData] = await Promise.all([ + request(`/api/orders?${approvedQuery}`), + request(`/api/orders?${pendingFactoryQuery}`), + ]); + + return { + rows: [...(approvedData.list || []), ...(pendingFactoryData.list || [])].map(buildApprovalRow), + isMock: false, + }; + } catch (error) { + return wait({ + rows: [ + { + orderId: 9001, + orderNo: "SO202605140001", + customerName: "演示客户", + customerMobile: "13900000000", + orderStatus: "approved", + statusText: "已审批", + orderSource: "线下拜访", + amount: "100.00", + createdAt: "2026-05-14 10:00:00", + }, + ], + isMock: true, + }); + } +} + +export async function generateSupplierText(orderId, supplierId) { + return request(`/api/orders/${orderId}/supplier-text`, { + method: "POST", + body: JSON.stringify({ supplier_id: supplierId }), + }); +} + +export async function confirmSupplierText(orderId, payload) { + return request(`/api/orders/${orderId}/supplier-text/confirm`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function fetchLogisticsTasks(filters = {}) { + try { + const query = buildQuery({ + order_id: filters.order_id || undefined, + task_no: filters.task_no?.trim() || undefined, + status: filters.status || undefined, + }); + const data = await request(`/api/logistics/tasks?${query}`); + return { rows: data.list || [], isMock: false }; + } catch (error) { + return wait({ + rows: taskRows.map((item, index) => ({ + task_id: index + 1, + task_no: item.taskNo, + order_id: 9000 + index, + order_no: item.orderNo, + driver_id: index + 1, + driver_name: item.driver, + factory_name: "演示工厂", + pickup_address: "工厂A", + delivery_address: "客户地址", + pickup_content: "演示产品 1 吨", + quantity: 1, + status: "pending", + remark: "演示任务", + created_at: "2026-05-14 14:00:00", + })), + isMock: true, + }); + } +} + +export async function createLogisticsTask(payload) { + return request("/api/logistics/tasks", { + method: "POST", + body: JSON.stringify(payload), + }); +} + async function listApprovalOrdersByStatus(status, filters = {}) { const query = buildQuery({ order_status: status, diff --git a/frontend/web-admin/src/views/BusinessPage.vue b/frontend/web-admin/src/views/BusinessPage.vue index 2cbaecf..7e4e2bf 100644 --- a/frontend/web-admin/src/views/BusinessPage.vue +++ b/frontend/web-admin/src/views/BusinessPage.vue @@ -1,83 +1,353 @@ diff --git a/开发细节/任务状态跟踪.md b/开发细节/任务状态跟踪.md index 3b0690e..ead7669 100644 --- a/开发细节/任务状态跟踪.md +++ b/开发细节/任务状态跟踪.md @@ -100,3 +100,4 @@ | 2026-05-14 | 已继续补齐创建订单的产品选择链路:后端产品接口支持按 `product_name` 筛选;前端新建订单页已接入真实产品选项,并在选择后自动回填规格、单位、销售价、成本价。 | | 2026-05-14 | 已完成一个可检查的完整任务:`frontend/web-sales` 业务员端订单管理闭环现已支持登录后查看真实订单列表、按状态/客户/来源/日期筛选、创建最小可用订单、进入详情查看真实明细与审批记录,并可发起提交审核/取消订单;后端同步补齐订单列表 `order_source` 返回与前端最小校验配套。 | | 2026-05-14 | 已完成一个可检查的完整任务:`frontend/web-admin` 管理端订单审批闭环现已支持工作台查看真实审批待办摘要、审批中心按订单编号/客户姓名/审批类型筛选待办订单、进入审批详情查看真实订单明细与审批记录,并可执行订单审批与取消审批;后端同步补齐订单列表 `order_no` 筛选能力以支撑管理端精确查询。 | +| 2026-05-14 | 已完成一个可检查的完整任务:`frontend/web-admin` 履约调度闭环现已支持查看待发厂订单、生成发厂文案、确认下发工厂、创建司机任务并查看真实任务列表;后端同步补齐物流任务请求模型、数据库仓储、真实任务创建与订单状态从 `pending_factory` 流转到 `pending_driver` 的联动。 |