1426 lines
58 KiB
HTML
1426 lines
58 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}수익/비용 차이분석{% endblock %}
|
|
|
|
{% block head_extra %}
|
|
<style>
|
|
.gap-page {
|
|
display: grid;
|
|
gap: 22px;
|
|
}
|
|
|
|
.gap-hero {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
gap: 16px;
|
|
align-items: end;
|
|
padding-bottom: 14px;
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
|
|
.gap-hero h1 {
|
|
font-size: 28px;
|
|
line-height: 1.18;
|
|
}
|
|
|
|
.gap-help {
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.gap-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.gap-action-stack {
|
|
display: grid;
|
|
gap: 8px;
|
|
justify-items: end;
|
|
}
|
|
|
|
.gap-scoreboard {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 14px;
|
|
}
|
|
|
|
.gap-score {
|
|
display: grid;
|
|
gap: 7px;
|
|
padding-bottom: 14px;
|
|
border-bottom: 1px solid var(--line);
|
|
min-height: 112px;
|
|
}
|
|
|
|
.gap-score .label {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.gap-score .value {
|
|
font-size: 25px;
|
|
font-weight: 900;
|
|
line-height: 1.12;
|
|
}
|
|
|
|
.gap-score .meta {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.gap-year-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 14px;
|
|
}
|
|
|
|
.year-card {
|
|
display: grid;
|
|
gap: 12px;
|
|
align-content: start;
|
|
min-height: 270px;
|
|
padding: 14px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: #ffffff;
|
|
}
|
|
|
|
.year-card[data-severity="danger"] {
|
|
border-color: #efb0aa;
|
|
background: #fff8f7;
|
|
}
|
|
|
|
.year-card[data-severity="warning"] {
|
|
border-color: #efcf8f;
|
|
background: #fffaf0;
|
|
}
|
|
|
|
.year-card[data-severity="ok"] {
|
|
border-color: #b8ddce;
|
|
background: #f6fcf9;
|
|
}
|
|
|
|
.year-head {
|
|
display: flex;
|
|
align-items: start;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
}
|
|
|
|
.year-head h2 {
|
|
font-size: 22px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.severity-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
min-height: 26px;
|
|
padding: 4px 8px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
font-weight: 900;
|
|
background: #eef2f6;
|
|
color: #344055;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.severity-pill.danger {
|
|
background: #ffe3df;
|
|
color: #9f2418;
|
|
}
|
|
|
|
.severity-pill.warning {
|
|
background: #fff1ce;
|
|
color: #835000;
|
|
}
|
|
|
|
.severity-pill.ok {
|
|
background: #e4f7ed;
|
|
color: #11633e;
|
|
}
|
|
|
|
.year-main {
|
|
display: grid;
|
|
gap: 6px;
|
|
}
|
|
|
|
.year-main strong {
|
|
font-size: 18px;
|
|
line-height: 1.25;
|
|
}
|
|
|
|
.gap-mini-grid {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.gap-mini-row {
|
|
display: grid;
|
|
grid-template-columns: 74px minmax(0, 1fr) 88px;
|
|
gap: 8px;
|
|
align-items: center;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.gap-track {
|
|
height: 8px;
|
|
background: rgba(17, 24, 39, 0.09);
|
|
border-radius: 999px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.gap-fill {
|
|
height: 100%;
|
|
width: var(--w, 2%);
|
|
min-width: 2px;
|
|
background: #2563eb;
|
|
}
|
|
|
|
.gap-fill.warn {
|
|
background: #b45309;
|
|
}
|
|
|
|
.gap-fill.danger {
|
|
background: #b42318;
|
|
}
|
|
|
|
.gap-amount {
|
|
text-align: right;
|
|
font-variant-numeric: tabular-nums;
|
|
white-space: nowrap;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.gap-tags {
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.gap-tag {
|
|
display: inline-flex;
|
|
min-height: 23px;
|
|
align-items: center;
|
|
padding: 3px 7px;
|
|
border: 1px solid #d7dee8;
|
|
border-radius: 6px;
|
|
background: rgba(255, 255, 255, 0.82);
|
|
font-size: 11px;
|
|
font-weight: 900;
|
|
color: #273241;
|
|
}
|
|
|
|
button.gap-tag {
|
|
cursor: pointer;
|
|
box-shadow: none;
|
|
min-height: 23px;
|
|
line-height: 1.15;
|
|
}
|
|
|
|
button.gap-tag:hover {
|
|
background: #eef2f6;
|
|
transform: none;
|
|
}
|
|
|
|
.detail-buttons {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 8px;
|
|
margin-top: auto;
|
|
}
|
|
|
|
.detail-buttons button {
|
|
min-height: 34px;
|
|
padding: 7px 9px;
|
|
font-size: 12px;
|
|
border-radius: 8px;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.gap-panel {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
.gap-panel h2 {
|
|
font-size: 20px;
|
|
}
|
|
|
|
.rationale-outline {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 10px;
|
|
margin: 6px 0 8px;
|
|
}
|
|
|
|
.rationale-card {
|
|
display: grid;
|
|
gap: 6px;
|
|
min-height: 112px;
|
|
padding: 12px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: #f8fafc;
|
|
}
|
|
|
|
.rationale-card strong {
|
|
font-size: 13px;
|
|
line-height: 1.25;
|
|
}
|
|
|
|
.rationale-card span {
|
|
color: #4b5563;
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.bridge-table table {
|
|
width: 100%;
|
|
min-width: 1440px;
|
|
table-layout: fixed;
|
|
}
|
|
|
|
.bridge-table th,
|
|
.bridge-table td {
|
|
width: 104px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.bridge-table th:first-child,
|
|
.bridge-table td:first-child {
|
|
width: 64px;
|
|
}
|
|
|
|
.bridge-value-with-rate {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: flex-end;
|
|
gap: 5px;
|
|
width: 100%;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.bridge-rate {
|
|
flex: 0 0 auto;
|
|
min-width: 42px;
|
|
color: inherit;
|
|
font-size: 10px;
|
|
font-weight: 800;
|
|
text-align: right;
|
|
opacity: 0.82;
|
|
}
|
|
|
|
.bridge-number {
|
|
appearance: none;
|
|
border: 0;
|
|
background: transparent;
|
|
color: #111827;
|
|
font: inherit;
|
|
font-weight: 900;
|
|
font-variant-numeric: tabular-nums;
|
|
text-align: right;
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
border-radius: 0;
|
|
box-shadow: none !important;
|
|
min-height: 0;
|
|
line-height: inherit;
|
|
}
|
|
|
|
.bridge-number:hover {
|
|
color: #111827;
|
|
background: transparent;
|
|
box-shadow: none;
|
|
transform: none;
|
|
}
|
|
|
|
.bridge-diff,
|
|
.bridge-diff .bridge-number {
|
|
font-weight: 900;
|
|
}
|
|
|
|
.bridge-positive,
|
|
.bridge-positive .bridge-number {
|
|
color: #1d4ed8;
|
|
}
|
|
|
|
.bridge-negative,
|
|
.bridge-negative .bridge-number {
|
|
color: #b42318;
|
|
}
|
|
|
|
.bridge-zero,
|
|
.bridge-zero .bridge-number {
|
|
color: #475569;
|
|
}
|
|
|
|
.bridge-positive .bridge-number:hover,
|
|
.bridge-negative .bridge-number:hover,
|
|
.bridge-zero .bridge-number:hover {
|
|
color: inherit;
|
|
}
|
|
|
|
.sign-plus {
|
|
color: #1d4ed8;
|
|
font-weight: 900;
|
|
}
|
|
|
|
.sign-minus {
|
|
color: #b42318;
|
|
font-weight: 900;
|
|
}
|
|
|
|
.bridge-note {
|
|
display: block;
|
|
margin-top: 4px;
|
|
color: #64748b;
|
|
font-size: 11px;
|
|
line-height: 1.35;
|
|
white-space: normal;
|
|
max-width: 170px;
|
|
margin-left: auto;
|
|
}
|
|
|
|
.substantive-table table {
|
|
min-width: 1180px;
|
|
}
|
|
|
|
.substantive-table tbody tr[data-section="수익"] {
|
|
background: #fbfdff;
|
|
}
|
|
|
|
.substantive-table tbody tr[data-section="영업비용"] {
|
|
background: #fffdf8;
|
|
}
|
|
|
|
.source-note {
|
|
color: #64748b;
|
|
font-size: 11px;
|
|
line-height: 1.45;
|
|
max-width: 270px;
|
|
}
|
|
|
|
.insight-cell {
|
|
min-width: 250px;
|
|
color: #334155;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.number {
|
|
text-align: right;
|
|
white-space: nowrap;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.positive {
|
|
color: #0f766e;
|
|
font-weight: 900;
|
|
}
|
|
|
|
.negative {
|
|
color: #b42318;
|
|
font-weight: 900;
|
|
}
|
|
|
|
.modal-card.gap-modal-card {
|
|
width: min(1120px, calc(100vw - 32px));
|
|
max-height: min(84vh, 900px);
|
|
border-radius: 10px;
|
|
display: grid;
|
|
grid-template-rows: auto minmax(0, 1fr);
|
|
padding: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.modal-card.gap-modal-card.wide {
|
|
width: min(1720px, calc(100vw - 20px));
|
|
max-height: min(90vh, 980px);
|
|
}
|
|
|
|
.gap-modal-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
padding: 14px 16px;
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
|
|
.gap-modal-head h2 {
|
|
font-size: 18px;
|
|
}
|
|
|
|
.gap-modal-body {
|
|
overflow: auto;
|
|
padding: 14px 16px 18px;
|
|
}
|
|
|
|
.gap-modal-body table {
|
|
min-width: 920px;
|
|
}
|
|
|
|
.gap-modal-body th {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 2;
|
|
background: #f8fafc;
|
|
box-shadow: inset 0 -1px 0 var(--line);
|
|
}
|
|
|
|
.raw-account-comparison-wrap {
|
|
max-height: calc(90vh - 160px);
|
|
}
|
|
|
|
.raw-account-comparison-table {
|
|
min-width: 1640px;
|
|
table-layout: fixed;
|
|
}
|
|
|
|
.raw-account-comparison-table th,
|
|
.raw-account-comparison-table td {
|
|
vertical-align: top;
|
|
}
|
|
|
|
.raw-account-comparison-table th:nth-child(1),
|
|
.raw-account-comparison-table td:nth-child(1) {
|
|
position: sticky;
|
|
left: 0;
|
|
z-index: 1;
|
|
width: 62px;
|
|
background: #ffffff;
|
|
box-shadow: inset -1px 0 0 var(--line);
|
|
}
|
|
|
|
.raw-account-comparison-table th:nth-child(1) {
|
|
z-index: 4;
|
|
background: #f8fafc;
|
|
}
|
|
|
|
.raw-account-comparison-table th:nth-child(2),
|
|
.raw-account-comparison-table td:nth-child(2) {
|
|
width: 88px;
|
|
}
|
|
|
|
.raw-account-comparison-table th:nth-child(3),
|
|
.raw-account-comparison-table td:nth-child(3) {
|
|
width: 104px;
|
|
}
|
|
|
|
.raw-account-comparison-table th:nth-child(4),
|
|
.raw-account-comparison-table td:nth-child(4) {
|
|
width: 150px;
|
|
}
|
|
|
|
.raw-account-comparison-table th:nth-child(5),
|
|
.raw-account-comparison-table td:nth-child(5),
|
|
.raw-account-comparison-table th:nth-child(6),
|
|
.raw-account-comparison-table td:nth-child(6),
|
|
.raw-account-comparison-table th:nth-child(7),
|
|
.raw-account-comparison-table td:nth-child(7),
|
|
.raw-account-comparison-table th:nth-child(8),
|
|
.raw-account-comparison-table td:nth-child(8),
|
|
.raw-account-comparison-table th:nth-child(9),
|
|
.raw-account-comparison-table td:nth-child(9) {
|
|
width: 128px;
|
|
}
|
|
|
|
.raw-account-comparison-table th:nth-child(10),
|
|
.raw-account-comparison-table td:nth-child(10),
|
|
.raw-account-comparison-table th:nth-child(11),
|
|
.raw-account-comparison-table td:nth-child(11) {
|
|
width: 210px;
|
|
}
|
|
|
|
.raw-account-comparison-table th:nth-child(12),
|
|
.raw-account-comparison-table td:nth-child(12) {
|
|
width: 190px;
|
|
}
|
|
|
|
.account-list {
|
|
display: grid;
|
|
gap: 4px;
|
|
min-width: 0;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
.account-chip {
|
|
display: grid;
|
|
grid-template-columns: auto minmax(0, 1fr);
|
|
gap: 5px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.account-code {
|
|
color: #475569;
|
|
font-weight: 900;
|
|
font-variant-numeric: tabular-nums;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.account-name {
|
|
min-width: 0;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
@media (min-width: 1500px) {
|
|
.raw-account-comparison-table {
|
|
min-width: 100%;
|
|
}
|
|
|
|
.raw-account-comparison-table th:nth-child(10),
|
|
.raw-account-comparison-table td:nth-child(10),
|
|
.raw-account-comparison-table th:nth-child(11),
|
|
.raw-account-comparison-table td:nth-child(11) {
|
|
width: 260px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 1180px) {
|
|
.raw-account-comparison-table {
|
|
min-width: 1480px;
|
|
}
|
|
|
|
.account-chip {
|
|
grid-template-columns: 1fr;
|
|
gap: 1px;
|
|
}
|
|
}
|
|
|
|
.modal-empty {
|
|
padding: 26px;
|
|
text-align: center;
|
|
color: var(--muted);
|
|
}
|
|
|
|
@media (max-width: 1180px) {
|
|
.gap-scoreboard,
|
|
.gap-year-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.gap-hero {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.gap-actions {
|
|
justify-content: start;
|
|
}
|
|
|
|
.gap-scoreboard,
|
|
.gap-year-grid,
|
|
.rationale-outline,
|
|
.detail-buttons {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% macro money(value) -%}
|
|
{{ "{:,.0f}".format(value or 0) }}
|
|
{%- endmacro %}
|
|
|
|
{% macro eok(value) -%}
|
|
{{ "{:,.1f}".format((value or 0) / 100000000) }}억
|
|
{%- endmacro %}
|
|
|
|
{% macro signed_eok(value) -%}
|
|
{%- if (value or 0) > 0 -%}+{{ "{:,.1f}".format((value or 0) / 100000000) }}억{%- else -%}{{ "{:,.1f}".format((value or 0) / 100000000) }}억{%- endif -%}
|
|
{%- endmacro %}
|
|
|
|
{% macro signed_eok_sign(value) -%}
|
|
{%- if (value or 0) > 0 -%}<span class="bridge-positive">+{{ "{:,.1f}".format((value or 0) / 100000000) }}억</span>
|
|
{%- elif (value or 0) < 0 -%}<span class="bridge-negative">-{{ "{:,.1f}".format(((value or 0) | abs) / 100000000) }}억</span>
|
|
{%- else -%}<span class="bridge-zero">0.0억</span>{%- endif -%}
|
|
{%- endmacro %}
|
|
|
|
{% macro gap_with_rate(value, basis) -%}
|
|
{%- set rate = ((value or 0) / basis * 100) if basis else 0 -%}
|
|
<span class="bridge-value-with-rate">
|
|
{{ signed_eok_sign(value) }}
|
|
<span class="bridge-rate">({{ "{:+.1f}".format(rate) }}%)</span>
|
|
</span>
|
|
{%- endmacro %}
|
|
|
|
{% block content %}
|
|
{% set data = gap_analysis %}
|
|
{% set totals = data.totals %}
|
|
<div class="gap-page">
|
|
<section class="panel gap-hero">
|
|
<div>
|
|
<h1>수익/비용 차이분석</h1>
|
|
</div>
|
|
<div class="gap-action-stack">
|
|
<div class="gap-actions">
|
|
<a class="button-link button-secondary" href="/annual-summary">연도별 수익/비용</a>
|
|
<button type="button" class="button-secondary" data-modal-kind="assumptions" data-year="all">집계 기준</button>
|
|
</div>
|
|
<button type="button" class="button-secondary" data-modal-kind="raw-account-comparison" data-year="all">
|
|
WEHAGO·Hanmac ERP 계정 비교
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel gap-panel">
|
|
<div>
|
|
<h2>핵심 브릿지</h2>
|
|
</div>
|
|
<div class="table-wrap bridge-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>연도</th>
|
|
<th class="number">wehago 매출</th>
|
|
<th class="number">매출조정</th>
|
|
<th class="number">wehago 매출(조정전)</th>
|
|
<th class="number">프로젝트손익분석 매출</th>
|
|
<th class="number">수익 차이</th>
|
|
<th class="number">수익 차이(조정전)</th>
|
|
<th class="number">wehago 비용</th>
|
|
<th class="number">프로젝트손익분석 비용</th>
|
|
<th class="number">비용 차이</th>
|
|
<th class="number">wehago 손익</th>
|
|
<th class="number">프로젝트손익분석 손익</th>
|
|
<th class="number">손익 차이</th>
|
|
<th class="number">손익 차이(조정전)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for row in data.yearly_rows %}
|
|
{% set project_bridge = data.project_profit_bridge_rows | selectattr("year", "equalto", row.year) | first %}
|
|
{% set pre_adjustment_wehago_revenue = row.wehago_revenue - row.adjustments.revenue %}
|
|
{% set project_revenue = project_bridge.project_revenue if project_bridge else 0 %}
|
|
{% set project_expense = project_bridge.project_expense if project_bridge else 0 %}
|
|
{% set project_profit = project_revenue - project_expense %}
|
|
{% set revenue_gap = row.wehago_revenue - project_revenue %}
|
|
{% set pre_adjustment_revenue_gap = pre_adjustment_wehago_revenue - project_revenue %}
|
|
{% set expense_gap = row.wehago_operating_expense - project_expense %}
|
|
{% set profit_gap = row.wehago_operating_profit - project_profit %}
|
|
{% set pre_adjustment_profit_gap = pre_adjustment_revenue_gap - expense_gap %}
|
|
<tr>
|
|
<td>{{ row.year }}년</td>
|
|
<td class="number">
|
|
<button type="button" class="bridge-number" data-modal-kind="revenue-amount" data-year="{{ row.year }}">{{ eok(row.wehago_revenue) }}</button>
|
|
</td>
|
|
<td class="number">
|
|
<button type="button" class="bridge-number" data-modal-kind="revenue-adjustment" data-year="{{ row.year }}">{{ signed_eok(row.adjustments.revenue) }}</button>
|
|
</td>
|
|
<td class="number">
|
|
<button type="button" class="bridge-number" data-modal-kind="revenue-pre-adjustment" data-year="{{ row.year }}">{{ eok(pre_adjustment_wehago_revenue) }}</button>
|
|
</td>
|
|
<td class="number">
|
|
{% if project_bridge %}
|
|
<button type="button" class="bridge-number" data-modal-kind="project-profit" data-year="{{ row.year }}">{{ eok(project_revenue) }}</button>
|
|
{% else %}-{% endif %}
|
|
</td>
|
|
<td class="number bridge-diff">
|
|
<button type="button" class="bridge-number" data-modal-kind="revenue-gap" data-year="{{ row.year }}">{{ signed_eok_sign(revenue_gap) }}</button>
|
|
</td>
|
|
<td class="number bridge-diff">
|
|
<button type="button" class="bridge-number" data-modal-kind="revenue-gap-pre" data-year="{{ row.year }}">{{ gap_with_rate(pre_adjustment_revenue_gap, row.wehago_revenue) }}</button>
|
|
</td>
|
|
<td class="number">
|
|
<button type="button" class="bridge-number" data-modal-kind="expense-amount" data-year="{{ row.year }}">{{ eok(row.wehago_operating_expense) }}</button>
|
|
</td>
|
|
<td class="number">
|
|
{% if project_bridge %}
|
|
<button type="button" class="bridge-number" data-modal-kind="project-profit" data-year="{{ row.year }}">{{ eok(project_expense) }}</button>
|
|
{% else %}-{% endif %}
|
|
</td>
|
|
<td class="number bridge-diff">
|
|
<button type="button" class="bridge-number" data-modal-kind="expense-gap" data-year="{{ row.year }}">{{ gap_with_rate(expense_gap, row.wehago_operating_expense) }}</button>
|
|
</td>
|
|
<td class="number">
|
|
<button type="button" class="bridge-number" data-modal-kind="accounts" data-year="{{ row.year }}">{{ signed_eok(row.wehago_operating_profit) }}</button>
|
|
</td>
|
|
<td class="number">
|
|
{% if project_bridge %}
|
|
<button type="button" class="bridge-number" data-modal-kind="project-profit" data-year="{{ row.year }}">{{ signed_eok(project_profit) }}</button>
|
|
{% else %}-{% endif %}
|
|
</td>
|
|
<td class="number bridge-diff">
|
|
<button type="button" class="bridge-number" data-modal-kind="profit-gap" data-year="{{ row.year }}">{{ signed_eok_sign(profit_gap) }}</button>
|
|
</td>
|
|
<td class="number bridge-diff">
|
|
<button type="button" class="bridge-number" data-modal-kind="profit-gap-pre" data-year="{{ row.year }}">{{ signed_eok_sign(pre_adjustment_profit_gap) }}</button>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="modal-backdrop" id="gapModal" aria-hidden="true">
|
|
<div class="modal-card gap-modal-card" role="dialog" aria-modal="true" aria-labelledby="gapModalTitle">
|
|
<div class="gap-modal-head">
|
|
<h2 id="gapModalTitle">상세</h2>
|
|
<button type="button" class="button-secondary" id="gapModalClose">닫기</button>
|
|
</div>
|
|
<div class="gap-modal-body" id="gapModalBody"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const gapData = {{ data | tojson }};
|
|
|
|
function money(value) {
|
|
return Math.round(value || 0).toLocaleString("ko-KR");
|
|
}
|
|
|
|
function eok(value) {
|
|
return `${((value || 0) / 100000000).toLocaleString("ko-KR", { maximumFractionDigits: 1, minimumFractionDigits: 1 })}억`;
|
|
}
|
|
|
|
function signedEok(value) {
|
|
const prefix = (value || 0) > 0 ? "+" : "";
|
|
return `${prefix}${eok(value)}`;
|
|
}
|
|
|
|
function signedEokSign(value) {
|
|
const amount = Math.abs(value || 0);
|
|
if ((value || 0) > 0) return `<span class="bridge-positive">+${eok(amount)}</span>`;
|
|
if ((value || 0) < 0) return `<span class="bridge-negative">-${eok(amount)}</span>`;
|
|
return `<span class="bridge-zero">${eok(0)}</span>`;
|
|
}
|
|
|
|
function gapWithRate(value, basis) {
|
|
const rate = basis ? ((value || 0) / basis) * 100 : 0;
|
|
const rateText = `${rate >= 0 ? "+" : ""}${rate.toLocaleString("ko-KR", {
|
|
minimumFractionDigits: 1,
|
|
maximumFractionDigits: 1,
|
|
})}%`;
|
|
return `<span class="bridge-value-with-rate">${signedEokSign(value)}<span class="bridge-rate">(${rateText})</span></span>`;
|
|
}
|
|
|
|
function esc(value) {
|
|
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
"\"": """,
|
|
"'": "'",
|
|
}[char]));
|
|
}
|
|
|
|
function filteredRows(kind, year) {
|
|
const source = {
|
|
bridge: gapData.yearly_rows,
|
|
substantive: gapData.substantive_gap_rows,
|
|
adjustments: gapData.adjustment_rows,
|
|
labor: gapData.labor_rows,
|
|
accounts: gapData.account_detail_rows,
|
|
rationale: gapData.reason_items,
|
|
assumptions: gapData.assumptions,
|
|
"project-profit": gapData.project_profit_bridge_rows,
|
|
"raw-account-comparison": gapData.raw_account_comparison_rows,
|
|
}[kind] || [];
|
|
if (kind === "assumptions" || year === "all") return source;
|
|
return source.filter((row) => String(row.year) === String(year));
|
|
}
|
|
|
|
function projectBridgeFor(year) {
|
|
return (gapData.project_profit_bridge_rows || []).find((item) => String(item.year) === String(year)) || {};
|
|
}
|
|
|
|
function yearlyRowFor(year) {
|
|
return (gapData.yearly_rows || []).find((item) => String(item.year) === String(year)) || {};
|
|
}
|
|
|
|
function accountRowsFor(year, type) {
|
|
const allowedBuckets = type === "revenue"
|
|
? ["매출"]
|
|
: ["매출원가 총액", "판관비", "판관비 조정"];
|
|
return (gapData.account_detail_rows || [])
|
|
.filter((row) => String(row.year) === String(year) && allowedBuckets.includes(row.bucket))
|
|
.sort((a, b) => String(a.account_code).localeCompare(String(b.account_code)));
|
|
}
|
|
|
|
function adjustmentRowsFor(year, type) {
|
|
const allowedBuckets = type === "revenue" ? ["revenue"] : ["cogs", "sga"];
|
|
return (gapData.adjustment_rows || [])
|
|
.filter((row) => String(row.year) === String(year) && allowedBuckets.includes(row.bucket))
|
|
.sort((a, b) => Math.abs(b.delta || 0) - Math.abs(a.delta || 0));
|
|
}
|
|
|
|
function table(headers, rows, options = {}) {
|
|
if (!rows.length) return `<div class="modal-empty">표시할 상세 데이터가 없습니다.</div>`;
|
|
const wrapClass = ["table-wrap", options.wrapClass || ""].filter(Boolean).join(" ");
|
|
const tableClass = options.tableClass || "";
|
|
return `
|
|
<div class="${wrapClass}">
|
|
<table class="${tableClass}">
|
|
<thead><tr>${headers.map((header) => `<th class="${header.className || ""}">${esc(header.label)}</th>`).join("")}</tr></thead>
|
|
<tbody>
|
|
${rows.map((row) => `
|
|
<tr>
|
|
${headers.map((header) => `<td class="${header.className || ""}">${header.render ? header.render(row) : esc(row[header.key])}</td>`).join("")}
|
|
</tr>
|
|
`).join("")}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function adjustmentSummary(type, year) {
|
|
const yearly = yearlyRowFor(year);
|
|
const adjustments = yearly.adjustments || {};
|
|
const amount = type === "revenue"
|
|
? (adjustments.revenue || 0)
|
|
: (adjustments.cogs || 0) + (adjustments.sga || 0);
|
|
if (!amount) return "";
|
|
const label = type === "revenue" ? "매출 조정값" : "비용 조정값";
|
|
return table([
|
|
{ label: "조정 항목", key: "label" },
|
|
{ label: "조정 금액", className: "number", render: (row) => signedEok(row.amount) },
|
|
], [{ label, amount }]);
|
|
}
|
|
|
|
function renderAmountDetail(year, type, preAdjustment = false) {
|
|
let rows = accountRowsFor(year, type);
|
|
if (type === "revenue" && preAdjustment) {
|
|
const adjustmentByAccount = adjustmentRowsFor(year, "revenue").reduce((result, row) => {
|
|
const code = String(row.account_code || "");
|
|
result[code] = (result[code] || 0) + Number(row.delta || 0);
|
|
return result;
|
|
}, {});
|
|
rows = rows.map((row) => ({
|
|
...row,
|
|
display_amount: Number(row.wehago_amount || 0) - Number(adjustmentByAccount[String(row.account_code || "")] || 0),
|
|
}));
|
|
}
|
|
return `
|
|
${adjustmentSummary(type, year)}
|
|
<h3>계정별 금액</h3>
|
|
${table([
|
|
{ label: "구분", key: "bucket" },
|
|
{ label: "계정코드", key: "account_code" },
|
|
{ label: "계정명", key: "account_name" },
|
|
{ label: "금액", className: "number", render: (row) => money(row.display_amount ?? row.wehago_amount) },
|
|
], rows)}
|
|
`;
|
|
}
|
|
|
|
function renderGapDetail(year, type) {
|
|
const yearly = yearlyRowFor(year);
|
|
const bridge = projectBridgeFor(year);
|
|
const projectRevenue = bridge.project_revenue || 0;
|
|
const projectExpense = bridge.project_expense || 0;
|
|
const projectProfit = projectRevenue - projectExpense;
|
|
const revenueAdjustment = ((yearly.adjustments || {}).revenue || 0);
|
|
const preAdjustmentRevenue = (yearly.wehago_revenue || 0) - revenueAdjustment;
|
|
const expenseGap = (yearly.wehago_operating_expense || 0) - projectExpense;
|
|
const preAdjustmentRevenueGap = preAdjustmentRevenue - projectRevenue;
|
|
const definitions = {
|
|
revenue: [
|
|
{ label: "WEHAGO 매출", amount: yearly.wehago_revenue || 0 },
|
|
{ label: "프로젝트손익분석 매출", amount: projectRevenue },
|
|
{ label: "수익 차이", amount: (yearly.wehago_revenue || 0) - projectRevenue },
|
|
],
|
|
expense: [
|
|
{ label: "WEHAGO 비용", amount: yearly.wehago_operating_expense || 0 },
|
|
{ label: "프로젝트손익분석 비용", amount: projectExpense },
|
|
{ label: "비용 차이", amount: (yearly.wehago_operating_expense || 0) - projectExpense },
|
|
],
|
|
profit: [
|
|
{ label: "WEHAGO 손익", amount: yearly.wehago_operating_profit || 0 },
|
|
{ label: "프로젝트손익분석 손익", amount: projectProfit },
|
|
{ label: "손익 차이", amount: (yearly.wehago_operating_profit || 0) - projectProfit },
|
|
],
|
|
"revenue-pre": [
|
|
{ label: "WEHAGO 매출(조정전)", amount: preAdjustmentRevenue },
|
|
{ label: "프로젝트손익분석 매출", amount: projectRevenue },
|
|
{ label: "수익 차이(조정전)", amount: preAdjustmentRevenueGap },
|
|
],
|
|
"profit-pre": [
|
|
{ label: "수익 차이(조정전)", amount: preAdjustmentRevenueGap },
|
|
{ label: "비용 차이", amount: expenseGap },
|
|
{ label: "손익 차이(조정전)", amount: preAdjustmentRevenueGap - expenseGap },
|
|
],
|
|
};
|
|
const rows = definitions[type] || [];
|
|
const adjustmentType = type === "revenue" ? "revenue" : type === "expense" ? "expense" : "";
|
|
return `
|
|
${adjustmentType ? adjustmentSummary(adjustmentType, year) : ""}
|
|
${table([
|
|
{ label: "구분", key: "label" },
|
|
{ label: "금액", className: "number", render: (row) => signedEok(row.amount) },
|
|
], rows)}
|
|
${type === "revenue" ? `
|
|
<h3>매출 조정 근거</h3>
|
|
${renderAdjustmentEvidence(adjustmentRowsFor(year, "revenue"))}
|
|
` : ""}
|
|
${type === "expense" ? `
|
|
<h3>비용 조정 근거</h3>
|
|
${renderAdjustmentEvidence(adjustmentRowsFor(year, "expense"))}
|
|
` : ""}
|
|
`;
|
|
}
|
|
|
|
function renderAdjustmentEvidence(rows) {
|
|
return table([
|
|
{ label: "일자", key: "ledger_date" },
|
|
{ label: "전표", key: "voucher_no" },
|
|
{ label: "구분", key: "bucket" },
|
|
{ label: "계정", render: (row) => `${esc(row.account_code)} ${esc(row.account_name)}` },
|
|
{ label: "적요", key: "description" },
|
|
{ label: "조정효과", className: "number", render: (row) => signedEok(row.delta) },
|
|
], rows);
|
|
}
|
|
|
|
function renderRevenueAdjustments(year) {
|
|
const rows = adjustmentRowsFor(year, "revenue");
|
|
const total = rows.reduce((sum, row) => sum + Number(row.delta || 0), 0);
|
|
return `
|
|
${table([
|
|
{ label: "구분", key: "label" },
|
|
{ label: "금액", className: "number", render: (row) => signedEok(row.amount) },
|
|
], [{ label: "매출조정 합계", amount: total }])}
|
|
<h3>매출조정 계정</h3>
|
|
${renderAdjustmentEvidence(rows)}
|
|
`;
|
|
}
|
|
|
|
function renderBridge(year) {
|
|
const rows = filteredRows("bridge", year);
|
|
return table([
|
|
{ label: "연도", render: (row) => `${esc(row.year)}년` },
|
|
{ label: "wehago 매출", className: "number", render: (row) => eok(row.wehago_revenue) },
|
|
{ label: "매출조정", className: "number", render: (row) => signedEok((row.adjustments || {}).revenue || 0) },
|
|
{ label: "wehago 매출(조정전)", className: "number", render: (row) => eok((row.wehago_revenue || 0) - ((row.adjustments || {}).revenue || 0)) },
|
|
{ label: "프로젝트손익분석 매출", className: "number", render: (row) => eok(projectBridgeFor(row.year).project_revenue || 0) },
|
|
{ label: "수익 차이", className: "number bridge-diff", render: (row) => signedEokSign((row.wehago_revenue || 0) - (projectBridgeFor(row.year).project_revenue || 0)) },
|
|
{ label: "수익 차이(조정전)", className: "number bridge-diff", render: (row) => gapWithRate(
|
|
(row.wehago_revenue || 0)
|
|
- ((row.adjustments || {}).revenue || 0)
|
|
- (projectBridgeFor(row.year).project_revenue || 0),
|
|
row.wehago_revenue || 0
|
|
) },
|
|
{ label: "wehago 비용", className: "number", render: (row) => eok(row.wehago_operating_expense) },
|
|
{ label: "프로젝트손익분석 비용", className: "number", render: (row) => eok(projectBridgeFor(row.year).project_expense || 0) },
|
|
{ label: "비용 차이", className: "number bridge-diff", render: (row) => gapWithRate(
|
|
(row.wehago_operating_expense || 0) - (projectBridgeFor(row.year).project_expense || 0),
|
|
row.wehago_operating_expense || 0
|
|
) },
|
|
{ label: "wehago 손익", className: "number", render: (row) => signedEok(row.wehago_operating_profit) },
|
|
{ label: "프로젝트손익분석 손익", className: "number", render: (row) => {
|
|
const bridge = projectBridgeFor(row.year);
|
|
return signedEok((bridge.project_revenue || 0) - (bridge.project_expense || 0));
|
|
} },
|
|
{ label: "손익 차이", className: "number bridge-diff", render: (row) => {
|
|
const bridge = projectBridgeFor(row.year);
|
|
return signedEokSign((row.wehago_operating_profit || 0) - ((bridge.project_revenue || 0) - (bridge.project_expense || 0)));
|
|
} },
|
|
{ label: "손익 차이(조정전)", className: "number bridge-diff", render: (row) => {
|
|
const bridge = projectBridgeFor(row.year);
|
|
const revenueGapBeforeAdjustment = (row.wehago_revenue || 0)
|
|
- ((row.adjustments || {}).revenue || 0)
|
|
- (bridge.project_revenue || 0);
|
|
const expenseGap = (row.wehago_operating_expense || 0) - (bridge.project_expense || 0);
|
|
return signedEokSign(revenueGapBeforeAdjustment - expenseGap);
|
|
} },
|
|
], rows);
|
|
}
|
|
|
|
function renderSubstantive(year) {
|
|
const rows = filteredRows("substantive", year);
|
|
return `
|
|
<div class="gap-help">
|
|
<p>금액은 같은 성격 항목끼리 맞춘 실질 비교입니다. 452 매출원가 총액은 6xx 원가 상세와 더하지 않고 별도 확인합니다.</p>
|
|
<p>남은 차이 = WEHAGO - Hanmac ERP - 식별 조정효과입니다.</p>
|
|
</div>
|
|
${table([
|
|
{ label: "연도", key: "year" },
|
|
{ label: "구분", key: "section" },
|
|
{ label: "항목", key: "item_label" },
|
|
{ label: "WEHAGO", className: "number", render: (row) => eok(row.wehago_amount) },
|
|
{ label: "Hanmac ERP", className: "number", render: (row) => eok(row.erp_amount) },
|
|
{ label: "차이", className: "number", render: (row) => signedEok(row.gap_amount) },
|
|
{ label: "조정효과", className: "number", render: (row) => signedEok(row.adjustment_amount) },
|
|
{ label: "남은 차이", className: "number", render: (row) => signedEok(row.residual_gap) },
|
|
{ label: "WEHAGO 계정", key: "wehago_accounts" },
|
|
{ label: "ERP 계정", key: "erp_accounts" },
|
|
{ label: "해석", key: "interpretation" },
|
|
], rows)}
|
|
`;
|
|
}
|
|
|
|
function renderAdjustments(year) {
|
|
const rows = filteredRows("adjustments", year);
|
|
return table([
|
|
{ label: "일자", key: "ledger_date" },
|
|
{ label: "전표", key: "voucher_no" },
|
|
{ label: "구분", key: "bucket" },
|
|
{ label: "계정", render: (row) => `${esc(row.account_code)} ${esc(row.account_name)}` },
|
|
{ label: "적요", key: "description" },
|
|
{ label: "거래처/구분", key: "vendor_name" },
|
|
{ label: "효과", className: "number", render: (row) => signedEok(row.delta) },
|
|
{ label: "차변", className: "number", render: (row) => money(row.debit) },
|
|
{ label: "대변", className: "number", render: (row) => money(row.credit) },
|
|
], rows);
|
|
}
|
|
|
|
function renderLabor(year) {
|
|
const rows = filteredRows("labor", year);
|
|
return table([
|
|
{ label: "연도", key: "year" },
|
|
{ label: "원가성 인건비", className: "number", render: (row) => eok(row.direct_labor) },
|
|
{ label: "판관 인건비", className: "number", render: (row) => eok(row.sga_labor) },
|
|
{ label: "WEHAGO 인건비 합계", className: "number", render: (row) => eok(row.wehago_labor_total) },
|
|
{ label: "프로젝트손익1 인건비", className: "number", render: (row) => eok(row.hanmac_labor_total) },
|
|
{ label: "프로젝트손익1 - WEHAGO 차이", className: "number", render: (row) => signedEok(row.hanmac_wehago_labor_gap) },
|
|
{ label: "R&D성", className: "number", render: (row) => eok(row.rnd_like) },
|
|
{ label: "총시간", className: "number", render: (row) => `${money(row.total_hours)}h` },
|
|
{ label: "역산 단가", className: "number", render: (row) => `${money(row.implied_hourly_without_rnd)}원/h` },
|
|
{ label: "검토", render: (row) => (row.tags || []).map((tag) => `<span class="gap-tag">${esc(tag)}</span>`).join(" ") },
|
|
], rows);
|
|
}
|
|
|
|
function renderProjectProfit(year) {
|
|
const rows = filteredRows("project-profit", year);
|
|
if (!rows.length) return `<div class="modal-empty">표시할 손익 차이 구성 데이터가 없습니다.</div>`;
|
|
const row = rows[0];
|
|
const yearly = (gapData.yearly_rows || []).find((item) => String(item.year) === String(year)) || {};
|
|
const yearlyAdjustments = yearly.adjustments || {};
|
|
const expenseAdjustment = (yearlyAdjustments.cogs || 0) + (yearlyAdjustments.sga || 0);
|
|
const projectRevenue = row.project_revenue || 0;
|
|
const projectExpense = row.project_expense || 0;
|
|
const projectProfit = projectRevenue - projectExpense;
|
|
const preAdjustmentWehagoRevenue = (yearly.wehago_revenue || 0) - (yearlyAdjustments.revenue || 0);
|
|
const revenueGap = (yearly.wehago_revenue || 0) - projectRevenue;
|
|
const adjustedRevenueGap = (yearly.wehago_revenue || 0) - ((row.erp_revenue || 0) + (row.revenue_adjustment_total || 0));
|
|
const expenseGap = (yearly.wehago_operating_expense || 0) - projectExpense;
|
|
const adjustedExpenseGap = (yearly.wehago_operating_expense || 0) - (projectExpense + expenseAdjustment);
|
|
const profitGap = (yearly.wehago_operating_profit || 0) - projectProfit;
|
|
const adjustedProjectProfit = ((row.erp_revenue || 0) + (row.revenue_adjustment_total || 0)) - (projectExpense + expenseAdjustment);
|
|
const adjustedProfitGap = (yearly.wehago_operating_profit || 0) - adjustedProjectProfit;
|
|
const projectProfitBasis = [
|
|
row.project_profit_financial_logic_version ? `재무 로직 ${row.project_profit_financial_logic_version}` : "",
|
|
row.project_profit_h_mapping_version ? `H코드 매핑 ${row.project_profit_h_mapping_version}` : "",
|
|
row.project_profit_generated_at ? `계산시각 ${String(row.project_profit_generated_at).replace("T", " ")}` : "",
|
|
].filter(Boolean).join(" · ");
|
|
const items = [
|
|
["wehago 매출", yearly.wehago_revenue || 0, "WEHAGO 계정별 원장 기준 매출액 전체"],
|
|
["wehago 매출(조정전)", preAdjustmentWehagoRevenue, "WEHAGO 매출에서 식별된 진행률·결산 매출 조정효과를 제외한 비교값"],
|
|
["프로젝트손익분석 매출", projectRevenue, "프로젝트 손익분석 페이지의 기간 ERP 매출 총합"],
|
|
["수익 차이", revenueGap, "WEHAGO 매출 - 프로젝트손익분석 매출"],
|
|
["진행률 조정 고려 후 수익 잔차", adjustedRevenueGap, "WEHAGO 매출 - (HANMAC ERP 매출 + 식별된 진행률·결산 매출 조정)"],
|
|
["HANMAC ERP 매출", row.erp_revenue, "HANMAC ERP 전표 4xx 매출 기준"],
|
|
["식별된 결산·진행률 매출 조정 순액", row.revenue_adjustment_total, `${money(row.revenue_adjustment_count)}개 전표의 순효과`],
|
|
["공통매출", row.common_revenue, `${money(row.common_revenue_row_count || 0)}개 ZZZZZZ 공통매출 행`],
|
|
["음수 청구금액", row.project_negative_billing, "청구 취소·차감 등 음수 청구의 합계"],
|
|
["ERP 매출-세금계산서 차이", row.project_revenue_billing_gap, "HANMAC ERP 매출 - 프로젝트손익분석 매출세금계산서 발행액"],
|
|
["wehago 비용", yearly.wehago_operating_expense || 0, "WEHAGO 계정별 원장 기준 원가 + 판관비"],
|
|
["프로젝트손익분석 비용", projectExpense, "프로젝트 손익분석 페이지의 기간 배부비용"],
|
|
["비용 차이", expenseGap, "WEHAGO 비용 - 프로젝트손익분석 비용"],
|
|
["회계감사 조정 고려 후 비용 잔차", adjustedExpenseGap, "WEHAGO 비용 - (프로젝트손익분석 비용 + 식별된 대체·상계·결산 비용 조정)"],
|
|
["식별된 비용 조정효과", expenseAdjustment, "WEHAGO 원장에서 감사·결산·대체 문구로 식별한 원가/판관비 조정 순효과"],
|
|
["wehago 손익", yearly.wehago_operating_profit || 0, "WEHAGO 매출 - WEHAGO 비용"],
|
|
["프로젝트손익분석 손익", projectProfit, "프로젝트손익분석 매출 - 프로젝트손익분석 비용"],
|
|
["손익 차이", profitGap, "WEHAGO 손익 - 프로젝트손익분석 손익"],
|
|
["조정 고려 후 손익 잔차", adjustedProfitGap, "진행률 매출 조정과 비용 조정을 함께 고려한 뒤의 손익 차이"],
|
|
["프로젝트손익1 인건비", row.hanmac_labor_total, "프로젝트 손익분석 페이지1의 인건비+조정인건비 합계"],
|
|
["중복 방지 제외 공통 급여", row.common_labor_excluded, "한맥 인건비와 중복되지 않도록 미배부"],
|
|
["부족근로 판관비", row.hanmac_missing_regular_sga_allocated, "한맥 인건비 구성요소이며 총비용에는 한 번만 반영"],
|
|
["공통 비인건비 배부", row.common_cost_allocated, "공통 5xx 중 급여성 비용 제외"],
|
|
["공통 판관비 배부", row.common_sga_allocated, "공통 6xx 판관비"],
|
|
["프로젝트 직접 비인건비", row.direct_project_nonlabor, "프로젝트 코드가 연결된 직접 비용"],
|
|
["손익 브릿지 검증 차이", row.profit_bridge_validation_gap, "수익 차이 - 비용 차이 - 손익 차이, 0원이면 산식 일치"],
|
|
["매출 브릿지 검증 차이", row.revenue_bridge_validation_gap, "WEHAGO→ERP 차이 + ERP→프로젝트 차이 검증, 0원이면 산식 일치"],
|
|
["ERP+조정매출 검증 차이", row.adjusted_revenue_bridge_validation_gap, "ERP 원매출 + 조정액 - ERP+조정매출, 0원이면 산식 일치"],
|
|
];
|
|
const summaryTable = table([
|
|
{ label: "구분", key: "label" },
|
|
{ label: "금액", className: "number", render: (item) => signedEok(item.amount) },
|
|
{ label: "해석", key: "note" },
|
|
], items.map(([label, amount, note]) => ({ label, amount, note })));
|
|
const adjustments = (gapData.adjustment_rows || [])
|
|
.filter((item) => String(item.year) === String(year) && item.bucket === "revenue")
|
|
.sort((a, b) => String(a.ledger_date).localeCompare(String(b.ledger_date)));
|
|
const adjustmentTable = table([
|
|
{ label: "일자", key: "ledger_date" },
|
|
{ label: "전표", key: "voucher_no" },
|
|
{ label: "계정", render: (item) => `${esc(item.account_code)} ${esc(item.account_name)}` },
|
|
{ label: "적요", key: "description" },
|
|
{ label: "구분/거래처", key: "vendor_name" },
|
|
{ label: "조정효과", className: "number", render: (item) => signedEok(item.delta) },
|
|
], adjustments);
|
|
const expenseAdjustments = (gapData.adjustment_rows || [])
|
|
.filter((item) => String(item.year) === String(year) && ["cogs", "sga"].includes(item.bucket))
|
|
.sort((a, b) => Math.abs(b.delta || 0) - Math.abs(a.delta || 0));
|
|
const expenseAdjustmentTable = table([
|
|
{ label: "일자", key: "ledger_date" },
|
|
{ label: "전표", key: "voucher_no" },
|
|
{ label: "구분", key: "bucket" },
|
|
{ label: "계정", render: (item) => `${esc(item.account_code)} ${esc(item.account_name)}` },
|
|
{ label: "적요", key: "description" },
|
|
{ label: "구분/거래처", key: "vendor_name" },
|
|
{ label: "조정효과", className: "number", render: (item) => signedEok(item.delta) },
|
|
], expenseAdjustments);
|
|
return `
|
|
<div class="gap-help">
|
|
<p><strong>핵심 브릿지의 프로젝트손익분석 매출은 기간 ERP 매출 총합, 비용은 기간 배부비용입니다.</strong></p>
|
|
${projectProfitBasis ? `<p>적용 기준: ${esc(projectProfitBasis)}</p>` : ""}
|
|
<p>수익 잔차는 진행률·결산 매출 조정을 고려한 뒤에도 남는 차이를, 비용 잔차는 대체·상계·결산 비용 조정을 고려한 뒤에도 남는 차이를 보여줍니다.</p>
|
|
</div>
|
|
${summaryTable}
|
|
<h3>식별된 매출 조정 전표 근거</h3>
|
|
${adjustmentTable}
|
|
<h3>식별된 비용 조정 전표 근거</h3>
|
|
${expenseAdjustmentTable}
|
|
`;
|
|
}
|
|
|
|
function renderAccounts(year) {
|
|
const rows = filteredRows("accounts", year);
|
|
return table([
|
|
{ label: "연도", key: "year" },
|
|
{ label: "구분", key: "bucket" },
|
|
{ label: "계정코드", key: "account_code" },
|
|
{ label: "계정명", key: "account_name" },
|
|
{ label: "WEHAGO 표시금액", className: "number", render: (row) => money(row.wehago_amount) },
|
|
], rows);
|
|
}
|
|
|
|
function renderAssumptions() {
|
|
return `<div class="gap-help">${gapData.assumptions.map((item) => `<p>${esc(item)}</p>`).join("")}</div>`;
|
|
}
|
|
|
|
function renderAccountList(value) {
|
|
const text = String(value || "").trim();
|
|
if (!text) return "";
|
|
const parts = text
|
|
.split(/\s*[,;]\s*/)
|
|
.map((part) => part.trim())
|
|
.filter(Boolean);
|
|
const items = parts.length ? parts : [text];
|
|
return `
|
|
<div class="account-list">
|
|
${items.map((item) => {
|
|
const match = item.match(/^([A-Za-z]?\d{2,}(?:-\d+)?)\s+(.+)$/);
|
|
if (!match) return `<span class="account-name">${esc(item)}</span>`;
|
|
return `
|
|
<span class="account-chip">
|
|
<span class="account-code">${esc(match[1])}</span>
|
|
<span class="account-name">${esc(match[2])}</span>
|
|
</span>
|
|
`;
|
|
}).join("")}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderRawAccountComparison(year) {
|
|
const rows = filteredRows("raw-account-comparison", year);
|
|
const summary = `
|
|
<div class="gap-help">
|
|
<p><strong>WEHAGO 조정 전 환산액 = WEHAGO 현재액 - 식별된 감사·결산·대체 조정효과</strong></p>
|
|
<p>Hanmac ERP는 원천 전표의 5xx 원가·6xx 판관비 및 대응 유사 계정군 합계입니다. 452 매출원가 총액과 WEHAGO 6xx 원가 상세는 중복 합산하지 않습니다.</p>
|
|
</div>
|
|
`;
|
|
return summary + table([
|
|
{ label: "연도", key: "year" },
|
|
{ label: "구분", key: "section" },
|
|
{ label: "비교단위", key: "comparison_level" },
|
|
{ label: "계정/계정군", key: "item_label" },
|
|
{ label: "WEHAGO 현재액", className: "number", render: (row) => money(row.wehago_current_amount) },
|
|
{ label: "감사·대체 조정", className: "number", render: (row) => signedEok(row.audit_adjustment_amount) },
|
|
{ label: "WEHAGO 조정 전", className: "number", render: (row) => money(row.wehago_pre_adjustment_amount) },
|
|
{ label: "Hanmac ERP", className: "number", render: (row) => money(row.erp_amount) },
|
|
{ label: "조정 전 차이", className: "number", render: (row) => signedEok(row.pre_adjustment_gap) },
|
|
{ label: "WEHAGO 계정", render: (row) => renderAccountList(row.wehago_accounts) },
|
|
{ label: "Hanmac ERP 계정", render: (row) => renderAccountList(row.erp_accounts) },
|
|
{ label: "비고", key: "note" },
|
|
], rows, {
|
|
wrapClass: "raw-account-comparison-wrap",
|
|
tableClass: "raw-account-comparison-table",
|
|
});
|
|
}
|
|
|
|
function renderRationale(year, tag) {
|
|
const reason = (gapData.reason_items || []).find((item) => String(item.year) === String(year) && item.tag === tag);
|
|
const yearly = (gapData.yearly_rows || []).find((item) => String(item.year) === String(year)) || {};
|
|
if (!reason) {
|
|
return `<div class="modal-empty">해당 판단 근거를 찾지 못했습니다.</div>`;
|
|
}
|
|
const revenueAccounts = (gapData.account_detail_rows || [])
|
|
.filter((row) => String(row.year) === String(year) && row.bucket === "매출")
|
|
.sort((a, b) => Math.abs(b.wehago_amount || 0) - Math.abs(a.wehago_amount || 0))
|
|
.slice(0, 8);
|
|
const costAccounts = (gapData.account_detail_rows || [])
|
|
.filter((row) => String(row.year) === String(year) && ["매출원가 총액", "원가 상세", "판관비", "판관비 조정"].includes(row.bucket))
|
|
.sort((a, b) => Math.abs(b.wehago_amount || 0) - Math.abs(a.wehago_amount || 0))
|
|
.slice(0, 10);
|
|
const relatedAdjustments = (gapData.adjustment_rows || [])
|
|
.filter((row) => String(row.year) === String(year) && (
|
|
tag.includes("매출") ? row.bucket === "revenue" :
|
|
tag.includes("원가") || tag.includes("452") || tag.includes("6xx") ? ["cogs", "sga"].includes(row.bucket) :
|
|
true
|
|
))
|
|
.sort((a, b) => Math.abs(b.delta || 0) - Math.abs(a.delta || 0))
|
|
.slice(0, 8);
|
|
const classificationRows = (gapData.classification_review_rows || [])
|
|
.filter((row) => String(row.year) === String(year))
|
|
.filter((row) => tag.includes("원가/판관비") || tag.includes("6xx/8xx"))
|
|
.sort((a, b) => Math.abs((b.erp_sga_amount || 0) - (b.wehago_sga_amount || 0)) - Math.abs((a.erp_sga_amount || 0) - (a.wehago_sga_amount || 0)));
|
|
const basisTable = table([
|
|
{ label: "근거 항목", key: "label" },
|
|
{ label: "금액/값", className: "number", render: (row) => typeof row.value === "number" ? (Math.abs(row.value) >= 1000000 ? signedEok(row.value) : money(row.value)) : esc(row.value) },
|
|
], reason.basis_rows || []);
|
|
const outline = (reason.outline_rows || []).length
|
|
? `<div class="rationale-outline">${reason.outline_rows.map((item) => `
|
|
<div class="rationale-card">
|
|
<strong>${esc(item.title)}</strong>
|
|
<span>${esc(item.body)}</span>
|
|
</div>
|
|
`).join("")}</div>`
|
|
: "";
|
|
const nextChecks = (reason.next_checks || []).map((item) => `<li>${esc(item)}</li>`).join("");
|
|
const accountRows = tag.includes("매출") ? revenueAccounts : costAccounts;
|
|
const accountTitle = tag.includes("매출") ? "관련 매출 계정" : "관련 원가/판관비 계정";
|
|
return `
|
|
<div class="gap-panel">
|
|
<div>
|
|
<h2>${esc(reason.tag)}</h2>
|
|
<p class="gap-help">${esc(reason.summary)}</p>
|
|
</div>
|
|
${outline}
|
|
${basisTable}
|
|
<div>
|
|
<h2>다음 확인 항목</h2>
|
|
<ul class="gap-help">${nextChecks}</ul>
|
|
</div>
|
|
${classificationRows.length ? `
|
|
<div>
|
|
<h2>계정명·적요 검토 결과</h2>
|
|
${table([
|
|
{ label: "항목", key: "label" },
|
|
{ label: "판단", key: "finding" },
|
|
{ label: "ERP 판관비", className: "number", render: (row) => eok(row.erp_sga_amount) },
|
|
{ label: "WEHAGO 원가", className: "number", render: (row) => eok(row.wehago_cost_amount) },
|
|
{ label: "WEHAGO 판관비", className: "number", render: (row) => eok(row.wehago_sga_amount) },
|
|
{ label: "ERP 계정", key: "erp_account_names" },
|
|
{ label: "WEHAGO 계정", key: "wehago_account_names" },
|
|
], classificationRows)}
|
|
</div>
|
|
` : ""}
|
|
<div>
|
|
<h2>${accountTitle}</h2>
|
|
${table([
|
|
{ label: "구분", key: "bucket" },
|
|
{ label: "계정", render: (row) => `${esc(row.account_code)} ${esc(row.account_name)}` },
|
|
{ label: "금액", className: "number", render: (row) => eok(row.wehago_amount) },
|
|
], accountRows)}
|
|
</div>
|
|
<div>
|
|
<h2>관련 감사/결산 조정 전표</h2>
|
|
${table([
|
|
{ label: "일자", key: "ledger_date" },
|
|
{ label: "전표", key: "voucher_no" },
|
|
{ label: "구분", key: "bucket" },
|
|
{ label: "계정", render: (row) => `${esc(row.account_code)} ${esc(row.account_name)}` },
|
|
{ label: "적요", key: "description" },
|
|
{ label: "효과", className: "number", render: (row) => signedEok(row.delta) },
|
|
], relatedAdjustments)}
|
|
</div>
|
|
<div>
|
|
<h2>연도 요약</h2>
|
|
${table([
|
|
{ label: "항목", key: "label" },
|
|
{ label: "금액", className: "number", render: (row) => signedEok(row.value) },
|
|
], [
|
|
{ label: "조정 후 매출 차이", value: yearly.adjusted_revenue_gap || 0 },
|
|
{ label: "조정 후 원가 차이", value: yearly.adjusted_cost_gap || 0 },
|
|
{ label: "조정 후 판관비 차이", value: yearly.adjusted_sga_gap || 0 },
|
|
{ label: "조정 후 영업비용 차이", value: yearly.adjusted_operating_expense_gap || 0 },
|
|
])}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function openModal(kind, year, tag = "") {
|
|
const modal = document.getElementById("gapModal");
|
|
const title = document.getElementById("gapModalTitle");
|
|
const body = document.getElementById("gapModalBody");
|
|
const card = modal.querySelector(".gap-modal-card");
|
|
const labels = {
|
|
bridge: `${year}년 차이 브릿지`,
|
|
"revenue-amount": `${year}년 WEHAGO 매출 계정별 금액`,
|
|
"revenue-adjustment": `${year}년 매출조정`,
|
|
"revenue-pre-adjustment": `${year}년 WEHAGO 매출(조정전) 계정별 금액`,
|
|
"expense-amount": `${year}년 WEHAGO 비용 계정별 금액`,
|
|
"revenue-gap": `${year}년 수익 차이`,
|
|
"revenue-gap-pre": `${year}년 수익 차이(조정전)`,
|
|
"expense-gap": `${year}년 비용 차이`,
|
|
"profit-gap": `${year}년 손익 차이`,
|
|
"profit-gap-pre": `${year}년 손익 차이(조정전)`,
|
|
substantive: year === "all" ? "주요 실질 차이 항목" : `${year}년 주요 실질 차이 항목`,
|
|
adjustments: `${year}년 감사/결산 조정 전표`,
|
|
labor: `${year}년 WEHAGO·한맥 인건비 비교`,
|
|
"project-profit": `${year}년 프로젝트손익·WEHAGO 손익 차이`,
|
|
accounts: `${year}년 계정 상세`,
|
|
rationale: `${year}년 판단 근거`,
|
|
assumptions: "집계 기준",
|
|
"raw-account-comparison": "WEHAGO 조정 전 · Hanmac ERP 계정 비교",
|
|
};
|
|
const renderers = {
|
|
bridge: renderBridge,
|
|
"revenue-amount": (selectedYear) => renderAmountDetail(selectedYear, "revenue", false),
|
|
"revenue-adjustment": renderRevenueAdjustments,
|
|
"revenue-pre-adjustment": (selectedYear) => renderAmountDetail(selectedYear, "revenue", true),
|
|
"expense-amount": (selectedYear) => renderAmountDetail(selectedYear, "expense", false),
|
|
"revenue-gap": (selectedYear) => renderGapDetail(selectedYear, "revenue"),
|
|
"revenue-gap-pre": (selectedYear) => renderGapDetail(selectedYear, "revenue-pre"),
|
|
"expense-gap": (selectedYear) => renderGapDetail(selectedYear, "expense"),
|
|
"profit-gap": (selectedYear) => renderGapDetail(selectedYear, "profit"),
|
|
"profit-gap-pre": (selectedYear) => renderGapDetail(selectedYear, "profit-pre"),
|
|
substantive: renderSubstantive,
|
|
adjustments: renderAdjustments,
|
|
labor: renderLabor,
|
|
"project-profit": renderProjectProfit,
|
|
accounts: renderAccounts,
|
|
rationale: (selectedYear) => renderRationale(selectedYear, tag),
|
|
assumptions: renderAssumptions,
|
|
"raw-account-comparison": renderRawAccountComparison,
|
|
};
|
|
title.textContent = kind === "rationale" ? `${year}년 ${tag}` : (labels[kind] || "상세");
|
|
body.innerHTML = (renderers[kind] || renderBridge)(year);
|
|
card?.classList.toggle("wide", kind === "raw-account-comparison");
|
|
modal.classList.add("open");
|
|
modal.setAttribute("aria-hidden", "false");
|
|
}
|
|
|
|
function closeModal() {
|
|
const modal = document.getElementById("gapModal");
|
|
modal.classList.remove("open");
|
|
modal.setAttribute("aria-hidden", "true");
|
|
}
|
|
|
|
document.querySelectorAll("[data-modal-kind]").forEach((button) => {
|
|
button.addEventListener("click", () => openModal(button.dataset.modalKind, button.dataset.year, button.dataset.tag || ""));
|
|
});
|
|
document.getElementById("gapModalClose")?.addEventListener("click", closeModal);
|
|
document.getElementById("gapModal")?.addEventListener("click", (event) => {
|
|
if (event.target.id === "gapModal") closeModal();
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") closeModal();
|
|
});
|
|
</script>
|
|
{% endblock %}
|