+
@@ -960,10 +1242,88 @@
return classes.join(' ');
};
+ const getColumnClass = (field) => `col-${String(field || '').replace(/[^a-zA-Z0-9_]/g, '_')}`;
+
+ const renderVoucherGroups = (container, payload, append = false, statusKey = '') => {
+ if (!container) return;
+ const groups = payload.groups || [];
+ if (!groups.length && !append) {
+ container.innerHTML = '
조건에 맞는 항목이 없습니다.
';
+ return;
+ }
+ const lineColumns = [
+ ['fiscal_year', '연도'],
+ ['ledger_date', 'WEHAGO 일자'],
+ ['voucher_no', '전표번호'],
+ ['draft_no', '가전표번호'],
+ ['ledger_account_name', 'WEHAGO 계정'],
+ ['voucher_account_name', 'ERP 계정'],
+ ['ledger_vendor', 'WEHAGO 거래처'],
+ ['voucher_vendor', 'ERP 거래처'],
+ ['ledger_debit', 'WEHAGO 차변'],
+ ['ledger_credit', 'WEHAGO 대변'],
+ ['voucher_debit', 'ERP 차변'],
+ ['voucher_credit', 'ERP 대변'],
+ ['ledger_desc', 'WEHAGO 적요'],
+ ['voucher_desc', 'ERP 적요'],
+ ];
+ const formatValue = (field, raw) => {
+ if (field === 'fiscal_year') return String(raw ?? '');
+ if (typeof raw === 'number') {
+ return Number.isInteger(raw)
+ ? raw.toLocaleString()
+ : raw.toLocaleString(undefined, { maximumFractionDigits: 2 });
+ }
+ return String(raw ?? '');
+ };
+ const lineHead = lineColumns.map(([field, label]) => `
${escapeHtml(label)} | `).join('');
+ const lineBody = groups.map((group, groupIndex) => {
+ const rows = Array.isArray(group.rows) ? group.rows : [];
+ return rows.map((row, rowIndex) => {
+ const rowClasses = [];
+ if (rowIndex === 0) {
+ rowClasses.push('voucher-table-group-start');
+ }
+ if (row.matched_case === 'boundary_excluded' || row.boundary_excluded === '1') {
+ rowClasses.push('boundary-excluded-row');
+ } else if (row.matched_case === 'bank_payable' || row.review_reason === 'BANK_PAYABLE_MATCH') {
+ rowClasses.push('bank-payable-case');
+ }
+ const cells = lineColumns.map(([field]) => {
+ const display = formatValue(field, row[field]);
+ return `
${escapeHtml(display)} | `;
+ }).join('');
+ return `
${cells}
`;
+ }).join('');
+ }).join('');
+ const tableHtml = `
+
+ ${lineHead}
+ ${lineBody}
+
+ `;
+ if (!append || !container.querySelector('.voucher-group-lines')) {
+ container.innerHTML = `
${tableHtml}
`;
+ } else {
+ const tbody = container.querySelector('.voucher-group-lines tbody');
+ const temp = document.createElement('tbody');
+ temp.innerHTML = lineBody;
+ if (tbody) {
+ tbody.insertAdjacentHTML('beforeend', temp.innerHTML);
+ } else {
+ container.innerHTML = `
${tableHtml}
`;
+ }
+ }
+ };
+
const renderTable = (container, payload, monoFields = [], append = false, statusKey = '') => {
if (!container) {
return;
}
+ if (statusKey === 'voucher_matched' || statusKey === 'erp_voucher_matched' || statusKey === 'voucher_unmatched' || statusKey === 'voucher_recheck') {
+ renderVoucherGroups(container, payload, append, statusKey);
+ return;
+ }
const columns = payload.columns || [];
const rows = payload.rows || [];
if (!rows.length && !append) {
@@ -974,7 +1334,7 @@
const includePairCheckbox = statusKey === 'ledger_only' || statusKey === 'voucher_only';
const head = [
(includeReviewCheckbox || includePairCheckbox) ? '
| ' : '',
- ...columns.map(([, label]) => `
${escapeHtml(label)} | `),
+ ...columns.map(([field, label]) => `
${escapeHtml(label)} | `),
].join('');
const body = rows.map((row) => {
const reviewKey = row.review_key || '';
@@ -1006,15 +1366,23 @@
display = String(raw);
}
const className = getCellClass(field, monoFields);
- return `
${escapeHtml(display)} | `;
+ return `
${escapeHtml(display)} | `;
+ }).join('');
+ const checkboxCell = includeReviewCheckbox
+ ? `
| `
+ : includePairCheckbox
+ ? `
| `
+ : '';
+ const rowClasses = [];
+ if (row.matched_case === 'bank_payable' || row.review_reason === 'BANK_PAYABLE_MATCH') {
+ rowClasses.push('bank-payable-case');
+ }
+ if (row.matched_case === 'boundary_excluded' || row.boundary_excluded === '1') {
+ rowClasses.push('boundary-excluded-row');
+ }
+ const rowClass = rowClasses.length ? ` class="${rowClasses.join(' ')}"` : '';
+ return `
${checkboxCell}${cells}
`;
}).join('');
- const checkboxCell = includeReviewCheckbox
- ? `
| `
- : includePairCheckbox
- ? `
| `
- : '';
- return `
${checkboxCell}${cells}
`;
- }).join('');
if (!append || !container.querySelector('table')) {
container.innerHTML = `
`;
} else {
@@ -1043,7 +1411,7 @@
}
};
- const setMeta = (key, totalCount, shownCount, notice = '') => {
+ const setMeta = (key, totalCount, shownCount, notice = '', stats = {}) => {
const target = document.querySelector(`[data-result-meta="${key}"]`);
if (target) {
const countText = `조회 결과 ${Number(totalCount || 0).toLocaleString()}건`;
@@ -1051,7 +1419,15 @@
? ` / 현재 ${Number(shownCount || 0).toLocaleString()}건`
: '';
const noticeText = notice ? ` / ${notice}` : '';
- target.textContent = `${countText}${shownText}${noticeText}`;
+ const bankPayableCount = Number(stats.bank_payable_case_count || 0);
+ const bankPayableText = key === 'matched' && bankPayableCount
+ ? `
`
+ : '';
+ const boundaryCount = Number(stats.boundary_excluded_count || 0);
+ const boundaryText = key === 'ledger_only' && boundaryCount
+ ? `
`
+ : '';
+ target.innerHTML = `${escapeHtml(`${countText}${shownText}${noticeText}`)}${bankPayableText}${boundaryText}`;
}
};
@@ -1064,6 +1440,43 @@
return payload;
};
+ const applySummaryPayload = (payload) => {
+ const sections = Array.isArray(payload?.metric_sections) ? payload.metric_sections : [];
+ sections.forEach((section) => {
+ const card = document.querySelector(`.status-card[data-status="${escapeSelectorValue(section.key || '')}"]`);
+ const valueNode = card?.querySelector('.status-value');
+ if (valueNode) {
+ valueNode.textContent = Number(section.count || 0).toLocaleString();
+ }
+ });
+ const lastAction = payload?.last_action;
+ if (lastActionMeta) {
+ if (lastAction && lastAction.id) {
+ lastActionMeta.textContent = `최근 작업: ${lastAction.action_type} / ${Number(lastAction.count || 0).toLocaleString()}건`;
+ } else {
+ lastActionMeta.textContent = '최근 작업: 없음';
+ }
+ }
+ if (undoLastActionBtn) {
+ undoLastActionBtn.disabled = !(lastAction && lastAction.id);
+ }
+ };
+
+ const loadDashboardSummary = async (attempt = 0) => {
+ const params = new URLSearchParams();
+ if (startYearValue) params.set('start_year', startYearValue);
+ if (endYearValue) params.set('end_year', endYearValue);
+ try {
+ const payload = await fetchJson(`/wehago-compare/api/summary?${params.toString()}`);
+ applySummaryPayload(payload);
+ if (payload?.pending && attempt < 6) {
+ window.setTimeout(() => loadDashboardSummary(attempt + 1), 1200);
+ }
+ } catch (_error) {
+ // Keep the page usable even if the summary loads later or fails once.
+ }
+ };
+
const buildQuery = (form, extra = {}) => {
const params = new URLSearchParams();
if (startYearValue) params.set('start_year', startYearValue);
@@ -1113,6 +1526,11 @@
selectedPairKey: '',
};
+ const syncRecommendationPanelState = () => {
+ if (!page || !recommendPanel) return;
+ page.classList.toggle('has-recommend-panel-open', recommendPanel.classList.contains('active'));
+ };
+
const refreshLastActionMeta = async () => {
if (!lastActionMeta || !undoLastActionBtn) return;
try {
@@ -1162,7 +1580,7 @@
const initStatusSuggest = (form, type) => {
if (!form) return;
const status = form.dataset.status || '';
- if (!['matched', 'amount_mismatch', 'ledger_only', 'voucher_only'].includes(status)) return;
+ if (!['matched', 'amount_mismatch', 'ledger_only', 'voucher_only', 'voucher_matched', 'erp_voucher_matched', 'voucher_unmatched', 'voucher_recheck'].includes(status)) return;
const inputName = type === 'account'
? 'account_keyword'
: type === 'wehago_account'
@@ -1359,6 +1777,8 @@
vendor_keyword: '거래처',
amount_keyword: '금액',
desc_keyword: '적요',
+ review_reason: '검증근거',
+ boundary_excluded: '연초/연말 대체·이월',
};
const getFormFieldValues = (form) => {
@@ -1373,6 +1793,14 @@
return values;
};
+ const syncFormWithActiveFilters = (statusKey, form) => {
+ if (!form) return;
+ const active = cumulativeFiltersByStatus.get(statusKey) || {};
+ form.querySelectorAll('input[name]').forEach((input) => {
+ input.value = active[input.name] || '';
+ });
+ };
+
const updateCumulativeFilter = (statusKey, fieldName, value, checked = true) => {
const current = { ...(cumulativeFiltersByStatus.get(statusKey) || {}) };
if (checked && String(value || '').trim()) {
@@ -1457,6 +1885,7 @@
계정유사도 |
거래처유사도 |
신뢰도 |
+
검토 |
연도 |
전표번호 |
일자 |
@@ -1489,6 +1918,7 @@
${escapeHtml((Number(pair.account_similarity || 0) * 100).toFixed(1) + '%')} |
${escapeHtml((Number(pair.vendor_similarity || 0) * 100).toFixed(1) + '%')} |
${escapeHtml(pair.confidence_level)} |
+
${escapeHtml(pair.substitution_hint || pair.reason || '')} |
${escapeHtml(ledger.fiscal_year || voucher.fiscal_year || '')} |
${escapeHtml(ledger.voucher_no || '')} |
${escapeHtml(ledger.ledger_date || '')} |
@@ -1566,12 +1996,14 @@
if (!recommendPanel) return;
setRecommendMode(mode);
recommendPanel.classList.add('active');
+ syncRecommendationPanelState();
recommendPanel.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const closeRecommendationPanel = () => {
if (!recommendPanel) return;
recommendPanel.classList.remove('active');
+ syncRecommendationPanelState();
};
const getSelectedSourceForIndividual = (sourceStatus) => {
@@ -1699,7 +2131,7 @@
};
const buildStatusQuery = (form, statusKey, extra = {}) => {
- const selected = form ? (cumulativeFiltersByStatus.get(statusKey) || {}) : {};
+ const selected = cumulativeFiltersByStatus.get(statusKey) || {};
const mapped = new URLSearchParams();
if (startYearValue) mapped.set('start_year', startYearValue);
if (endYearValue) mapped.set('end_year', endYearValue);
@@ -1712,9 +2144,11 @@
const account = String(selected.account_keyword || '').trim();
const vendor = String(selected.vendor_keyword || '').trim();
const amount = String(selected.amount_keyword || '').trim();
+ const boundaryExcluded = String(selected.boundary_excluded || '').trim();
if (account) mapped.set('wehago_account', account);
if (vendor) mapped.set('wehago_vendor', vendor);
if (amount) mapped.set('wehago_amount', amount);
+ if (boundaryExcluded) mapped.set('boundary_excluded', boundaryExcluded);
} else if (statusKey === 'voucher_only') {
const account = String(selected.account_keyword || '').trim();
const vendor = String(selected.vendor_keyword || '').trim();
@@ -1722,6 +2156,22 @@
if (account) mapped.set('erp_account', account);
if (vendor) mapped.set('erp_vendor', vendor);
if (amount) mapped.set('erp_amount', amount);
+ } else if (statusKey === 'voucher_matched' || statusKey === 'erp_voucher_matched' || statusKey === 'voucher_unmatched' || statusKey === 'voucher_recheck') {
+ const account = String(selected.account_keyword || '').trim();
+ const vendor = String(selected.vendor_keyword || '').trim();
+ const amount = String(selected.amount_keyword || '').trim();
+ if (account) {
+ mapped.set('wehago_account', account);
+ mapped.set('erp_account', account);
+ }
+ if (vendor) {
+ mapped.set('wehago_vendor', vendor);
+ mapped.set('erp_vendor', vendor);
+ }
+ if (amount) {
+ mapped.set('wehago_amount', amount);
+ mapped.set('erp_amount', amount);
+ }
} else {
for (const [key, value] of Object.entries(selected)) {
if (String(value).trim()) {
@@ -1744,8 +2194,7 @@
if (current.loading) return;
if (!append && form) {
const selected = getFormFieldValues(form);
- const merged = { ...(cumulativeFiltersByStatus.get(statusKey) || {}), ...selected };
- cumulativeFiltersByStatus.set(statusKey, merged);
+ cumulativeFiltersByStatus.set(statusKey, selected);
renderActiveFilterCards(statusKey);
}
const nextOffset = append ? (current.nextOffset || 0) : 0;
@@ -1767,7 +2216,7 @@
form,
shownCount,
});
- setMeta(statusKey, payload.total_count, shownCount, payload.notice);
+ setMeta(statusKey, payload.total_count, shownCount, payload.notice, payload);
setLoadMoreState(statusKey, payload.has_more, false);
} catch (error) {
if (!append) {
@@ -1796,6 +2245,7 @@
panel.classList.add('active');
card.setAttribute('aria-expanded', 'true');
const form = panel.querySelector('form[data-filter-kind="status"]');
+ syncFormWithActiveFilters(statusKey, form);
loadStatusRows(statusKey, form, false);
}
});
@@ -1818,6 +2268,23 @@
});
document.addEventListener('click', (event) => {
+ const caseButton = event.target.closest('[data-case-filter="bank_payable"], [data-case-filter="boundary_excluded"]');
+ if (caseButton) {
+ const caseFilter = caseButton.dataset.caseFilter || '';
+ const statusKey = caseFilter === 'boundary_excluded' ? 'ledger_only' : 'matched';
+ const current = { ...(cumulativeFiltersByStatus.get(statusKey) || {}) };
+ if (caseFilter === 'boundary_excluded') {
+ current.boundary_excluded = '1';
+ } else {
+ current.review_reason = 'BANK_PAYABLE_MATCH';
+ }
+ cumulativeFiltersByStatus.set(statusKey, current);
+ renderActiveFilterCards(statusKey);
+ const form = document.querySelector(`form[data-filter-kind="status"][data-status="${statusKey}"]`);
+ syncFormWithActiveFilters(statusKey, form);
+ loadStatusRows(statusKey, null, false);
+ return;
+ }
const removeButton = event.target.closest('[data-remove-active-filter]');
if (!removeButton) return;
const statusKey = removeButton.dataset.removeActiveFilter || '';
@@ -2105,6 +2572,7 @@
const startYear = document.getElementById('start_year');
const endYear = document.getElementById('end_year');
+ syncRecommendationPanelState();
const syncYearOptions = () => {
if (!startYear || !endYear) {
return;
@@ -2126,6 +2594,9 @@
endYear.addEventListener('change', syncYearOptions);
syncYearOptions();
}
+ window.setTimeout(() => {
+ loadDashboardSummary();
+ }, 400);
refreshLastActionMeta();
})();
diff --git a/wehago_compare.py b/wehago_compare.py
index 4c5fb4b..76780d3 100644
--- a/wehago_compare.py
+++ b/wehago_compare.py
@@ -7,6 +7,7 @@ import threading
import time
from dataclasses import dataclass
from datetime import date, datetime
+from difflib import SequenceMatcher
from functools import lru_cache
from pathlib import Path
from typing import Any, Iterable
@@ -54,11 +55,17 @@ LEDGER_HEADERS = [
"계정명",
]
+CONSOLIDATED_LEDGER_PREFIX_HEADERS = ["계정코드", "계정명", "원본파일", "원본시트", "원본행번호"]
+
STATUS_META = [
("matched", "Matched", "WEHAGO 기준으로 정상 매칭된 항목"),
("ledger_only", "Unmatched", "WEHAGO에는 있으나 ERP와 연결되지 않은 항목"),
("voucher_only", "ERP Unmatched", "ERP 기준으로 매치되지 않은 항목"),
("amount_mismatch", "Recheck", "전표번호는 같지만 금액이 달라 다시 확인이 필요한 항목"),
+ ("voucher_matched", "WEHAGO", "WEHAGO 전표 기준으로 매칭된 결과"),
+ ("erp_voucher_matched", "Voucher", "Hanmac ERP 전표 기준으로 매칭된 결과"),
+ ("voucher_unmatched", "WEHAGO Unmatched", "WEHAGO 전표 기준으로 매칭되지 않은 결과"),
+ ("voucher_recheck", "WEHAGO Recheck", "WEHAGO 전표 기준으로 재검토가 필요한 결과"),
]
DETAIL_COLUMN_MAP = {
@@ -101,6 +108,7 @@ DETAIL_COLUMN_MAP = {
("ledger_credit", "대변"),
("voucher_debit", "ERP 차변"),
("voucher_credit", "ERP 대변"),
+ ("substitution_hint", "대체 검토"),
("ledger_desc", "WEHAGO 적요"),
("voucher_desc", "ERP 적요"),
],
@@ -114,6 +122,80 @@ DETAIL_COLUMN_MAP = {
("voucher_credit", "대변"),
("voucher_desc", "적요"),
],
+ "voucher_matched": [
+ ("fiscal_year", "연도"),
+ ("ledger_date", "WEHAGO 일자"),
+ ("proof_date", "ERP 증빙일자"),
+ ("voucher_no", "전표번호"),
+ ("draft_no", "가전표번호"),
+ ("ledger_row_count", "WEHAGO 행수"),
+ ("voucher_row_count", "ERP 행수"),
+ ("ledger_debit", "WEHAGO 차변"),
+ ("ledger_credit", "WEHAGO 대변"),
+ ("voucher_debit", "ERP 차변"),
+ ("voucher_credit", "ERP 대변"),
+ ("ledger_accounts", "WEHAGO 계정"),
+ ("voucher_accounts", "ERP 계정"),
+ ("ledger_vendors", "WEHAGO 거래처"),
+ ("voucher_vendors", "ERP 거래처"),
+ ("review_reason", "검증근거"),
+ ],
+ "voucher_unmatched": [
+ ("fiscal_year", "연도"),
+ ("status_label", "구분"),
+ ("ledger_date", "WEHAGO 일자"),
+ ("proof_date", "ERP 증빙일자"),
+ ("voucher_no", "전표번호"),
+ ("draft_no", "가전표번호"),
+ ("ledger_row_count", "WEHAGO 행수"),
+ ("voucher_row_count", "ERP 행수"),
+ ("ledger_debit", "WEHAGO 차변"),
+ ("ledger_credit", "WEHAGO 대변"),
+ ("voucher_debit", "ERP 차변"),
+ ("voucher_credit", "ERP 대변"),
+ ("ledger_accounts", "WEHAGO 계정"),
+ ("voucher_accounts", "ERP 계정"),
+ ("ledger_vendors", "WEHAGO 거래처"),
+ ("voucher_vendors", "ERP 거래처"),
+ ("review_reason", "검토사유"),
+ ],
+ "voucher_recheck": [
+ ("fiscal_year", "연도"),
+ ("status_label", "구분"),
+ ("ledger_date", "WEHAGO 일자"),
+ ("proof_date", "ERP 증빙일자"),
+ ("voucher_no", "전표번호"),
+ ("draft_no", "가전표번호"),
+ ("ledger_row_count", "WEHAGO 행수"),
+ ("voucher_row_count", "ERP 행수"),
+ ("ledger_debit", "WEHAGO 차변"),
+ ("ledger_credit", "WEHAGO 대변"),
+ ("voucher_debit", "ERP 차변"),
+ ("voucher_credit", "ERP 대변"),
+ ("ledger_accounts", "WEHAGO 계정"),
+ ("voucher_accounts", "ERP 계정"),
+ ("ledger_vendors", "WEHAGO 거래처"),
+ ("voucher_vendors", "ERP 거래처"),
+ ("review_reason", "검토사유"),
+ ],
+ "erp_voucher_matched": [
+ ("fiscal_year", "연도"),
+ ("ledger_date", "WEHAGO 일자"),
+ ("proof_date", "ERP 증빙일자"),
+ ("voucher_no", "전표번호"),
+ ("draft_no", "가전표번호"),
+ ("ledger_row_count", "WEHAGO 행수"),
+ ("voucher_row_count", "ERP 행수"),
+ ("ledger_debit", "WEHAGO 차변"),
+ ("ledger_credit", "WEHAGO 대변"),
+ ("voucher_debit", "ERP 차변"),
+ ("voucher_credit", "ERP 대변"),
+ ("ledger_accounts", "WEHAGO 계정"),
+ ("voucher_accounts", "ERP 계정"),
+ ("ledger_vendors", "WEHAGO 거래처"),
+ ("voucher_vendors", "ERP 거래처"),
+ ("review_reason", "검증근거"),
+ ],
}
WEHAGO_COLUMNS = [
@@ -146,10 +228,47 @@ _SUGGEST_CACHE: dict[str, dict[str, Any]] = {}
_SUGGEST_CACHE_TTL_SEC = 20
_STATUS_ROWS_CACHE: dict[str, dict[str, Any]] = {}
_STATUS_ROWS_CACHE_TTL_SEC = 300
+_VOUCHER_SECTION_CACHE: dict[str, dict[str, Any]] = {}
+_VOUCHER_SECTION_CACHE_TTL_SEC = 300
_PAIR_RECOMMEND_CACHE: dict[str, dict[str, Any]] = {}
_PAIR_RECOMMEND_CACHE_TTL_SEC = 120
_STATUS_CACHE_WARMING: set[str] = set()
_STATUS_CACHE_WARMING_LOCK = threading.Lock()
+_METRIC_COUNTS_WARMING: set[str] = set()
+_METRIC_COUNTS_WARMING_LOCK = threading.Lock()
+ENABLE_GENERATED_RECHECK_CANDIDATES = False
+RECHECK_RESOLUTION_POLICY_VERSION = "recheck-flex-strong-conditions-v13-cross-year-and-match-validation"
+METRIC_COUNT_CACHE_VERSION = "voucher-summary-v5"
+_PAIR_RECOMMEND_PERSIST_TTL_SEC = 1800
+_PAIR_RECOMMEND_JOB_EVENT = threading.Event()
+_PAIR_RECOMMEND_WORKER_LOCK = threading.Lock()
+_PAIR_RECOMMEND_WORKER_STARTED = False
+PAIR_RECOMMEND_POLICY_VERSION = "date-window-8m-no-month-token-gate-v1"
+PAIR_RECOMMEND_DATE_WINDOW_MONTHS = 8
+
+
+@lru_cache(maxsize=8)
+def _file_content_signature(path_text: str, mtime_ns: int, size: int) -> str:
+ path = Path(path_text)
+ try:
+ return hashlib.sha1(path.read_bytes()).hexdigest()
+ except Exception:
+ return f"{mtime_ns}:{size}"
+
+
+def _current_logic_signature() -> str:
+ module_path = Path(__file__).resolve()
+ stat = module_path.stat()
+ file_sig = _file_content_signature(str(module_path), int(stat.st_mtime_ns), int(stat.st_size))
+ return "|".join(
+ [
+ METRIC_COUNT_CACHE_VERSION,
+ RECHECK_RESOLUTION_POLICY_VERSION,
+ PAIR_RECOMMEND_POLICY_VERSION,
+ "generated-recheck-on" if ENABLE_GENERATED_RECHECK_CANDIDATES else "generated-recheck-off",
+ file_sig,
+ ]
+ )
def _fast_metric_counts_from_result_files(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]:
@@ -225,6 +344,362 @@ def _fast_metric_counts_from_result_files(conn: Any, start_year: int | None, end
return counts
+def _empty_metric_counts() -> dict[str, int]:
+ return {status_key: 0 for status_key, _, _ in STATUS_META}
+
+
+def _metric_counts_signature(conn: Any, start_year: int | None, end_year: int | None) -> str:
+ return "|".join(
+ [
+ _current_logic_signature(),
+ _build_bundle_signature(start_year, end_year),
+ _build_db_state_signature(conn, start_year, end_year),
+ ]
+ )
+
+
+def _load_metric_counts_cache(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int] | None:
+ if start_year is None or end_year is None:
+ return _empty_metric_counts()
+ signature = _metric_counts_signature(conn, start_year, end_year)
+ cached = conn.execute(
+ text(
+ """
+ SELECT counts_json
+ FROM wehago_metric_count_cache
+ WHERE start_year = :start_year
+ AND end_year = :end_year
+ AND signature = :signature
+ LIMIT 1
+ """
+ ),
+ {"start_year": start_year, "end_year": end_year, "signature": signature},
+ ).first()
+ if not cached or not cached[0]:
+ return None
+ try:
+ payload = json.loads(str(cached[0]))
+ if isinstance(payload, dict):
+ return {status_key: int(payload.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META}
+ except Exception:
+ return None
+ return None
+
+
+def _load_latest_metric_counts_cache_any_signature(
+ conn: Any,
+ start_year: int | None,
+ end_year: int | None,
+) -> dict[str, int] | None:
+ if start_year is None or end_year is None:
+ return _empty_metric_counts()
+ cached = conn.execute(
+ text(
+ """
+ SELECT counts_json
+ FROM wehago_metric_count_cache
+ WHERE start_year = :start_year
+ AND end_year = :end_year
+ ORDER BY updated_at DESC, created_at DESC
+ LIMIT 1
+ """
+ ),
+ {"start_year": start_year, "end_year": end_year},
+ ).first()
+ if not cached or not cached[0]:
+ return None
+ try:
+ payload = json.loads(str(cached[0]))
+ if isinstance(payload, dict):
+ return {status_key: int(payload.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META}
+ except Exception:
+ return None
+ return None
+
+
+def _store_metric_counts_cache(
+ conn: Any,
+ start_year: int | None,
+ end_year: int | None,
+ counts: dict[str, int],
+) -> None:
+ if start_year is None or end_year is None:
+ return
+ signature = _metric_counts_signature(conn, start_year, end_year)
+ conn.execute(
+ text(
+ """
+ INSERT INTO wehago_metric_count_cache (
+ start_year, end_year, signature, counts_json, created_at, updated_at
+ ) VALUES (
+ :start_year, :end_year, :signature, :counts_json, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+ )
+ ON CONFLICT(start_year, end_year, signature) DO UPDATE SET
+ counts_json = excluded.counts_json,
+ updated_at = CURRENT_TIMESTAMP
+ """
+ ),
+ {
+ "start_year": start_year,
+ "end_year": end_year,
+ "signature": signature,
+ "counts_json": json.dumps(counts, ensure_ascii=False),
+ },
+ )
+
+
+def _fallback_metric_counts_from_db(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]:
+ year_sql = build_year_filter_sql("c.fiscal_year")
+ return {
+ "matched": int(
+ conn.execute(
+ text(
+ """
+ SELECT COUNT(*)
+ FROM wehago_ledger_rows l
+ JOIN wehago_comparison_results c
+ ON c.fiscal_year = l.fiscal_year AND c.voucher_no = l.compare_voucher_no
+ WHERE c.status = 'matched'
+ AND """
+ + year_sql
+ ),
+ {"start_year": start_year, "end_year": end_year},
+ ).scalar_one()
+ or 0
+ ),
+ "ledger_only": int(
+ conn.execute(
+ text(
+ """
+ SELECT COUNT(*)
+ FROM wehago_ledger_rows l
+ JOIN wehago_comparison_results c
+ ON c.fiscal_year = l.fiscal_year AND c.voucher_no = l.compare_voucher_no
+ WHERE c.status = 'ledger_only'
+ AND """
+ + year_sql
+ ),
+ {"start_year": start_year, "end_year": end_year},
+ ).scalar_one()
+ or 0
+ ),
+ "amount_mismatch": int(
+ conn.execute(
+ text(
+ """
+ SELECT COUNT(*)
+ FROM wehago_ledger_rows l
+ JOIN wehago_comparison_results c
+ ON c.fiscal_year = l.fiscal_year AND c.voucher_no = l.compare_voucher_no
+ WHERE c.status = 'amount_mismatch'
+ AND """
+ + year_sql
+ ),
+ {"start_year": start_year, "end_year": end_year},
+ ).scalar_one()
+ or 0
+ ),
+ "voucher_only": int(
+ conn.execute(
+ text(
+ """
+ SELECT COUNT(*)
+ FROM wehago_voucher_rows v
+ JOIN wehago_comparison_results c
+ ON c.fiscal_year = v.fiscal_year AND c.voucher_no = v.compare_voucher_no
+ WHERE c.status = 'voucher_only'
+ AND """
+ + year_sql
+ ),
+ {"start_year": start_year, "end_year": end_year},
+ ).scalar_one()
+ or 0
+ ),
+ }
+
+
+def _voucher_metric_counts_from_db(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]:
+ year_sql = build_year_filter_sql("fiscal_year")
+ matched_count = int(
+ conn.execute(
+ text(
+ """
+ SELECT COUNT(*)
+ FROM wehago_comparison_results
+ WHERE status = 'matched'
+ AND """
+ + year_sql
+ ),
+ {"start_year": start_year, "end_year": end_year},
+ ).scalar_one()
+ or 0
+ )
+ unmatched_count = int(
+ conn.execute(
+ text(
+ """
+ SELECT COUNT(*)
+ FROM wehago_comparison_results
+ WHERE status = 'ledger_only'
+ AND """
+ + year_sql
+ ),
+ {"start_year": start_year, "end_year": end_year},
+ ).scalar_one()
+ or 0
+ )
+ recheck_count = int(
+ conn.execute(
+ text(
+ """
+ SELECT COUNT(*)
+ FROM wehago_comparison_results
+ WHERE status = 'amount_mismatch'
+ AND """
+ + year_sql
+ ),
+ {"start_year": start_year, "end_year": end_year},
+ ).scalar_one()
+ or 0
+ )
+ return {
+ "voucher_matched": matched_count,
+ "erp_voucher_matched": matched_count,
+ "voucher_unmatched": unmatched_count,
+ "voucher_recheck": recheck_count,
+ }
+
+
+def _resolved_metric_counts(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]:
+ counts = _empty_metric_counts()
+ if start_year is None or end_year is None:
+ return counts
+ if start_year != end_year:
+ rows_by_status: dict[str, list[dict[str, Any]]] = {
+ "matched": [],
+ "ledger_only": [],
+ "amount_mismatch": [],
+ "voucher_only": [],
+ }
+ for year in range(start_year, end_year + 1):
+ sections = _get_or_create_year_resolved_sections(conn, year)
+ if not sections:
+ db_counts = _fallback_metric_counts_from_db(conn, year, year)
+ for status_key, value in db_counts.items():
+ counts[status_key] += value
+ continue
+ for status_key in rows_by_status:
+ rows_by_status[status_key].extend(sections.get(status_key, {}).get("rows", []))
+ section_payload = {
+ status_key: {
+ "rows": list(rows),
+ "count": len(rows),
+ "columns": DETAIL_COLUMN_MAP[status_key],
+ }
+ for status_key, rows in rows_by_status.items()
+ }
+ section_payload = _promote_cross_year_auto_matches(section_payload)
+ for status_key in counts:
+ counts[status_key] = len(section_payload.get(status_key, {}).get("rows", []))
+ voucher_sections = _build_voucher_sections_from_rows_by_status(
+ {
+ "matched": list(section_payload.get("matched", {}).get("rows", [])),
+ "ledger_only": list(section_payload.get("ledger_only", {}).get("rows", [])),
+ "amount_mismatch": list(section_payload.get("amount_mismatch", {}).get("rows", [])),
+ "voucher_only": list(section_payload.get("voucher_only", {}).get("rows", [])),
+ }
+ )
+ counts["voucher_matched"] = len(voucher_sections.get("voucher_matched", []))
+ counts["erp_voucher_matched"] = len(voucher_sections.get("erp_voucher_matched", []))
+ counts["voucher_unmatched"] = len(voucher_sections.get("voucher_unmatched", []))
+ counts["voucher_recheck"] = len(voucher_sections.get("voucher_recheck", []))
+ if not counts["voucher_matched"] and not counts["erp_voucher_matched"] and not counts["voucher_unmatched"] and not counts["voucher_recheck"] and any(counts.get(key) for key in ("matched", "ledger_only", "voucher_only", "amount_mismatch")):
+ counts.update(_voucher_metric_counts_from_db(conn, start_year, end_year))
+ return counts
+ rows_by_status: dict[str, list[dict[str, Any]]] = {
+ "matched": [],
+ "ledger_only": [],
+ "amount_mismatch": [],
+ "voucher_only": [],
+ }
+ for year in range(start_year, end_year + 1):
+ sections = _get_or_create_year_resolved_sections(conn, year)
+ if not sections:
+ db_counts = _fallback_metric_counts_from_db(conn, year, year)
+ for status_key, value in db_counts.items():
+ counts[status_key] += value
+ continue
+ for status_key in ("matched", "ledger_only", "amount_mismatch", "voucher_only"):
+ counts[status_key] += len(sections.get(status_key, {}).get("rows", []))
+ rows_by_status[status_key].extend(sections.get(status_key, {}).get("rows", []))
+ voucher_sections = _build_voucher_sections_from_rows_by_status(rows_by_status)
+ counts["voucher_matched"] = len(voucher_sections.get("voucher_matched", []))
+ counts["erp_voucher_matched"] = len(voucher_sections.get("erp_voucher_matched", []))
+ counts["voucher_unmatched"] = len(voucher_sections.get("voucher_unmatched", []))
+ counts["voucher_recheck"] = len(voucher_sections.get("voucher_recheck", []))
+ if not counts["voucher_matched"] and not counts["erp_voucher_matched"] and not counts["voucher_unmatched"] and not counts["voucher_recheck"] and any(counts.get(key) for key in ("matched", "ledger_only", "voucher_only", "amount_mismatch")):
+ counts.update(_voucher_metric_counts_from_db(conn, start_year, end_year))
+ return counts
+
+
+def get_dashboard_metric_counts(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]:
+ cached = _load_metric_counts_cache(conn, start_year, end_year)
+ if cached is not None:
+ return cached
+ counts = _resolved_metric_counts(conn, start_year, end_year)
+ _store_metric_counts_cache(conn, start_year, end_year, counts)
+ return counts
+
+
+def get_dashboard_metric_counts_nonblocking(
+ engine: Any,
+ start_year: int | None,
+ end_year: int | None,
+) -> tuple[dict[str, int], bool]:
+ init_wehago_compare_db(engine)
+ with engine.begin() as conn:
+ cached = _load_metric_counts_cache(conn, start_year, end_year)
+ if cached is not None:
+ return cached, False
+ fallback = _load_latest_metric_counts_cache_any_signature(conn, start_year, end_year)
+ return fallback or _empty_metric_counts(), fallback is None
+
+
+def _warm_metric_counts_worker(engine: Any, start_year: int, end_year: int, warm_key: str) -> None:
+ try:
+ init_wehago_compare_db(engine)
+ with engine.begin() as conn:
+ counts = _resolved_metric_counts(conn, start_year, end_year)
+ _store_metric_counts_cache(conn, start_year, end_year, counts)
+ finally:
+ with _METRIC_COUNTS_WARMING_LOCK:
+ _METRIC_COUNTS_WARMING.discard(warm_key)
+
+
+def warm_metric_counts_async(engine: Any, start_year: int | None, end_year: int | None) -> None:
+ if start_year is None or end_year is None:
+ return
+ if start_year > end_year:
+ start_year, end_year = end_year, start_year
+ init_wehago_compare_db(engine)
+ with engine.begin() as conn:
+ signature = _metric_counts_signature(conn, start_year, end_year)
+ if _load_metric_counts_cache(conn, start_year, end_year) is not None:
+ return
+ warm_key = f"{start_year}:{end_year}:{signature}"
+ with _METRIC_COUNTS_WARMING_LOCK:
+ if warm_key in _METRIC_COUNTS_WARMING:
+ return
+ _METRIC_COUNTS_WARMING.add(warm_key)
+ worker = threading.Thread(
+ target=_warm_metric_counts_worker,
+ args=(engine, start_year, end_year, warm_key),
+ daemon=True,
+ name=f"wehago-metric-counts-{start_year}-{end_year}",
+ )
+ worker.start()
+
+
@dataclass
class WehagoImportSummary:
scanned_files: int = 0
@@ -235,6 +710,26 @@ class WehagoImportSummary:
comparison_rows: int = 0
+@dataclass(slots=True)
+class MatchRowFeatures:
+ row: dict[str, Any]
+ row_key: str
+ account_code: str
+ account_name: str
+ vendor_name: str
+ desc_text: str
+ account_tokens: set[str]
+ vendor_tokens: set[str]
+ desc_tokens: set[str]
+ month_tokens: set[int]
+ date_value: date | None
+ debit_amount: float
+ credit_amount: float
+ match_amount: float
+ primary_side: str
+ positive_amounts: tuple[float, ...]
+
+
def clean(value: Any) -> str:
return "" if value is None else str(value).strip()
@@ -249,6 +744,61 @@ def normalize_voucher_no(value: Any) -> str:
return clean(value).replace(" ", "")
+def _format_compare_date_key(value: Any) -> str:
+ parsed = parse_excel_date(value)
+ if not parsed:
+ return ""
+ return parsed.replace("-", "")
+
+
+def normalize_compare_voucher_no(value: Any, date_value: Any = None) -> str:
+ voucher_no = normalize_voucher_no(value)
+ if not voucher_no:
+ return ""
+ full_match = re.match(r"^11-(\d{8})-(\d+)-\d+$", voucher_no)
+ if full_match:
+ return f"{full_match.group(1)}-{int(full_match.group(2)):05d}"
+ draft_match = re.match(r"^11-(\d{8})-[^-]+-(\d+)-\d+$", voucher_no)
+ if draft_match:
+ return f"{draft_match.group(1)}-{int(draft_match.group(2)):05d}"
+ if re.fullmatch(r"\d+", voucher_no):
+ date_key = _format_compare_date_key(date_value)
+ if date_key:
+ return f"{date_key}-{int(voucher_no):05d}"
+ return voucher_no
+
+
+def _voucher_no_year(value: Any) -> int | None:
+ voucher_no = normalize_voucher_no(value)
+ match = re.match(r"^11-((?:19|20)\d{2})\d{4}-", voucher_no)
+ if match:
+ return int(match.group(1))
+ return None
+
+
+def choose_effective_erp_voucher_no(confirmed_no: Any, draft_no: Any, year_hint: int | None = None) -> str:
+ confirmed = normalize_voucher_no(confirmed_no)
+ draft = normalize_voucher_no(draft_no)
+ if year_hint:
+ confirmed_year = _voucher_no_year(confirmed)
+ draft_year = _voucher_no_year(draft)
+ if confirmed and confirmed_year == year_hint:
+ return confirmed
+ if draft and draft_year == year_hint:
+ return draft
+ return confirmed or draft
+
+
+def infer_voucher_fiscal_year(
+ confirmed_no: Any,
+ draft_no: Any,
+ proof_date: Any,
+ year_hint: int | None,
+) -> int | None:
+ effective_no = choose_effective_erp_voucher_no(confirmed_no, draft_no, year_hint)
+ return _voucher_no_year(effective_no) or year_from_date_text(proof_date) or year_hint
+
+
def parse_amount(value: Any) -> float:
text_value = clean(value).replace(",", "")
if not text_value:
@@ -307,6 +857,12 @@ def detect_file_kind(path: Path) -> tuple[str | None, list[str], str]:
return "voucher", header, sheet.title
if header[: len(LEDGER_HEADERS)] == LEDGER_HEADERS:
return "ledger", header, sheet.title
+ if (
+ header[: len(CONSOLIDATED_LEDGER_PREFIX_HEADERS)] == CONSOLIDATED_LEDGER_PREFIX_HEADERS
+ and header[len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) : len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) + len(LEDGER_HEADERS)]
+ == LEDGER_HEADERS
+ ):
+ return "ledger", header, sheet.title
return None, header, sheet.title
@@ -358,7 +914,7 @@ def infer_year_hint(path: Path, file_kind: str, sample_rows: list[tuple[Any, ...
if year_value:
return year_value
for row in sample_rows[:50]:
- voucher_no = normalize_voucher_no(row[10] if len(row) > 10 else row[1] if len(row) > 1 else "")
+ voucher_no = normalize_compare_voucher_no(row[10] if len(row) > 10 else row[1] if len(row) > 1 else "")
match = re.search(r"-(\d{4})\d{4}-", voucher_no)
if match:
return int(match.group(1))
@@ -581,6 +1137,63 @@ def init_wehago_compare_db(engine: Any) -> None:
"""
)
)
+ conn.execute(
+ text(
+ """
+ CREATE TABLE IF NOT EXISTS wehago_pair_recommend_cache (
+ cache_key TEXT PRIMARY KEY,
+ start_year INTEGER,
+ end_year INTEGER,
+ ledger_voucher_no TEXT NOT NULL DEFAULT '',
+ ledger_review_reason TEXT NOT NULL DEFAULT '',
+ voucher_voucher_no TEXT NOT NULL DEFAULT '',
+ voucher_review_reason TEXT NOT NULL DEFAULT '',
+ row_limit INTEGER NOT NULL DEFAULT 300,
+ payload_json TEXT NOT NULL DEFAULT '',
+ pair_count INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_accessed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ """
+ )
+ )
+ conn.execute(
+ text(
+ """
+ CREATE TABLE IF NOT EXISTS wehago_metric_count_cache (
+ start_year INTEGER NOT NULL,
+ end_year INTEGER NOT NULL,
+ signature TEXT NOT NULL,
+ counts_json TEXT NOT NULL DEFAULT '{}',
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (start_year, end_year, signature)
+ )
+ """
+ )
+ )
+ conn.execute(
+ text(
+ """
+ CREATE TABLE IF NOT EXISTS wehago_background_jobs (
+ job_key TEXT PRIMARY KEY,
+ job_type TEXT NOT NULL,
+ payload_json TEXT NOT NULL DEFAULT '{}',
+ state TEXT NOT NULL DEFAULT 'queued',
+ error_message TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ started_at TEXT NOT NULL DEFAULT '',
+ finished_at TEXT NOT NULL DEFAULT ''
+ )
+ """
+ )
+ )
+ ensure_column(conn, "wehago_result_row_cache", "resolved_state_signature", "TEXT NOT NULL DEFAULT ''")
+ ensure_column(conn, "wehago_result_row_cache", "parse_state_signature", "TEXT NOT NULL DEFAULT ''")
+ ensure_column(conn, "wehago_result_row_cache", "resolved_payload_json", "TEXT NOT NULL DEFAULT ''")
+ ensure_column(conn, "wehago_result_row_cache", "resolved_created_at", "TEXT NOT NULL DEFAULT ''")
ensure_column(conn, "wehago_source_files", "source_origin", "TEXT NOT NULL DEFAULT 'filesystem'")
ensure_column(conn, "wehago_manual_pair_matches", "match_source", "TEXT NOT NULL DEFAULT 'manual'")
ensure_column(conn, "wehago_manual_pair_matches", "confidence_score", "REAL NOT NULL DEFAULT 0")
@@ -595,6 +1208,9 @@ def init_wehago_compare_db(engine: Any) -> None:
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_recheck_reviews_year ON wehago_recheck_reviews(fiscal_year, reviewed_at)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_manual_pairs_year ON wehago_manual_pair_matches(fiscal_year, created_at)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_action_history_created ON wehago_action_history(created_at)"))
+ conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_pair_recommend_cache_updated ON wehago_pair_recommend_cache(updated_at)"))
+ conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_metric_count_cache_range ON wehago_metric_count_cache(start_year, end_year, updated_at)"))
+ conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_background_jobs_state_created ON wehago_background_jobs(state, created_at)"))
def upsert_source_file(
@@ -673,11 +1289,13 @@ def upsert_source_file(
def build_voucher_signature(values: list[Any], year_hint: int | None) -> str:
proof_date = parse_excel_date(values[20] if len(values) > 20 else None)
- fiscal_year = year_from_date_text(proof_date) or year_hint or ""
+ draft_no = clean(values[1] if len(values) > 1 else "")
+ confirmed_no = clean(values[10] if len(values) > 10 else "")
+ fiscal_year = infer_voucher_fiscal_year(confirmed_no, draft_no, proof_date, year_hint) or ""
parts = [
clean(values[0] if len(values) > 0 else ""),
- clean(values[1] if len(values) > 1 else ""),
- clean(values[10] if len(values) > 10 else ""),
+ draft_no,
+ confirmed_no,
clean(values[2] if len(values) > 2 else ""),
clean(values[3] if len(values) > 3 else ""),
f"{parse_amount(values[4] if len(values) > 4 else 0):.2f}",
@@ -748,10 +1366,11 @@ def import_voucher_rows(conn: Any, source_id: int, sheet_name: str, rows: Iterab
values = list(row)
if not any(item is not None and clean(item) for item in values):
continue
- proof_date = parse_excel_date(values[20] if len(values) > 20 else None)
- fiscal_year = year_from_date_text(proof_date) or year_hint
draft_no = clean(values[1] if len(values) > 1 else "")
confirmed_no = clean(values[10] if len(values) > 10 else "")
+ proof_date = parse_excel_date(values[20] if len(values) > 20 else None)
+ fiscal_year = infer_voucher_fiscal_year(confirmed_no, draft_no, proof_date, year_hint)
+ effective_no = choose_effective_erp_voucher_no(confirmed_no, draft_no, year_hint)
debit_supply = parse_amount(values[4] if len(values) > 4 else 0)
credit_supply = parse_amount(values[6] if len(values) > 6 else 0)
conn.execute(
@@ -783,7 +1402,7 @@ def import_voucher_rows(conn: Any, source_id: int, sheet_name: str, rows: Iterab
"proof_date": proof_date,
"voucher_type": clean(values[21] if len(values) > 21 else ""),
"management_item": clean(values[22] if len(values) > 22 else ""),
- "compare_voucher_no": normalize_voucher_no(confirmed_no or draft_no),
+ "compare_voucher_no": normalize_compare_voucher_no(effective_no, proof_date),
"compare_amount": debit_supply if debit_supply else credit_supply,
"compare_side": "debit" if debit_supply else ("credit" if credit_supply else ""),
"compare_vendor": normalize_text(values[18] if len(values) > 18 else ""),
@@ -812,8 +1431,13 @@ def import_ledger_rows(conn: Any, source_id: int, sheet_name: str, rows: Iterabl
)
for row_number, row in enumerate(rows, start=2):
values = list(row)
+ if len(values) >= len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) + len(LEDGER_HEADERS):
+ values = values[len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) : len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) + len(LEDGER_HEADERS)]
if not any(item is not None and clean(item) for item in values):
continue
+ normalized_values = {clean(item).replace(" ", "") for item in values}
+ if normalized_values & {"[월계]", "[누계]", "월계", "누계"}:
+ continue
ledger_date = parse_excel_date(values[0] if len(values) > 0 else None, default_year=year_hint)
fiscal_year = year_from_date_text(ledger_date) or year_hint
debit = parse_amount(values[3] if len(values) > 3 else 0)
@@ -833,7 +1457,7 @@ def import_ledger_rows(conn: Any, source_id: int, sheet_name: str, rows: Iterabl
"voucher_no": clean(values[6] if len(values) > 6 else ""),
"account_code": clean(values[7] if len(values) > 7 else ""),
"account_name": clean(values[8] if len(values) > 8 else ""),
- "compare_voucher_no": normalize_voucher_no(values[6] if len(values) > 6 else ""),
+ "compare_voucher_no": normalize_compare_voucher_no(values[6] if len(values) > 6 else "", ledger_date),
"compare_amount": debit if debit else credit,
"compare_side": "debit" if debit else ("credit" if credit else ""),
"compare_vendor": normalize_text(values[2] if len(values) > 2 else ""),
@@ -956,6 +1580,26 @@ def rebuild_comparison_results(conn: Any) -> None:
"""
)
)
+ boundary_sql = _boundary_excluded_sql("l.ledger_date", "l.description", "l.account_name")
+ conn.execute(
+ text(
+ f"""
+ UPDATE wehago_comparison_results
+ SET status = 'ledger_only',
+ notes = CASE
+ WHEN COALESCE(notes, '') = '' THEN '연초/연말 대체·이월 전표는 매칭 대상에서 제외했습니다.'
+ ELSE notes || ' / 연초/연말 대체·이월 전표는 매칭 대상에서 제외했습니다.'
+ END
+ WHERE EXISTS (
+ SELECT 1
+ FROM wehago_ledger_rows l
+ WHERE l.fiscal_year = wehago_comparison_results.fiscal_year
+ AND l.compare_voucher_no = wehago_comparison_results.voucher_no
+ AND {boundary_sql}
+ )
+ """
+ )
+ )
def refresh_wehago_compare_data(engine: Any, source_root: Path | None = None) -> dict[str, int]:
@@ -1003,6 +1647,7 @@ def refresh_wehago_compare_data(engine: Any, source_root: Path | None = None) ->
_SUGGEST_CACHE.clear()
_STATUS_ROWS_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
+ clear_persisted_pair_recommend_cache(engine)
return {
"scanned_files": summary.scanned_files,
"imported_files": summary.imported_files,
@@ -1129,10 +1774,11 @@ def import_uploaded_erp_voucher_file(engine: Any, upload_path: Path, original_fi
duplicate_rows += 1
continue
seen_signatures.add(signature)
- proof_date = parse_excel_date(values[20] if len(values) > 20 else None)
- fiscal_year = year_from_date_text(proof_date) or year_hint
draft_no = clean(values[1] if len(values) > 1 else "")
confirmed_no = clean(values[10] if len(values) > 10 else "")
+ proof_date = parse_excel_date(values[20] if len(values) > 20 else None)
+ fiscal_year = infer_voucher_fiscal_year(confirmed_no, draft_no, proof_date, year_hint)
+ effective_no = choose_effective_erp_voucher_no(confirmed_no, draft_no, year_hint)
debit_supply = parse_amount(values[4] if len(values) > 4 else 0)
credit_supply = parse_amount(values[6] if len(values) > 6 else 0)
conn.execute(
@@ -1164,7 +1810,7 @@ def import_uploaded_erp_voucher_file(engine: Any, upload_path: Path, original_fi
"proof_date": proof_date,
"voucher_type": clean(values[21] if len(values) > 21 else ""),
"management_item": clean(values[22] if len(values) > 22 else ""),
- "compare_voucher_no": normalize_voucher_no(confirmed_no or draft_no),
+ "compare_voucher_no": normalize_compare_voucher_no(effective_no, proof_date),
"compare_amount": debit_supply if debit_supply else credit_supply,
"compare_side": "debit" if debit_supply else ("credit" if credit_supply else ""),
"compare_vendor": normalize_text(values[18] if len(values) > 18 else ""),
@@ -1189,7 +1835,11 @@ def import_uploaded_erp_voucher_file(engine: Any, upload_path: Path, original_fi
)
rebuild_comparison_results(conn)
comparison_rows = int(conn.execute(text("SELECT COUNT(*) FROM wehago_comparison_results")).scalar_one())
-
+ _DASHBOARD_CACHE.clear()
+ _SUGGEST_CACHE.clear()
+ _STATUS_ROWS_CACHE.clear()
+ _PAIR_RECOMMEND_CACHE.clear()
+ clear_persisted_pair_recommend_cache(engine)
return {
"source_id": source_id,
"inserted_rows": inserted,
@@ -1208,22 +1858,26 @@ def build_year_filter_sql(column_name: str = "fiscal_year") -> str:
def fetch_metric_sections(conn: Any, start_year: int | None, end_year: int | None) -> list[dict[str, Any]]:
sections: list[dict[str, Any]] = []
+ voucher_counts = _voucher_metric_counts_from_db(conn, start_year, end_year)
for status_key, label, description in STATUS_META:
- count = int(
- conn.execute(
- text(
- """
- SELECT COUNT(*)
- FROM wehago_comparison_results
- WHERE status = :status
- AND """
- + build_year_filter_sql()
- + """
- """
- ),
- {"status": status_key, "start_year": start_year, "end_year": end_year},
- ).scalar_one()
- )
+ if status_key in {"voucher_matched", "erp_voucher_matched", "voucher_unmatched"}:
+ count = int(voucher_counts.get(status_key, 0) or 0)
+ else:
+ count = int(
+ conn.execute(
+ text(
+ """
+ SELECT COUNT(*)
+ FROM wehago_comparison_results
+ WHERE status = :status
+ AND """
+ + build_year_filter_sql()
+ + """
+ """
+ ),
+ {"status": status_key, "start_year": start_year, "end_year": end_year},
+ ).scalar_one()
+ )
sections.append(
{
"key": status_key,
@@ -1577,6 +2231,12 @@ def parse_compare_result_bundle(
"ledger_credit": matched_ledger.get("ledger_credit", 0),
"review_reason": review_reason,
"review_memo": clean(row[voucher_idx["review_memo"]]) if "review_memo" in voucher_idx else "",
+ "substitution_hint": _build_account_substitution_hint(
+ matched_ledger.get("ledger_account_code", ""),
+ matched_ledger.get("ledger_account_name", ""),
+ clean(row[voucher_idx["계정코드"]]) if "계정코드" in voucher_idx else "",
+ clean(row[voucher_idx["계정명칭"]]) if "계정명칭" in voucher_idx else "",
+ ),
}
item["review_key"] = build_review_key(item)
item["match_identity_key"] = build_match_identity_key(item)
@@ -1629,7 +2289,7 @@ def parse_compare_result_bundle(
metric_map["ledger_only"]["count"] += 1
metric_map["ledger_only"]["rows"].append(item)
- return metric_map
+ return _append_substitution_review_rows(metric_map)
def build_metric_sections_from_results(start_year: int | None, end_year: int | None) -> list[dict[str, Any]]:
@@ -1658,7 +2318,7 @@ def build_metric_sections_from_results(start_year: int | None, end_year: int | N
built_sections: list[dict[str, Any]] = []
for status_key, label, description in STATUS_META:
- section = aggregate[status_key]
+ section = aggregate.get(status_key, {"count": 0, "columns": None, "rows": []})
built_sections.append(
{
"key": status_key,
@@ -1700,7 +2360,7 @@ def build_metric_sections_with_review_state(conn: Any, start_year: int | None, e
built_sections: list[dict[str, Any]] = []
for status_key, label, description in STATUS_META:
- section = aggregate[status_key]
+ section = aggregate.get(status_key, {"count": 0, "columns": None, "rows": []})
built_sections.append(
{
"key": status_key,
@@ -1862,7 +2522,7 @@ def _build_bundle_signature(start_year: int | None, end_year: int | None) -> str
for year in range(start_year, end_year + 1):
bundle = discover_compare_result_bundle(year)
if not bundle:
- parts.append(f"{year}:none")
+ parts.append(f"{year}:db")
continue
ledger_mtime = f"{bundle['ledger_result'].stat().st_mtime:.3f}"
voucher_mtime = f"{bundle['voucher_result'].stat().st_mtime:.3f}"
@@ -1870,10 +2530,62 @@ def _build_bundle_signature(start_year: int | None, end_year: int | None) -> str
return "|".join(parts)
+def _empty_status_sections() -> dict[str, dict[str, Any]]:
+ return {
+ status_key: {
+ "count": 0,
+ "columns": DETAIL_COLUMN_MAP[status_key],
+ "rows": [],
+ }
+ for status_key, _label, _description in STATUS_META
+ }
+
+
+def _build_db_status_sections(conn: Any, year: int) -> dict[str, dict[str, Any]]:
+ sections = _empty_status_sections()
+ for status_key in sections:
+ payload = _fetch_status_detail_rows_from_db(
+ conn,
+ year,
+ year,
+ status_key,
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ False,
+ 0,
+ 1_000_000,
+ )
+ rows = payload["rows"]
+ for row in rows:
+ if status_key in {"matched", "ledger_only", "amount_mismatch"}:
+ row["ledger_row_key"] = build_ledger_row_key(row)
+ if status_key in {"matched", "voucher_only", "amount_mismatch"}:
+ row["voucher_row_key"] = build_voucher_row_key(row)
+ row["review_key"] = build_review_key(row)
+ row["match_identity_key"] = build_match_identity_key(row)
+ sections[status_key]["rows"] = rows
+ sections[status_key]["count"] = len(rows)
+
+ sections = _append_substitution_review_rows(sections)
+ sections = _promote_direct_auto_matches(sections)
+ sections = _apply_previous_year_erp_candidates(conn, year, sections)
+ sections = apply_saved_recheck_reviews(sections, get_saved_recheck_review_keys(conn, year, year))
+ sections = apply_saved_manual_pair_matches(sections, get_saved_manual_pair_matches(conn, year, year))
+ return sections
+
+
def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[str, Any]] | None:
bundle = discover_compare_result_bundle(year)
if not bundle:
return None
+ parse_signature = _current_logic_signature()
ledger_path = str(bundle["ledger_result"])
voucher_path = str(bundle["voucher_result"])
ledger_mtime = float(bundle["ledger_result"].stat().st_mtime)
@@ -1889,6 +2601,7 @@ def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[
AND ledger_result_mtime = :ledger_result_mtime
AND voucher_result_path = :voucher_result_path
AND voucher_result_mtime = :voucher_result_mtime
+ AND parse_state_signature = :parse_signature
LIMIT 1
"""
),
@@ -1898,13 +2611,14 @@ def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[
"ledger_result_mtime": ledger_mtime,
"voucher_result_path": voucher_path,
"voucher_result_mtime": voucher_mtime,
+ "parse_signature": parse_signature,
},
).first()
if cached and cached[0]:
try:
payload = json.loads(str(cached[0]))
if isinstance(payload, dict):
- return payload
+ return _append_substitution_review_rows(payload)
except Exception:
pass
@@ -1924,6 +2638,7 @@ def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[
ledger_result_mtime,
voucher_result_path,
voucher_result_mtime,
+ parse_state_signature,
payload_json,
created_at
) VALUES (
@@ -1932,6 +2647,7 @@ def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[
:ledger_result_mtime,
:voucher_result_path,
:voucher_result_mtime,
+ :parse_signature,
:payload_json,
CURRENT_TIMESTAMP
)
@@ -1943,6 +2659,7 @@ def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[
"ledger_result_mtime": ledger_mtime,
"voucher_result_path": voucher_path,
"voucher_result_mtime": voucher_mtime,
+ "parse_signature": parse_signature,
"payload_json": json.dumps(parsed, ensure_ascii=False),
},
)
@@ -1956,6 +2673,7 @@ def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[
AND ledger_result_mtime = :ledger_result_mtime
AND voucher_result_path = :voucher_result_path
AND voucher_result_mtime = :voucher_result_mtime
+ AND parse_state_signature = :parse_signature
)
"""
),
@@ -1965,12 +2683,137 @@ def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[
"ledger_result_mtime": ledger_mtime,
"voucher_result_path": voucher_path,
"voucher_result_mtime": voucher_mtime,
+ "parse_signature": parse_signature,
},
)
return parsed
+def _get_or_create_year_resolved_sections(conn: Any, year: int) -> dict[str, dict[str, Any]] | None:
+ signature = _build_db_state_signature(conn, year, year)
+ cached_payload = _load_year_resolved_sections_cache(conn, year, signature)
+ if cached_payload is not None:
+ return cached_payload
+
+ parsed_sections = _get_or_create_year_cached_sections(conn, year)
+ if parsed_sections is not None:
+ sections = _apply_boundary_exclusions_to_sections(conn, parsed_sections)
+ sections = apply_saved_recheck_reviews(sections, get_saved_recheck_review_keys(conn, year, year))
+ sections = apply_saved_manual_pair_matches(sections, get_saved_manual_pair_matches(conn, year, year))
+ sections = _apply_previous_year_erp_candidates(conn, year, sections)
+ else:
+ sections = _build_db_status_sections(conn, year)
+ _store_year_resolved_sections_cache(conn, year, signature, sections)
+ return sections
+
+
+def _load_year_resolved_sections_cache(
+ conn: Any,
+ year: int,
+ signature: str | None = None,
+) -> dict[str, dict[str, Any]] | None:
+ signature = signature or _build_db_state_signature(conn, year, year)
+ cached = conn.execute(
+ text(
+ """
+ SELECT resolved_payload_json
+ FROM wehago_result_row_cache
+ WHERE fiscal_year = :fiscal_year
+ AND ledger_result_path = 'db'
+ AND voucher_result_path = 'db'
+ AND resolved_state_signature = :signature
+ LIMIT 1
+ """
+ ),
+ {"fiscal_year": year, "signature": signature},
+ ).first()
+ if cached and cached[0]:
+ try:
+ payload = json.loads(str(cached[0]))
+ if isinstance(payload, dict):
+ return payload
+ except Exception:
+ pass
+ return None
+
+
+def _store_year_resolved_sections_cache(
+ conn: Any,
+ year: int,
+ signature: str,
+ sections: dict[str, dict[str, Any]],
+) -> None:
+ conn.execute(
+ text(
+ """
+ INSERT OR REPLACE INTO wehago_result_row_cache (
+ fiscal_year,
+ ledger_result_path,
+ ledger_result_mtime,
+ voucher_result_path,
+ voucher_result_mtime,
+ payload_json,
+ resolved_state_signature,
+ resolved_payload_json,
+ resolved_created_at,
+ created_at
+ ) VALUES (
+ :fiscal_year,
+ 'db',
+ 0,
+ 'db',
+ 0,
+ '{}',
+ :signature,
+ :payload_json,
+ CURRENT_TIMESTAMP,
+ CURRENT_TIMESTAMP
+ )
+ """
+ ),
+ {
+ "fiscal_year": year,
+ "signature": signature,
+ "payload_json": json.dumps(sections, ensure_ascii=False),
+ },
+ )
+ conn.execute(
+ text(
+ """
+ DELETE FROM wehago_result_row_cache
+ WHERE fiscal_year = :fiscal_year
+ AND ledger_result_path = 'db'
+ AND voucher_result_path = 'db'
+ AND resolved_state_signature <> :signature
+ """
+ ),
+ {"fiscal_year": year, "signature": signature},
+ )
+
+
def _build_db_state_signature(conn: Any, start_year: int | None, end_year: int | None) -> str:
+ source = conn.execute(
+ text(
+ """
+ SELECT COALESCE(MAX(imported_at), ''), COUNT(*)
+ FROM wehago_source_files
+ WHERE (:start_year IS NULL OR year_hint >= :start_year)
+ AND (:end_year IS NULL OR year_hint <= :end_year)
+ """
+ ),
+ {"start_year": start_year, "end_year": end_year},
+ ).first()
+ comparison = conn.execute(
+ text(
+ """
+ SELECT COUNT(*)
+ FROM wehago_comparison_results
+ WHERE (:start_year IS NULL OR fiscal_year >= :start_year)
+ AND (:end_year IS NULL OR fiscal_year <= :end_year)
+ """
+ ),
+ {"start_year": start_year, "end_year": end_year},
+ ).first()
recheck = conn.execute(
text(
"""
@@ -1993,7 +2836,32 @@ def _build_db_state_signature(conn: Any, start_year: int | None, end_year: int |
),
{"start_year": start_year, "end_year": end_year},
).first()
- return f"r:{recheck[0]}:{recheck[1]}|p:{pair[0]}:{pair[1]}"
+ return (
+ f"{_current_logic_signature()}|"
+ f"s:{source[0]}:{source[1]}|c:{comparison[0]}|"
+ f"r:{recheck[0]}:{recheck[1]}|p:{pair[0]}:{pair[1]}"
+ )
+
+
+def _discover_available_fiscal_years(conn: Any) -> list[int]:
+ rows = conn.execute(
+ text(
+ """
+ SELECT DISTINCT fiscal_year
+ FROM (
+ SELECT fiscal_year FROM wehago_ledger_rows
+ UNION
+ SELECT fiscal_year FROM wehago_voucher_rows
+ UNION
+ SELECT fiscal_year FROM wehago_comparison_results
+ )
+ WHERE fiscal_year IS NOT NULL
+ AND fiscal_year > 0
+ ORDER BY fiscal_year
+ """
+ )
+ ).fetchall()
+ return [int(row[0]) for row in rows if str(row[0] or "").isdigit()]
def _get_cached_status_rows_by_range(engine: Any, start_year: int | None, end_year: int | None) -> dict[str, list[dict[str, Any]]]:
@@ -2014,8 +2882,6 @@ def _get_cached_status_rows_by_range(engine: Any, start_year: int | None, end_ye
if cached and (now - float(cached.get("ts", 0))) <= _STATUS_ROWS_CACHE_TTL_SEC:
return cached["rows_by_status"]
- reviewed_keys = get_saved_recheck_review_keys(conn, start_year, end_year)
- manual_pair_matches = get_saved_manual_pair_matches(conn, start_year, end_year)
rows_by_status: dict[str, list[dict[str, Any]]] = {
"matched": [],
"ledger_only": [],
@@ -2023,18 +2889,69 @@ def _get_cached_status_rows_by_range(engine: Any, start_year: int | None, end_ye
"voucher_only": [],
}
for year in range(start_year, end_year + 1):
- parsed = _get_or_create_year_cached_sections(conn, year)
+ parsed = _get_or_create_year_resolved_sections(conn, year)
if not parsed:
continue
- parsed = apply_saved_recheck_reviews(parsed, reviewed_keys)
- parsed = apply_saved_manual_pair_matches(parsed, manual_pair_matches)
for status_key in rows_by_status:
rows_by_status[status_key].extend(parsed[status_key]["rows"])
+ if start_year != end_year:
+ section_payload = {
+ status_key: {
+ "rows": list(rows),
+ "count": len(rows),
+ "columns": DETAIL_COLUMN_MAP[status_key],
+ }
+ for status_key, rows in rows_by_status.items()
+ }
+ section_payload = _promote_cross_year_auto_matches(section_payload)
+ rows_by_status = {
+ status_key: list(section_payload[status_key]["rows"])
+ for status_key in rows_by_status
+ }
_STATUS_ROWS_CACHE.clear()
_STATUS_ROWS_CACHE[cache_key] = {"ts": now, "rows_by_status": rows_by_status}
return rows_by_status
+def _build_voucher_section_cache_key(conn: Any, start_year: int, end_year: int) -> str:
+ return "|".join(
+ [
+ str(start_year),
+ str(end_year),
+ _build_bundle_signature(start_year, end_year),
+ _build_db_state_signature(conn, start_year, end_year),
+ ]
+ )
+
+
+def _get_cached_voucher_sections_by_range(
+ engine: Any,
+ start_year: int | None,
+ end_year: int | None,
+ rows_by_status: dict[str, list[dict[str, Any]]] | None = None,
+) -> dict[str, list[dict[str, Any]]]:
+ if start_year is None or end_year is None:
+ return {
+ "voucher_matched": [],
+ "erp_voucher_matched": [],
+ "voucher_unmatched": [],
+ "voucher_recheck": [],
+ }
+ init_wehago_compare_db(engine)
+ with engine.begin() as conn:
+ cache_key = _build_voucher_section_cache_key(conn, start_year, end_year)
+ now = time.time()
+ cached = _VOUCHER_SECTION_CACHE.get(cache_key)
+ if cached and (now - float(cached.get("ts", 0))) <= _VOUCHER_SECTION_CACHE_TTL_SEC:
+ return cached["sections"]
+ if rows_by_status is None:
+ rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
+ sections = _build_voucher_sections_from_rows_by_status(rows_by_status)
+ _VOUCHER_SECTION_CACHE.clear()
+ _VOUCHER_SECTION_CACHE[cache_key] = {"ts": now, "sections": sections}
+ return sections
+
+
def _push_action_history(conn: Any, action_type: str, payload: dict[str, Any]) -> int:
result = conn.execute(
text(
@@ -2193,6 +3110,7 @@ def save_recheck_review_rows(engine: Any, rows: list[dict[str, Any]]) -> int:
_SUGGEST_CACHE.clear()
_STATUS_ROWS_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
+ clear_persisted_pair_recommend_cache(engine)
return len(normalized_rows)
@@ -2313,6 +3231,7 @@ def save_manual_pair_matches(
_SUGGEST_CACHE.clear()
_STATUS_ROWS_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
+ clear_persisted_pair_recommend_cache(engine)
return len(rows_to_save)
@@ -2390,6 +3309,7 @@ def undo_last_action(engine: Any) -> dict[str, Any]:
_SUGGEST_CACHE.clear()
_STATUS_ROWS_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
+ clear_persisted_pair_recommend_cache(engine)
return {"undone": True, "action_type": action_type, "affected": affected}
@@ -2405,11 +3325,87 @@ def _parse_iso_date(value: Any) -> date | None:
def _tokenize_for_similarity(value: Any) -> set[str]:
normalized = normalize_text(value)
+ if not normalized:
+ return set()
+ normalized = _normalize_core_similarity_text(normalized)
if not normalized:
return set()
if len(normalized) <= 2:
return {normalized}
- return {normalized[i : i + 2] for i in range(len(normalized) - 1)}
+ tokens = {normalized[i : i + 2] for i in range(len(normalized) - 1)}
+ tokens.add(normalized)
+ for keyword in _extract_core_keywords(normalized):
+ tokens.add(keyword)
+ return tokens
+
+
+def _normalize_core_similarity_text(value: Any) -> str:
+ text_value = normalize_text(value)
+ replacements = [
+ "주식회사",
+ "유한회사",
+ "합자회사",
+ "합명회사",
+ "재단법인",
+ "사단법인",
+ "농업회사법인",
+ "회사",
+ "법인",
+ "부가가치세",
+ "전자세금계산서",
+ "세금계산서",
+ ]
+ for token in replacements:
+ text_value = text_value.replace(token, "")
+ return text_value
+
+
+def _extract_core_keywords(value: Any) -> set[str]:
+ text_value = _normalize_core_similarity_text(value)
+ if not text_value:
+ return set()
+ keywords: set[str] = set()
+ candidates = [
+ "미수금",
+ "매출금",
+ "외상매출금",
+ "매입세액",
+ "부가세대급금",
+ "부가세예수금",
+ "대급금",
+ "예수금",
+ "보통예금",
+ "미지급금",
+ "급여",
+ "퇴직급여",
+ "복리후생",
+ "여비교통",
+ "접대",
+ "통신",
+ "전력",
+ "수도광열",
+ "차량유지",
+ "보험",
+ "교육훈련",
+ "도서인쇄",
+ "사무용품",
+ "소모품",
+ "지급수수료",
+ "수수료",
+ "감가상각",
+ "이자",
+ "배당",
+ "잡이익",
+ "잡손실",
+ ]
+ for keyword in candidates:
+ normalized_keyword = normalize_text(keyword)
+ if normalized_keyword and normalized_keyword in text_value:
+ keywords.add(normalized_keyword)
+ for size in (4, 3):
+ if len(text_value) >= size:
+ keywords.update(text_value[i : i + size] for i in range(len(text_value) - size + 1))
+ return keywords
def _jaccard_similarity(left: Any, right: Any) -> float:
@@ -2430,6 +3426,1540 @@ def _jaccard_similarity_tokens(left_tokens: set[str], right_tokens: set[str]) ->
return (inter / union) if union else 0.0
+def _extract_month_tokens(value: Any) -> set[int]:
+ text = str(value or "")
+ tokens: set[int] = set()
+ for raw in re.findall(r"(? bool:
+ left_months = _extract_month_tokens(left)
+ right_months = _extract_month_tokens(right)
+ if not left_months or not right_months:
+ return False
+ return left_months.isdisjoint(right_months)
+
+
+def _has_conflicting_month_token_sets(left_months: set[int], right_months: set[int]) -> bool:
+ if not left_months or not right_months:
+ return False
+ return left_months.isdisjoint(right_months)
+
+
+def _extract_desc_date_tokens(value: Any) -> set[str]:
+ text_value = clean(value)
+ if not text_value:
+ return set()
+ tokens: set[str] = set()
+ for year, month, day in re.findall(r"(? set[int]:
+ months: set[int] = set()
+ for token in tokens:
+ parts = token.split("-")
+ if len(parts) == 3:
+ month_text = parts[1]
+ elif len(parts) == 2 and len(parts[0]) == 4:
+ month_text = parts[1]
+ else:
+ month_text = parts[0]
+ try:
+ month = int(month_text)
+ except ValueError:
+ continue
+ if 1 <= month <= 12:
+ months.add(month)
+ return months
+
+
+def _strip_desc_date_tokens(value: Any) -> str:
+ text_value = clean(value)
+ text_value = re.sub(r"(? bool:
+ left_dates = _extract_desc_date_tokens(left)
+ right_dates = _extract_desc_date_tokens(right)
+ if not left_dates and not right_dates:
+ return True
+ if left_dates == right_dates:
+ return True
+ left_months = _date_token_month_numbers(left_dates)
+ right_months = _date_token_month_numbers(right_dates)
+ return bool(left_months and right_months and left_months == right_months)
+
+
+def _date_tokens_conflict(left: Any, right: Any) -> bool:
+ left_dates = _extract_desc_date_tokens(left)
+ right_dates = _extract_desc_date_tokens(right)
+ return bool(left_dates and right_dates and not _date_tokens_compatible(left, right))
+
+
+def _core_token_overlap(left: Any, right: Any) -> bool:
+ left_tokens = _extract_core_keywords(left)
+ right_tokens = _extract_core_keywords(right)
+ if left_tokens & right_tokens:
+ return True
+ left_norm = _normalize_core_similarity_text(left)
+ right_norm = _normalize_core_similarity_text(right)
+ if not left_norm or not right_norm:
+ return False
+ if len(left_norm) >= 2 and left_norm in right_norm:
+ return True
+ if len(right_norm) >= 2 and right_norm in left_norm:
+ return True
+ return _jaccard_similarity(left_norm, right_norm) >= 0.55
+
+
+def _core_token_strong_overlap(left: Any, right: Any) -> bool:
+ left_tokens = _extract_core_keywords(left)
+ right_tokens = _extract_core_keywords(right)
+ if left_tokens and right_tokens and left_tokens & right_tokens:
+ return True
+ left_norm = _normalize_core_similarity_text(left)
+ right_norm = _normalize_core_similarity_text(right)
+ if not left_norm or not right_norm:
+ return False
+ return _jaccard_similarity(left_norm, right_norm) >= 0.72
+
+
+def _account_names_compatible(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> bool:
+ ledger_code = clean(ledger_row.get("ledger_account_code"))
+ voucher_code = clean(voucher_row.get("voucher_account_code"))
+ ledger_name = clean(ledger_row.get("ledger_account_name"))
+ voucher_name = clean(voucher_row.get("voucher_account_name"))
+ if ledger_code and voucher_code and ledger_code == voucher_code:
+ return True
+ if normalize_text(ledger_name) and normalize_text(ledger_name) == normalize_text(voucher_name):
+ return True
+ if _build_account_substitution_hint(ledger_code, ledger_name, voucher_code, voucher_name):
+ return True
+ if _account_base_names_compatible(ledger_name, voucher_name):
+ return True
+ ledger_family = _classify_account_family(ledger_code, ledger_name)
+ voucher_family = _classify_account_family(voucher_code, voucher_name)
+ if ledger_family and ledger_family == voucher_family:
+ return True
+ return _jaccard_similarity(ledger_name, voucher_name) >= 0.55
+
+
+def _account_base_name(value: Any) -> str:
+ text_value = clean(value)
+ text_value = re.sub(r"^\s*원가\)\s*", "", text_value)
+ text_value = text_value.split("(", 1)[0]
+ normalized = normalize_text(text_value)
+ if normalized.startswith("원가"):
+ normalized = normalized[2:]
+ return normalized
+
+
+def _account_base_names_compatible(left: Any, right: Any) -> bool:
+ left_base = _account_base_name(left)
+ right_base = _account_base_name(right)
+ if not left_base or not right_base:
+ return False
+ if left_base == right_base:
+ return True
+ short, long = sorted((left_base, right_base), key=len)
+ return len(short) >= 2 and short in long
+
+
+def _same_or_similar_vendor(row: dict[str, Any]) -> bool:
+ return _core_token_overlap(row.get("ledger_vendor"), row.get("voucher_vendor"))
+
+
+def _same_or_similar_desc(row: dict[str, Any]) -> bool:
+ if not _date_tokens_compatible(row.get("ledger_desc"), row.get("voucher_desc")):
+ return False
+ ledger_desc = _strip_desc_date_tokens(row.get("ledger_desc"))
+ voucher_desc = _strip_desc_date_tokens(row.get("voucher_desc"))
+ return _core_token_strong_overlap(ledger_desc, voucher_desc)
+
+
+def _contained_core_desc_match(left: Any, right: Any) -> bool:
+ if not _date_tokens_compatible(left, right):
+ return False
+ left_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(left))
+ right_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(right))
+ if not left_norm or not right_norm:
+ return False
+ short, long = sorted((left_norm, right_norm), key=len)
+ return len(short) >= 2 and short in long
+
+
+def _short_core_desc_fuzzy_match(left: Any, right: Any) -> bool:
+ if not _date_tokens_compatible(left, right):
+ return False
+ left_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(left))
+ right_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(right))
+ if not left_norm or not right_norm:
+ return False
+ if max(len(left_norm), len(right_norm)) > 4 or min(len(left_norm), len(right_norm)) < 2:
+ return False
+ return SequenceMatcher(None, left_norm, right_norm).ratio() >= 0.75
+
+
+def _bank_payable_desc_approved(row: dict[str, Any]) -> bool:
+ ledger_desc = row.get("ledger_desc")
+ voucher_desc = row.get("voucher_desc")
+ return (
+ _same_or_similar_desc(row)
+ or _contained_core_desc_match(ledger_desc, voucher_desc)
+ or _short_core_desc_fuzzy_match(ledger_desc, voucher_desc)
+ or _shared_strong_keyword(ledger_desc, voucher_desc, {"보증"})
+ )
+
+
+def _shared_strong_keyword(left: Any, right: Any, keywords: set[str]) -> bool:
+ left_norm = normalize_text(left)
+ right_norm = normalize_text(right)
+ return any(keyword and keyword in left_norm and keyword in right_norm for keyword in keywords)
+
+
+def _is_same_bank_guarantee_case(row: dict[str, Any]) -> bool:
+ ledger_family = _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name"))
+ voucher_family = _classify_account_family(row.get("voucher_account_code"), row.get("voucher_account_name"))
+ return (
+ ledger_family == "bank"
+ and voucher_family == "bank"
+ and _shared_strong_keyword(row.get("ledger_desc"), row.get("voucher_desc"), {"보증"})
+ )
+
+
+def _is_approved_guarantee_account_pair(row: dict[str, Any]) -> bool:
+ if not _shared_strong_keyword(row.get("ledger_desc"), row.get("voucher_desc"), {"보증"}):
+ return False
+ ledger_name = normalize_text(row.get("ledger_account_name"))
+ voucher_name = normalize_text(row.get("voucher_account_name"))
+ approved_pairs = (
+ ("민사보전금", "임차보증금"),
+ ("세금과공과금", "보험료"),
+ ("선급금", "전도금"),
+ ("보증수수료", "지급수수료"),
+ )
+ for left_marker, right_marker in approved_pairs:
+ left = normalize_text(left_marker)
+ right = normalize_text(right_marker)
+ if left in ledger_name and right in voucher_name:
+ return True
+ if right in ledger_name and left in voucher_name:
+ return True
+ return False
+
+
+def _account_base_desc_approved(row: dict[str, Any]) -> bool:
+ if not _account_base_names_compatible(row.get("ledger_account_name"), row.get("voucher_account_name")):
+ return False
+ return (
+ _same_or_similar_desc(row)
+ or _contained_core_desc_match(row.get("ledger_desc"), row.get("voucher_desc"))
+ )
+
+
+def _has_boundary_substitution_date(row: dict[str, Any]) -> bool:
+ try:
+ fiscal_year = int(row.get("fiscal_year") or 0)
+ except (TypeError, ValueError):
+ fiscal_year = 0
+ for field in ("ledger_date", "proof_date"):
+ parsed = _parse_iso_date(row.get(field))
+ if parsed is None:
+ continue
+ if fiscal_year and parsed.year != fiscal_year:
+ continue
+ if (parsed.month, parsed.day) in {(1, 1), (12, 31)}:
+ return True
+ return False
+
+
+def _is_substitution_like_row(row: dict[str, Any]) -> bool:
+ review_text = normalize_text(
+ " ".join(
+ clean(row.get(field))
+ for field in ("review_reason", "substitution_hint", "ledger_desc", "voucher_desc")
+ )
+ )
+ if any(token in review_text for token in ("대체", "substitution")):
+ return True
+ ledger_family = _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name"))
+ voucher_family = _classify_account_family(row.get("voucher_account_code"), row.get("voucher_account_name"))
+ return bool(_build_account_substitution_hint(
+ row.get("ledger_account_code"),
+ row.get("ledger_account_name"),
+ row.get("voucher_account_code"),
+ row.get("voucher_account_name"),
+ ) and ledger_family != voucher_family)
+
+
+def _is_bank_payable_case(row: dict[str, Any]) -> bool:
+ ledger_family = _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name"))
+ voucher_family = _classify_account_family(row.get("voucher_account_code"), row.get("voucher_account_name"))
+ return {ledger_family, voucher_family} == {"bank", "payable"}
+
+
+def _is_boundary_substitution_row(row: dict[str, Any]) -> bool:
+ return _has_boundary_substitution_date(row) and _is_substitution_like_row(row)
+
+
+def _account_candidate_family_keys(account_code: Any, account_name: Any) -> set[str]:
+ account_code_text = clean(account_code)
+ account_name_norm = normalize_text(account_name)
+ family = _classify_account_family(account_code_text, account_name)
+ keys: set[str] = set()
+ if account_code_text:
+ keys.add(f"code:{account_code_text}")
+ if account_name_norm:
+ keys.add(f"name:{account_name_norm}")
+ if family:
+ keys.add(f"family:{family}")
+ related = {
+ "payable": {"bank"},
+ "bank": {"payable"},
+ "receivable": {"sales"},
+ "sales": {"receivable"},
+ "vat_input": {"vat_output"},
+ "vat_output": {"vat_input"},
+ }
+ for related_family in related.get(family, set()):
+ keys.add(f"family:{related_family}")
+ return keys
+
+
+def _build_amount_account_index(
+ rows: list[dict[str, Any]],
+ *,
+ prefix: str,
+) -> tuple[dict[float, list[dict[str, Any]]], dict[tuple[float, str], list[dict[str, Any]]]]:
+ amount_index: dict[float, list[dict[str, Any]]] = {}
+ account_index: dict[tuple[float, str], list[dict[str, Any]]] = {}
+ for row in rows:
+ amount = round(_get_row_match_amount(row, prefix), 2)
+ if amount <= 0:
+ continue
+ amount_index.setdefault(amount, []).append(row)
+ for key in _account_candidate_family_keys(
+ row.get(f"{prefix}_account_code"),
+ row.get(f"{prefix}_account_name"),
+ ):
+ account_index.setdefault((amount, key), []).append(row)
+ return amount_index, account_index
+
+
+def _candidate_rows_for_amount_account(
+ amount: float,
+ ledger_row: dict[str, Any],
+ amount_index: dict[float, list[dict[str, Any]]],
+ account_index: dict[tuple[float, str], list[dict[str, Any]]],
+ *,
+ wide_bucket_limit: int = 80,
+ filtered_bucket_limit: int = 160,
+) -> list[dict[str, Any]]:
+ candidates = amount_index.get(amount, [])
+ if len(candidates) <= wide_bucket_limit:
+ return candidates
+ filtered: list[dict[str, Any]] = []
+ seen_keys: set[int] = set()
+ for key in _account_candidate_family_keys(
+ ledger_row.get("ledger_account_code"),
+ ledger_row.get("ledger_account_name"),
+ ):
+ for candidate in account_index.get((amount, key), []):
+ object_key = id(candidate)
+ if object_key in seen_keys:
+ continue
+ seen_keys.add(object_key)
+ filtered.append(candidate)
+ if len(filtered) > filtered_bucket_limit:
+ return []
+ return filtered
+
+
+def _is_recheck_row_clear_match(row: dict[str, Any]) -> bool:
+ ledger_amount = _get_row_match_amount(row, "ledger")
+ voucher_amount = _get_row_match_amount(row, "voucher")
+ if ledger_amount <= 0 or abs(ledger_amount - voucher_amount) >= 0.5:
+ return False
+ ledger_side = _determine_primary_side(row)
+ voucher_side = "debit" if parse_amount(row.get("voucher_debit")) > 0 and parse_amount(row.get("voucher_credit")) <= 0 else "credit" if parse_amount(row.get("voucher_credit")) > 0 and parse_amount(row.get("voucher_debit")) <= 0 else "either"
+ if not _nature_compatible(
+ row.get("ledger_account_code"),
+ row.get("ledger_account_name"),
+ ledger_side,
+ row.get("voucher_account_code"),
+ row.get("voucher_account_name"),
+ voucher_side,
+ ):
+ return False
+ if _is_boundary_substitution_row(row):
+ return False
+ vendor_match = _same_or_similar_vendor(row)
+ desc_match = _same_or_similar_desc(row)
+ if _account_base_desc_approved(row):
+ return True
+ if _is_approved_guarantee_account_pair(row):
+ return True
+ if not _account_names_compatible(row, row):
+ return False
+ if _is_same_bank_guarantee_case(row):
+ return True
+ if _is_bank_payable_case(row) and _bank_payable_desc_approved(row):
+ return True
+ if vendor_match and desc_match:
+ return True
+ if vendor_match and not _date_tokens_conflict(row.get("ledger_desc"), row.get("voucher_desc")):
+ return True
+ if desc_match:
+ return True
+ return False
+
+
+def _month_gap(left: date, right: date) -> int:
+ return abs((left.year - right.year) * 12 + (left.month - right.month))
+
+
+def _normalize_account_family_text(value: Any) -> str:
+ return normalize_text(value)
+
+
+def _classify_account_family(account_code: Any, account_name: Any) -> str:
+ normalized_name = _normalize_account_family_text(account_name)
+ normalized_code = clean(account_code)
+ payable_markers = ("미지급금", "외상미지급금")
+ bank_markers = ("보통예금",)
+ receivable_markers = ("미수금", "외상매출금", "매출채권", "공사미수금")
+ sales_markers = ("매출금", "매출", "용역수입", "수입")
+ vat_input_markers = ("부가세대급금", "매입세액", "부가세매입", "부가가치세대급금")
+ vat_output_markers = ("부가세예수금", "매출세액", "부가세매출", "부가가치세예수금")
+ education_markers = ("교육훈련비", "교육훈련")
+ welfare_markers = ("복리후생비", "복리후생", "회식대")
+ if any(marker in normalized_name for marker in payable_markers):
+ return "payable"
+ if any(marker in normalized_name for marker in bank_markers):
+ return "bank"
+ if any(marker in normalized_name for marker in receivable_markers):
+ return "receivable"
+ if any(marker in normalized_name for marker in sales_markers):
+ return "sales"
+ if any(marker in normalized_name for marker in vat_input_markers):
+ return "vat_input"
+ if any(marker in normalized_name for marker in vat_output_markers):
+ return "vat_output"
+ if any(marker in normalized_name for marker in education_markers):
+ return "education_training"
+ if any(marker in normalized_name for marker in welfare_markers):
+ return "welfare"
+ if normalized_code.startswith(("211", "213")):
+ return "payable"
+ if normalized_code.startswith(("111", "112")):
+ return "bank"
+ if normalized_code.startswith(("108", "120", "112")):
+ return "receivable"
+ if normalized_code.startswith(("401", "411", "412", "413", "414", "415")):
+ return "sales"
+ if normalized_code.startswith(("135", "136")):
+ return "vat_input"
+ if normalized_code.startswith(("255",)):
+ return "vat_output"
+ return ""
+
+
+def _classify_account_category(account_code: Any, account_name: Any) -> str:
+ normalized_name = _normalize_account_family_text(account_name)
+ normalized_code = clean(account_code)
+
+ asset_markers = (
+ "보통예금", "예금", "현금", "미수금", "외상매출금", "매출채권", "공사미수금",
+ "선급", "대여금", "가수금환급", "부가세대급금", "매입세액",
+ )
+ liability_markers = (
+ "미지급금", "외상미지급금", "외상매입금", "매입채무", "예수금", "부가세예수금", "매출세액", "선수금", "차입금",
+ )
+ equity_markers = ("자본금", "이익잉여금", "자본잉여금")
+ revenue_markers = ("매출", "용역수입", "수입", "수익")
+ expense_markers = (
+ "원가", "비용", "차량유지비", "지급임차료", "임차료", "복리후생비",
+ "교육훈련비", "급여", "외주비", "수수료", "소모품비", "여비교통비",
+ )
+
+ if any(marker in normalized_name for marker in asset_markers):
+ return "asset"
+ if any(marker in normalized_name for marker in liability_markers):
+ return "liability"
+ if any(marker in normalized_name for marker in equity_markers):
+ return "equity"
+ if any(marker in normalized_name for marker in revenue_markers):
+ return "revenue"
+ if any(marker in normalized_name for marker in expense_markers):
+ return "expense"
+
+ family = _classify_account_family(account_code, account_name)
+ family_category_map = {
+ "bank": "asset",
+ "receivable": "asset",
+ "vat_input": "asset",
+ "payable": "liability",
+ "vat_output": "liability",
+ "sales": "revenue",
+ "education_training": "expense",
+ "welfare": "expense",
+ }
+ if family in family_category_map:
+ return family_category_map[family]
+
+ if normalized_code.startswith(("1",)):
+ return "asset"
+ if normalized_code.startswith(("2",)):
+ return "liability"
+ if normalized_code.startswith(("3",)):
+ return "equity"
+ if normalized_code.startswith(("4",)):
+ return "revenue"
+ if normalized_code.startswith(("5", "6", "7", "8", "9")):
+ return "expense"
+ return ""
+
+
+def _account_nature_signature(account_code: Any, account_name: Any, side: str) -> str:
+ category = _classify_account_category(account_code, account_name)
+ if not category or side not in {"debit", "credit"}:
+ return ""
+ if category in {"asset", "expense"}:
+ direction = "increase" if side == "debit" else "decrease"
+ else:
+ direction = "increase" if side == "credit" else "decrease"
+ return f"{category}:{direction}"
+
+
+def _nature_compatible(
+ ledger_code: Any,
+ ledger_name: Any,
+ ledger_side: str,
+ voucher_code: Any,
+ voucher_name: Any,
+ voucher_side: str,
+) -> bool:
+ ledger_signature = _account_nature_signature(ledger_code, ledger_name, ledger_side)
+ voucher_signature = _account_nature_signature(voucher_code, voucher_name, voucher_side)
+ if not ledger_signature or not voucher_signature:
+ return True
+ return ledger_signature == voucher_signature
+
+
+def _is_vat_family(family: str) -> bool:
+ return family in {"vat_input", "vat_output"}
+
+
+def _is_income_expense_category(category: str) -> bool:
+ return category in {"revenue", "expense"}
+
+
+def _is_asset_liability_category(category: str) -> bool:
+ return category in {"asset", "liability"}
+
+
+def _account_category_pair_allowed(
+ ledger_code: Any,
+ ledger_name: Any,
+ voucher_code: Any,
+ voucher_name: Any,
+) -> bool:
+ ledger_category = _classify_account_category(ledger_code, ledger_name)
+ voucher_category = _classify_account_category(voucher_code, voucher_name)
+ ledger_family = _classify_account_family(ledger_code, ledger_name)
+ voucher_family = _classify_account_family(voucher_code, voucher_name)
+
+ # VAT rows should be matched only against VAT/tax rows.
+ if _is_vat_family(ledger_family) or _is_vat_family(voucher_family):
+ return _is_vat_family(ledger_family) and _is_vat_family(voucher_family)
+
+ # Asset/liability rows should not match revenue/expense rows and vice versa.
+ if (
+ _is_asset_liability_category(ledger_category)
+ and _is_income_expense_category(voucher_category)
+ ) or (
+ _is_income_expense_category(ledger_category)
+ and _is_asset_liability_category(voucher_category)
+ ):
+ return False
+
+ return True
+
+
+def _build_account_substitution_hint(
+ ledger_code: Any,
+ ledger_name: Any,
+ voucher_code: Any,
+ voucher_name: Any,
+) -> str:
+ ledger_family = _classify_account_family(ledger_code, ledger_name)
+ voucher_family = _classify_account_family(voucher_code, voucher_name)
+ if {ledger_family, voucher_family} == {"payable", "bank"}:
+ return "미지급금/보통예금 대체 검토"
+ if {ledger_family, voucher_family} == {"receivable", "sales"}:
+ return "미수금/매출금 유사 계정"
+ if ledger_family and ledger_family == voucher_family and ledger_family in {"vat_input", "vat_output"}:
+ return "부가세 계정 유사"
+ return ""
+
+
+def _get_row_match_amount(row: dict[str, Any], prefix: str) -> float:
+ debit = parse_amount(row.get(f"{prefix}_debit"))
+ credit = parse_amount(row.get(f"{prefix}_credit"))
+ if debit > 0 and credit <= 0:
+ return debit
+ if credit > 0 and debit <= 0:
+ return credit
+ return max(debit, credit)
+
+
+def _get_side_amount(row: dict[str, Any], prefix: str, side: str) -> float:
+ if side == "debit":
+ return parse_amount(row.get(f"{prefix}_debit"))
+ if side == "credit":
+ return parse_amount(row.get(f"{prefix}_credit"))
+ return _get_row_match_amount(row, prefix)
+
+
+def _build_match_row_features(
+ row: dict[str, Any],
+ *,
+ prefix: str,
+ row_key_field: str,
+ account_code_field: str,
+ account_name_field: str,
+ vendor_field: str,
+ desc_field: str,
+ date_field: str,
+) -> MatchRowFeatures:
+ debit_amount = parse_amount(row.get(f"{prefix}_debit"))
+ credit_amount = parse_amount(row.get(f"{prefix}_credit"))
+ if debit_amount > 0 and credit_amount <= 0:
+ match_amount = debit_amount
+ primary_side = "debit"
+ elif credit_amount > 0 and debit_amount <= 0:
+ match_amount = credit_amount
+ primary_side = "credit"
+ else:
+ match_amount = max(debit_amount, credit_amount)
+ primary_side = "either"
+ positive_amounts = tuple(
+ amount
+ for amount in {
+ round(debit_amount, 2),
+ round(credit_amount, 2),
+ }
+ if amount > 0
+ )
+ desc_text = clean(row.get(desc_field))
+ return MatchRowFeatures(
+ row=row,
+ row_key=clean(row.get(row_key_field)),
+ account_code=clean(row.get(account_code_field)),
+ account_name=clean(row.get(account_name_field)),
+ vendor_name=clean(row.get(vendor_field)),
+ desc_text=desc_text,
+ account_tokens=_tokenize_for_similarity(row.get(account_name_field)),
+ vendor_tokens=_tokenize_for_similarity(row.get(vendor_field)),
+ desc_tokens=_tokenize_for_similarity(desc_text),
+ month_tokens=_extract_month_tokens(desc_text),
+ date_value=_parse_iso_date(row.get(date_field)),
+ debit_amount=debit_amount,
+ credit_amount=credit_amount,
+ match_amount=match_amount,
+ primary_side=primary_side,
+ positive_amounts=positive_amounts,
+ )
+
+
+def _get_feature_side_amount(features: MatchRowFeatures, side: str) -> float:
+ if side == "debit":
+ return features.debit_amount
+ if side == "credit":
+ return features.credit_amount
+ return features.match_amount
+
+
+def _is_strong_substitution_candidate(
+ score_result: dict[str, Any],
+ desc_sim: float,
+) -> bool:
+ return bool(
+ score_result.get("substitution_hint")
+ and float(score_result.get("amount_gap", 0)) < 0.5
+ and desc_sim >= 0.85
+ )
+
+
+def _is_strong_text_match(
+ amount_gap: float,
+ vendor_sim: float,
+ desc_sim: float,
+ month_conflict: bool,
+) -> bool:
+ return bool(
+ amount_gap < 0.5
+ and not month_conflict
+ and (
+ (vendor_sim >= 0.65 and desc_sim >= 0.82)
+ or (vendor_sim >= 0.82 and desc_sim >= 0.55)
+ or (vendor_sim >= 0.55 and desc_sim >= 0.72)
+ )
+ )
+
+
+def _is_reviewable_text_match(
+ amount_gap: float,
+ vendor_sim: float,
+ desc_sim: float,
+ month_conflict: bool,
+) -> bool:
+ return bool(
+ amount_gap < 0.5
+ and not month_conflict
+ and (vendor_sim >= 0.4 or desc_sim >= 0.6)
+ )
+
+
+def _build_substitution_review_rows(
+ ledger_rows: list[dict[str, Any]],
+ voucher_rows: list[dict[str, Any]],
+ existing_pairs: set[tuple[str, str]],
+) -> list[dict[str, Any]]:
+ if not ledger_rows or not voucher_rows:
+ return []
+
+ voucher_amount_index, voucher_account_index = _build_amount_account_index(voucher_rows, prefix="voucher")
+
+ built: list[dict[str, Any]] = []
+ seen_pairs = set(existing_pairs)
+ for ledger_row in ledger_rows:
+ amount = round(_get_row_match_amount(ledger_row, "ledger"), 2)
+ if amount <= 0:
+ continue
+ candidates = _candidate_rows_for_amount_account(
+ amount,
+ ledger_row,
+ voucher_amount_index,
+ voucher_account_index,
+ )
+ if not candidates:
+ continue
+ for voucher_row in candidates:
+ ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row)
+ voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
+ pair_key = (ledger_key, voucher_key)
+ if not ledger_key or not voucher_key or pair_key in seen_pairs:
+ continue
+
+ score_result = _score_pair_match(ledger_row, voucher_row)
+ desc_sim = _jaccard_similarity(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc"))
+ if not _is_strong_substitution_candidate(score_result, desc_sim):
+ continue
+
+ item = {
+ "fiscal_year": ledger_row.get("fiscal_year") or voucher_row.get("fiscal_year"),
+ "voucher_no": ledger_row.get("voucher_no", ""),
+ "ledger_date": ledger_row.get("ledger_date", ""),
+ "draft_no": voucher_row.get("draft_no", ""),
+ "ledger_account_code": ledger_row.get("ledger_account_code", ""),
+ "ledger_account_name": ledger_row.get("ledger_account_name", ""),
+ "voucher_account_code": voucher_row.get("voucher_account_code", ""),
+ "voucher_account_name": voucher_row.get("voucher_account_name", ""),
+ "ledger_vendor": ledger_row.get("ledger_vendor", ""),
+ "voucher_vendor": voucher_row.get("voucher_vendor", ""),
+ "ledger_debit": parse_amount(ledger_row.get("ledger_debit")),
+ "ledger_credit": parse_amount(ledger_row.get("ledger_credit")),
+ "voucher_debit": parse_amount(voucher_row.get("voucher_debit")),
+ "voucher_credit": parse_amount(voucher_row.get("voucher_credit")),
+ "ledger_desc": ledger_row.get("ledger_desc", ""),
+ "voucher_desc": voucher_row.get("voucher_desc", ""),
+ "review_reason": "SUBSTITUTION_RECHECK",
+ "review_memo": "",
+ "substitution_hint": score_result.get("substitution_hint") or "미지급금/보통예금 대체 검토",
+ }
+ item["review_key"] = build_review_key(item)
+ item["match_identity_key"] = build_match_identity_key(item)
+ item["ledger_row_key"] = ledger_key
+ item["voucher_row_key"] = voucher_key
+ built.append(item)
+ seen_pairs.add(pair_key)
+ return built
+
+
+def _build_quality_review_rows(
+ ledger_rows: list[dict[str, Any]],
+ voucher_rows: list[dict[str, Any]],
+ existing_pairs: set[tuple[str, str]],
+) -> list[dict[str, Any]]:
+ if not ledger_rows or not voucher_rows:
+ return []
+
+ voucher_amount_index, voucher_account_index = _build_amount_account_index(voucher_rows, prefix="voucher")
+
+ built: list[dict[str, Any]] = []
+ seen_pairs = set(existing_pairs)
+ for ledger_row in ledger_rows:
+ amount = round(_get_row_match_amount(ledger_row, "ledger"), 2)
+ if amount <= 0:
+ continue
+ best_row: dict[str, Any] | None = None
+ best_score: dict[str, Any] | None = None
+ for voucher_row in _candidate_rows_for_amount_account(
+ amount,
+ ledger_row,
+ voucher_amount_index,
+ voucher_account_index,
+ ):
+ ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row)
+ voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
+ pair_key = (ledger_key, voucher_key)
+ if not ledger_key or not voucher_key or pair_key in seen_pairs:
+ continue
+ score_result = _score_pair_match(ledger_row, voucher_row)
+ if score_result.get("auto_eligible"):
+ continue
+ if not _is_reviewable_text_match(
+ float(score_result.get("amount_gap", 0)),
+ float(score_result.get("vendor_similarity", 0)),
+ float(score_result.get("desc_similarity", 0)),
+ bool(score_result.get("month_conflict")),
+ ):
+ continue
+ if best_score is None or float(score_result.get("score", 0)) > float(best_score.get("score", 0)):
+ best_row = voucher_row
+ best_score = score_result
+ if best_row is None or best_score is None:
+ continue
+ item = {
+ "fiscal_year": ledger_row.get("fiscal_year") or best_row.get("fiscal_year"),
+ "voucher_no": ledger_row.get("voucher_no", ""),
+ "ledger_date": ledger_row.get("ledger_date", ""),
+ "draft_no": best_row.get("draft_no", ""),
+ "ledger_account_code": ledger_row.get("ledger_account_code", ""),
+ "ledger_account_name": ledger_row.get("ledger_account_name", ""),
+ "voucher_account_code": best_row.get("voucher_account_code", ""),
+ "voucher_account_name": best_row.get("voucher_account_name", ""),
+ "ledger_vendor": ledger_row.get("ledger_vendor", ""),
+ "voucher_vendor": best_row.get("voucher_vendor", ""),
+ "ledger_debit": parse_amount(ledger_row.get("ledger_debit")),
+ "ledger_credit": parse_amount(ledger_row.get("ledger_credit")),
+ "voucher_debit": parse_amount(best_row.get("voucher_debit")),
+ "voucher_credit": parse_amount(best_row.get("voucher_credit")),
+ "ledger_desc": ledger_row.get("ledger_desc", ""),
+ "voucher_desc": best_row.get("voucher_desc", ""),
+ "review_reason": "QUALITY_RECHECK",
+ "review_memo": "",
+ "substitution_hint": best_score.get("substitution_hint", ""),
+ }
+ item["review_key"] = build_review_key(item)
+ item["match_identity_key"] = build_match_identity_key(item)
+ item["ledger_row_key"] = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row)
+ item["voucher_row_key"] = clean(best_row.get("voucher_row_key")) or build_voucher_row_key(best_row)
+ built.append(item)
+ seen_pairs.add((item["ledger_row_key"], item["voucher_row_key"]))
+ return built
+
+
+def _rebalance_matched_rows_for_review(
+ sections: dict[str, dict[str, Any]],
+) -> dict[str, dict[str, Any]]:
+ kept_rows: list[dict[str, Any]] = []
+ moved_rows: list[dict[str, Any]] = []
+ moved_to_ledger_only: list[dict[str, Any]] = []
+ for row in sections["matched"]["rows"]:
+ score_result = _score_pair_match(row, row)
+ if _is_boundary_substitution_row(row):
+ recheck_row = dict(row)
+ recheck_row["review_reason"] = "BOUNDARY_SUBSTITUTION_RECHECK"
+ recheck_row.setdefault("review_memo", "")
+ recheck_row["substitution_hint"] = (
+ score_result.get("substitution_hint")
+ or clean(recheck_row.get("substitution_hint"))
+ or "연초/연말 대체전표 매칭 제외"
+ )
+ moved_rows.append(recheck_row)
+ continue
+ if not _matched_row_has_voucher_payload(row):
+ ledger_only_row = dict(row)
+ ledger_only_row["review_reason"] = "MISSING_MATCH_TARGET"
+ ledger_only_row["review_memo"] = ""
+ moved_to_ledger_only.append(ledger_only_row)
+ continue
+ if score_result.get("month_conflict") or _date_tokens_conflict(row.get("ledger_desc"), row.get("voucher_desc")):
+ recheck_row = dict(row)
+ recheck_row["review_reason"] = "MONTH_CONFLICT_RECHECK"
+ recheck_row.setdefault("review_memo", "")
+ moved_rows.append(recheck_row)
+ continue
+ if not score_result.get("auto_eligible") and not _is_recheck_row_clear_match(row):
+ recheck_row = dict(row)
+ recheck_row["review_reason"] = "WEAK_MATCH_RECHECK"
+ recheck_row.setdefault("review_memo", "")
+ moved_rows.append(recheck_row)
+ continue
+ kept_rows.append(row)
+ if moved_rows or moved_to_ledger_only:
+ sections["matched"]["rows"] = kept_rows
+ sections["matched"]["count"] = len(kept_rows)
+ sections["amount_mismatch"]["rows"].extend(moved_rows)
+ sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"])
+ sections["ledger_only"]["rows"].extend(moved_to_ledger_only)
+ sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"])
+ return sections
+
+
+def _promote_recheck_rows_to_matched(
+ sections: dict[str, dict[str, Any]],
+) -> dict[str, dict[str, Any]]:
+ matched_rows = list(sections["matched"]["rows"])
+ recheck_rows = list(sections["amount_mismatch"]["rows"])
+ promoted_rows: list[dict[str, Any]] = []
+ remaining_rows: list[dict[str, Any]] = []
+
+ existing_identity_keys = {
+ clean(row.get("match_identity_key")) or build_match_identity_key(row)
+ for row in matched_rows
+ }
+
+ for row in recheck_rows:
+ score_result = _score_pair_match(row, row)
+ if score_result.get("auto_eligible") or _is_recheck_row_clear_match(row):
+ identity_key = clean(row.get("match_identity_key")) or build_match_identity_key(row)
+ if identity_key not in existing_identity_keys:
+ promoted = dict(row)
+ if _is_bank_payable_case(promoted):
+ promoted["review_reason"] = "BANK_PAYABLE_MATCH"
+ promoted["matched_case"] = "bank_payable"
+ promoted["substitution_hint"] = clean(promoted.get("substitution_hint")) or "보통예금/미지급금 case"
+ else:
+ promoted["review_reason"] = "자동승격매칭" if score_result.get("auto_eligible") else "RECHECK_CLEAR_MATCH"
+ promoted_rows.append(promoted)
+ existing_identity_keys.add(identity_key)
+ else:
+ remaining_rows.append(row)
+
+ if promoted_rows:
+ sections["matched"]["rows"] = matched_rows + promoted_rows
+ sections["matched"]["count"] = len(sections["matched"]["rows"])
+ sections["amount_mismatch"]["rows"] = remaining_rows
+ sections["amount_mismatch"]["count"] = len(remaining_rows)
+ return sections
+
+
+def _promote_direct_auto_matches(
+ sections: dict[str, dict[str, Any]],
+) -> dict[str, dict[str, Any]]:
+ ledger_rows = list(sections["ledger_only"]["rows"])
+ voucher_rows = list(sections["voucher_only"]["rows"])
+ if not ledger_rows or not voucher_rows:
+ return sections
+
+ amount_index, account_index = _build_amount_account_index(voucher_rows, prefix="voucher")
+
+ edge_candidates: list[tuple[float, dict[str, Any], dict[str, Any], dict[str, Any]]] = []
+ for ledger_row in ledger_rows:
+ candidate_amounts = {
+ round(parse_amount(ledger_row.get("ledger_debit")), 2),
+ round(parse_amount(ledger_row.get("ledger_credit")), 2),
+ }
+ seen_voucher_keys: set[str] = set()
+ for amount in candidate_amounts:
+ if amount <= 0:
+ continue
+ for voucher_row in _candidate_rows_for_amount_account(
+ amount,
+ ledger_row,
+ amount_index,
+ account_index,
+ ):
+ voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
+ if voucher_key in seen_voucher_keys:
+ continue
+ seen_voucher_keys.add(voucher_key)
+ score_result = _score_pair_match(ledger_row, voucher_row)
+ if not score_result.get("auto_eligible"):
+ continue
+ boundary_probe = dict(ledger_row)
+ boundary_probe.update(
+ {
+ "proof_date": voucher_row.get("proof_date", ""),
+ "voucher_account_code": voucher_row.get("voucher_account_code", ""),
+ "voucher_account_name": voucher_row.get("voucher_account_name", ""),
+ "voucher_vendor": voucher_row.get("voucher_vendor", ""),
+ "voucher_desc": voucher_row.get("voucher_desc", ""),
+ }
+ )
+ if _is_boundary_substitution_row(boundary_probe):
+ continue
+ edge_candidates.append((float(score_result.get("score", 0)), ledger_row, voucher_row, score_result))
+
+ if not edge_candidates:
+ return sections
+
+ edge_candidates.sort(key=lambda item: item[0], reverse=True)
+ matched_ledger_keys: set[str] = set()
+ matched_voucher_keys: set[str] = set()
+ promoted_rows: list[dict[str, Any]] = []
+
+ for _score, ledger_row, voucher_row, score_result in edge_candidates:
+ ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row)
+ voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
+ if ledger_key in matched_ledger_keys or voucher_key in matched_voucher_keys:
+ continue
+ merged = dict(ledger_row)
+ merged.update(
+ {
+ "proof_date": voucher_row.get("proof_date", ""),
+ "draft_no": voucher_row.get("draft_no", ""),
+ "voucher_account_code": voucher_row.get("voucher_account_code", ""),
+ "voucher_account_name": voucher_row.get("voucher_account_name", ""),
+ "voucher_vendor": voucher_row.get("voucher_vendor", ""),
+ "voucher_debit": voucher_row.get("voucher_debit", 0),
+ "voucher_credit": voucher_row.get("voucher_credit", 0),
+ "voucher_desc": voucher_row.get("voucher_desc", ""),
+ "voucher_row_key": voucher_row.get("voucher_row_key", ""),
+ "review_reason": "자동승격매칭",
+ "review_memo": "",
+ "match_identity_key": build_manual_pair_key(ledger_key, voucher_key),
+ }
+ )
+ promoted_rows.append(merged)
+ matched_ledger_keys.add(ledger_key)
+ matched_voucher_keys.add(voucher_key)
+
+ if not promoted_rows:
+ return sections
+
+ sections["matched"]["rows"].extend(promoted_rows)
+ sections["matched"]["count"] = len(sections["matched"]["rows"])
+ sections["ledger_only"]["rows"] = [
+ row for row in ledger_rows
+ if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in matched_ledger_keys
+ ]
+ sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"])
+ sections["voucher_only"]["rows"] = [
+ row for row in voucher_rows
+ if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys
+ ]
+ sections["voucher_only"]["count"] = len(sections["voucher_only"]["rows"])
+ return sections
+
+
+def _matched_row_has_voucher_payload(row: dict[str, Any]) -> bool:
+ return bool(
+ clean(row.get("draft_no"))
+ or clean(row.get("voucher_account_code"))
+ or clean(row.get("voucher_account_name"))
+ or clean(row.get("voucher_vendor"))
+ or clean(row.get("voucher_desc"))
+ or _get_row_match_amount(row, "voucher") > 0
+ )
+
+
+def _promote_cross_year_auto_matches(
+ sections: dict[str, dict[str, Any]],
+) -> dict[str, dict[str, Any]]:
+ ledger_rows = list(sections["ledger_only"]["rows"])
+ voucher_rows = list(sections["voucher_only"]["rows"])
+ if not ledger_rows or not voucher_rows:
+ return sections
+
+ amount_index, account_index = _build_amount_account_index(voucher_rows, prefix="voucher")
+ edge_candidates: list[tuple[float, dict[str, Any], dict[str, Any], dict[str, Any]]] = []
+
+ for ledger_row in ledger_rows:
+ ledger_year = int(ledger_row.get("fiscal_year") or 0)
+ if not ledger_year:
+ continue
+ candidate_amounts = {
+ round(parse_amount(ledger_row.get("ledger_debit")), 2),
+ round(parse_amount(ledger_row.get("ledger_credit")), 2),
+ }
+ seen_voucher_keys: set[str] = set()
+ for amount in candidate_amounts:
+ if amount <= 0:
+ continue
+ for voucher_row in _candidate_rows_for_amount_account(amount, ledger_row, amount_index, account_index):
+ voucher_year = int(voucher_row.get("fiscal_year") or 0)
+ if abs(ledger_year - voucher_year) != 1:
+ continue
+ voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
+ if voucher_key in seen_voucher_keys:
+ continue
+ seen_voucher_keys.add(voucher_key)
+ score_result = _score_pair_match(ledger_row, voucher_row)
+ probe = dict(ledger_row)
+ probe.update(
+ {
+ "proof_date": voucher_row.get("proof_date", ""),
+ "draft_no": voucher_row.get("draft_no", ""),
+ "voucher_account_code": voucher_row.get("voucher_account_code", ""),
+ "voucher_account_name": voucher_row.get("voucher_account_name", ""),
+ "voucher_vendor": voucher_row.get("voucher_vendor", ""),
+ "voucher_debit": voucher_row.get("voucher_debit", 0),
+ "voucher_credit": voucher_row.get("voucher_credit", 0),
+ "voucher_desc": voucher_row.get("voucher_desc", ""),
+ }
+ )
+ if not score_result.get("auto_eligible"):
+ continue
+ if not (_same_or_similar_desc(probe) or _same_or_similar_vendor(probe)):
+ continue
+ edge_candidates.append((float(score_result.get("score", 0)), ledger_row, voucher_row, score_result))
+
+ if not edge_candidates:
+ return sections
+
+ edge_candidates.sort(key=lambda item: item[0], reverse=True)
+ matched_ledger_keys: set[str] = set()
+ matched_voucher_keys: set[str] = set()
+ promoted_rows: list[dict[str, Any]] = []
+
+ for _score, ledger_row, voucher_row, _score_result in edge_candidates:
+ ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row)
+ voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
+ if ledger_key in matched_ledger_keys or voucher_key in matched_voucher_keys:
+ continue
+ merged = dict(ledger_row)
+ merged.update(
+ {
+ "proof_date": voucher_row.get("proof_date", ""),
+ "draft_no": voucher_row.get("draft_no", ""),
+ "voucher_account_code": voucher_row.get("voucher_account_code", ""),
+ "voucher_account_name": voucher_row.get("voucher_account_name", ""),
+ "voucher_vendor": voucher_row.get("voucher_vendor", ""),
+ "voucher_debit": voucher_row.get("voucher_debit", 0),
+ "voucher_credit": voucher_row.get("voucher_credit", 0),
+ "voucher_desc": voucher_row.get("voucher_desc", ""),
+ "voucher_row_key": voucher_row.get("voucher_row_key", ""),
+ "review_reason": "연도교차자동매칭",
+ "matched_case": "cross_year",
+ "review_memo": "",
+ "match_identity_key": build_manual_pair_key(ledger_key, voucher_key),
+ }
+ )
+ promoted_rows.append(merged)
+ matched_ledger_keys.add(ledger_key)
+ matched_voucher_keys.add(voucher_key)
+
+ if not promoted_rows:
+ return sections
+
+ sections["matched"]["rows"].extend(promoted_rows)
+ sections["matched"]["count"] = len(sections["matched"]["rows"])
+ sections["ledger_only"]["rows"] = [
+ row for row in ledger_rows
+ if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in matched_ledger_keys
+ ]
+ sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"])
+ sections["voucher_only"]["rows"] = [
+ row for row in voucher_rows
+ if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys
+ ]
+ sections["voucher_only"]["count"] = len(sections["voucher_only"]["rows"])
+ return sections
+
+
+def _apply_previous_year_erp_candidates(
+ conn: Any,
+ year: int,
+ sections: dict[str, dict[str, Any]],
+) -> dict[str, dict[str, Any]]:
+ if year <= 0:
+ return sections
+ ledger_rows = list(sections["ledger_only"]["rows"])
+ if not ledger_rows:
+ return sections
+
+ available_years = set(_discover_available_fiscal_years(conn))
+ prior_years = sorted((candidate_year for candidate_year in available_years if candidate_year < year), reverse=True)
+ next_years = sorted(candidate_year for candidate_year in available_years if candidate_year > year)
+ candidate_years = prior_years + next_years
+ if not candidate_years:
+ return sections
+
+ def jan1_row_is_excluded(row: dict[str, Any]) -> bool:
+ return _extract_boundary_month_day(row.get("ledger_date"), row.get("fiscal_year")) == "01-01" and _row_has_earlier_fiscal_year_source(conn, row)
+
+ adjacent_voucher_rows: list[dict[str, Any]] = []
+ for candidate_year in candidate_years:
+ payload = _fetch_status_detail_rows_from_db(
+ conn,
+ candidate_year,
+ candidate_year,
+ "voucher_only",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ False,
+ 0,
+ 1_000_000,
+ )
+ adjacent_voucher_rows.extend(list(payload.get("rows", [])))
+ if not adjacent_voucher_rows:
+ return sections
+
+ def remaining_matched_voucher_keys() -> set[str]:
+ return {
+ clean(row.get("voucher_row_key")) or build_voucher_row_key(row)
+ for row in sections["matched"]["rows"]
+ if clean(row.get("voucher_row_key")) or build_voucher_row_key(row)
+ }
+
+ def remaining_matched_ledger_keys() -> set[str]:
+ return {
+ clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
+ for row in sections["matched"]["rows"]
+ if clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
+ }
+
+ def apply_adjacent_candidates(eligible_ledger_rows: list[dict[str, Any]], *, boundary_phase: bool) -> list[dict[str, Any]]:
+ if not eligible_ledger_rows:
+ return []
+ matched_ledger_keys = remaining_matched_ledger_keys()
+ matched_voucher_keys = remaining_matched_voucher_keys()
+ usable_voucher_rows = [
+ row
+ for row in adjacent_voucher_rows
+ if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys
+ ]
+ if not usable_voucher_rows:
+ return []
+
+ amount_index, account_index = _build_amount_account_index(usable_voucher_rows, prefix="voucher")
+ edge_candidates: list[tuple[float, dict[str, Any], dict[str, Any], dict[str, Any]]] = []
+
+ for ledger_row in eligible_ledger_rows:
+ ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row)
+ if not ledger_key or ledger_key in matched_ledger_keys:
+ continue
+ if jan1_row_is_excluded(ledger_row):
+ continue
+ if boundary_phase != _is_boundary_substitution_row(ledger_row):
+ continue
+ candidate_amounts = {
+ round(parse_amount(ledger_row.get("ledger_debit")), 2),
+ round(parse_amount(ledger_row.get("ledger_credit")), 2),
+ }
+ seen_voucher_keys: set[str] = set()
+ for amount in candidate_amounts:
+ if amount <= 0:
+ continue
+ for voucher_row in _candidate_rows_for_amount_account(
+ amount,
+ ledger_row,
+ amount_index,
+ account_index,
+ ):
+ voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
+ if not voucher_key or voucher_key in matched_voucher_keys or voucher_key in seen_voucher_keys:
+ continue
+ seen_voucher_keys.add(voucher_key)
+ score_result = _score_pair_match(ledger_row, voucher_row)
+ probe = dict(ledger_row)
+ probe.update(
+ {
+ "proof_date": voucher_row.get("proof_date", ""),
+ "draft_no": voucher_row.get("draft_no", ""),
+ "voucher_account_code": voucher_row.get("voucher_account_code", ""),
+ "voucher_account_name": voucher_row.get("voucher_account_name", ""),
+ "voucher_vendor": voucher_row.get("voucher_vendor", ""),
+ "voucher_debit": voucher_row.get("voucher_debit", 0),
+ "voucher_credit": voucher_row.get("voucher_credit", 0),
+ "voucher_desc": voucher_row.get("voucher_desc", ""),
+ }
+ )
+ if boundary_phase != _is_boundary_substitution_row(probe):
+ continue
+ if not score_result.get("auto_eligible"):
+ continue
+ if not (_same_or_similar_desc(probe) or _same_or_similar_vendor(probe)):
+ continue
+ edge_candidates.append((float(score_result.get("score", 0)), ledger_row, voucher_row, score_result))
+
+ if not edge_candidates:
+ return []
+
+ edge_candidates.sort(key=lambda item: item[0], reverse=True)
+ phase_matched_ledger_keys: set[str] = set()
+ phase_matched_voucher_keys: set[str] = set()
+ promoted_rows: list[dict[str, Any]] = []
+
+ for _score, ledger_row, voucher_row, _score_result in edge_candidates:
+ ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row)
+ voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
+ if (
+ ledger_key in matched_ledger_keys
+ or voucher_key in matched_voucher_keys
+ or ledger_key in phase_matched_ledger_keys
+ or voucher_key in phase_matched_voucher_keys
+ ):
+ continue
+ voucher_year = int(voucher_row.get("fiscal_year") or 0)
+ review_reason = "전기ERP자동매칭" if voucher_year < year else "차기ERP자동매칭"
+ matched_case = "previous_year_erp" if voucher_year < year else "next_year_erp"
+ merged = dict(ledger_row)
+ merged.update(
+ {
+ "proof_date": voucher_row.get("proof_date", ""),
+ "draft_no": voucher_row.get("draft_no", ""),
+ "voucher_account_code": voucher_row.get("voucher_account_code", ""),
+ "voucher_account_name": voucher_row.get("voucher_account_name", ""),
+ "voucher_vendor": voucher_row.get("voucher_vendor", ""),
+ "voucher_debit": voucher_row.get("voucher_debit", 0),
+ "voucher_credit": voucher_row.get("voucher_credit", 0),
+ "voucher_desc": voucher_row.get("voucher_desc", ""),
+ "voucher_row_key": voucher_row.get("voucher_row_key", ""),
+ "review_reason": review_reason,
+ "matched_case": matched_case,
+ "review_memo": "",
+ "match_identity_key": build_manual_pair_key(ledger_key, voucher_key),
+ }
+ )
+ promoted_rows.append(merged)
+ phase_matched_ledger_keys.add(ledger_key)
+ phase_matched_voucher_keys.add(voucher_key)
+
+ if promoted_rows:
+ sections["matched"]["rows"].extend(promoted_rows)
+ sections["matched"]["count"] = len(sections["matched"]["rows"])
+ remaining_keys = remaining_matched_ledger_keys()
+ ledger_rows[:] = [
+ row
+ for row in ledger_rows
+ if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in remaining_keys
+ ]
+ sections["ledger_only"]["rows"] = ledger_rows
+ sections["ledger_only"]["count"] = len(ledger_rows)
+ return promoted_rows
+
+ def build_adjacent_review_rows(eligible_ledger_rows: list[dict[str, Any]], *, boundary_phase: bool) -> list[dict[str, Any]]:
+ matched_voucher_keys = remaining_matched_voucher_keys()
+ matched_ledger_keys = remaining_matched_ledger_keys()
+ review_voucher_rows = [
+ row
+ for row in adjacent_voucher_rows
+ if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys
+ ]
+ review_ledger_rows = [
+ row
+ for row in eligible_ledger_rows
+ if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in matched_ledger_keys
+ and (_is_boundary_substitution_row(row) == boundary_phase)
+ and not jan1_row_is_excluded(row)
+ ]
+ if not review_ledger_rows or not review_voucher_rows:
+ return []
+ existing_pairs = {
+ (
+ clean(row.get("ledger_row_key")) or build_ledger_row_key(row),
+ clean(row.get("voucher_row_key")) or build_voucher_row_key(row),
+ )
+ for row in sections["amount_mismatch"]["rows"]
+ }
+ review_rows = []
+ review_rows.extend(_build_substitution_review_rows(review_ledger_rows, review_voucher_rows, existing_pairs))
+ existing_pairs.update(
+ {
+ (
+ clean(row.get("ledger_row_key")) or build_ledger_row_key(row),
+ clean(row.get("voucher_row_key")) or build_voucher_row_key(row),
+ )
+ for row in review_rows
+ }
+ )
+ review_rows.extend(_build_quality_review_rows(review_ledger_rows, review_voucher_rows, existing_pairs))
+ return review_rows
+
+ # 1) 일반 전표를 먼저 인접 연도 ERP와 비교
+ apply_adjacent_candidates(ledger_rows, boundary_phase=False)
+ normal_review_rows = build_adjacent_review_rows(ledger_rows, boundary_phase=False)
+ if normal_review_rows:
+ review_ledger_keys = {
+ clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
+ for row in normal_review_rows
+ }
+ sections["amount_mismatch"]["rows"].extend(normal_review_rows)
+ sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"])
+ sections["ledger_only"]["rows"] = [
+ row
+ for row in sections["ledger_only"]["rows"]
+ if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in review_ledger_keys
+ ]
+ sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"])
+ ledger_rows = list(sections["ledger_only"]["rows"])
+
+ # 2) 연초/연말 대체성 전표는 최후 순서로만 인접 연도 ERP와 비교
+ apply_adjacent_candidates(ledger_rows, boundary_phase=True)
+ boundary_review_rows = build_adjacent_review_rows(ledger_rows, boundary_phase=True)
+ if boundary_review_rows:
+ review_ledger_keys = {
+ clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
+ for row in boundary_review_rows
+ }
+ sections["amount_mismatch"]["rows"].extend(boundary_review_rows)
+ sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"])
+ sections["ledger_only"]["rows"] = [
+ row
+ for row in sections["ledger_only"]["rows"]
+ if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in review_ledger_keys
+ ]
+ sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"])
+ return sections
+
+
+def _matched_signature_set(rows: list[dict[str, Any]]) -> set[str]:
+ signatures: set[str] = set()
+ for row in rows:
+ parts = [
+ clean(row.get("voucher_no")),
+ clean(row.get("draft_no")),
+ normalize_text(row.get("ledger_vendor")),
+ normalize_text(row.get("voucher_vendor")),
+ normalize_text(row.get("ledger_desc")),
+ normalize_text(row.get("voucher_desc")),
+ clean(row.get("ledger_account_name")),
+ clean(row.get("voucher_account_name")),
+ f"{_get_row_match_amount(row, 'ledger'):.2f}",
+ f"{_get_row_match_amount(row, 'voucher'):.2f}",
+ ]
+ signatures.add("|".join(parts))
+ return signatures
+
+
+def _drop_recheck_rows_covered_by_matched(
+ sections: dict[str, dict[str, Any]],
+) -> dict[str, dict[str, Any]]:
+ matched_signatures = _matched_signature_set(sections["matched"]["rows"])
+ matched_ledger_keys = {
+ clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
+ for row in sections["matched"]["rows"]
+ if clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
+ }
+ matched_voucher_keys = {
+ clean(row.get("voucher_row_key")) or build_voucher_row_key(row)
+ for row in sections["matched"]["rows"]
+ if clean(row.get("voucher_row_key")) or build_voucher_row_key(row)
+ }
+ if not matched_signatures and not matched_ledger_keys and not matched_voucher_keys:
+ return sections
+ remaining_rows: list[dict[str, Any]] = []
+ for row in sections["amount_mismatch"]["rows"]:
+ ledger_key = clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
+ voucher_key = clean(row.get("voucher_row_key")) or build_voucher_row_key(row)
+ if (ledger_key and ledger_key in matched_ledger_keys) or (voucher_key and voucher_key in matched_voucher_keys):
+ continue
+ signature = "|".join(
+ [
+ clean(row.get("voucher_no")),
+ clean(row.get("draft_no")),
+ normalize_text(row.get("ledger_vendor")),
+ normalize_text(row.get("voucher_vendor")),
+ normalize_text(row.get("ledger_desc")),
+ normalize_text(row.get("voucher_desc")),
+ clean(row.get("ledger_account_name")),
+ clean(row.get("voucher_account_name")),
+ f"{_get_row_match_amount(row, 'ledger'):.2f}",
+ f"{_get_row_match_amount(row, 'voucher'):.2f}",
+ ]
+ )
+ if signature in matched_signatures:
+ continue
+ remaining_rows.append(row)
+ sections["amount_mismatch"]["rows"] = remaining_rows
+ sections["amount_mismatch"]["count"] = len(remaining_rows)
+ return sections
+
+
+def _append_substitution_review_rows(
+ sections: dict[str, dict[str, Any]],
+) -> dict[str, dict[str, Any]]:
+ sections = _rebalance_matched_rows_for_review(sections)
+ sections = _promote_recheck_rows_to_matched(sections)
+ if not ENABLE_GENERATED_RECHECK_CANDIDATES:
+ return _drop_recheck_rows_covered_by_matched(sections)
+
+ sections = _promote_direct_auto_matches(sections)
+ amount_rows = sections["amount_mismatch"]["rows"]
+ existing_pairs = {
+ (
+ clean(row.get("ledger_row_key")) or build_ledger_row_key(row),
+ clean(row.get("voucher_row_key")) or build_voucher_row_key(row),
+ )
+ for row in amount_rows
+ }
+
+ matched_rows = list(sections["matched"]["rows"])
+ ledger_only_rows = list(sections["ledger_only"]["rows"])
+ voucher_only_rows = list(sections["voucher_only"]["rows"])
+
+ synthetic_rows = []
+ synthetic_rows.extend(
+ _build_substitution_review_rows(matched_rows + ledger_only_rows, voucher_only_rows, existing_pairs)
+ )
+ existing_pairs.update(
+ {
+ (
+ clean(row.get("ledger_row_key")) or build_ledger_row_key(row),
+ clean(row.get("voucher_row_key")) or build_voucher_row_key(row),
+ )
+ for row in synthetic_rows
+ }
+ )
+ synthetic_rows.extend(
+ _build_substitution_review_rows(ledger_only_rows, matched_rows + voucher_only_rows, existing_pairs)
+ )
+ existing_pairs.update(
+ {
+ (
+ clean(row.get("ledger_row_key")) or build_ledger_row_key(row),
+ clean(row.get("voucher_row_key")) or build_voucher_row_key(row),
+ )
+ for row in synthetic_rows
+ }
+ )
+ synthetic_rows.extend(
+ _build_quality_review_rows(ledger_only_rows, voucher_only_rows, existing_pairs)
+ )
+ if not synthetic_rows:
+ sections = _promote_recheck_rows_to_matched(sections)
+ return _drop_recheck_rows_covered_by_matched(sections)
+
+ sections["amount_mismatch"]["rows"].extend(synthetic_rows)
+ sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"])
+ sections = _promote_recheck_rows_to_matched(sections)
+ return _drop_recheck_rows_covered_by_matched(sections)
+
+
def _numeric_amount_for_side(row: dict[str, Any], side: str) -> float:
if side == "debit":
return parse_amount(row.get("ledger_debit") if "ledger_debit" in row else row.get("voucher_debit"))
@@ -2452,25 +4982,73 @@ def _determine_primary_side(ledger_row: dict[str, Any]) -> str:
def _score_pair_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> dict[str, Any]:
+ ledger_features = _build_match_row_features(
+ ledger_row,
+ prefix="ledger",
+ row_key_field="ledger_row_key",
+ account_code_field="ledger_account_code",
+ account_name_field="ledger_account_name",
+ vendor_field="ledger_vendor",
+ desc_field="ledger_desc",
+ date_field="ledger_date",
+ )
+ voucher_features = _build_match_row_features(
+ voucher_row,
+ prefix="voucher",
+ row_key_field="voucher_row_key",
+ account_code_field="voucher_account_code",
+ account_name_field="voucher_account_name",
+ vendor_field="voucher_vendor",
+ desc_field="voucher_desc",
+ date_field="proof_date",
+ )
+ return _score_pair_match_features(ledger_features, voucher_features)
+
+
+def _score_pair_match_features(ledger: MatchRowFeatures, voucher: MatchRowFeatures) -> dict[str, Any]:
score = 0.0
reasons: list[str] = []
hard_pass = True
+ account_supported = False
- side = _determine_primary_side(ledger_row)
- if side == "debit":
- ledger_amount = parse_amount(ledger_row.get("ledger_debit"))
- voucher_amount = parse_amount(voucher_row.get("voucher_debit"))
- elif side == "credit":
- ledger_amount = parse_amount(ledger_row.get("ledger_credit"))
- voucher_amount = parse_amount(voucher_row.get("voucher_credit"))
- else:
- ledger_amount = max(parse_amount(ledger_row.get("ledger_debit")), parse_amount(ledger_row.get("ledger_credit")))
- voucher_amount = max(parse_amount(voucher_row.get("voucher_debit")), parse_amount(voucher_row.get("voucher_credit")))
-
- amount_gap = abs(ledger_amount - voucher_amount)
+ side = ledger.primary_side
+ ledger_amount = _get_feature_side_amount(ledger, side)
+ voucher_same_side_amount = _get_feature_side_amount(voucher, side)
+ voucher_any_side_amount = voucher.match_amount
+ amount_gap_same_side = abs(ledger_amount - voucher_same_side_amount)
+ amount_gap_any_side = abs(ledger_amount - voucher_any_side_amount)
+ amount_gap = min(amount_gap_same_side, amount_gap_any_side)
+ side_flipped_match = amount_gap_any_side < 0.5 and amount_gap_same_side >= 0.5
+ nature_same_side_ok = _nature_compatible(
+ ledger.account_code,
+ ledger.account_name,
+ side,
+ voucher.account_code,
+ voucher.account_name,
+ side,
+ )
+ opposite_side = "credit" if side == "debit" else "debit" if side == "credit" else "either"
+ nature_opposite_side_ok = _nature_compatible(
+ ledger.account_code,
+ ledger.account_name,
+ side,
+ voucher.account_code,
+ voucher.account_name,
+ opposite_side,
+ ) if opposite_side in {"debit", "credit"} else False
+ family_pair_allowed = _account_category_pair_allowed(
+ ledger.account_code,
+ ledger.account_name,
+ voucher.account_code,
+ voucher.account_name,
+ )
+ ledger_family = _classify_account_family(ledger.account_code, ledger.account_name)
+ voucher_family = _classify_account_family(voucher.account_code, voucher.account_name)
if amount_gap < 0.5 and ledger_amount > 0:
score += 50
reasons.append("금액 일치")
+ if side_flipped_match:
+ reasons.append("차대 방향 보정")
elif amount_gap < 5 and ledger_amount > 0:
score += 35
reasons.append("금액 근접")
@@ -2480,26 +5058,81 @@ def _score_pair_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -
else:
hard_pass = False
- ledger_code = clean(ledger_row.get("ledger_account_code"))
- voucher_code = clean(voucher_row.get("voucher_account_code"))
- account_sim = _jaccard_similarity(ledger_row.get("ledger_account_name"), voucher_row.get("voucher_account_name"))
- if ledger_code and voucher_code and ledger_code == voucher_code:
- score += 22
- reasons.append("계정코드 일치")
- elif ledger_code and voucher_code and ledger_code[:4] == voucher_code[:4]:
- score += 10
- reasons.append("계정코드 대분류 일치")
+ if not family_pair_allowed:
+ hard_pass = False
+ score -= 60
+ reasons.append("계정 성격 교차 불가")
+
+ if side in {"debit", "credit"}:
+ if amount_gap_same_side < 0.5 and not nature_same_side_ok:
+ hard_pass = False
+ score -= 40
+ reasons.append("차대 성격 불일치")
+ elif side_flipped_match:
+ # 같은 금액이어도 반대 방향이면 원칙적으로 다른 거래 성격으로 본다.
+ if not nature_opposite_side_ok:
+ hard_pass = False
+ score -= 45
+ reasons.append("차대 방향 성격 불일치")
+
+ substitution_hint = _build_account_substitution_hint(
+ ledger.account_code,
+ ledger.account_name,
+ voucher.account_code,
+ voucher.account_name,
+ )
+ account_sim = _jaccard_similarity_tokens(ledger.account_tokens, voucher.account_tokens)
+ bank_pair = ledger_family == "bank" and voucher_family == "bank"
+ if ledger.account_code and voucher.account_code and ledger.account_code == voucher.account_code:
+ score += 4 if bank_pair else 22
+ reasons.append("보통예금 코드 일치" if bank_pair else "계정코드 일치")
+ account_supported = True
+ elif ledger.account_code and voucher.account_code and ledger.account_code[:4] == voucher.account_code[:4]:
+ score += 2 if bank_pair else 10
+ reasons.append("보통예금 대분류 일치" if bank_pair else "계정코드 대분류 일치")
+ account_supported = True
+ elif substitution_hint:
+ score += 12
+ reasons.append(substitution_hint)
+ account_supported = True
else:
if account_sim >= 0.8:
- score += 16
- reasons.append("계정명 유사도 높음")
+ score += 3 if bank_pair else 16
+ reasons.append("보통예금 계정명 유사" if bank_pair else "계정명 유사도 높음")
+ account_supported = True
elif account_sim >= 0.55:
- score += 8
- reasons.append("계정명 유사")
+ score += 2 if bank_pair else 8
+ reasons.append("보통예금 계정명 유사" if bank_pair else "계정명 유사")
+ account_supported = True
+ elif _account_base_names_compatible(ledger.account_name, voucher.account_name):
+ score += 2 if bank_pair else 12
+ reasons.append("보통예금 핵심 일치" if bank_pair else "계정명 핵심 일치")
+ account_supported = True
else:
- hard_pass = False
+ if ledger_family and ledger_family == voucher_family:
+ score += 2 if bank_pair else 10
+ reasons.append("보통예금 계정군 일치" if bank_pair else "계정군 일치")
+ account_supported = True
- vendor_sim = _jaccard_similarity(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_vendor"))
+ vendor_sim = _jaccard_similarity_tokens(ledger.vendor_tokens, voucher.vendor_tokens)
+ row_for_text = {
+ "ledger_account_code": ledger.account_code,
+ "ledger_account_name": ledger.account_name,
+ "ledger_vendor": ledger.vendor_name,
+ "ledger_desc": ledger.desc_text,
+ "voucher_account_code": voucher.account_code,
+ "voucher_account_name": voucher.account_name,
+ "voucher_vendor": voucher.vendor_name,
+ "voucher_desc": voucher.desc_text,
+ }
+ account_strong = bool(
+ (ledger.account_code and voucher.account_code and ledger.account_code == voucher.account_code)
+ or substitution_hint
+ or account_sim >= 0.8
+ or _account_names_compatible(row_for_text, row_for_text)
+ )
+ vendor_core_match = _same_or_similar_vendor(row_for_text)
+ desc_core_match = _same_or_similar_desc(row_for_text)
if vendor_sim >= 0.9:
score += 16
reasons.append("거래처 일치")
@@ -2512,7 +5145,7 @@ def _score_pair_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -
else:
score -= 8
- desc_sim = _jaccard_similarity(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc"))
+ desc_sim = _jaccard_similarity_tokens(ledger.desc_tokens, voucher.desc_tokens)
if desc_sim >= 0.85:
score += 10
reasons.append("적요 매우 유사")
@@ -2522,19 +5155,54 @@ def _score_pair_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -
elif desc_sim >= 0.35:
score += 2
- ledger_date = _parse_iso_date(ledger_row.get("ledger_date"))
- proof_date = _parse_iso_date(voucher_row.get("proof_date"))
- if ledger_date and proof_date:
- day_gap = abs((ledger_date - proof_date).days)
- if day_gap <= 3:
+ month_conflict = _has_conflicting_month_token_sets(
+ ledger.month_tokens,
+ voucher.month_tokens,
+ ) or _date_tokens_conflict(ledger.desc_text, voucher.desc_text)
+
+ if substitution_hint and amount_gap < 0.5:
+ if desc_sim >= 0.85:
score += 8
- reasons.append("일자 근접")
- elif day_gap <= 10:
+ reasons.append("대체 적요 일치")
+ elif desc_sim >= 0.6:
score += 4
- elif day_gap <= 45:
- score += 1
+ reasons.append("대체 적요 유사")
+
+ strong_text_match = _is_strong_text_match(amount_gap, vendor_sim, desc_sim, month_conflict)
+ if strong_text_match:
+ score += 10
+ reasons.append("적요/거래처 강한 일치")
+ elif not account_supported:
+ hard_pass = False
+
+ if ledger.date_value and voucher.date_value:
+ day_gap = abs((ledger.date_value - voucher.date_value).days)
+ month_gap = _month_gap(ledger.date_value, voucher.date_value)
+ if strong_text_match:
+ if day_gap <= 7:
+ score += 8
+ reasons.append("일자 근접")
+ elif day_gap <= 31:
+ score += 5
+ reasons.append("일자 차이 허용")
+ elif month_gap <= PAIR_RECOMMEND_DATE_WINDOW_MONTHS:
+ score += 2
+ reasons.append("전후 8개월 내 일자 차이")
+ else:
+ score -= 6
+ reasons.append("전후 8개월 초과")
else:
- score -= 6
+ if day_gap <= 3:
+ score += 8
+ reasons.append("일자 근접")
+ elif day_gap <= 10:
+ score += 4
+ elif month_gap <= PAIR_RECOMMEND_DATE_WINDOW_MONTHS:
+ score += 1
+ reasons.append("전후 8개월 내 일자 차이")
+ else:
+ score -= 6
+ reasons.append("전후 8개월 초과")
confidence = "low"
if score >= 88:
@@ -2544,13 +5212,14 @@ def _score_pair_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -
auto_eligible = bool(
hard_pass
- and score >= 88
and amount_gap < 0.5
+ and not side_flipped_match
+ and not month_conflict
and (
- (ledger_code and voucher_code and ledger_code == voucher_code)
- or _jaccard_similarity(ledger_row.get("ledger_account_name"), voucher_row.get("voucher_account_name")) >= 0.8
+ (score >= 82 and strong_text_match and vendor_sim >= 0.65)
+ or (score >= 72 and account_strong and vendor_core_match)
+ or (score >= 68 and account_strong and desc_core_match)
)
- and vendor_sim >= 0.65
)
return {
"score": round(score, 2),
@@ -2560,7 +5229,11 @@ def _score_pair_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -
"hard_pass": hard_pass,
"vendor_similarity": round(vendor_sim, 4),
"account_similarity": round(account_sim, 4),
+ "desc_similarity": round(desc_sim, 4),
"amount_gap": round(amount_gap, 2),
+ "month_conflict": month_conflict,
+ "strong_text_match": strong_text_match,
+ "substitution_hint": substitution_hint,
}
@@ -2613,7 +5286,317 @@ def _collect_status_rows_for_workbench(
return result
-def recommend_pair_matches(
+def _build_pair_recommend_cache_key(
+ start_year: int | None,
+ end_year: int | None,
+ ledger_voucher_no: str = "",
+ ledger_review_reason: str = "",
+ voucher_voucher_no: str = "",
+ voucher_review_reason: str = "",
+ limit: int = 300,
+) -> str:
+ safe_limit = max(min(int(limit or 300), 1000), 1)
+ return "|".join(
+ [
+ PAIR_RECOMMEND_POLICY_VERSION,
+ str(start_year),
+ str(end_year),
+ normalize_text(ledger_voucher_no),
+ normalize_text(ledger_review_reason),
+ normalize_text(voucher_voucher_no),
+ normalize_text(voucher_review_reason),
+ str(safe_limit),
+ ]
+ )
+
+
+def _delete_stale_pair_recommend_cache(conn: Any) -> None:
+ conn.execute(
+ text(
+ """
+ DELETE FROM wehago_pair_recommend_cache
+ WHERE strftime('%s', 'now') - strftime('%s', updated_at) > :ttl
+ """
+ ),
+ {"ttl": int(_PAIR_RECOMMEND_PERSIST_TTL_SEC)},
+ )
+
+
+def _load_persisted_pair_recommend_cache(conn: Any, cache_key: str) -> dict[str, Any] | None:
+ row = conn.execute(
+ text(
+ """
+ SELECT payload_json
+ FROM wehago_pair_recommend_cache
+ WHERE cache_key = :cache_key
+ AND strftime('%s', 'now') - strftime('%s', updated_at) <= :ttl
+ """
+ ),
+ {"cache_key": cache_key, "ttl": int(_PAIR_RECOMMEND_PERSIST_TTL_SEC)},
+ ).mappings().first()
+ if not row:
+ return None
+ try:
+ payload = json.loads(row["payload_json"] or "{}")
+ except json.JSONDecodeError:
+ return None
+ conn.execute(
+ text(
+ """
+ UPDATE wehago_pair_recommend_cache
+ SET last_accessed_at = CURRENT_TIMESTAMP
+ WHERE cache_key = :cache_key
+ """
+ ),
+ {"cache_key": cache_key},
+ )
+ return payload if isinstance(payload, dict) else None
+
+
+def _store_persisted_pair_recommend_cache(
+ conn: Any,
+ cache_key: str,
+ payload: dict[str, Any],
+ *,
+ start_year: int | None,
+ end_year: int | None,
+ ledger_voucher_no: str,
+ ledger_review_reason: str,
+ voucher_voucher_no: str,
+ voucher_review_reason: str,
+ limit: int,
+) -> None:
+ safe_limit = max(min(int(limit or 300), 1000), 1)
+ payload_json = json.dumps(payload, ensure_ascii=False)
+ pair_count = len(payload.get("pairs") or [])
+ conn.execute(
+ text(
+ """
+ INSERT INTO wehago_pair_recommend_cache (
+ cache_key, start_year, end_year,
+ ledger_voucher_no, ledger_review_reason, voucher_voucher_no, voucher_review_reason,
+ row_limit, payload_json, pair_count, created_at, updated_at, last_accessed_at
+ ) VALUES (
+ :cache_key, :start_year, :end_year,
+ :ledger_voucher_no, :ledger_review_reason, :voucher_voucher_no, :voucher_review_reason,
+ :row_limit, :payload_json, :pair_count, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+ )
+ ON CONFLICT(cache_key) DO UPDATE SET
+ start_year = excluded.start_year,
+ end_year = excluded.end_year,
+ ledger_voucher_no = excluded.ledger_voucher_no,
+ ledger_review_reason = excluded.ledger_review_reason,
+ voucher_voucher_no = excluded.voucher_voucher_no,
+ voucher_review_reason = excluded.voucher_review_reason,
+ row_limit = excluded.row_limit,
+ payload_json = excluded.payload_json,
+ pair_count = excluded.pair_count,
+ updated_at = CURRENT_TIMESTAMP,
+ last_accessed_at = CURRENT_TIMESTAMP
+ """
+ ),
+ {
+ "cache_key": cache_key,
+ "start_year": start_year,
+ "end_year": end_year,
+ "ledger_voucher_no": clean(ledger_voucher_no),
+ "ledger_review_reason": clean(ledger_review_reason),
+ "voucher_voucher_no": clean(voucher_voucher_no),
+ "voucher_review_reason": clean(voucher_review_reason),
+ "row_limit": safe_limit,
+ "payload_json": payload_json,
+ "pair_count": pair_count,
+ },
+ )
+
+
+def clear_persisted_pair_recommend_cache(engine: Any) -> None:
+ init_wehago_compare_db(engine)
+ with engine.begin() as conn:
+ conn.execute(text("DELETE FROM wehago_pair_recommend_cache"))
+ conn.execute(text("DELETE FROM wehago_background_jobs WHERE job_type = 'pair_recommend_precompute'"))
+
+
+def _ensure_pair_recommend_worker(engine: Any) -> None:
+ global _PAIR_RECOMMEND_WORKER_STARTED
+ with _PAIR_RECOMMEND_WORKER_LOCK:
+ if _PAIR_RECOMMEND_WORKER_STARTED:
+ return
+ worker = threading.Thread(
+ target=_pair_recommend_worker_loop,
+ args=(engine,),
+ daemon=True,
+ name="wehago-pair-recommend-worker",
+ )
+ worker.start()
+ _PAIR_RECOMMEND_WORKER_STARTED = True
+
+
+def enqueue_pair_recommend_precompute(
+ engine: Any,
+ start_year: int | None,
+ end_year: int | None,
+ ledger_voucher_no: str = "",
+ ledger_review_reason: str = "",
+ voucher_voucher_no: str = "",
+ voucher_review_reason: str = "",
+ limit: int = 300,
+) -> None:
+ safe_limit = max(min(int(limit or 300), 1000), 1)
+ cache_key = _build_pair_recommend_cache_key(
+ start_year,
+ end_year,
+ ledger_voucher_no=ledger_voucher_no,
+ ledger_review_reason=ledger_review_reason,
+ voucher_voucher_no=voucher_voucher_no,
+ voucher_review_reason=voucher_review_reason,
+ limit=safe_limit,
+ )
+ payload = {
+ "cache_key": cache_key,
+ "start_year": start_year,
+ "end_year": end_year,
+ "ledger_voucher_no": clean(ledger_voucher_no),
+ "ledger_review_reason": clean(ledger_review_reason),
+ "voucher_voucher_no": clean(voucher_voucher_no),
+ "voucher_review_reason": clean(voucher_review_reason),
+ "limit": safe_limit,
+ }
+ init_wehago_compare_db(engine)
+ with engine.begin() as conn:
+ _delete_stale_pair_recommend_cache(conn)
+ existing_payload = _load_persisted_pair_recommend_cache(conn, cache_key)
+ if existing_payload is not None:
+ return
+ existing_job = conn.execute(
+ text(
+ """
+ SELECT state
+ FROM wehago_background_jobs
+ WHERE job_key = :job_key
+ AND job_type = 'pair_recommend_precompute'
+ """
+ ),
+ {"job_key": cache_key},
+ ).mappings().first()
+ if existing_job and clean(existing_job.get("state")) in {"queued", "running"}:
+ return
+ conn.execute(
+ text(
+ """
+ INSERT INTO wehago_background_jobs (
+ job_key, job_type, payload_json, state, error_message, created_at, updated_at
+ ) VALUES (
+ :job_key, 'pair_recommend_precompute', :payload_json, 'queued', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+ )
+ ON CONFLICT(job_key) DO UPDATE SET
+ payload_json = excluded.payload_json,
+ state = CASE
+ WHEN wehago_background_jobs.state = 'running' THEN wehago_background_jobs.state
+ ELSE 'queued'
+ END,
+ error_message = '',
+ updated_at = CURRENT_TIMESTAMP
+ """
+ ),
+ {"job_key": cache_key, "payload_json": json.dumps(payload, ensure_ascii=False)},
+ )
+ _ensure_pair_recommend_worker(engine)
+ _PAIR_RECOMMEND_JOB_EVENT.set()
+
+
+def _pair_recommend_worker_loop(engine: Any) -> None:
+ while True:
+ _PAIR_RECOMMEND_JOB_EVENT.wait(timeout=5.0)
+ _PAIR_RECOMMEND_JOB_EVENT.clear()
+ while True:
+ init_wehago_compare_db(engine)
+ with engine.begin() as conn:
+ job = conn.execute(
+ text(
+ """
+ SELECT job_key, payload_json
+ FROM wehago_background_jobs
+ WHERE job_type = 'pair_recommend_precompute'
+ AND state = 'queued'
+ ORDER BY created_at ASC
+ LIMIT 1
+ """
+ )
+ ).mappings().first()
+ if not job:
+ break
+ conn.execute(
+ text(
+ """
+ UPDATE wehago_background_jobs
+ SET state = 'running',
+ error_message = '',
+ started_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE job_key = :job_key
+ """
+ ),
+ {"job_key": job["job_key"]},
+ )
+ try:
+ payload = json.loads(job["payload_json"] or "{}")
+ if not isinstance(payload, dict):
+ payload = {}
+ result = _compute_pair_recommendations_payload(
+ engine,
+ start_year=payload.get("start_year"),
+ end_year=payload.get("end_year"),
+ ledger_voucher_no=payload.get("ledger_voucher_no", ""),
+ ledger_review_reason=payload.get("ledger_review_reason", ""),
+ voucher_voucher_no=payload.get("voucher_voucher_no", ""),
+ voucher_review_reason=payload.get("voucher_review_reason", ""),
+ limit=int(payload.get("limit") or 300),
+ )
+ with engine.begin() as conn:
+ _store_persisted_pair_recommend_cache(
+ conn,
+ payload.get("cache_key") or job["job_key"],
+ result,
+ start_year=payload.get("start_year"),
+ end_year=payload.get("end_year"),
+ ledger_voucher_no=payload.get("ledger_voucher_no", ""),
+ ledger_review_reason=payload.get("ledger_review_reason", ""),
+ voucher_voucher_no=payload.get("voucher_voucher_no", ""),
+ voucher_review_reason=payload.get("voucher_review_reason", ""),
+ limit=int(payload.get("limit") or 300),
+ )
+ conn.execute(
+ text(
+ """
+ UPDATE wehago_background_jobs
+ SET state = 'done',
+ error_message = '',
+ finished_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE job_key = :job_key
+ """
+ ),
+ {"job_key": job["job_key"]},
+ )
+ except Exception as exc:
+ with engine.begin() as conn:
+ conn.execute(
+ text(
+ """
+ UPDATE wehago_background_jobs
+ SET state = 'failed',
+ error_message = :error_message,
+ finished_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE job_key = :job_key
+ """
+ ),
+ {"job_key": job["job_key"], "error_message": clean(exc)},
+ )
+
+
+def _compute_pair_recommendations_payload(
engine: Any,
start_year: int | None,
end_year: int | None,
@@ -2624,22 +5607,6 @@ def recommend_pair_matches(
limit: int = 300,
) -> dict[str, Any]:
safe_limit = max(min(int(limit or 300), 1000), 1)
- cache_key = "|".join(
- [
- str(start_year),
- str(end_year),
- normalize_text(ledger_voucher_no),
- normalize_text(ledger_review_reason),
- normalize_text(voucher_voucher_no),
- normalize_text(voucher_review_reason),
- str(safe_limit),
- ]
- )
- now = time.time()
- cached = _PAIR_RECOMMEND_CACHE.get(cache_key)
- if cached and (now - float(cached.get("ts", 0))) <= _PAIR_RECOMMEND_CACHE_TTL_SEC:
- return cached["payload"]
-
dataset = _collect_status_rows_for_workbench(
engine,
start_year,
@@ -2652,196 +5619,76 @@ def recommend_pair_matches(
ledger_rows = dataset["ledger_only"]
voucher_rows = dataset["voucher_only"]
if not ledger_rows or not voucher_rows:
- payload = {"pairs": [], "stats": {"ledger_rows": len(ledger_rows), "voucher_rows": len(voucher_rows), "recommended": 0, "auto_eligible": 0}}
- _PAIR_RECOMMEND_CACHE.clear()
- _PAIR_RECOMMEND_CACHE[cache_key] = {"ts": now, "payload": payload}
- return payload
+ return {"pairs": [], "stats": {"ledger_rows": len(ledger_rows), "voucher_rows": len(voucher_rows), "recommended": 0, "auto_eligible": 0}}
- amount_index: dict[float, list[dict[str, Any]]] = {}
- for voucher_row in voucher_rows:
- amounts = {
- round(parse_amount(voucher_row.get("voucher_debit")), 2),
- round(parse_amount(voucher_row.get("voucher_credit")), 2),
- }
- for amount in amounts:
+ ledger_features = [
+ _build_match_row_features(
+ row,
+ prefix="ledger",
+ row_key_field="ledger_row_key",
+ account_code_field="ledger_account_code",
+ account_name_field="ledger_account_name",
+ vendor_field="ledger_vendor",
+ desc_field="ledger_desc",
+ date_field="ledger_date",
+ )
+ for row in ledger_rows
+ ]
+ voucher_features = [
+ _build_match_row_features(
+ row,
+ prefix="voucher",
+ row_key_field="voucher_row_key",
+ account_code_field="voucher_account_code",
+ account_name_field="voucher_account_name",
+ vendor_field="voucher_vendor",
+ desc_field="voucher_desc",
+ date_field="proof_date",
+ )
+ for row in voucher_rows
+ ]
+
+ amount_index: dict[float, list[MatchRowFeatures]] = {}
+ for voucher_feature in voucher_features:
+ for amount in voucher_feature.positive_amounts:
if amount <= 0:
continue
- amount_index.setdefault(amount, []).append(voucher_row)
-
- voucher_tokens_by_key: dict[str, tuple[set[str], set[str], set[str], str, str]] = {}
- for voucher_row in voucher_rows:
- voucher_key = clean(voucher_row.get("voucher_row_key"))
- if not voucher_key:
- continue
- voucher_tokens_by_key[voucher_key] = (
- _tokenize_for_similarity(voucher_row.get("voucher_account_name")),
- _tokenize_for_similarity(voucher_row.get("voucher_vendor")),
- _tokenize_for_similarity(voucher_row.get("voucher_desc")),
- clean(voucher_row.get("voucher_account_code")),
- clean(voucher_row.get("voucher_account_name")),
- )
+ amount_index.setdefault(amount, []).append(voucher_feature)
edge_candidates: list[dict[str, Any]] = []
- for ledger_row in ledger_rows:
- candidate_amounts = {
- round(parse_amount(ledger_row.get("ledger_debit")), 2),
- round(parse_amount(ledger_row.get("ledger_credit")), 2),
- }
- voucher_candidates: list[dict[str, Any]] = []
+ for ledger_feature in ledger_features:
+ voucher_candidates: list[MatchRowFeatures] = []
seen_keys: set[str] = set()
- for amount in candidate_amounts:
- if amount <= 0:
- continue
- for voucher_row in amount_index.get(amount, []):
- voucher_key = clean(voucher_row.get("voucher_row_key"))
+ for amount in ledger_feature.positive_amounts:
+ for voucher_feature in amount_index.get(amount, []):
+ voucher_key = voucher_feature.row_key
if voucher_key and voucher_key not in seen_keys:
seen_keys.add(voucher_key)
- voucher_candidates.append(voucher_row)
+ voucher_candidates.append(voucher_feature)
if not voucher_candidates:
continue
- ledger_key = clean(ledger_row.get("ledger_row_key"))
- ledger_account_code = clean(ledger_row.get("ledger_account_code"))
- ledger_account_tokens = _tokenize_for_similarity(ledger_row.get("ledger_account_name"))
- ledger_vendor_tokens = _tokenize_for_similarity(ledger_row.get("ledger_vendor"))
- ledger_desc_tokens = _tokenize_for_similarity(ledger_row.get("ledger_desc"))
- side = _determine_primary_side(ledger_row)
- if side == "debit":
- ledger_amount = parse_amount(ledger_row.get("ledger_debit"))
- elif side == "credit":
- ledger_amount = parse_amount(ledger_row.get("ledger_credit"))
- else:
- ledger_amount = max(parse_amount(ledger_row.get("ledger_debit")), parse_amount(ledger_row.get("ledger_credit")))
-
- top_row: dict[str, Any] | None = None
+ top_feature: MatchRowFeatures | None = None
top_score: dict[str, Any] | None = None
second_best_score = -999.0
- for voucher_row in voucher_candidates:
- voucher_key = clean(voucher_row.get("voucher_row_key"))
- token_payload = voucher_tokens_by_key.get(voucher_key)
- if not token_payload:
- continue
- voucher_account_tokens, voucher_vendor_tokens, voucher_desc_tokens, voucher_code, voucher_account_name = token_payload
-
- score = 0.0
- reasons: list[str] = []
- hard_pass = True
-
- if side == "debit":
- voucher_amount = parse_amount(voucher_row.get("voucher_debit"))
- elif side == "credit":
- voucher_amount = parse_amount(voucher_row.get("voucher_credit"))
- else:
- voucher_amount = max(parse_amount(voucher_row.get("voucher_debit")), parse_amount(voucher_row.get("voucher_credit")))
-
- amount_gap = abs(ledger_amount - voucher_amount)
- if amount_gap < 0.5 and ledger_amount > 0:
- score += 50
- reasons.append("금액 일치")
- elif amount_gap < 5 and ledger_amount > 0:
- score += 35
- reasons.append("금액 근접")
- elif amount_gap < 100 and ledger_amount > 0:
- score += 10
- reasons.append("금액 유사")
- else:
- hard_pass = False
-
- account_sim = _jaccard_similarity_tokens(ledger_account_tokens, voucher_account_tokens)
- if ledger_account_code and voucher_code and ledger_account_code == voucher_code:
- score += 22
- reasons.append("계정코드 일치")
- elif ledger_account_code and voucher_code and ledger_account_code[:4] == voucher_code[:4]:
- score += 10
- reasons.append("계정코드 대분류 일치")
- else:
- if account_sim >= 0.8:
- score += 16
- reasons.append("계정명 유사도 높음")
- elif account_sim >= 0.55:
- score += 8
- reasons.append("계정명 유사")
- else:
- hard_pass = False
-
- vendor_sim = _jaccard_similarity_tokens(ledger_vendor_tokens, voucher_vendor_tokens)
- if vendor_sim >= 0.9:
- score += 16
- reasons.append("거래처 일치")
- elif vendor_sim >= 0.65:
- score += 10
- reasons.append("거래처 유사")
- elif vendor_sim >= 0.4:
- score += 4
- reasons.append("거래처 일부 유사")
- else:
- score -= 8
-
- desc_sim = _jaccard_similarity_tokens(ledger_desc_tokens, voucher_desc_tokens)
- if desc_sim >= 0.85:
- score += 10
- reasons.append("적요 매우 유사")
- elif desc_sim >= 0.6:
- score += 6
- reasons.append("적요 유사")
- elif desc_sim >= 0.35:
- score += 2
-
- ledger_date = _parse_iso_date(ledger_row.get("ledger_date"))
- proof_date = _parse_iso_date(voucher_row.get("proof_date"))
- if ledger_date and proof_date:
- day_gap = abs((ledger_date - proof_date).days)
- if day_gap <= 3:
- score += 8
- reasons.append("일자 근접")
- elif day_gap <= 10:
- score += 4
- elif day_gap <= 45:
- score += 1
- else:
- score -= 6
-
- confidence = "low"
- if score >= 88:
- confidence = "high"
- elif score >= 72:
- confidence = "medium"
-
- score_result = {
- "score": round(score, 2),
- "confidence_level": confidence,
- "reason": ", ".join(reasons[:4]),
- "auto_eligible": bool(
- hard_pass
- and score >= 88
- and amount_gap < 0.5
- and (
- (ledger_account_code and voucher_code and ledger_account_code == voucher_code)
- or account_sim >= 0.8
- )
- and vendor_sim >= 0.65
- ),
- "hard_pass": hard_pass,
- "vendor_similarity": round(vendor_sim, 4),
- "account_similarity": round(account_sim, 4),
- "amount_gap": round(amount_gap, 2),
- }
+ for voucher_feature in voucher_candidates:
+ score_result = _score_pair_match_features(ledger_feature, voucher_feature)
if not score_result["hard_pass"] or score_result["score"] < 72:
continue
if top_score is None or score_result["score"] > float(top_score["score"]):
second_best_score = float(top_score["score"]) if top_score else second_best_score
- top_row = voucher_row
+ top_feature = voucher_feature
top_score = score_result
elif score_result["score"] > second_best_score:
second_best_score = score_result["score"]
- if top_row is None or top_score is None:
+ if top_feature is None or top_score is None:
continue
if top_score["score"] - second_best_score < 6:
continue
edge_candidates.append(
{
- "ledger_row": ledger_row,
- "voucher_row": top_row,
+ "ledger_row": ledger_feature.row,
+ "voucher_row": top_feature.row,
"score": top_score["score"],
"confidence_level": top_score["confidence_level"],
"reason": top_score["reason"],
@@ -2849,6 +5696,7 @@ def recommend_pair_matches(
"vendor_similarity": top_score.get("vendor_similarity", 0),
"account_similarity": top_score.get("account_similarity", 0),
"amount_gap": top_score.get("amount_gap", 0),
+ "substitution_hint": top_score.get("substitution_hint", ""),
}
)
@@ -2878,6 +5726,7 @@ def recommend_pair_matches(
"vendor_similarity": edge.get("vendor_similarity", 0),
"account_similarity": edge.get("account_similarity", 0),
"amount_gap": edge.get("amount_gap", 0),
+ "substitution_hint": edge.get("substitution_hint", ""),
"ledger_row": edge["ledger_row"],
"voucher_row": edge["voucher_row"],
}
@@ -2895,6 +5744,64 @@ def recommend_pair_matches(
"high_confidence": sum(1 for row in picked if row["confidence_level"] == "high"),
},
}
+ return payload
+
+
+def recommend_pair_matches(
+ engine: Any,
+ start_year: int | None,
+ end_year: int | None,
+ ledger_voucher_no: str = "",
+ ledger_review_reason: str = "",
+ voucher_voucher_no: str = "",
+ voucher_review_reason: str = "",
+ limit: int = 300,
+) -> dict[str, Any]:
+ safe_limit = max(min(int(limit or 300), 1000), 1)
+ cache_key = _build_pair_recommend_cache_key(
+ start_year,
+ end_year,
+ ledger_voucher_no=ledger_voucher_no,
+ ledger_review_reason=ledger_review_reason,
+ voucher_voucher_no=voucher_voucher_no,
+ voucher_review_reason=voucher_review_reason,
+ limit=safe_limit,
+ )
+ now = time.time()
+ cached = _PAIR_RECOMMEND_CACHE.get(cache_key)
+ if cached and (now - float(cached.get("ts", 0))) <= _PAIR_RECOMMEND_CACHE_TTL_SEC:
+ return cached["payload"]
+ init_wehago_compare_db(engine)
+ with engine.begin() as conn:
+ persisted = _load_persisted_pair_recommend_cache(conn, cache_key)
+ if persisted is not None:
+ _PAIR_RECOMMEND_CACHE.clear()
+ _PAIR_RECOMMEND_CACHE[cache_key] = {"ts": now, "payload": persisted}
+ return persisted
+
+ payload = _compute_pair_recommendations_payload(
+ engine,
+ start_year=start_year,
+ end_year=end_year,
+ ledger_voucher_no=ledger_voucher_no,
+ ledger_review_reason=ledger_review_reason,
+ voucher_voucher_no=voucher_voucher_no,
+ voucher_review_reason=voucher_review_reason,
+ limit=safe_limit,
+ )
+ with engine.begin() as conn:
+ _store_persisted_pair_recommend_cache(
+ conn,
+ cache_key,
+ payload,
+ start_year=start_year,
+ end_year=end_year,
+ ledger_voucher_no=ledger_voucher_no,
+ ledger_review_reason=ledger_review_reason,
+ voucher_voucher_no=voucher_voucher_no,
+ voucher_review_reason=voucher_review_reason,
+ limit=safe_limit,
+ )
_PAIR_RECOMMEND_CACHE.clear()
_PAIR_RECOMMEND_CACHE[cache_key] = {"ts": now, "payload": payload}
return payload
@@ -2975,19 +5882,45 @@ def get_individual_pair_recommendations(
dataset = _collect_status_rows_for_workbench(engine, start_year, end_year)
ledger_rows = dataset["ledger_only"]
voucher_rows = dataset["voucher_only"]
+ ledger_features = [
+ _build_match_row_features(
+ row,
+ prefix="ledger",
+ row_key_field="ledger_row_key",
+ account_code_field="ledger_account_code",
+ account_name_field="ledger_account_name",
+ vendor_field="ledger_vendor",
+ desc_field="ledger_desc",
+ date_field="ledger_date",
+ )
+ for row in ledger_rows
+ ]
+ voucher_features = [
+ _build_match_row_features(
+ row,
+ prefix="voucher",
+ row_key_field="voucher_row_key",
+ account_code_field="voucher_account_code",
+ account_name_field="voucher_account_name",
+ vendor_field="voucher_vendor",
+ desc_field="voucher_desc",
+ date_field="proof_date",
+ )
+ for row in voucher_rows
+ ]
- source_row = None
+ source_feature = None
if normalized_source_status == "ledger_only":
- for row in ledger_rows:
- if clean(row.get("ledger_row_key")) == normalized_row_key:
- source_row = row
+ for feature in ledger_features:
+ if feature.row_key == normalized_row_key:
+ source_feature = feature
break
else:
- for row in voucher_rows:
- if clean(row.get("voucher_row_key")) == normalized_row_key:
- source_row = row
+ for feature in voucher_features:
+ if feature.row_key == normalized_row_key:
+ source_feature = feature
break
- if source_row is None:
+ if source_feature is None:
return {
"source_status": normalized_source_status,
"source_row": None,
@@ -2998,61 +5931,48 @@ def get_individual_pair_recommendations(
"limit": limit,
"has_more": False,
"next_offset": 0,
+ "bank_payable_case_count": 0,
}
candidates: list[dict[str, Any]] = []
if normalized_source_status == "ledger_only":
- source_amounts = {
- round(parse_amount(source_row.get("ledger_debit")), 2),
- round(parse_amount(source_row.get("ledger_credit")), 2),
- }
- for target in voucher_rows:
- target_amounts = {
- round(parse_amount(target.get("voucher_debit")), 2),
- round(parse_amount(target.get("voucher_credit")), 2),
- }
- if not (source_amounts & target_amounts):
+ source_amounts = set(source_feature.positive_amounts)
+ for target_feature in voucher_features:
+ if not (source_amounts & set(target_feature.positive_amounts)):
continue
- score = _score_pair_match(source_row, target)
+ score = _score_pair_match_features(source_feature, target_feature)
if not score["hard_pass"] or score["score"] < 60:
continue
candidates.append(
{
- "pair_key": build_manual_pair_key(clean(source_row.get("ledger_row_key")), clean(target.get("voucher_row_key"))),
+ "pair_key": build_manual_pair_key(source_feature.row_key, target_feature.row_key),
"score": score["score"],
"confidence_level": score["confidence_level"],
"amount_gap": score.get("amount_gap", 0),
"account_similarity": score.get("account_similarity", 0),
"vendor_similarity": score.get("vendor_similarity", 0),
"reason": score.get("reason", ""),
- "target_row": target,
+ "target_row": target_feature.row,
}
)
else:
- source_amounts = {
- round(parse_amount(source_row.get("voucher_debit")), 2),
- round(parse_amount(source_row.get("voucher_credit")), 2),
- }
- for target in ledger_rows:
- target_amounts = {
- round(parse_amount(target.get("ledger_debit")), 2),
- round(parse_amount(target.get("ledger_credit")), 2),
- }
- if not (source_amounts & target_amounts):
+ source_amounts = set(source_feature.positive_amounts)
+ for target_feature in ledger_features:
+ if not (source_amounts & set(target_feature.positive_amounts)):
continue
- score = _score_pair_match(target, source_row)
+ score = _score_pair_match_features(target_feature, source_feature)
if not score["hard_pass"] or score["score"] < 60:
continue
candidates.append(
{
- "pair_key": build_manual_pair_key(clean(target.get("ledger_row_key")), clean(source_row.get("voucher_row_key"))),
+ "pair_key": build_manual_pair_key(target_feature.row_key, source_feature.row_key),
"score": score["score"],
"confidence_level": score["confidence_level"],
"amount_gap": score.get("amount_gap", 0),
"account_similarity": score.get("account_similarity", 0),
"vendor_similarity": score.get("vendor_similarity", 0),
"reason": score.get("reason", ""),
- "target_row": target,
+ "target_row": target_feature.row,
}
)
@@ -3069,7 +5989,7 @@ def get_individual_pair_recommendations(
next_offset = safe_offset + len(rows)
return {
"source_status": normalized_source_status,
- "source_row": source_row,
+ "source_row": source_feature.row,
"rows": rows,
"total_count": len(candidates),
"shown_count": len(rows),
@@ -3139,6 +6059,1290 @@ def _filter_status_row(
return True
+def _is_bank_payable_matched_row(row: dict[str, Any]) -> bool:
+ return clean(row.get("matched_case")) == "bank_payable" or clean(row.get("review_reason")) == "BANK_PAYABLE_MATCH"
+
+
+BOUNDARY_EXCLUSION_KEYWORDS = (
+ "대체",
+ "이월",
+ "전기",
+ "기초",
+ "기말",
+ "마감",
+ "결산",
+ "손익",
+ "잉여금",
+)
+STRONG_CARRYOVER_KEYWORDS = (
+ "전기이월",
+ "기초이월",
+ "전기잔액",
+ "기초잔액",
+)
+
+
+def _is_boundary_date_value(value: Any, fiscal_year: Any = None) -> bool:
+ date_text = clean(value)
+ if not date_text:
+ return False
+ match = re.search(r"(\d{4})[-./](\d{1,2})[-./](\d{1,2})", date_text)
+ if match:
+ year_value = int(match.group(1))
+ month_day = f"{int(match.group(2)):02d}-{int(match.group(3)):02d}"
+ target_year = int(fiscal_year or 0) if str(fiscal_year or "").isdigit() else None
+ return month_day in {"01-01", "12-31"} and (not target_year or year_value == target_year)
+ return bool(re.search(r"(^|[^0-9])(?:0?1[-./]0?1|12[-./]31)([^0-9]|$)", date_text))
+
+
+def _extract_boundary_month_day(value: Any, fiscal_year: Any = None) -> str:
+ date_text = clean(value)
+ if not date_text:
+ return ""
+ match = re.search(r"(\d{4})[-./](\d{1,2})[-./](\d{1,2})", date_text)
+ if match:
+ year_value = int(match.group(1))
+ month_day = f"{int(match.group(2)):02d}-{int(match.group(3)):02d}"
+ target_year = int(fiscal_year or 0) if str(fiscal_year or "").isdigit() else None
+ if month_day in {"01-01", "12-31"} and (not target_year or year_value == target_year):
+ return month_day
+ return ""
+ loose = re.search(r"(^|[^0-9])(0?1[-./]0?1|12[-./]31)([^0-9]|$)", date_text)
+ if not loose:
+ return ""
+ token = loose.group(2)
+ if token.startswith(("1-1", "01-1", "1/1", "01/1", "1.1", "01.1", "01-01", "01/01", "01.01")):
+ return "01-01"
+ return "12-31"
+
+
+def _row_has_earlier_fiscal_year_source(conn: Any, row: dict[str, Any]) -> bool:
+ try:
+ fiscal_year = int(row.get("fiscal_year") or 0)
+ except (TypeError, ValueError):
+ fiscal_year = 0
+ if fiscal_year <= 0:
+ return False
+ account_code = clean(row.get("ledger_account_code") or row.get("account_code"))
+ account_name = clean(row.get("ledger_account_name") or row.get("account_name"))
+ if not account_code and not account_name:
+ return False
+ exists = conn.execute(
+ text(
+ """
+ SELECT 1
+ FROM wehago_ledger_rows
+ WHERE fiscal_year < :fiscal_year
+ AND (
+ (:account_code <> '' AND COALESCE(account_code, '') = :account_code)
+ OR (:account_name <> '' AND COALESCE(account_name, '') = :account_name)
+ )
+ LIMIT 1
+ """
+ ),
+ {
+ "fiscal_year": fiscal_year,
+ "account_code": account_code,
+ "account_name": account_name,
+ },
+ ).first()
+ return bool(exists)
+
+
+def _build_earlier_year_account_lookup(conn: Any) -> dict[int, set[str]]:
+ rows = conn.execute(
+ text(
+ """
+ SELECT fiscal_year, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name
+ FROM wehago_ledger_rows
+ WHERE fiscal_year IS NOT NULL
+ AND fiscal_year > 0
+ """
+ )
+ ).mappings()
+ accounts_by_year: dict[int, set[str]] = {}
+ for row in rows:
+ year = int(row["fiscal_year"] or 0)
+ if year <= 0:
+ continue
+ bucket = accounts_by_year.setdefault(year, set())
+ account_code = clean(row["account_code"])
+ account_name = clean(row["account_name"])
+ if account_code:
+ bucket.add(f"code:{account_code}")
+ if account_name:
+ bucket.add(f"name:{account_name}")
+ earlier_lookup: dict[int, set[str]] = {}
+ accumulated: set[str] = set()
+ for year in sorted(accounts_by_year):
+ earlier_lookup[year] = set(accumulated)
+ accumulated.update(accounts_by_year[year])
+ return earlier_lookup
+
+
+def _row_has_earlier_fiscal_year_source_from_lookup(
+ row: dict[str, Any],
+ earlier_lookup: dict[int, set[str]],
+) -> bool:
+ try:
+ fiscal_year = int(row.get("fiscal_year") or 0)
+ except (TypeError, ValueError):
+ fiscal_year = 0
+ if fiscal_year <= 0:
+ return False
+ earlier_accounts = earlier_lookup.get(fiscal_year, set())
+ if not earlier_accounts:
+ return False
+ account_code = clean(row.get("ledger_account_code") or row.get("account_code"))
+ account_name = clean(row.get("ledger_account_name") or row.get("account_name"))
+ return (
+ (bool(account_code) and f"code:{account_code}" in earlier_accounts)
+ or (bool(account_name) and f"name:{account_name}" in earlier_accounts)
+ )
+
+
+def _has_boundary_exclusion_keyword(*values: Any) -> bool:
+ text_value = normalize_text(" ".join(clean(value) for value in values if clean(value)))
+ return any(keyword in text_value for keyword in BOUNDARY_EXCLUSION_KEYWORDS)
+
+
+def _has_strong_carryover_keyword(*values: Any) -> bool:
+ text_value = normalize_text(" ".join(clean(value) for value in values if clean(value)))
+ compact_value = text_value.replace(" ", "")
+ return any(keyword in compact_value for keyword in STRONG_CARRYOVER_KEYWORDS)
+
+
+def _is_boundary_excluded_unmatched_row(
+ row: dict[str, Any],
+ conn: Any | None = None,
+ earlier_year_account_lookup: dict[int, set[str]] | None = None,
+) -> bool:
+ if clean(row.get("boundary_excluded")) == "1" or clean(row.get("matched_case")) == "boundary_excluded":
+ return True
+ month_day = _extract_boundary_month_day(row.get("ledger_date"), row.get("fiscal_year"))
+ text_values = (
+ row.get("ledger_desc"),
+ row.get("description"),
+ row.get("ledger_account_name"),
+ row.get("account_name"),
+ row.get("review_reason"),
+ row.get("notes"),
+ )
+ if month_day == "01-01":
+ if earlier_year_account_lookup is not None:
+ return _row_has_earlier_fiscal_year_source_from_lookup(row, earlier_year_account_lookup)
+ return bool(conn is not None and _row_has_earlier_fiscal_year_source(conn, row))
+ if _has_strong_carryover_keyword(*text_values):
+ return True
+ if not month_day:
+ return False
+ return _has_boundary_exclusion_keyword(*text_values)
+
+
+def _annotate_boundary_excluded_row(
+ row: dict[str, Any],
+ conn: Any | None = None,
+ earlier_year_account_lookup: dict[int, set[str]] | None = None,
+) -> dict[str, Any]:
+ if _is_boundary_excluded_unmatched_row(row, conn, earlier_year_account_lookup):
+ row["boundary_excluded"] = "1"
+ row["boundary_excluded_label"] = "연초/연말 대체·이월"
+ row["matched_case"] = "boundary_excluded"
+ return row
+
+
+def _refresh_section_counts(sections: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
+ for section in sections.values():
+ rows = section.get("rows", [])
+ if isinstance(rows, list):
+ section["count"] = len(rows)
+ return sections
+
+
+def _apply_boundary_exclusions_to_sections(conn: Any, sections: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
+ earlier_year_account_lookup = _build_earlier_year_account_lookup(conn)
+ prepared: dict[str, dict[str, Any]] = {}
+ for status_key, section in sections.items():
+ copied = dict(section)
+ copied["rows"] = [dict(row) for row in section.get("rows", [])]
+ prepared[status_key] = copied
+
+ ledger_only_rows = prepared.setdefault("ledger_only", {"rows": [], "count": 0, "columns": DETAIL_COLUMN_MAP["ledger_only"]})["rows"]
+ for status_key in ("matched", "amount_mismatch"):
+ kept_rows: list[dict[str, Any]] = []
+ for row in prepared.get(status_key, {}).get("rows", []):
+ if _is_boundary_excluded_unmatched_row(row, conn, earlier_year_account_lookup):
+ ledger_only_rows.append(_annotate_boundary_excluded_row(row, conn, earlier_year_account_lookup))
+ else:
+ kept_rows.append(row)
+ if status_key in prepared:
+ prepared[status_key]["rows"] = kept_rows
+
+ prepared["ledger_only"]["rows"] = [
+ _annotate_boundary_excluded_row(row, conn, earlier_year_account_lookup)
+ for row in prepared.get("ledger_only", {}).get("rows", [])
+ ]
+ return _refresh_section_counts(prepared)
+
+
+def _boundary_excluded_sql(date_sql: str, *text_sqls: str) -> str:
+ text_expr = " || ' ' || ".join(f"COALESCE({field}, '')" for field in text_sqls)
+ compact_text_expr = f"REPLACE(({text_expr}), ' ', '')"
+ keyword_sql = " OR ".join(f"{text_expr} LIKE '%{keyword}%'" for keyword in BOUNDARY_EXCLUSION_KEYWORDS)
+ strong_keyword_sql = " OR ".join(f"{compact_text_expr} LIKE '%{keyword}%'" for keyword in STRONG_CARRYOVER_KEYWORDS)
+ return f"(({strong_keyword_sql}) OR (substr(COALESCE({date_sql}, ''), 6, 5) IN ('01-01', '12-31') AND ({keyword_sql})))"
+
+
+def _append_like_filter(conditions: list[str], params: dict[str, Any], field_sql: str, param_name: str, value: str) -> None:
+ cleaned = clean(value)
+ if not cleaned:
+ return
+ conditions.append(f"COALESCE({field_sql}, '') LIKE :{param_name}")
+ params[param_name] = f"%{cleaned}%"
+
+
+def _append_amount_filter(conditions: list[str], params: dict[str, Any], fields: list[str], param_name: str, value: str) -> None:
+ cleaned = clean(value)
+ if not cleaned:
+ return
+ amount = parse_amount(cleaned)
+ if abs(amount) < 0.5 and not re.search(r"\d", cleaned):
+ return
+ parts: list[str] = []
+ for index, field_sql in enumerate(fields):
+ key = f"{param_name}_{index}"
+ parts.append(f"ABS(COALESCE({field_sql}, 0) - :{key}) < 0.5")
+ params[key] = amount
+ conditions.append("(" + " OR ".join(parts) + ")")
+
+
+def _fetch_status_detail_rows_from_db(
+ conn: Any,
+ start_year: int,
+ end_year: int,
+ status: str,
+ voucher_filter: str,
+ draft_filter: str,
+ wehago_account_filter: str,
+ erp_account_filter: str,
+ wehago_amount_filter: str,
+ erp_amount_filter: str,
+ wehago_vendor_filter: str,
+ erp_vendor_filter: str,
+ desc_filter: str,
+ boundary_excluded_filter: bool,
+ offset: int,
+ limit: int,
+) -> dict[str, Any]:
+ params: dict[str, Any] = {
+ "start_year": start_year,
+ "end_year": end_year,
+ "status": status,
+ "limit": limit,
+ "offset": offset,
+ }
+ conditions = [
+ "c.status = :status",
+ "c.fiscal_year >= :start_year",
+ "c.fiscal_year <= :end_year",
+ ]
+ voucher_rep = """
+ SELECT *
+ FROM (
+ SELECT fiscal_year, compare_voucher_no, proof_date, confirmed_no, draft_no,
+ account_code, account_name, vendor_name, desc1, desc2, debit_supply, credit_supply,
+ ROW_NUMBER() OVER (
+ PARTITION BY fiscal_year, compare_voucher_no
+ ORDER BY row_number
+ ) AS rn
+ FROM wehago_voucher_rows
+ WHERE COALESCE(compare_voucher_no, '') <> ''
+ )
+ WHERE rn = 1
+ """
+
+ if status == "ledger_only":
+ boundary_sql = _boundary_excluded_sql("l.ledger_date", "l.description", "l.account_name", "c.notes")
+ select_sql = """
+ c.fiscal_year AS fiscal_year,
+ COALESCE(l.voucher_no, c.voucher_no) AS voucher_no,
+ COALESCE(l.ledger_date, '') AS ledger_date,
+ COALESCE(l.account_code, '') AS ledger_account_code,
+ COALESCE(l.account_name, c.ledger_accounts, '') AS ledger_account_name,
+ COALESCE(l.vendor_name, c.ledger_vendors, '') AS ledger_vendor,
+ l.debit AS ledger_debit,
+ l.credit AS ledger_credit,
+ COALESCE(l.description, '') AS ledger_desc,
+ CASE WHEN {boundary_sql} THEN '1' ELSE '' END AS boundary_excluded,
+ CASE WHEN {boundary_sql} THEN '연초/연말 대체·이월' ELSE '' END AS boundary_excluded_label,
+ CASE WHEN {boundary_sql} THEN 'boundary_excluded' ELSE '' END AS matched_case
+ """.format(boundary_sql=boundary_sql)
+ joins = """
+ JOIN wehago_ledger_rows l
+ ON l.fiscal_year = c.fiscal_year AND l.compare_voucher_no = c.voucher_no
+ """
+ _append_like_filter(conditions, params, "COALESCE(l.voucher_no, c.voucher_no)", "voucher_no", voucher_filter)
+ _append_like_filter(conditions, params, "COALESCE(l.account_name, c.ledger_accounts)", "wehago_account", wehago_account_filter)
+ _append_like_filter(conditions, params, "COALESCE(l.vendor_name, c.ledger_vendors)", "wehago_vendor", wehago_vendor_filter)
+ _append_like_filter(conditions, params, "l.description", "desc_keyword", desc_filter)
+ _append_amount_filter(conditions, params, ["l.debit", "l.credit"], "wehago_amount", wehago_amount_filter)
+ if boundary_excluded_filter:
+ conditions.append(boundary_sql)
+ elif status == "voucher_only":
+ select_sql = """
+ c.fiscal_year AS fiscal_year,
+ COALESCE(v.confirmed_no, v.draft_no, c.voucher_no) AS voucher_no,
+ COALESCE(v.proof_date, '') AS proof_date,
+ COALESCE(v.draft_no, '') AS draft_no,
+ COALESCE(v.account_code, '') AS voucher_account_code,
+ COALESCE(v.account_name, c.voucher_accounts, '') AS voucher_account_name,
+ COALESCE(v.vendor_name, c.voucher_vendors, '') AS voucher_vendor,
+ v.debit_supply AS voucher_debit,
+ v.credit_supply AS voucher_credit,
+ TRIM(COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, '')) AS voucher_desc
+ """
+ joins = """
+ JOIN wehago_voucher_rows v
+ ON v.fiscal_year = c.fiscal_year AND v.compare_voucher_no = c.voucher_no
+ """
+ _append_like_filter(conditions, params, "COALESCE(v.confirmed_no, v.draft_no, c.voucher_no)", "voucher_no", voucher_filter)
+ _append_like_filter(conditions, params, "v.draft_no", "draft_no", draft_filter)
+ _append_like_filter(conditions, params, "COALESCE(v.account_name, c.voucher_accounts)", "erp_account", erp_account_filter)
+ _append_like_filter(conditions, params, "COALESCE(v.vendor_name, c.voucher_vendors)", "erp_vendor", erp_vendor_filter)
+ _append_like_filter(conditions, params, "TRIM(COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, ''))", "desc_keyword", desc_filter)
+ _append_amount_filter(conditions, params, ["v.debit_supply", "v.credit_supply"], "erp_amount", erp_amount_filter)
+ else:
+ select_sql = """
+ c.fiscal_year AS fiscal_year,
+ COALESCE(l.ledger_date, v.proof_date, '') AS ledger_date,
+ COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no) AS voucher_no,
+ COALESCE(v.draft_no, '') AS draft_no,
+ COALESCE(l.account_code, '') AS ledger_account_code,
+ COALESCE(l.account_name, c.ledger_accounts, '') AS ledger_account_name,
+ COALESCE(v.account_code, '') AS voucher_account_code,
+ COALESCE(v.account_name, c.voucher_accounts, '') AS voucher_account_name,
+ COALESCE(l.vendor_name, c.ledger_vendors, '') AS ledger_vendor,
+ COALESCE(v.vendor_name, c.voucher_vendors, '') AS voucher_vendor,
+ l.debit AS ledger_debit,
+ l.credit AS ledger_credit,
+ COALESCE(v.debit_supply, 0) AS voucher_debit,
+ COALESCE(v.credit_supply, 0) AS voucher_credit,
+ COALESCE(l.description, '') AS ledger_desc,
+ TRIM(COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, '')) AS voucher_desc,
+ c.notes AS review_reason
+ """
+ joins = f"""
+ JOIN wehago_ledger_rows l
+ ON l.fiscal_year = c.fiscal_year AND l.compare_voucher_no = c.voucher_no
+ LEFT JOIN ({voucher_rep}) v
+ ON v.fiscal_year = c.fiscal_year AND v.compare_voucher_no = c.voucher_no
+ """
+ _append_like_filter(conditions, params, "COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no)", "voucher_no", voucher_filter)
+ _append_like_filter(conditions, params, "v.draft_no", "draft_no", draft_filter)
+ _append_like_filter(conditions, params, "COALESCE(l.account_name, c.ledger_accounts)", "wehago_account", wehago_account_filter)
+ _append_like_filter(conditions, params, "COALESCE(v.account_name, c.voucher_accounts)", "erp_account", erp_account_filter)
+ _append_like_filter(conditions, params, "COALESCE(l.vendor_name, c.ledger_vendors)", "wehago_vendor", wehago_vendor_filter)
+ _append_like_filter(conditions, params, "COALESCE(v.vendor_name, c.voucher_vendors)", "erp_vendor", erp_vendor_filter)
+ _append_like_filter(
+ conditions,
+ params,
+ "COALESCE(l.description, '') || ' ' || COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, '') || ' ' || COALESCE(c.notes, '')",
+ "desc_keyword",
+ desc_filter,
+ )
+ _append_amount_filter(conditions, params, ["l.debit", "l.credit"], "wehago_amount", wehago_amount_filter)
+ _append_amount_filter(conditions, params, ["v.debit_supply", "v.credit_supply"], "erp_amount", erp_amount_filter)
+
+ where_sql = " AND ".join(conditions)
+ from_sql = f"FROM wehago_comparison_results c {joins} WHERE {where_sql}"
+ total_count = int(conn.execute(text(f"SELECT COUNT(*) {from_sql}"), params).scalar_one() or 0)
+ rows = [
+ {key: clean(value) if isinstance(value, str) else value for key, value in dict(row).items()}
+ for row in conn.execute(
+ text(
+ f"""
+ SELECT {select_sql}
+ {from_sql}
+ ORDER BY c.fiscal_year, c.voucher_no
+ LIMIT :limit OFFSET :offset
+ """
+ ),
+ params,
+ ).mappings()
+ ]
+ next_offset = offset + len(rows)
+ boundary_excluded_count = 0
+ if status == "ledger_only":
+ boundary_sql = _boundary_excluded_sql("l.ledger_date", "l.description", "l.account_name", "c.notes")
+ boundary_count_conditions = [
+ item
+ for item in conditions
+ if item != boundary_sql and item != f"NOT {boundary_sql}"
+ ]
+ boundary_excluded_count = int(
+ conn.execute(
+ text(
+ f"""
+ SELECT COUNT(*)
+ FROM wehago_comparison_results c {joins}
+ WHERE {' AND '.join(boundary_count_conditions + [boundary_sql])}
+ """
+ ),
+ params,
+ ).scalar_one()
+ or 0
+ )
+ return {
+ "columns": DETAIL_COLUMN_MAP[status],
+ "rows": rows,
+ "total_count": total_count,
+ "shown_count": len(rows),
+ "offset": offset,
+ "limit": limit,
+ "has_more": next_offset < total_count,
+ "next_offset": next_offset,
+ "notice": "",
+ "bank_payable_case_count": 0,
+ "boundary_excluded_count": boundary_excluded_count,
+ }
+
+
+def _build_status_detail_response_from_rows(
+ rows_by_status: dict[str, list[dict[str, Any]]],
+ status: str,
+ voucher_filter: str,
+ draft_filter: str,
+ wehago_account_filter: str,
+ erp_account_filter: str,
+ wehago_amount_filter: str,
+ erp_amount_filter: str,
+ wehago_vendor_filter: str,
+ erp_vendor_filter: str,
+ desc_filter: str,
+ boundary_excluded_filter: bool,
+ offset: int,
+ limit: int,
+) -> dict[str, Any]:
+ source_rows = [
+ _annotate_boundary_excluded_row(dict(row)) if status == "ledger_only" else dict(row)
+ for row in rows_by_status.get(status, [])
+ ]
+ filtered_rows = [
+ row
+ for row in source_rows
+ if _filter_status_row(
+ row,
+ voucher_filter,
+ draft_filter,
+ wehago_account_filter,
+ erp_account_filter,
+ wehago_amount_filter,
+ erp_amount_filter,
+ wehago_vendor_filter,
+ erp_vendor_filter,
+ desc_filter,
+ )
+ and (not boundary_excluded_filter or _is_boundary_excluded_unmatched_row(row))
+ ]
+ next_offset = offset + min(limit, max(len(filtered_rows) - offset, 0))
+ shown_rows = filtered_rows[offset : offset + limit]
+ return {
+ "columns": DETAIL_COLUMN_MAP[status],
+ "rows": shown_rows,
+ "total_count": len(filtered_rows),
+ "shown_count": len(shown_rows),
+ "offset": offset,
+ "limit": limit,
+ "has_more": next_offset < len(filtered_rows),
+ "next_offset": next_offset,
+ "notice": "",
+ "bank_payable_case_count": sum(1 for row in filtered_rows if _is_bank_payable_matched_row(row)),
+ "boundary_excluded_count": sum(1 for row in source_rows if _is_boundary_excluded_unmatched_row(row)) if status == "ledger_only" else 0,
+ }
+
+
+def _build_voucher_sections_from_rows_by_status(
+ rows_by_status: dict[str, list[dict[str, Any]]],
+) -> dict[str, list[dict[str, Any]]]:
+ grouped: dict[tuple[str, int, str, str], dict[str, Any]] = {}
+ matched_ledger_keys: set[str] = set()
+ matched_voucher_keys: set[str] = set()
+ matched_wehago_group_keys: set[tuple[int, str, str]] = set()
+
+ def erp_set_key_from_values(fiscal_year: Any, draft_no: Any, voucher_no: Any, proof_date: Any) -> tuple[int, str]:
+ normalized_draft = clean(draft_no)
+ normalized_voucher = clean(voucher_no)
+ normalized_date = clean(proof_date)
+ base = normalized_draft or normalized_voucher
+ if base:
+ base = re.sub(r"-\d+$", "", base)
+ if not base and normalized_date and normalized_voucher:
+ base = f"{normalized_date}|{normalized_voucher}"
+ return (int(fiscal_year or 0), base)
+
+ def wehago_group_key(row: dict[str, Any]) -> tuple[int, str, str]:
+ return (
+ int(row.get("fiscal_year") or 0),
+ clean(row.get("voucher_no")),
+ clean(row.get("ledger_date")),
+ )
+
+ def erp_group_key(row: dict[str, Any]) -> tuple[int, str]:
+ return erp_set_key_from_values(
+ row.get("fiscal_year"),
+ row.get("draft_no"),
+ row.get("voucher_no"),
+ row.get("proof_date"),
+ )
+
+ def row_origin_rank(row: dict[str, Any]) -> int:
+ status_label = clean(row.get("status_label"))
+ if status_label == "Matched":
+ return 0
+ if status_label == "Unmatched":
+ return 1
+ if status_label == "ERP Unmatched":
+ return 2
+ if status_label == "Recheck":
+ return 3
+ return 4
+
+ def ensure_group(status_key: str, row: dict[str, Any]) -> dict[str, Any]:
+ fiscal_year = int(row.get("fiscal_year") or 0)
+ voucher_no = clean(row.get("voucher_no"))
+ draft_no = clean(row.get("draft_no"))
+ ledger_date = clean(row.get("ledger_date"))
+ proof_date = clean(row.get("proof_date"))
+ fallback_voucher_key = clean(
+ row.get("ledger_row_key")
+ or row.get("voucher_row_key")
+ or row.get("review_key")
+ or ledger_date
+ or proof_date
+ or row.get("ledger_desc")
+ or row.get("voucher_desc")
+ )
+ group_key_parts = [part for part in [voucher_no, draft_no, ledger_date, proof_date] if part]
+ if status_key in {"matched", "ledger_only", "amount_mismatch"}:
+ if voucher_no and ledger_date:
+ group_key = "|".join([str(fiscal_year), ledger_date, voucher_no])
+ elif voucher_no and proof_date:
+ group_key = "|".join([str(fiscal_year), proof_date, voucher_no])
+ elif group_key_parts:
+ group_key = "|".join([str(fiscal_year), *group_key_parts])
+ else:
+ group_key = fallback_voucher_key
+ elif draft_no and proof_date:
+ group_key = "|".join([str(fiscal_year), proof_date, draft_no])
+ elif voucher_no and proof_date:
+ group_key = "|".join([str(fiscal_year), proof_date, voucher_no])
+ elif group_key_parts:
+ group_key = "|".join([str(fiscal_year), *group_key_parts])
+ else:
+ group_key = fallback_voucher_key
+ key = (status_key, fiscal_year, group_key, "")
+ current = grouped.get(key)
+ if current is None:
+ status_label_map = {
+ "matched": "Matched",
+ "ledger_only": "Unmatched",
+ "voucher_only": "ERP Unmatched",
+ "amount_mismatch": "Recheck",
+ }
+ current = {
+ "fiscal_year": fiscal_year,
+ "status_label": status_label_map.get(status_key, status_key),
+ "ledger_date": ledger_date,
+ "proof_date": proof_date,
+ "voucher_no": voucher_no,
+ "draft_no": draft_no,
+ "ledger_row_count": 0,
+ "voucher_row_count": 0,
+ "ledger_debit": 0.0,
+ "ledger_credit": 0.0,
+ "voucher_debit": 0.0,
+ "voucher_credit": 0.0,
+ "ledger_accounts": [],
+ "voucher_accounts": [],
+ "ledger_vendors": [],
+ "voucher_vendors": [],
+ "review_reason": [],
+ "rows": [],
+ "wehago_group_key": wehago_group_key(row),
+ "erp_group_keys": set(),
+ "draft_nos": [],
+ }
+ grouped[key] = current
+ return current
+
+ def append_unique(target: list[str], value: Any) -> None:
+ text_value = clean(value)
+ if text_value and text_value not in target:
+ target.append(text_value)
+
+ for status_key in ("matched", "ledger_only", "voucher_only", "amount_mismatch"):
+ for row in rows_by_status.get(status_key, []):
+ current = ensure_group(status_key, row)
+ if not current.get("ledger_date"):
+ current["ledger_date"] = clean(row.get("ledger_date"))
+ if not current.get("proof_date"):
+ current["proof_date"] = clean(row.get("proof_date"))
+ if clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc")):
+ current["ledger_row_count"] += 1
+ if clean(row.get("voucher_account_name")) or clean(row.get("voucher_desc")):
+ current["voucher_row_count"] += 1
+ current["ledger_debit"] += parse_amount(row.get("ledger_debit"))
+ current["ledger_credit"] += parse_amount(row.get("ledger_credit"))
+ current["voucher_debit"] += parse_amount(row.get("voucher_debit"))
+ current["voucher_credit"] += parse_amount(row.get("voucher_credit"))
+ append_unique(current["ledger_accounts"], row.get("ledger_account_name"))
+ append_unique(current["voucher_accounts"], row.get("voucher_account_name"))
+ append_unique(current["ledger_vendors"], row.get("ledger_vendor"))
+ append_unique(current["voucher_vendors"], row.get("voucher_vendor"))
+ append_unique(current["review_reason"], row.get("review_reason"))
+ append_unique(current["draft_nos"], row.get("draft_no"))
+ row_payload = dict(row)
+ row_payload.setdefault("status_label", current.get("status_label"))
+ current["rows"].append(row_payload)
+ if status_key in {"matched", "amount_mismatch"}:
+ current["erp_group_keys"].add(erp_group_key(row))
+ if status_key == "matched":
+ ledger_key = clean(row.get("ledger_row_key"))
+ voucher_key = clean(row.get("voucher_row_key"))
+ if ledger_key:
+ matched_ledger_keys.add(ledger_key)
+ if voucher_key:
+ matched_voucher_keys.add(voucher_key)
+ current["erp_group_keys"].add(erp_group_key(row))
+
+ ledger_only_index: dict[tuple[int, str, str], list[dict[str, Any]]] = {}
+ for row in rows_by_status.get("ledger_only", []):
+ ledger_only_index.setdefault(wehago_group_key(row), []).append(dict(row))
+
+ voucher_only_index: dict[tuple[int, str], list[dict[str, Any]]] = {}
+ for row in rows_by_status.get("voucher_only", []):
+ voucher_only_index.setdefault(erp_group_key(row), []).append(dict(row))
+
+ recheck_by_wehago_index: dict[tuple[int, str, str], list[dict[str, Any]]] = {}
+ recheck_by_erp_index: dict[tuple[int, str], list[dict[str, Any]]] = {}
+ for row in rows_by_status.get("amount_mismatch", []):
+ row_copy = dict(row)
+ recheck_by_wehago_index.setdefault(wehago_group_key(row_copy), []).append(row_copy)
+ recheck_by_erp_index.setdefault(erp_group_key(row_copy), []).append(row_copy)
+ recheck_wehago_group_keys = set(recheck_by_wehago_index.keys())
+
+ def recheck_identity(row: dict[str, Any]) -> str:
+ return (
+ clean(row.get("review_key"))
+ or "|".join(
+ [
+ clean(row.get("ledger_row_key")),
+ clean(row.get("voucher_row_key")),
+ clean(row.get("voucher_no")),
+ clean(row.get("draft_no")),
+ clean(row.get("ledger_account_name")),
+ clean(row.get("voucher_account_name")),
+ clean(row.get("ledger_desc")),
+ clean(row.get("voucher_desc")),
+ ]
+ )
+ )
+
+ def append_recheck_row(target_rows: list[dict[str, Any]], extra_recheck: dict[str, Any], appended_recheck_ids: set[str]) -> None:
+ identity = recheck_identity(extra_recheck)
+ if identity and identity in appended_recheck_ids:
+ return
+ if identity:
+ appended_recheck_ids.add(identity)
+ recheck_payload = dict(extra_recheck)
+ recheck_payload["status_label"] = "Recheck"
+ target_rows.append(recheck_payload)
+
+ voucher_sections = {
+ "voucher_matched": [],
+ "erp_voucher_matched": [],
+ "voucher_unmatched": [],
+ "voucher_recheck": [],
+ }
+ for (status_key, _fiscal_year, _voucher_no, _draft_no), row in grouped.items():
+ if status_key == "matched":
+ matched_wehago_group_keys.add(row.get("wehago_group_key"))
+ for extra_ledger in ledger_only_index.get(row.get("wehago_group_key"), []):
+ ledger_key = clean(extra_ledger.get("ledger_row_key"))
+ if ledger_key and ledger_key in matched_ledger_keys:
+ continue
+ row["rows"].append(
+ {
+ **dict(extra_ledger),
+ "status_label": "Unmatched",
+ "voucher_account_code": "",
+ "voucher_account_name": "",
+ "voucher_vendor": "",
+ "voucher_debit": "",
+ "voucher_credit": "",
+ "voucher_desc": "",
+ "draft_no": "",
+ "proof_date": "",
+ }
+ )
+ appended_recheck_ids: set[str] = set()
+ for extra_recheck in recheck_by_wehago_index.get(row.get("wehago_group_key"), []):
+ append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids)
+ appended_voucher_keys: set[str] = set()
+ for erp_key in row.get("erp_group_keys", set()):
+ for extra_voucher in voucher_only_index.get(erp_key, []):
+ voucher_key = clean(extra_voucher.get("voucher_row_key"))
+ if voucher_key and voucher_key in matched_voucher_keys:
+ continue
+ if voucher_key and voucher_key in appended_voucher_keys:
+ continue
+ if voucher_key:
+ appended_voucher_keys.add(voucher_key)
+ row["rows"].append(
+ {
+ **dict(extra_voucher),
+ "status_label": "ERP Unmatched",
+ "ledger_account_code": "",
+ "ledger_account_name": "",
+ "ledger_vendor": "",
+ "ledger_debit": "",
+ "ledger_credit": "",
+ "ledger_desc": "",
+ "ledger_date": row.get("ledger_date", ""),
+ "voucher_no": row.get("voucher_no", extra_voucher.get("voucher_no", "")),
+ }
+ )
+ for extra_recheck in recheck_by_erp_index.get(erp_key, []):
+ append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids)
+ row["rows"].sort(
+ key=lambda item: (
+ clean(item.get("ledger_date")) or clean(item.get("proof_date")),
+ clean(item.get("voucher_no")),
+ row_origin_rank(item),
+ 0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("ledger_account_name")) else 2,
+ clean(item.get("ledger_account_name")) or clean(item.get("voucher_account_name")),
+ clean(item.get("draft_no")),
+ -parse_amount(item.get("ledger_debit") or item.get("voucher_debit")),
+ -parse_amount(item.get("ledger_credit") or item.get("voucher_credit")),
+ clean(item.get("ledger_desc")) or clean(item.get("voucher_desc")),
+ )
+ )
+ normalized_summary = dict(row)
+ normalized_summary["ledger_accounts"] = ", ".join(row["ledger_accounts"])
+ normalized_summary["voucher_accounts"] = ", ".join(row["voucher_accounts"])
+ normalized_summary["ledger_vendors"] = ", ".join(row["ledger_vendors"])
+ normalized_summary["voucher_vendors"] = ", ".join(row["voucher_vendors"])
+ normalized_summary["review_reason"] = " / ".join(row["review_reason"])
+ normalized_summary["draft_no"] = ", ".join(row["draft_nos"])
+ normalized_group = {
+ "summary": normalized_summary,
+ "rows": list(row["rows"]),
+ }
+ if status_key == "matched":
+ voucher_sections["voucher_matched"].append(normalized_group)
+ elif status_key == "ledger_only" and row.get("wehago_group_key") not in matched_wehago_group_keys and row.get("wehago_group_key") not in recheck_wehago_group_keys:
+ voucher_sections["voucher_unmatched"].append(normalized_group)
+ elif status_key == "amount_mismatch" and row.get("wehago_group_key") not in matched_wehago_group_keys:
+ for extra_ledger in ledger_only_index.get(row.get("wehago_group_key"), []):
+ ledger_key = clean(extra_ledger.get("ledger_row_key"))
+ if ledger_key and ledger_key in matched_ledger_keys:
+ continue
+ row["rows"].append(
+ {
+ **dict(extra_ledger),
+ "status_label": "Unmatched",
+ "voucher_account_code": "",
+ "voucher_account_name": "",
+ "voucher_vendor": "",
+ "voucher_debit": "",
+ "voucher_credit": "",
+ "voucher_desc": "",
+ "draft_no": "",
+ "proof_date": "",
+ }
+ )
+ appended_recheck_ids: set[str] = {recheck_identity(existing_row) for existing_row in row["rows"]}
+ appended_voucher_keys: set[str] = set()
+ for erp_key in row.get("erp_group_keys", set()):
+ for extra_voucher in voucher_only_index.get(erp_key, []):
+ voucher_key = clean(extra_voucher.get("voucher_row_key"))
+ if voucher_key and voucher_key in matched_voucher_keys:
+ continue
+ if voucher_key and voucher_key in appended_voucher_keys:
+ continue
+ if voucher_key:
+ appended_voucher_keys.add(voucher_key)
+ row["rows"].append(
+ {
+ **dict(extra_voucher),
+ "status_label": "ERP Unmatched",
+ "ledger_account_code": "",
+ "ledger_account_name": "",
+ "ledger_vendor": "",
+ "ledger_debit": "",
+ "ledger_credit": "",
+ "ledger_desc": "",
+ "ledger_date": row.get("ledger_date", ""),
+ "voucher_no": row.get("voucher_no", extra_voucher.get("voucher_no", "")),
+ }
+ )
+ for extra_recheck in recheck_by_erp_index.get(erp_key, []):
+ append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids)
+ row["rows"].sort(
+ key=lambda item: (
+ clean(item.get("ledger_date")) or clean(item.get("proof_date")),
+ clean(item.get("voucher_no")),
+ row_origin_rank(item),
+ 0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("ledger_account_name")) else 2,
+ clean(item.get("ledger_account_name")) or clean(item.get("voucher_account_name")),
+ clean(item.get("draft_no")),
+ -parse_amount(item.get("ledger_debit") or item.get("voucher_debit")),
+ -parse_amount(item.get("ledger_credit") or item.get("voucher_credit")),
+ clean(item.get("ledger_desc")) or clean(item.get("voucher_desc")),
+ )
+ )
+ normalized_summary = dict(row)
+ normalized_summary["ledger_accounts"] = ", ".join(row["ledger_accounts"])
+ normalized_summary["voucher_accounts"] = ", ".join(row["voucher_accounts"])
+ normalized_summary["ledger_vendors"] = ", ".join(row["ledger_vendors"])
+ normalized_summary["voucher_vendors"] = ", ".join(row["voucher_vendors"])
+ normalized_summary["review_reason"] = " / ".join(row["review_reason"])
+ normalized_summary["draft_no"] = ", ".join(row["draft_nos"])
+ normalized_group = {
+ "summary": normalized_summary,
+ "rows": list(row["rows"]),
+ }
+ voucher_sections["voucher_recheck"].append(normalized_group)
+
+ erp_grouped: dict[tuple[int, str], dict[str, Any]] = {}
+ for row in rows_by_status.get("matched", []):
+ erp_key = erp_group_key(row)
+ fiscal_year = int(row.get("fiscal_year") or 0)
+ current = erp_grouped.get(erp_key)
+ if current is None:
+ current = {
+ "fiscal_year": fiscal_year,
+ "status_label": "Matched",
+ "ledger_date": clean(row.get("ledger_date")),
+ "proof_date": clean(row.get("proof_date")),
+ "voucher_no": clean(row.get("voucher_no")),
+ "draft_no": clean(row.get("draft_no")),
+ "ledger_row_count": 0,
+ "voucher_row_count": 0,
+ "ledger_debit": 0.0,
+ "ledger_credit": 0.0,
+ "voucher_debit": 0.0,
+ "voucher_credit": 0.0,
+ "ledger_accounts": [],
+ "voucher_accounts": [],
+ "ledger_vendors": [],
+ "voucher_vendors": [],
+ "review_reason": [],
+ "rows": [],
+ "wehago_group_keys": set(),
+ "erp_group_key": erp_key,
+ "voucher_nos": [],
+ "draft_nos": [],
+ }
+ erp_grouped[erp_key] = current
+ if not current.get("ledger_date"):
+ current["ledger_date"] = clean(row.get("ledger_date"))
+ if not current.get("proof_date"):
+ current["proof_date"] = clean(row.get("proof_date"))
+ if not current.get("voucher_no"):
+ current["voucher_no"] = clean(row.get("voucher_no"))
+ if not current.get("draft_no"):
+ current["draft_no"] = clean(row.get("draft_no"))
+ if clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc")):
+ current["ledger_row_count"] += 1
+ if clean(row.get("voucher_account_name")) or clean(row.get("voucher_desc")):
+ current["voucher_row_count"] += 1
+ current["ledger_debit"] += parse_amount(row.get("ledger_debit"))
+ current["ledger_credit"] += parse_amount(row.get("ledger_credit"))
+ current["voucher_debit"] += parse_amount(row.get("voucher_debit"))
+ current["voucher_credit"] += parse_amount(row.get("voucher_credit"))
+ append_unique(current["ledger_accounts"], row.get("ledger_account_name"))
+ append_unique(current["voucher_accounts"], row.get("voucher_account_name"))
+ append_unique(current["ledger_vendors"], row.get("ledger_vendor"))
+ append_unique(current["voucher_vendors"], row.get("voucher_vendor"))
+ append_unique(current["review_reason"], row.get("review_reason"))
+ append_unique(current["voucher_nos"], row.get("voucher_no"))
+ append_unique(current["draft_nos"], row.get("draft_no"))
+ current["wehago_group_keys"].add(wehago_group_key(row))
+ row_payload = dict(row)
+ row_payload.setdefault("status_label", "Matched")
+ current["rows"].append(row_payload)
+
+ for row in erp_grouped.values():
+ appended_ledger_keys: set[str] = set()
+ appended_recheck_ids: set[str] = set()
+ for wehago_key in row.get("wehago_group_keys", set()):
+ for extra_ledger in ledger_only_index.get(wehago_key, []):
+ ledger_key = clean(extra_ledger.get("ledger_row_key"))
+ if ledger_key and ledger_key in matched_ledger_keys:
+ continue
+ if ledger_key and ledger_key in appended_ledger_keys:
+ continue
+ if ledger_key:
+ appended_ledger_keys.add(ledger_key)
+ row["rows"].append(
+ {
+ **dict(extra_ledger),
+ "status_label": "Unmatched",
+ "voucher_account_code": "",
+ "voucher_account_name": "",
+ "voucher_vendor": "",
+ "voucher_debit": "",
+ "voucher_credit": "",
+ "voucher_desc": "",
+ "draft_no": row.get("draft_no", ""),
+ "proof_date": row.get("proof_date", ""),
+ }
+ )
+ for extra_recheck in recheck_by_wehago_index.get(wehago_key, []):
+ append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids)
+ appended_voucher_keys: set[str] = set()
+ for extra_voucher in voucher_only_index.get(row.get("erp_group_key"), []):
+ voucher_key = clean(extra_voucher.get("voucher_row_key"))
+ if voucher_key and voucher_key in matched_voucher_keys:
+ continue
+ if voucher_key and voucher_key in appended_voucher_keys:
+ continue
+ if voucher_key:
+ appended_voucher_keys.add(voucher_key)
+ row["rows"].append(
+ {
+ **dict(extra_voucher),
+ "status_label": "ERP Unmatched",
+ "ledger_account_code": "",
+ "ledger_account_name": "",
+ "ledger_vendor": "",
+ "ledger_debit": "",
+ "ledger_credit": "",
+ "ledger_desc": "",
+ "ledger_date": row.get("ledger_date", ""),
+ "voucher_no": row.get("voucher_no", extra_voucher.get("voucher_no", "")),
+ }
+ )
+ for extra_recheck in recheck_by_erp_index.get(row.get("erp_group_key"), []):
+ append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids)
+ row["rows"].sort(
+ key=lambda item: (
+ clean(item.get("proof_date")) or clean(item.get("ledger_date")),
+ clean(item.get("draft_no")),
+ row_origin_rank(item),
+ 0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("voucher_account_name")) else 2,
+ clean(item.get("voucher_account_name")) or clean(item.get("ledger_account_name")),
+ clean(item.get("voucher_no")),
+ -parse_amount(item.get("voucher_debit") or item.get("ledger_debit")),
+ -parse_amount(item.get("voucher_credit") or item.get("ledger_credit")),
+ clean(item.get("voucher_desc")) or clean(item.get("ledger_desc")),
+ )
+ )
+ normalized_summary = dict(row)
+ normalized_summary["ledger_accounts"] = ", ".join(row["ledger_accounts"])
+ normalized_summary["voucher_accounts"] = ", ".join(row["voucher_accounts"])
+ normalized_summary["ledger_vendors"] = ", ".join(row["ledger_vendors"])
+ normalized_summary["voucher_vendors"] = ", ".join(row["voucher_vendors"])
+ normalized_summary["review_reason"] = " / ".join(row["review_reason"])
+ normalized_summary["voucher_no"] = ", ".join(row["voucher_nos"])
+ normalized_summary["draft_no"] = ", ".join(row["draft_nos"])
+ voucher_sections["erp_voucher_matched"].append(
+ {
+ "summary": normalized_summary,
+ "rows": list(row["rows"]),
+ }
+ )
+ for status_key in voucher_sections:
+ voucher_sections[status_key].sort(
+ key=lambda item: (
+ int(item.get("summary", {}).get("fiscal_year") or 0),
+ clean(item.get("summary", {}).get("proof_date")) or clean(item.get("summary", {}).get("ledger_date")),
+ clean(item.get("summary", {}).get("voucher_no")),
+ clean(item.get("summary", {}).get("draft_no")),
+ )
+ )
+ return voucher_sections
+
+
+def _filter_voucher_status_row(
+ row: dict[str, Any],
+ voucher_filter: str,
+ draft_filter: str,
+ wehago_account_filter: str,
+ erp_account_filter: str,
+ wehago_amount_filter: str,
+ erp_amount_filter: str,
+ wehago_vendor_filter: str,
+ erp_vendor_filter: str,
+ desc_filter: str,
+) -> bool:
+ summary = row.get("summary", row)
+ if voucher_filter and voucher_filter not in normalize_text(row.get("voucher_no")):
+ if voucher_filter not in normalize_text(summary.get("voucher_no")):
+ return False
+ if draft_filter and draft_filter not in normalize_text(summary.get("draft_no")):
+ return False
+ if wehago_account_filter and wehago_account_filter not in normalize_text(summary.get("ledger_accounts")):
+ return False
+ if erp_account_filter and erp_account_filter not in normalize_text(summary.get("voucher_accounts")):
+ return False
+ if wehago_vendor_filter and wehago_vendor_filter not in normalize_text(summary.get("ledger_vendors")):
+ return False
+ if erp_vendor_filter and erp_vendor_filter not in normalize_text(summary.get("voucher_vendors")):
+ return False
+ if desc_filter:
+ haystack = " ".join(
+ [
+ clean(summary.get("review_reason")),
+ clean(summary.get("ledger_accounts")),
+ clean(summary.get("voucher_accounts")),
+ clean(summary.get("ledger_vendors")),
+ clean(summary.get("voucher_vendors")),
+ " ".join(clean(item.get("ledger_desc")) for item in row.get("rows", [])),
+ " ".join(clean(item.get("voucher_desc")) for item in row.get("rows", [])),
+ ]
+ )
+ if desc_filter not in normalize_text(haystack):
+ return False
+ if wehago_amount_filter:
+ amount_text = " ".join(
+ [
+ str(int(round(parse_amount(summary.get("ledger_debit"))))),
+ str(int(round(parse_amount(summary.get("ledger_credit"))))),
+ ]
+ )
+ if clean(wehago_amount_filter) not in amount_text:
+ return False
+ if erp_amount_filter:
+ amount_text = " ".join(
+ [
+ str(int(round(parse_amount(summary.get("voucher_debit"))))),
+ str(int(round(parse_amount(summary.get("voucher_credit"))))),
+ ]
+ )
+ if clean(erp_amount_filter) not in amount_text:
+ return False
+ return True
+
+
+def _build_voucher_status_detail_response_from_rows(
+ rows_by_status: dict[str, list[dict[str, Any]]],
+ status: str,
+ voucher_filter: str,
+ draft_filter: str,
+ wehago_account_filter: str,
+ erp_account_filter: str,
+ wehago_amount_filter: str,
+ erp_amount_filter: str,
+ wehago_vendor_filter: str,
+ erp_vendor_filter: str,
+ desc_filter: str,
+ offset: int,
+ limit: int,
+) -> dict[str, Any]:
+ voucher_sections = _build_voucher_sections_from_rows_by_status(rows_by_status)
+ source_rows = voucher_sections.get(status, [])
+ filtered_rows = [
+ group
+ for group in source_rows
+ if _filter_voucher_status_row(
+ group,
+ voucher_filter,
+ draft_filter,
+ wehago_account_filter,
+ erp_account_filter,
+ wehago_amount_filter,
+ erp_amount_filter,
+ wehago_vendor_filter,
+ erp_vendor_filter,
+ desc_filter,
+ )
+ ]
+ next_offset = offset + min(limit, max(len(filtered_rows) - offset, 0))
+ shown_groups = filtered_rows[offset : offset + limit]
+ return {
+ "columns": DETAIL_COLUMN_MAP[status],
+ "rows": [dict(group.get("summary", {})) for group in shown_groups],
+ "groups": shown_groups,
+ "total_count": len(filtered_rows),
+ "shown_count": len(shown_groups),
+ "offset": offset,
+ "limit": limit,
+ "has_more": next_offset < len(filtered_rows),
+ "next_offset": next_offset,
+ "notice": "",
+ "bank_payable_case_count": 0,
+ "boundary_excluded_count": 0,
+ }
+
+
+def _fetch_voucher_status_detail_rows_from_db(
+ conn: Any,
+ start_year: int,
+ end_year: int,
+ status: str,
+ voucher_filter: str,
+ draft_filter: str,
+ wehago_account_filter: str,
+ erp_account_filter: str,
+ wehago_amount_filter: str,
+ erp_amount_filter: str,
+ wehago_vendor_filter: str,
+ erp_vendor_filter: str,
+ desc_filter: str,
+ offset: int,
+ limit: int,
+) -> dict[str, Any]:
+ if status in {"voucher_matched", "erp_voucher_matched"}:
+ status_condition = "c.status = 'matched'"
+ elif status == "voucher_unmatched":
+ status_condition = "c.status = 'ledger_only'"
+ else:
+ status_condition = "c.status = 'amount_mismatch'"
+ account_filter = normalize_text(wehago_account_filter or erp_account_filter)
+ vendor_filter = normalize_text(wehago_vendor_filter or erp_vendor_filter)
+ amount_filter = clean(wehago_amount_filter or erp_amount_filter)
+ params: dict[str, Any] = {
+ "start_year": start_year,
+ "end_year": end_year,
+ "voucher_no": voucher_filter,
+ "voucher_like": f"%{voucher_filter}%",
+ "draft_no": draft_filter,
+ "draft_like": f"%{draft_filter}%",
+ "account_keyword": account_filter,
+ "account_like": f"%{account_filter}%",
+ "vendor_keyword": vendor_filter,
+ "vendor_like": f"%{vendor_filter}%",
+ "desc_keyword": desc_filter,
+ "desc_like": f"%{desc_filter}%",
+ "amount_keyword": amount_filter,
+ "limit": limit,
+ "offset": offset,
+ }
+ conditions = [
+ build_year_filter_sql("c.fiscal_year"),
+ status_condition,
+ "(:voucher_no = '' OR COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no) LIKE :voucher_like)",
+ "(:draft_no = '' OR COALESCE(v.draft_no, '') LIKE :draft_like)",
+ "(:account_keyword = '' OR COALESCE(c.ledger_accounts, '') LIKE :account_like OR COALESCE(c.voucher_accounts, '') LIKE :account_like)",
+ "(:vendor_keyword = '' OR COALESCE(c.ledger_vendors, '') LIKE :vendor_like OR COALESCE(c.voucher_vendors, '') LIKE :vendor_like)",
+ """(
+ :desc_keyword = ''
+ OR COALESCE(c.notes, '') LIKE :desc_like
+ OR EXISTS (
+ SELECT 1
+ FROM wehago_ledger_rows l2
+ WHERE l2.fiscal_year = c.fiscal_year
+ AND l2.compare_voucher_no = c.voucher_no
+ AND COALESCE(l2.description, '') LIKE :desc_like
+ )
+ OR EXISTS (
+ SELECT 1
+ FROM wehago_voucher_rows v2
+ WHERE v2.fiscal_year = c.fiscal_year
+ AND v2.compare_voucher_no = c.voucher_no
+ AND TRIM(COALESCE(v2.desc1, '') || ' ' || COALESCE(v2.desc2, '')) LIKE :desc_like
+ )
+ )""",
+ """(
+ :amount_keyword = ''
+ OR CAST(ABS(COALESCE(c.ledger_debit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%'
+ OR CAST(ABS(COALESCE(c.ledger_credit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%'
+ OR CAST(ABS(COALESCE(c.voucher_debit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%'
+ OR CAST(ABS(COALESCE(c.voucher_credit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%'
+ )""",
+ ]
+ voucher_rep = """
+ SELECT *
+ FROM (
+ SELECT fiscal_year, compare_voucher_no, proof_date, confirmed_no, draft_no,
+ ROW_NUMBER() OVER (
+ PARTITION BY fiscal_year, compare_voucher_no
+ ORDER BY row_number
+ ) AS rn
+ FROM wehago_voucher_rows
+ WHERE COALESCE(compare_voucher_no, '') <> ''
+ )
+ WHERE rn = 1
+ """
+ ledger_rep = """
+ SELECT *
+ FROM (
+ SELECT fiscal_year, compare_voucher_no, ledger_date, voucher_no,
+ ROW_NUMBER() OVER (
+ PARTITION BY fiscal_year, compare_voucher_no
+ ORDER BY row_number
+ ) AS rn
+ FROM wehago_ledger_rows
+ WHERE COALESCE(compare_voucher_no, '') <> ''
+ )
+ WHERE rn = 1
+ """
+ from_sql = f"""
+ FROM wehago_comparison_results c
+ LEFT JOIN ({ledger_rep}) l
+ ON l.fiscal_year = c.fiscal_year AND l.compare_voucher_no = c.voucher_no
+ LEFT JOIN ({voucher_rep}) v
+ ON v.fiscal_year = c.fiscal_year AND v.compare_voucher_no = c.voucher_no
+ WHERE {' AND '.join(conditions)}
+ """
+ select_sql = """
+ c.fiscal_year AS fiscal_year,
+ CASE c.status
+ WHEN 'matched' THEN 'Matched'
+ WHEN 'ledger_only' THEN 'Unmatched'
+ WHEN 'voucher_only' THEN 'ERP Unmatched'
+ WHEN 'amount_mismatch' THEN 'Recheck'
+ ELSE COALESCE(c.status, '')
+ END AS status_label,
+ COALESCE(l.ledger_date, '') AS ledger_date,
+ COALESCE(v.proof_date, '') AS proof_date,
+ COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no) AS voucher_no,
+ COALESCE(v.draft_no, '') AS draft_no,
+ COALESCE(c.ledger_row_count, 0) AS ledger_row_count,
+ COALESCE(c.voucher_row_count, 0) AS voucher_row_count,
+ COALESCE(c.ledger_debit, 0) AS ledger_debit,
+ COALESCE(c.ledger_credit, 0) AS ledger_credit,
+ COALESCE(c.voucher_debit, 0) AS voucher_debit,
+ COALESCE(c.voucher_credit, 0) AS voucher_credit,
+ COALESCE(c.ledger_accounts, '') AS ledger_accounts,
+ COALESCE(c.voucher_accounts, '') AS voucher_accounts,
+ COALESCE(c.ledger_vendors, '') AS ledger_vendors,
+ COALESCE(c.voucher_vendors, '') AS voucher_vendors,
+ COALESCE(c.notes, '') AS review_reason
+ """
+ total_count = int(conn.execute(text(f"SELECT COUNT(*) {from_sql}"), params).scalar_one() or 0)
+ rows = [
+ {key: clean(value) if isinstance(value, str) else value for key, value in dict(row).items()}
+ for row in conn.execute(
+ text(
+ f"""
+ SELECT {select_sql}
+ {from_sql}
+ ORDER BY c.fiscal_year, c.voucher_no
+ LIMIT :limit OFFSET :offset
+ """
+ ),
+ params,
+ ).mappings().all()
+ ]
+ next_offset = offset + len(rows)
+ return {
+ "columns": DETAIL_COLUMN_MAP[status],
+ "rows": rows,
+ "total_count": total_count,
+ "shown_count": len(rows),
+ "offset": offset,
+ "limit": limit,
+ "has_more": next_offset < total_count,
+ "next_offset": next_offset,
+ "notice": "",
+ "bank_payable_case_count": 0,
+ "boundary_excluded_count": 0,
+ }
+
+
def _apply_broad_query_guard(
rows: list[dict[str, Any]],
voucher_no: str,
@@ -3169,6 +7373,7 @@ def get_status_detail_rows(
erp_vendor: str = "",
desc_keyword: str = "",
review_reason: str = "",
+ boundary_excluded: str = "",
offset: int = 0,
limit: int = 200,
) -> dict[str, Any]:
@@ -3192,10 +7397,14 @@ def get_status_detail_rows(
"ledgeronly": "ledger_only",
"amountmismatch": "amount_mismatch",
"voucheronly": "voucher_only",
+ "vouchermatched": "voucher_matched",
+ "erpvouchermatched": "erp_voucher_matched",
+ "voucherunmatched": "voucher_unmatched",
+ "voucherrecheck": "voucher_recheck",
}
- if normalized_status not in {"matched", "ledger_only", "amount_mismatch", "voucher_only"}:
+ if normalized_status not in {"matched", "ledger_only", "amount_mismatch", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}:
normalized_status = allowed.get(normalized_status, normalized_status)
- if normalized_status not in {"matched", "ledger_only", "amount_mismatch", "voucher_only"}:
+ if normalized_status not in {"matched", "ledger_only", "amount_mismatch", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}:
raise ValueError("상태 값이 올바르지 않습니다.")
voucher_filter = normalize_text(voucher_no)
@@ -3205,11 +7414,80 @@ def get_status_detail_rows(
wehago_vendor_filter = normalize_text(wehago_vendor)
erp_vendor_filter = normalize_text(erp_vendor)
desc_filter = normalize_text(desc_keyword or review_reason)
+ boundary_excluded_filter = clean(boundary_excluded).lower() in {"1", "true", "yes", "y", "on"}
safe_offset = max(int(offset or 0), 0)
safe_limit = max(min(int(limit or 200), 500), 1)
- has_filters = any(
- [
+ with engine.begin() as conn:
+ can_use_resolved_cache = True
+ for year in range(start_year, end_year + 1):
+ cached_sections = _load_year_resolved_sections_cache(conn, year)
+ if cached_sections is None:
+ can_use_resolved_cache = False
+ break
+
+ if normalized_status in {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}:
+ rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
+ voucher_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status)
+ source_rows = voucher_sections.get(normalized_status, [])
+ filtered_rows = [
+ group
+ for group in source_rows
+ if _filter_voucher_status_row(
+ group,
+ voucher_filter,
+ draft_filter,
+ wehago_account_filter,
+ erp_account_filter,
+ clean(wehago_amount),
+ clean(erp_amount),
+ wehago_vendor_filter,
+ erp_vendor_filter,
+ desc_filter,
+ )
+ ]
+ next_offset = safe_offset + min(safe_limit, max(len(filtered_rows) - safe_offset, 0))
+ shown_groups = filtered_rows[safe_offset : safe_offset + safe_limit]
+ return {
+ "columns": DETAIL_COLUMN_MAP[normalized_status],
+ "rows": [dict(group.get("summary", {})) for group in shown_groups],
+ "groups": shown_groups,
+ "total_count": len(filtered_rows),
+ "shown_count": len(shown_groups),
+ "offset": safe_offset,
+ "limit": safe_limit,
+ "has_more": next_offset < len(filtered_rows),
+ "next_offset": next_offset,
+ "notice": "",
+ "bank_payable_case_count": 0,
+ "boundary_excluded_count": 0,
+ }
+ if can_use_resolved_cache:
+ rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
+ return _build_status_detail_response_from_rows(
+ rows_by_status,
+ normalized_status,
+ voucher_filter,
+ draft_filter,
+ wehago_account_filter,
+ erp_account_filter,
+ clean(wehago_amount),
+ clean(erp_amount),
+ wehago_vendor_filter,
+ erp_vendor_filter,
+ desc_filter,
+ boundary_excluded_filter,
+ safe_offset,
+ safe_limit,
+ )
+
+ warm_status_cache_async(engine, start_year, end_year)
+ warm_metric_counts_async(engine, start_year, end_year)
+ return _fetch_status_detail_rows_from_db(
+ conn,
+ start_year,
+ end_year,
+ normalized_status,
voucher_filter,
draft_filter,
wehago_account_filter,
@@ -3219,63 +7497,10 @@ def get_status_detail_rows(
wehago_vendor_filter,
erp_vendor_filter,
desc_filter,
- ]
- )
-
- if not has_filters:
- columns = DETAIL_COLUMN_MAP[normalized_status]
- rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
- total_count = 0
- rows_out: list[dict[str, Any]] = []
- skipped = 0
- needed = safe_limit
- section_rows = rows_by_status.get(normalized_status, [])
- total_count = len(section_rows)
- if section_rows:
- rows_out = section_rows[safe_offset : safe_offset + safe_limit]
- next_offset = safe_offset + len(rows_out)
- return {
- "columns": columns,
- "rows": rows_out,
- "total_count": total_count,
- "shown_count": len(rows_out),
- "offset": safe_offset,
- "limit": safe_limit,
- "has_more": next_offset < total_count,
- "next_offset": next_offset,
- "notice": "",
- }
-
- rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
- collected: list[dict[str, Any]] = []
- columns = DETAIL_COLUMN_MAP[normalized_status]
- for row in rows_by_status.get(normalized_status, []):
- if _filter_status_row(
- row,
- voucher_filter,
- draft_filter,
- wehago_account_filter,
- erp_account_filter,
- clean(wehago_amount),
- clean(erp_amount),
- wehago_vendor_filter,
- erp_vendor_filter,
- desc_filter,
- ):
- collected.append(row)
- shown_rows = collected[safe_offset : safe_offset + safe_limit]
- next_offset = safe_offset + len(shown_rows)
- return {
- "columns": columns,
- "rows": shown_rows,
- "total_count": len(collected),
- "shown_count": len(shown_rows),
- "offset": safe_offset,
- "limit": safe_limit,
- "has_more": next_offset < len(collected),
- "next_offset": next_offset,
- "notice": "",
- }
+ boundary_excluded_filter,
+ safe_offset,
+ safe_limit,
+ )
def get_status_field_suggestions(
@@ -3296,12 +7521,20 @@ def get_status_field_suggestions(
"amountmismatch": "amount_mismatch",
"ledgeronly": "ledger_only",
"voucheronly": "voucher_only",
+ "vouchermatched": "voucher_matched",
+ "erpvouchermatched": "erp_voucher_matched",
+ "voucherunmatched": "voucher_unmatched",
+ "voucherrecheck": "voucher_recheck",
"ledger_only": "ledger_only",
"voucher_only": "voucher_only",
"amount_mismatch": "amount_mismatch",
+ "voucher_matched": "voucher_matched",
+ "erp_voucher_matched": "erp_voucher_matched",
+ "voucher_unmatched": "voucher_unmatched",
+ "voucher_recheck": "voucher_recheck",
}
normalized_status = status_map.get(normalized_status, normalized_status)
- if normalized_status not in {"matched", "amount_mismatch", "ledger_only", "voucher_only"}:
+ if normalized_status not in {"matched", "amount_mismatch", "ledger_only", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}:
raise ValueError("자동완성 상태 값이 올바르지 않습니다.")
normalized_field = normalize_text(field).lower()
@@ -3322,6 +7555,7 @@ def get_status_field_suggestions(
normalized_field,
str(start_year),
str(end_year),
+ _current_logic_signature(),
_build_bundle_signature(start_year, end_year),
normalize_text(keyword),
]
@@ -3330,6 +7564,68 @@ def get_status_field_suggestions(
cached = _SUGGEST_CACHE.get(cache_key)
if cached and (now - float(cached.get("ts", 0))) <= _SUGGEST_CACHE_TTL_SEC:
all_rows = cached["rows"]
+ elif normalized_status in {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}:
+ with engine.begin() as conn:
+ can_use_resolved_cache = True
+ for year in range(start_year, end_year + 1):
+ if _load_year_resolved_sections_cache(conn, year) is None:
+ can_use_resolved_cache = False
+ break
+ if can_use_resolved_cache:
+ source_rows = _build_voucher_sections_from_rows_by_status(
+ _get_cached_status_rows_by_range(engine, start_year, end_year)
+ ).get(normalized_status, [])
+ else:
+ source_rows = _fetch_voucher_status_detail_rows_from_db(
+ conn,
+ start_year,
+ end_year,
+ normalized_status,
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ 0,
+ 100000,
+ ).get("rows", [])
+ keyword_norm = normalize_text(keyword)
+ seen: set[str] = set()
+ all_rows = []
+ for group in source_rows:
+ row = group.get("summary", group)
+ voucher_no = clean(row.get("voucher_no"))
+ draft_no = clean(row.get("draft_no"))
+ if normalized_field == "account":
+ labels = [clean(row.get("ledger_accounts")), clean(row.get("voucher_accounts"))]
+ elif normalized_field == "vendor":
+ labels = [clean(row.get("ledger_vendors")), clean(row.get("voucher_vendors"))]
+ elif normalized_field == "draftno":
+ labels = [draft_no]
+ else:
+ labels = [voucher_no]
+ for label in labels:
+ key = clean(label)
+ if not key or key in seen:
+ continue
+ if keyword_norm and keyword_norm not in normalize_text(key):
+ continue
+ seen.add(key)
+ all_rows.append(
+ {
+ "voucher_no": voucher_no,
+ "code": "",
+ "name": "",
+ "label": key,
+ }
+ )
+ _SUGGEST_CACHE.clear()
+ _PAIR_RECOMMEND_CACHE.clear()
+ _SUGGEST_CACHE[cache_key] = {"ts": now, "rows": all_rows}
else:
rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
keyword_norm = normalize_text(keyword)
@@ -3548,7 +7844,8 @@ def get_erp_filtered_rows(
def _warm_status_cache_worker(engine: Any, start_year: int, end_year: int, warm_key: str) -> None:
try:
- _get_cached_status_rows_by_range(engine, start_year, end_year)
+ rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
+ _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status)
except Exception:
pass
finally:
@@ -3578,6 +7875,9 @@ def get_wehago_compare_dashboard(
engine: Any,
start_year: int | None = None,
end_year: int | None = None,
+ *,
+ include_metric_counts: bool = True,
+ warm_caches: bool = True,
) -> dict[str, Any]:
init_wehago_compare_db(engine)
years = discover_available_years()
@@ -3597,24 +7897,30 @@ def get_wehago_compare_dashboard(
if start_year and end_year and start_year > end_year:
start_year, end_year = end_year, start_year
- rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
+ with engine.begin() as conn:
+ metric_counts = (
+ get_dashboard_metric_counts(conn, start_year, end_year)
+ if include_metric_counts
+ else {status_key: 0 for status_key, _, _ in STATUS_META}
+ )
+ account_options = build_account_options_from_db(conn, start_year, end_year)
+ latest_upload = fetch_latest_upload_meta(conn)
+ last_action = get_last_action_summary(conn=conn)
metric_sections = [
{
"key": status_key,
"label": label,
"description": description,
- "count": len(rows_by_status.get(status_key, [])),
+ "count": int(metric_counts.get(status_key, 0) or 0),
"columns": DETAIL_COLUMN_MAP[status_key],
"rows": [],
}
for status_key, label, description in STATUS_META
]
-
- with engine.begin() as conn:
- account_options = build_account_options_from_db(conn, start_year, end_year)
- latest_upload = fetch_latest_upload_meta(conn)
- last_action = get_last_action_summary(conn=conn)
- warm_status_cache_async(engine, start_year, end_year)
+ if warm_caches:
+ warm_metric_counts_async(engine, start_year, end_year)
+ if start_year == end_year:
+ warm_status_cache_async(engine, start_year, end_year)
return {
"page_title": "전표비교",
@@ -3629,3 +7935,63 @@ def get_wehago_compare_dashboard(
"latest_upload": latest_upload,
"last_action": last_action,
}
+
+
+def get_wehago_compare_summary(
+ engine: Any,
+ start_year: int | None = None,
+ end_year: int | None = None,
+) -> dict[str, Any]:
+ dashboard = get_wehago_compare_dashboard(
+ engine,
+ start_year=start_year,
+ end_year=end_year,
+ include_metric_counts=False,
+ warm_caches=False,
+ )
+ metric_counts, pending = get_dashboard_metric_counts_nonblocking(
+ engine,
+ dashboard.get("selected_start_year"),
+ dashboard.get("selected_end_year"),
+ )
+ metric_sections = [
+ {
+ **section,
+ "count": int(metric_counts.get(section.get("key"), 0) or 0),
+ }
+ for section in dashboard.get("metric_sections", [])
+ ]
+ return {
+ "selected_start_year": dashboard.get("selected_start_year"),
+ "selected_end_year": dashboard.get("selected_end_year"),
+ "metric_sections": metric_sections,
+ "last_action": dashboard.get("last_action"),
+ "pending": pending,
+ }
+
+
+def enqueue_default_pair_recommend_precompute(
+ engine: Any,
+ start_year: int | None = None,
+ end_year: int | None = None,
+ limit: int = 300,
+) -> None:
+ years = discover_available_years()
+ default_year = get_default_year(years)
+ resolved_start = start_year
+ resolved_end = end_year
+ if resolved_start is None and resolved_end is None:
+ resolved_start = default_year
+ resolved_end = default_year
+ elif resolved_start is None:
+ resolved_start = resolved_end
+ elif resolved_end is None:
+ resolved_end = resolved_start
+ if resolved_start is None or resolved_end is None:
+ return
+ enqueue_pair_recommend_precompute(
+ engine,
+ start_year=resolved_start,
+ end_year=resolved_end,
+ limit=limit,
+ )