Keep view mode across tabs and speed up recommendation pipeline
This commit is contained in:
@@ -142,6 +142,48 @@
|
|||||||
line-height: 1.18;
|
line-height: 1.18;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .summary-panel {
|
||||||
|
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
min-height: calc(100vh - 150px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .summary-overview {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .metric-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .summary-overview .stat-card {
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 10px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .summary-overview .stat-card .value {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .chart-stack {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .chart-box {
|
||||||
|
padding: 12px 12px 14px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .chart-svg,
|
||||||
|
body[data-view-mode="dual"] .expense-chart-svg {
|
||||||
|
aspect-ratio: 1560 / 620;
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1000px) {
|
@media (max-width: 1000px) {
|
||||||
.summary-layout,
|
.summary-layout,
|
||||||
.summary-panel,
|
.summary-panel,
|
||||||
|
|||||||
+135
-1
@@ -20,6 +20,9 @@
|
|||||||
--page-gutter: clamp(14px, 1.8vw, 24px);
|
--page-gutter: clamp(14px, 1.8vw, 24px);
|
||||||
--panel-pad: clamp(16px, 1.4vw, 20px);
|
--panel-pad: clamp(16px, 1.4vw, 20px);
|
||||||
--page-frame-width: min(1520px, calc(100vw - (var(--page-gutter) * 2)));
|
--page-frame-width: min(1520px, calc(100vw - (var(--page-gutter) * 2)));
|
||||||
|
--page-frame-width-dual: min(2560px, calc(100vw - (var(--page-gutter) * 2)));
|
||||||
|
--page-frame-width-single: min(1520px, calc(100vw - (var(--page-gutter) * 2)));
|
||||||
|
--status-widget-height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -48,11 +51,28 @@
|
|||||||
padding: var(--page-gutter);
|
padding: var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="single"] {
|
||||||
|
--page-frame-width: var(--page-frame-width-single);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] {
|
||||||
|
--page-frame-width: var(--page-frame-width-dual);
|
||||||
|
--page-gutter: clamp(4px, 0.45vw, 10px);
|
||||||
|
--panel-pad: clamp(12px, 1vw, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
width: min(100%, var(--page-frame-width));
|
width: min(100%, var(--page-frame-width));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--page-gutter);
|
gap: var(--page-gutter);
|
||||||
|
min-height: calc(100vh - (var(--page-gutter) * 2));
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .page {
|
||||||
|
width: calc(100vw - (var(--page-gutter) * 2));
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page > * {
|
.page > * {
|
||||||
@@ -436,13 +456,81 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(248, 250, 252, 0.92);
|
background: rgba(248, 250, 252, 0.92);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
padding: 6px 10px;
|
min-height: var(--status-widget-height);
|
||||||
|
padding: 0 10px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.view-mode-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 80px;
|
||||||
|
height: var(--status-widget-height);
|
||||||
|
min-height: var(--status-widget-height);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #eef2f6;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.16s ease, border-color 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-switch:hover {
|
||||||
|
transform: none;
|
||||||
|
background: #e7ecf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-switch .label {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 3px;
|
||||||
|
width: 36px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
text-align: center;
|
||||||
|
color: #ffffff;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
transition: left 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-switch::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 36px;
|
||||||
|
height: calc(var(--status-widget-height) - 8px);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #111827;
|
||||||
|
box-shadow: 0 6px 12px rgba(15, 23, 42, 0.18);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-switch[data-mode="dual"]::before {
|
||||||
|
transform: translateX(38px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-switch[data-mode="dual"] .label {
|
||||||
|
left: 41px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-switch[data-mode="single"]::before {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
.sync-status-head {
|
.sync-status-head {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -530,6 +618,9 @@
|
|||||||
<a href="/annual-summary" class="{% if request.url.path == '/annual-summary' %}active{% endif %}">연도별 수익/비용</a>
|
<a href="/annual-summary" class="{% if request.url.path == '/annual-summary' %}active{% endif %}">연도별 수익/비용</a>
|
||||||
<a href="/wehago-compare" class="{% if request.url.path == '/wehago-compare' %}active{% endif %}">전표비교</a>
|
<a href="/wehago-compare" class="{% if request.url.path == '/wehago-compare' %}active{% endif %}">전표비교</a>
|
||||||
<div class="nav-spacer"></div>
|
<div class="nav-spacer"></div>
|
||||||
|
<button type="button" class="view-mode-switch" id="viewModeSwitch" data-mode="dual" aria-label="화면 구성 전환">
|
||||||
|
<span class="label" id="viewModeSwitchLabel">듀얼</span>
|
||||||
|
</button>
|
||||||
<aside
|
<aside
|
||||||
class="sync-status"
|
class="sync-status"
|
||||||
id="syncStatusWidget"
|
id="syncStatusWidget"
|
||||||
@@ -561,6 +652,49 @@
|
|||||||
</div>
|
</div>
|
||||||
{% block script %}{% endblock %}
|
{% block script %}{% endblock %}
|
||||||
<script>
|
<script>
|
||||||
|
(() => {
|
||||||
|
const body = document.body;
|
||||||
|
const switchButton = document.getElementById("viewModeSwitch");
|
||||||
|
const switchLabel = document.getElementById("viewModeSwitchLabel");
|
||||||
|
if (!body || !switchButton || !switchLabel) return;
|
||||||
|
|
||||||
|
const MODE_KEY = "intranet-view-mode";
|
||||||
|
const SOURCE_KEY = "intranet-view-mode-source";
|
||||||
|
|
||||||
|
const computeAutoMode = () => {
|
||||||
|
const width = window.innerWidth || 0;
|
||||||
|
const height = window.innerHeight || 1;
|
||||||
|
const ratio = width / Math.max(height, 1);
|
||||||
|
return (width >= 1450 || ratio >= 1.7) ? "dual" : "single";
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyMode = (mode) => {
|
||||||
|
const normalized = mode === "single" ? "single" : "dual";
|
||||||
|
body.dataset.viewMode = normalized;
|
||||||
|
switchButton.dataset.mode = normalized;
|
||||||
|
switchButton.setAttribute("aria-pressed", normalized === "single" ? "true" : "false");
|
||||||
|
switchLabel.textContent = normalized === "single" ? "싱글" : "듀얼";
|
||||||
|
};
|
||||||
|
|
||||||
|
const savedMode = window.localStorage.getItem(MODE_KEY);
|
||||||
|
const savedSource = window.localStorage.getItem(SOURCE_KEY);
|
||||||
|
if ((savedMode === "single" || savedMode === "dual") && savedSource === "manual") {
|
||||||
|
applyMode(savedMode);
|
||||||
|
} else {
|
||||||
|
const autoMode = computeAutoMode();
|
||||||
|
applyMode(autoMode);
|
||||||
|
window.localStorage.setItem(MODE_KEY, autoMode);
|
||||||
|
window.localStorage.setItem(SOURCE_KEY, "auto");
|
||||||
|
}
|
||||||
|
|
||||||
|
switchButton.addEventListener("click", () => {
|
||||||
|
const nextMode = body.dataset.viewMode === "single" ? "dual" : "single";
|
||||||
|
applyMode(nextMode);
|
||||||
|
window.localStorage.setItem(MODE_KEY, nextMode);
|
||||||
|
window.localStorage.setItem(SOURCE_KEY, "manual");
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
const widget = document.getElementById("syncStatusWidget");
|
const widget = document.getElementById("syncStatusWidget");
|
||||||
if (!widget) return;
|
if (!widget) return;
|
||||||
|
|||||||
@@ -182,6 +182,46 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .dashboard-layout {
|
||||||
|
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
min-height: calc(100vh - 150px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .dashboard-status-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .dashboard-status-grid .stat-card {
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 10px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .dashboard-status-grid .stat-card .value {
|
||||||
|
font-size: clamp(16px, 1.15vw, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .dashboard-chart-stack {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .chart-panel {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .chart-shell {
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .chart-svg {
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.dashboard-layout {
|
.dashboard-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
+32
-4
@@ -123,10 +123,10 @@
|
|||||||
color: #363b44;
|
color: #363b44;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: transparent;
|
||||||
border: 1px solid var(--line);
|
border: 0;
|
||||||
border-radius: 10px;
|
border-radius: 0;
|
||||||
padding: 6px 10px;
|
padding: 0;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2298,6 +2298,34 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .overview-row {
|
||||||
|
grid-template-columns: minmax(260px, 300px) minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .metric-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .metric-note-grid {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .chart-stage {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .chart-svg {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .analysis-shell,
|
||||||
|
body[data-view-mode="dual"] .analysis-main,
|
||||||
|
body[data-view-mode="dual"] .analysis-content {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.overview-row,
|
.overview-row,
|
||||||
.analysis-toolbar,
|
.analysis-toolbar,
|
||||||
|
|||||||
@@ -648,6 +648,40 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .voucher-page:has(#pairRecommendPanel.active) {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
column-gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .voucher-page:has(#pairRecommendPanel.active) > .voucher-topbar {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .voucher-page:has(#pairRecommendPanel.active) > .panel-shell:not(#pairRecommendPanel) {
|
||||||
|
grid-column: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .voucher-page:has(#pairRecommendPanel.active) > #pairRecommendPanel.active {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
position: sticky;
|
||||||
|
top: 10px;
|
||||||
|
max-height: calc(100vh - 90px);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] #pairRecommendPanel.active .table-wrap {
|
||||||
|
max-height: calc(100vh - 230px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .detail-panel .table-wrap {
|
||||||
|
max-height: clamp(300px, 44vh, 520px);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.voucher-topbar,
|
.voucher-topbar,
|
||||||
.panel-header,
|
.panel-header,
|
||||||
@@ -1736,6 +1770,8 @@
|
|||||||
const panels = Array.from(document.querySelectorAll('.detail-panel'));
|
const panels = Array.from(document.querySelectorAll('.detail-panel'));
|
||||||
cards.forEach((card) => {
|
cards.forEach((card) => {
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
|
// 상태 탭 전환 시 추천/개별 추천 패널은 즉시 닫는다.
|
||||||
|
closeRecommendationPanel();
|
||||||
const targetId = card.dataset.target;
|
const targetId = card.dataset.target;
|
||||||
const statusKey = card.dataset.status;
|
const statusKey = card.dataset.status;
|
||||||
const panel = targetId ? document.getElementById(targetId) : null;
|
const panel = targetId ? document.getElementById(targetId) : null;
|
||||||
|
|||||||
+193
-37
@@ -145,7 +145,9 @@ _DASHBOARD_CACHE_TTL_SEC = 20
|
|||||||
_SUGGEST_CACHE: dict[str, dict[str, Any]] = {}
|
_SUGGEST_CACHE: dict[str, dict[str, Any]] = {}
|
||||||
_SUGGEST_CACHE_TTL_SEC = 20
|
_SUGGEST_CACHE_TTL_SEC = 20
|
||||||
_STATUS_ROWS_CACHE: dict[str, dict[str, Any]] = {}
|
_STATUS_ROWS_CACHE: dict[str, dict[str, Any]] = {}
|
||||||
_STATUS_ROWS_CACHE_TTL_SEC = 20
|
_STATUS_ROWS_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: set[str] = set()
|
||||||
_STATUS_CACHE_WARMING_LOCK = threading.Lock()
|
_STATUS_CACHE_WARMING_LOCK = threading.Lock()
|
||||||
|
|
||||||
@@ -1000,6 +1002,7 @@ def refresh_wehago_compare_data(engine: Any, source_root: Path | None = None) ->
|
|||||||
_DASHBOARD_CACHE.clear()
|
_DASHBOARD_CACHE.clear()
|
||||||
_SUGGEST_CACHE.clear()
|
_SUGGEST_CACHE.clear()
|
||||||
_STATUS_ROWS_CACHE.clear()
|
_STATUS_ROWS_CACHE.clear()
|
||||||
|
_PAIR_RECOMMEND_CACHE.clear()
|
||||||
return {
|
return {
|
||||||
"scanned_files": summary.scanned_files,
|
"scanned_files": summary.scanned_files,
|
||||||
"imported_files": summary.imported_files,
|
"imported_files": summary.imported_files,
|
||||||
@@ -2189,6 +2192,7 @@ def save_recheck_review_rows(engine: Any, rows: list[dict[str, Any]]) -> int:
|
|||||||
_DASHBOARD_CACHE.clear()
|
_DASHBOARD_CACHE.clear()
|
||||||
_SUGGEST_CACHE.clear()
|
_SUGGEST_CACHE.clear()
|
||||||
_STATUS_ROWS_CACHE.clear()
|
_STATUS_ROWS_CACHE.clear()
|
||||||
|
_PAIR_RECOMMEND_CACHE.clear()
|
||||||
return len(normalized_rows)
|
return len(normalized_rows)
|
||||||
|
|
||||||
|
|
||||||
@@ -2308,6 +2312,7 @@ def save_manual_pair_matches(
|
|||||||
_DASHBOARD_CACHE.clear()
|
_DASHBOARD_CACHE.clear()
|
||||||
_SUGGEST_CACHE.clear()
|
_SUGGEST_CACHE.clear()
|
||||||
_STATUS_ROWS_CACHE.clear()
|
_STATUS_ROWS_CACHE.clear()
|
||||||
|
_PAIR_RECOMMEND_CACHE.clear()
|
||||||
return len(rows_to_save)
|
return len(rows_to_save)
|
||||||
|
|
||||||
|
|
||||||
@@ -2384,6 +2389,7 @@ def undo_last_action(engine: Any) -> dict[str, Any]:
|
|||||||
_DASHBOARD_CACHE.clear()
|
_DASHBOARD_CACHE.clear()
|
||||||
_SUGGEST_CACHE.clear()
|
_SUGGEST_CACHE.clear()
|
||||||
_STATUS_ROWS_CACHE.clear()
|
_STATUS_ROWS_CACHE.clear()
|
||||||
|
_PAIR_RECOMMEND_CACHE.clear()
|
||||||
return {"undone": True, "action_type": action_type, "affected": affected}
|
return {"undone": True, "action_type": action_type, "affected": affected}
|
||||||
|
|
||||||
|
|
||||||
@@ -2416,6 +2422,14 @@ def _jaccard_similarity(left: Any, right: Any) -> float:
|
|||||||
return (inter / union) if union else 0.0
|
return (inter / union) if union else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _jaccard_similarity_tokens(left_tokens: set[str], right_tokens: set[str]) -> float:
|
||||||
|
if not left_tokens or not right_tokens:
|
||||||
|
return 0.0
|
||||||
|
inter = len(left_tokens & right_tokens)
|
||||||
|
union = len(left_tokens | right_tokens)
|
||||||
|
return (inter / union) if union else 0.0
|
||||||
|
|
||||||
|
|
||||||
def _numeric_amount_for_side(row: dict[str, Any], side: str) -> float:
|
def _numeric_amount_for_side(row: dict[str, Any], side: str) -> float:
|
||||||
if side == "debit":
|
if side == "debit":
|
||||||
return parse_amount(row.get("ledger_debit") if "ledger_debit" in row else row.get("voucher_debit"))
|
return parse_amount(row.get("ledger_debit") if "ledger_debit" in row else row.get("voucher_debit"))
|
||||||
@@ -2561,35 +2575,14 @@ def _collect_status_rows_for_workbench(
|
|||||||
) -> dict[str, list[dict[str, Any]]]:
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
if start_year is None or end_year is None:
|
if start_year is None or end_year is None:
|
||||||
return {"ledger_only": [], "voucher_only": []}
|
return {"ledger_only": [], "voucher_only": []}
|
||||||
if engine is not None:
|
|
||||||
init_wehago_compare_db(engine)
|
|
||||||
reviewed_keys: set[str] = set()
|
|
||||||
manual_pair_matches: list[dict[str, Any]] = []
|
|
||||||
if engine is not None:
|
|
||||||
with engine.begin() as conn:
|
|
||||||
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)
|
|
||||||
|
|
||||||
result: dict[str, list[dict[str, Any]]] = {"ledger_only": [], "voucher_only": []}
|
result: dict[str, list[dict[str, Any]]] = {"ledger_only": [], "voucher_only": []}
|
||||||
ledger_voucher_filter = normalize_text(ledger_voucher_no)
|
ledger_voucher_filter = normalize_text(ledger_voucher_no)
|
||||||
ledger_reason_filter = normalize_text(ledger_review_reason)
|
ledger_reason_filter = normalize_text(ledger_review_reason)
|
||||||
voucher_voucher_filter = normalize_text(voucher_voucher_no)
|
voucher_voucher_filter = normalize_text(voucher_voucher_no)
|
||||||
voucher_reason_filter = normalize_text(voucher_review_reason)
|
voucher_reason_filter = normalize_text(voucher_review_reason)
|
||||||
|
|
||||||
for year in range(start_year, end_year + 1):
|
rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
|
||||||
bundle = discover_compare_result_bundle(year)
|
for row in rows_by_status["ledger_only"]:
|
||||||
if not bundle:
|
|
||||||
continue
|
|
||||||
parsed = parse_compare_result_bundle(
|
|
||||||
str(bundle["ledger_result"]),
|
|
||||||
bundle["ledger_result"].stat().st_mtime,
|
|
||||||
str(bundle["voucher_result"]),
|
|
||||||
bundle["voucher_result"].stat().st_mtime,
|
|
||||||
year,
|
|
||||||
)
|
|
||||||
parsed = apply_saved_recheck_reviews(parsed, reviewed_keys)
|
|
||||||
parsed = apply_saved_manual_pair_matches(parsed, manual_pair_matches)
|
|
||||||
for row in parsed["ledger_only"]["rows"]:
|
|
||||||
if _filter_status_row(
|
if _filter_status_row(
|
||||||
row,
|
row,
|
||||||
ledger_voucher_filter,
|
ledger_voucher_filter,
|
||||||
@@ -2603,7 +2596,7 @@ def _collect_status_rows_for_workbench(
|
|||||||
ledger_reason_filter,
|
ledger_reason_filter,
|
||||||
):
|
):
|
||||||
result["ledger_only"].append(row)
|
result["ledger_only"].append(row)
|
||||||
for row in parsed["voucher_only"]["rows"]:
|
for row in rows_by_status["voucher_only"]:
|
||||||
if _filter_status_row(
|
if _filter_status_row(
|
||||||
row,
|
row,
|
||||||
voucher_voucher_filter,
|
voucher_voucher_filter,
|
||||||
@@ -2630,6 +2623,23 @@ def recommend_pair_matches(
|
|||||||
voucher_review_reason: str = "",
|
voucher_review_reason: str = "",
|
||||||
limit: int = 300,
|
limit: int = 300,
|
||||||
) -> dict[str, Any]:
|
) -> 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(
|
dataset = _collect_status_rows_for_workbench(
|
||||||
engine,
|
engine,
|
||||||
start_year,
|
start_year,
|
||||||
@@ -2642,7 +2652,10 @@ def recommend_pair_matches(
|
|||||||
ledger_rows = dataset["ledger_only"]
|
ledger_rows = dataset["ledger_only"]
|
||||||
voucher_rows = dataset["voucher_only"]
|
voucher_rows = dataset["voucher_only"]
|
||||||
if not ledger_rows or not voucher_rows:
|
if not ledger_rows or not voucher_rows:
|
||||||
return {"pairs": [], "stats": {"ledger_rows": len(ledger_rows), "voucher_rows": len(voucher_rows), "recommended": 0, "auto_eligible": 0}}
|
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
|
||||||
|
|
||||||
amount_index: dict[float, list[dict[str, Any]]] = {}
|
amount_index: dict[float, list[dict[str, Any]]] = {}
|
||||||
for voucher_row in voucher_rows:
|
for voucher_row in voucher_rows:
|
||||||
@@ -2655,6 +2668,19 @@ def recommend_pair_matches(
|
|||||||
continue
|
continue
|
||||||
amount_index.setdefault(amount, []).append(voucher_row)
|
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")),
|
||||||
|
)
|
||||||
|
|
||||||
edge_candidates: list[dict[str, Any]] = []
|
edge_candidates: list[dict[str, Any]] = []
|
||||||
for ledger_row in ledger_rows:
|
for ledger_row in ledger_rows:
|
||||||
candidate_amounts = {
|
candidate_amounts = {
|
||||||
@@ -2673,23 +2699,149 @@ def recommend_pair_matches(
|
|||||||
voucher_candidates.append(voucher_row)
|
voucher_candidates.append(voucher_row)
|
||||||
if not voucher_candidates:
|
if not voucher_candidates:
|
||||||
continue
|
continue
|
||||||
scored_candidates: list[tuple[dict[str, Any], dict[str, Any]]] = []
|
|
||||||
|
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_score: dict[str, Any] | None = None
|
||||||
|
second_best_score = -999.0
|
||||||
for voucher_row in voucher_candidates:
|
for voucher_row in voucher_candidates:
|
||||||
score_result = _score_pair_match(ledger_row, voucher_row)
|
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),
|
||||||
|
}
|
||||||
if not score_result["hard_pass"] or score_result["score"] < 72:
|
if not score_result["hard_pass"] or score_result["score"] < 72:
|
||||||
continue
|
continue
|
||||||
scored_candidates.append((voucher_row, score_result))
|
if top_score is None or score_result["score"] > float(top_score["score"]):
|
||||||
if not scored_candidates:
|
second_best_score = float(top_score["score"]) if top_score else second_best_score
|
||||||
|
top_row = voucher_row
|
||||||
|
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:
|
||||||
continue
|
continue
|
||||||
scored_candidates.sort(key=lambda item: item[1]["score"], reverse=True)
|
if top_score["score"] - second_best_score < 6:
|
||||||
top_candidate, top_score = scored_candidates[0]
|
|
||||||
second_score = scored_candidates[1][1]["score"] if len(scored_candidates) > 1 else -999
|
|
||||||
if top_score["score"] - second_score < 6:
|
|
||||||
continue
|
continue
|
||||||
edge_candidates.append(
|
edge_candidates.append(
|
||||||
{
|
{
|
||||||
"ledger_row": ledger_row,
|
"ledger_row": ledger_row,
|
||||||
"voucher_row": top_candidate,
|
"voucher_row": top_row,
|
||||||
"score": top_score["score"],
|
"score": top_score["score"],
|
||||||
"confidence_level": top_score["confidence_level"],
|
"confidence_level": top_score["confidence_level"],
|
||||||
"reason": top_score["reason"],
|
"reason": top_score["reason"],
|
||||||
@@ -2730,10 +2882,10 @@ def recommend_pair_matches(
|
|||||||
"voucher_row": edge["voucher_row"],
|
"voucher_row": edge["voucher_row"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if len(picked) >= max(min(int(limit or 300), 1000), 1):
|
if len(picked) >= safe_limit:
|
||||||
break
|
break
|
||||||
|
|
||||||
return {
|
payload = {
|
||||||
"pairs": picked,
|
"pairs": picked,
|
||||||
"stats": {
|
"stats": {
|
||||||
"ledger_rows": len(ledger_rows),
|
"ledger_rows": len(ledger_rows),
|
||||||
@@ -2743,6 +2895,9 @@ def recommend_pair_matches(
|
|||||||
"high_confidence": sum(1 for row in picked if row["confidence_level"] == "high"),
|
"high_confidence": sum(1 for row in picked if row["confidence_level"] == "high"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
_PAIR_RECOMMEND_CACHE.clear()
|
||||||
|
_PAIR_RECOMMEND_CACHE[cache_key] = {"ts": now, "payload": payload}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def save_recommended_pair_matches(
|
def save_recommended_pair_matches(
|
||||||
@@ -3260,6 +3415,7 @@ def get_status_field_suggestions(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
_SUGGEST_CACHE.clear()
|
_SUGGEST_CACHE.clear()
|
||||||
|
_PAIR_RECOMMEND_CACHE.clear()
|
||||||
_SUGGEST_CACHE[cache_key] = {"ts": now, "rows": all_rows}
|
_SUGGEST_CACHE[cache_key] = {"ts": now, "rows": all_rows}
|
||||||
|
|
||||||
safe_offset = max(int(offset or 0), 0)
|
safe_offset = max(int(offset or 0), 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user