优化订单、产品类别模块
This commit is contained in:
parent
4e47386e56
commit
ce680ac2f3
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 = {}) {
|
||||
|
||||
@ -450,47 +450,64 @@
|
||||
</div>
|
||||
<button class="modal-close" type="button" @click="cancelCreateProduct">×</button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>产品名称</span>
|
||||
<input v-model.trim="productCreateForm.product_name" type="text" placeholder="请输入产品名称" />
|
||||
</label>
|
||||
<label>
|
||||
<span>规格</span>
|
||||
<input v-model.trim="productCreateForm.specification" type="text" placeholder="请输入规格" />
|
||||
</label>
|
||||
<label>
|
||||
<span>单位</span>
|
||||
<input v-model.trim="productCreateForm.unit" type="text" placeholder="请输入单位" />
|
||||
</label>
|
||||
<label>
|
||||
<span>分类</span>
|
||||
<select v-model="productCreateForm.category_id">
|
||||
<option value="">请选择分类</option>
|
||||
<option v-for="item in categoryOptions" :key="item.value" :value="String(item.value)">
|
||||
{{ item.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>成本价</span>
|
||||
<input v-model.number="productCreateForm.cost_price" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>销售价</span>
|
||||
<input v-model.number="productCreateForm.sale_price" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select v-model.number="productCreateForm.status">
|
||||
<option :value="1">启用</option>
|
||||
<option :value="0">停用</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="full-width">
|
||||
<span>备注</span>
|
||||
<textarea v-model.trim="productCreateForm.remark" rows="3" placeholder="请输入备注"></textarea>
|
||||
</label>
|
||||
<div class="product-create-summary">
|
||||
<p>同一产品可一次新增多个规格,每个规格可配置不同的销售价和成本价。</p>
|
||||
<button class="ghost-btn small-btn" type="button" @click="addProductSpecRow">添加规格</button>
|
||||
</div>
|
||||
<div v-for="(specRow, index) in productSpecRows" :key="specRow.rowKey" class="spec-card">
|
||||
<div class="spec-card-header">
|
||||
<h5>规格 {{ index + 1 }}</h5>
|
||||
<button v-if="productSpecRows.length > 1" class="link-btn danger-link" type="button" @click="removeProductSpecRow(index)">删除</button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>产品名称</span>
|
||||
<input v-model.trim="specRow.product_name" type="text" placeholder="请输入产品名称" />
|
||||
</label>
|
||||
<label>
|
||||
<span>规格</span>
|
||||
<input v-model.trim="specRow.specification" type="text" placeholder="请输入规格" />
|
||||
</label>
|
||||
<label>
|
||||
<span>单位</span>
|
||||
<input v-model.trim="specRow.unit" type="text" placeholder="请输入单位" />
|
||||
</label>
|
||||
<label>
|
||||
<span>分类</span>
|
||||
<select v-model="specRow.category_id">
|
||||
<option value="">请选择分类</option>
|
||||
<option v-for="item in categoryOptions" :key="item.value" :value="String(item.value)">
|
||||
{{ item.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>成本价</span>
|
||||
<input v-model.number="specRow.cost_price" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>销售价</span>
|
||||
<input v-model.number="specRow.sale_price" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select v-model.number="specRow.status">
|
||||
<option :value="1">启用</option>
|
||||
<option :value="0">停用</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>默认规格</span>
|
||||
<select v-model="specRow.is_default">
|
||||
<option :value="true">是</option>
|
||||
<option :value="false">否</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="full-width">
|
||||
<span>备注</span>
|
||||
<textarea v-model.trim="specRow.remark" rows="3" placeholder="请输入备注"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row split">
|
||||
<button class="ghost-btn" @click="cancelCreateProduct">取消新增</button>
|
||||
@ -508,27 +525,21 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>产品名称</th>
|
||||
<th>规格</th>
|
||||
<th>单位</th>
|
||||
<th>规格数量</th>
|
||||
<th>分类</th>
|
||||
<th>成本价</th>
|
||||
<th>销售价</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in productRows" :key="row.productId || `${row.productName}-${row.specification}`">
|
||||
<tr v-for="row in productRows" :key="row.productName">
|
||||
<td>{{ row.productName }}</td>
|
||||
<td>{{ row.specification }}</td>
|
||||
<td>{{ row.unit }}</td>
|
||||
<td>{{ row.specifications.length }}</td>
|
||||
<td>{{ row.categoryName }}</td>
|
||||
<td>{{ row.costPrice }}</td>
|
||||
<td>{{ row.salePrice }}</td>
|
||||
<td>{{ row.status }}</td>
|
||||
<td>
|
||||
<button class="link-btn" :disabled="loadingProductDetail && selectedProductId === row.productId" @click="handleViewProductDetail(row)">
|
||||
{{ loadingProductDetail && selectedProductId === row.productId ? "加载中..." : "查看详情" }}
|
||||
<button class="link-btn" :disabled="loadingProductDetail && selectedProductId === row.productName" @click="handleViewProductDetail(row)">
|
||||
{{ loadingProductDetail && selectedProductId === row.productName ? "加载中..." : "查看详情" }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@ -537,27 +548,116 @@
|
||||
</div>
|
||||
|
||||
<div v-if="productDetail" class="modal-mask" @click.self="closeProductDetail">
|
||||
<section class="modal-panel detail-modal">
|
||||
<section class="modal-panel detail-modal wide-detail-modal">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h4>产品详情</h4>
|
||||
<span>产品 ID:{{ productDetail.product_id }}</span>
|
||||
<span>产品主档:{{ productDetail.product_name }}</span>
|
||||
</div>
|
||||
<button class="modal-close" type="button" @click="closeProductDetail">×</button>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-grid product-master-grid">
|
||||
<article>
|
||||
<h5>基本信息</h5>
|
||||
<p>产品名称:{{ productDetail.product_name }}</p>
|
||||
<p>规格:{{ productDetail.specification }}</p>
|
||||
<p>单位:{{ productDetail.unit }}</p>
|
||||
</article>
|
||||
<article>
|
||||
<h5>分类与价格</h5>
|
||||
<h5>主档信息</h5>
|
||||
<p>分类:{{ productDetail.category_name || "-" }}</p>
|
||||
<p>成本价:{{ formatAmount(productDetail.cost_price) }}</p>
|
||||
<p>销售价:{{ formatAmount(productDetail.sale_price) }}</p>
|
||||
<p>状态:{{ Number(productDetail.status || 1) === 1 ? "启用" : "停用" }}</p>
|
||||
<p>备注:{{ productDetail.remark || "-" }}</p>
|
||||
</article>
|
||||
<article>
|
||||
<h5>规格总览</h5>
|
||||
<p>规格数量:{{ productDetail.specifications.length }}</p>
|
||||
<p>默认规格:{{ productDetail.specifications.find((item) => item.is_default)?.specification || "-" }}</p>
|
||||
<p>支持展开后逐个编辑规格明细。</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="spec-list">
|
||||
<details v-for="spec in productDetail.specifications" :key="spec.product_id" class="spec-detail-item">
|
||||
<summary>
|
||||
<div>
|
||||
<strong>{{ spec.specification }}</strong>
|
||||
<span>{{ spec.unit }} · {{ Number(spec.status || 1) === 1 ? "启用" : "停用" }}</span>
|
||||
</div>
|
||||
<div class="spec-actions">
|
||||
<span v-if="spec.is_default" class="default-badge">默认规格</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="ghost-btn small-btn"
|
||||
@click.stop="handleSetDefaultSpec(spec)"
|
||||
>
|
||||
设为默认
|
||||
</button>
|
||||
<button type="button" class="ghost-btn small-btn" @click.stop="startEditSpec(spec)">
|
||||
编辑规格
|
||||
</button>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="detail-grid spec-inner-grid">
|
||||
<article>
|
||||
<h5>价格信息</h5>
|
||||
<p>成本价:{{ formatAmount(spec.cost_price) }}</p>
|
||||
<p>销售价:{{ formatAmount(spec.sale_price) }}</p>
|
||||
</article>
|
||||
<article>
|
||||
<h5>扩展信息</h5>
|
||||
<p>备注:{{ spec.remark || "-" }}</p>
|
||||
<p>规格 ID:{{ spec.product_id }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="showEditSpecForm" class="modal-mask" @click.self="cancelEditSpec">
|
||||
<section class="modal-panel">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h4>编辑规格</h4>
|
||||
<span>产品:{{ editingSpecForm.product_name }}</span>
|
||||
</div>
|
||||
<button class="modal-close" type="button" @click="cancelEditSpec">×</button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>规格</span>
|
||||
<input v-model.trim="editingSpecForm.specification" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>单位</span>
|
||||
<input v-model.trim="editingSpecForm.unit" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>分类</span>
|
||||
<select v-model="editingSpecForm.category_id">
|
||||
<option value="">请选择分类</option>
|
||||
<option v-for="item in categoryOptions" :key="item.value" :value="String(item.value)">
|
||||
{{ item.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>成本价</span>
|
||||
<input v-model.number="editingSpecForm.cost_price" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>销售价</span>
|
||||
<input v-model.number="editingSpecForm.sale_price" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select v-model.number="editingSpecForm.status">
|
||||
<option :value="1">启用</option>
|
||||
<option :value="0">停用</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="full-width">
|
||||
<span>备注</span>
|
||||
<textarea v-model.trim="editingSpecForm.remark" rows="3"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="action-row split">
|
||||
<button class="ghost-btn" @click="cancelEditSpec">取消</button>
|
||||
<button class="primary-btn" :disabled="updatingSpec" @click="handleUpdateSpec">保存规格</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@ -722,7 +822,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
|
||||
import {
|
||||
@ -730,6 +830,8 @@ import {
|
||||
createProduct,
|
||||
createProductCategory,
|
||||
createSupplier,
|
||||
setDefaultProductSpec,
|
||||
updateProductSpecification,
|
||||
fetchCustomerDetail,
|
||||
fetchCustomerList,
|
||||
fetchMasterData,
|
||||
@ -827,7 +929,11 @@ const productFilters = reactive({
|
||||
category_id: "",
|
||||
status: "",
|
||||
});
|
||||
const productCreateForm = reactive({
|
||||
const productSpecRows = ref([]);
|
||||
const showEditSpecForm = ref(false);
|
||||
const updatingSpec = ref(false);
|
||||
const editingSpecId = ref(null);
|
||||
const editingSpecForm = reactive({
|
||||
product_name: "",
|
||||
specification: "",
|
||||
unit: "",
|
||||
@ -838,6 +944,28 @@ const productCreateForm = reactive({
|
||||
remark: "",
|
||||
});
|
||||
|
||||
function buildProductSpecRow() {
|
||||
return {
|
||||
rowKey: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
product_name: "",
|
||||
specification: "",
|
||||
unit: "",
|
||||
category_id: "",
|
||||
cost_price: 0,
|
||||
sale_price: 0,
|
||||
status: 1,
|
||||
remark: "",
|
||||
};
|
||||
}
|
||||
|
||||
function createProductRowFromSpec(spec) {
|
||||
return {
|
||||
...spec,
|
||||
rowKey: `${spec.product_id}-${spec.specification}`,
|
||||
category_id: spec.category_id ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
const loadingSuppliers = ref(true);
|
||||
const creatingSupplier = ref(false);
|
||||
const showCreateSupplierForm = ref(false);
|
||||
@ -978,14 +1106,20 @@ function closeSupplierDetail() {
|
||||
}
|
||||
|
||||
function resetProductCreateForm() {
|
||||
productCreateForm.product_name = "";
|
||||
productCreateForm.specification = "";
|
||||
productCreateForm.unit = "";
|
||||
productCreateForm.category_id = "";
|
||||
productCreateForm.cost_price = 0;
|
||||
productCreateForm.sale_price = 0;
|
||||
productCreateForm.status = 1;
|
||||
productCreateForm.remark = "";
|
||||
productSpecRows.value = [buildProductSpecRow()];
|
||||
}
|
||||
|
||||
resetProductCreateForm();
|
||||
|
||||
function addProductSpecRow() {
|
||||
productSpecRows.value.push(buildProductSpecRow());
|
||||
}
|
||||
|
||||
function removeProductSpecRow(index) {
|
||||
if (productSpecRows.value.length <= 1) {
|
||||
return;
|
||||
}
|
||||
productSpecRows.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function openCreateProductModal() {
|
||||
@ -1002,6 +1136,57 @@ function resetProductDetail() {
|
||||
productDetail.value = null;
|
||||
}
|
||||
|
||||
function resetEditSpecForm() {
|
||||
editingSpecId.value = null;
|
||||
showEditSpecForm.value = false;
|
||||
editingSpecForm.product_name = "";
|
||||
editingSpecForm.specification = "";
|
||||
editingSpecForm.unit = "";
|
||||
editingSpecForm.category_id = "";
|
||||
editingSpecForm.cost_price = 0;
|
||||
editingSpecForm.sale_price = 0;
|
||||
editingSpecForm.status = 1;
|
||||
editingSpecForm.remark = "";
|
||||
}
|
||||
|
||||
function cancelEditSpec() {
|
||||
resetEditSpecForm();
|
||||
}
|
||||
|
||||
function startEditSpec(spec) {
|
||||
editingSpecId.value = spec.product_id;
|
||||
editingSpecForm.product_name = productDetail.value?.product_name || "";
|
||||
editingSpecForm.specification = spec.specification || "";
|
||||
editingSpecForm.unit = spec.unit || "";
|
||||
editingSpecForm.category_id = String(productDetail.value?.category_id || "");
|
||||
editingSpecForm.cost_price = Number(spec.cost_price || 0);
|
||||
editingSpecForm.sale_price = Number(spec.sale_price || 0);
|
||||
editingSpecForm.status = Number(spec.status || 1);
|
||||
editingSpecForm.remark = spec.remark === "-" ? "" : spec.remark || "";
|
||||
showEditSpecForm.value = true;
|
||||
}
|
||||
|
||||
async function handleSetDefaultSpec(spec) {
|
||||
if (!spec?.product_id) {
|
||||
setMessage("请选择有效规格", "error");
|
||||
return;
|
||||
}
|
||||
loadingProductDetail.value = true;
|
||||
setMessage("");
|
||||
try {
|
||||
await setDefaultProductSpec(spec.product_id);
|
||||
if (productDetail.value?.product_name) {
|
||||
productDetail.value = await fetchProductDetail(productDetail.value.product_name);
|
||||
}
|
||||
await handleSearchProducts();
|
||||
setMessage(`已将 ${spec.specification} 设为默认规格。`);
|
||||
} catch (error) {
|
||||
setMessage(error.message || "设置默认规格失败", "error");
|
||||
} finally {
|
||||
loadingProductDetail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetSupplierCreateForm() {
|
||||
supplierCreateForm.supplier_name = "";
|
||||
supplierCreateForm.supplier_type = "factory";
|
||||
@ -1256,29 +1441,39 @@ async function handleResetProducts() {
|
||||
}
|
||||
|
||||
async function handleCreateProduct() {
|
||||
if (!productCreateForm.product_name.trim()) {
|
||||
setMessage("请输入产品名称", "error");
|
||||
const validRows = productSpecRows.value.filter((row) => row.product_name.trim() && row.specification.trim() && row.unit.trim());
|
||||
if (!validRows.length) {
|
||||
setMessage("请至少填写一个完整的产品规格", "error");
|
||||
return;
|
||||
}
|
||||
if (!productCreateForm.specification.trim()) {
|
||||
setMessage("请输入产品规格", "error");
|
||||
return;
|
||||
}
|
||||
if (!productCreateForm.unit.trim()) {
|
||||
setMessage("请输入产品单位", "error");
|
||||
const productName = validRows[0].product_name.trim();
|
||||
if (validRows.some((row) => row.product_name.trim() !== productName)) {
|
||||
setMessage("同一个产品主档下的规格名称必须一致", "error");
|
||||
return;
|
||||
}
|
||||
creatingProduct.value = true;
|
||||
setMessage("");
|
||||
try {
|
||||
const result = await createProduct({
|
||||
...productCreateForm,
|
||||
category_id: productCreateForm.category_id ? Number(productCreateForm.category_id) : null,
|
||||
product_name: productName,
|
||||
category_id: validRows[0].category_id ? Number(validRows[0].category_id) : null,
|
||||
status: Number(validRows[0].status || 1),
|
||||
remark: validRows[0].remark,
|
||||
specifications: validRows.map((row) => ({
|
||||
specification: row.specification,
|
||||
unit: row.unit,
|
||||
category_id: row.category_id ? Number(row.category_id) : null,
|
||||
cost_price: Number(row.cost_price || 0),
|
||||
sale_price: Number(row.sale_price || 0),
|
||||
status: Number(row.status || 1),
|
||||
remark: row.remark,
|
||||
is_default: Boolean(row.is_default),
|
||||
})),
|
||||
});
|
||||
showCreateProductForm.value = false;
|
||||
resetProductCreateForm();
|
||||
await handleSearchProducts();
|
||||
setMessage(`产品 ${result.product_name} 创建成功。`);
|
||||
setMessage(`产品 ${result.product_name} 创建成功,已保存 ${result.specifications?.length || 0} 个规格。`);
|
||||
} catch (error) {
|
||||
setMessage(error.message || "创建产品失败", "error");
|
||||
} finally {
|
||||
@ -1287,15 +1482,15 @@ async function handleCreateProduct() {
|
||||
}
|
||||
|
||||
async function handleViewProductDetail(row) {
|
||||
if (!row.productId) {
|
||||
if (!row.productName) {
|
||||
setMessage("当前产品暂不支持查看详情", "error");
|
||||
return;
|
||||
}
|
||||
loadingProductDetail.value = true;
|
||||
selectedProductId.value = row.productId;
|
||||
selectedProductId.value = row.productName;
|
||||
try {
|
||||
productDetail.value = await fetchProductDetail(row.productId);
|
||||
setMessage(`已加载产品 ${row.productName} 的详情。`);
|
||||
productDetail.value = await fetchProductDetail(row.productName);
|
||||
setMessage(`已加载产品 ${row.productName} 的规格详情。`);
|
||||
} catch (error) {
|
||||
setMessage(error.message || "加载产品详情失败", "error");
|
||||
} finally {
|
||||
@ -1303,6 +1498,45 @@ async function handleViewProductDetail(row) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateSpec() {
|
||||
if (!editingSpecId.value) {
|
||||
setMessage("请选择要编辑的规格", "error");
|
||||
return;
|
||||
}
|
||||
if (!editingSpecForm.product_name.trim()) {
|
||||
setMessage("请输入产品名称", "error");
|
||||
return;
|
||||
}
|
||||
if (!editingSpecForm.specification.trim()) {
|
||||
setMessage("请输入规格", "error");
|
||||
return;
|
||||
}
|
||||
if (!editingSpecForm.unit.trim()) {
|
||||
setMessage("请输入单位", "error");
|
||||
return;
|
||||
}
|
||||
updatingSpec.value = true;
|
||||
setMessage("");
|
||||
try {
|
||||
await updateProductSpecification(editingSpecId.value, {
|
||||
...editingSpecForm,
|
||||
category_id: editingSpecForm.category_id ? Number(editingSpecForm.category_id) : null,
|
||||
status: Number(editingSpecForm.status || 1),
|
||||
is_default: false,
|
||||
});
|
||||
resetEditSpecForm();
|
||||
await handleSearchProducts();
|
||||
if (productDetail.value?.product_name) {
|
||||
productDetail.value = await fetchProductDetail(productDetail.value.product_name);
|
||||
}
|
||||
setMessage("规格更新成功。");
|
||||
} catch (error) {
|
||||
setMessage(error.message || "更新规格失败", "error");
|
||||
} finally {
|
||||
updatingSpec.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearchSuppliers() {
|
||||
loadingSuppliers.value = true;
|
||||
setMessage("");
|
||||
@ -1465,6 +1699,13 @@ th { color: #334155; font-size: 13px; background: #f8fafc; }
|
||||
.modal-header h4 { margin: 0 0 6px; font-size: 18px; }
|
||||
.modal-header span { color: #64748b; font-size: 13px; }
|
||||
.modal-close { width: 36px; height: 36px; border: 1px solid #d1d5db; background: #f8fafc; font-size: 22px; line-height: 1; color: #334155; }
|
||||
.product-create-summary { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 12px; padding: 12px 14px; border: 1px solid #dbe3ef; border-radius: 14px; background: #f8fbff; }
|
||||
.product-create-summary p { margin: 0; color: #475569; }
|
||||
.spec-card { margin-bottom: 14px; padding: 14px; border: 1px solid #dbe3ef; border-radius: 16px; background: #fff; }
|
||||
.spec-card-header { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.spec-card-header h5 { margin: 0; font-size: 15px; }
|
||||
.small-btn { padding: 8px 12px; }
|
||||
.danger-link { color: #dc2626; }
|
||||
.link-btn { color: #2563eb; cursor: pointer; }
|
||||
.link-btn:disabled { color: #9ca3af; background: #f3f4f6; cursor: not-allowed; }
|
||||
|
||||
|
||||
@ -105,42 +105,51 @@
|
||||
<section class="form-section full-width">
|
||||
<div class="section-header">
|
||||
<h3>订单明细</h3>
|
||||
<span>当前保留 1 条最小可用明细</span>
|
||||
<div class="section-actions">
|
||||
<span>支持一次录入多个产品</span>
|
||||
<button type="button" class="ghost-btn small-btn" @click="addItemRow">添加产品</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-grid">
|
||||
<label>
|
||||
<span>选择产品</span>
|
||||
<select v-model="selectedProductId" @change="handleProductChange">
|
||||
<option value="">请选择产品</option>
|
||||
<option v-for="option in productOptions" :key="option.value" :value="String(option.value)">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>产品名称</span>
|
||||
<input v-model.trim="item.product_name" type="text" placeholder="请输入产品名称" />
|
||||
</label>
|
||||
<label>
|
||||
<span>规格</span>
|
||||
<input v-model.trim="item.specification" type="text" placeholder="请输入规格" />
|
||||
</label>
|
||||
<label>
|
||||
<span>单位</span>
|
||||
<input v-model.trim="item.unit" type="text" placeholder="例如:吨" />
|
||||
</label>
|
||||
<label>
|
||||
<span>数量</span>
|
||||
<input v-model.number="item.quantity" type="number" min="0.01" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>销售单价</span>
|
||||
<input v-model.number="item.sale_price" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>成本单价</span>
|
||||
<input v-model.number="item.cost_price" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<div v-for="(row, index) in items" :key="row.rowKey" class="item-card">
|
||||
<div class="item-card-header">
|
||||
<h4>产品 {{ index + 1 }}</h4>
|
||||
<button v-if="items.length > 1" type="button" class="link-btn danger-link" @click="removeItemRow(index)">删除</button>
|
||||
</div>
|
||||
<div class="section-grid">
|
||||
<label>
|
||||
<span>选择产品</span>
|
||||
<select v-model="row.selectedProductId" @change="handleProductChange(row)">
|
||||
<option value="">请选择产品</option>
|
||||
<option v-for="option in productOptions" :key="option.value" :value="String(option.value)">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>产品名称</span>
|
||||
<input v-model.trim="row.product_name" type="text" placeholder="请输入产品名称" />
|
||||
</label>
|
||||
<label>
|
||||
<span>规格</span>
|
||||
<input v-model.trim="row.specification" type="text" placeholder="请输入规格" />
|
||||
</label>
|
||||
<label>
|
||||
<span>单位</span>
|
||||
<input v-model.trim="row.unit" type="text" placeholder="例如:吨" />
|
||||
</label>
|
||||
<label>
|
||||
<span>数量</span>
|
||||
<input v-model.number="row.quantity" type="number" min="0.01" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>销售单价</span>
|
||||
<input v-model.number="row.sale_price" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>成本单价</span>
|
||||
<input v-model.number="row.cost_price" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
@ -190,7 +199,25 @@ const customerOptions = ref([]);
|
||||
const factoryOptions = ref([]);
|
||||
const productOptions = ref([]);
|
||||
const selectedCustomerId = ref("");
|
||||
const selectedProductId = ref("");
|
||||
const selectedProductSpecMap = ref({});
|
||||
function buildDefaultItem() {
|
||||
return {
|
||||
rowKey: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
selectedProductId: "",
|
||||
product_id: null,
|
||||
product_name: "",
|
||||
specification: "",
|
||||
unit: "",
|
||||
quantity: 1,
|
||||
sale_price: 0,
|
||||
cost_price: 0,
|
||||
rebate_amount: 0,
|
||||
freight_amount: 0,
|
||||
tax_amount: 0,
|
||||
other_fee_amount: 0,
|
||||
remark: "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultForm() {
|
||||
return {
|
||||
@ -209,28 +236,15 @@ function buildDefaultForm() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultItem() {
|
||||
return {
|
||||
product_id: null,
|
||||
product_name: "",
|
||||
specification: "",
|
||||
unit: "",
|
||||
quantity: 1,
|
||||
sale_price: 0,
|
||||
cost_price: 0,
|
||||
rebate_amount: 0,
|
||||
freight_amount: 0,
|
||||
tax_amount: 0,
|
||||
other_fee_amount: 0,
|
||||
remark: "",
|
||||
};
|
||||
}
|
||||
|
||||
const form = reactive(buildDefaultForm());
|
||||
const item = reactive(buildDefaultItem());
|
||||
const items = ref([buildDefaultItem()]);
|
||||
|
||||
const saleTotal = computed(() => Number(item.quantity || 0) * Number(item.sale_price || 0));
|
||||
const costTotal = computed(() => Number(item.quantity || 0) * Number(item.cost_price || 0));
|
||||
const saleTotal = computed(() =>
|
||||
items.value.reduce((total, row) => total + Number(row.quantity || 0) * Number(row.sale_price || 0), 0),
|
||||
);
|
||||
const costTotal = computed(() =>
|
||||
items.value.reduce((total, row) => total + Number(row.quantity || 0) * Number(row.cost_price || 0), 0),
|
||||
);
|
||||
const profitTotal = computed(
|
||||
() =>
|
||||
saleTotal.value -
|
||||
@ -243,12 +257,22 @@ const profitTotal = computed(
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, buildDefaultForm());
|
||||
Object.assign(item, buildDefaultItem());
|
||||
items.value = [buildDefaultItem()];
|
||||
selectedCustomerId.value = "";
|
||||
selectedProductId.value = "";
|
||||
message.value = "";
|
||||
}
|
||||
|
||||
function addItemRow() {
|
||||
items.value.push(buildDefaultItem());
|
||||
}
|
||||
|
||||
function removeItemRow(index) {
|
||||
if (items.value.length <= 1) {
|
||||
return;
|
||||
}
|
||||
items.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function handleCustomerChange() {
|
||||
const selected = customerOptions.value.find((option) => String(option.value) === selectedCustomerId.value);
|
||||
if (!selected) {
|
||||
@ -259,17 +283,17 @@ function handleCustomerChange() {
|
||||
form.customer_address = selected.customer_address;
|
||||
}
|
||||
|
||||
function handleProductChange() {
|
||||
const selected = productOptions.value.find((option) => String(option.value) === selectedProductId.value);
|
||||
function handleProductChange(row) {
|
||||
const selected = productOptions.value.find((option) => String(option.value) === row.selectedProductId);
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
item.product_id = selected.value;
|
||||
item.product_name = selected.product_name;
|
||||
item.specification = selected.specification;
|
||||
item.unit = selected.unit;
|
||||
item.sale_price = Number(selected.sale_price || 0);
|
||||
item.cost_price = Number(selected.cost_price || 0);
|
||||
row.product_id = selected.value;
|
||||
row.product_name = selected.product_name;
|
||||
row.specification = selected.specification;
|
||||
row.unit = selected.unit;
|
||||
row.sale_price = Number(selected.sale_price || 0);
|
||||
row.cost_price = Number(selected.cost_price || 0);
|
||||
}
|
||||
|
||||
function fillDemoData() {
|
||||
@ -283,14 +307,14 @@ function fillDemoData() {
|
||||
}
|
||||
|
||||
if (productOptions.value.length) {
|
||||
selectedProductId.value = String(productOptions.value[0].value);
|
||||
handleProductChange();
|
||||
items.value[0].selectedProductId = String(productOptions.value[0].value);
|
||||
handleProductChange(items.value[0]);
|
||||
} else {
|
||||
item.product_name = "演示产品A";
|
||||
item.specification = "10kg";
|
||||
item.unit = "吨";
|
||||
item.sale_price = 100;
|
||||
item.cost_price = 60;
|
||||
items.value[0].product_name = "演示产品A";
|
||||
items.value[0].specification = "10kg";
|
||||
items.value[0].unit = "吨";
|
||||
items.value[0].sale_price = 100;
|
||||
items.value[0].cost_price = 60;
|
||||
}
|
||||
|
||||
form.order_source = "线下拜访";
|
||||
@ -303,12 +327,12 @@ function fillDemoData() {
|
||||
form.other_fee_total = 2;
|
||||
form.remark = "前后端联调演示订单";
|
||||
|
||||
item.quantity = 1;
|
||||
item.rebate_amount = 5;
|
||||
item.freight_amount = 10;
|
||||
item.tax_amount = 3;
|
||||
item.other_fee_amount = 2;
|
||||
item.remark = "演示明细";
|
||||
items.value[0].quantity = 1;
|
||||
items.value[0].rebate_amount = 5;
|
||||
items.value[0].freight_amount = 10;
|
||||
items.value[0].tax_amount = 3;
|
||||
items.value[0].other_fee_amount = 2;
|
||||
items.value[0].remark = "演示明细";
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
@ -321,7 +345,17 @@ async function loadOptions() {
|
||||
]);
|
||||
customerOptions.value = customers;
|
||||
factoryOptions.value = factories;
|
||||
productOptions.value = products;
|
||||
productOptions.value = products.flatMap((product) =>
|
||||
(product.specifications || []).map((spec) => ({
|
||||
value: spec.product_id,
|
||||
label: `${product.product_name} / ${spec.specification}`,
|
||||
product_name: product.product_name,
|
||||
specification: spec.specification,
|
||||
unit: spec.unit,
|
||||
sale_price: spec.sale_price,
|
||||
cost_price: spec.cost_price,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
message.value = error.message || "加载客户、工厂、产品选项失败";
|
||||
messageType.value = "error";
|
||||
@ -342,8 +376,9 @@ async function handleSubmit() {
|
||||
messageType.value = "error";
|
||||
return;
|
||||
}
|
||||
if (!item.product_name.trim()) {
|
||||
message.value = "请输入产品名称";
|
||||
const validItems = items.value.filter((row) => row.product_name.trim() && row.specification.trim() && row.unit.trim());
|
||||
if (!validItems.length) {
|
||||
message.value = "请至少填写一个完整的产品明细";
|
||||
messageType.value = "error";
|
||||
return;
|
||||
}
|
||||
@ -353,7 +388,7 @@ async function handleSubmit() {
|
||||
try {
|
||||
const result = await createOrder({
|
||||
...form,
|
||||
items: [{ ...item }],
|
||||
items: validItems.map(({ rowKey, selectedProductId, ...rest }) => ({ ...rest })),
|
||||
});
|
||||
message.value = `订单 ${result.order_no} 创建成功,当前状态:${result.order_status}`;
|
||||
messageType.value = "success";
|
||||
@ -455,7 +490,9 @@ h2 {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
.section-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.section-header h3,
|
||||
.summary strong {
|
||||
@ -525,6 +562,21 @@ select {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
margin-bottom: 14px;
|
||||
padding: 14px;
|
||||
border: 1px solid #dbe3ef;
|
||||
border-radius: 16px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
.item-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.item-card-header h4 { margin: 0; }
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@ -537,6 +589,8 @@ button {
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.small-btn { padding: 8px 12px; }
|
||||
.danger-link { color: #dc2626; }
|
||||
|
||||
.primary-btn {
|
||||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
||||
@ -567,6 +621,12 @@ button:disabled {
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
.section-header,
|
||||
.item-card-header,
|
||||
.product-create-summary {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user