| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252 |
- // ============================================================
- // 拉迷全屋定制 · 报价引擎(纯函数,浏览器/CLI 共用)
- // 两种模式:
- // quick — AI快速粗略报价:投影面积/厨房延米 × 三档材质区间单价
- // detail — 明细报价:Excel 预算表模型(柜体+门板补差+附件逐项)
- // 销售策略:客户分型 → 方案/折扣/浮动 → 活动叠加 → 谈价区间
- // ============================================================
- import {
- PRICE_TIERS, DEFAULT_TIER, CUSTOMER_TIERS, QUOTATION_PLANS,
- CABINET_EXTRA_TEMPLATES, EXTRA_METHOD_MAP, PRICE_BOOK,
- KITCHEN_RULES, DEFAULTS, ACTIVITY_MODULES, SELLING_POINTS,
- } from './defaults.js';
- const r2 = (v) => Math.round(v * 100) / 100;
- const num = (v, d = 0) => { const n = parseFloat(v); return Number.isFinite(n) ? n : d; };
- // ---------- 材质档位 ----------
- export function getTier(name) {
- const t = PRICE_TIERS.find((x) => x.name === (name || DEFAULT_TIER));
- return t || PRICE_TIERS[1];
- }
- // 浮动百分比(10~15)在区间内线性插值 → 单价(qGetAiUnitPrice 同源逻辑)
- export function tierUnitPrice(tier, floatPct = 15) {
- const k = Math.min(1, Math.max(0, (num(floatPct, 15) - 10) / 5));
- return r2(tier.min + k * (tier.max - tier.min));
- }
- // ---------- 厨房计费米数(qGetKitchenBillableMeters 同源)----------
- export function kitchenBillable(walls = [], rules = {}) {
- const R = { ...KITCHEN_RULES, ...rules };
- const ws = (Array.isArray(walls) ? walls : []).map((v) => num(v)).filter((v) => v > 0);
- if (!ws.length) return null;
- const total = ws.reduce((a, b) => a + b, 0);
- const minWall = Math.min(...ws);
- const maxWall = Math.max(...ws);
- const type = minWall <= R.typeThresholdM ? 'L' : 'U';
- const overlapCount = type === 'L' ? 1 : 2;
- const usedLen = type === 'L' ? maxWall + minWall : maxWall + maxWall + minWall;
- const billable = Math.max(0, usedLen - (overlapCount * R.overlapMm) / 1000 - R.fridgeMm / 1000);
- return {
- walls: ws, total: r2(total), minWall: r2(minWall), maxWall: r2(maxWall),
- kitchenType: type, overlapCount, fridgeMm: R.fridgeMm, usedLen: r2(usedLen), billable: r2(billable),
- };
- }
- export const isKitchen = (name = '') => /厨房|橱柜/.test(String(name));
- // ---------- 附件数量启发式(Excel 模型总结,均可编辑) ----------
- export function estimateExtras(cabinet) {
- const w = num(cabinet.width, 1.5);
- const h = num(cabinet.height, DEFAULTS.height);
- const type = String(cabinet.type || '');
- let tplKey = Object.keys(CABINET_EXTRA_TEMPLATES).find((k) => k !== '_default' && type.includes(k));
- if (!tplKey && isKitchen(type)) tplKey = '厨房';
- const names = CABINET_EXTRA_TEMPLATES[tplKey || '_default'] || CABINET_EXTRA_TEMPLATES._default;
- const sections = h > 1.6 ? 2 : 1; // 高柜分上下段
- const doorsPerSection = Math.max(1, Math.round(w / 0.45));
- const doors = sections * doorsPerSection;
- const hingePerDoor = h > 2 ? 3 : 2;
- const out = [];
- for (const name of names) {
- const pb = PRICE_BOOK[name];
- if (!pb) continue;
- let qty = 0; let note = '';
- switch (name) {
- case '铰链': qty = doors * hingePerDoor; note = `${doors}门×${hingePerDoor}铰链`; break;
- case '自弹器': qty = Math.max(1, doors - 1); note = '反弹门配'; break;
- case '见光板': qty = 1; note = '单侧见光(0.5m宽)'; break;
- case '灯带': qty = r2(w * 2); note = '顶底双层'; break;
- case '变压器+开关': qty = 1; note = 'LED电源'; break;
- case '衣杆': qty = r2(w); note = '通长挂杆'; break;
- case '抽屉': qty = type.includes('主卧') || type.includes('衣帽') ? 3 : 2; break;
- case '金属拉手': case '拉手': qty = doors + 2; break;
- case '碗篮': case '锅篮': qty = 1; break;
- default: qty = name === '玻璃门升级' || name === '护墙板' ? 0 : 1;
- }
- if (qty > 0) out.push({ name, method: pb.method, unit: pb.unit, unitPrice: pb.price, qty, note });
- }
- return out;
- }
- // ---------- 单柜体计价 ----------
- // 计价数量:投影面积 = 宽×高;厨房 = 计费延米
- export function cabinetQty(cabinet, rules = {}) {
- if (isKitchen(cabinet.type)) {
- const kb = kitchenBillable(cabinet.walls || [cabinet.width, cabinet.height, cabinet.width, cabinet.height], rules);
- return { qty: kb ? kb.billable : num(cabinet.width), unit: 'm', kb };
- }
- return { qty: r2(num(cabinet.width) * num(cabinet.height)), unit: 'm²', kb: null };
- }
- // 快速模式:单柜体一行
- export function quickLine(cabinet, { tierName, floatPct = 15, rules } = {}) {
- const tier = getTier(tierName);
- const U = tierUnitPrice(tier, floatPct);
- const { qty, unit, kb } = cabinetQty(cabinet, rules);
- return {
- mode: 'quick', name: cabinet.type, tierName: tier.name,
- qty, unit, unitPrice: U, total: r2(qty * U), kb,
- dims: num(cabinet.width) && num(cabinet.height) ? `${num(cabinet.width)}m×${num(cabinet.height)}m` : '',
- };
- }
- // 明细模式:单柜体 → Excel 风格明细行
- export function detailLines(cabinet, opts = {}) {
- const { doorDiffPrice = PRICE_BOOK['柜体投影单价'].price > 0 ? 538 : 538, rules, includeDoorDiff = true } = opts;
- const lines = [];
- const isK = isKitchen(cabinet.type);
- const { qty } = cabinetQty(cabinet, rules);
- if (isK) {
- // 厨房单件计价(Excel 模式:地柜+吊柜+台面+功能五金)
- const walls = cabinet.walls || [cabinet.width, cabinet.height, cabinet.width, cabinet.height];
- const straight = Math.max(0, ...walls); // 直线段米数近似
- for (const [name, factor, note] of [
- ['地柜', 1, '沿墙地柜'], ['吊柜', 1, '沿墙吊柜'], ['台面', 1, '石英石台面'],
- ]) {
- const pb = PRICE_BOOK[name];
- lines.push({ name, qty: r2(qty), unit: 'm', unitPrice: pb.price, total: r2(qty * pb.price), note, method: pb.method });
- }
- for (const name of ['碗篮', '锅篮']) {
- const pb = PRICE_BOOK[name];
- lines.push({ name, qty: 1, unit: '个', unitPrice: pb.price, total: pb.price, note: '标准配置', method: pb.method });
- }
- return { cabinet, isKitchen: true, lines, subtotal: r2(lines.reduce((s, l) => s + l.total, 0)) };
- }
- const pb = PRICE_BOOK['生态澳松板柜体'];
- lines.push({ name: '柜体', qty, unit: 'm²', unitPrice: pb.price, total: r2(qty * pb.price), note: '生态澳松板', method: pb.method });
- if (includeDoorDiff && doorDiffPrice > 0) {
- lines.push({ name: '板门补差价', qty, unit: 'm²', unitPrice: doorDiffPrice, total: r2(qty * doorDiffPrice), note: '双饰面门补差', method: pb.method });
- }
- for (const ex of estimateExtras(cabinet)) {
- const total = ex.method === '按投影面积' ? r2(ex.qty * 0.5 * num(cabinet.height) * ex.unitPrice)
- : ex.method === '按延米' ? r2(ex.qty * ex.unitPrice)
- : r2(ex.qty * ex.unitPrice);
- lines.push({ name: ex.name, qty: ex.qty, unit: ex.unit, unitPrice: ex.unitPrice, total, note: ex.note, method: ex.method });
- }
- return { cabinet, isKitchen: false, lines, subtotal: r2(lines.reduce((s, l) => s + l.total, 0)) };
- }
- // ---------- 报价主入口 ----------
- // inputs: { cabinets:[{type,width,height,walls?,excluded?}], tier, floatPct, discount, tierKey(客户分型), planId, mode }
- export function buildQuote(input = {}) {
- const {
- cabinets = [], mode = 'quick',
- tierName = input.tier || DEFAULT_TIER,
- floatPct = 15, discount = null, tierKey = 'standard', planId = null,
- rules = {}, extras = [], // extras: [{name, room, qty, unitPrice}] 手动增删项
- clientName = '', note = '',
- } = input;
- const plan = QUOTATION_PLANS.find((p) => p.id === (planId || CUSTOMER_TIERS[tierKey]?.defaultPlan)) || QUOTATION_PLANS[1];
- const custTier = CUSTOMER_TIERS[tierKey] || CUSTOMER_TIERS.standard;
- const effDiscount = discount != null ? num(discount) : (custTier.defaultDiscount ?? plan.discountRate ?? 1);
- const rooms = cabinets
- .filter((c) => !c.excluded)
- .map((c, i) => {
- const base = mode === 'detail' ? detailLines(c, { rules }) : { ...quickLine(c, { tierName, floatPct, rules }), lines: null };
- const id = c.id || `cab_${i + 1}`;
- return { id, type: c.type, dims: base.dims || `${num(c.width)}m×${num(c.height)}m`, ...base };
- });
- const itemsSubtotal = r2(rooms.reduce((s, r) => s + (r.subtotal ?? r.total), 0));
- const manualExtras = extras.map((e, i) => ({
- id: e.id || `ext_${i + 1}`, name: e.name, qty: num(e.qty, 1), unit: e.unit || '项',
- unitPrice: num(e.unitPrice), total: r2(num(e.qty, 1) * num(e.unitPrice)), kind: e.kind || 'add',
- }));
- const extrasTotal = r2(manualExtras.reduce((s, e) => s + (e.kind === 'remove' ? -e.total : e.total), 0));
- const premiumRate = custTier.premiumRate || 1;
- const afterPremium = r2((itemsSubtotal + extrasTotal) * premiumRate);
- const afterDiscount = r2(afterPremium * effDiscount);
- // 活动叠加(自动/手动)
- const acts = (input.activities || (custTier.autoEnableActivity ? ACTIVITY_MODULES.map((m) => m.id) : []))
- .map((id) => ACTIVITY_MODULES.find((m) => m.id === id)).filter(Boolean);
- let activityTotal = 0;
- const activityLines = [];
- for (const m of acts) {
- let amt = 0; let desc = m.desc || m.name;
- if (m.type === 'tiered_cashback') {
- const hit = [...(m.tiers || [])].sort((a, b) => b.min - a.min).find((t) => afterDiscount >= t.min);
- if (!hit) continue;
- amt = hit.cash; desc = `满¥${hit.min.toLocaleString()}返¥${hit.cash.toLocaleString()}`;
- } else if (m.type === 'coupon' && afterDiscount < (m.threshold || 0)) continue;
- else if (m.type === 'deposit' && afterDiscount < (m.minOrder || 30000)) continue;
- else amt = m.amount || 0;
- activityTotal += amt;
- activityLines.push({ id: m.id, name: m.name, desc, amount: amt });
- }
- const finalPrice = Math.max(0, r2(afterDiscount - activityTotal));
- // 谈价区间(浮动率)
- const rate = num(floatPct, 15) / 100;
- const range = { low: Math.round(finalPrice * (1 - rate)), high: Math.round(finalPrice * (1 + rate)) };
- // 业主视角价值点
- const selling = SELLING_POINTS[tierKey] || SELLING_POINTS.standard;
- return {
- meta: {
- clientName, note: note || custTier.strategy, mode,
- tierName: mode === 'quick' ? getTier(tierName).name : undefined,
- floatPct, planId: plan.id, planName: plan.name,
- customerTier: tierKey, customerLabel: custTier.label,
- discount: effDiscount, premiumRate: premiumRate !== 1 ? premiumRate : undefined,
- generatedAt: new Date().toISOString(),
- },
- rooms, manualExtras, activityLines,
- totals: {
- itemsSubtotal, extrasTotal, premiumRate, afterPremium, discount: effDiscount,
- afterDiscount, activityTotal, finalPrice, range,
- discountLabel: custTier.discounts?.find((d) => Math.abs(d.rate - effDiscount) < 0.001)?.label || `折扣${Math.round(effDiscount * 100)}%`,
- },
- selling,
- config: { tierKey, tierName, floatPct, discount: effDiscount, planId: plan.id, mode },
- };
- }
- // ---------- 语义化调整应用(销售边聊边改) ----------
- // ops: [{op:'add'|'remove'|'resize'|'rename'|'price', target, ...}]
- export function applyOps(cabinets, ops = []) {
- const out = cabinets.map((c) => ({ ...c }));
- for (const op of ops) {
- if (op.op === 'add') {
- out.push({ type: op.type || op.name || '柜体', width: num(op.width, 1.5), height: num(op.height, DEFAULTS.height), from: '语音新增' });
- } else if (op.op === 'remove') {
- const i = out.findIndex((c) => c.type === op.target);
- if (i >= 0) out.splice(i, 1);
- } else if (op.op === 'resize') {
- const c = out.find((c) => c.type === op.target);
- if (c) { if (op.width) c.width = num(op.width); if (op.height) c.height = num(op.height); c.from = '手动调整'; }
- } else if (op.op === 'rename') {
- const c = out.find((c) => c.type === op.target);
- if (c) c.type = op.to || c.type;
- } else if (op.op === 'exclude') {
- const c = out.find((c) => c.type === op.target);
- if (c) c.excluded = true;
- } else if (op.op === 'include') {
- const c = out.find((c) => c.type === op.target);
- if (c) delete c.excluded;
- }
- }
- return out;
- }
- // 授权范围内校验(浮动区间即授权边界)
- export function withinAuthority(quote, price) {
- const { range } = quote.totals;
- return price >= range.low && price <= range.high;
- }
- export default { buildQuote, applyOps, quickLine, detailLines, kitchenBillable, tierUnitPrice, getTier, withinAuthority };
|