6-30 更新价格计算方法

This commit is contained in:
wsb1224 2026-06-30 20:54:17 +08:00
parent a43ea0e027
commit ec9f710572
14 changed files with 929 additions and 133 deletions

View File

@ -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路径参数

View File

@ -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 响应)
# ======================================================================

View File

@ -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 路由使用。"""

View File

@ -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:
"""构建附件行数据字典。

View File

@ -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_costsurcharge_itemstotal_surcharge
cost_priceformula_detailtax 字段以及 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)

View File

@ -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 时序问题

View File

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

View File

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

View File

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

View File

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

View File

@ -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 || "如 长×宽×单价");

View File

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

View File

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

View File

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