diff --git a/cost-pdf.html b/cost-pdf.html index c6e0378..f0be05d 100644 --- a/cost-pdf.html +++ b/cost-pdf.html @@ -151,17 +151,22 @@ const POOL_A_PROJECTS = ['총무 [26-관리-03]', '부서 공통 [26-관리-06]']; const POOL_B_PROJECTS = ['관리[26-공통-01]', '생산[26-공통-02]', '인사 [26-관리-02]']; - const TEAM_RATIOS = { '일반경비': { '철근': 0.45, '제작': 0.30, '공무': 0.25 } }; + const TEAM_RATIOS = { + '지급임차료': { '철근': 0.13, '제작': 0.52, '공무': 0.35 }, + '전력비': { '철근': 0.60, '제작': 0.30, '공무': 0.10 }, + // 지급임차료/전력비 외 계정 + 풀 인건비는 일반경비로 본다. + '일반경비': { '철근': 0.45, '제작': 0.30, '공무': 0.25 } + }; const NON_MANAGED_FORMS = ['가족사지원', '공통', '기타', '시설관리', '연구개발', '현장자재', '현장지원', '공통(거더)', '공통(데크,가로보)', '품질']; const FORM_ALLOC_C_RULES = { - '가족사지원': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], - '공통': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], - '기타': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], - '시설관리': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], - '연구개발': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], - '현장자재': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], - '현장지원': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], - '공통(거더)': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'DR.거더', 'DR.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더'], + '가족사지원': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'RFI.거더', 'RFI.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], + '공통': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'RFI.거더', 'RFI.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], + '기타': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'RFI.거더', 'RFI.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], + '시설관리': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'RFI.거더', 'RFI.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], + '연구개발': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'RFI.거더', 'RFI.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], + '현장자재': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'RFI.거더', 'RFI.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], + '현장지원': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'RFI.거더', 'RFI.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '거푸집', '가로보', '가설벤트'], + '공통(거더)': ['노출거더', '분절거더', '철도교', 'DPC.거더', 'RFI.거더', 'RFI.거더(v2.0)', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', 'SP.거더'], '공통(데크,가로보)': ['강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '가로보'], '품질': ['노출거더', 'Inverted-T', 'Post-Tension 거더', 'Pre Beam', '강교 Deck', '캔틸레버Deck', 'Full-DepthDeck', 'PSCDeck', 'RCDeck', '가로보'] }; @@ -276,13 +281,19 @@ const t = String(teamRaw || '').trim(); if (!t) return '공통'; if (t.includes('관리')) return '관리팀'; - if (t.includes('공무')) return '공무'; - if (t.includes('제작')) return '제작'; - if (t.includes('철근')) return '철근'; + if (t.includes('공무')) return '공무팀'; + if (t.includes('제작')) return '제작팀'; + if (t.includes('철근')) return '철근팀'; if (t.includes('공통')) return '공통'; if (t.includes('일용')) return '공통'; return '공통'; }; + const formatTeamLabel = (teamName = '') => { + const t = String(teamName || '').trim(); + if (!t) return '공통팀'; + if (t.endsWith('팀')) return t; + return `${t}팀`; + }; const mapExpenseRows = (data) => data.flatMap((r, i) => { const account = String(r['계정'] || r['소계정명'] || '미분류').trim(); @@ -395,9 +406,9 @@ const n = String(name || '').trim(); const r = String(regularType || '').trim(); if (r.includes('일용')) return '공통'; - if (teamMfg.has(n)) return '제작'; - if (teamAdmin.has(n)) return '공무'; - return '철근'; + if (teamMfg.has(n)) return '제작팀'; + if (teamAdmin.has(n)) return '공무팀'; + return '철근팀'; }; const toNum = (v) => { const n = parseFloat(String(v || '').replace(/[^0-9.-]/g, '')); @@ -463,9 +474,9 @@ rate: w.rate, team: String(w.regularType || '').includes('일용') ? '공통' - : teamMfg.has(w.name) ? '제작' - : teamAdmin.has(w.name) ? '공무' - : '철근' + : teamMfg.has(w.name) ? '제작팀' + : teamAdmin.has(w.name) ? '공무팀' + : '철근팀' })); setFactoryWorkers(fallbackWorkers); applyFactoryDefaults(fallbackWorkers); @@ -721,25 +732,114 @@ Object.entries(p.byTeam).forEach(([tm, val]) => { if (!tmMap[tm]) tmMap[tm] = { name: tm, direct: 0, allocA: 0, allocB: 0, final: 0, hours: 0, breakdown: {}, allocTrace: [] }; tmMap[tm].direct += val; - const ratio = p.direct > 0 ? val / p.direct : 0; - const shareHours = p.hours * ratio; - const shareAllocA = p.allocA * ratio; - const shareAllocB = p.allocB * ratio; - tmMap[tm].allocA += shareAllocA; - tmMap[tm].allocB += shareAllocB; - tmMap[tm].hours += shareHours; + tmMap[tm].hours += p.direct > 0 ? (p.hours * (val / p.direct)) : 0; tmMap[tm].breakdown[p.name] = (tmMap[tm].breakdown[p.name] || 0) + val; - tmMap[tm].allocTrace.push({ - projectName: p.name, - directShare: val, - ratio, - projectHours: p.hours, - shareHours, - allocA: shareAllocA, - allocB: shareAllocB - }); }); }); + + const ensureTeam = (name) => { + if (!tmMap[name]) tmMap[name] = { name, direct: 0, allocA: 0, allocB: 0, final: 0, hours: 0, breakdown: {}, allocTrace: [] }; + return tmMap[name]; + }; + + // 팀별 하이브리드 배분: + // 1) 공통 성격 풀은 팀 공수 비례 + // 2) 관리 성격 풀은 계정별 고정비율 + const commonPool = { A: 0, B: 0 }; + const bucketA = { '지급임차료': 0, '전력비': 0, '일반경비': 0 }; + const bucketB = { '지급임차료': 0, '전력비': 0, '일반경비': 0 }; + const classifyAccount = (account) => { + const raw = String(account || ''); + const compact = raw.replace(/\s+/g, '').toLowerCase(); + const digits = compact.replace(/[^0-9]/g, ''); + if (digits === '819' || digits === '81901') return '지급임차료'; + if (compact.includes('전력비') || compact.includes('전기료') || compact.includes('815')) return '전력비'; + return '일반경비'; + }; + const isCommonPoolProject = (projectName) => String(projectName || '').includes('공통'); + + fEx.forEach(e => { + const bucket = classifyAccount(e.account); + if (isA(e.projectName)) { + if (isCommonPoolProject(e.projectName)) commonPool.A += e.amount; + else bucketA[bucket] += e.amount; + } else if (isB(e.projectName)) { + if (isCommonPoolProject(e.projectName)) commonPool.B += e.amount; + else bucketB[bucket] += e.amount; + } + }); + + laborWithCost.forEach(l => { + if (isA(l.projectName)) { + if (isCommonPoolProject(l.projectName)) commonPool.A += l.cost; + else bucketA['일반경비'] += l.cost; + } else if (isB(l.projectName)) { + if (isCommonPoolProject(l.projectName)) commonPool.B += l.cost; + else bucketB['일반경비'] += l.cost; + } + }); + + // 토글 OFF 상태일 때 분배 제외 + if (!allocPoolA) commonPool.A = 0; + if (!allocPoolB) commonPool.B = 0; + if (!allocPoolA) Object.keys(bucketA).forEach(k => { bucketA[k] = 0; }); + if (!allocPoolB) Object.keys(bucketB).forEach(k => { bucketB[k] = 0; }); + + const teamHoursMap = {}; + let totalTeamHours = 0; + laborWithCost.forEach(l => { + if (isA(l.projectName) || isB(l.projectName)) return; + const t = normalizeTeamName(l.team); + if (t === '관리팀') return; + teamHoursMap[t] = (teamHoursMap[t] || 0) + (Number(l.hours) || 0); + totalTeamHours += (Number(l.hours) || 0); + }); + + const distributeCommonByHours = (poolName, amount, allocKey) => { + if (!amount || totalTeamHours <= 0) return; + Object.entries(teamHoursMap).forEach(([team, hrs]) => { + const ratio = hrs / totalTeamHours; + const share = amount * ratio; + const row = ensureTeam(team); + row[allocKey] += share; + row.allocTrace.push({ + projectName: `${poolName}/공통(공수)`, + directShare: amount, + ratio, + projectHours: totalTeamHours, + shareHours: hrs, + allocA: allocKey === 'allocA' ? share : 0, + allocB: allocKey === 'allocB' ? share : 0 + }); + }); + }; + + const distributeManagedByRatio = (poolName, bucketMap, allocKey) => { + Object.entries(bucketMap).forEach(([bucket, amount]) => { + if (!amount) return; + const ratios = TEAM_RATIOS[bucket] || TEAM_RATIOS['일반경비']; + Object.entries(ratios).forEach(([team, ratio]) => { + const share = amount * ratio; + const row = ensureTeam(team); + row[allocKey] += share; + row.allocTrace.push({ + projectName: `${poolName}/${bucket}`, + directShare: amount, + ratio, + projectHours: 0, + shareHours: 0, + allocA: allocKey === 'allocA' ? share : 0, + allocB: allocKey === 'allocB' ? share : 0 + }); + }); + }); + }; + + distributeCommonByHours('POOL A', commonPool.A, 'allocA'); + distributeCommonByHours('POOL B', commonPool.B, 'allocB'); + distributeManagedByRatio('POOL A', bucketA, 'allocA'); + distributeManagedByRatio('POOL B', bucketB, 'allocB'); + return Object.values(tmMap).map(t => ({ ...t, final: t.direct + t.allocA + t.allocB, @@ -779,14 +879,14 @@ if (dailyWorkers.has(name) || String(name).includes('일용')) { map[name] = '공통'; } else { - map[name] = teamMfg.has(name) ? '제작' : teamAdmin.has(name) ? '공무' : '철근'; + map[name] = teamMfg.has(name) ? '제작팀' : teamAdmin.has(name) ? '공무팀' : '철근팀'; } }); return map; }, [factoryWorkers, wageSettings, laborRows]); const factoryTeamCounts = useMemo(() => { - const base = { '철근': 0, '제작': 0, '공무': 0, '공통': 0, '관리팀': 0 }; + const base = { '철근팀': 0, '제작팀': 0, '공무팀': 0, '공통': 0, '관리팀': 0 }; Object.values(workerTeamMap || {}).forEach(team => { if (base[team] !== undefined) base[team] += 1; }); @@ -985,7 +1085,7 @@
{results.all.team.map(tm => ({c.label}
-+
{c.unit ? (c.unit === 'HR' ? utils.formatHr(c.val) : c.val.toLocaleString()) : utils.formatWon(c.val)} {c.unit && {c.unit}}
@@ -1089,7 +1189,7 @@