6-30 更新价格计算方法
This commit is contained in:
parent
a43ea0e027
commit
ec9f710572
@ -22,6 +22,7 @@ from backend.app.schemas.orders import (
|
||||
ChangeOrderStatusRequest,
|
||||
ConfirmSupplierTextRequest,
|
||||
CreateOrderRequest,
|
||||
ReviseOrderRequest,
|
||||
SupplierTextRequest,
|
||||
UpdateOrderRequest,
|
||||
UpdateOrderTrackingRequest,
|
||||
@ -182,6 +183,29 @@ def update_order(
|
||||
return success_payload(order)
|
||||
|
||||
|
||||
@router.post("/{order_id}/revise")
|
||||
def revise_order(
|
||||
order_id: int,
|
||||
payload: ReviseOrderRequest,
|
||||
order_service: OrderService = Depends(get_order_service),
|
||||
session: Session = Depends(get_db_session),
|
||||
current_user: dict = Depends(require_roles("manager", "admin")),
|
||||
_permission_user: dict = Depends(require_permissions("order:update")),
|
||||
) -> dict:
|
||||
"""管理员修订订单(修改商品后自动重算成本)
|
||||
|
||||
用途:管理员修改订单商品信息后,系统自动调用定价引擎重新计算成本,
|
||||
记录修改前后差异,生成审计日志。
|
||||
请求参数:order_id(路径参数)+ ReviseOrderRequest(修改后商品列表和修订原因)。
|
||||
返回值:修订结果,包含修改前后差异。
|
||||
权限要求:经理或管理员,且需 order:update 权限。
|
||||
"""
|
||||
result = order_service.revise_order(order_id, payload.model_dump(), session, current_user)
|
||||
if not result:
|
||||
raise AppException(code=ErrorCode.NOT_FOUND, message="订单不存在", status_code=404)
|
||||
return success_payload(result)
|
||||
|
||||
|
||||
@router.get("/{order_id}")
|
||||
def get_order(
|
||||
order_id: int, # 订单 ID(路径参数)
|
||||
|
||||
@ -484,6 +484,69 @@ def calculate_quotation(
|
||||
})
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# AI 辅助定价
|
||||
# ======================================================================
|
||||
|
||||
@router.post("/quotation/ai-suggest")
|
||||
def ai_suggest_pricing(
|
||||
payload: dict,
|
||||
session: Session = Depends(get_db_session),
|
||||
_user: dict = Depends(require_roles("admin", "manager", "salesman")),
|
||||
) -> dict:
|
||||
"""AI 辅助定价建议(规则缺失时的兜底方案)
|
||||
|
||||
当产品没有配置定价规则时,调用此接口获取 AI 建议的定价。
|
||||
AI 只做建议,最终需要人工确认后才能使用。
|
||||
|
||||
请求参数:{ product_id, quantity, length_m?, width_m?, weight_g?, specification? }
|
||||
返回值:{ suggested_cost, ai_reason, ai_confidence, needs_manual_confirmation }
|
||||
"""
|
||||
from backend.app.models.business import Product
|
||||
|
||||
product_id = payload.get("product_id")
|
||||
if not product_id:
|
||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="product_id 不能为空", status_code=400)
|
||||
|
||||
product = session.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise AppException(code=ErrorCode.NOT_FOUND, message="产品不存在", status_code=404)
|
||||
|
||||
# 基于产品基础信息给出建议
|
||||
cost_price = float(product.cost_price or 0)
|
||||
quantity = float(payload.get("quantity", 1) or 1)
|
||||
length_m = payload.get("length_m")
|
||||
width_m = payload.get("width_m")
|
||||
weight_g = payload.get("weight_g")
|
||||
|
||||
suggested_cost = 0
|
||||
ai_reason = ""
|
||||
|
||||
if cost_price > 0:
|
||||
# 有基础成本价:按数量计算
|
||||
if length_m and width_m:
|
||||
area = float(length_m) * float(width_m)
|
||||
suggested_cost = round(area * cost_price * quantity, 2)
|
||||
ai_reason = f"基于产品成本价 ¥{cost_price}/㎡,面积 {area}㎡ × {quantity}件"
|
||||
elif weight_g:
|
||||
suggested_cost = round(float(weight_g) / 1000 * cost_price * quantity, 2)
|
||||
ai_reason = f"基于产品成本价 ¥{cost_price}/kg,重量 {weight_g}g × {quantity}件"
|
||||
else:
|
||||
suggested_cost = round(cost_price * quantity, 2)
|
||||
ai_reason = f"基于产品成本价 ¥{cost_price} × {quantity}件"
|
||||
else:
|
||||
ai_reason = "产品未设置基础成本价,无法自动建议"
|
||||
suggested_cost = 0
|
||||
|
||||
return {
|
||||
"suggested_cost": suggested_cost,
|
||||
"ai_reason": ai_reason,
|
||||
"ai_confidence": 0.6 if cost_price > 0 else 0.1,
|
||||
"needs_manual_confirmation": True,
|
||||
"ai_assisted": True,
|
||||
}
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 序列化工具(将 ORM 模型转换为字典,便于返回 JSON 响应)
|
||||
# ======================================================================
|
||||
|
||||
@ -32,6 +32,12 @@ class OrderListQuery(BaseModel):
|
||||
order_source: str | None = None
|
||||
"""订单来源筛选"""
|
||||
|
||||
order_type: str | None = None
|
||||
"""订单类型筛选(industry/daily)"""
|
||||
|
||||
need_invoice: int | None = None
|
||||
"""是否需要发票筛选(0=否,1=是)"""
|
||||
|
||||
start_time: str | None = None
|
||||
"""查询起始时间(ISO 格式)"""
|
||||
|
||||
@ -264,7 +270,7 @@ class ApproveOrderRequest(BaseModel):
|
||||
"""审批订单请求体。被 POST /api/orders/{id}/approve 路由使用。"""
|
||||
|
||||
approve_result: str
|
||||
"""审批结果(如 approved / rejected)"""
|
||||
"""审批结果(pass=通过, reject=退回)"""
|
||||
|
||||
approve_opinion: str | None = None
|
||||
"""审批意见"""
|
||||
@ -273,13 +279,7 @@ class ApproveOrderRequest(BaseModel):
|
||||
"""下发工厂 ID(审批通过时可选,同时选择工厂和司机可跳过待工厂节点)"""
|
||||
|
||||
driver_id: int | None = None
|
||||
"""司机 ID(审批通过时可选,同时选择工厂和司机可跳过待工厂节点)"""
|
||||
|
||||
factory_id: int | None = None
|
||||
"""下发工厂 ID(非自发订单审批通过时可选传入,审批后直接绑定工厂)"""
|
||||
|
||||
driver_id: int | None = None
|
||||
"""分配司机 ID(非自发订单审批通过时可选传入,审批后直接创建物流任务)"""
|
||||
"""分配司机 ID(审批通过时可选,同时选择工厂和司机可直接创建物流任务)"""
|
||||
|
||||
|
||||
class SupplierTextRequest(BaseModel):
|
||||
@ -312,6 +312,31 @@ class ChangeOrderStatusRequest(BaseModel):
|
||||
"""备注"""
|
||||
|
||||
|
||||
class ReviseOrderRequest(BaseModel):
|
||||
"""管理员修订订单请求体。被 POST /api/orders/{id}/revise 路由使用。"""
|
||||
|
||||
items: list[OrderItemPayload] = Field(min_length=1)
|
||||
"""修改后的订单商品明细列表(全量替换,至少一项)"""
|
||||
|
||||
revision_reason: str | None = None
|
||||
"""修订原因"""
|
||||
|
||||
contract_amount: float | None = None
|
||||
"""合同金额(可选,不传则保持原值)"""
|
||||
|
||||
rebate_total: float = Field(default=0, ge=0)
|
||||
"""返利合计"""
|
||||
|
||||
freight_total: float = Field(default=0, ge=0)
|
||||
"""运费合计"""
|
||||
|
||||
tax_total: float = Field(default=0, ge=0)
|
||||
"""税额合计"""
|
||||
|
||||
other_fee_total: float = Field(default=0, ge=0)
|
||||
"""其他费用合计"""
|
||||
|
||||
|
||||
class UpdateOrderTrackingRequest(BaseModel):
|
||||
"""修改自发订单快递单号请求体。被 PUT /api/orders/{id}/tracking 路由使用。"""
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
|
||||
from backend.app.core.cache import cache_delete, cache_delete_pattern, cache_get, cache_set, make_cache_key
|
||||
from backend.app.core.error_codes import ErrorCode
|
||||
from backend.app.core.exceptions import AppException
|
||||
from backend.app.repositories.audit_repository import AuditRepository
|
||||
from backend.app.repositories.customer_repository import CustomerRepository
|
||||
from backend.app.repositories.config_repository import ConfigRepository
|
||||
from backend.app.repositories.file_repository import FileRepository
|
||||
@ -27,6 +28,7 @@ from backend.app.repositories.reminder_repository import ReminderRepository
|
||||
from backend.app.services.arrears_service import arrears_service
|
||||
from backend.app.services.audit_service import audit_service
|
||||
from backend.app.services.event_bus import event_bus
|
||||
from backend.app.services.pricing_engine import pricing_engine
|
||||
|
||||
|
||||
class OrderService:
|
||||
@ -53,6 +55,99 @@ class OrderService:
|
||||
self.supplier_repository = SupplierRepository()
|
||||
self.file_repository = FileRepository()
|
||||
self.reminder_repository = ReminderRepository()
|
||||
self.audit_repository = AuditRepository()
|
||||
|
||||
def _enrich_items_with_pricing(self, session: Session, items: list[dict]) -> list[dict]:
|
||||
"""使用定价引擎为订单项计算成本并填充快照字段。
|
||||
|
||||
对每个商品项:
|
||||
1. 查找对应的定价规则 (ProductPricingRule)
|
||||
2. 调用定价引擎计算
|
||||
3. 用引擎结果覆盖 cost_price 并填充快照字段
|
||||
如果找不到规则或计算失败,保留原始 cost_price 不变。
|
||||
|
||||
Args:
|
||||
session: 数据库会话
|
||||
items: 订单项列表(来自前端 payload)
|
||||
|
||||
Returns:
|
||||
填充了定价快照字段的订单项列表
|
||||
"""
|
||||
from backend.app.models.business import ProductPricingRule, Product
|
||||
|
||||
enriched = []
|
||||
for item in items:
|
||||
product_id = item.get("product_id")
|
||||
if not product_id:
|
||||
enriched.append(item)
|
||||
continue
|
||||
|
||||
# 查找定价规则
|
||||
rule = session.query(ProductPricingRule).filter(
|
||||
ProductPricingRule.product_id == product_id,
|
||||
ProductPricingRule.deleted == 0,
|
||||
ProductPricingRule.status == 1,
|
||||
).first()
|
||||
|
||||
if not rule:
|
||||
enriched.append(item)
|
||||
continue
|
||||
|
||||
# 获取产品属性(厚度、克重等)
|
||||
product = session.query(Product).filter(Product.id == product_id).first()
|
||||
product_attrs = {}
|
||||
if product:
|
||||
if product.thickness:
|
||||
product_attrs["thickness"] = product.thickness
|
||||
if product.weight_gsm:
|
||||
product_attrs["weight_gsm"] = product.weight_gsm
|
||||
if product.default_width_m:
|
||||
product_attrs["default_width_m"] = float(product.default_width_m)
|
||||
|
||||
# 构建用户输入:从 item 中提取定价相关字段
|
||||
user_inputs = {}
|
||||
if item.get("length_m"):
|
||||
user_inputs["length"] = item["length_m"]
|
||||
user_inputs["length_unit"] = "m"
|
||||
if item.get("width_m"):
|
||||
user_inputs["width"] = item["width_m"]
|
||||
user_inputs["width_unit"] = "m"
|
||||
if item.get("quantity"):
|
||||
user_inputs["quantity"] = item["quantity"]
|
||||
if item.get("price_tier"):
|
||||
user_inputs["price_tier"] = item["price_tier"]
|
||||
# 从 surcharge_detail 中恢复附加费选择
|
||||
surcharge_detail = item.get("surcharge_detail")
|
||||
if surcharge_detail:
|
||||
try:
|
||||
surcharges = json.loads(surcharge_detail) if isinstance(surcharge_detail, str) else surcharge_detail
|
||||
if isinstance(surcharges, list):
|
||||
for s in surcharges:
|
||||
key = s.get("key")
|
||||
if key:
|
||||
user_inputs[f"surcharge_{key}"] = True
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
try:
|
||||
result = pricing_engine.calculate_for_order_item(rule, user_inputs, product_attrs)
|
||||
# 用引擎结果覆盖成本价
|
||||
item_copy = dict(item)
|
||||
item_copy["cost_price"] = result["cost_price"]
|
||||
# 填充快照字段
|
||||
snapshot = result.get("item_snapshot", {})
|
||||
if snapshot.get("pricing_type"):
|
||||
item_copy["pricing_type"] = snapshot["pricing_type"]
|
||||
if result.get("area_sqm"):
|
||||
item_copy["area_sqm"] = result["area_sqm"]
|
||||
if snapshot.get("surcharge_detail"):
|
||||
item_copy["surcharge_detail"] = snapshot["surcharge_detail"]
|
||||
enriched.append(item_copy)
|
||||
except Exception:
|
||||
# 计价失败时保留原始数据
|
||||
enriched.append(item)
|
||||
|
||||
return enriched
|
||||
|
||||
def list_orders(self, session: Session | None = None, filters: dict | None = None, current_user: dict | None = None) -> dict:
|
||||
"""查询订单列表。
|
||||
@ -292,10 +387,11 @@ class OrderService:
|
||||
*[self._build_attachment_row(item) for item in order_attachments],
|
||||
*[self._build_attachment_row(item) for item in task_attachments],
|
||||
],
|
||||
"calculation_detail": self._build_calculation_detail(order, items),
|
||||
"calculation_detail": self._build_calculation_detail(order, items, session),
|
||||
"profit_alert_threshold": float(self._get_config_value(session, "profit_alert_threshold", "0")),
|
||||
"customer_demand": order.customer_demand,
|
||||
"remark": order.remark,
|
||||
"revision_info": self._get_latest_revision(session, order.id),
|
||||
}
|
||||
# 写缓存(缓存完整结果,角色过滤在读取后执行)
|
||||
cache_set(cache_key, result, ttl=60)
|
||||
@ -361,6 +457,10 @@ class OrderService:
|
||||
items = payload["items"]
|
||||
if not items:
|
||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="订单明细不能为空", status_code=400)
|
||||
|
||||
# 调用定价引擎为每个订单项计算成本并生成快照
|
||||
items = self._enrich_items_with_pricing(session, items)
|
||||
|
||||
sale_total = sum(item["quantity"] * item["sale_price"] for item in items)
|
||||
cost_total = sum(item["quantity"] * item["cost_price"] for item in items)
|
||||
# 使用合同金额作为收入计算利润,如果没有合同金额则使用系统计算的销售金额
|
||||
@ -447,6 +547,170 @@ class OrderService:
|
||||
|
||||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
|
||||
|
||||
def revise_order(
|
||||
self,
|
||||
order_id: int,
|
||||
payload: dict,
|
||||
session: Session | None = None,
|
||||
current_user: dict | None = None,
|
||||
) -> dict | None:
|
||||
"""管理员修订订单(修改商品后自动重算)。
|
||||
|
||||
与 update_order 不同,此方法:
|
||||
1. 记录修改前的快照
|
||||
2. 调用定价引擎重新计算所有商品成本
|
||||
3. 生成修订版本记录
|
||||
4. 返回修改前后差异
|
||||
|
||||
仅 manager/admin 角色可调用。
|
||||
|
||||
Args:
|
||||
order_id: 订单 ID
|
||||
payload: 包含 items(修改后商品列表)和 revision_reason(修改原因)
|
||||
session: 数据库会话
|
||||
current_user: 当前登录用户信息
|
||||
|
||||
Returns:
|
||||
包含修订结果和前后差异的字典
|
||||
"""
|
||||
if session is not None:
|
||||
try:
|
||||
order = self.order_repository.get_order(session, order_id)
|
||||
if order is None:
|
||||
return None
|
||||
# 管理员可以修订任何非终态订单
|
||||
terminal_statuses = {"canceled", "settled"}
|
||||
if order.order_status in terminal_statuses:
|
||||
raise AppException(
|
||||
code=ErrorCode.PARAM_ERROR,
|
||||
message="已取消或已结算的订单不允许修订",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
items = payload.get("items", [])
|
||||
if not items:
|
||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="订单明细不能为空", status_code=400)
|
||||
|
||||
# 记录修改前的快照
|
||||
old_items = self.order_repository.list_order_items(session, order_id)
|
||||
before_snapshot = {
|
||||
"order": {
|
||||
"sale_price_total": float(order.sale_price_total or 0),
|
||||
"cost_price_total": float(order.cost_price_total or 0),
|
||||
"profit_total": float(order.profit_total or 0),
|
||||
"profit_rate": float(order.profit_rate or 0),
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"product_name": item.product_name,
|
||||
"specification": item.specification,
|
||||
"quantity": float(item.quantity or 0),
|
||||
"cost_price": float(item.cost_price or 0),
|
||||
"sale_price": float(item.sale_price or 0),
|
||||
}
|
||||
for item in old_items
|
||||
],
|
||||
}
|
||||
|
||||
# 调用定价引擎重新计算
|
||||
items = self._enrich_items_with_pricing(session, items)
|
||||
|
||||
sale_total = sum(item["quantity"] * item["sale_price"] for item in items)
|
||||
cost_total = sum(item["quantity"] * item["cost_price"] for item in items)
|
||||
income_amount = payload.get("contract_amount") or order.contract_amount or sale_total
|
||||
rebate_total = payload.get("rebate_total", float(order.rebate_total or 0))
|
||||
freight_total = payload.get("freight_total", float(order.freight_total or 0))
|
||||
tax_total = payload.get("tax_total", float(order.tax_total or 0))
|
||||
other_fee_total = payload.get("other_fee_total", float(order.other_fee_total or 0))
|
||||
profit_total = income_amount - cost_total - rebate_total - freight_total - tax_total - other_fee_total
|
||||
profit_rate = round((profit_total / income_amount) * 100, 2) if income_amount else 0
|
||||
|
||||
self.order_repository.update_order_with_items(
|
||||
session,
|
||||
order,
|
||||
{
|
||||
"sale_price_total": sale_total,
|
||||
"cost_price_total": cost_total,
|
||||
"rebate_total": rebate_total,
|
||||
"freight_total": freight_total,
|
||||
"tax_total": tax_total,
|
||||
"other_fee_total": other_fee_total,
|
||||
"profit_total": profit_total,
|
||||
"profit_rate": profit_rate,
|
||||
},
|
||||
items,
|
||||
)
|
||||
|
||||
# 记录修改后的快照
|
||||
after_snapshot = {
|
||||
"order": {
|
||||
"sale_price_total": round(sale_total, 2),
|
||||
"cost_price_total": round(cost_total, 2),
|
||||
"profit_total": round(profit_total, 2),
|
||||
"profit_rate": profit_rate,
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"product_name": item.get("product_name"),
|
||||
"specification": item.get("specification"),
|
||||
"quantity": item.get("quantity"),
|
||||
"cost_price": item.get("cost_price"),
|
||||
"sale_price": item.get("sale_price"),
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
}
|
||||
|
||||
# 写审计日志
|
||||
audit_service.write_log(
|
||||
session,
|
||||
{
|
||||
"operate_type": "order_revise",
|
||||
"biz_type": "sales_order",
|
||||
"biz_id": order.id,
|
||||
"before_value": before_snapshot,
|
||||
"after_value": after_snapshot,
|
||||
"remark": f"修订订单 {order.order_no}:{payload.get('revision_reason', '管理员修改')}",
|
||||
},
|
||||
)
|
||||
|
||||
# 写审批日志(使修订记录出现在审批时间线中)
|
||||
self.order_approve_log_repository.create_log(
|
||||
session,
|
||||
{
|
||||
"order_id": order.id,
|
||||
"approve_type": "revise",
|
||||
"approve_result": "revised",
|
||||
"before_status": order.order_status,
|
||||
"after_status": order.order_status,
|
||||
"approve_opinion": payload.get("revision_reason", "管理员修订"),
|
||||
"operator_id": current_user.get("user_id") if current_user else None,
|
||||
},
|
||||
)
|
||||
|
||||
session.commit()
|
||||
self._invalidate_order_cache(order.id)
|
||||
|
||||
return {
|
||||
"order_id": order.id,
|
||||
"order_no": order.order_no,
|
||||
"order_status": order.order_status,
|
||||
"sale_price_total": round(sale_total, 2),
|
||||
"cost_price_total": round(cost_total, 2),
|
||||
"profit_total": round(profit_total, 2),
|
||||
"profit_rate": profit_rate,
|
||||
"revision_reason": payload.get("revision_reason", ""),
|
||||
"before": before_snapshot,
|
||||
"after": after_snapshot,
|
||||
}
|
||||
except AppException:
|
||||
session.rollback()
|
||||
raise
|
||||
except SQLAlchemyError as exc:
|
||||
session.rollback()
|
||||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
|
||||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
|
||||
|
||||
def submit_order(
|
||||
self,
|
||||
order_id: int,
|
||||
@ -656,35 +920,18 @@ class OrderService:
|
||||
else:
|
||||
target_status = "rejected"
|
||||
# 如果有工厂ID,设置到订单上
|
||||
if payload.get("factory_id") and target_status in ("pending_factory",):
|
||||
if payload.get("factory_id") and target_status in ("pending_factory", "pending_driver"):
|
||||
order.factory_id = payload["factory_id"]
|
||||
# 同时选择了工厂和司机:在同一个事务内创建物流任务,直接进入待司机接单
|
||||
if payload.get("factory_id") and payload.get("driver_id") and target_status == "pending_factory":
|
||||
task = self.logistics_repository.create_task(
|
||||
session,
|
||||
{
|
||||
"task_no": f"LT{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||
"order_id": order.id,
|
||||
"driver_id": payload["driver_id"],
|
||||
"factory_id": payload["factory_id"],
|
||||
"pickup_address": payload.get("pickup_address", ""),
|
||||
"delivery_address": payload.get("delivery_address", ""),
|
||||
"pickup_content": payload.get("pickup_content", ""),
|
||||
"quantity": payload.get("quantity", 1),
|
||||
"status": "pending",
|
||||
"created_by": current_user.get("user_id") if current_user else None,
|
||||
},
|
||||
)
|
||||
target_status = "pending_driver"
|
||||
self.order_repository.update_order_status(session, order, target_status)
|
||||
|
||||
# 有工厂+司机时,在同一事务内创建物流任务
|
||||
# 审批通过且有工厂+司机时,创建物流任务
|
||||
if payload["approve_result"] == "pass" and target_status == "pending_driver" and payload.get("driver_id"):
|
||||
# 获取工厂地址作为取货地址
|
||||
pickup_address = ""
|
||||
factory = self.supplier_repository.get_supplier(session, payload["factory_id"])
|
||||
if factory and factory.address:
|
||||
pickup_address = factory.address
|
||||
if payload.get("factory_id"):
|
||||
factory = self.supplier_repository.get_supplier(session, payload["factory_id"])
|
||||
if factory and factory.address:
|
||||
pickup_address = factory.address
|
||||
|
||||
task = self.logistics_repository.create_task(
|
||||
session,
|
||||
@ -692,7 +939,7 @@ class OrderService:
|
||||
"task_no": f"LT{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||
"order_id": order.id,
|
||||
"driver_id": payload["driver_id"],
|
||||
"factory_id": payload["factory_id"],
|
||||
"factory_id": payload.get("factory_id"),
|
||||
"pickup_address": pickup_address,
|
||||
"delivery_address": "",
|
||||
"pickup_content": "",
|
||||
@ -1241,6 +1488,9 @@ class OrderService:
|
||||
if not items:
|
||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="订单明细不能为空", status_code=400)
|
||||
|
||||
# 调用定价引擎为每个订单项计算成本并生成快照
|
||||
items = self._enrich_items_with_pricing(session, items)
|
||||
|
||||
sale_total = sum(item["quantity"] * item["sale_price"] for item in items)
|
||||
cost_total = sum(item["quantity"] * item["cost_price"] for item in items)
|
||||
# 使用合同金额作为收入计算利润,如果没有合同金额则使用系统计算的销售金额
|
||||
@ -1687,7 +1937,7 @@ class OrderService:
|
||||
if approve_result == "refuse" and not (payload.get("approve_opinion") or "").strip():
|
||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="取消审批驳回时必须填写意见", status_code=400)
|
||||
|
||||
def _build_calculation_detail(self, order, items) -> dict:
|
||||
def _build_calculation_detail(self, order, items, session=None) -> dict:
|
||||
"""构建订单利润计算明细。
|
||||
|
||||
包含公式说明、各费用汇总和每条明细的金额拆分,支持报价引擎快照数据。
|
||||
@ -1695,6 +1945,7 @@ class OrderService:
|
||||
Args:
|
||||
order: 订单 ORM 对象
|
||||
items: 订单明细 ORM 对象列表
|
||||
session: 数据库会话(可选,用于查询定价规则公式)
|
||||
|
||||
Returns:
|
||||
包含计算公式、汇总金额和明细的字典
|
||||
@ -1739,6 +1990,21 @@ class OrderService:
|
||||
detail_row["surcharge_items"] = []
|
||||
detail_row["supplier_model"] = getattr(item, "supplier_model", None)
|
||||
detail_row["price_tier"] = getattr(item, "price_tier", None)
|
||||
# 查询定价规则公式
|
||||
if session and item.product_id:
|
||||
try:
|
||||
from backend.app.models.business import ProductPricingRule
|
||||
rule = session.query(ProductPricingRule).filter(
|
||||
ProductPricingRule.product_id == item.product_id,
|
||||
ProductPricingRule.deleted == 0,
|
||||
).first()
|
||||
if rule:
|
||||
detail_row["formula_expr"] = getattr(rule, "formula_expr", None)
|
||||
detail_row["formula_note"] = getattr(rule, "formula_note", None)
|
||||
if not detail_row.get("pricing_type"):
|
||||
detail_row["pricing_type"] = getattr(rule, "pricing_type", None)
|
||||
except Exception:
|
||||
pass
|
||||
item_details.append(detail_row)
|
||||
|
||||
# 税计算说明
|
||||
@ -1838,6 +2104,39 @@ class OrderService:
|
||||
"created_at": task.created_at.strftime("%Y-%m-%d %H:%M:%S") if task.created_at else "",
|
||||
}
|
||||
|
||||
def _get_latest_revision(self, session: Session, order_id: int) -> dict | None:
|
||||
"""获取订单最近一次修订记录。
|
||||
|
||||
从审计日志中查找 operate_type=order_revise 的最新记录,
|
||||
返回修订前后的差异快照,供审批页展示。
|
||||
|
||||
Args:
|
||||
session: 数据库会话
|
||||
order_id: 订单 ID
|
||||
|
||||
Returns:
|
||||
修订信息字典,无修订记录时返回 None
|
||||
"""
|
||||
try:
|
||||
logs = self.audit_repository.list_logs(session, {
|
||||
"biz_type": "sales_order",
|
||||
"biz_id": order_id,
|
||||
"operate_type": "order_revise",
|
||||
})
|
||||
if not logs:
|
||||
return None
|
||||
latest = logs[0] # 按 id 倒序,第一个是最新的
|
||||
before = json.loads(latest.before_value) if latest.before_value else None
|
||||
after = json.loads(latest.after_value) if latest.after_value else None
|
||||
return {
|
||||
"revision_time": latest.operate_time.strftime("%Y-%m-%d %H:%M:%S") if latest.operate_time else "",
|
||||
"revision_remark": latest.remark or "",
|
||||
"before": before,
|
||||
"after": after,
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _build_attachment_row(self, attachment) -> dict:
|
||||
"""构建附件行数据字典。
|
||||
|
||||
|
||||
@ -30,24 +30,23 @@ class PricingEngine:
|
||||
product_attrs: 产品属性 {"thickness": 0.08} 用于阈值判断
|
||||
|
||||
Returns:
|
||||
{
|
||||
"base_cost", "surcharge_items", "total_surcharge", "cost_price",
|
||||
"formula_detail", "formula_detail_steps"
|
||||
}
|
||||
结构化计价结果字典,包含 base_cost、surcharge_items、total_surcharge、
|
||||
cost_price、formula_detail、tax 字段,以及 rule_snapshot 用于快照存储。
|
||||
|
||||
被调用路由: pricing.py - POST /pricing/calculate
|
||||
"""
|
||||
product_attrs = product_attrs or {}
|
||||
|
||||
# 1. 单位换算 → 统一为米
|
||||
# 1. 单位换算
|
||||
normalized = self._normalize_inputs(rule, user_inputs)
|
||||
area_sqm = normalized.get("length_m", 0) * normalized.get("width_m", 0)
|
||||
|
||||
# 2. 构建公式上下文并求值
|
||||
formula_constants = self._safe_load_json(rule.formula_constants) or {}
|
||||
context = {
|
||||
"input": normalized,
|
||||
"rule": {"base_unit_price": float(rule.base_unit_price)},
|
||||
"const": self._safe_load_json(rule.formula_constants) or {},
|
||||
"const": formula_constants,
|
||||
"product": product_attrs,
|
||||
}
|
||||
base_cost = self._evaluate_formula(rule.formula_expr or "0", context)
|
||||
@ -67,12 +66,10 @@ class PricingEngine:
|
||||
tax_inclusive = int(getattr(rule, "tax_inclusive", 0) or 0)
|
||||
if tax_rate > 0:
|
||||
if tax_inclusive:
|
||||
# 含税定价:cost_price 已含税,反算不含税价
|
||||
price_ex_tax = round(cost_price / (1 + tax_rate / 100), 2)
|
||||
tax_amount = round(cost_price - price_ex_tax, 2)
|
||||
price_in_tax = cost_price
|
||||
else:
|
||||
# 不含税定价:cost_price 为不含税价,正算含税价
|
||||
price_ex_tax = cost_price
|
||||
tax_amount = round(cost_price * tax_rate / 100, 2)
|
||||
price_in_tax = round(cost_price + tax_amount, 2)
|
||||
@ -81,16 +78,70 @@ class PricingEngine:
|
||||
tax_amount = 0
|
||||
price_in_tax = cost_price
|
||||
|
||||
# 6. 构建变量快照(用于审计和回显)
|
||||
formula_variables = {}
|
||||
for prefix, values in context.items():
|
||||
if isinstance(values, dict):
|
||||
for k, v in values.items():
|
||||
formula_variables[f"{prefix}.{k}"] = v
|
||||
|
||||
# 7. 构建定价规则快照
|
||||
rule_snapshot = {
|
||||
"pricing_rule_id": getattr(rule, "id", None),
|
||||
"pricing_type": getattr(rule, "pricing_type", None),
|
||||
"pricing_unit": getattr(rule, "pricing_unit", None),
|
||||
"base_unit_price": float(rule.base_unit_price or 0),
|
||||
"formula_expr": getattr(rule, "formula_expr", None),
|
||||
"formula_note": getattr(rule, "formula_note", None),
|
||||
}
|
||||
|
||||
return {
|
||||
"base_cost": round(base_cost, 2),
|
||||
"surcharge_items": surcharges,
|
||||
"total_surcharge": total_surcharge,
|
||||
"cost_price": cost_price,
|
||||
"formula_detail": formula_detail,
|
||||
"formula_variables": formula_variables,
|
||||
"tax_rate": tax_rate,
|
||||
"tax_inclusive": tax_inclusive,
|
||||
"price_ex_tax": price_ex_tax,
|
||||
"tax_amount": tax_amount,
|
||||
"price_in_tax": price_in_tax,
|
||||
"area_sqm": round(area_sqm, 6),
|
||||
"rule_snapshot": rule_snapshot,
|
||||
# AI 辅助标记(当前由规则引擎计算,ai_assisted=False)
|
||||
# 当规则缺失时由 AI 填充,需人工确认
|
||||
"ai_assisted": False,
|
||||
"ai_reason": None,
|
||||
"ai_confidence": None,
|
||||
"needs_manual_confirmation": False,
|
||||
}
|
||||
|
||||
def calculate_for_order_item(self, rule, user_inputs: dict, product_attrs: dict | None = None) -> dict:
|
||||
"""为订单项执行计价并返回可直接存入快照的结构化结果。
|
||||
|
||||
在 calculate() 基础上,额外返回适合存入 SalesOrderItem 的字段映射。
|
||||
|
||||
Args:
|
||||
rule: ProductPricingRule ORM 对象
|
||||
user_inputs: 前端传入的输入
|
||||
product_attrs: 产品属性
|
||||
|
||||
Returns:
|
||||
包含 item_snapshot 字段的计价结果,可直接写入订单项
|
||||
"""
|
||||
result = self.calculate(rule, user_inputs, product_attrs)
|
||||
snapshot = result["rule_snapshot"]
|
||||
return {
|
||||
**result,
|
||||
"item_snapshot": {
|
||||
"pricing_type": snapshot.get("pricing_type"),
|
||||
"pricing_unit": snapshot.get("pricing_unit"),
|
||||
"area_sqm": result["area_sqm"],
|
||||
"surcharge_detail": json.dumps(result["surcharge_items"], ensure_ascii=False) if result["surcharge_items"] else None,
|
||||
"price_tier": user_inputs.get("price_tier"),
|
||||
"cost_price": result["cost_price"],
|
||||
},
|
||||
}
|
||||
|
||||
def get_available_surcharge_options(self, rule) -> list[dict]:
|
||||
@ -118,20 +169,28 @@ class PricingEngine:
|
||||
|
||||
_UNIT_TO_M = {"m": 1.0, "cm": 0.01, "mm": 0.001}
|
||||
|
||||
# 重量单位 → 千克
|
||||
_UNIT_TO_KG = {"kg": 1.0, "g": 0.001, "t": 1000.0}
|
||||
|
||||
# 面积单位 → 平方米
|
||||
_UNIT_TO_SQM = {"sqm": 1.0, "sqcm": 0.0001, "sqmm": 0.000001}
|
||||
|
||||
def _normalize_inputs(self, rule, user_inputs: dict) -> dict:
|
||||
"""将前端输入转换为统一的米制数值。
|
||||
"""将前端输入转换为统一的标准单位数值。
|
||||
|
||||
根据 pricing_inputs 声明解析字段,自动读取 _unit 后缀进行单位换算。
|
||||
- 长度类字段统一转为米(_m 后缀)
|
||||
- 重量类字段统一转为千克(_kg 后缀)
|
||||
- 面积类字段统一转为平方米(_sqm 后缀)
|
||||
|
||||
Args:
|
||||
rule: 定价规则 ORM 对象
|
||||
user_inputs: 前端传入的原始输入字典
|
||||
|
||||
Returns:
|
||||
标准化后的输入字典,包含原始值和 _m 后缀的米制值
|
||||
标准化后的输入字典,包含原始值和标准化后缀值
|
||||
"""
|
||||
normalized = {}
|
||||
# 解析 pricing_inputs 声明
|
||||
inputs_decl = self._safe_load_json(rule.pricing_inputs) or []
|
||||
|
||||
for field in inputs_decl:
|
||||
@ -146,11 +205,20 @@ class PricingEngine:
|
||||
normalized[key] = val
|
||||
continue
|
||||
|
||||
# 带单位的数值字段:读取对应 _unit 后缀
|
||||
unit_key = f"{key}_unit"
|
||||
unit = user_inputs.get(unit_key, field.get("default_unit", "m"))
|
||||
multiplier = self._UNIT_TO_M.get(unit, 1.0)
|
||||
normalized[f"{key}_m"] = round(val * multiplier, 6)
|
||||
field_type = field.get("unit_type", "length")
|
||||
|
||||
if field_type == "weight":
|
||||
multiplier = self._UNIT_TO_KG.get(unit, 1.0)
|
||||
normalized[f"{key}_kg"] = round(val * multiplier, 6)
|
||||
elif field_type == "area":
|
||||
multiplier = self._UNIT_TO_SQM.get(unit, 1.0)
|
||||
normalized[f"{key}_sqm"] = round(val * multiplier, 6)
|
||||
else:
|
||||
multiplier = self._UNIT_TO_M.get(unit, 1.0)
|
||||
normalized[f"{key}_m"] = round(val * multiplier, 6)
|
||||
|
||||
normalized[key] = val
|
||||
|
||||
return normalized
|
||||
@ -165,6 +233,8 @@ class PricingEngine:
|
||||
支持 $input.xxx、$rule.xxx、$const.xxx、$product.xxx 变量引用,
|
||||
以及 ${condition} ? a : b 三元条件表达式。
|
||||
|
||||
使用正则替换(按变量名长度降序)避免短变量名误匹配长变量名前缀。
|
||||
|
||||
Args:
|
||||
expr: 公式表达式字符串
|
||||
context: 变量上下文字典
|
||||
@ -174,12 +244,20 @@ class PricingEngine:
|
||||
"""
|
||||
resolved = expr
|
||||
|
||||
# 替换 $input.xxx / $rule.xxx / $const.xxx / $product.xxx
|
||||
# 收集所有 (完整变量名, 替换值),按变量名长度降序排列防止前缀误匹配
|
||||
replacements = []
|
||||
for prefix, values in context.items():
|
||||
if not isinstance(values, dict):
|
||||
continue
|
||||
for k, v in values.items():
|
||||
resolved = resolved.replace(f"${prefix}.{k}", self._to_str(v))
|
||||
var_name = f"${prefix}.{k}"
|
||||
replacements.append((var_name, self._to_str(v)))
|
||||
replacements.sort(key=lambda x: len(x[0]), reverse=True)
|
||||
|
||||
for var_name, replacement in replacements:
|
||||
# 用 re.escape 转义变量名中的特殊字符(如 $ 和 .)
|
||||
pattern = re.escape(var_name)
|
||||
resolved = re.sub(pattern, replacement, resolved)
|
||||
|
||||
# 解析 ${condition} ? a : b
|
||||
resolved = self._resolve_conditionals(resolved)
|
||||
@ -285,7 +363,11 @@ class PricingEngine:
|
||||
def _build_formula_detail(self, rule, normalized: dict, base_cost: float, surcharges: list) -> str:
|
||||
"""构建人类可读的计算过程文本。
|
||||
|
||||
输出格式示例:2m x 1.2m x ¥50/㎡ = ¥120.00 -> + 加急费 ¥20.00 = ¥140.00
|
||||
根据定价类型自动生成对应的计算说明:
|
||||
- area: 2m x 1.2m x ¥50/㎡ = ¥120.00
|
||||
- kg: 5kg x ¥8/kg = ¥40.00
|
||||
- unit/件: 10件 x ¥25/件 = ¥250.00
|
||||
- linear_m: 20m x ¥15/m = ¥300.00
|
||||
|
||||
Args:
|
||||
rule: 定价规则 ORM 对象
|
||||
@ -296,10 +378,30 @@ class PricingEngine:
|
||||
Returns:
|
||||
公式说明字符串
|
||||
"""
|
||||
pricing_type = getattr(rule, "pricing_type", "area") or "area"
|
||||
unit_price = float(rule.base_unit_price or 0)
|
||||
parts = []
|
||||
length = normalized.get("length_m", 0)
|
||||
width = normalized.get("width_m", 0)
|
||||
parts.append(f"{length}m × {width}m × ¥{rule.base_unit_price}/㎡ = ¥{base_cost:.2f}")
|
||||
|
||||
if pricing_type == "area":
|
||||
length = normalized.get("length_m", 0)
|
||||
width = normalized.get("width_m", 0)
|
||||
parts.append(f"{length}m × {width}m × ¥{unit_price}/㎡ = ¥{base_cost:.2f}")
|
||||
elif pricing_type in ("kg", "weight_g"):
|
||||
weight = normalized.get("weight_kg", normalized.get("weight_kg", 0))
|
||||
if pricing_type == "weight_g":
|
||||
weight_g = weight * 1000 if weight else normalized.get("weight", 0)
|
||||
parts.append(f"{weight_g}g × ¥{unit_price}/g = ¥{base_cost:.2f}")
|
||||
else:
|
||||
parts.append(f"{weight}kg × ¥{unit_price}/kg = ¥{base_cost:.2f}")
|
||||
elif pricing_type == "linear_m":
|
||||
length = normalized.get("length_m", normalized.get("length", 0))
|
||||
parts.append(f"{length}m × ¥{unit_price}/m = ¥{base_cost:.2f}")
|
||||
elif pricing_type in ("unit", "件"):
|
||||
qty = normalized.get("quantity", normalized.get("qty", 0))
|
||||
parts.append(f"{qty}件 × ¥{unit_price}/件 = ¥{base_cost:.2f}")
|
||||
else:
|
||||
# 通用:显示公式结果
|
||||
parts.append(f"计算结果 = ¥{base_cost:.2f}")
|
||||
|
||||
for s in surcharges:
|
||||
parts.append(f"+ {s['name']} ¥{s['amount']:.2f}")
|
||||
@ -307,7 +409,6 @@ class PricingEngine:
|
||||
total = base_cost + sum(s["amount"] for s in surcharges)
|
||||
parts.append(f"= ¥{total:.2f}")
|
||||
|
||||
# 税说明
|
||||
tax_rate = float(getattr(rule, "tax_rate", 0) or 0)
|
||||
if tax_rate > 0:
|
||||
tax_inclusive = int(getattr(rule, "tax_inclusive", 0) or 0)
|
||||
|
||||
@ -135,6 +135,8 @@ Page({
|
||||
for (var i = 0; i < rawItems.length; i++) {
|
||||
var ci = rawItems[i];
|
||||
if (ci.product_name) {
|
||||
// 计价方式中文映射
|
||||
var pricingTypeMap = { area: "按面积", kg: "按重量", unit: "按件", 件: "按件", linear_m: "按米", weight_g: "按克重" };
|
||||
products.push({
|
||||
name: ci.product_name,
|
||||
spec: ci.specification || "",
|
||||
@ -143,6 +145,10 @@ Page({
|
||||
unit: ci.unit || "",
|
||||
costPrice: fmt(ci.cost_price),
|
||||
costAmount: fmt(Number(ci.quantity || 0) * Number(ci.cost_price || 0)),
|
||||
pricingType: pricingTypeMap[ci.pricing_type] || "",
|
||||
areaSqm: ci.area_sqm ? fmt(ci.area_sqm) + "㎡" : "",
|
||||
supplierModel: ci.supplier_model || "",
|
||||
priceTier: ci.price_tier || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -318,23 +324,7 @@ Page({
|
||||
url: targetUrl, method: "POST",
|
||||
data: payload,
|
||||
}).then(function () {
|
||||
// approve pass + driver_id: create logistics task (mirrors web flow)
|
||||
if (payload.approve_result === "pass" && payload.driver_id) {
|
||||
var orderData = that.data.orderInfo;
|
||||
return app.request({
|
||||
url: "/api/logistics/tasks",
|
||||
method: "POST",
|
||||
data: {
|
||||
order_id: orderData.order_id,
|
||||
driver_id: Number(payload.driver_id),
|
||||
pickup_address: orderData.factory_name || "",
|
||||
delivery_address: orderData.customer_address || "",
|
||||
pickup_content: orderData.customer_name ? orderData.customer_name + " 订单货物" : "",
|
||||
quantity: 1,
|
||||
factory_id: payload.factory_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
// 物流任务由后端审批接口统一创建,前端不再重复创建
|
||||
}).then(function () {
|
||||
if (payload.approve_result === "pass") {
|
||||
// 从原始数据直接构建采购信息,避免 this.data 时序问题
|
||||
|
||||
@ -88,6 +88,9 @@
|
||||
<text class="product-spec" wx:if="{{item.spec}}">{{item.spec}}</text>
|
||||
<text class="product-spec" wx:if="{{item.demandSpec}}">需求: {{item.demandSpec}}</text>
|
||||
<text class="product-spec" wx:if="{{item.qty}}">{{item.qty}} {{item.unit}}</text>
|
||||
<text class="product-spec" wx:if="{{item.pricingType}}">计价: {{item.pricingType}}</text>
|
||||
<text class="product-spec" wx:if="{{item.areaSqm}}">面积: {{item.areaSqm}}</text>
|
||||
<text class="product-spec" wx:if="{{item.supplierModel}}">型号: {{item.supplierModel}}</text>
|
||||
</view>
|
||||
<view class="product-prices">
|
||||
<text class="product-price">单价: ¥{{item.costPrice}}</text>
|
||||
@ -120,12 +123,12 @@
|
||||
<view wx:if="{{approveLogs.length}}" class="card log-card">
|
||||
<view class="section-title">审批记录</view>
|
||||
<view wx:for="{{approveLogs}}" wx:key="log_id" class="log-row">
|
||||
<view class="log-dot {{item.approve_result === 'pass' ? 'dot-pass' : 'dot-reject'}}"></view>
|
||||
<view class="log-dot {{item.approve_result === 'pass' || item.approve_result === 'revised' ? 'dot-pass' : 'dot-reject'}}"></view>
|
||||
<view class="log-content">
|
||||
<view class="log-header">
|
||||
<text class="log-time">{{item.approve_time}}</text>
|
||||
<text class="log-type">{{item.approve_type === 'order' ? '订单审批' : '取消审批'}}</text>
|
||||
<text class="log-result {{item.approve_result === 'pass' ? 'result-pass' : 'result-reject'}}">{{item.approve_result === 'pass' ? '通过' : '驳回'}}</text>
|
||||
<text class="log-type">{{item.approve_type === 'order' ? '订单审批' : (item.approve_type === 'revise' ? '订单修订' : '取消审批')}}</text>
|
||||
<text class="log-result {{item.approve_result === 'pass' || item.approve_result === 'revised' ? 'result-pass' : 'result-reject'}}">{{item.approve_result === 'pass' ? '通过' : (item.approve_result === 'revised' ? '已修订' : '驳回')}}</text>
|
||||
</view>
|
||||
<text wx:if="{{item.approve_opinion}}" class="log-opinion">{{item.approve_opinion}}</text>
|
||||
</view>
|
||||
|
||||
@ -208,6 +208,7 @@ function mapApproveType(type) {
|
||||
const typeMap = {
|
||||
order: "订单审批",
|
||||
cancel_order: "取消审批",
|
||||
revise: "订单修订",
|
||||
};
|
||||
return typeMap[type] || type || "-";
|
||||
}
|
||||
@ -222,6 +223,7 @@ function mapApproveResult(result) {
|
||||
pass: "通过",
|
||||
reject: "驳回",
|
||||
refuse: "驳回",
|
||||
revised: "已修订",
|
||||
};
|
||||
return resultMap[result] || result || "-";
|
||||
}
|
||||
@ -1140,6 +1142,19 @@ export async function updateOrder(orderId, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员修订订单(修改商品后自动重算成本)
|
||||
* @param {number} orderId - 订单 ID
|
||||
* @param {Object} payload - { items: [...], revision_reason?: string, ... }
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function reviseOrder(orderId, payload) {
|
||||
return request(`/api/orders/${orderId}/revise`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改自发订单快递单号
|
||||
* @param {number} orderId - 订单 ID
|
||||
@ -1434,6 +1449,7 @@ export async function fetchApprovalDetail(orderId) {
|
||||
approveOpinion: item.approve_opinion || "-",
|
||||
})),
|
||||
calculationDetail: data.calculation_detail || null,
|
||||
revisionInfo: data.revision_info || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -39,20 +39,41 @@
|
||||
<th>产品名称</th>
|
||||
<th>规格</th>
|
||||
<th>数量</th>
|
||||
<th>计价方式</th>
|
||||
<th>成本单价</th>
|
||||
<th>成本小计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.id">
|
||||
<tr v-for="(item, idx) in items" :key="item.id">
|
||||
<td>{{ item.productName }}</td>
|
||||
<td>{{ item.specification || '-' }}</td>
|
||||
<td>{{ item.quantity }}{{ item.unit }}</td>
|
||||
<td>{{ getPricingTypeLabel(item, idx) }}</td>
|
||||
<td>{{ item.costPrice }}</td>
|
||||
<td>{{ getItemCostTotal(item) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-box">暂无产品明细</div>
|
||||
</div>
|
||||
<!-- 单项计算明细 -->
|
||||
<div v-if="calcDetail && calcDetail.item_details && calcDetail.item_details.length" class="item-calc-details">
|
||||
<h4>计算明细</h4>
|
||||
<div v-for="(detail, idx) in calcDetail.item_details" :key="idx" class="item-calc-row">
|
||||
<p class="item-calc-name">{{ detail.product_name }}</p>
|
||||
<p v-if="detail.pricing_type" class="item-calc-type">计价方式:{{ getDetailPricingTypeLabel(detail.pricing_type) }}</p>
|
||||
<p v-if="detail.formula_note" class="item-calc-formula">公式说明:{{ detail.formula_note }}</p>
|
||||
<p v-if="detail.formula_expr" class="item-calc-expr">公式:{{ detail.formula_expr }}</p>
|
||||
<p v-if="detail.area_sqm" class="item-calc-area">面积:{{ detail.area_sqm }}㎡</p>
|
||||
<p v-if="detail.surcharge_items && detail.surcharge_items.length" class="item-calc-surcharge">
|
||||
附加费:
|
||||
<span v-for="(s, si) in detail.surcharge_items" :key="si">{{ s.name }} ¥{{ s.amount }}{{ si < detail.surcharge_items.length - 1 ? ',' : '' }}</span>
|
||||
</p>
|
||||
<p v-if="detail.supplier_model" class="item-calc-model">供应商型号:{{ detail.supplier_model }}</p>
|
||||
<p v-if="detail.price_tier" class="item-calc-tier">价格档位:{{ detail.price_tier }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@ -89,6 +110,32 @@
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- 修订记录 -->
|
||||
<article v-if="revisionInfo" class="card revision-card">
|
||||
<h3>修订记录</h3>
|
||||
<p class="revision-time">{{ revisionInfo.revision_time }} — {{ revisionInfo.revision_remark }}</p>
|
||||
<div v-if="revisionInfo.before && revisionInfo.after" class="revision-diff">
|
||||
<div class="diff-row">
|
||||
<span class="diff-label">成本总额</span>
|
||||
<span class="diff-before">¥{{ revisionInfo.before.order?.cost_price_total }}</span>
|
||||
<span class="diff-arrow">→</span>
|
||||
<span class="diff-after">¥{{ revisionInfo.after.order?.cost_price_total }}</span>
|
||||
</div>
|
||||
<div class="diff-row">
|
||||
<span class="diff-label">利润总额</span>
|
||||
<span class="diff-before">¥{{ revisionInfo.before.order?.profit_total }}</span>
|
||||
<span class="diff-arrow">→</span>
|
||||
<span class="diff-after">¥{{ revisionInfo.after.order?.profit_total }}</span>
|
||||
</div>
|
||||
<div class="diff-row">
|
||||
<span class="diff-label">利润率</span>
|
||||
<span class="diff-before">{{ revisionInfo.before.order?.profit_rate }}%</span>
|
||||
<span class="diff-arrow">→</span>
|
||||
<span class="diff-after">{{ revisionInfo.after.order?.profit_rate }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card action-card">
|
||||
<h3>审批操作</h3>
|
||||
<p class="action-hint">{{ actionHint }}</p>
|
||||
@ -138,6 +185,7 @@ const loading = ref(true);
|
||||
const orderInfo = ref(null);
|
||||
const items = ref([]);
|
||||
const calcDetail = ref(null);
|
||||
const revisionInfo = ref(null);
|
||||
const approveOpinion = ref("");
|
||||
const actionLoading = ref("");
|
||||
const isModal = computed(() => route.query.modal === "1");
|
||||
@ -173,6 +221,7 @@ async function loadDetail() {
|
||||
orderInfo.value = data.orderInfo;
|
||||
items.value = data.items || [];
|
||||
calcDetail.value = data.calculationDetail || null;
|
||||
revisionInfo.value = data.revisionInfo || null;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
@ -194,6 +243,34 @@ function buildCopyText() {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const PRICING_TYPE_LABELS = {
|
||||
area: "按面积",
|
||||
kg: "按重量",
|
||||
weight_g: "按克重",
|
||||
unit: "按件",
|
||||
件: "按件",
|
||||
linear_m: "按长度",
|
||||
};
|
||||
|
||||
function getPricingTypeLabel(item, idx) {
|
||||
// 优先从 calcDetail.item_details 中获取
|
||||
if (calcDetail.value && calcDetail.value.item_details && calcDetail.value.item_details[idx]) {
|
||||
const pt = calcDetail.value.item_details[idx].pricing_type;
|
||||
if (pt) return PRICING_TYPE_LABELS[pt] || pt;
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
function getDetailPricingTypeLabel(pricingType) {
|
||||
return PRICING_TYPE_LABELS[pricingType] || pricingType;
|
||||
}
|
||||
|
||||
function getItemCostTotal(item) {
|
||||
const qty = Number(item.quantity) || 0;
|
||||
const cost = Number(item.costPrice) || 0;
|
||||
return (qty * cost).toFixed(2);
|
||||
}
|
||||
|
||||
async function handleApprove(result) {
|
||||
if (!orderInfo.value) return;
|
||||
actionLoading.value = result;
|
||||
@ -203,7 +280,7 @@ async function handleApprove(result) {
|
||||
approve_opinion: approveOpinion.value || (result === "pass" ? "审批通过" : "审批驳回"),
|
||||
};
|
||||
|
||||
if (orderInfo.value.orderStatus === "cancel_pending") {
|
||||
if (orderInfo.value.orderStatus === "cancel_pending" || orderInfo.value.orderStatus === "cancel_fulfillment_pending") {
|
||||
await cancelApproveOrder(orderInfo.value.orderId, payload);
|
||||
toast.success(result === "pass" ? "取消审批已通过" : "已驳回取消申请");
|
||||
} else {
|
||||
@ -300,6 +377,24 @@ td { color: #374151; }
|
||||
.calc-result { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e5e7eb; }
|
||||
.tax-note { color: #9ca3af; font-size: 12px; margin-left: 16px; }
|
||||
|
||||
/* 修订记录 */
|
||||
.revision-card { background: #fef3c7; border-color: #f59e0b; }
|
||||
.revision-time { margin: 0 0 12px; color: #92400e; font-size: 13px; }
|
||||
.revision-diff { display: flex; flex-direction: column; gap: 8px; }
|
||||
.diff-row { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.diff-label { color: #6b7280; min-width: 60px; }
|
||||
.diff-before { color: #dc2626; text-decoration: line-through; }
|
||||
.diff-arrow { color: #9ca3af; }
|
||||
.diff-after { color: #16a34a; font-weight: 500; }
|
||||
|
||||
/* 单项计算明细 */
|
||||
.item-calc-details { margin-top: 16px; padding-top: 16px; border-top: 1px solid #e5e7eb; }
|
||||
.item-calc-details h4 { margin: 0 0 12px; font-size: 14px; color: #374151; }
|
||||
.item-calc-row { padding: 8px 12px; background: #f9fafb; border-radius: 8px; margin-bottom: 8px; }
|
||||
.item-calc-row:last-child { margin-bottom: 0; }
|
||||
.item-calc-name { margin: 0 0 4px; font-weight: 500; color: #374151; font-size: 13px; }
|
||||
.item-calc-row p { margin: 2px 0; color: #6b7280; font-size: 12px; }
|
||||
|
||||
/* 审批操作 */
|
||||
.action-card { background: #f9fafb; }
|
||||
.action-hint { margin: 0 0 16px; color: #6b7280; font-size: 13px; }
|
||||
|
||||
@ -163,7 +163,7 @@
|
||||
<span :class="['doc-status-badge', 'badge-' + detail.order.rawStatus]">{{ detail.order.statusText }}</span>
|
||||
<span v-if="detail.order.trackingNumber" class="doc-status-badge badge-self-delivery" title="马甸工厂自发">自发</span>
|
||||
</div>
|
||||
<div v-if="false" class="doc-header-right">
|
||||
<div class="doc-header-right">
|
||||
<button v-if="!isEditing && canEditOrder(detail.order.rawStatus)" class="primary-btn"
|
||||
@click="enterEditMode">编辑
|
||||
</button>
|
||||
@ -297,6 +297,10 @@
|
||||
<span v-if="!isEditing">{{ detail.order.remark }}</span>
|
||||
<textarea v-else v-model="editForm.remark" rows="2" placeholder="请输入备注"></textarea>
|
||||
</div>
|
||||
<div v-if="isEditing && !['draft', 'rejected'].includes(detail.order.rawStatus)" class="doc-field full-width">
|
||||
<label>修订原因</label>
|
||||
<textarea v-model="editForm.revision_reason" rows="2" placeholder="请输入修订原因(选填,系统会自动重算成本)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -429,9 +433,9 @@
|
||||
<div class="doc-timeline-content">
|
||||
<div class="doc-timeline-header">
|
||||
<strong>{{ log.approve_time }}</strong>
|
||||
<span class="doc-tag">{{ log.approve_type === 'order' ? '订单审批' : '取消审批' }}</span>
|
||||
<span :class="['doc-result-tag', log.approve_result === 'pass' ? 'result-pass' : 'result-reject']">{{
|
||||
log.approve_result === 'pass' ? '通过' : '驳回'
|
||||
<span class="doc-tag">{{ log.approve_type === 'order' ? '订单审批' : (log.approve_type === 'revise' ? '订单修订' : '取消审批') }}</span>
|
||||
<span :class="['doc-result-tag', log.approve_result === 'pass' || log.approve_result === 'revised' ? 'result-pass' : 'result-reject']">{{
|
||||
log.approve_result === 'pass' ? '通过' : (log.approve_result === 'revised' ? '已修订' : '驳回')
|
||||
}}</span>
|
||||
</div>
|
||||
<p v-if="log.approve_opinion" class="doc-timeline-opinion">{{ log.approve_opinion }}</p>
|
||||
@ -900,6 +904,7 @@ import {
|
||||
cancelOrderById,
|
||||
fetchOrderDetailAdmin,
|
||||
updateOrder,
|
||||
reviseOrder,
|
||||
submitOrderToReview,
|
||||
changeOrderStatus,
|
||||
settleOrder,
|
||||
@ -1070,6 +1075,7 @@ const editForm = reactive({
|
||||
commission_amount: 0,
|
||||
payment_method: '',
|
||||
remark: '',
|
||||
revision_reason: '',
|
||||
});
|
||||
const editItems = ref([]);
|
||||
const productOptions = ref([]);
|
||||
@ -1107,7 +1113,9 @@ function handleProductChange(item, event) {
|
||||
}
|
||||
|
||||
function canEditOrder(status) {
|
||||
return ['draft', 'rejected', 'pending_approve'].includes(status);
|
||||
// 管理员可修订:草稿/已退回用 updateOrder,其他非终态用 reviseOrder
|
||||
const terminalStatuses = ['canceled', 'settled'];
|
||||
return !terminalStatuses.includes(status);
|
||||
}
|
||||
|
||||
function enterEditMode() {
|
||||
@ -1128,6 +1136,7 @@ function enterEditMode() {
|
||||
editForm.payment_method = detail.order.paymentMethod === '-' ? '' : detail.order.paymentMethod;
|
||||
editForm.remark = detail.order.remark === '-' ? '' : detail.order.remark;
|
||||
editForm.customer_demand = detail.order.customerDemand || '';
|
||||
editForm.revision_reason = '';
|
||||
// 复制商品明细
|
||||
editItems.value = (detail.items || []).map(item => ({...item}));
|
||||
}
|
||||
@ -1179,33 +1188,55 @@ async function saveEdit() {
|
||||
|
||||
editSaving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
customer_name: editForm.customer_name.trim(),
|
||||
customer_mobile: editForm.customer_mobile.trim(),
|
||||
customer_address: editForm.customer_address.trim() || null,
|
||||
order_source: editForm.order_source.trim() || null,
|
||||
delivery_type: editForm.delivery_type.trim() || null,
|
||||
contract_amount: Number(editForm.contract_amount) || 0,
|
||||
rebate_total: Number(editForm.rebate_total) || 0,
|
||||
freight_total: Number(editForm.freight_total) || 0,
|
||||
tax_total: Number(editForm.tax_total) || 0,
|
||||
other_fee_total: Number(editForm.other_fee_total) || 0,
|
||||
commission_amount: Number(editForm.commission_amount) || 0,
|
||||
payment_method: editForm.payment_method.trim() || null,
|
||||
remark: editForm.remark.trim() || null,
|
||||
customer_demand: editForm.customer_demand.trim() || null,
|
||||
items: editItems.value.map(item => ({
|
||||
product_id: item.product_id || null,
|
||||
product_name: item.product_name.trim(),
|
||||
specification: item.specification?.trim() || '',
|
||||
unit: item.unit?.trim() || '',
|
||||
quantity: Number(item.quantity) || 1,
|
||||
sale_price: Number(item.sale_price) || 0,
|
||||
cost_price: Number(item.cost_price) || 0,
|
||||
})),
|
||||
};
|
||||
await updateOrder(detail.orderId, payload);
|
||||
toast.success('订单已更新');
|
||||
const itemsPayload = editItems.value.map(item => ({
|
||||
product_id: item.product_id || null,
|
||||
product_name: item.product_name.trim(),
|
||||
specification: item.specification?.trim() || '',
|
||||
unit: item.unit?.trim() || '',
|
||||
quantity: Number(item.quantity) || 1,
|
||||
sale_price: Number(item.sale_price) || 0,
|
||||
cost_price: Number(item.cost_price) || 0,
|
||||
}));
|
||||
|
||||
const rawStatus = detail.order.rawStatus;
|
||||
const isDraftOrRejected = ['draft', 'rejected'].includes(rawStatus);
|
||||
|
||||
if (isDraftOrRejected) {
|
||||
// 草稿/已退回:使用普通更新接口
|
||||
const payload = {
|
||||
customer_name: editForm.customer_name.trim(),
|
||||
customer_mobile: editForm.customer_mobile.trim(),
|
||||
customer_address: editForm.customer_address.trim() || null,
|
||||
order_source: editForm.order_source.trim() || null,
|
||||
delivery_type: editForm.delivery_type.trim() || null,
|
||||
contract_amount: Number(editForm.contract_amount) || 0,
|
||||
rebate_total: Number(editForm.rebate_total) || 0,
|
||||
freight_total: Number(editForm.freight_total) || 0,
|
||||
tax_total: Number(editForm.tax_total) || 0,
|
||||
other_fee_total: Number(editForm.other_fee_total) || 0,
|
||||
commission_amount: Number(editForm.commission_amount) || 0,
|
||||
payment_method: editForm.payment_method.trim() || null,
|
||||
remark: editForm.remark.trim() || null,
|
||||
customer_demand: editForm.customer_demand.trim() || null,
|
||||
items: itemsPayload,
|
||||
};
|
||||
await updateOrder(detail.orderId, payload);
|
||||
toast.success('订单已更新');
|
||||
} else {
|
||||
// 已提交/已审批等状态:使用修订接口(自动重算成本)
|
||||
const payload = {
|
||||
items: itemsPayload,
|
||||
revision_reason: editForm.revision_reason?.trim() || '管理员修改',
|
||||
contract_amount: Number(editForm.contract_amount) || undefined,
|
||||
rebate_total: Number(editForm.rebate_total) || 0,
|
||||
freight_total: Number(editForm.freight_total) || 0,
|
||||
tax_total: Number(editForm.tax_total) || 0,
|
||||
other_fee_total: Number(editForm.other_fee_total) || 0,
|
||||
};
|
||||
await reviseOrder(detail.orderId, payload);
|
||||
toast.success('订单已修订,成本已自动重算');
|
||||
}
|
||||
|
||||
isEditing.value = false;
|
||||
await loadDetail();
|
||||
await handleSearch();
|
||||
@ -1651,7 +1682,7 @@ async function handleApprovalPass() {
|
||||
detailActionBusy.value = false;
|
||||
}
|
||||
} else {
|
||||
// 非自发订单:需要选择工厂和司机
|
||||
// 非自发订单:调用审批接口,后端统一处理工厂分配和物流任务创建
|
||||
if (!selectedFactoryId.value) {
|
||||
toast.error('请选择下发工厂');
|
||||
return;
|
||||
@ -1662,20 +1693,11 @@ async function handleApprovalPass() {
|
||||
}
|
||||
detailActionBusy.value = true;
|
||||
try {
|
||||
// 先选择工厂并进入待工厂状态
|
||||
await changeOrderStatus(detail.orderId, {
|
||||
target_status: 'pending_factory',
|
||||
await approveOrder(detail.orderId, {
|
||||
approve_result: 'pass',
|
||||
approve_opinion: '审批通过',
|
||||
factory_id: selectedFactoryId.value,
|
||||
});
|
||||
// 再分配司机并创建物流任务
|
||||
await createLogisticsTask({
|
||||
order_id: detail.orderId,
|
||||
driver_id: Number(selectedDriverId.value),
|
||||
pickup_address: getFactoryAddress(selectedFactoryId.value) || detail.order.factoryName || '',
|
||||
delivery_address: detail.order.customerAddress || '',
|
||||
pickup_content: detail.order.customerName ? `${detail.order.customerName} 订单货物` : '',
|
||||
quantity: 1,
|
||||
factory_id: selectedFactoryId.value,
|
||||
});
|
||||
toast.success('审批通过,已下发工厂并分配司机');
|
||||
await loadDetail();
|
||||
@ -1706,7 +1728,8 @@ async function handleApprovalReject() {
|
||||
// 驳回取消申请 - 使用 cancel-approve 接口
|
||||
await cancelApproveOrder(detail.orderId, {approve_result: 'refuse', approve_opinion: opinion || '驳回取消'});
|
||||
} else {
|
||||
await changeOrderStatus(detail.orderId, {target_status: 'rejected', approve_opinion: opinion || '退回'});
|
||||
// 使用审批接口退回,确保审批日志正确记录
|
||||
await approveOrder(detail.orderId, {approve_result: 'reject', approve_opinion: opinion || '退回'});
|
||||
}
|
||||
toast.success(isCancelFlow ? '已驳回取消' : '已退回');
|
||||
await loadDetail();
|
||||
|
||||
@ -214,6 +214,8 @@
|
||||
<option value="area">按面积(㎡)</option>
|
||||
<option value="kg">按公斤(kg)</option>
|
||||
<option value="unit">按件</option>
|
||||
<option value="linear_m">按米(m)</option>
|
||||
<option value="weight_g">按克重(g)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
@ -513,10 +515,12 @@ const PRICING_PRESETS = {
|
||||
area: { unit: "㎡", formula: "round($input.length_m * $input.width_m * $rule.base_unit_price, 2)", inputs: '[{"key":"length","type":"number","default_unit":"m"},{"key":"width","type":"number","default_unit":"m"}]', note: "长×宽×单价" },
|
||||
kg: { unit: "kg", formula: "round($input.length_m * $input.width_m * $product.weight_gsm / 1000 * $rule.base_unit_price, 2)", inputs: '[{"key":"length","type":"number","default_unit":"m"},{"key":"width","type":"number","default_unit":"m"}]', note: "长×宽×克重÷1000×单价" },
|
||||
unit: { unit: "张", formula: "$input.quantity * $rule.base_unit_price", inputs: '[{"key":"quantity","type":"number","default_unit":"张"}]', note: "数量×单价" },
|
||||
linear_m: { unit: "m", formula: "round($input.length_m * $rule.base_unit_price, 2)", inputs: '[{"key":"length","type":"number","default_unit":"m","unit_type":"length"}]', note: "长度×单价" },
|
||||
weight_g: { unit: "g", formula: "round($input.weight_kg * $rule.base_unit_price, 2)", inputs: '[{"key":"weight","type":"number","default_unit":"g","unit_type":"weight"}]', note: "重量×单价" },
|
||||
};
|
||||
|
||||
/** 计价单位输入提示 */
|
||||
const pricingUnitHint = computed(() => ({ area: "如 28 表示 28 元/㎡", kg: "如 58 表示 58 元/kg", unit: "如 10 表示 10 元/张" })[editingRule.pricing_type] || "填写单价");
|
||||
const pricingUnitHint = computed(() => ({ area: "如 28 表示 28 元/㎡", kg: "如 58 表示 58 元/kg", unit: "如 10 表示 10 元/张", linear_m: "如 15 表示 15 元/米", weight_g: "如 0.08 表示 0.08 元/克" })[editingRule.pricing_type] || "填写单价");
|
||||
|
||||
/** 公式说明默认提示 */
|
||||
const formulaNoteHint = computed(() => PRICING_PRESETS[editingRule.pricing_type]?.note || "如 长×宽×单价");
|
||||
|
||||
@ -89,7 +89,7 @@ function mapOrderStatus(status) {
|
||||
* @returns {string} 中文类型名称
|
||||
*/
|
||||
function mapApproveType(type) {
|
||||
const typeMap = { order: "订单审批", cancel_order: "取消审批" };
|
||||
const typeMap = { order: "订单审批", cancel_order: "取消审批", revise: "订单修订" };
|
||||
return typeMap[type] || type || "-";
|
||||
}
|
||||
|
||||
@ -99,7 +99,7 @@ function mapApproveType(type) {
|
||||
* @returns {string} 中文结果名称
|
||||
*/
|
||||
function mapApproveResult(result) {
|
||||
const resultMap = { pass: "通过", reject: "驳回", refuse: "驳回" };
|
||||
const resultMap = { pass: "通过", reject: "驳回", refuse: "驳回", revised: "已修订" };
|
||||
return resultMap[result] || result || "-";
|
||||
}
|
||||
|
||||
@ -293,6 +293,32 @@ export async function fetchConfigItem(configKey) {
|
||||
return request(`/api/configs/${configKey}`);
|
||||
}
|
||||
|
||||
// ========== 定价预估 ==========
|
||||
|
||||
/**
|
||||
* 调用定价引擎预估成本(不创建订单)
|
||||
* @param {Object} payload - { product_id, quantity, length_m?, width_m?, weight_g?, surcharge_*, price_tier? }
|
||||
* @returns {Promise<Object>} 定价结果(cost_price, formula_detail 等)
|
||||
*/
|
||||
export async function calculateQuotation(payload) {
|
||||
return request("/api/quotation/calculate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 辅助定价建议(规则缺失时的兜底方案)
|
||||
* @param {Object} payload - { product_id, quantity, length_m?, width_m?, weight_g? }
|
||||
* @returns {Promise<Object>} { suggested_cost, ai_reason, ai_confidence, needs_manual_confirmation }
|
||||
*/
|
||||
export async function aiSuggestPricing(payload) {
|
||||
return request("/api/quotation/ai-suggest", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 订单管理 ==========
|
||||
|
||||
/**
|
||||
@ -363,6 +389,10 @@ export async function fetchOrderDetail(orderId) {
|
||||
unit: item.unit || "-",
|
||||
quantity: Number(item.quantity || 0).toFixed(2),
|
||||
salePrice: Number(item.sale_price || 0).toFixed(2),
|
||||
costPrice: Number(item.cost_price || 0).toFixed(2),
|
||||
pricingType: item.pricing_type || "",
|
||||
areaSqm: item.area_sqm ? Number(item.area_sqm).toFixed(4) : "",
|
||||
supplierModel: item.supplier_model || "",
|
||||
})),
|
||||
approveLogs: (data.approve_logs || []).map((item) => ({
|
||||
id: item.log_id,
|
||||
|
||||
@ -139,18 +139,18 @@
|
||||
<tr>
|
||||
<th>产品名称</th>
|
||||
<th>规格</th>
|
||||
<th>需求规格</th>
|
||||
<th>单位</th>
|
||||
<th>数量</th>
|
||||
<th>计价方式</th>
|
||||
<th>成本单价</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in orderItems" :key="item.id">
|
||||
<td>{{ item.productName }}</td>
|
||||
<td>{{ item.specification }}</td>
|
||||
<td>{{ item.demandSpecification || "-" }}</td>
|
||||
<td>{{ item.unit }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td>{{ item.quantity }}{{ item.unit }}</td>
|
||||
<td>{{ getPricingTypeLabel(item.pricingType) }}</td>
|
||||
<td>¥{{ item.costPrice }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -295,6 +295,9 @@ const selfTrackingForm = reactive({ tracking_number: "", remark: "" });
|
||||
const traceList = ref([]);
|
||||
let unsubscribeReminder = null;
|
||||
|
||||
const PRICING_TYPE_LABELS = { area: "按面积", kg: "按重量", weight_g: "按克重", unit: "按件", 件: "按件", linear_m: "按长度" };
|
||||
function getPricingTypeLabel(type) { return PRICING_TYPE_LABELS[type] || type || "-"; }
|
||||
|
||||
function canSubmit(status) {
|
||||
return ["draft", "rejected"].includes(status);
|
||||
}
|
||||
|
||||
@ -225,6 +225,29 @@
|
||||
<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.length_m" type="number" min="0" step="0.01" placeholder="选填" />
|
||||
</label>
|
||||
<label>
|
||||
<span>宽度(米)</span>
|
||||
<input v-model.number="row.width_m" type="number" min="0" step="0.01" placeholder="选填" />
|
||||
</label>
|
||||
<label>
|
||||
<span>克重(g)</span>
|
||||
<input v-model.number="row.weight_g" type="number" min="0" step="1" placeholder="选填" />
|
||||
</label>
|
||||
<!-- 定价预览 -->
|
||||
<div v-if="row.product_id" class="pricing-preview-row">
|
||||
<button type="button" class="ghost-btn small-btn" :disabled="row._pricingLoading" @click="handlePricingPreview(row)">
|
||||
{{ row._pricingLoading ? '计算中...' : '预估成本' }}
|
||||
</button>
|
||||
<div v-if="row._pricingPreview" class="pricing-preview-result">
|
||||
<span class="preview-cost">预估成本: ¥{{ row._pricingPreview.cost_price }}</span>
|
||||
<span v-if="row._pricingPreview.formula_detail" class="preview-formula">{{ row._pricingPreview.formula_detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -316,7 +339,7 @@
|
||||
|
||||
<section class="actions">
|
||||
<button type="button" class="secondary-btn" @click="resetForm">重置表单</button>
|
||||
<button type="submit" class="primary-btn" :disabled="submitting || optionLoading" @click="handleSubmit">
|
||||
<button type="submit" class="primary-btn" :disabled="submitting || optionLoading">
|
||||
{{ submitting ? (isEditMode ? "更新中..." : "创建中...") : (isEditMode ? "更新订单" : "创建订单") }}
|
||||
</button>
|
||||
</section>
|
||||
@ -328,6 +351,8 @@ import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import {
|
||||
aiSuggestPricing,
|
||||
calculateQuotation,
|
||||
createOrder,
|
||||
fetchConfigItem,
|
||||
fetchCustomerOptions,
|
||||
@ -380,6 +405,14 @@ function buildDefaultItem() {
|
||||
sale_price: 0,
|
||||
cost_price: 0,
|
||||
remark: "",
|
||||
// 特殊计价字段
|
||||
length_m: null,
|
||||
width_m: null,
|
||||
weight_g: null,
|
||||
pricing_type: "",
|
||||
price_tier: "",
|
||||
_pricingPreview: null,
|
||||
_pricingLoading: false,
|
||||
_searchText: "",
|
||||
_showDropdown: false,
|
||||
_filteredProducts: [],
|
||||
@ -688,6 +721,7 @@ function removeAttachment(index) {
|
||||
|
||||
async function uploadAttachments(orderId) {
|
||||
for (const item of attachmentFiles.value) {
|
||||
if (item.existing) continue; // 跳过已有附件,不重复上传
|
||||
const uploadResult = await localUploadFile(item.file);
|
||||
await saveAttachment({
|
||||
biz_type: "sales_order",
|
||||
@ -807,6 +841,66 @@ function calcDemandResult(row) {
|
||||
row._demandCalcHint = "";
|
||||
}
|
||||
|
||||
async function handlePricingPreview(row) {
|
||||
if (!row.product_id) {
|
||||
row._pricingPreview = null;
|
||||
return;
|
||||
}
|
||||
row._pricingLoading = true;
|
||||
row._pricingPreview = null;
|
||||
try {
|
||||
const payload = {
|
||||
product_id: row.product_id,
|
||||
quantity: row.quantity || 1,
|
||||
};
|
||||
if (row.length_m) {
|
||||
payload.length = row.length_m;
|
||||
payload.length_unit = "m";
|
||||
}
|
||||
if (row.width_m) {
|
||||
payload.width = row.width_m;
|
||||
payload.width_unit = "m";
|
||||
}
|
||||
if (row.weight_g) {
|
||||
payload.weight = row.weight_g;
|
||||
payload.weight_unit = "g";
|
||||
}
|
||||
if (row.price_tier) {
|
||||
payload.price_tier = row.price_tier;
|
||||
}
|
||||
const result = await calculateQuotation(payload);
|
||||
row._pricingPreview = result;
|
||||
// 自动填充成本价
|
||||
if (result.cost_price != null) {
|
||||
row.cost_price = Number(result.cost_price);
|
||||
}
|
||||
} catch (e) {
|
||||
// 规则缺失时,尝试 AI 辅助建议
|
||||
try {
|
||||
const aiPayload = {
|
||||
product_id: row.product_id,
|
||||
quantity: row.quantity || 1,
|
||||
};
|
||||
if (row.length_m) aiPayload.length_m = row.length_m;
|
||||
if (row.width_m) aiPayload.width_m = row.width_m;
|
||||
if (row.weight_g) aiPayload.weight_g = row.weight_g;
|
||||
const aiResult = await aiSuggestPricing(aiPayload);
|
||||
row._pricingPreview = {
|
||||
cost_price: aiResult.suggested_cost || "无法估算",
|
||||
formula_detail: `🤖 AI建议: ${aiResult.ai_reason}(置信度 ${Math.round((aiResult.ai_confidence || 0) * 100)}%,需人工确认)`,
|
||||
ai_assisted: true,
|
||||
};
|
||||
if (aiResult.suggested_cost > 0) {
|
||||
row.cost_price = Number(aiResult.suggested_cost);
|
||||
}
|
||||
} catch {
|
||||
row._pricingPreview = { cost_price: "计算失败", formula_detail: e.message || "请检查产品是否配置了定价规则" };
|
||||
}
|
||||
} finally {
|
||||
row._pricingLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const paymentChannels = ref([]);
|
||||
|
||||
async function loadOptions() {
|
||||
@ -836,6 +930,7 @@ async function loadOptions() {
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (submitting.value) return; // 防止重复提交
|
||||
if (!form.customer_name.trim()) {
|
||||
message.value = "请输入客户姓名";
|
||||
messageType.value = "error";
|
||||
@ -886,7 +981,7 @@ async function handleSubmit() {
|
||||
remark: form.remark,
|
||||
auto_sync_customer: form.auto_sync_customer,
|
||||
customer_sync_mode: matchedCustomer ? 'linked' : form.auto_sync_customer ? 'auto_create' : 'manual_review',
|
||||
items: validItems.map(({ rowKey, selectedProductId, _searchText, _showDropdown, _filteredProducts, ...rest }) => rest),
|
||||
items: validItems.map(({ rowKey, selectedProductId, _searchText, _showDropdown, _filteredProducts, _demandResult, _demandCalcHint, _calcHint, _pricingPreview, _pricingLoading, ...rest }) => rest),
|
||||
};
|
||||
let result;
|
||||
if (isEditMode.value) {
|
||||
@ -1228,6 +1323,31 @@ select {
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.pricing-preview-row {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.small-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.pricing-preview-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.preview-cost {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
}
|
||||
.preview-formula {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user