优化订单、产品类别模块

This commit is contained in:
taiyi 2026-05-21 14:59:38 +08:00
parent 4e47386e56
commit ce680ac2f3
9 changed files with 820 additions and 271 deletions

View File

@ -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,

View File

@ -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)

View File

@ -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):

View File

@ -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",

View File

@ -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,
}
],
}

View File

@ -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 {

View File

@ -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 = {}) {

View File

@ -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; }

View File

@ -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) {