// ============================================================ // 拉迷全屋定制 · 报价引擎(纯函数,浏览器/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 };