quote.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. // ============================================================
  2. // 拉迷全屋定制 · 报价引擎(纯函数,浏览器/CLI 共用)
  3. // 两种模式:
  4. // quick — AI快速粗略报价:投影面积/厨房延米 × 三档材质区间单价
  5. // detail — 明细报价:Excel 预算表模型(柜体+门板补差+附件逐项)
  6. // 销售策略:客户分型 → 方案/折扣/浮动 → 活动叠加 → 谈价区间
  7. // ============================================================
  8. import {
  9. PRICE_TIERS, DEFAULT_TIER, CUSTOMER_TIERS, QUOTATION_PLANS,
  10. CABINET_EXTRA_TEMPLATES, EXTRA_METHOD_MAP, PRICE_BOOK,
  11. KITCHEN_RULES, DEFAULTS, ACTIVITY_MODULES, SELLING_POINTS,
  12. } from './defaults.js';
  13. const r2 = (v) => Math.round(v * 100) / 100;
  14. const num = (v, d = 0) => { const n = parseFloat(v); return Number.isFinite(n) ? n : d; };
  15. // ---------- 材质档位 ----------
  16. export function getTier(name) {
  17. const t = PRICE_TIERS.find((x) => x.name === (name || DEFAULT_TIER));
  18. return t || PRICE_TIERS[1];
  19. }
  20. // 浮动百分比(10~15)在区间内线性插值 → 单价(qGetAiUnitPrice 同源逻辑)
  21. export function tierUnitPrice(tier, floatPct = 15) {
  22. const k = Math.min(1, Math.max(0, (num(floatPct, 15) - 10) / 5));
  23. return r2(tier.min + k * (tier.max - tier.min));
  24. }
  25. // ---------- 厨房计费米数(qGetKitchenBillableMeters 同源)----------
  26. export function kitchenBillable(walls = [], rules = {}) {
  27. const R = { ...KITCHEN_RULES, ...rules };
  28. const ws = (Array.isArray(walls) ? walls : []).map((v) => num(v)).filter((v) => v > 0);
  29. if (!ws.length) return null;
  30. const total = ws.reduce((a, b) => a + b, 0);
  31. const minWall = Math.min(...ws);
  32. const maxWall = Math.max(...ws);
  33. const type = minWall <= R.typeThresholdM ? 'L' : 'U';
  34. const overlapCount = type === 'L' ? 1 : 2;
  35. const usedLen = type === 'L' ? maxWall + minWall : maxWall + maxWall + minWall;
  36. const billable = Math.max(0, usedLen - (overlapCount * R.overlapMm) / 1000 - R.fridgeMm / 1000);
  37. return {
  38. walls: ws, total: r2(total), minWall: r2(minWall), maxWall: r2(maxWall),
  39. kitchenType: type, overlapCount, fridgeMm: R.fridgeMm, usedLen: r2(usedLen), billable: r2(billable),
  40. };
  41. }
  42. export const isKitchen = (name = '') => /厨房|橱柜/.test(String(name));
  43. // ---------- 附件数量启发式(Excel 模型总结,均可编辑) ----------
  44. export function estimateExtras(cabinet) {
  45. const w = num(cabinet.width, 1.5);
  46. const h = num(cabinet.height, DEFAULTS.height);
  47. const type = String(cabinet.type || '');
  48. let tplKey = Object.keys(CABINET_EXTRA_TEMPLATES).find((k) => k !== '_default' && type.includes(k));
  49. if (!tplKey && isKitchen(type)) tplKey = '厨房';
  50. const names = CABINET_EXTRA_TEMPLATES[tplKey || '_default'] || CABINET_EXTRA_TEMPLATES._default;
  51. const sections = h > 1.6 ? 2 : 1; // 高柜分上下段
  52. const doorsPerSection = Math.max(1, Math.round(w / 0.45));
  53. const doors = sections * doorsPerSection;
  54. const hingePerDoor = h > 2 ? 3 : 2;
  55. const out = [];
  56. for (const name of names) {
  57. const pb = PRICE_BOOK[name];
  58. if (!pb) continue;
  59. let qty = 0; let note = '';
  60. switch (name) {
  61. case '铰链': qty = doors * hingePerDoor; note = `${doors}门×${hingePerDoor}铰链`; break;
  62. case '自弹器': qty = Math.max(1, doors - 1); note = '反弹门配'; break;
  63. case '见光板': qty = 1; note = '单侧见光(0.5m宽)'; break;
  64. case '灯带': qty = r2(w * 2); note = '顶底双层'; break;
  65. case '变压器+开关': qty = 1; note = 'LED电源'; break;
  66. case '衣杆': qty = r2(w); note = '通长挂杆'; break;
  67. case '抽屉': qty = type.includes('主卧') || type.includes('衣帽') ? 3 : 2; break;
  68. case '金属拉手': case '拉手': qty = doors + 2; break;
  69. case '碗篮': case '锅篮': qty = 1; break;
  70. default: qty = name === '玻璃门升级' || name === '护墙板' ? 0 : 1;
  71. }
  72. if (qty > 0) out.push({ name, method: pb.method, unit: pb.unit, unitPrice: pb.price, qty, note });
  73. }
  74. return out;
  75. }
  76. // ---------- 单柜体计价 ----------
  77. // 计价数量:投影面积 = 宽×高;厨房 = 计费延米
  78. export function cabinetQty(cabinet, rules = {}) {
  79. if (isKitchen(cabinet.type)) {
  80. const kb = kitchenBillable(cabinet.walls || [cabinet.width, cabinet.height, cabinet.width, cabinet.height], rules);
  81. return { qty: kb ? kb.billable : num(cabinet.width), unit: 'm', kb };
  82. }
  83. return { qty: r2(num(cabinet.width) * num(cabinet.height)), unit: 'm²', kb: null };
  84. }
  85. // 快速模式:单柜体一行
  86. export function quickLine(cabinet, { tierName, floatPct = 15, rules } = {}) {
  87. const tier = getTier(tierName);
  88. const U = tierUnitPrice(tier, floatPct);
  89. const { qty, unit, kb } = cabinetQty(cabinet, rules);
  90. return {
  91. mode: 'quick', name: cabinet.type, tierName: tier.name,
  92. qty, unit, unitPrice: U, total: r2(qty * U), kb,
  93. dims: num(cabinet.width) && num(cabinet.height) ? `${num(cabinet.width)}m×${num(cabinet.height)}m` : '',
  94. };
  95. }
  96. // 明细模式:单柜体 → Excel 风格明细行
  97. export function detailLines(cabinet, opts = {}) {
  98. const { doorDiffPrice = PRICE_BOOK['柜体投影单价'].price > 0 ? 538 : 538, rules, includeDoorDiff = true } = opts;
  99. const lines = [];
  100. const isK = isKitchen(cabinet.type);
  101. const { qty } = cabinetQty(cabinet, rules);
  102. if (isK) {
  103. // 厨房单件计价(Excel 模式:地柜+吊柜+台面+功能五金)
  104. const walls = cabinet.walls || [cabinet.width, cabinet.height, cabinet.width, cabinet.height];
  105. const straight = Math.max(0, ...walls); // 直线段米数近似
  106. for (const [name, factor, note] of [
  107. ['地柜', 1, '沿墙地柜'], ['吊柜', 1, '沿墙吊柜'], ['台面', 1, '石英石台面'],
  108. ]) {
  109. const pb = PRICE_BOOK[name];
  110. lines.push({ name, qty: r2(qty), unit: 'm', unitPrice: pb.price, total: r2(qty * pb.price), note, method: pb.method });
  111. }
  112. for (const name of ['碗篮', '锅篮']) {
  113. const pb = PRICE_BOOK[name];
  114. lines.push({ name, qty: 1, unit: '个', unitPrice: pb.price, total: pb.price, note: '标准配置', method: pb.method });
  115. }
  116. return { cabinet, isKitchen: true, lines, subtotal: r2(lines.reduce((s, l) => s + l.total, 0)) };
  117. }
  118. const pb = PRICE_BOOK['生态澳松板柜体'];
  119. lines.push({ name: '柜体', qty, unit: 'm²', unitPrice: pb.price, total: r2(qty * pb.price), note: '生态澳松板', method: pb.method });
  120. if (includeDoorDiff && doorDiffPrice > 0) {
  121. lines.push({ name: '板门补差价', qty, unit: 'm²', unitPrice: doorDiffPrice, total: r2(qty * doorDiffPrice), note: '双饰面门补差', method: pb.method });
  122. }
  123. for (const ex of estimateExtras(cabinet)) {
  124. const total = ex.method === '按投影面积' ? r2(ex.qty * 0.5 * num(cabinet.height) * ex.unitPrice)
  125. : ex.method === '按延米' ? r2(ex.qty * ex.unitPrice)
  126. : r2(ex.qty * ex.unitPrice);
  127. lines.push({ name: ex.name, qty: ex.qty, unit: ex.unit, unitPrice: ex.unitPrice, total, note: ex.note, method: ex.method });
  128. }
  129. return { cabinet, isKitchen: false, lines, subtotal: r2(lines.reduce((s, l) => s + l.total, 0)) };
  130. }
  131. // ---------- 报价主入口 ----------
  132. // inputs: { cabinets:[{type,width,height,walls?,excluded?}], tier, floatPct, discount, tierKey(客户分型), planId, mode }
  133. export function buildQuote(input = {}) {
  134. const {
  135. cabinets = [], mode = 'quick',
  136. tierName = input.tier || DEFAULT_TIER,
  137. floatPct = 15, discount = null, tierKey = 'standard', planId = null,
  138. rules = {}, extras = [], // extras: [{name, room, qty, unitPrice}] 手动增删项
  139. clientName = '', note = '',
  140. } = input;
  141. const plan = QUOTATION_PLANS.find((p) => p.id === (planId || CUSTOMER_TIERS[tierKey]?.defaultPlan)) || QUOTATION_PLANS[1];
  142. const custTier = CUSTOMER_TIERS[tierKey] || CUSTOMER_TIERS.standard;
  143. const effDiscount = discount != null ? num(discount) : (custTier.defaultDiscount ?? plan.discountRate ?? 1);
  144. const rooms = cabinets
  145. .filter((c) => !c.excluded)
  146. .map((c, i) => {
  147. const base = mode === 'detail' ? detailLines(c, { rules }) : { ...quickLine(c, { tierName, floatPct, rules }), lines: null };
  148. const id = c.id || `cab_${i + 1}`;
  149. return { id, type: c.type, dims: base.dims || `${num(c.width)}m×${num(c.height)}m`, ...base };
  150. });
  151. const itemsSubtotal = r2(rooms.reduce((s, r) => s + (r.subtotal ?? r.total), 0));
  152. const manualExtras = extras.map((e, i) => ({
  153. id: e.id || `ext_${i + 1}`, name: e.name, qty: num(e.qty, 1), unit: e.unit || '项',
  154. unitPrice: num(e.unitPrice), total: r2(num(e.qty, 1) * num(e.unitPrice)), kind: e.kind || 'add',
  155. }));
  156. const extrasTotal = r2(manualExtras.reduce((s, e) => s + (e.kind === 'remove' ? -e.total : e.total), 0));
  157. const premiumRate = custTier.premiumRate || 1;
  158. const afterPremium = r2((itemsSubtotal + extrasTotal) * premiumRate);
  159. const afterDiscount = r2(afterPremium * effDiscount);
  160. // 活动叠加(自动/手动)
  161. const acts = (input.activities || (custTier.autoEnableActivity ? ACTIVITY_MODULES.map((m) => m.id) : []))
  162. .map((id) => ACTIVITY_MODULES.find((m) => m.id === id)).filter(Boolean);
  163. let activityTotal = 0;
  164. const activityLines = [];
  165. for (const m of acts) {
  166. let amt = 0; let desc = m.desc || m.name;
  167. if (m.type === 'tiered_cashback') {
  168. const hit = [...(m.tiers || [])].sort((a, b) => b.min - a.min).find((t) => afterDiscount >= t.min);
  169. if (!hit) continue;
  170. amt = hit.cash; desc = `满¥${hit.min.toLocaleString()}返¥${hit.cash.toLocaleString()}`;
  171. } else if (m.type === 'coupon' && afterDiscount < (m.threshold || 0)) continue;
  172. else if (m.type === 'deposit' && afterDiscount < (m.minOrder || 30000)) continue;
  173. else amt = m.amount || 0;
  174. activityTotal += amt;
  175. activityLines.push({ id: m.id, name: m.name, desc, amount: amt });
  176. }
  177. const finalPrice = Math.max(0, r2(afterDiscount - activityTotal));
  178. // 谈价区间(浮动率)
  179. const rate = num(floatPct, 15) / 100;
  180. const range = { low: Math.round(finalPrice * (1 - rate)), high: Math.round(finalPrice * (1 + rate)) };
  181. // 业主视角价值点
  182. const selling = SELLING_POINTS[tierKey] || SELLING_POINTS.standard;
  183. return {
  184. meta: {
  185. clientName, note: note || custTier.strategy, mode,
  186. tierName: mode === 'quick' ? getTier(tierName).name : undefined,
  187. floatPct, planId: plan.id, planName: plan.name,
  188. customerTier: tierKey, customerLabel: custTier.label,
  189. discount: effDiscount, premiumRate: premiumRate !== 1 ? premiumRate : undefined,
  190. generatedAt: new Date().toISOString(),
  191. },
  192. rooms, manualExtras, activityLines,
  193. totals: {
  194. itemsSubtotal, extrasTotal, premiumRate, afterPremium, discount: effDiscount,
  195. afterDiscount, activityTotal, finalPrice, range,
  196. discountLabel: custTier.discounts?.find((d) => Math.abs(d.rate - effDiscount) < 0.001)?.label || `折扣${Math.round(effDiscount * 100)}%`,
  197. },
  198. selling,
  199. config: { tierKey, tierName, floatPct, discount: effDiscount, planId: plan.id, mode },
  200. };
  201. }
  202. // ---------- 语义化调整应用(销售边聊边改) ----------
  203. // ops: [{op:'add'|'remove'|'resize'|'rename'|'price', target, ...}]
  204. export function applyOps(cabinets, ops = []) {
  205. const out = cabinets.map((c) => ({ ...c }));
  206. for (const op of ops) {
  207. if (op.op === 'add') {
  208. out.push({ type: op.type || op.name || '柜体', width: num(op.width, 1.5), height: num(op.height, DEFAULTS.height), from: '语音新增' });
  209. } else if (op.op === 'remove') {
  210. const i = out.findIndex((c) => c.type === op.target);
  211. if (i >= 0) out.splice(i, 1);
  212. } else if (op.op === 'resize') {
  213. const c = out.find((c) => c.type === op.target);
  214. if (c) { if (op.width) c.width = num(op.width); if (op.height) c.height = num(op.height); c.from = '手动调整'; }
  215. } else if (op.op === 'rename') {
  216. const c = out.find((c) => c.type === op.target);
  217. if (c) c.type = op.to || c.type;
  218. } else if (op.op === 'exclude') {
  219. const c = out.find((c) => c.type === op.target);
  220. if (c) c.excluded = true;
  221. } else if (op.op === 'include') {
  222. const c = out.find((c) => c.type === op.target);
  223. if (c) delete c.excluded;
  224. }
  225. }
  226. return out;
  227. }
  228. // 授权范围内校验(浮动区间即授权边界)
  229. export function withinAuthority(quote, price) {
  230. const { range } = quote.totals;
  231. return price >= range.low && price <= range.high;
  232. }
  233. export default { buildQuote, applyOps, quickLine, detailLines, kitchenBillable, tierUnitPrice, getTier, withinAuthority };