6-30 更新价格计算方法
This commit is contained in:
parent
a43ea0e027
commit
ec9f710572
@ -22,6 +22,7 @@ from backend.app.schemas.orders import (
|
|||||||
ChangeOrderStatusRequest,
|
ChangeOrderStatusRequest,
|
||||||
ConfirmSupplierTextRequest,
|
ConfirmSupplierTextRequest,
|
||||||
CreateOrderRequest,
|
CreateOrderRequest,
|
||||||
|
ReviseOrderRequest,
|
||||||
SupplierTextRequest,
|
SupplierTextRequest,
|
||||||
UpdateOrderRequest,
|
UpdateOrderRequest,
|
||||||
UpdateOrderTrackingRequest,
|
UpdateOrderTrackingRequest,
|
||||||
@ -182,6 +183,29 @@ def update_order(
|
|||||||
return success_payload(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}")
|
@router.get("/{order_id}")
|
||||||
def get_order(
|
def get_order(
|
||||||
order_id: int, # 订单 ID(路径参数)
|
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 响应)
|
# 序列化工具(将 ORM 模型转换为字典,便于返回 JSON 响应)
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
|
|||||||
@ -32,6 +32,12 @@ class OrderListQuery(BaseModel):
|
|||||||
order_source: str | None = None
|
order_source: str | None = None
|
||||||
"""订单来源筛选"""
|
"""订单来源筛选"""
|
||||||
|
|
||||||
|
order_type: str | None = None
|
||||||
|
"""订单类型筛选(industry/daily)"""
|
||||||
|
|
||||||
|
need_invoice: int | None = None
|
||||||
|
"""是否需要发票筛选(0=否,1=是)"""
|
||||||
|
|
||||||
start_time: str | None = None
|
start_time: str | None = None
|
||||||
"""查询起始时间(ISO 格式)"""
|
"""查询起始时间(ISO 格式)"""
|
||||||
|
|
||||||
@ -264,7 +270,7 @@ class ApproveOrderRequest(BaseModel):
|
|||||||
"""审批订单请求体。被 POST /api/orders/{id}/approve 路由使用。"""
|
"""审批订单请求体。被 POST /api/orders/{id}/approve 路由使用。"""
|
||||||
|
|
||||||
approve_result: str
|
approve_result: str
|
||||||
"""审批结果(如 approved / rejected)"""
|
"""审批结果(pass=通过, reject=退回)"""
|
||||||
|
|
||||||
approve_opinion: str | None = None
|
approve_opinion: str | None = None
|
||||||
"""审批意见"""
|
"""审批意见"""
|
||||||
@ -273,13 +279,7 @@ class ApproveOrderRequest(BaseModel):
|
|||||||
"""下发工厂 ID(审批通过时可选,同时选择工厂和司机可跳过待工厂节点)"""
|
"""下发工厂 ID(审批通过时可选,同时选择工厂和司机可跳过待工厂节点)"""
|
||||||
|
|
||||||
driver_id: int | None = None
|
driver_id: int | None = None
|
||||||
"""司机 ID(审批通过时可选,同时选择工厂和司机可跳过待工厂节点)"""
|
"""分配司机 ID(审批通过时可选,同时选择工厂和司机可直接创建物流任务)"""
|
||||||
|
|
||||||
factory_id: int | None = None
|
|
||||||
"""下发工厂 ID(非自发订单审批通过时可选传入,审批后直接绑定工厂)"""
|
|
||||||
|
|
||||||
driver_id: int | None = None
|
|
||||||
"""分配司机 ID(非自发订单审批通过时可选传入,审批后直接创建物流任务)"""
|
|
||||||
|
|
||||||
|
|
||||||
class SupplierTextRequest(BaseModel):
|
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):
|
class UpdateOrderTrackingRequest(BaseModel):
|
||||||
"""修改自发订单快递单号请求体。被 PUT /api/orders/{id}/tracking 路由使用。"""
|
"""修改自发订单快递单号请求体。被 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.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.error_codes import ErrorCode
|
||||||
from backend.app.core.exceptions import AppException
|
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.customer_repository import CustomerRepository
|
||||||
from backend.app.repositories.config_repository import ConfigRepository
|
from backend.app.repositories.config_repository import ConfigRepository
|
||||||
from backend.app.repositories.file_repository import FileRepository
|
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.arrears_service import arrears_service
|
||||||
from backend.app.services.audit_service import audit_service
|
from backend.app.services.audit_service import audit_service
|
||||||
from backend.app.services.event_bus import event_bus
|
from backend.app.services.event_bus import event_bus
|
||||||
|
from backend.app.services.pricing_engine import pricing_engine
|
||||||
|
|
||||||
|
|
||||||
class OrderService:
|
class OrderService:
|
||||||
@ -53,6 +55,99 @@ class OrderService:
|
|||||||
self.supplier_repository = SupplierRepository()
|
self.supplier_repository = SupplierRepository()
|
||||||
self.file_repository = FileRepository()
|
self.file_repository = FileRepository()
|
||||||
self.reminder_repository = ReminderRepository()
|
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:
|
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 order_attachments],
|
||||||
*[self._build_attachment_row(item) for item in task_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")),
|
"profit_alert_threshold": float(self._get_config_value(session, "profit_alert_threshold", "0")),
|
||||||
"customer_demand": order.customer_demand,
|
"customer_demand": order.customer_demand,
|
||||||
"remark": order.remark,
|
"remark": order.remark,
|
||||||
|
"revision_info": self._get_latest_revision(session, order.id),
|
||||||
}
|
}
|
||||||
# 写缓存(缓存完整结果,角色过滤在读取后执行)
|
# 写缓存(缓存完整结果,角色过滤在读取后执行)
|
||||||
cache_set(cache_key, result, ttl=60)
|
cache_set(cache_key, result, ttl=60)
|
||||||
@ -361,6 +457,10 @@ class OrderService:
|
|||||||
items = payload["items"]
|
items = payload["items"]
|
||||||
if not items:
|
if not items:
|
||||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="订单明细不能为空", status_code=400)
|
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)
|
sale_total = sum(item["quantity"] * item["sale_price"] for item in items)
|
||||||
cost_total = sum(item["quantity"] * item["cost_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)
|
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(
|
def submit_order(
|
||||||
self,
|
self,
|
||||||
order_id: int,
|
order_id: int,
|
||||||
@ -656,35 +920,18 @@ class OrderService:
|
|||||||
else:
|
else:
|
||||||
target_status = "rejected"
|
target_status = "rejected"
|
||||||
# 如果有工厂ID,设置到订单上
|
# 如果有工厂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"]
|
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)
|
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"):
|
if payload["approve_result"] == "pass" and target_status == "pending_driver" and payload.get("driver_id"):
|
||||||
# 获取工厂地址作为取货地址
|
# 获取工厂地址作为取货地址
|
||||||
pickup_address = ""
|
pickup_address = ""
|
||||||
factory = self.supplier_repository.get_supplier(session, payload["factory_id"])
|
if payload.get("factory_id"):
|
||||||
if factory and factory.address:
|
factory = self.supplier_repository.get_supplier(session, payload["factory_id"])
|
||||||
pickup_address = factory.address
|
if factory and factory.address:
|
||||||
|
pickup_address = factory.address
|
||||||
|
|
||||||
task = self.logistics_repository.create_task(
|
task = self.logistics_repository.create_task(
|
||||||
session,
|
session,
|
||||||
@ -692,7 +939,7 @@ class OrderService:
|
|||||||
"task_no": f"LT{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
"task_no": f"LT{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||||
"order_id": order.id,
|
"order_id": order.id,
|
||||||
"driver_id": payload["driver_id"],
|
"driver_id": payload["driver_id"],
|
||||||
"factory_id": payload["factory_id"],
|
"factory_id": payload.get("factory_id"),
|
||||||
"pickup_address": pickup_address,
|
"pickup_address": pickup_address,
|
||||||
"delivery_address": "",
|
"delivery_address": "",
|
||||||
"pickup_content": "",
|
"pickup_content": "",
|
||||||
@ -1241,6 +1488,9 @@ class OrderService:
|
|||||||
if not items:
|
if not items:
|
||||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="订单明细不能为空", status_code=400)
|
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)
|
sale_total = sum(item["quantity"] * item["sale_price"] for item in items)
|
||||||
cost_total = sum(item["quantity"] * item["cost_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():
|
if approve_result == "refuse" and not (payload.get("approve_opinion") or "").strip():
|
||||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="取消审批驳回时必须填写意见", status_code=400)
|
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:
|
Args:
|
||||||
order: 订单 ORM 对象
|
order: 订单 ORM 对象
|
||||||
items: 订单明细 ORM 对象列表
|
items: 订单明细 ORM 对象列表
|
||||||
|
session: 数据库会话(可选,用于查询定价规则公式)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
包含计算公式、汇总金额和明细的字典
|
包含计算公式、汇总金额和明细的字典
|
||||||
@ -1739,6 +1990,21 @@ class OrderService:
|
|||||||
detail_row["surcharge_items"] = []
|
detail_row["surcharge_items"] = []
|
||||||
detail_row["supplier_model"] = getattr(item, "supplier_model", None)
|
detail_row["supplier_model"] = getattr(item, "supplier_model", None)
|
||||||
detail_row["price_tier"] = getattr(item, "price_tier", 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)
|
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 "",
|
"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:
|
def _build_attachment_row(self, attachment) -> dict:
|
||||||
"""构建附件行数据字典。
|
"""构建附件行数据字典。
|
||||||
|
|
||||||
|
|||||||
@ -30,24 +30,23 @@ class PricingEngine:
|
|||||||
product_attrs: 产品属性 {"thickness": 0.08} 用于阈值判断
|
product_attrs: 产品属性 {"thickness": 0.08} 用于阈值判断
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{
|
结构化计价结果字典,包含 base_cost、surcharge_items、total_surcharge、
|
||||||
"base_cost", "surcharge_items", "total_surcharge", "cost_price",
|
cost_price、formula_detail、tax 字段,以及 rule_snapshot 用于快照存储。
|
||||||
"formula_detail", "formula_detail_steps"
|
|
||||||
}
|
|
||||||
|
|
||||||
被调用路由: pricing.py - POST /pricing/calculate
|
被调用路由: pricing.py - POST /pricing/calculate
|
||||||
"""
|
"""
|
||||||
product_attrs = product_attrs or {}
|
product_attrs = product_attrs or {}
|
||||||
|
|
||||||
# 1. 单位换算 → 统一为米
|
# 1. 单位换算
|
||||||
normalized = self._normalize_inputs(rule, user_inputs)
|
normalized = self._normalize_inputs(rule, user_inputs)
|
||||||
area_sqm = normalized.get("length_m", 0) * normalized.get("width_m", 0)
|
area_sqm = normalized.get("length_m", 0) * normalized.get("width_m", 0)
|
||||||
|
|
||||||
# 2. 构建公式上下文并求值
|
# 2. 构建公式上下文并求值
|
||||||
|
formula_constants = self._safe_load_json(rule.formula_constants) or {}
|
||||||
context = {
|
context = {
|
||||||
"input": normalized,
|
"input": normalized,
|
||||||
"rule": {"base_unit_price": float(rule.base_unit_price)},
|
"rule": {"base_unit_price": float(rule.base_unit_price)},
|
||||||
"const": self._safe_load_json(rule.formula_constants) or {},
|
"const": formula_constants,
|
||||||
"product": product_attrs,
|
"product": product_attrs,
|
||||||
}
|
}
|
||||||
base_cost = self._evaluate_formula(rule.formula_expr or "0", context)
|
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)
|
tax_inclusive = int(getattr(rule, "tax_inclusive", 0) or 0)
|
||||||
if tax_rate > 0:
|
if tax_rate > 0:
|
||||||
if tax_inclusive:
|
if tax_inclusive:
|
||||||
# 含税定价:cost_price 已含税,反算不含税价
|
|
||||||
price_ex_tax = round(cost_price / (1 + tax_rate / 100), 2)
|
price_ex_tax = round(cost_price / (1 + tax_rate / 100), 2)
|
||||||
tax_amount = round(cost_price - price_ex_tax, 2)
|
tax_amount = round(cost_price - price_ex_tax, 2)
|
||||||
price_in_tax = cost_price
|
price_in_tax = cost_price
|
||||||
else:
|
else:
|
||||||
# 不含税定价:cost_price 为不含税价,正算含税价
|
|
||||||
price_ex_tax = cost_price
|
price_ex_tax = cost_price
|
||||||
tax_amount = round(cost_price * tax_rate / 100, 2)
|
tax_amount = round(cost_price * tax_rate / 100, 2)
|
||||||
price_in_tax = round(cost_price + tax_amount, 2)
|
price_in_tax = round(cost_price + tax_amount, 2)
|
||||||
@ -81,16 +78,70 @@ class PricingEngine:
|
|||||||
tax_amount = 0
|
tax_amount = 0
|
||||||
price_in_tax = cost_price
|
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 {
|
return {
|
||||||
"base_cost": round(base_cost, 2),
|
"base_cost": round(base_cost, 2),
|
||||||
"surcharge_items": surcharges,
|
"surcharge_items": surcharges,
|
||||||
"total_surcharge": total_surcharge,
|
"total_surcharge": total_surcharge,
|
||||||
"cost_price": cost_price,
|
"cost_price": cost_price,
|
||||||
"formula_detail": formula_detail,
|
"formula_detail": formula_detail,
|
||||||
|
"formula_variables": formula_variables,
|
||||||
"tax_rate": tax_rate,
|
"tax_rate": tax_rate,
|
||||||
|
"tax_inclusive": tax_inclusive,
|
||||||
"price_ex_tax": price_ex_tax,
|
"price_ex_tax": price_ex_tax,
|
||||||
"tax_amount": tax_amount,
|
"tax_amount": tax_amount,
|
||||||
"price_in_tax": price_in_tax,
|
"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]:
|
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_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:
|
def _normalize_inputs(self, rule, user_inputs: dict) -> dict:
|
||||||
"""将前端输入转换为统一的米制数值。
|
"""将前端输入转换为统一的标准单位数值。
|
||||||
|
|
||||||
根据 pricing_inputs 声明解析字段,自动读取 _unit 后缀进行单位换算。
|
根据 pricing_inputs 声明解析字段,自动读取 _unit 后缀进行单位换算。
|
||||||
|
- 长度类字段统一转为米(_m 后缀)
|
||||||
|
- 重量类字段统一转为千克(_kg 后缀)
|
||||||
|
- 面积类字段统一转为平方米(_sqm 后缀)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
rule: 定价规则 ORM 对象
|
rule: 定价规则 ORM 对象
|
||||||
user_inputs: 前端传入的原始输入字典
|
user_inputs: 前端传入的原始输入字典
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
标准化后的输入字典,包含原始值和 _m 后缀的米制值
|
标准化后的输入字典,包含原始值和标准化后缀值
|
||||||
"""
|
"""
|
||||||
normalized = {}
|
normalized = {}
|
||||||
# 解析 pricing_inputs 声明
|
|
||||||
inputs_decl = self._safe_load_json(rule.pricing_inputs) or []
|
inputs_decl = self._safe_load_json(rule.pricing_inputs) or []
|
||||||
|
|
||||||
for field in inputs_decl:
|
for field in inputs_decl:
|
||||||
@ -146,11 +205,20 @@ class PricingEngine:
|
|||||||
normalized[key] = val
|
normalized[key] = val
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 带单位的数值字段:读取对应 _unit 后缀
|
|
||||||
unit_key = f"{key}_unit"
|
unit_key = f"{key}_unit"
|
||||||
unit = user_inputs.get(unit_key, field.get("default_unit", "m"))
|
unit = user_inputs.get(unit_key, field.get("default_unit", "m"))
|
||||||
multiplier = self._UNIT_TO_M.get(unit, 1.0)
|
field_type = field.get("unit_type", "length")
|
||||||
normalized[f"{key}_m"] = round(val * multiplier, 6)
|
|
||||||
|
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
|
normalized[key] = val
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
@ -165,6 +233,8 @@ class PricingEngine:
|
|||||||
支持 $input.xxx、$rule.xxx、$const.xxx、$product.xxx 变量引用,
|
支持 $input.xxx、$rule.xxx、$const.xxx、$product.xxx 变量引用,
|
||||||
以及 ${condition} ? a : b 三元条件表达式。
|
以及 ${condition} ? a : b 三元条件表达式。
|
||||||
|
|
||||||
|
使用正则替换(按变量名长度降序)避免短变量名误匹配长变量名前缀。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
expr: 公式表达式字符串
|
expr: 公式表达式字符串
|
||||||
context: 变量上下文字典
|
context: 变量上下文字典
|
||||||
@ -174,12 +244,20 @@ class PricingEngine:
|
|||||||
"""
|
"""
|
||||||
resolved = expr
|
resolved = expr
|
||||||
|
|
||||||
# 替换 $input.xxx / $rule.xxx / $const.xxx / $product.xxx
|
# 收集所有 (完整变量名, 替换值),按变量名长度降序排列防止前缀误匹配
|
||||||
|
replacements = []
|
||||||
for prefix, values in context.items():
|
for prefix, values in context.items():
|
||||||
if not isinstance(values, dict):
|
if not isinstance(values, dict):
|
||||||
continue
|
continue
|
||||||
for k, v in values.items():
|
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
|
# 解析 ${condition} ? a : b
|
||||||
resolved = self._resolve_conditionals(resolved)
|
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:
|
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:
|
Args:
|
||||||
rule: 定价规则 ORM 对象
|
rule: 定价规则 ORM 对象
|
||||||
@ -296,10 +378,30 @@ class PricingEngine:
|
|||||||
Returns:
|
Returns:
|
||||||
公式说明字符串
|
公式说明字符串
|
||||||
"""
|
"""
|
||||||
|
pricing_type = getattr(rule, "pricing_type", "area") or "area"
|
||||||
|
unit_price = float(rule.base_unit_price or 0)
|
||||||
parts = []
|
parts = []
|
||||||
length = normalized.get("length_m", 0)
|
|
||||||
width = normalized.get("width_m", 0)
|
if pricing_type == "area":
|
||||||
parts.append(f"{length}m × {width}m × ¥{rule.base_unit_price}/㎡ = ¥{base_cost:.2f}")
|
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:
|
for s in surcharges:
|
||||||
parts.append(f"+ {s['name']} ¥{s['amount']:.2f}")
|
parts.append(f"+ {s['name']} ¥{s['amount']:.2f}")
|
||||||
@ -307,7 +409,6 @@ class PricingEngine:
|
|||||||
total = base_cost + sum(s["amount"] for s in surcharges)
|
total = base_cost + sum(s["amount"] for s in surcharges)
|
||||||
parts.append(f"= ¥{total:.2f}")
|
parts.append(f"= ¥{total:.2f}")
|
||||||
|
|
||||||
# 税说明
|
|
||||||
tax_rate = float(getattr(rule, "tax_rate", 0) or 0)
|
tax_rate = float(getattr(rule, "tax_rate", 0) or 0)
|
||||||
if tax_rate > 0:
|
if tax_rate > 0:
|
||||||
tax_inclusive = int(getattr(rule, "tax_inclusive", 0) or 0)
|
tax_inclusive = int(getattr(rule, "tax_inclusive", 0) or 0)
|
||||||
|
|||||||
@ -135,6 +135,8 @@ Page({
|
|||||||
for (var i = 0; i < rawItems.length; i++) {
|
for (var i = 0; i < rawItems.length; i++) {
|
||||||
var ci = rawItems[i];
|
var ci = rawItems[i];
|
||||||
if (ci.product_name) {
|
if (ci.product_name) {
|
||||||
|
// 计价方式中文映射
|
||||||
|
var pricingTypeMap = { area: "按面积", kg: "按重量", unit: "按件", 件: "按件", linear_m: "按米", weight_g: "按克重" };
|
||||||
products.push({
|
products.push({
|
||||||
name: ci.product_name,
|
name: ci.product_name,
|
||||||
spec: ci.specification || "",
|
spec: ci.specification || "",
|
||||||
@ -143,6 +145,10 @@ Page({
|
|||||||
unit: ci.unit || "",
|
unit: ci.unit || "",
|
||||||
costPrice: fmt(ci.cost_price),
|
costPrice: fmt(ci.cost_price),
|
||||||
costAmount: fmt(Number(ci.quantity || 0) * Number(ci.cost_price || 0)),
|
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",
|
url: targetUrl, method: "POST",
|
||||||
data: payload,
|
data: payload,
|
||||||
}).then(function () {
|
}).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 () {
|
}).then(function () {
|
||||||
if (payload.approve_result === "pass") {
|
if (payload.approve_result === "pass") {
|
||||||
// 从原始数据直接构建采购信息,避免 this.data 时序问题
|
// 从原始数据直接构建采购信息,避免 this.data 时序问题
|
||||||
|
|||||||
@ -88,6 +88,9 @@
|
|||||||
<text class="product-spec" wx:if="{{item.spec}}">{{item.spec}}</text>
|
<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.demandSpec}}">需求: {{item.demandSpec}}</text>
|
||||||
<text class="product-spec" wx:if="{{item.qty}}">{{item.qty}} {{item.unit}}</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>
|
||||||
<view class="product-prices">
|
<view class="product-prices">
|
||||||
<text class="product-price">单价: ¥{{item.costPrice}}</text>
|
<text class="product-price">单价: ¥{{item.costPrice}}</text>
|
||||||
@ -120,12 +123,12 @@
|
|||||||
<view wx:if="{{approveLogs.length}}" class="card log-card">
|
<view wx:if="{{approveLogs.length}}" class="card log-card">
|
||||||
<view class="section-title">审批记录</view>
|
<view class="section-title">审批记录</view>
|
||||||
<view wx:for="{{approveLogs}}" wx:key="log_id" class="log-row">
|
<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-content">
|
||||||
<view class="log-header">
|
<view class="log-header">
|
||||||
<text class="log-time">{{item.approve_time}}</text>
|
<text class="log-time">{{item.approve_time}}</text>
|
||||||
<text class="log-type">{{item.approve_type === 'order' ? '订单审批' : '取消审批'}}</text>
|
<text class="log-type">{{item.approve_type === 'order' ? '订单审批' : (item.approve_type === 'revise' ? '订单修订' : '取消审批')}}</text>
|
||||||
<text class="log-result {{item.approve_result === 'pass' ? 'result-pass' : 'result-reject'}}">{{item.approve_result === 'pass' ? '通过' : '驳回'}}</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>
|
</view>
|
||||||
<text wx:if="{{item.approve_opinion}}" class="log-opinion">{{item.approve_opinion}}</text>
|
<text wx:if="{{item.approve_opinion}}" class="log-opinion">{{item.approve_opinion}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@ -208,6 +208,7 @@ function mapApproveType(type) {
|
|||||||
const typeMap = {
|
const typeMap = {
|
||||||
order: "订单审批",
|
order: "订单审批",
|
||||||
cancel_order: "取消审批",
|
cancel_order: "取消审批",
|
||||||
|
revise: "订单修订",
|
||||||
};
|
};
|
||||||
return typeMap[type] || type || "-";
|
return typeMap[type] || type || "-";
|
||||||
}
|
}
|
||||||
@ -222,6 +223,7 @@ function mapApproveResult(result) {
|
|||||||
pass: "通过",
|
pass: "通过",
|
||||||
reject: "驳回",
|
reject: "驳回",
|
||||||
refuse: "驳回",
|
refuse: "驳回",
|
||||||
|
revised: "已修订",
|
||||||
};
|
};
|
||||||
return resultMap[result] || result || "-";
|
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
|
* @param {number} orderId - 订单 ID
|
||||||
@ -1434,6 +1449,7 @@ export async function fetchApprovalDetail(orderId) {
|
|||||||
approveOpinion: item.approve_opinion || "-",
|
approveOpinion: item.approve_opinion || "-",
|
||||||
})),
|
})),
|
||||||
calculationDetail: data.calculation_detail || null,
|
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>
|
||||||
|
<th>计价方式</th>
|
||||||
<th>成本单价</th>
|
<th>成本单价</th>
|
||||||
|
<th>成本小计</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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.productName }}</td>
|
||||||
<td>{{ item.specification || '-' }}</td>
|
<td>{{ item.specification || '-' }}</td>
|
||||||
<td>{{ item.quantity }}{{ item.unit }}</td>
|
<td>{{ item.quantity }}{{ item.unit }}</td>
|
||||||
|
<td>{{ getPricingTypeLabel(item, idx) }}</td>
|
||||||
<td>{{ item.costPrice }}</td>
|
<td>{{ item.costPrice }}</td>
|
||||||
|
<td>{{ getItemCostTotal(item) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div v-else class="empty-box">暂无产品明细</div>
|
<div v-else class="empty-box">暂无产品明细</div>
|
||||||
</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>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -89,6 +110,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</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">
|
<article class="card action-card">
|
||||||
<h3>审批操作</h3>
|
<h3>审批操作</h3>
|
||||||
<p class="action-hint">{{ actionHint }}</p>
|
<p class="action-hint">{{ actionHint }}</p>
|
||||||
@ -138,6 +185,7 @@ const loading = ref(true);
|
|||||||
const orderInfo = ref(null);
|
const orderInfo = ref(null);
|
||||||
const items = ref([]);
|
const items = ref([]);
|
||||||
const calcDetail = ref(null);
|
const calcDetail = ref(null);
|
||||||
|
const revisionInfo = ref(null);
|
||||||
const approveOpinion = ref("");
|
const approveOpinion = ref("");
|
||||||
const actionLoading = ref("");
|
const actionLoading = ref("");
|
||||||
const isModal = computed(() => route.query.modal === "1");
|
const isModal = computed(() => route.query.modal === "1");
|
||||||
@ -173,6 +221,7 @@ async function loadDetail() {
|
|||||||
orderInfo.value = data.orderInfo;
|
orderInfo.value = data.orderInfo;
|
||||||
items.value = data.items || [];
|
items.value = data.items || [];
|
||||||
calcDetail.value = data.calculationDetail || null;
|
calcDetail.value = data.calculationDetail || null;
|
||||||
|
revisionInfo.value = data.revisionInfo || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
@ -194,6 +243,34 @@ function buildCopyText() {
|
|||||||
return lines.join("\n");
|
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) {
|
async function handleApprove(result) {
|
||||||
if (!orderInfo.value) return;
|
if (!orderInfo.value) return;
|
||||||
actionLoading.value = result;
|
actionLoading.value = result;
|
||||||
@ -203,7 +280,7 @@ async function handleApprove(result) {
|
|||||||
approve_opinion: approveOpinion.value || (result === "pass" ? "审批通过" : "审批驳回"),
|
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);
|
await cancelApproveOrder(orderInfo.value.orderId, payload);
|
||||||
toast.success(result === "pass" ? "取消审批已通过" : "已驳回取消申请");
|
toast.success(result === "pass" ? "取消审批已通过" : "已驳回取消申请");
|
||||||
} else {
|
} else {
|
||||||
@ -300,6 +377,24 @@ td { color: #374151; }
|
|||||||
.calc-result { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e5e7eb; }
|
.calc-result { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e5e7eb; }
|
||||||
.tax-note { color: #9ca3af; font-size: 12px; margin-left: 16px; }
|
.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-card { background: #f9fafb; }
|
||||||
.action-hint { margin: 0 0 16px; color: #6b7280; font-size: 13px; }
|
.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 :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>
|
<span v-if="detail.order.trackingNumber" class="doc-status-badge badge-self-delivery" title="马甸工厂自发">自发</span>
|
||||||
</div>
|
</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"
|
<button v-if="!isEditing && canEditOrder(detail.order.rawStatus)" class="primary-btn"
|
||||||
@click="enterEditMode">编辑
|
@click="enterEditMode">编辑
|
||||||
</button>
|
</button>
|
||||||
@ -297,6 +297,10 @@
|
|||||||
<span v-if="!isEditing">{{ detail.order.remark }}</span>
|
<span v-if="!isEditing">{{ detail.order.remark }}</span>
|
||||||
<textarea v-else v-model="editForm.remark" rows="2" placeholder="请输入备注"></textarea>
|
<textarea v-else v-model="editForm.remark" rows="2" placeholder="请输入备注"></textarea>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -429,9 +433,9 @@
|
|||||||
<div class="doc-timeline-content">
|
<div class="doc-timeline-content">
|
||||||
<div class="doc-timeline-header">
|
<div class="doc-timeline-header">
|
||||||
<strong>{{ log.approve_time }}</strong>
|
<strong>{{ log.approve_time }}</strong>
|
||||||
<span class="doc-tag">{{ log.approve_type === 'order' ? '订单审批' : '取消审批' }}</span>
|
<span class="doc-tag">{{ log.approve_type === 'order' ? '订单审批' : (log.approve_type === 'revise' ? '订单修订' : '取消审批') }}</span>
|
||||||
<span :class="['doc-result-tag', log.approve_result === 'pass' ? 'result-pass' : 'result-reject']">{{
|
<span :class="['doc-result-tag', log.approve_result === 'pass' || log.approve_result === 'revised' ? 'result-pass' : 'result-reject']">{{
|
||||||
log.approve_result === 'pass' ? '通过' : '驳回'
|
log.approve_result === 'pass' ? '通过' : (log.approve_result === 'revised' ? '已修订' : '驳回')
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="log.approve_opinion" class="doc-timeline-opinion">{{ log.approve_opinion }}</p>
|
<p v-if="log.approve_opinion" class="doc-timeline-opinion">{{ log.approve_opinion }}</p>
|
||||||
@ -900,6 +904,7 @@ import {
|
|||||||
cancelOrderById,
|
cancelOrderById,
|
||||||
fetchOrderDetailAdmin,
|
fetchOrderDetailAdmin,
|
||||||
updateOrder,
|
updateOrder,
|
||||||
|
reviseOrder,
|
||||||
submitOrderToReview,
|
submitOrderToReview,
|
||||||
changeOrderStatus,
|
changeOrderStatus,
|
||||||
settleOrder,
|
settleOrder,
|
||||||
@ -1070,6 +1075,7 @@ const editForm = reactive({
|
|||||||
commission_amount: 0,
|
commission_amount: 0,
|
||||||
payment_method: '',
|
payment_method: '',
|
||||||
remark: '',
|
remark: '',
|
||||||
|
revision_reason: '',
|
||||||
});
|
});
|
||||||
const editItems = ref([]);
|
const editItems = ref([]);
|
||||||
const productOptions = ref([]);
|
const productOptions = ref([]);
|
||||||
@ -1107,7 +1113,9 @@ function handleProductChange(item, event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canEditOrder(status) {
|
function canEditOrder(status) {
|
||||||
return ['draft', 'rejected', 'pending_approve'].includes(status);
|
// 管理员可修订:草稿/已退回用 updateOrder,其他非终态用 reviseOrder
|
||||||
|
const terminalStatuses = ['canceled', 'settled'];
|
||||||
|
return !terminalStatuses.includes(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
function enterEditMode() {
|
function enterEditMode() {
|
||||||
@ -1128,6 +1136,7 @@ function enterEditMode() {
|
|||||||
editForm.payment_method = detail.order.paymentMethod === '-' ? '' : detail.order.paymentMethod;
|
editForm.payment_method = detail.order.paymentMethod === '-' ? '' : detail.order.paymentMethod;
|
||||||
editForm.remark = detail.order.remark === '-' ? '' : detail.order.remark;
|
editForm.remark = detail.order.remark === '-' ? '' : detail.order.remark;
|
||||||
editForm.customer_demand = detail.order.customerDemand || '';
|
editForm.customer_demand = detail.order.customerDemand || '';
|
||||||
|
editForm.revision_reason = '';
|
||||||
// 复制商品明细
|
// 复制商品明细
|
||||||
editItems.value = (detail.items || []).map(item => ({...item}));
|
editItems.value = (detail.items || []).map(item => ({...item}));
|
||||||
}
|
}
|
||||||
@ -1179,33 +1188,55 @@ async function saveEdit() {
|
|||||||
|
|
||||||
editSaving.value = true;
|
editSaving.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const itemsPayload = editItems.value.map(item => ({
|
||||||
customer_name: editForm.customer_name.trim(),
|
product_id: item.product_id || null,
|
||||||
customer_mobile: editForm.customer_mobile.trim(),
|
product_name: item.product_name.trim(),
|
||||||
customer_address: editForm.customer_address.trim() || null,
|
specification: item.specification?.trim() || '',
|
||||||
order_source: editForm.order_source.trim() || null,
|
unit: item.unit?.trim() || '',
|
||||||
delivery_type: editForm.delivery_type.trim() || null,
|
quantity: Number(item.quantity) || 1,
|
||||||
contract_amount: Number(editForm.contract_amount) || 0,
|
sale_price: Number(item.sale_price) || 0,
|
||||||
rebate_total: Number(editForm.rebate_total) || 0,
|
cost_price: Number(item.cost_price) || 0,
|
||||||
freight_total: Number(editForm.freight_total) || 0,
|
}));
|
||||||
tax_total: Number(editForm.tax_total) || 0,
|
|
||||||
other_fee_total: Number(editForm.other_fee_total) || 0,
|
const rawStatus = detail.order.rawStatus;
|
||||||
commission_amount: Number(editForm.commission_amount) || 0,
|
const isDraftOrRejected = ['draft', 'rejected'].includes(rawStatus);
|
||||||
payment_method: editForm.payment_method.trim() || null,
|
|
||||||
remark: editForm.remark.trim() || null,
|
if (isDraftOrRejected) {
|
||||||
customer_demand: editForm.customer_demand.trim() || null,
|
// 草稿/已退回:使用普通更新接口
|
||||||
items: editItems.value.map(item => ({
|
const payload = {
|
||||||
product_id: item.product_id || null,
|
customer_name: editForm.customer_name.trim(),
|
||||||
product_name: item.product_name.trim(),
|
customer_mobile: editForm.customer_mobile.trim(),
|
||||||
specification: item.specification?.trim() || '',
|
customer_address: editForm.customer_address.trim() || null,
|
||||||
unit: item.unit?.trim() || '',
|
order_source: editForm.order_source.trim() || null,
|
||||||
quantity: Number(item.quantity) || 1,
|
delivery_type: editForm.delivery_type.trim() || null,
|
||||||
sale_price: Number(item.sale_price) || 0,
|
contract_amount: Number(editForm.contract_amount) || 0,
|
||||||
cost_price: Number(item.cost_price) || 0,
|
rebate_total: Number(editForm.rebate_total) || 0,
|
||||||
})),
|
freight_total: Number(editForm.freight_total) || 0,
|
||||||
};
|
tax_total: Number(editForm.tax_total) || 0,
|
||||||
await updateOrder(detail.orderId, payload);
|
other_fee_total: Number(editForm.other_fee_total) || 0,
|
||||||
toast.success('订单已更新');
|
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;
|
isEditing.value = false;
|
||||||
await loadDetail();
|
await loadDetail();
|
||||||
await handleSearch();
|
await handleSearch();
|
||||||
@ -1651,7 +1682,7 @@ async function handleApprovalPass() {
|
|||||||
detailActionBusy.value = false;
|
detailActionBusy.value = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 非自发订单:需要选择工厂和司机
|
// 非自发订单:调用审批接口,后端统一处理工厂分配和物流任务创建
|
||||||
if (!selectedFactoryId.value) {
|
if (!selectedFactoryId.value) {
|
||||||
toast.error('请选择下发工厂');
|
toast.error('请选择下发工厂');
|
||||||
return;
|
return;
|
||||||
@ -1662,20 +1693,11 @@ async function handleApprovalPass() {
|
|||||||
}
|
}
|
||||||
detailActionBusy.value = true;
|
detailActionBusy.value = true;
|
||||||
try {
|
try {
|
||||||
// 先选择工厂并进入待工厂状态
|
await approveOrder(detail.orderId, {
|
||||||
await changeOrderStatus(detail.orderId, {
|
approve_result: 'pass',
|
||||||
target_status: 'pending_factory',
|
approve_opinion: '审批通过',
|
||||||
factory_id: selectedFactoryId.value,
|
factory_id: selectedFactoryId.value,
|
||||||
});
|
|
||||||
// 再分配司机并创建物流任务
|
|
||||||
await createLogisticsTask({
|
|
||||||
order_id: detail.orderId,
|
|
||||||
driver_id: Number(selectedDriverId.value),
|
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('审批通过,已下发工厂并分配司机');
|
toast.success('审批通过,已下发工厂并分配司机');
|
||||||
await loadDetail();
|
await loadDetail();
|
||||||
@ -1706,7 +1728,8 @@ async function handleApprovalReject() {
|
|||||||
// 驳回取消申请 - 使用 cancel-approve 接口
|
// 驳回取消申请 - 使用 cancel-approve 接口
|
||||||
await cancelApproveOrder(detail.orderId, {approve_result: 'refuse', approve_opinion: opinion || '驳回取消'});
|
await cancelApproveOrder(detail.orderId, {approve_result: 'refuse', approve_opinion: opinion || '驳回取消'});
|
||||||
} else {
|
} else {
|
||||||
await changeOrderStatus(detail.orderId, {target_status: 'rejected', approve_opinion: opinion || '退回'});
|
// 使用审批接口退回,确保审批日志正确记录
|
||||||
|
await approveOrder(detail.orderId, {approve_result: 'reject', approve_opinion: opinion || '退回'});
|
||||||
}
|
}
|
||||||
toast.success(isCancelFlow ? '已驳回取消' : '已退回');
|
toast.success(isCancelFlow ? '已驳回取消' : '已退回');
|
||||||
await loadDetail();
|
await loadDetail();
|
||||||
|
|||||||
@ -214,6 +214,8 @@
|
|||||||
<option value="area">按面积(㎡)</option>
|
<option value="area">按面积(㎡)</option>
|
||||||
<option value="kg">按公斤(kg)</option>
|
<option value="kg">按公斤(kg)</option>
|
||||||
<option value="unit">按件</option>
|
<option value="unit">按件</option>
|
||||||
|
<option value="linear_m">按米(m)</option>
|
||||||
|
<option value="weight_g">按克重(g)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<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: "长×宽×单价" },
|
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×单价" },
|
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: "数量×单价" },
|
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 || "如 长×宽×单价");
|
const formulaNoteHint = computed(() => PRICING_PRESETS[editingRule.pricing_type]?.note || "如 长×宽×单价");
|
||||||
|
|||||||
@ -89,7 +89,7 @@ function mapOrderStatus(status) {
|
|||||||
* @returns {string} 中文类型名称
|
* @returns {string} 中文类型名称
|
||||||
*/
|
*/
|
||||||
function mapApproveType(type) {
|
function mapApproveType(type) {
|
||||||
const typeMap = { order: "订单审批", cancel_order: "取消审批" };
|
const typeMap = { order: "订单审批", cancel_order: "取消审批", revise: "订单修订" };
|
||||||
return typeMap[type] || type || "-";
|
return typeMap[type] || type || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,7 +99,7 @@ function mapApproveType(type) {
|
|||||||
* @returns {string} 中文结果名称
|
* @returns {string} 中文结果名称
|
||||||
*/
|
*/
|
||||||
function mapApproveResult(result) {
|
function mapApproveResult(result) {
|
||||||
const resultMap = { pass: "通过", reject: "驳回", refuse: "驳回" };
|
const resultMap = { pass: "通过", reject: "驳回", refuse: "驳回", revised: "已修订" };
|
||||||
return resultMap[result] || result || "-";
|
return resultMap[result] || result || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -293,6 +293,32 @@ export async function fetchConfigItem(configKey) {
|
|||||||
return request(`/api/configs/${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 || "-",
|
unit: item.unit || "-",
|
||||||
quantity: Number(item.quantity || 0).toFixed(2),
|
quantity: Number(item.quantity || 0).toFixed(2),
|
||||||
salePrice: Number(item.sale_price || 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) => ({
|
approveLogs: (data.approve_logs || []).map((item) => ({
|
||||||
id: item.log_id,
|
id: item.log_id,
|
||||||
|
|||||||
@ -139,18 +139,18 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>产品名称</th>
|
<th>产品名称</th>
|
||||||
<th>规格</th>
|
<th>规格</th>
|
||||||
<th>需求规格</th>
|
|
||||||
<th>单位</th>
|
|
||||||
<th>数量</th>
|
<th>数量</th>
|
||||||
|
<th>计价方式</th>
|
||||||
|
<th>成本单价</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in orderItems" :key="item.id">
|
<tr v-for="item in orderItems" :key="item.id">
|
||||||
<td>{{ item.productName }}</td>
|
<td>{{ item.productName }}</td>
|
||||||
<td>{{ item.specification }}</td>
|
<td>{{ item.specification }}</td>
|
||||||
<td>{{ item.demandSpecification || "-" }}</td>
|
<td>{{ item.quantity }}{{ item.unit }}</td>
|
||||||
<td>{{ item.unit }}</td>
|
<td>{{ getPricingTypeLabel(item.pricingType) }}</td>
|
||||||
<td>{{ item.quantity }}</td>
|
<td>¥{{ item.costPrice }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -295,6 +295,9 @@ const selfTrackingForm = reactive({ tracking_number: "", remark: "" });
|
|||||||
const traceList = ref([]);
|
const traceList = ref([]);
|
||||||
let unsubscribeReminder = null;
|
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) {
|
function canSubmit(status) {
|
||||||
return ["draft", "rejected"].includes(status);
|
return ["draft", "rejected"].includes(status);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -225,6 +225,29 @@
|
|||||||
<span>数量</span>
|
<span>数量</span>
|
||||||
<input v-model.number="row.quantity" type="number" min="0.01" step="0.01" />
|
<input v-model.number="row.quantity" type="number" min="0.01" step="0.01" />
|
||||||
</label>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -316,7 +339,7 @@
|
|||||||
|
|
||||||
<section class="actions">
|
<section class="actions">
|
||||||
<button type="button" class="secondary-btn" @click="resetForm">重置表单</button>
|
<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 ? "更新订单" : "创建订单") }}
|
{{ submitting ? (isEditMode ? "更新中..." : "创建中...") : (isEditMode ? "更新订单" : "创建订单") }}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
@ -328,6 +351,8 @@ import { computed, onMounted, reactive, ref } from "vue";
|
|||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
aiSuggestPricing,
|
||||||
|
calculateQuotation,
|
||||||
createOrder,
|
createOrder,
|
||||||
fetchConfigItem,
|
fetchConfigItem,
|
||||||
fetchCustomerOptions,
|
fetchCustomerOptions,
|
||||||
@ -380,6 +405,14 @@ function buildDefaultItem() {
|
|||||||
sale_price: 0,
|
sale_price: 0,
|
||||||
cost_price: 0,
|
cost_price: 0,
|
||||||
remark: "",
|
remark: "",
|
||||||
|
// 特殊计价字段
|
||||||
|
length_m: null,
|
||||||
|
width_m: null,
|
||||||
|
weight_g: null,
|
||||||
|
pricing_type: "",
|
||||||
|
price_tier: "",
|
||||||
|
_pricingPreview: null,
|
||||||
|
_pricingLoading: false,
|
||||||
_searchText: "",
|
_searchText: "",
|
||||||
_showDropdown: false,
|
_showDropdown: false,
|
||||||
_filteredProducts: [],
|
_filteredProducts: [],
|
||||||
@ -688,6 +721,7 @@ function removeAttachment(index) {
|
|||||||
|
|
||||||
async function uploadAttachments(orderId) {
|
async function uploadAttachments(orderId) {
|
||||||
for (const item of attachmentFiles.value) {
|
for (const item of attachmentFiles.value) {
|
||||||
|
if (item.existing) continue; // 跳过已有附件,不重复上传
|
||||||
const uploadResult = await localUploadFile(item.file);
|
const uploadResult = await localUploadFile(item.file);
|
||||||
await saveAttachment({
|
await saveAttachment({
|
||||||
biz_type: "sales_order",
|
biz_type: "sales_order",
|
||||||
@ -807,6 +841,66 @@ function calcDemandResult(row) {
|
|||||||
row._demandCalcHint = "";
|
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([]);
|
const paymentChannels = ref([]);
|
||||||
|
|
||||||
async function loadOptions() {
|
async function loadOptions() {
|
||||||
@ -836,6 +930,7 @@ async function loadOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
|
if (submitting.value) return; // 防止重复提交
|
||||||
if (!form.customer_name.trim()) {
|
if (!form.customer_name.trim()) {
|
||||||
message.value = "请输入客户姓名";
|
message.value = "请输入客户姓名";
|
||||||
messageType.value = "error";
|
messageType.value = "error";
|
||||||
@ -886,7 +981,7 @@ async function handleSubmit() {
|
|||||||
remark: form.remark,
|
remark: form.remark,
|
||||||
auto_sync_customer: form.auto_sync_customer,
|
auto_sync_customer: form.auto_sync_customer,
|
||||||
customer_sync_mode: matchedCustomer ? 'linked' : form.auto_sync_customer ? 'auto_create' : 'manual_review',
|
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;
|
let result;
|
||||||
if (isEditMode.value) {
|
if (isEditMode.value) {
|
||||||
@ -1228,6 +1323,31 @@ select {
|
|||||||
border: 1px solid #bbf7d0;
|
border: 1px solid #bbf7d0;
|
||||||
border-radius: 8px;
|
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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user