| ${escapeHtml(row.pm_department || "-")} |
${codeCell} |
${escapeHtml(row.project_name || "-")} |
@@ -1086,39 +1687,58 @@
${amountCell(row, "all", "collection", row.collection_amount, { codes: row.direct_codes || [row.support_dept_code], label: "수금금액" })} |
${formatBalance(row.contract_balance_amount)} |
${formatRate(row.collection_rate)} |
- ${amountCell(row, "pre", "labor", pre.labor)} |
+ ${amountCell(row, "all", "revenue", row.period_revenue_amount, { codes: row.direct_codes || [row.support_dept_code], label: "기간 ERP매출" })} |
+ ${amountCell(row, "all", "billing", row.period_billing_amount, { codes: row.direct_codes || [row.support_dept_code], label: "기간청구" })} |
+ ${amountCell(row, "all", "collection", row.period_collection_amount, { codes: row.direct_codes || [row.support_dept_code], label: "기간수금" })} |
+ |
+ ${amountCell(row, "pre", "labor_combined", Number(pre.labor || 0) + Number(pre.labor_adjustment || 0))} |
+ ${showPreOutsourceColumn ? `${amountCell(row, "pre", "outsource", pre.outsource)} | ` : ""}
${amountCell(row, "pre", "overhead", pre.overhead)} |
+ ${amountCell(row, "pre", "sga_labor_combined", Number(pre.sga_labor || 0) + Number(pre.sga_labor_adjustment || 0))} |
${amountCell(row, "pre", "sga", pre.sga)} |
${amountCell(row, "pre", "sales", pre.sales)} |
- ${amountCell(row, "during", "labor", during.labor)} |
+ ${amountCell(row, "during", "labor_combined", Number(during.labor || 0) + Number(during.labor_adjustment || 0))} |
${amountCell(row, "during", "outsource", during.outsource)} |
${amountCell(row, "during", "overhead", during.overhead)} |
+ ${amountCell(row, "during", "sga_labor_combined", Number(during.sga_labor || 0) + Number(during.sga_labor_adjustment || 0))} |
${amountCell(row, "during", "sga", during.sga)} |
${amountCell(row, "during", "sales", during.sales)} |
- ${amountCell(row, "post", "labor", post.labor)} |
+ ${amountCell(row, "post", "labor_combined", Number(post.labor || 0) + Number(post.labor_adjustment || 0))} |
${amountCell(row, "post", "outsource", post.outsource)} |
${amountCell(row, "post", "overhead", post.overhead)} |
+ ${amountCell(row, "post", "sga_labor_combined", Number(post.sga_labor || 0) + Number(post.sga_labor_adjustment || 0))} |
${amountCell(row, "post", "sga", post.sga)} |
${amountCell(row, "post", "sales", post.sales)} |
${amountCell(row, "all", "revenue", row.revenue_amount, { codes: row.direct_codes || [row.support_dept_code], label: "청구수익" })} |
${amountCell(row, "all", "cost_total", row.cost_total, { codes: row.direct_codes, label: "원가합계" })} |
+ ${formatNumber(row.cost_labor_total)} |
${amountCell(row, "all", "sga_total", row.sga_total, { codes: row.direct_codes, label: "판관비합계" })} |
+ ${formatNumber(row.sga_labor_total)} |
${amountCell(row, "all", "sales_total", row.sales_total, { codes: row.direct_codes, label: "영업비합계" })} |
${amountCell(row, "all", "total_cost", row.total_cost, { codes: row.direct_codes, label: "총비용" })} |
- ${formatNumber(row.profit_amount)} |
- ${formatRate(row.revenue_profit_rate)} |
- ${formatRate(row.collection_profit_rate)} |
- ${formatRate(row.cumulative_profit_rate)} |
+ ${row.is_common_master ? "-" : formatNumber(row.profit_amount)} |
+ ${row.is_common_master ? "-" : formatRate(row.revenue_profit_rate)} |
+ ${row.is_common_master ? "-" : formatRate(row.collection_profit_rate)} |
+ ${row.is_common_master ? "-" : formatNumber(row.cumulative_revenue_amount)} |
+ ${row.is_common_master ? "-" : formatNumber(row.cumulative_total_cost)} |
+ ${row.is_common_master ? "-" : formatNumber(row.cumulative_profit_amount)} |
+ ${row.is_common_master ? "-" : formatRate(row.cumulative_profit_rate)} |
`;
}).join("") + bottomSpacer;
+ if (tableWrap && tableWrap.scrollLeft !== preservedScrollLeft) {
+ tableWrap.scrollLeft = preservedScrollLeft;
+ }
+ stableTableScrollLeft = tableWrap?.scrollLeft || stableTableScrollLeft;
+ lastRenderedTableScrollTop = tableWrap?.scrollTop || 0;
}
function recalcVisibleSummary() {
const rows = getFilteredRows();
latestDisplayRows = rows;
- const buildSummary = (usePeriodValues = false) => rows.reduce((acc, row) => {
- for (const key of ["contract_amount", "billing_amount", "collection_amount", "contract_balance_amount", "revenue_amount", "cost_total", "sga_total", "sales_total", "total_cost", "profit_amount"]) {
+ const accountingRows = rows.filter((row) => !row.exclude_from_totals);
+ const buildSummary = (usePeriodValues = false) => accountingRows.reduce((acc, row) => {
+ for (const key of ["contract_amount", "billing_amount", "collection_amount", "contract_balance_amount", "revenue_amount", "period_billing_amount", "period_negative_billing_amount", "period_collection_amount", "period_revenue_amount", "period_revenue_billing_gap", "period_revenue_collection_gap", "cost_total", "cost_labor_total", "sga_total", "sga_labor_total", "sales_total", "total_cost", "profit_amount", "cumulative_revenue_amount", "cumulative_total_cost", "cumulative_profit_amount"]) {
acc[key] += Number(row[key] || 0);
}
if (usePeriodValues) {
@@ -1126,14 +1746,16 @@
acc.collection_amount += Number(row.period_collection_amount || 0) - Number(row.collection_amount || 0);
acc.revenue_amount += Number(row.period_revenue_amount || 0) - Number(row.revenue_amount || 0);
acc.cost_total += Number(row.period_cost_total || 0) - Number(row.cost_total || 0);
+ acc.cost_labor_total += Number(row.period_cost_labor_total || 0) - Number(row.cost_labor_total || 0);
acc.sga_total += Number(row.period_sga_total || 0) - Number(row.sga_total || 0);
+ acc.sga_labor_total += Number(row.period_sga_labor_total || 0) - Number(row.sga_labor_total || 0);
acc.sales_total += Number(row.period_sales_total || 0) - Number(row.sales_total || 0);
acc.total_cost += Number(row.period_total_cost || 0) - Number(row.total_cost || 0);
acc.profit_amount += Number(row.period_profit_amount || 0) - Number(row.profit_amount || 0);
}
for (const phase of ["pre", "during", "post"]) {
const bucket = row.phases?.[phase] || {};
- for (const item of ["labor", "outsource", "overhead", "sga", "sales"]) {
+ for (const item of ["labor", "labor_adjustment", "outsource", "overhead", "sga_labor", "sga_labor_adjustment", "sga", "sales"]) {
acc.phases[phase][item] += Number(bucket[item] || 0);
}
}
@@ -1146,16 +1768,26 @@
collection_amount: 0,
contract_balance_amount: 0,
revenue_amount: 0,
+ period_billing_amount: 0,
+ period_negative_billing_amount: 0,
+ period_collection_amount: 0,
+ period_revenue_amount: 0,
+ period_revenue_billing_gap: 0,
+ period_revenue_collection_gap: 0,
cost_total: 0,
+ cost_labor_total: 0,
sga_total: 0,
+ sga_labor_total: 0,
sales_total: 0,
total_cost: 0,
profit_amount: 0,
cumulative_profit_amount: 0,
+ cumulative_revenue_amount: 0,
+ cumulative_total_cost: 0,
phases: {
- pre: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 },
- during: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 },
- post: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 },
+ pre: { labor: 0, labor_adjustment: 0, outsource: 0, overhead: 0, sga_labor: 0, sga_labor_adjustment: 0, sga: 0, sales: 0 },
+ during: { labor: 0, labor_adjustment: 0, outsource: 0, overhead: 0, sga_labor: 0, sga_labor_adjustment: 0, sga: 0, sales: 0 },
+ post: { labor: 0, labor_adjustment: 0, outsource: 0, overhead: 0, sga_labor: 0, sga_labor_adjustment: 0, sga: 0, sales: 0 },
},
});
const summary = buildSummary(false);
@@ -1163,29 +1795,73 @@
summary.contract_profit_rate = summary.contract_amount ? summary.profit_amount / summary.contract_amount * 100 : 0;
summary.revenue_profit_rate = summary.revenue_amount ? summary.profit_amount / summary.revenue_amount * 100 : 0;
summary.collection_profit_rate = summary.collection_amount ? summary.profit_amount / summary.collection_amount * 100 : 0;
- summary.cumulative_profit_rate = summary.collection_amount ? (summary.collection_amount - summary.total_cost) / summary.collection_amount * 100 : 0;
+ summary.cumulative_profit_rate = summary.cumulative_revenue_amount ? summary.cumulative_profit_amount / summary.cumulative_revenue_amount * 100 : 0;
const kpiSummary = buildSummary(true);
kpiSummary.collection_rate = kpiSummary.contract_amount ? kpiSummary.collection_amount / kpiSummary.contract_amount * 100 : 0;
kpiSummary.contract_profit_rate = kpiSummary.contract_amount ? kpiSummary.profit_amount / kpiSummary.contract_amount * 100 : 0;
kpiSummary.revenue_profit_rate = kpiSummary.revenue_amount ? kpiSummary.profit_amount / kpiSummary.revenue_amount * 100 : 0;
kpiSummary.collection_profit_rate = kpiSummary.collection_amount ? kpiSummary.profit_amount / kpiSummary.collection_amount * 100 : 0;
+ kpiSummary.cumulative_revenue_amount = summary.cumulative_revenue_amount;
+ kpiSummary.cumulative_total_cost = summary.cumulative_total_cost;
+ kpiSummary.cumulative_profit_amount = summary.cumulative_profit_amount;
kpiSummary.cumulative_profit_rate = summary.cumulative_profit_rate;
+ latestKpiSummary = kpiSummary;
renderKpis(kpiSummary);
renderSummaryTable(summary);
}
- async function loadData() {
+ function scheduleRefreshPoll(jobId) {
+ if (!jobId || activeRefreshJobId === jobId) return;
+ activeRefreshJobId = jobId;
+ if (refreshPollTimer) window.clearTimeout(refreshPollTimer);
+ const poll = async () => {
+ try {
+ const response = await fetch(`/api/system-jobs/${encodeURIComponent(jobId)}`, {
+ headers: { Accept: "application/json" },
+ cache: "no-store",
+ });
+ const payload = await response.json();
+ const job = payload.job || null;
+ if (!response.ok || !job) throw new Error(payload.error || "갱신 상태를 확인하지 못했습니다.");
+ if (["done", "failed", "cancelled"].includes(String(job.status || ""))) {
+ activeRefreshJobId = "";
+ refreshPollTimer = null;
+ if (job.status === "done") await loadData({ background: false, preserve: true });
+ else searchButton.textContent = "조회";
+ return;
+ }
+ searchButton.textContent = "갱신 중";
+ refreshPollTimer = window.setTimeout(poll, 1200);
+ } catch (error) {
+ activeRefreshJobId = "";
+ refreshPollTimer = null;
+ searchButton.textContent = "조회";
+ }
+ };
+ refreshPollTimer = window.setTimeout(poll, 700);
+ }
+
+ async function loadData(options = {}) {
restoreRememberedDates();
if (activeLoadController) activeLoadController.abort();
activeLoadController = new AbortController();
const controller = activeLoadController;
+ const preserve = options.preserve !== false && allRows.length > 0;
+ const background = options.background !== false;
searchButton.disabled = true;
searchButton.textContent = "조회 중";
- kpis.innerHTML = `직접 귀속된 상세 내역이 없습니다.
`;
+ if (detailType === "billing") {
+ return `
+ 추가 배분 내역이 없습니다.
`;
+ return `
+ 조회 중입니다.
`;
modal.classList.add("open");
const response = await fetch(`/cost-analysis/detail?${params.toString()}`, { headers: { Accept: "application/json" } });
const payload = await response.json();
if (!response.ok || payload.error) throw new Error(payload.error || "상세 조회에 실패했습니다.");
- const rows = Array.isArray(payload.rows) ? payload.rows : [];
- if (payload.detail_type === "labor") {
- modalHeader.innerHTML = `
-