Update wehago matching logic and exclude reports

This commit is contained in:
b17301
2026-07-03 09:07:04 +09:00
parent a09190ecd3
commit da073ad6ef
62 changed files with 36940 additions and 1554 deletions
+253 -25
View File
@@ -466,6 +466,17 @@
margin-left: 0;
}
.status-unit {
display: block;
width: 100%;
margin-top: 2px;
font-size: 11px;
font-weight: 800;
line-height: 1;
color: #64748b;
text-align: left;
}
.status-card[data-count-pending="true"] .status-value {
color: #64748b;
font-size: 13px;
@@ -653,18 +664,20 @@
.table-wrap {
width: 100%;
max-width: 100%;
overflow-x: hidden;
overflow-x: auto;
overflow-y: auto;
border-top: 1px solid rgba(217, 221, 227, 0.9);
background: transparent;
max-height: 460px;
background: #fff;
max-height: none;
box-sizing: border-box;
position: relative;
isolation: isolate;
scrollbar-gutter: stable;
}
.detail-panel .table-wrap {
max-height: 360px;
overflow-x: hidden;
overflow-y: auto;
height: clamp(440px, 68dvh, 980px);
max-height: none;
}
.table-footer {
@@ -703,8 +716,10 @@
.table-wrap th {
position: sticky;
top: 0;
z-index: 1;
z-index: 10;
background: #f8fafc;
background-clip: padding-box;
box-shadow: 0 1px 0 rgba(148, 163, 184, 0.55), 0 3px 8px rgba(15, 23, 42, 0.1);
font-size: 13px;
letter-spacing: 0.01em;
white-space: nowrap;
@@ -784,14 +799,46 @@
font-size: 12px;
}
.voucher-review-notice td {
background: #fff7ed;
color: #9a3412;
font-size: 12px;
border-bottom-color: rgba(251, 146, 60, 0.28);
}
.voucher-review-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.voucher-review-badge {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 999px;
background: #fed7aa;
color: #7c2d12;
font-size: 11px;
font-weight: 800;
}
.voucher-review-note {
color: #9a3412;
}
.voucher-group-lines {
overflow: visible;
max-height: none;
position: relative;
background: #fff;
}
.voucher-group-lines table {
width: 100%;
min-width: 0;
min-width: 1540px;
border-collapse: collapse;
table-layout: fixed;
}
@@ -830,8 +877,10 @@
.voucher-group-lines th {
position: sticky;
top: 0;
z-index: 2;
z-index: 10;
background: #f8fafc;
background-clip: padding-box;
box-shadow: 0 1px 0 rgba(148, 163, 184, 0.55), 0 3px 8px rgba(15, 23, 42, 0.1);
color: inherit;
font-size: 13px;
letter-spacing: 0.01em;
@@ -1513,11 +1562,14 @@
}
body[data-view-mode="dual"] #pairRecommendPanel.active .table-wrap {
max-height: calc(100vh - 230px);
height: calc(100dvh - 230px);
min-height: 360px;
max-height: none;
}
body[data-view-mode="dual"] .detail-panel .table-wrap {
max-height: clamp(300px, 44vh, 520px);
height: clamp(400px, 62dvh, 840px);
max-height: none;
}
body[data-view-mode="dual"] .cell-code {
@@ -1549,6 +1601,10 @@
}
@media (max-width: 720px) {
.detail-panel .table-wrap {
height: clamp(360px, 62dvh, 720px);
}
.voucher-topbar,
.panel-header,
.data-panel summary {
@@ -1638,6 +1694,15 @@
<div><h2>기간 {{ wehago_compare.selected_start_year or '-' }} ~ {{ wehago_compare.selected_end_year or '-' }}</h2></div>
</div>
<div class="panel-body">
{% set final_summary = wehago_compare.wehago_final_status_summary or {} %}
{% set final_diag = final_summary.diagnostics or {} %}
<div class="panel-meta" id="wehagoFinalBasisMeta">
WEHAGO 전표 단위 기준:
{{ "{:,}".format(final_diag.wehago_status_total or final_summary.classified_total or 0) }}건 /
원천 {{ "{:,}".format(final_diag.wehago_voucher_total or final_summary.raw_total or 0) }}건 /
차이 {{ "{:,}".format(final_diag.wehago_status_difference or final_summary.difference or 0) }}건 /
{{ final_summary.source or 'projection' }}
</div>
<div class="status-grid">
{% for section in wehago_compare.metric_sections %}
{% if section.key in ['voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted', 'hanmac_unconnected', 'erp_voucher_matched', 'erp_voucher_unmatched'] %}
@@ -1646,6 +1711,9 @@
class="status-card"
data-target="detail-{{ section.key }}"
data-status="{{ section.key }}"
data-count="{{ section.count or 0 }}"
data-count-unit="{{ section.count_unit|default('전표그룹') }}"
data-projection-signature="{{ section.projection_signature|default('') }}"
data-count-pending="{% if wehago_compare.pending and section.count == 0 %}true{% else %}false{% endif %}"
aria-expanded="false"
>
@@ -1653,6 +1721,7 @@
<span class="status-value">
{% if wehago_compare.pending and section.count == 0 %}갱신 중{% else %}{{ "{:,}".format(section.count) }}{% endif %}
</span>
<span class="status-unit">{{ section.count_unit|default('전표그룹') }}</span>
</button>
{% endif %}
{% endfor %}
@@ -1698,7 +1767,7 @@
<div class="suggest-dropdown" data-erp-account-suggest="{{ section.key }}"></div>
</div>
<div class="suggest-field">
<input type="text" name="voucher_no" placeholder="전표번호" autocomplete="off">
<input type="text" name="voucher_no" placeholder="일자+전표번호" autocomplete="off">
<div class="suggest-dropdown" data-voucher-suggest="{{ section.key }}"></div>
</div>
<div class="suggest-field">
@@ -1723,7 +1792,7 @@
{% else %}
<form class="filter-form" data-filter-kind="status" data-status="{{ section.key }}">
<div class="suggest-field">
<input type="text" name="voucher_no" placeholder="전표번호" autocomplete="off">
<input type="text" name="voucher_no" placeholder="일자+전표번호" autocomplete="off">
<div class="suggest-dropdown" data-voucher-suggest="{{ section.key }}"></div>
</div>
<div class="suggest-field">
@@ -2064,15 +2133,65 @@
];
};
const firstPresentValue = (...values) => {
for (const value of values) {
if (value === null || value === undefined) continue;
if (typeof value === 'string' && value.trim() === '') continue;
return value;
}
return '';
};
const normalizeVoucherDisplayRow = (row = {}, summary = {}) => {
const source = row || {};
const group = summary || {};
return {
...source,
status_label: firstPresentValue(source.status_label, group.status_label),
fiscal_year: firstPresentValue(source.fiscal_year, group.fiscal_year),
ledger_date: firstPresentValue(source.ledger_date, group.ledger_date),
proof_date: firstPresentValue(source.proof_date, group.proof_date),
voucher_no: firstPresentValue(source.voucher_no, group.voucher_no),
// 가전표번호는 확정전표번호로 보완하지 않는다. 번호 오염을 막기 위해 draft_no 계열만 허용한다.
draft_no: firstPresentValue(source.draft_no, source.erp_draft_no, source.hanmac_draft_no, group.draft_no),
ledger_account_name: firstPresentValue(source.ledger_account_name, source.wehago_account_name, group.ledger_accounts),
ledger_vendor: firstPresentValue(source.ledger_vendor, source.wehago_vendor, group.ledger_vendors),
ledger_debit: firstPresentValue(source.ledger_debit, source.wehago_debit, group.ledger_debit),
ledger_credit: firstPresentValue(source.ledger_credit, source.wehago_credit, group.ledger_credit),
ledger_desc: firstPresentValue(source.ledger_desc, source.wehago_desc, group.ledger_desc),
voucher_account_name: firstPresentValue(
source.voucher_account_name,
source.erp_account_name,
source.hanmac_account_name,
group.voucher_account_name,
group.voucher_accounts
),
voucher_vendor: firstPresentValue(
source.voucher_vendor,
source.erp_vendor,
source.hanmac_vendor,
group.voucher_vendor,
group.voucher_vendors
),
voucher_debit: firstPresentValue(source.voucher_debit, source.erp_debit, source.hanmac_debit, group.voucher_debit),
voucher_credit: firstPresentValue(source.voucher_credit, source.erp_credit, source.hanmac_credit, group.voucher_credit),
voucher_desc: firstPresentValue(source.voucher_desc, source.erp_desc, source.hanmac_desc, group.voucher_desc),
};
};
const renderVoucherDetailTable = (group) => {
if (!voucherDetailTableWrap) return;
const rows = Array.isArray(group?.rows) ? group.rows : [];
const summary = group?.summary || {};
const rawRows = Array.isArray(group?.rows) ? group.rows : [];
const rows = rawRows.length
? rawRows.map((row) => normalizeVoucherDisplayRow(row, summary))
: [normalizeVoucherDisplayRow(summary, summary)];
const isHanmacUnconnected = String(group?.summary?.status_label || '').toLowerCase() === 'hanmac unconnected';
const lineColumns = [
['status_label', '구분'],
...getVoucherLineColumns(isHanmacUnconnected ? 'hanmac_unconnected' : ''),
];
if (!rows.length) {
if (!rawRows.length && !Object.keys(summary).length) {
voucherDetailTableWrap.innerHTML = '<div class="table-placeholder">표시할 상세 행이 없습니다.</div>';
return;
}
@@ -2383,6 +2502,57 @@
}
return String(raw ?? '');
};
const getVoucherReviewBadges = (group) => {
const summary = group?.summary || {};
const reasonText = [
summary.review_reason || '',
...(Array.isArray(group?.rows) ? group.rows.map((row) => row?.review_reason || '') : []),
].join(' / ');
const badges = [];
const add = (key, label, note = '') => {
if (!reasonText.includes(key)) return;
if (badges.some((item) => item.label === label)) return;
badges.push({ label, note });
};
const hasStrongInternalCandidate = [
'INTERNAL_STATUS=matched_direct_candidate',
'INTERNAL_STATUS=matched_with_settlement_bridge_candidate',
'INTERNAL_STATUS=matched_bundle_candidate',
].some((key) => reasonText.includes(key));
add('PARTIAL_WEAK_MONTH_MISMATCH', '월 불일치·약한 근거', '부가세/비용 직접행 없이 일부 정산행만 맞아 재검토가 필요합니다.');
add('ERP_PARTIAL_VOUCHER_ROWS_ONLY', 'ERP 일부행 후보', '같은 ERP 전표의 일부 행만 연결된 후보입니다.');
add('TAX_INVOICE_CORE_ROWS_NOT_MATCHED', '핵심 계정 누락', '부가세·비용·채권채무 구조가 완전하지 않습니다.');
add('PROJECT_TOKEN_CONFLICT', '현장명 불일치', 'WEHAGO와 ERP의 현장/사업명 단서가 다릅니다.');
add('MONTH_CONFLICT_RECHECK', '월 불일치', '전표월 또는 증빙월 확인이 필요합니다.');
if (!hasStrongInternalCandidate) {
add('WEAK_MATCH_RECHECK', '약한 매칭', '금액 외 단서가 부족합니다.');
}
add('DIRECT_MATCH_CANDIDATE', '직접 대응 후보', '금액·계정·거래 문맥이 함께 일치하는 ERP 원본행입니다.');
add('SETTLEMENT_BRIDGE_CANDIDATE', '정산 브리지', '직접 업무행이 아니라 결제·정산 상대행으로 연결된 후보입니다.');
add('SHARED_ALLOCATION_CANDIDATE', '공유 합산행', '여러 WEHAGO 전표의 배분 합계가 이 ERP 원본행을 구성합니다.');
add('SHARED_BUNDLE_PAYMENT_CANDIDATE', 'N:1 결제 묶음', '여러 WEHAGO 전표가 하나의 ERP 결제행을 공유하는 묶음 후보입니다.');
add('ERP_FULL_DRAFT_VOUCHER_CONTEXT', 'ERP 전체 전표', '후보 가전표의 원본행 전체를 표시합니다.');
return badges;
};
const renderVoucherReviewNoticeRow = (group, colSpan) => {
if (statusKey !== 'voucher_recheck') return '';
const badges = getVoucherReviewBadges(group);
if (!badges.length) return '';
const badgeHtml = badges
.map((item) => `<span class="voucher-review-badge" title="${escapeHtml(item.note || item.label)}">${escapeHtml(item.label)}</span>`)
.join('');
const note = badges.map((item) => item.note).find(Boolean) || '상세 버튼에서 연결 행을 확인해주세요.';
return `
<tr class="voucher-review-notice">
<td colspan="${Number(colSpan || 1)}">
<div class="voucher-review-badges">
${badgeHtml}
<span class="voucher-review-note">${escapeHtml(note)}</span>
</div>
</td>
</tr>
`;
};
if (!groups.length) {
const fallbackRows = Array.isArray(payload.rows) ? payload.rows : [];
if (!fallbackRows.length && !append) {
@@ -2393,7 +2563,8 @@
includeReviewCheckbox ? '<th class="selection-col">매칭</th><th class="selection-col">분리</th>' : '',
...lineColumns.map(([field, label]) => `<th class="${getColumnClass(field)}">${escapeHtml(label)}</th>`),
].join('');
const fallbackBody = fallbackRows.map((row, summaryIndex) => {
const fallbackBody = fallbackRows.map((rawRow, summaryIndex) => {
const row = normalizeVoucherDisplayRow(rawRow, rawRow);
const groupKey = `${row.fiscal_year || ''}|${row.voucher_no || ''}|${row.draft_no || ''}|${row.ledger_date || ''}|${row.proof_date || ''}|${summaryIndex}`;
const cells = lineColumns.map(([field]) => {
const display = formatValue(field, row[field]);
@@ -2431,9 +2602,13 @@
...lineColumns.map(([field, label]) => `<th class="${getColumnClass(field)}">${escapeHtml(label)}</th>`),
].join('');
const lineBody = groups.map((group, groupIndex) => {
const rows = Array.isArray(group.rows) ? group.rows : [];
const summary = group?.summary || {};
const rawRows = Array.isArray(group.rows) ? group.rows : [];
const rows = rawRows.length
? rawRows.map((row) => normalizeVoucherDisplayRow(row, summary))
: [normalizeVoucherDisplayRow(summary, summary)];
const groupKey = buildVoucherRecheckGroupKey(group, groupIndex);
return rows.map((row, rowIndex) => {
const groupRowsHtml = rows.map((row, rowIndex) => {
const rowClasses = [];
if (rowIndex === 0) {
rowClasses.push('voucher-table-group-start');
@@ -2455,6 +2630,7 @@
}
return `<tr class="${rowClasses.join(' ')}">${checkboxCell}${cells}</tr>`;
}).join('');
return `${renderVoucherReviewNoticeRow(group, lineColumns.length + (includeReviewCheckbox ? 2 : 0))}${groupRowsHtml}`;
}).join('');
const tableHtml = `
<table>
@@ -2575,9 +2751,10 @@
const setMeta = (key, totalCount, shownCount, notice = '', stats = {}) => {
const target = document.querySelector(`[data-result-meta="${key}"]`);
if (target) {
const countText = `조회 결과 ${Number(totalCount || 0).toLocaleString()}`;
const unit = String(stats.count_unit || document.querySelector(`.status-card[data-status="${key}"]`)?.dataset.countUnit || '건');
const countText = `조회 결과 ${Number(totalCount || 0).toLocaleString()}${unit}`;
const shownText = shownCount
? ` / 현재 ${Number(shownCount || 0).toLocaleString()}`
? ` / 현재 ${Number(shownCount || 0).toLocaleString()}${unit}`
: '';
const readyYears = Array.isArray(stats.ready_years) ? stats.ready_years : [];
const pendingYears = Array.isArray(stats.pending_years) ? stats.pending_years : [];
@@ -2616,7 +2793,21 @@
},
signal: options.signal || controller?.signal,
});
const payload = await response.json();
const responseText = await response.text();
let payload = {};
if (responseText) {
try {
payload = JSON.parse(responseText);
} catch (_error) {
const fallbackMessage = responseText.length > 240
? `${responseText.slice(0, 240)}...`
: responseText;
if (!response.ok) {
throw new Error(fallbackMessage || `HTTP ${response.status}`);
}
throw new Error('서버 응답을 JSON으로 해석할 수 없습니다.');
}
}
if (!response.ok) {
throw new Error(payload.error || '조회 중 오류가 발생했습니다.');
}
@@ -2755,6 +2946,16 @@
};
const applySummaryPayload = (payload) => {
const finalBasisMeta = document.getElementById('wehagoFinalBasisMeta');
if (finalBasisMeta) {
const summary = payload?.wehago_final_status_summary || {};
const diagnostics = summary.diagnostics || {};
const statusTotal = Number(diagnostics.wehago_status_total ?? summary.classified_total ?? 0);
const rawTotal = Number(diagnostics.wehago_voucher_total ?? summary.raw_total ?? 0);
const difference = Number(diagnostics.wehago_status_difference ?? summary.difference ?? 0);
const source = String(summary.source || 'projection');
finalBasisMeta.textContent = `WEHAGO 전표 단위 기준: ${statusTotal.toLocaleString()}건 / 원천 ${rawTotal.toLocaleString()}건 / 차이 ${difference.toLocaleString()}건 / ${source}`;
}
const sections = Array.isArray(payload?.metric_sections) ? payload.metric_sections : [];
sections.forEach((section) => {
const card = document.querySelector(`.status-card[data-status="${escapeSelectorValue(section.key || '')}"]`);
@@ -2763,6 +2964,13 @@
const isPendingCount = Boolean(payload?.pending) && countValue === 0;
if (card) {
card.dataset.countPending = isPendingCount ? 'true' : 'false';
card.dataset.count = String(countValue || 0);
card.dataset.countUnit = String(section.count_unit || card.dataset.countUnit || '전표');
card.dataset.projectionSignature = String(section.projection_signature || '');
const unitNode = card.querySelector('.status-unit');
if (unitNode) {
unitNode.textContent = card.dataset.countUnit;
}
}
if (valueNode) {
valueNode.textContent = isPendingCount ? '갱신 중' : countValue.toLocaleString();
@@ -2779,7 +2987,8 @@
if (undoLastActionBtn) {
undoLastActionBtn.disabled = !(lastAction && lastAction.id);
}
const pending = Boolean(payload?.pending);
const jobActive = ['queued', 'running'].includes(String(payload?.latest_job?.status || payload?.system_job?.status || ''));
const pending = Boolean(payload?.pending) && jobActive;
summaryRefreshState.pending = pending;
if (summaryRefreshState.timerId) {
clearTimeout(summaryRefreshState.timerId);
@@ -3843,6 +4052,11 @@
clearTimeout(detailRetryTimers.get(statusKey));
detailRetryTimers.delete(statusKey);
}
if (!append) {
detailRequestControllers.forEach((controller, key) => {
if (key !== statusKey) controller.abort();
});
}
const current = detailState.get(statusKey) || { offset: 0, totalCount: 0, loading: false };
if (current.loading) {
const activeController = detailRequestControllers.get(statusKey);
@@ -3863,7 +4077,12 @@
setMeta(statusKey, current.totalCount || 0, 0, '조회 중입니다...');
}
const requestController = new AbortController();
const requestTimeoutId = window.setTimeout(() => requestController.abort(), 20000);
let requestAbortReason = '';
const detailRequestTimeoutMs = 30000;
const requestTimeoutId = window.setTimeout(() => {
requestAbortReason = 'timeout';
requestController.abort();
}, detailRequestTimeoutMs);
detailRequestControllers.set(statusKey, requestController);
try {
const pageLimit = usesCursorPaging ? 24 : 60;
@@ -3875,6 +4094,12 @@
{ signal: requestController.signal, timeoutMs: 20000 },
);
if (detailRequestControllers.get(statusKey) !== requestController) return;
const card = document.querySelector(`.status-card[data-status="${statusKey}"]`);
const cardSignature = String(card?.dataset.projectionSignature || '').trim();
const payloadSignature = String(payload?.projection_signature || '').trim();
if (!append && cardSignature && payloadSignature && cardSignature !== payloadSignature) {
card.dataset.projectionSignature = payloadSignature;
}
renderTable(wrap, payload, ['voucher_no'], append, statusKey);
const shownCount = append ? nextOffset + payload.shown_count : payload.shown_count;
detailState.set(statusKey, {
@@ -3900,8 +4125,11 @@
if (error.name === 'AbortError') {
if (detailRequestControllers.get(statusKey) === requestController) {
if (!append) {
wrap.innerHTML = '<div class="table-placeholder">조회 시간이 길어져 중단했습니다. 조건을 줄여 다시 조회해주세요.</div>';
setMeta(statusKey, current.totalCount || 0, 0, '조회 시간이 길어져 중단했습니다.');
const message = requestAbortReason === 'timeout'
? '상세 조회 캐시를 준비하는 데 시간이 걸리고 있습니다. 조회 캐시 재생성 또는 잠시 후 다시 확인해주세요.'
: '이전 조회가 취소되었습니다.';
wrap.innerHTML = `<div class="table-placeholder">${escapeHtml(message)}</div>`;
setMeta(statusKey, current.totalCount || 0, 0, message);
}
detailState.set(statusKey, { ...current, loading: false, form });
setLoadMoreState(statusKey, current.hasMore, false);