/** * 合同组件工具:金额汇总计算(产品清单 → 各部分金额 / 总金额 / 总成本 / 总利润) * * 金额归属: * - 硬件部分 = Σ 产品类别为「硬件(货物)」的小计 * - 软件部分 = Σ 产品类别为「软件(许可)」的小计 * - 其他部分 = Σ 服务 / 开发 / 其他类小计 * - 合同总金额 = 硬件 + 软件 + 其他 * - 产品总成本 = Σ (数量 × 成本单价),软件开发行按其「开发总成本」计入 * - 合同总利润 = 合同总金额 - 产品总成本 * * 软件开发(开发类)行口径:模块报价中「开发总成本」(默认人天×统一单价,可手工改写) * 作为该行的成本单价并计入总成本;对外金额取用户填写的销售单价,两者差额即为毛利。 * 归类口径:分类代码 1/2 或名称含「硬件」/「软件」分别计入硬件/软件,其余计入其他(与后端一致)。 */ const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100; /** * 软件开发模块树按人天的金额汇总(即「开发总成本」的默认口径): * - 含子节点 → 汇总子节点; * - 叶子节点 → 人天 × 统一人天单价。 */ export function treeSellTotal(nodes, unitPrice = 0) { const list = Array.isArray(nodes) ? nodes : []; const up = Number(unitPrice) || 0; return round2( list.reduce((sum, n) => { if (Array.isArray(n.children) && n.children.length) { return sum + treeSellTotal(n.children, up); } return sum + round2((Number(n.man_days) || 0) * up); }, 0) ); } /** * 软件开发模块树叶子节点人天合计(父级不重复计,仅用于展示)。 */ export function treeManDays(nodes) { const list = Array.isArray(nodes) ? nodes : []; const sum = list.reduce((acc, n) => { if (Array.isArray(n.children) && n.children.length) return acc + treeManDays(n.children); return acc + (Number(n.man_days) || 0); }, 0); return Math.round(sum * 10) / 10; } /** 单行数量:软件开发行固定按 1 个单位计(历史数据可能缺数量,兜底为 1) */ function lineQty(row) { if (row?.line_type === "dev") return Number(row?.quantity) || 1; return Number(row?.quantity) || 0; } /** * 软件开发行的开发总成本(= 模块报价带出的总价): * 优先取 dev_total_price(可手工改写),回退人天汇总,兼容历史数据。 */ export function devCost(row) { const manual = Number(row?.dev_total_price) || 0; if (manual > 0) return round2(manual); return treeSellTotal(row?.dev_tree, row?.dev_unit_price); } /** * 软件开发行的销售单价:由用户自行填写; * 未填写时依次回退「开发总成本 → 人天汇总」,避免历史/未填数据显示为 0。 */ export function devSellPrice(row) { const p = Number(row?.price) || 0; if (p > 0) return round2(p); return devCost(row); } /** 单行金额:成品 = 数量×单价;软件开发 = 数量×销售单价(销售单价默认由开发总成本带出,可自行填写) */ export function lineAmount(row) { if (row?.line_type === "dev") return round2(lineQty(row) * devSellPrice(row)); return round2((Number(row?.quantity) || 0) * (Number(row?.price) || 0)); } /** 单行成本:成品 = 数量×成本单价;软件开发 = 数量×开发总成本(模块报价带出) */ export function lineCost(row) { if (row?.line_type === "dev") return round2(lineQty(row) * devCost(row)); return round2((Number(row?.quantity) || 0) * (Number(row?.cost_price) || 0)); } export function buildSummary(products) { const rows = Array.isArray(products) ? products : []; let hardware = 0; let software = 0; let other = 0; let totalCost = 0; rows.forEach((row) => { if (row?.line_type === "dev") { software += lineAmount(row); // 软件开发计入软件部分,并按开发总成本计入产品成本 totalCost += lineCost(row); return; } const qty = Number(row?.quantity) || 0; const price = Number(row?.price) || 0; const costPrice = Number(row?.cost_price) || 0; const amount = round2(qty * price); totalCost += round2(qty * costPrice); const cat = String(row?.category || ""); if (cat === "1" || cat.includes("硬件")) hardware += amount; else if (cat === "2" || cat.includes("软件")) software += amount; else other += amount; }); hardware = round2(hardware); software = round2(software); other = round2(other); const totalAmount = round2(hardware + software + other); return { hardware_amount: hardware, software_amount: software, other_amount: other, total_amount: totalAmount, total_cost: round2(totalCost), total_profit: round2(totalAmount - round2(totalCost)), }; } /** 生成合同编号建议值:HT-YYYYMMDD-4位随机 */ export function genContractNo() { const d = new Date(); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); const rand = String(Math.floor(Math.random() * 10000)).padStart(4, "0"); return `HT-${y}${m}${day}-${rand}`; } /** 甲乙丙丁角色 key,按 party_count 取前 n 个 */ export const PARTY_ROLES = [ { key: "party_a", label: "甲方" }, { key: "party_b", label: "乙方" }, { key: "party_c", label: "丙方" }, { key: "party_d", label: "丁方" }, ];