Update cost-pdf.html: Refined settlement logic for billing/claims and team normalization.

This commit is contained in:
2026-03-04 13:20:51 +09:00
parent 687ce4e9e4
commit 38333a8540

View File

@@ -150,8 +150,8 @@
} catch(e) { console.warn("Firebase not available."); }
const POOL_A_PROJECTS = ['총무 [26-관리-03]', '부서 공통 [26-관리-06]'];
const POOL_B_PROJECTS = ['관리', '생산', '인사 [26-관리-02]'];
const TEAM_RATIOS = { '일반경비': { '철근': 0.45, '제작': 0.30, '공무': 0.25 } };
const POOL_B_PROJECTS = ['관리[26-공통-01]', '생산[26-공통-02]', '인사 [26-관리-02]'];
const TEAM_RATIOS = { '일반경비': { '철근': 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', '거푸집', '가로보', '가설벤트'],
@@ -272,9 +272,21 @@
return atob(b64 || '');
};
const normalizeTeamName = (teamRaw = '') => {
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 '공통';
return '공통';
};
const mapExpenseRows = (data) => data.flatMap((r, i) => {
const account = String(r['계정'] || r['소계정명'] || '미분류').trim();
const team = String(r['팀'] || r['팀명'] || '기타').trim();
const team = normalizeTeamName(String(r['팀'] || r['팀명'] || '기타').trim());
const projectName = String(r['교량명'] || r['사업명'] || r['사업코드'] || '미지정').trim();
const amount = utils.parseNum(r['공급가액'] || r['공급가'] || r['합계']);
const date = utils.parseDate(r['거래일'] || r['날짜']);
@@ -286,7 +298,7 @@
const mapLaborRows = (data) => data.flatMap((r, i) => {
const date = utils.parseDate(r['근무일'] || r['날짜']);
const worker = r['근무자명'] || r['성명'] || '';
const team = String(r['근무팀'] || r['소속팀'] || '기타').trim();
const team = normalizeTeamName(String(r['근무팀'] || r['소속팀'] || '기타').trim());
const projectName = String(r['교량명'] || r['사업명'] || '미지정').trim();
const hours = utils.parseNum(r['근무시간'] || r['시간']);
const form = String(r['형식'] || '미분류').trim();
@@ -382,10 +394,10 @@
const resolveTeam = (name = '', regularType = '') => {
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 (r.includes('일용')) return '공통';
if (teamMfg.has(n)) return '제작';
if (teamAdmin.has(n)) return '공무';
return '철근';
};
const toNum = (v) => {
const n = parseFloat(String(v || '').replace(/[^0-9.-]/g, ''));
@@ -450,10 +462,10 @@
name: w.name,
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);
@@ -493,6 +505,19 @@
}
};
const normalizeFormKey = (name) => String(name || '').replace(/\s+/g, '').trim().toLowerCase();
const normalizeProjectKey = (name) => {
const raw = String(name || '').trim();
const bracket = raw.match(/\[([^\]]+)\]/);
const base = (bracket?.[1] || raw).replace(/\s+/g, '').trim();
const alias = {
'관리': '26-공통-01',
'생산': '26-공통-02',
'인사': '26-관리-02',
'총무': '26-관리-03',
'부서공통': '26-관리-06'
};
return String(alias[base] || base).toLowerCase();
};
const toMonthKey = (dateStr) => String(dateStr || '').slice(0, 7);
const inMonthRange = (dateStr) => {
@@ -532,8 +557,8 @@
};
const calculateSettlement = (mode) => {
const isB = (v) => POOL_B_PROJECTS.includes(v);
const isA = (v) => POOL_A_PROJECTS.includes(v);
const isB = (v) => POOL_B_PROJECTS.some(p => normalizeProjectKey(p) === normalizeProjectKey(v));
const isA = (v) => POOL_A_PROJECTS.some(p => normalizeProjectKey(p) === normalizeProjectKey(v));
const fEx = expenses.filter(i => inMonthRange(i.date));
const fLb = laborRows.filter(i => inMonthRange(i.date));
const monthFactor = utils.monthSpan(startDate, endDate);
@@ -562,7 +587,8 @@
if (!projectMap[e.projectName]) projectMap[e.projectName] = { name: e.projectName, direct: 0, hours: 0, byAccount: {}, byTeam: {}, byForm: {} };
projectMap[e.projectName].direct += e.amount;
projectMap[e.projectName].byAccount[e.account] = (projectMap[e.projectName].byAccount[e.account] || 0) + e.amount;
projectMap[e.projectName].byTeam[e.team] = (projectMap[e.projectName].byTeam[e.team] || 0) + e.amount;
const nTeam = normalizeTeamName(e.team);
projectMap[e.projectName].byTeam[nTeam] = (projectMap[e.projectName].byTeam[nTeam] || 0) + e.amount;
const f = e.form || '미분류';
projectMap[e.projectName].byForm[f] = (projectMap[e.projectName].byForm[f] || 0) + e.amount;
}
@@ -576,7 +602,8 @@
projectMap[l.projectName].direct += l.cost;
projectMap[l.projectName].hours += l.hours;
projectMap[l.projectName].byAccount['인건비(직접)'] = (projectMap[l.projectName].byAccount['인건비(직접)'] || 0) + l.cost;
projectMap[l.projectName].byTeam[l.team] = (projectMap[l.projectName].byTeam[l.team] || 0) + l.cost;
const nTeam = normalizeTeamName(l.team);
projectMap[l.projectName].byTeam[nTeam] = (projectMap[l.projectName].byTeam[nTeam] || 0) + l.cost;
const f = l.form || '미분류';
projectMap[l.projectName].byForm[f] = (projectMap[l.projectName].byForm[f] || 0) + l.cost;
revenueHrs += l.hours;
@@ -628,7 +655,12 @@
formMap[fName].breakdown[p.name] = (formMap[fName].breakdown[p.name] || 0) + val;
formMap[fName].allocTrace.push({
projectName: p.name,
directShare: v
directShare: val,
ratio,
projectHours: p.hours,
shareHours,
allocA: shareAllocA,
allocB: shareAllocB
});
});
});
@@ -745,16 +777,16 @@
Object.keys(wageSettings || {}).forEach(name => {
if (map[name]) return;
if (dailyWorkers.has(name) || String(name).includes('일용')) {
map[name] = '일용직';
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 };
const base = { '철근': 0, '제작': 0, '공무': 0, '공통': 0, '관리팀': 0 };
Object.values(workerTeamMap || {}).forEach(team => {
if (base[team] !== undefined) base[team] += 1;
});
@@ -854,13 +886,13 @@
<div className="summary-row">
{allocPoolA && (
<div className="summary-cell">
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-1 block">운영비 배분 총액 (POOL A)</span>
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-1 block">운영비 배분 총액 (A)</span>
<span className="text-xl font-black text-blue-700">{utils.formatWon(results.poolA)}</span>
</div>
)}
{allocPoolB && (
<div className="summary-cell">
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-1 block">일반 관리비 배분액 (POOL B)</span>
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-1 block">일반 관리비 배분액 (B)</span>
<span className="text-xl font-black text-orange-700">{utils.formatWon(results.poolB)}</span>
</div>
)}
@@ -966,7 +998,7 @@
</div>
<div className="mt-20 pt-10 border-t border-slate-100 text-center space-y-4">
<p className="text-[9px] font-bold text-slate-300 uppercase tracking-[0.6em] italic">Skilled JANGHEON COST ANALYSIS ENGINE v5.5</p>
<p className="text-[9px] font-bold text-slate-300 uppercase tracking-[0.6em] italic">JANGHEON COST ANALYSIS ENGINE v5.5</p>
<div className="flex justify-center gap-10 py-6 opacity-20 grayscale">
<div className="border-2 border-slate-900 p-2 px-8 rounded-full font-black text-slate-900 text-lg uppercase italic">Approved</div>
<div className="border-2 border-slate-900 p-2 px-8 rounded-full font-black text-slate-900 text-lg uppercase italic">JANGHEON</div>
@@ -997,13 +1029,13 @@
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[
{ label: '조회 총 발생 원가', val: results.total, color: 'text-slate-900' },
{ label: '운영비 (POOL A)', val: results.poolA, color: 'text-blue-600' },
{ label: '관리비 (POOL B)', val: results.poolB, color: 'text-orange-600' },
{ label: '운영비 (A)', val: results.poolA, color: 'text-blue-600' },
{ label: '관리비 (B)', val: results.poolB, color: 'text-orange-600' },
{ label: '실 투입 총 공수', val: results.grandTotalHrs, unit: 'HR', color: 'text-emerald-600' }
].map((c, i) => (
<div key={i} className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm transition-all hover:bg-slate-50/50">
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{c.label}</p>
<p className={`text-xl font-extrabold ${c.color} tracking-tighter leading-none`}>
<p className={`text-xl font-extrabold ${c.color} tracking-tighter leathering-none`}>
{c.unit ? (c.unit === 'HR' ? utils.formatHr(c.val) : c.val.toLocaleString()) : utils.formatWon(c.val)}
{c.unit && <span className="text-sm font-bold ml-1 opacity-50">{c.unit}</span>}
</p>
@@ -1042,15 +1074,15 @@
</div>
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-5 space-y-4">
<h3 className="text-sm font-black text-slate-800">POOL 기준 안내</h3>
<h3 className="text-sm font-black text-slate-800">배분 기준 안내</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
<div className="bg-blue-50 border border-blue-100 rounded-xl p-4 space-y-2">
<div className="font-black text-blue-800">운영비 (POOL A)</div>
<div className="font-black text-blue-800">운영비 (A)</div>
<div className="text-slate-700">운영/공통 성격 비용을 수익 프로젝트 공수 비율로 배분하는 풀입니다.</div>
<div className="text-slate-500">기본 프로젝트: {POOL_A_PROJECTS.join(', ') || '-'}</div>
</div>
<div className="bg-orange-50 border border-orange-100 rounded-xl p-4 space-y-2">
<div className="font-black text-orange-800">관리비 (POOL B)</div>
<div className="font-black text-orange-800">관리비 (B)</div>
<div className="text-slate-700">관리/간접 성격 비용을 수익 프로젝트 공수 비율로 배분하는 풀입니다.</div>
<div className="text-slate-500">기본 프로젝트: {POOL_B_PROJECTS.join(', ') || '-'}</div>
</div>
@@ -1221,7 +1253,7 @@
{settingsTab === 'wage' && (
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-6">
{['철근', '제작', '공무', '일용직'].map(team => (
{['철근', '제작', '공무', '공통', '관리팀'].map(team => (
<div key={team} className="bg-slate-50 rounded-2xl border border-slate-100 p-4">
<div className="text-xs font-black text-slate-500">{team}</div>
<div className="text-2xl font-black text-slate-800 mt-1">{factoryTeamCounts[team] || 0}<span className="text-sm ml-1 text-slate-400"></span></div>
@@ -1231,10 +1263,11 @@
<div className="mb-6 flex items-center gap-2">
{[
{ id: 'ALL', label: '전체' },
{ id: '철근', label: '철근' },
{ id: '제작', label: '제작' },
{ id: '공무', label: '공무' },
{ id: '일용직', label: '일용직' }
{ id: '철근', label: '철근' },
{ id: '제작', label: '제작' },
{ id: '공무', label: '공무' },
{ id: '공통', label: '공통' },
{ id: '관리팀', label: '관리팀' }
].map(opt => (
<button
key={opt.id}
@@ -1251,7 +1284,7 @@
<div key={n} className="bg-slate-50 p-5 rounded-2xl border border-slate-100 flex justify-between items-center group hover:bg-white transition-all shadow-sm">
<div className="flex items-center gap-2">
<span className="font-bold text-base underline underline-offset-8 decoration-slate-200 tracking-tight italic">{n}</span>
<span className="px-2 py-0.5 rounded-full text-[10px] font-black bg-slate-200 text-slate-700">{workerTeamMap[n] || '철근'}</span>
<span className="px-2 py-0.5 rounded-full text-[10px] font-black bg-slate-200 text-slate-700">{workerTeamMap[n] || '철근'}</span>
</div>
<div className="flex items-center gap-4">
<select className="bg-white border text-sm font-bold rounded-xl px-4 py-2.5 outline-none cursor-pointer focus:ring-4 focus:ring-blue-100 transition-all border-slate-200" value={wageSettings[n].type} onChange={e => {
@@ -1351,7 +1384,7 @@
<h4 className="font-bold text-slate-800 text-base uppercase tracking-tighter italic">A/B 배분 계산과정</h4>
</div>
<div className="bg-indigo-50 border border-indigo-100 rounded-2xl p-4 text-xs text-slate-700 leading-relaxed">
<div>기준식: <span className="font-black">배분금액 = (배분공수 / 전체 수익공수) × Pool 금액</span></div>
<div>기준식: <span className="font-black">배분금액 = (배분공수 / 전체 수익공수) × 배분 기준 금액</span></div>
<div className="mt-1">A = ({utils.formatHr(selectedDetail.hours || 0)} / {utils.formatHr(selectedDetail.allocMeta.revenueHrs || 0)}) × {utils.formatWon(selectedDetail.allocMeta.poolAVal || 0)} = <span className="font-black text-blue-700">{utils.formatWon(selectedDetail.allocA || 0)}</span></div>
<div>B = ({utils.formatHr(selectedDetail.hours || 0)} / {utils.formatHr(selectedDetail.allocMeta.revenueHrs || 0)}) × {utils.formatWon(selectedDetail.allocMeta.poolBVal || 0)} = <span className="font-black text-orange-700">{utils.formatWon(selectedDetail.allocB || 0)}</span></div>
</div>