diff --git a/backend/app/api/products.py b/backend/app/api/products.py index 45bd941..62bc8f8 100644 --- a/backend/app/api/products.py +++ b/backend/app/api/products.py @@ -9,6 +9,8 @@ from backend.app.schemas.products import ( CreateProductRequest, UpdateCategoryRequest, UpdateProductCategoryRequest, + UpdateProductRequest, + UpdateProductSpecificationRequest, ) from backend.app.services.product_service import ProductService @@ -90,6 +92,35 @@ def create_product( return success_payload(product_service.create_product(payload.model_dump(), session)) +@router.put("/api/products/{product_id}") +def update_product( + product_id: int, + payload: UpdateProductRequest, + product_service: ProductService = Depends(get_product_service), + session: Session = Depends(get_db_session), +) -> dict: + return success_payload(product_service.update_product(product_id, payload.model_dump(), session)) + + +@router.put("/api/products/specifications/{product_id}") +def update_product_specification( + product_id: int, + payload: UpdateProductSpecificationRequest, + product_service: ProductService = Depends(get_product_service), + session: Session = Depends(get_db_session), +) -> dict: + return success_payload(product_service.update_product_specification(product_id, payload.model_dump(), session)) + + +@router.put("/api/products/specifications/{product_id}/default") +def set_default_specification( + product_id: int, + product_service: ProductService = Depends(get_product_service), + session: Session = Depends(get_db_session), +) -> dict: + return success_payload(product_service.set_default_specification(product_id, session)) + + @router.get("/api/products/{product_id}") def get_product( product_id: int, diff --git a/backend/app/models/business.py b/backend/app/models/business.py index a94cc91..3fafa58 100644 --- a/backend/app/models/business.py +++ b/backend/app/models/business.py @@ -43,6 +43,7 @@ class Product(TimestampMixin, AuditMixin, Base): cost_price: Mapped[float] = mapped_column(Numeric(18, 2), default=0) sale_price: Mapped[float] = mapped_column(Numeric(18, 2), default=0) status: Mapped[int] = mapped_column(default=1) + is_default: Mapped[int] = mapped_column(default=0) remark: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/backend/app/schemas/products.py b/backend/app/schemas/products.py index d5e5103..b74c031 100644 --- a/backend/app/schemas/products.py +++ b/backend/app/schemas/products.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field class CreateCategoryRequest(BaseModel): @@ -17,8 +17,7 @@ class UpdateCategoryRequest(BaseModel): remark: str | None = None -class CreateProductRequest(BaseModel): - product_name: str +class ProductSpecificationPayload(BaseModel): specification: str unit: str category_id: int | None = None @@ -26,6 +25,33 @@ class CreateProductRequest(BaseModel): sale_price: float = 0 status: int = 1 remark: str | None = None + is_default: bool = False + + +class CreateProductRequest(BaseModel): + product_name: str + category_id: int | None = None + status: int = 1 + remark: str | None = None + specifications: list[ProductSpecificationPayload] = Field(default_factory=list) + + +class UpdateProductRequest(BaseModel): + product_name: str + category_id: int | None = None + status: int = 1 + remark: str | None = None + + +class UpdateProductSpecificationRequest(BaseModel): + specification: str + unit: str + category_id: int | None = None + cost_price: float = 0 + sale_price: float = 0 + status: int = 1 + remark: str | None = None + is_default: bool = False class UpdateProductCategoryRequest(BaseModel): diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py index 6ec173c..b9cb6c6 100644 --- a/backend/app/services/order_service.py +++ b/backend/app/services/order_service.py @@ -546,7 +546,12 @@ class OrderService: return None self._ensure_order_access(order, current_user) self._ensure_status(order.order_status, {"approved"}, "当前状态不允许生成发厂文案") - supplier = self.order_repository.get_supplier(session, payload["supplier_id"]) + + 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) return { @@ -562,9 +567,12 @@ class OrderService: if order is None: return None self._ensure_status(order["order_status"], {"approved"}, "当前状态不允许生成发厂文案") + supplier_id = payload.get("supplier_id") or order.get("factory_id") + if not supplier_id: + raise AppException(code=ErrorCode.NOT_FOUND, message="订单未绑定工厂", status_code=404) return { "order_id": order_id, - "supplier_id": payload["supplier_id"], + "supplier_id": supplier_id, "template_type": "default", "text_content": f"订单 {order['order_no']} 请安排生产与发货", } @@ -586,13 +594,18 @@ class OrderService: return None self._ensure_order_access(order, current_user) self._ensure_status(order.order_status, {"approved"}, "当前状态不允许确认发厂") - supplier = self.order_repository.get_supplier(session, payload["supplier_id"]) + + 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, current_user) + self._record_supplier_text_log(session, order, {**payload, "supplier_id": supplier_id}, current_user) audit_service.write_log( session, { @@ -602,7 +615,7 @@ class OrderService: "before_value": {"order_status": before_status}, "after_value": { "order_status": order.order_status, - "supplier_id": payload["supplier_id"], + "supplier_id": supplier_id, "text_content": payload["text_content"], "remark": payload.get("remark"), }, @@ -627,6 +640,9 @@ class OrderService: if order is None: return None self._ensure_status(order["order_status"], {"approved"}, "当前状态不允许确认发厂") + supplier_id = payload.get("supplier_id") or order.get("factory_id") + if not supplier_id: + raise AppException(code=ErrorCode.NOT_FOUND, message="订单未绑定工厂", status_code=404) return { "order_id": order_id, "order_status": "pending_factory", diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py index 0e5f0f0..2d73e35 100644 --- a/backend/app/services/product_service.py +++ b/backend/app/services/product_service.py @@ -98,35 +98,51 @@ class ProductService: return {"category_id": category_id, "updated": True} + def _group_products(self, products: list, categories: dict[int, object]) -> list[dict]: + grouped: dict[str, dict] = {} + for product in products: + key = product.product_name + category_name = categories[product.category_id].category_name if product.category_id in categories else (product.category or "") + entry = grouped.setdefault( + key, + { + "product_name": product.product_name, + "product_id": product.id, + "category_id": product.category_id, + "category_name": category_name, + "status": product.status, + "remark": product.remark, + "specifications": [], + }, + ) + entry["specifications"].append( + { + "product_id": product.id, + "specification": product.specification, + "unit": product.unit, + "cost_price": float(product.cost_price or 0), + "sale_price": float(product.sale_price or 0), + "status": product.status, + "remark": product.remark, + "is_default": bool(getattr(product, "is_default", 0)), + } + ) + for entry in grouped.values(): + if entry["specifications"] and not any(spec["is_default"] for spec in entry["specifications"]): + entry["specifications"][0]["is_default"] = True + return list(grouped.values()) + def list_products(self, filters: dict | None = None, session: Session | None = None) -> dict: if session is not None: try: products = self.repository.list_products(session, filters or {}) - categories = { - category.id: category - for category in self.repository.list_categories(session, {}) - } + categories = {category.id: category for category in self.repository.list_categories(session, {})} + grouped_list = self._group_products(products, categories) return { - "total": len(products), + "total": len(grouped_list), "page_no": 1, - "page_size": len(products) or 20, - "list": [ - { - "product_id": product.id, - "product_name": product.product_name, - "specification": product.specification, - "unit": product.unit, - "category_id": product.category_id, - "category_name": categories[product.category_id].category_name - if product.category_id in categories - else (product.category or ""), - "cost_price": float(product.cost_price or 0), - "sale_price": float(product.sale_price or 0), - "status": product.status, - "remark": product.remark, - } - for product in products - ], + "page_size": len(grouped_list) or 20, + "list": grouped_list, } except SQLAlchemyError: pass @@ -166,23 +182,98 @@ class ProductService: "list": product_list, } + def update_product(self, product_id: int, payload: dict, session: Session | None = None) -> dict: + if session is not None: + try: + product = self.repository.get_product(session, product_id) + if product is None: + raise AppException(code=ErrorCode.NOT_FOUND, message="产品主档不存在", status_code=404) + if not payload["product_name"].strip(): + raise AppException(code=ErrorCode.PARAM_ERROR, message="产品名称不能为空", status_code=400) + category = None + if payload.get("category_id") is not None: + category = self.repository.get_category(session, payload["category_id"]) + if category is None or category.status != 1: + raise AppException(code=ErrorCode.NOT_FOUND, message="产品分类不存在或已停用", status_code=404) + product.product_name = payload["product_name"] + product.category_id = payload.get("category_id") + product.category = category.category_name if category else None + product.status = payload.get("status", product.status) + product.remark = payload.get("remark", product.remark) + session.add(product) + session.commit() + return {"product_id": product.id, "product_name": product.product_name, "updated": True} + except AppException: + session.rollback() + raise + except SQLAlchemyError: + session.rollback() + return {"product_id": product_id, **payload} + + def update_product_specification(self, product_id: int, payload: dict, session: Session | None = None) -> dict: + if session is not None: + try: + product = self.repository.get_product(session, product_id) + if product is None: + raise AppException(code=ErrorCode.NOT_FOUND, message="产品规格不存在", status_code=404) + if not payload["specification"].strip(): + raise AppException(code=ErrorCode.PARAM_ERROR, message="产品规格不能为空", status_code=400) + if not payload["unit"].strip(): + raise AppException(code=ErrorCode.PARAM_ERROR, message="产品单位不能为空", status_code=400) + existed = self.repository.get_product_by_name_and_specification(session, product.product_name, payload["specification"]) + if existed is not None and existed.id != product_id: + raise AppException(code=ErrorCode.DUPLICATE, message="同一产品下的规格不能重复", status_code=400) + category = None + if payload.get("category_id") is not None: + category = self.repository.get_category(session, payload["category_id"]) + if category is None or category.status != 1: + raise AppException(code=ErrorCode.NOT_FOUND, message="产品分类不存在或已停用", status_code=404) + product.specification = payload["specification"] + product.unit = payload["unit"] + product.category_id = payload.get("category_id") + product.category = category.category_name if category else product.category + product.cost_price = payload.get("cost_price", product.cost_price) + product.sale_price = payload.get("sale_price", product.sale_price) + product.status = payload.get("status", product.status) + product.remark = payload.get("remark", product.remark) + session.add(product) + session.commit() + return {"product_id": product.id, "product_name": product.product_name, "updated": True} + except AppException: + session.rollback() + raise + except SQLAlchemyError: + session.rollback() + return {"product_id": product_id, **payload} + + def set_default_specification(self, product_id: int, session: Session | None = None) -> dict: + if session is not None: + try: + product = self.repository.get_product(session, product_id) + if product is None: + raise AppException(code=ErrorCode.NOT_FOUND, message="产品规格不存在", status_code=404) + siblings = self.repository.list_products(session, {"product_name": product.product_name}) + for sibling in siblings: + sibling.is_default = 1 if sibling.id == product.id else 0 + session.add(sibling) + session.flush() + session.commit() + return {"product_id": product.id, "product_name": product.product_name, "is_default": True} + except AppException: + session.rollback() + raise + except SQLAlchemyError: + session.rollback() + return {"product_id": product_id, "is_default": True} + def create_product(self, payload: dict, session: Session | None = None) -> dict: if session is not None: try: if not payload["product_name"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="产品名称不能为空", status_code=400) - if not payload["specification"].strip(): - raise AppException(code=ErrorCode.PARAM_ERROR, message="产品规格不能为空", status_code=400) - if not payload["unit"].strip(): - raise AppException(code=ErrorCode.PARAM_ERROR, message="产品单位不能为空", status_code=400) - - existed = self.repository.get_product_by_name_and_specification( - session, - payload["product_name"], - payload["specification"], - ) - if existed is not None: - raise AppException(code=ErrorCode.DUPLICATE, message="产品已存在", status_code=400) + specifications = payload.get("specifications") or [] + if not specifications: + raise AppException(code=ErrorCode.PARAM_ERROR, message="请至少填写一个规格明细", status_code=400) category = None if payload.get("category_id") is not None: @@ -190,21 +281,51 @@ class ProductService: if category is None or category.status != 1: raise AppException(code=ErrorCode.NOT_FOUND, message="产品分类不存在或已停用", status_code=404) - product = self.repository.create_product( - session, - { - **payload, - "category": category.category_name if category else None, - }, - ) + created_specs = [] + for spec in specifications: + if not spec["specification"].strip(): + raise AppException(code=ErrorCode.PARAM_ERROR, message="产品规格不能为空", status_code=400) + if not spec["unit"].strip(): + raise AppException(code=ErrorCode.PARAM_ERROR, message="产品单位不能为空", status_code=400) + existed = self.repository.get_product_by_name_and_specification( + session, + payload["product_name"], + spec["specification"], + ) + if existed is not None: + raise AppException(code=ErrorCode.DUPLICATE, message="同一产品下的规格不能重复", status_code=400) + product = self.repository.create_product( + session, + { + "product_name": payload["product_name"], + "specification": spec["specification"], + "unit": spec["unit"], + "category_id": spec.get("category_id") or payload.get("category_id"), + "category": category.category_name if category else None, + "cost_price": spec.get("cost_price", 0), + "sale_price": spec.get("sale_price", 0), + "status": spec.get("status", payload.get("status", 1)), + "remark": spec.get("remark") or payload.get("remark"), + }, + ) + created_specs.append(product) session.commit() return { - "product_id": product.id, - "product_name": product.product_name, - "specification": product.specification, - "unit": product.unit, - "category_id": product.category_id, - "status": product.status, + "product_name": payload["product_name"], + "product_id": created_specs[0].id if created_specs else None, + "specifications": [ + { + "product_id": item.id, + "specification": item.specification, + "unit": item.unit, + "cost_price": float(item.cost_price or 0), + "sale_price": float(item.sale_price or 0), + "status": item.status, + "remark": item.remark, + "is_default": index == 0, + } + for index, item in enumerate(created_specs) + ], } except AppException: session.rollback() @@ -215,9 +336,7 @@ class ProductService: return { "product_id": 2003, "product_name": payload["product_name"], - "specification": payload["specification"], - "unit": payload["unit"], - "status": payload.get("status", 1), + "specifications": payload.get("specifications", []), } def get_product(self, product_id: int, session: Session | None = None) -> dict: @@ -225,36 +344,29 @@ class ProductService: try: product = self.repository.get_product(session, product_id) if product is not None: - category_name = product.category or "" - if product.category_id is not None: - category = self.repository.get_category(session, product.category_id) - if category is not None: - category_name = category.category_name - return { - "product_id": product.id, - "product_name": product.product_name, - "specification": product.specification, - "unit": product.unit, - "category_id": product.category_id, - "category_name": category_name, - "cost_price": float(product.cost_price or 0), - "sale_price": float(product.sale_price or 0), - "status": product.status, - "remark": product.remark, - } + products = self.repository.list_products(session, {"product_name": product.product_name}) + categories = {category.id: category for category in self.repository.list_categories(session, {})} + grouped = self._group_products(products, categories) + if grouped: + return grouped[0] except SQLAlchemyError: pass return { - "product_id": product_id, "product_name": "演示产品A", - "specification": "10kg", - "unit": "吨", - "category_id": 1, "category_name": "工业品", - "cost_price": 60, - "sale_price": 100, - "status": 1, + "specifications": [ + { + "product_id": product_id, + "specification": "10kg", + "unit": "吨", + "cost_price": 60, + "sale_price": 100, + "status": 1, + "remark": "-", + "is_default": True, + } + ], } diff --git a/frontend/mini-manager/pages/supplier-text.js b/frontend/mini-manager/pages/supplier-text.js index 45ae0f4..ad5b790 100644 --- a/frontend/mini-manager/pages/supplier-text.js +++ b/frontend/mini-manager/pages/supplier-text.js @@ -6,7 +6,7 @@ Page({ orderRows: [], selectedOrderId: null, selectedOrderNo: "", - selectedSupplierId: 1001, + selectedSupplierId: null, textContent: "", templateType: "default", confirmRemark: "微信管理层端确认发厂", @@ -46,13 +46,30 @@ Page({ } }, - handleChooseOrder(event) { + async handleChooseOrder(event) { const { id, no } = event.currentTarget.dataset; + const app = getApp(); this.setData({ selectedOrderId: id, selectedOrderNo: no, + selectedSupplierId: null, textContent: "", + message: "", }); + + try { + const detail = await app.request({ + url: `/api/orders/${id}`, + method: "GET", + }); + this.setData({ + selectedSupplierId: detail.factory_id || null, + }); + } catch (error) { + this.setData({ + message: error.message || "加载订单详情失败", + }); + } }, handleTextInput(event) { @@ -72,6 +89,10 @@ Page({ this.setData({ message: "请先选择一笔订单" }); return; } + if (!this.data.selectedSupplierId) { + this.setData({ message: "当前订单未绑定工厂,请先完善工厂信息" }); + return; + } const app = getApp(); this.setData({ actionLoading: true, message: "" }); try { @@ -99,6 +120,10 @@ Page({ this.setData({ message: "请先选择一笔订单" }); return; } + if (!this.data.selectedSupplierId) { + this.setData({ message: "当前订单未绑定工厂,请先完善工厂信息" }); + return; + } const app = getApp(); this.setData({ actionLoading: true, message: "" }); try { diff --git a/frontend/web-admin/src/mockApi.js b/frontend/web-admin/src/mockApi.js index 6a6edaf..65eb8e2 100644 --- a/frontend/web-admin/src/mockApi.js +++ b/frontend/web-admin/src/mockApi.js @@ -511,16 +511,20 @@ export async function fetchProductList(filters = {}) { const data = await request(`/api/products?${query}`); return { rows: (data.list || []).map((item) => ({ - productId: item.product_id, productName: item.product_name, - specification: item.specification || "-", - unit: item.unit || "-", - categoryId: item.category_id, categoryName: item.category_name || "-", - costPrice: Number(item.cost_price || 0).toFixed(2), - salePrice: Number(item.sale_price || 0).toFixed(2), + categoryId: item.category_id, status: Number(item.status || 1) === 1 ? "启用" : "停用", remark: item.remark || "-", + specifications: (item.specifications || []).map((spec) => ({ + productId: spec.product_id, + specification: spec.specification || "-", + unit: spec.unit || "-", + costPrice: Number(spec.cost_price || 0).toFixed(2), + salePrice: Number(spec.sale_price || 0).toFixed(2), + status: Number(spec.status || 1) === 1 ? "启用" : "停用", + remark: spec.remark || "-", + })), })), isMock: false, }; @@ -531,16 +535,22 @@ export async function fetchProductList(filters = {}) { return wait({ rows: [ { - productId: 2001, productName: "演示产品A", - specification: "10kg", - unit: "吨", - categoryId: 1, categoryName: "工业品", - costPrice: "60.00", - salePrice: "100.00", + categoryId: 1, status: "启用", remark: "-", + specifications: [ + { + productId: 2001, + specification: "10kg", + unit: "吨", + costPrice: "60.00", + salePrice: "100.00", + status: "启用", + remark: "-", + }, + ], }, ], isMock: true, @@ -555,8 +565,35 @@ export async function createProduct(payload) { }); } -export async function fetchProductDetail(productId) { - return request(`/api/products/${productId}`); +export async function fetchProductDetail(productName) { + const data = await request(`/api/products?product_name=${encodeURIComponent(productName)}`); + const first = (data.list || []).find((item) => item.product_name === productName) || data.list?.[0]; + if (!first) { + return null; + } + return { + product_name: first.product_name, + category_name: first.category_name || "-", + category_id: first.category_id, + status: first.status, + remark: first.remark, + specifications: (first.specifications || []).map((spec) => ({ + product_id: spec.product_id, + specification: spec.specification || "-", + unit: spec.unit || "-", + cost_price: Number(spec.cost_price || 0).toFixed(2), + sale_price: Number(spec.sale_price || 0).toFixed(2), + status: Number(spec.status || 1) === 1 ? "启用" : "停用", + remark: spec.remark || "-", + is_default: Boolean(spec.is_default), + })), + }; +} + +export async function setDefaultProductSpec(productId) { + return request(`/api/products/specifications/${productId}/default`, { + method: "PUT", + }); } export async function fetchSupplierList(filters = {}) { diff --git a/frontend/web-admin/src/views/MasterDataPage.vue b/frontend/web-admin/src/views/MasterDataPage.vue index f2d8464..2c26ac4 100644 --- a/frontend/web-admin/src/views/MasterDataPage.vue +++ b/frontend/web-admin/src/views/MasterDataPage.vue @@ -450,47 +450,64 @@ -
- - - - - - - - +
+

同一产品可一次新增多个规格,每个规格可配置不同的销售价和成本价。

+ +
+
+
+
规格 {{ index + 1 }}
+ +
+
+ + + + + + + + + +
@@ -508,27 +525,21 @@ 产品名称 - 规格 - 单位 + 规格数量 分类 - 成本价 - 销售价 状态 操作 - + {{ row.productName }} - {{ row.specification }} - {{ row.unit }} + {{ row.specifications.length }} {{ row.categoryName }} - {{ row.costPrice }} - {{ row.salePrice }} {{ row.status }} - @@ -537,27 +548,116 @@
+ @@ -722,7 +822,7 @@