748 lines
29 KiB
Markdown
748 lines
29 KiB
Markdown
# 产品报价系统集成方案
|
||
|
||
## 一、背景与目标
|
||
|
||
将《价格成本核算表兼报价系统》Excel 中的产品数据与报价计算逻辑集成到现有订单管理系统中。
|
||
|
||
**Excel 文件包含 8 个产品线**:揉面垫、硅胶烤垫、日用高温布、特氟龙玻纤胶带、特氟龙纯膜胶带、高温布、公斤布、输送带。
|
||
|
||
**核心目标**:
|
||
1. 在系统中完整管理产品数据(多供应商、多成本价、多价格层级)
|
||
2. 实现公式驱动的报价计算(按㎡、按公斤、输送带加工费等)
|
||
3. 价格数据由系统维护,替代 Excel 管理
|
||
|
||
---
|
||
|
||
## 二、现有系统差距分析
|
||
|
||
| 维度 | Excel 中的能力 | 系统现状 | 差距 |
|
||
|------|---------------|---------|------|
|
||
| 产品定价方式 | 公式驱动(长×宽×单价) | 固定单价 | 缺少维度输入和面积计算 |
|
||
| 多供应商成本 | 每个产品有 WW/ZB/BC/AK 等多供应商不同成本价 | 只有一个 cost_price | 缺少 supplier_product_cost 表 |
|
||
| 价格层级 | 特批价 / 一级经销 / 二级经销 | 只有一个 sale_price | 缺少 price_tier 管理 |
|
||
| 加工费项 | 包边、印刷、logo、钉扣、接头等结构化附加费 | 只有 other_fee_amount | 缺少加工费模板 |
|
||
| 计价单位 | 按㎡ / 按公斤(需换算) | 只有 unit 字段 | 缺少 pricing_unit 和换算逻辑 |
|
||
|
||
---
|
||
|
||
## 三、数据库设计
|
||
|
||
### 3.1 新增表
|
||
|
||
#### product_pricing_rule(产品定价规则表)
|
||
|
||
存储每个产品的计价方式和公式参数。
|
||
|
||
```sql
|
||
CREATE TABLE `product_pricing_rule` (
|
||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||
`product_id` bigint NOT NULL COMMENT '产品ID(关联 product 表中该产品的默认规格行)',
|
||
`product_name` varchar(128) NOT NULL COMMENT '产品名称冗余',
|
||
`pricing_type` varchar(32) NOT NULL DEFAULT 'area' COMMENT '显示类型:area 按面积 / kg 按公斤 / unit 按件,用于前端标签展示',
|
||
`base_unit_price` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '基准单价(元/㎡ 或 元/kg)',
|
||
`pricing_unit` varchar(16) NOT NULL DEFAULT '㎡' COMMENT '计价单位:㎡ / kg / 张 / 米',
|
||
`pricing_inputs` text COMMENT '输入字段声明JSON,定义下单时需要用户填写的字段',
|
||
`formula_expr` text COMMENT '公式表达式,如 round($input.length_m * $input.width_m * $rule.base_unit_price, 2)',
|
||
`formula_constants` text COMMENT '公式常量JSON,存储阈值、系数等',
|
||
`surcharge_json` text COMMENT '附加费配置JSON(per_sqm/per_piece/per_linear_m/per_sqm_threshold)',
|
||
`formula_note` text COMMENT '计算公式说明文本,展示给业务员参考',
|
||
`status` tinyint NOT NULL DEFAULT 1,
|
||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
`deleted` tinyint NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_product_pricing_rule_product_id` (`product_id`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品定价规则表';
|
||
```
|
||
|
||
#### supplier_product_cost(供应商产品成本表)
|
||
|
||
每个产品在每个供应商处的采购成本和型号。
|
||
|
||
```sql
|
||
CREATE TABLE `supplier_product_cost` (
|
||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||
`product_id` bigint NOT NULL COMMENT '产品ID',
|
||
`supplier_id` bigint NOT NULL COMMENT '供应商ID(关联 base_supplier)',
|
||
`supplier_model` varchar(64) DEFAULT NULL COMMENT '供应商型号(如 9011WJ-145g)',
|
||
`our_model` varchar(64) DEFAULT NULL COMMENT '我司型号(如 9011WJ-145g)',
|
||
`thickness` varchar(32) DEFAULT NULL COMMENT '厚度(mm)',
|
||
`weight_gsm` int DEFAULT NULL COMMENT '克重(g/㎡)',
|
||
`base_fabric_weight` int DEFAULT NULL COMMENT '基布克重',
|
||
`cost_price` decimal(18,4) NOT NULL COMMENT '该供应商的采购单价',
|
||
`cost_unit` varchar(16) NOT NULL DEFAULT '㎡' COMMENT '成本单位:㎡ / kg / 张',
|
||
`is_primary` tinyint NOT NULL DEFAULT 0 COMMENT '是否默认/主要供应商',
|
||
`remark` varchar(255) DEFAULT NULL,
|
||
`status` tinyint NOT NULL DEFAULT 1,
|
||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
`deleted` tinyint NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (`id`),
|
||
KEY `idx_supplier_product_cost_product_id` (`product_id`),
|
||
KEY `idx_supplier_product_cost_supplier_id` (`supplier_id`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商产品成本表';
|
||
```
|
||
|
||
#### product_price_tier(产品价格层级表)
|
||
|
||
每个产品按客户层级的销售价格。
|
||
|
||
```sql
|
||
CREATE TABLE `product_price_tier` (
|
||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||
`product_id` bigint NOT NULL COMMENT '产品ID',
|
||
`tier_code` varchar(32) NOT NULL COMMENT '层级编码:special 特批 / tier1 一级经销 / tier2 二级经销 / default 默认',
|
||
`tier_name` varchar(64) NOT NULL COMMENT '层级名称',
|
||
`price` decimal(18,4) NOT NULL COMMENT '该层级的销售单价',
|
||
`price_unit` varchar(16) NOT NULL DEFAULT '㎡' COMMENT '价格单位:㎡ / kg / 张',
|
||
`remark` varchar(255) DEFAULT NULL,
|
||
`status` tinyint NOT NULL DEFAULT 1,
|
||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
`deleted` tinyint NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_product_price_tier` (`product_id`, `tier_code`),
|
||
KEY `idx_product_price_tier_product_id` (`product_id`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品价格层级表';
|
||
```
|
||
|
||
### 3.2 扩展现有表
|
||
|
||
#### product 表增加字段
|
||
|
||
```sql
|
||
ALTER TABLE `product` ADD COLUMN `category_id` bigint DEFAULT NULL COMMENT '分类ID' AFTER `category`;
|
||
ALTER TABLE `product` ADD COLUMN `pricing_type` varchar(32) DEFAULT NULL COMMENT '计价类型' AFTER `sale_price`;
|
||
ALTER TABLE `product` ADD COLUMN `pricing_unit` varchar(16) DEFAULT '㎡' COMMENT '计价单位' AFTER `pricing_type`;
|
||
ALTER TABLE `product` ADD COLUMN `thickness` varchar(32) DEFAULT NULL COMMENT '厚度(mm)' AFTER `pricing_unit`;
|
||
ALTER TABLE `product` ADD COLUMN `weight_gsm` int DEFAULT NULL COMMENT '克重(g/㎡)' AFTER `thickness`;
|
||
ALTER TABLE `product` ADD COLUMN `default_width_m` decimal(10,4) DEFAULT NULL COMMENT '默认宽度(米)' AFTER `weight_gsm`;
|
||
ALTER TABLE `product` ADD COLUMN `is_default` tinyint NOT NULL DEFAULT 0 COMMENT '是否默认规格' AFTER `default_width_m`;
|
||
```
|
||
|
||
> 注:`category_id`、`is_default` 已在 ORM model 中定义但 SQL 建表脚本中缺失,此处补齐。
|
||
|
||
#### crm_customer 表增加字段
|
||
|
||
```sql
|
||
ALTER TABLE `crm_customer` ADD COLUMN `price_tier` varchar(32) DEFAULT 'default' COMMENT '价格层级编码' AFTER `customer_type`;
|
||
```
|
||
|
||
---
|
||
|
||
## 四、定价规则引擎设计
|
||
|
||
### 4.0 设计原则
|
||
|
||
**目标**:新增产品时,只在系统管理界面配置,不需要改代码。
|
||
|
||
**核心思路**:报价引擎是一个**通用解释器**,所有公式逻辑都存储在 `product_pricing_rule` 的 JSON 配置中。引擎读取配置后,按声明式的结构执行计算,不存在任何产品特定的 if/else 分支。
|
||
|
||
**三条设计约束**:
|
||
|
||
1. **公式可配置**:`formula_expr` 字段存储数学表达式,引擎用变量替换求值
|
||
2. **输入可配置**:`pricing_inputs` 字段定义下单时需要用户填写的字段,前端动态渲染表单
|
||
3. **附加费可配置**:`surcharge_json` 字段定义可选加工项,支持条件判断和多种计价方式
|
||
|
||
### 4.1 配置结构详解
|
||
|
||
`product_pricing_rule` 表的每个字段承载的职责:
|
||
|
||
```
|
||
pricing_rule
|
||
├── pricing_type ← 保留,用于前端显示标签(不再决定计算逻辑)
|
||
├── pricing_inputs ← 定义下单表单:需要哪些输入字段
|
||
├── formula_expr ← 定义核心计算公式
|
||
├── base_unit_price ← 基准单价(被公式引用)
|
||
├── formula_constants ← 公式中用到的常量参数
|
||
├── surcharge_json ← 定义可选附加费/加工费项
|
||
└── formula_note ← 给业务员看的公式说明
|
||
```
|
||
|
||
#### pricing_inputs — 输入字段声明
|
||
|
||
告诉引擎:这个产品下单时需要用户填哪些数据。
|
||
|
||
```json
|
||
[
|
||
{
|
||
"key": "length",
|
||
"label": "长度",
|
||
"type": "number",
|
||
"unit_options": ["m", "cm", "mm"],
|
||
"default_unit": "m",
|
||
"default_value": null
|
||
},
|
||
{
|
||
"key": "width",
|
||
"label": "宽度",
|
||
"type": "number",
|
||
"unit_options": ["m", "cm", "mm"],
|
||
"default_unit": "m",
|
||
"default_value": null
|
||
}
|
||
]
|
||
```
|
||
|
||
**type 枚举**:`number`(数字)、`select`(下拉选择)、`boolean`(是/否)
|
||
|
||
前端根据此配置**自动生成输入表单**,无需为每个产品硬编码界面。
|
||
|
||
不同产品的 pricing_inputs 对比:
|
||
|
||
| 产品 | pricing_inputs |
|
||
|------|---------------|
|
||
| 揉面垫/高温布/特氟龙胶带 | length, width |
|
||
| 公斤布 | length, width |
|
||
| 输送带 | length, width, joint_width, edge_binding_length, is_membrane_edge |
|
||
| 硅胶烤垫 | length, width |
|
||
|
||
> 输送带需要更多输入字段——这就是为什么需要声明式配置,而不是硬编码一个 `dimensions: {length, width}` 的固定结构。
|
||
|
||
#### formula_expr — 公式表达式
|
||
|
||
引擎支持的表达式语法:
|
||
|
||
```
|
||
基本运算:+ - * /
|
||
变量引用:$input.length_m (前端传入的长度,已转为米)
|
||
$input.width_m (前端传入的宽度,已转为米)
|
||
$rule.base_unit_price
|
||
常量引用:$const.xxx (formula_constants 中的值)
|
||
条件表达式:${条件} ? 值A : 值B
|
||
函数: round(值, 小数位数)
|
||
```
|
||
|
||
各产品的 formula_expr:
|
||
|
||
**面积计价(揉面垫、高温布、特氟龙胶带等)**:
|
||
```
|
||
round($input.length_m * $input.width_m * $rule.base_unit_price, 2)
|
||
```
|
||
|
||
**公斤布(按重量换算)**:
|
||
```
|
||
round($input.length_m * $input.width_m * $const.price_per_kg / $const.length_per_kg, 2)
|
||
```
|
||
|
||
**输送带(带条件分支)**:
|
||
```
|
||
round(
|
||
$input.length_m * $input.width_m * $rule.base_unit_price
|
||
+ $input.length_m * $input.width_m * (${input.thickness <= $const.threshold} ? $const.processing_under : $const.processing_above)
|
||
+ $input.joint_width * $const.joint_price
|
||
+ $input.edge_binding_length * (${input.is_membrane_edge} ? $const.membrane_price : $const.normal_edge_price)
|
||
, 2)
|
||
```
|
||
|
||
> 关键点:条件判断 `${thickness <= 0.25} ? 2 : 5` 直接写在公式表达式里,引擎解释执行,不需要代码分支。
|
||
|
||
#### formula_constants — 公式常量
|
||
|
||
公式中引用的常量参数,以 JSON 键值对存储:
|
||
|
||
```json
|
||
// 高温布 9018AJ
|
||
{
|
||
"threshold": null
|
||
}
|
||
|
||
// 输送带
|
||
{
|
||
"threshold": 0.25,
|
||
"processing_under": 2,
|
||
"processing_above": 5,
|
||
"joint_price": 66,
|
||
"normal_edge_price": 4.5,
|
||
"membrane_price": 6
|
||
}
|
||
|
||
// 公斤布 0.11白公布
|
||
{
|
||
"price_per_kg": 58,
|
||
"length_per_kg": 4.7,
|
||
"weight_gsm": 170
|
||
}
|
||
```
|
||
|
||
#### surcharge_json — 附加费配置
|
||
|
||
附加费项的声明式配置,每项定义名称、计价方式和参数:
|
||
|
||
```json
|
||
{
|
||
"edge_binding": {
|
||
"name": "包边",
|
||
"method": "per_sqm",
|
||
"price": 11,
|
||
"needs_input": false
|
||
},
|
||
"large_printing": {
|
||
"name": "大印刷",
|
||
"method": "per_sqm",
|
||
"price": 7,
|
||
"needs_input": false
|
||
},
|
||
"small_logo": {
|
||
"name": "小logo",
|
||
"method": "per_piece",
|
||
"price": 2,
|
||
"needs_input": true,
|
||
"input_key": "logo_quantity"
|
||
},
|
||
"button": {
|
||
"name": "钉扣",
|
||
"method": "per_piece",
|
||
"price": 2,
|
||
"needs_input": true,
|
||
"input_key": "button_count"
|
||
}
|
||
}
|
||
```
|
||
|
||
**method 枚举与计算规则**:
|
||
|
||
| method | 含义 | 公式 | needs_input |
|
||
|--------|------|------|------------|
|
||
| `per_sqm` | 按面积 | area_sqm × price | false |
|
||
| `per_piece` | 按件 | input_value × price | true |
|
||
| `per_linear_m` | 按线性米 | input_value × price | true |
|
||
| `per_sqm_threshold` | 条件面积 | area_sqm × (条件 ? price_a : price_b) | false |
|
||
|
||
附加费也可以用表达式定义 `price_expr`,覆盖固定 price 值:
|
||
|
||
```json
|
||
{
|
||
"processing": {
|
||
"name": "加工费",
|
||
"method": "per_sqm_threshold",
|
||
"needs_input": false,
|
||
"threshold": {
|
||
"field": "thickness",
|
||
"lte": 0.25,
|
||
"price_true": 2,
|
||
"price_false": 5
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
> 当 `method: per_sqm_threshold` 时,引擎检查产品属性或输入参数中的阈值字段,选择不同的单价。
|
||
|
||
### 4.2 完整产品配置示例
|
||
|
||
#### 示例 1:0.7mm 硅胶烤垫(面积 + 附加费)
|
||
|
||
```json
|
||
{
|
||
"pricing_type": "area",
|
||
"base_unit_price": 39.54,
|
||
"pricing_unit": "㎡",
|
||
"pricing_inputs": [
|
||
{"key": "length", "label": "长度", "type": "number", "unit_options": ["m","cm","mm"], "default_unit": "m"},
|
||
{"key": "width", "label": "宽度", "type": "number", "unit_options": ["m","cm","mm"], "default_unit": "m"}
|
||
],
|
||
"formula_expr": "round($input.length_m * $input.width_m * $rule.base_unit_price, 2)",
|
||
"formula_constants": {},
|
||
"surcharge_json": {
|
||
"edge_binding": {"name": "包边", "method": "per_sqm", "price": 11, "needs_input": false},
|
||
"large_printing": {"name": "大印刷", "method": "per_sqm", "price": 7, "needs_input": false},
|
||
"small_logo": {"name": "小logo", "method": "per_piece", "price": 2, "needs_input": true, "input_key": "logo_quantity"},
|
||
"button": {"name": "钉扣", "method": "per_piece", "price": 2, "needs_input": true, "input_key": "button_count"}
|
||
},
|
||
"formula_note": "卷料算法:长×宽×单价;有包边:长×宽×(单价+包边价格);印刷款:长×宽×(单价+印刷价格);钉扣每张加2元"
|
||
}
|
||
```
|
||
|
||
#### 示例 2:公斤布 0.11白公布(重量换算)
|
||
|
||
```json
|
||
{
|
||
"pricing_type": "area",
|
||
"base_unit_price": 10,
|
||
"pricing_unit": "㎡",
|
||
"pricing_inputs": [
|
||
{"key": "length", "label": "长度", "type": "number", "unit_options": ["m","cm","mm"], "default_unit": "m"},
|
||
{"key": "width", "label": "宽度", "type": "number", "unit_options": ["m","cm","mm"], "default_unit": "m"}
|
||
],
|
||
"formula_expr": "round($input.length_m * $input.width_m * $const.price_per_kg / $const.length_per_kg, 2)",
|
||
"formula_constants": {
|
||
"price_per_kg": 58,
|
||
"length_per_kg": 4.7,
|
||
"weight_gsm": 170
|
||
},
|
||
"surcharge_json": {},
|
||
"formula_note": "每公斤长度4.7m,每公斤价格58元(未税),换算每㎡价=58÷4.7≈12.34"
|
||
}
|
||
```
|
||
|
||
#### 示例 3:输送带 9008AJ(条件加工费)
|
||
|
||
```json
|
||
{
|
||
"pricing_type": "area",
|
||
"base_unit_price": 16,
|
||
"pricing_unit": "㎡",
|
||
"pricing_inputs": [
|
||
{"key": "length", "label": "长度", "type": "number", "unit_options": ["m","cm","mm"], "default_unit": "m"},
|
||
{"key": "width", "label": "宽度", "type": "number", "unit_options": ["m","cm","mm"], "default_unit": "m"},
|
||
{"key": "joint_width", "label": "接头宽度(m)", "type": "number", "unit_options": ["m"], "default_unit": "m"},
|
||
{"key": "edge_binding_length", "label": "包边长度(m)", "type": "number", "unit_options": ["m"], "default_unit": "m"},
|
||
{"key": "is_membrane_edge", "label": "是否膜包边", "type": "boolean", "default_value": false}
|
||
],
|
||
"formula_expr": "round($input.length_m * $input.width_m * $rule.base_unit_price + $input.length_m * $input.width_m * ($const.processing) + $input.joint_width * $const.joint_price + $input.edge_binding_length * (${input.is_membrane_edge} ? $const.membrane_price : $const.normal_edge_price), 2)",
|
||
"formula_constants": {
|
||
"threshold": 0.25,
|
||
"processing_under": 2,
|
||
"processing_above": 5,
|
||
"processing": 2,
|
||
"joint_price": 66,
|
||
"normal_edge_price": 4.5,
|
||
"membrane_price": 6
|
||
},
|
||
"surcharge_json": {},
|
||
"formula_note": "布带输送带成本:卷料成本+2元/㎡+接头宽度×66+包边长度×4.5(膜包边×6);0.25mm以上改为+5元/㎡"
|
||
}
|
||
```
|
||
|
||
> **注意**:输送带示例中 `processing` 常量需根据产品厚度动态设置(≤0.25mm 设为 2,>0.25mm 设为 5)。这在管理界面配置时,根据产品规格的厚度值选择填入对应数字即可。如果希望完全自动化,可在 formula_constants 中配置阈值判断,由引擎根据 `product.thickness` 自动选择(见 4.3 节扩展机制)。
|
||
|
||
### 4.3 引擎核心
|
||
|
||
```python
|
||
import re
|
||
from decimal import Decimal
|
||
|
||
class PricingEngine:
|
||
"""配置驱动的报价计算引擎"""
|
||
|
||
def calculate(self, rule, user_inputs, product_attrs=None, supplier_id=None, customer_id=None):
|
||
"""
|
||
rule: PricingRule ORM 对象
|
||
user_inputs: 前端传入的用户输入 {"length": 2, "width": 1.2, "length_unit": "m", ...}
|
||
product_attrs: 产品属性 {"thickness": 0.08, ...}(用于阈值判断)
|
||
"""
|
||
# 1. 单位换算
|
||
normalized = self._normalize_inputs(rule, user_inputs)
|
||
|
||
# 2. 计算基础成本(公式求值)
|
||
base_cost = self._evaluate_formula(rule.formula_expr, {
|
||
"input": normalized,
|
||
"rule": {"base_unit_price": rule.base_unit_price},
|
||
"const": rule.formula_constants or {},
|
||
"product": product_attrs or {},
|
||
})
|
||
|
||
# 3. 计算附加费
|
||
surcharges = self._calculate_surcharges(
|
||
rule.surcharge_json, user_inputs, normalized, product_attrs
|
||
)
|
||
total_surcharge = sum(s["amount"] for s in surcharges)
|
||
cost_price = base_cost + total_surcharge
|
||
|
||
# 4. 查询价格层级
|
||
tier_prices = {}
|
||
if customer_id:
|
||
tier_prices = db.get_price_tiers(rule.product_id, customer_id)
|
||
|
||
return {
|
||
"cost_price": round(cost_price, 2),
|
||
"base_cost": round(base_cost, 2),
|
||
"surcharge_items": surcharges,
|
||
"total_surcharge": round(total_surcharge, 2),
|
||
"sale_price_tier": tier_prices,
|
||
"formula_detail": self._build_formula_detail(rule, normalized, base_cost, surcharges),
|
||
}
|
||
|
||
def _evaluate_formula(self, expr, context):
|
||
"""
|
||
公式表达式求值器。
|
||
支持:四则运算、变量替换、条件表达式、round函数。
|
||
安全限制:只允许数学运算,不允许任意代码执行。
|
||
"""
|
||
resolved = expr
|
||
# 替换 $input.xxx / $rule.xxx / $const.xxx / $product.xxx
|
||
for prefix, values in context.items():
|
||
for key, val in values.items():
|
||
resolved = resolved.replace(f"${prefix}.{key}", str(val))
|
||
# 替换 ${condition} ? a : b 中的条件
|
||
resolved = self._resolve_conditionals(resolved)
|
||
# 求值
|
||
return float(eval(resolved, {"__builtins__": {}}, {"round": round}))
|
||
|
||
def _calculate_surcharges(self, surcharge_json, user_inputs, normalized, product_attrs):
|
||
"""遍历 surcharge_json 中用户勾选的项,逐项计算"""
|
||
results = []
|
||
area = normalized.get("length_m", 0) * normalized.get("width_m", 0)
|
||
for key, config in (surcharge_json or {}).items():
|
||
if not user_inputs.get(f"surcharge_{key}", False):
|
||
continue
|
||
amount = 0
|
||
if config["method"] == "per_sqm":
|
||
amount = area * config["price"]
|
||
elif config["method"] == "per_piece":
|
||
amount = user_inputs.get(config.get("input_key", key), 0) * config["price"]
|
||
elif config["method"] == "per_linear_m":
|
||
amount = user_inputs.get(config.get("input_key", key), 0) * config["price"]
|
||
elif config["method"] == "per_sqm_threshold":
|
||
threshold = config.get("threshold", {})
|
||
field_val = product_attrs.get(threshold.get("field", ""), 0)
|
||
price = threshold["price_true"] if field_val <= threshold.get("lte", 0) else threshold["price_false"]
|
||
amount = area * price
|
||
results.append({"key": key, "name": config["name"], "method": config["method"],
|
||
"amount": round(amount, 2)})
|
||
return results
|
||
```
|
||
|
||
**引擎只有约 60 行核心代码**,没有产品特定的 if/else。所有产品差异都由配置驱动。
|
||
|
||
### 4.4 新增产品的操作步骤(纯配置,不改代码)
|
||
|
||
1. 在「产品管理」中新增产品 → 填写名称、规格、厚度、克重等
|
||
2. 在「定价规则」中新增:
|
||
- 填写 `pricing_inputs`:在界面勾选/配置该产品需要的输入字段(长度、宽度、接头宽度等)
|
||
- 填写 `formula_expr`:输入公式表达式
|
||
- 填写 `formula_constants`:填写公式中的常量值
|
||
- 填写 `surcharge_json`:配置可选加工项(如有)
|
||
- 填写 `formula_note`:给业务员看的说明
|
||
3. 在「供应商成本」中录入各供应商的采购价
|
||
4. 在「价格层级」中录入各层级的销售价
|
||
|
||
**整个过程在管理界面完成,不需要任何人写代码。**
|
||
|
||
### 4.5 边界情况与安全约束
|
||
|
||
**公式表达式的安全限制**:
|
||
|
||
引擎的 `_evaluate_formula` 方法只允许数学运算,不允许:
|
||
- 任意 Python 代码执行
|
||
- 文件/网络访问
|
||
- 函数调用(仅预定义的 `round`)
|
||
|
||
实现方式:替换变量后,用 `eval(expr, {"__builtins__": {}}, {"round": round})` 限制执行环境。
|
||
|
||
**公式复杂度上限**:
|
||
|
||
当前设计覆盖 Excel 中全部已知公式。如果将来出现无法用四则运算+条件表达式描述的逻辑(例如需要循环、查表、调用外部 API),说明该产品确实需要代码扩展。此时的扩展方式:在引擎中新增一个 `custom_function` 注册机制,允许注册 Python 函数作为公式中的函数调用(如 `$func.special_pricing()`)。但这属于远期扩展,当前不需要实现。
|
||
|
||
### 4.6 报价计算接口设计
|
||
|
||
```
|
||
POST /api/quotation/calculate
|
||
|
||
Request:
|
||
{
|
||
"product_id": 123,
|
||
"supplier_id": null,
|
||
"customer_id": 456,
|
||
"user_inputs": { // 统一输入格式:前端根据 pricing_inputs 动态生成
|
||
"length": 2.0,
|
||
"width": 1.2,
|
||
"length_unit": "m",
|
||
"width_unit": "m",
|
||
"joint_width": null, // 输送带专用,其他产品为 null
|
||
"edge_binding_length": null,
|
||
"is_membrane_edge": null
|
||
},
|
||
"surcharge_selections": { // 勾选的附加费项
|
||
"edge_binding": true,
|
||
"large_printing": true,
|
||
"small_logo": false,
|
||
"button": false
|
||
},
|
||
"surcharge_inputs": { // 附加费的自定义输入值
|
||
"logo_quantity": 0,
|
||
"button_count": 0
|
||
}
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"product_name": "0.7mm 硅胶烤垫",
|
||
"area_sqm": 2.4,
|
||
"base_cost": 94.90,
|
||
"surcharge_items": [
|
||
{"key": "edge_binding", "name": "包边", "method": "per_sqm", "amount": 26.40},
|
||
{"key": "large_printing", "name": "大印刷", "method": "per_sqm", "amount": 16.80}
|
||
],
|
||
"total_surcharge": 43.20,
|
||
"cost_price": 138.10,
|
||
"sale_price_tier": {
|
||
"special": 118.80,
|
||
"tier1": 129.60,
|
||
"tier2": 144.00
|
||
},
|
||
"recommended_sale_price": 129.60,
|
||
"formula_detail": "2.0m × 1.2m × 39.54 = 94.90 + 包边 26.40 + 印刷 16.80 = 138.10",
|
||
"available_surcharge_options": [
|
||
{"key": "edge_binding", "name": "包边", "method": "per_sqm", "price": 11, "needs_input": false},
|
||
{"key": "large_printing", "name": "大印刷", "method": "per_sqm", "price": 7, "needs_input": false},
|
||
{"key": "small_logo", "name": "小logo", "method": "per_piece", "price": 2, "needs_input": true, "input_key": "logo_quantity"},
|
||
{"key": "button", "name": "钉扣", "method": "per_piece", "price": 2, "needs_input": true, "input_key": "button_count"}
|
||
]
|
||
}
|
||
```
|
||
|
||
> 前端通过 `pricing_inputs`(获取输入表单结构)和 `available_surcharge_options`(获取附加费选项)实现完全动态渲染,不硬编码任何产品特定逻辑。
|
||
|
||
---
|
||
|
||
## 五、订单明细扩展
|
||
|
||
### 5.1 sales_order_item 增加字段
|
||
|
||
```sql
|
||
ALTER TABLE `sales_order_item` ADD COLUMN `pricing_type` varchar(32) DEFAULT NULL COMMENT '计价类型快照';
|
||
ALTER TABLE `sales_order_item` ADD COLUMN `length_m` decimal(10,4) DEFAULT NULL COMMENT '长度(米)';
|
||
ALTER TABLE `sales_order_item` ADD COLUMN `width_m` decimal(10,4) DEFAULT NULL COMMENT '宽度(米)';
|
||
ALTER TABLE `sales_order_item` ADD COLUMN `area_sqm` decimal(18,4) DEFAULT NULL COMMENT '面积(㎡)';
|
||
ALTER TABLE `sales_order_item` ADD COLUMN `surcharge_detail` text COMMENT '附加费明细JSON';
|
||
ALTER TABLE `sales_order_item` ADD COLUMN `processing_detail` text COMMENT '加工费明细JSON';
|
||
ALTER TABLE `sales_order_item` ADD COLUMN `supplier_id` bigint DEFAULT NULL COMMENT '供应商ID';
|
||
ALTER TABLE `sales_order_item` ADD COLUMN `supplier_model` varchar(64) DEFAULT NULL COMMENT '供应商型号';
|
||
ALTER TABLE `sales_order_item` ADD COLUMN `price_tier` varchar(32) DEFAULT NULL COMMENT '价格层级快照';
|
||
```
|
||
|
||
### 5.2 订单创建流程变化
|
||
|
||
**现有流程**:手动填 sale_price 和 cost_price → 系统算总额
|
||
|
||
**新流程**:
|
||
1. 业务员选择产品 → 选择规格 → 填写尺寸(长/宽) → 勾选加工项
|
||
2. 前端调用 `/api/quotation/calculate` 获取成本价和推荐售价
|
||
3. 业务员确认或调整售价 → 保存为订单明细
|
||
4. 系统记录完整的计价过程(dimensions、surcharge_detail、pricing_type 等),保证可追溯
|
||
|
||
---
|
||
|
||
## 六、后端 API 设计
|
||
|
||
### 6.1 定价规则管理
|
||
|
||
```
|
||
GET /api/pricing-rules # 列表查询
|
||
GET /api/pricing-rules/{product_id} # 获取某产品的定价规则
|
||
POST /api/prricing-rules # 新增定价规则
|
||
PUT /api/pricing-rules/{id} # 更新定价规则
|
||
```
|
||
|
||
### 6.2 供应商成本管理
|
||
|
||
```
|
||
GET /api/supplier-costs?product_id={id} # 某产品的各供应商成本
|
||
POST /api/supplier-costs # 新增供应商成本
|
||
PUT /api/supplier-costs/{id} # 更新供应商成本
|
||
DELETE /api/supplier-costs/{id} # 删除
|
||
```
|
||
|
||
### 6.3 价格层级管理
|
||
|
||
```
|
||
GET /api/price-tiers?product_id={id} # 某产品的价格层级
|
||
POST /api/price-tiers # 新增价格层级
|
||
PUT /api/price-tiers/{id} # 更新
|
||
DELETE /api/price-tiers/{id} # 删除
|
||
```
|
||
|
||
### 6.4 报价计算
|
||
|
||
```
|
||
POST /api/quotation/calculate # 计算报价(见 4.2 接口设计)
|
||
GET /api/quotation/preview/{product_id} # 快速预览(默认参数)
|
||
```
|
||
|
||
### 6.5 产品管理接口扩展
|
||
|
||
现有 `/api/products` 接口增加:
|
||
- 创建/更新产品时,同时写入 `product_pricing_rule`
|
||
- 产品列表返回时包含 `pricing_type`、`pricing_unit` 信息
|
||
- 产品详情返回时包含供应商成本列表和价格层级
|
||
|
||
---
|
||
|
||
## 七、前端改动范围
|
||
|
||
### 7.1 产品管理页面(web-admin)
|
||
|
||
- 产品新增/编辑表单增加:计价类型、计价单位、厚度、克重、默认宽度
|
||
- 产品详情页增加:定价规则配置、供应商成本列表、价格层级列表
|
||
- 新增「定价规则管理」菜单项
|
||
|
||
### 7.2 订单创建页面(web-sales)
|
||
|
||
- 订单明细行增加:尺寸输入(长/宽 + 单位选择)、附加费勾选
|
||
- 选择产品后自动触发报价计算,实时显示成本价和推荐售价
|
||
- 支持指定供应商(下拉选择,显示各供应商成本对比)
|
||
|
||
### 7.3 小程序端(mini-manager)
|
||
|
||
- 订单创建同 web-sales 的尺寸输入和自动计算
|
||
|
||
---
|
||
|
||
## 八、数据迁移策略
|
||
|
||
### 8.1 从 Excel 迁移产品数据
|
||
|
||
Excel 8个Sheet → 对应 8个产品分类 → 每个规格行创建为 Product 记录
|
||
|
||
**迁移脚本逻辑**:
|
||
1. 创建产品分类:揉面垫、硅胶烤垫、日用高温布、特氟龙玻纤胶带、特氟龙纯膜胶带、高温布、公斤布、输送带
|
||
2. 解析每个Sheet的规格行,创建 Product 记录(product_name = 产品品类,specification = 规格参数)
|
||
3. 从"恒宇型号"列提取我司型号
|
||
4. 从供应商列提取成本价,创建 supplier_product_cost 记录
|
||
5. 从价格层级列创建 product_price_tier 记录
|
||
6. 根据Sheet类型创建 product_pricing_rule 记录
|
||
|
||
### 8.2 迁移数据量估算
|
||
|
||
| Sheet | 预估产品数 | 预估供应商成本记录 | 预估价格层级记录 |
|
||
|-------|-----------|-------------------|----------------|
|
||
| 揉面垫 | 2 | 2 | 2 |
|
||
| 硅胶烤垫 | 3 | 3 | 3 |
|
||
| 日用高温布 | 12 | 12×1~2 | 12 |
|
||
| 特氟龙玻纤胶带 | 8 | 8×2~3 | 8×3 |
|
||
| 特氟龙纯膜胶带 | 6 | 6×2 | 6×2 |
|
||
| 高温布 | 16 | 16×3~5 | 16×3 |
|
||
| 公斤布 | 11 | 11×1~3 | 11 |
|
||
| 输送带 | 17 | 17×1 | 17×3 |
|
||
| **合计** | **~75** | **~200+** | **~250+** |
|
||
|
||
---
|
||
|
||
## 九、实现分期建议
|
||
|
||
### 第一期:数据模型 + 产品管理(预计 2-3 天)
|
||
|
||
- 新增 3 张数据库表(product_pricing_rule、supplier_product_cost、product_price_tier)
|
||
- product 表补充字段
|
||
- crm_customer 增加 price_tier 字段
|
||
- 后端 API:定价规则、供应商成本、价格层级的 CRUD
|
||
- 前端:产品管理页面增加定价规则配置
|
||
- 数据迁移脚本:从 Excel 导入产品数据
|
||
|
||
### 第二期:报价引擎 + 订单集成(预计 2-3 天)
|
||
|
||
- 后端:POST /api/quotation/calculate 报价计算服务
|
||
- sales_order_item 增加字段
|
||
- 订单创建流程集成报价计算
|
||
- 前端:订单创建页面增加尺寸输入和自动报价
|
||
- 前端:供应商选择和成本对比
|
||
|
||
### 第三期:优化(预计 1-2 天)
|
||
|
||
- 报价单导出(PDF)
|
||
- 价格变更历史记录
|
||
- 批量导入/更新价格
|
||
- 报表:按供应商的成本分析
|
||
|
||
---
|
||
|
||
## 十、注意事项
|
||
|
||
1. **向后兼容**:pricing_type 字段为 nullable,现有产品不受影响,定价类型为 null 时按原有固定单价逻辑处理
|
||
2. **单位换算**:所有计算在后端统一转换为米后计算,前端只做输入
|
||
3. **精度**:金额保留 2 位小数,中间计算保留 4 位
|
||
4. **权限**:供应商成本价仅 admin 和 manager 可见(对 salesman 隐藏),与现有 `_filter_order_detail_by_role` 逻辑保持一致
|
||
5. **审计**:定价规则和价格层级的修改需写入 audit_log
|