Files
HM_project_Viewer_Board/scripts/reconcile_wehago_projection_to_db.py
T

3196 lines
131 KiB
Python

from __future__ import annotations
import argparse
import json
import re
import sqlite3
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from runtime_config import DB_PATH
from wehago_compare import (
QUERY_PROJECTION_VERSION,
_account_nature_signature,
_account_category_pair_allowed,
_classify_account_category,
_classify_account_family,
_is_wehago_excepted_voucher_group,
_is_vat_family,
_group_has_offset_tax_invoice_structure,
_group_has_tax_invoice_cancel_signal,
_offset_group_vector,
_move_wehago_confirmed_reversal_pairs_to_excepted,
_voucher_group_has_review_reason,
_voucher_group_month_days,
_voucher_groups_within_days,
_nature_compatible,
build_voucher_row_key,
clean,
)
YEAR = 2025
RECONCILED_PROJECTION_VERSION = "db-reconciled-v2"
WEHAGO_STATUSES = ("voucher_matched", "voucher_unmatched", "voucher_recheck", "voucher_excepted")
ERP_STATUSES = ("erp_voucher_matched", "erp_voucher_unmatched")
ALL_VOUCHER_STATUSES = WEHAGO_STATUSES + ERP_STATUSES
RAW_ERP_ROWS_BY_DRAFT_BASE: dict[str, list[dict[str, Any]]] = defaultdict(list)
MANUAL_OFFSET_EXCEPTED_IDENTITIES: set[str] = set()
EXCEPTED_REASON_TOKENS = (
"WEHAGO_EXCEPTED_OFFSET_REVERSAL_PAIR",
"WEHAGO_EXCEPTED_CONFIRMED_REVERSAL_PAIR",
"WEHAGO_EXCEPTED_OFFSET_ENTRY",
"WEHAGO_EXCEPTED_SUBSTITUTION_ENTRY",
"WEHAGO_EXCEPTED_AUDIT_ADJUSTMENT",
"WEHAGO_EXCEPTED_CLOSING_REVERSAL",
"WEHAGO_EXCEPTED_OPENING_BALANCE",
"WEHAGO_EXCEPTED_YEAR_OPENING_SUBSTITUTION",
"WEHAGO_EXCEPTED_EXACT_REVERSAL_PAIR",
"WEHAGO_EXCEPTED_MANAGEMENT_ITEM_REVERSAL_PAIR",
"WEHAGO_EXCEPTED_CANCEL_REISSUE_CANCEL",
"WEHAGO_EXCEPTED_CANCEL_REISSUE_SUPERSEDED",
"MANUAL_OFFSET_PAIR_EXCEPTED",
)
GROUP_COLUMNS = (
"start_year",
"end_year",
"status_key",
"signature",
"group_index",
"fiscal_year",
"ledger_date",
"proof_date",
"voucher_no",
"draft_no",
"ledger_row_count",
"voucher_row_count",
"ledger_debit",
"ledger_credit",
"voucher_debit",
"voucher_credit",
"ledger_accounts",
"voucher_accounts",
"ledger_vendors",
"voucher_vendors",
"review_reason",
"search_text",
)
ROW_COLUMNS = (
"start_year",
"end_year",
"status_key",
"signature",
"group_index",
"row_index",
"fiscal_year",
"status_label",
"ledger_date",
"proof_date",
"voucher_no",
"draft_no",
"ledger_account_name",
"voucher_account_name",
"ledger_vendor",
"voucher_vendor",
"ledger_debit",
"ledger_credit",
"voucher_debit",
"voucher_credit",
"ledger_desc",
"voucher_desc",
"review_reason",
"matched_case",
"ledger_row_key",
"voucher_row_key",
"match_identity_key",
)
def parse_amount(value: Any) -> float:
try:
return float(str(value or "0").replace(",", ""))
except Exception:
return 0.0
def amount_key(value: Any) -> str:
amount = parse_amount(value)
if abs(amount - round(amount)) < 0.0001:
return str(int(round(amount)))
return f"{amount:.2f}".rstrip("0").rstrip(".")
def db_key_from_display(year: int, ledger_date: Any, voucher_no: Any) -> str:
voucher = clean(voucher_no)
if re.fullmatch(r"\d{8}-\d{5}", voucher):
return voucher
date_text = clean(ledger_date)
full_date_match = re.search(r"((?:19|20)\d{2})[-./](\d{1,2})[-./](\d{1,2})", date_text)
date_match = re.search(r"(\d{1,2})[-./](\d{1,2})", date_text)
voucher_digits = re.sub(r"\D+", "", voucher)
if full_date_match and voucher_digits:
return f"{int(full_date_match.group(1)):04d}{int(full_date_match.group(2)):02d}{int(full_date_match.group(3)):02d}-{int(voucher_digits):05d}"
if date_match and voucher_digits:
return f"{int(year):04d}{int(date_match.group(1)):02d}{int(date_match.group(2)):02d}-{int(voucher_digits):05d}"
return ""
def display_date_from_db_key(db_key: str) -> str:
return f"{db_key[4:6]}-{db_key[6:8]}" if re.fullmatch(r"\d{8}-\d{5}", db_key) else ""
def display_voucher_from_db_key(db_key: str) -> str:
return db_key.split("-", 1)[1] if "-" in db_key else db_key
def erp_voucher_base(value: Any) -> str:
text = clean(value)
match = re.fullmatch(r"(11-\d{8}-[^-]+-\d+)-\d+", text)
if match:
return match.group(1)
return text
def dict_row(row: sqlite3.Row) -> dict[str, Any]:
return {key: row[key] for key in row.keys()}
def log_step(message: str) -> None:
print(f"[reconcile] {datetime.now().isoformat(timespec='seconds')} {message}", flush=True)
def latest_projection_signature(cur: sqlite3.Cursor) -> str:
row = cur.execute(
"""
SELECT payload_json
FROM wehago_action_history
WHERE action_type = 'auto_recheck_promote'
ORDER BY id DESC
LIMIT 1
"""
).fetchone()
if row:
try:
payload = json.loads(row[0] or "{}")
signature = clean(payload.get("signature"))
if signature and "|db-reconciled-" not in signature:
exists = cur.execute(
"""
SELECT 1
FROM wehago_compare_query_groups
WHERE start_year = ? AND end_year = ? AND signature = ?
LIMIT 1
""",
(YEAR, YEAR, signature),
).fetchone()
if exists:
return signature
except Exception:
pass
row = cur.execute(
"""
SELECT signature, MAX(updated_at) AS max_updated_at
FROM wehago_compare_query_groups
WHERE start_year = ? AND end_year = ? AND signature LIKE ?
AND signature NOT LIKE '%|db-reconciled-%'
GROUP BY signature
ORDER BY max_updated_at DESC
LIMIT 1
""",
(YEAR, YEAR, f"{QUERY_PROJECTION_VERSION}|%"),
).fetchone()
if not row:
row = cur.execute(
"""
SELECT signature, MAX(updated_at) AS max_updated_at
FROM wehago_compare_query_groups
WHERE start_year = ? AND end_year = ?
AND signature NOT LIKE '%|db-reconciled-%'
GROUP BY signature
ORDER BY max_updated_at DESC
LIMIT 1
""",
(YEAR, YEAR),
).fetchone()
if not row:
settings_row = cur.execute(
"""
SELECT setting_json
FROM wehago_compare_settings
WHERE setting_key = ?
LIMIT 1
""",
(f"wehago_active_query_projection:{YEAR}:{YEAR}",),
).fetchone()
if settings_row:
try:
payload = json.loads(settings_row[0] or "{}")
signature = clean(payload.get("signature")) if isinstance(payload, dict) else ""
if signature:
exists = cur.execute(
"""
SELECT 1
FROM wehago_compare_query_groups
WHERE start_year = ? AND end_year = ? AND signature = ?
LIMIT 1
""",
(YEAR, YEAR, signature),
).fetchone()
if exists:
return signature
except Exception:
pass
if not row:
raise RuntimeError("No current query projection was found.")
return clean(row["signature"])
def load_groups(conn: sqlite3.Connection, signature: str) -> dict[str, list[dict[str, Any]]]:
group_rows = conn.execute(
f"""
SELECT *
FROM wehago_compare_query_groups
WHERE start_year = ? AND end_year = ? AND signature = ?
AND status_key IN ({','.join('?' for _ in ALL_VOUCHER_STATUSES)})
ORDER BY status_key, group_index
""",
(YEAR, YEAR, signature, *ALL_VOUCHER_STATUSES),
).fetchall()
detail_rows = conn.execute(
f"""
SELECT *
FROM wehago_compare_query_rows
WHERE start_year = ? AND end_year = ? AND signature = ?
AND status_key IN ({','.join('?' for _ in ALL_VOUCHER_STATUSES)})
ORDER BY status_key, group_index, row_index
""",
(YEAR, YEAR, signature, *ALL_VOUCHER_STATUSES),
).fetchall()
rows_by_key: dict[tuple[str, int], list[dict[str, Any]]] = defaultdict(list)
for row in detail_rows:
item = dict_row(row)
rows_by_key[(clean(item.get("status_key")), int(item.get("group_index") or 0))].append(item)
groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in group_rows:
summary = dict_row(row)
status_key = clean(summary.get("status_key"))
group_index = int(summary.get("group_index") or 0)
groups[status_key].append(
{
"summary": summary,
"rows": rows_by_key.get((status_key, group_index), []),
"source_group_index": group_index,
}
)
return groups
def load_db_wehago(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
rows = conn.execute(
"""
SELECT *
FROM wehago_comparison_results
WHERE fiscal_year = ?
AND status <> 'voucher_only'
ORDER BY voucher_no
""",
(YEAR,),
).fetchall()
return {clean(row["voucher_no"]): dict_row(row) for row in rows if clean(row["voucher_no"])}
def load_raw_erp_rows_by_draft_base(conn: sqlite3.Connection) -> dict[str, list[dict[str, Any]]]:
result: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in conn.execute(
"""
SELECT *
FROM wehago_voucher_rows
WHERE fiscal_year BETWEEN ? AND ?
AND (COALESCE(draft_no, '') <> '' OR COALESCE(confirmed_no, '') <> '')
ORDER BY row_number
""",
(YEAR - 1, YEAR + 1),
).fetchall():
payload = dict_row(row)
keys = {
erp_voucher_base(payload.get("draft_no")),
erp_voucher_base(payload.get("confirmed_no")),
}
for key in keys:
if key:
result[key].append(payload)
return result
def group_identity(group: dict[str, Any]) -> str:
summary = group.get("summary") or {}
return db_key_from_display(
int(summary.get("fiscal_year") or YEAR),
summary.get("ledger_date"),
summary.get("voucher_no"),
)
def load_manual_offset_excepted_identities(conn: sqlite3.Connection) -> set[str]:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS wehago_manual_offset_excepted (
pair_key TEXT PRIMARY KEY,
left_identity TEXT NOT NULL,
right_identity TEXT NOT NULL,
start_year INTEGER NOT NULL,
end_year INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
identities: set[str] = set()
for row in conn.execute(
"""
SELECT left_identity, right_identity
FROM wehago_manual_offset_excepted
"""
).fetchall():
identities.update(filter(None, (clean(row["left_identity"]), clean(row["right_identity"]))))
return identities
def row_wehago_identity(row: dict[str, Any], fallback_group: dict[str, Any] | None = None) -> str:
row_key = db_key_from_display(
int(row.get("fiscal_year") or YEAR),
row.get("ledger_date"),
row.get("voucher_no"),
)
if row_key:
return row_key
if fallback_group:
return group_identity(fallback_group)
return ""
def has_erp_value(row: dict[str, Any]) -> bool:
return bool(clean(row.get("voucher_account_name"))) and (
abs(parse_amount(row.get("voucher_debit"))) > 0.0001
or abs(parse_amount(row.get("voucher_credit"))) > 0.0001
)
def has_wehago_value(row: dict[str, Any]) -> bool:
return bool(clean(row.get("ledger_account_name"))) and (
abs(parse_amount(row.get("ledger_debit"))) > 0.0001
or abs(parse_amount(row.get("ledger_credit"))) > 0.0001
)
def blank_erp_side(row: dict[str, Any], reason: str) -> dict[str, Any]:
payload = dict(row)
payload["status_label"] = "Unmatched"
payload["proof_date"] = ""
payload["draft_no"] = ""
payload["voucher_account_name"] = ""
payload["voucher_vendor"] = ""
payload["voucher_debit"] = 0
payload["voucher_credit"] = 0
payload["voucher_desc"] = ""
payload["voucher_row_key"] = ""
payload["match_identity_key"] = ""
payload["review_reason"] = reason
return payload
def blank_wehago_side(row: dict[str, Any], reason: str) -> dict[str, Any]:
payload = dict(row)
payload["status_label"] = "ERP Unmatched"
payload["ledger_date"] = ""
payload["ledger_account_name"] = ""
payload["ledger_vendor"] = ""
payload["ledger_debit"] = 0
payload["ledger_credit"] = 0
payload["ledger_desc"] = ""
payload["ledger_row_key"] = ""
payload["match_identity_key"] = ""
payload["review_reason"] = reason
return payload
def ledger_row_identity(row: dict[str, Any]) -> tuple[Any, ...]:
row_key = clean(row.get("ledger_row_key"))
if row_key:
return ("key", row_key)
return (
"side",
clean(row.get("ledger_account_name")),
clean(row.get("ledger_vendor")),
amount_key(row.get("ledger_debit")),
amount_key(row.get("ledger_credit")),
clean(row.get("ledger_desc")),
)
def voucher_row_identity(row: dict[str, Any]) -> tuple[Any, ...]:
row_key = clean(row.get("voucher_row_key"))
if row_key:
return ("key", row_key)
return (
"side",
clean(row.get("draft_no")),
clean(row.get("voucher_account_name")),
clean(row.get("voucher_vendor")),
amount_key(row.get("voucher_debit")),
amount_key(row.get("voucher_credit")),
clean(row.get("voucher_desc")),
)
def direct_row_score(row: dict[str, Any]) -> tuple[int, float, int]:
same_amount = int(
abs(parse_amount(row.get("ledger_debit")) - parse_amount(row.get("voucher_debit"))) < 0.5
and abs(parse_amount(row.get("ledger_credit")) - parse_amount(row.get("voucher_credit"))) < 0.5
)
amount = max(
abs(parse_amount(row.get("ledger_debit"))),
abs(parse_amount(row.get("ledger_credit"))),
abs(parse_amount(row.get("voucher_debit"))),
abs(parse_amount(row.get("voucher_credit"))),
)
has_vendor = int(bool(clean(row.get("ledger_vendor"))) and clean(row.get("ledger_vendor")) == clean(row.get("voucher_vendor")))
return same_amount, amount, has_vendor
def effective_account_side(row: dict[str, Any], prefix: str) -> str:
net_debit = parse_amount(row.get(f"{prefix}_debit")) - parse_amount(row.get(f"{prefix}_credit"))
if net_debit > 0.0001:
return "debit"
if net_debit < -0.0001:
return "credit"
return "either"
def direct_row_matches_account_nature(row: dict[str, Any]) -> bool:
if not (has_wehago_value(row) and has_erp_value(row)):
return True
if not _account_category_pair_allowed(
"",
row.get("ledger_account_name"),
"",
row.get("voucher_account_name"),
):
return False
return _nature_compatible(
"",
row.get("ledger_account_name"),
effective_account_side(row, "ledger"),
"",
row.get("voucher_account_name"),
effective_account_side(row, "voucher"),
)
def compact_account_name(value: Any) -> str:
return normalized_compact(re.sub(r"^\d+\s*", "", clean(value)))
def row_account_category(row: dict[str, Any], prefix: str) -> str:
account_name = clean(row.get(f"{prefix}_account_name"))
compact = compact_account_name(account_name)
if any(marker in compact for marker in ("세금과공과", "퇴직금", "퇴직급여", "임금", "제수당", "보험료", "보증수수료")):
return "expense"
return _classify_account_category("", account_name)
def row_account_family(row: dict[str, Any], prefix: str) -> str:
return _classify_account_family("", row.get(f"{prefix}_account_name"))
def is_business_category(category: str) -> bool:
return category in {"expense", "revenue"}
def is_vat_row(row: dict[str, Any], prefix: str) -> bool:
return _is_vat_family(row_account_family(row, prefix))
def is_business_row(row: dict[str, Any], prefix: str) -> bool:
family = row_account_family(row, prefix)
if _is_vat_family(family) or family in {"bank", "payable", "receivable", "advance"}:
return False
return is_business_category(row_account_category(row, prefix))
def is_settlement_row(row: dict[str, Any], prefix: str) -> bool:
family = row_account_family(row, prefix)
category = row_account_category(row, prefix)
return family in {"bank", "payable", "receivable", "advance"} or category in {"asset", "liability"}
def row_principle_signature(row: dict[str, Any], prefix: str) -> str:
return _account_nature_signature("", row.get(f"{prefix}_account_name"), effective_account_side(row, prefix))
def row_business_match_key(row: dict[str, Any], prefix: str) -> tuple[str, str, str, str]:
return (
row_account_category(row, prefix),
compact_account_name(row.get(f"{prefix}_account_name")),
amount_key(row_side_amount(row, prefix)),
normalized_compact(row.get(f"{prefix}_desc")),
)
def row_principle_pair_allowed(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> bool:
ledger_category = row_account_category(ledger_row, "ledger")
voucher_category = row_account_category(voucher_row, "voucher")
ledger_family = row_account_family(ledger_row, "ledger")
voucher_family = row_account_family(voucher_row, "voucher")
if _is_vat_family(ledger_family) or _is_vat_family(voucher_family):
return _is_vat_family(ledger_family) and _is_vat_family(voucher_family)
if ledger_category != voucher_category or ledger_category not in {"expense", "revenue"}:
return False
ledger_account = compact_account_name(ledger_row.get("ledger_account_name"))
voucher_account = compact_account_name(voucher_row.get("voucher_account_name"))
if (
ledger_category == "expense"
and effective_account_side(ledger_row, "ledger") == effective_account_side(voucher_row, "voucher")
and (
("보증수수료" in ledger_account and ("보험료" in voucher_account or "보증보험" in voucher_account))
or ("보증수수료" in voucher_account and ("보험료" in ledger_account or "보증보험" in ledger_account))
)
):
return True
return _nature_compatible(
"",
ledger_row.get("ledger_account_name"),
effective_account_side(ledger_row, "ledger"),
"",
voucher_row.get("voucher_account_name"),
effective_account_side(voucher_row, "voucher"),
)
def group_has_wehago_rows(group: dict[str, Any]) -> bool:
return any(has_wehago_value(row) for row in group.get("rows") or [])
def group_has_real_erp_candidate(group: dict[str, Any]) -> bool:
return any(has_erp_value(row) for row in group.get("rows") or [])
def group_has_unmatched_wehago_rows(group: dict[str, Any]) -> bool:
return any(has_wehago_value(row) and not has_erp_value(row) for row in group.get("rows") or [])
def group_review_text(group: dict[str, Any]) -> str:
return " ".join(
[
clean((group.get("summary") or {}).get("review_reason")),
*(clean(row.get("review_reason")) for row in group.get("rows") or []),
]
)
def group_has_excepted_reason(group: dict[str, Any]) -> bool:
review_text = group_review_text(group)
return any(token in review_text for token in EXCEPTED_REASON_TOKENS)
def row_side_amount(row: dict[str, Any], prefix: str) -> float:
return max(
abs(parse_amount(row.get(f"{prefix}_debit"))),
abs(parse_amount(row.get(f"{prefix}_credit"))),
)
def raw_erp_entry_amount_side(entry: dict[str, Any], category: str) -> tuple[float, str]:
fields = (
("debit_supply", "debit"),
("credit_supply", "credit"),
)
if category in {"asset", "liability"}:
fields = (
("debit_supply", "debit"),
("credit_supply", "credit"),
("debit_tax", "debit"),
("credit_tax", "credit"),
)
for field, side in fields:
amount = parse_amount(entry.get(field))
if abs(amount) >= 0.5:
return abs(amount), side
return 0.0, "either"
def raw_erp_entry_signed_amount_side(entry: dict[str, Any], category: str) -> tuple[float, str]:
fields = (
("debit_supply", "debit"),
("credit_supply", "credit"),
)
if category in {"asset", "liability"}:
fields = (
("debit_supply", "debit"),
("credit_supply", "credit"),
("debit_tax", "debit"),
("credit_tax", "credit"),
)
for field, side in fields:
amount = parse_amount(entry.get(field))
if abs(amount) >= 0.5:
return amount, side
return 0.0, "either"
def raw_erp_entry_to_row(entry: dict[str, Any], ledger_row: dict[str, Any], side: str, reason: str) -> dict[str, Any]:
amount, _entry_side = raw_erp_entry_amount_side(
entry,
_classify_account_category(entry.get("account_code"), entry.get("account_name")),
)
desc = " ".join(part for part in (clean(entry.get("desc1")), clean(entry.get("desc2"))) if part)
row = dict(ledger_row)
row.update(
{
"proof_date": clean(entry.get("proof_date")),
"draft_no": clean(entry.get("draft_no")) or clean(entry.get("confirmed_no")),
"voucher_account_code": clean(entry.get("account_code")),
"voucher_account_name": clean(entry.get("account_name")),
"voucher_vendor": clean(entry.get("vendor_name")),
"voucher_debit": amount if side == "debit" else 0,
"voucher_credit": amount if side == "credit" else 0,
"voucher_desc": desc,
"status_label": "Matched",
"review_reason": reason,
}
)
row["voucher_row_key"] = build_voucher_row_key(row)
row["match_identity_key"] = "|".join(
clean(part)
for part in (
row.get("fiscal_year"),
row.get("voucher_no"),
row.get("ledger_row_key"),
row.get("draft_no"),
row.get("voucher_row_key"),
)
if clean(part)
)
return row
def normalized_compact(value: Any) -> str:
return re.sub(r"\s+", "", clean(value)).lower()
def row_draft_bases(rows: list[dict[str, Any]], summary: dict[str, Any]) -> set[str]:
bases: set[str] = set()
for value in [summary.get("draft_no"), *(row.get("draft_no") for row in rows)]:
for part in re.split(r"[,/]\s*", clean(value)):
base = erp_voucher_base(part)
if base:
bases.add(base)
return bases
def dates_match_wehago(proof_date: Any, ledger_date: Any, fiscal_year: int) -> bool:
proof = clean(proof_date)
ledger = clean(ledger_date)
if not proof or not ledger:
return False
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", proof):
proof_key = proof
else:
matched = re.search(r"(20\d{2})[-./]?(\d{1,2})[-./]?(\d{1,2})", proof)
if not matched:
return False
proof_key = f"{int(matched.group(1)):04d}-{int(matched.group(2)):02d}-{int(matched.group(3)):02d}"
if re.fullmatch(r"\d{1,2}[-./]\d{1,2}", ledger):
month, day = re.split(r"[-./]", ledger)
ledger_key = f"{int(fiscal_year):04d}-{int(month):02d}-{int(day):02d}"
elif re.fullmatch(r"\d{4}-\d{2}-\d{2}", ledger):
ledger_key = ledger
else:
return False
return proof_key == ledger_key
def revenue_entry_semantically_matches(row: dict[str, Any], entry: dict[str, Any]) -> bool:
ledger_account = normalized_compact(row.get("ledger_account_name"))
ledger_desc = normalized_compact(row.get("ledger_desc"))
entry_text = normalized_compact(" ".join([clean(entry.get("account_name")), clean(entry.get("desc1")), clean(entry.get("desc2"))]))
if "주차" in ledger_account:
return "주차" in entry_text
if "임대" in ledger_account:
return ("임대" in entry_text or "관리비" in entry_text) and "주차" not in entry_text
if "관리" in ledger_account:
return "관리" in entry_text
ledger_tokens = {token for token in re.split(r"[^0-9a-z가-힣]+", ledger_account + " " + ledger_desc) if len(token) >= 2}
entry_tokens = {token for token in re.split(r"[^0-9a-z가-힣]+", entry_text) if len(token) >= 2}
return bool(ledger_tokens & entry_tokens)
def find_amount_matching_subset(
entries: list[dict[str, Any]],
target_amount: float,
category: str,
) -> tuple[list[dict[str, Any]], str] | None:
candidates: list[tuple[dict[str, Any], float, str]] = []
for entry in entries[:12]:
amount, side = raw_erp_entry_amount_side(entry, category)
if amount > 0:
candidates.append((entry, amount, side))
best: tuple[list[dict[str, Any]], str] | None = None
for mask in range(1, 1 << len(candidates)):
selected: list[dict[str, Any]] = []
total = 0.0
side = ""
for index, (entry, amount, entry_side) in enumerate(candidates):
if not (mask & (1 << index)):
continue
if side and entry_side != side:
selected = []
break
side = entry_side
selected.append(entry)
total += amount
if not selected or len(selected) < 2:
continue
if abs(total - target_amount) < 0.5:
if best is None or len(selected) < len(best[0]):
best = (selected, side)
return best
def retarget_to_same_draft_split_revenue(
row: dict[str, Any],
group_rows: list[dict[str, Any]],
summary: dict[str, Any],
) -> dict[str, Any] | None:
if not has_wehago_value(row) or has_erp_value(row):
return None
ledger_category = _classify_account_category("", row.get("ledger_account_name"))
if ledger_category != "revenue":
return None
ledger_side = effective_account_side(row, "ledger")
ledger_amount = row_side_amount(row, "ledger")
if ledger_side not in {"debit", "credit"} or ledger_amount <= 0:
return None
fiscal_year = int(row.get("fiscal_year") or summary.get("fiscal_year") or YEAR)
bases = row_draft_bases(group_rows, summary)
for base in sorted(bases):
entries = [
entry
for entry in RAW_ERP_ROWS_BY_DRAFT_BASE.get(base, [])
if _classify_account_category(entry.get("account_code"), entry.get("account_name")) == "revenue"
and dates_match_wehago(entry.get("proof_date"), row.get("ledger_date") or summary.get("ledger_date"), fiscal_year)
and revenue_entry_semantically_matches(row, entry)
and _nature_compatible("", row.get("ledger_account_name"), ledger_side, entry.get("account_code"), entry.get("account_name"), ledger_side)
and (
not clean(row.get("ledger_vendor"))
or not clean(entry.get("vendor_name"))
or normalized_compact(row.get("ledger_vendor")) in normalized_compact(entry.get("vendor_name"))
or normalized_compact(entry.get("vendor_name")) in normalized_compact(row.get("ledger_vendor"))
)
]
matched = find_amount_matching_subset(entries, ledger_amount, "revenue")
if matched is None:
continue
selected, side = matched
if side != ledger_side:
continue
payload = dict(row)
payload["proof_date"] = clean(selected[0].get("proof_date"))
payload["draft_no"] = ", ".join(clean(entry.get("draft_no")) or clean(entry.get("confirmed_no")) for entry in selected if clean(entry.get("draft_no")) or clean(entry.get("confirmed_no")))
payload["voucher_account_name"] = ", ".join(clean(entry.get("account_name")) for entry in selected if clean(entry.get("account_name")))
payload["voucher_vendor"] = clean(selected[0].get("vendor_name"))
payload["voucher_debit"] = ledger_amount if side == "debit" else 0
payload["voucher_credit"] = ledger_amount if side == "credit" else 0
payload["voucher_desc"] = " / ".join(
clean(" ".join(part for part in (entry.get("desc1"), entry.get("desc2")) if clean(part)))
for entry in selected
if clean(entry.get("desc1")) or clean(entry.get("desc2"))
)
payload["status_label"] = "Matched"
payload["review_reason"] = "PROJECTION_RECONCILE_SPLIT_REVENUE_SAME_DRAFT_PROOF_DATE"
payload["voucher_row_key"] = build_voucher_row_key(payload)
payload["match_identity_key"] = "|".join(
clean(part)
for part in (
payload.get("fiscal_year"),
payload.get("voucher_no"),
payload.get("ledger_row_key"),
payload.get("draft_no"),
payload.get("voucher_row_key"),
)
if clean(part)
)
return payload
return None
def apply_same_draft_split_revenue_matches(
rows: list[dict[str, Any]],
summary: dict[str, Any],
) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for row in rows:
retargeted = retarget_to_same_draft_split_revenue(row, rows, summary)
result.append(retargeted if retargeted is not None else row)
return result
def retarget_to_same_draft_business_account(row: dict[str, Any]) -> dict[str, Any] | None:
if not (has_wehago_value(row) and clean(row.get("draft_no"))):
return None
ledger_category = _classify_account_category("", row.get("ledger_account_name"))
if ledger_category not in {"expense", "revenue"}:
return None
ledger_side = effective_account_side(row, "ledger")
ledger_amount = row_side_amount(row, "ledger")
if ledger_amount <= 0:
return None
candidate_bases = {
erp_voucher_base(part)
for part in re.split(r"[,/]\s*", clean(row.get("draft_no")))
if clean(part)
}
best: tuple[int, dict[str, Any], str] | None = None
for base in candidate_bases:
for entry in RAW_ERP_ROWS_BY_DRAFT_BASE.get(base, []):
voucher_category = _classify_account_category(entry.get("account_code"), entry.get("account_name"))
if voucher_category != ledger_category:
continue
amount, side = raw_erp_entry_amount_side(entry, voucher_category)
if abs(amount - ledger_amount) >= 0.5:
continue
if not _nature_compatible("", row.get("ledger_account_name"), ledger_side, entry.get("account_code"), entry.get("account_name"), side):
continue
score = 0
if clean(row.get("ledger_vendor")) and clean(row.get("ledger_vendor")) in clean(entry.get("vendor_name")):
score += 10
if clean(entry.get("vendor_name")) and clean(entry.get("vendor_name")) in clean(row.get("ledger_vendor")):
score += 10
if clean(row.get("ledger_desc")) and clean(row.get("ledger_desc")) == clean(" ".join(part for part in (entry.get("desc1"), entry.get("desc2")) if clean(part))):
score += 20
if best is None or score > best[0]:
best = (score, entry, side)
if best is None:
if ledger_category != "expense":
return None
grouped_best: tuple[int, dict[str, Any], dict[str, Any], str] | None = None
for base in candidate_bases:
entries = RAW_ERP_ROWS_BY_DRAFT_BASE.get(base, [])
business_entries = [
entry
for entry in entries
if _classify_account_category(entry.get("account_code"), entry.get("account_name")) == "expense"
]
if not business_entries:
continue
for payable_entry in entries:
if _classify_account_family(payable_entry.get("account_code"), payable_entry.get("account_name")) != "payable":
continue
amount, _payable_side = raw_erp_entry_amount_side(payable_entry, "liability")
if abs(amount - ledger_amount) >= 0.5:
continue
for business_entry in business_entries:
business_amount, business_side = raw_erp_entry_amount_side(business_entry, "expense")
if business_amount + 0.5 < ledger_amount:
continue
if not _nature_compatible("", row.get("ledger_account_name"), ledger_side, business_entry.get("account_code"), business_entry.get("account_name"), business_side):
continue
score = 0
payee = clean(payable_entry.get("vendor_name"))
ledger_text = f"{clean(row.get('ledger_vendor'))} {clean(row.get('ledger_desc'))}"
if payee and payee in ledger_text:
score += 40
if clean(payable_entry.get("desc1")) and clean(payable_entry.get("desc1")) in ledger_text:
score += 20
if clean(business_entry.get("account_name")) and "여비교통비" in clean(business_entry.get("account_name")) and "여비교통비" in clean(row.get("ledger_account_name")):
score += 20
if grouped_best is None or score > grouped_best[0]:
grouped_best = (score, business_entry, payable_entry, business_side)
if grouped_best is None:
return None
business_entry = dict(grouped_best[1])
payable_entry = grouped_best[2]
business_entry["debit_supply"] = ledger_amount if grouped_best[3] == "debit" else 0
business_entry["credit_supply"] = ledger_amount if grouped_best[3] == "credit" else 0
business_entry["vendor_name"] = clean(payable_entry.get("vendor_name")) or clean(business_entry.get("vendor_name"))
business_entry["desc1"] = clean(payable_entry.get("desc1")) or clean(business_entry.get("desc1"))
business_entry["management_item"] = clean(payable_entry.get("management_item")) or clean(business_entry.get("management_item"))
return raw_erp_entry_to_row(
business_entry,
row,
grouped_best[3],
"PROJECTION_RECONCILE_RETARGET_GROUPED_EXPENSE_PAYABLE_DETAIL",
)
return raw_erp_entry_to_row(best[1], row, best[2], "PROJECTION_RECONCILE_RETARGET_SAME_DRAFT_BUSINESS_ACCOUNT")
def allowed_erp_drafts_for_group(rows: list[dict[str, Any]]) -> set[str]:
erp_rows = [row for row in rows if has_erp_value(row) and clean(row.get("draft_no"))]
exception_rows: dict[str, set[tuple[Any, ...]]] = defaultdict(set)
for row in rows:
family = _classify_account_family("", row.get("ledger_account_name"))
category = _classify_account_category("", row.get("ledger_account_name"))
bucket = "vat" if _is_vat_family(family) else category if category in {"expense", "asset"} else ""
if bucket:
exception_rows[bucket].add(ledger_row_identity(row))
capacity = max(1, *(len(row_keys) for row_keys in exception_rows.values())) if exception_rows else 1
drafts: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in erp_rows:
drafts[erp_voucher_base(row.get("draft_no"))].append(row)
ranked = sorted(
drafts,
key=lambda draft: (
sum(direct_row_score(row)[0] for row in drafts[draft]),
sum(direct_row_score(row)[1] for row in drafts[draft]),
sum(direct_row_score(row)[2] for row in drafts[draft]),
-len(drafts[draft]),
draft,
),
reverse=True,
)
return set(ranked[: max(1, capacity)])
def clean_group_rows(group: dict[str, Any], status_key: str) -> dict[str, Any]:
rows = [dict(row) for row in group.get("rows") or []]
summary = dict(group.get("summary") or {})
if status_key == "voucher_matched":
normalized_rows: list[dict[str, Any]] = []
for row in rows:
if direct_row_matches_account_nature(row):
normalized_rows.append(row)
continue
retargeted = retarget_to_same_draft_business_account(row)
if retargeted is not None:
normalized_rows.append(retargeted)
else:
normalized_rows.append(blank_erp_side(row, "PROJECTION_RECONCILE_INVALID_ACCOUNT_NATURE"))
rows = normalized_rows
rows = apply_same_draft_split_revenue_matches(rows, summary)
allowed_drafts = allowed_erp_drafts_for_group(rows)
cleaned: list[dict[str, Any]] = []
for row in rows:
if clean(row.get("voucher_account_name")) and (
not has_erp_value(row)
or (clean(row.get("draft_no")) and erp_voucher_base(row.get("draft_no")) not in allowed_drafts)
):
if has_wehago_value(row):
cleaned.append(blank_erp_side(row, "PROJECTION_RECONCILE_UNMATCHED_ERP_OVER_CAP"))
continue
else:
cleaned.append(row)
direct_drafts = {
erp_voucher_base(row.get("draft_no"))
for row in cleaned
if has_wehago_value(row) and has_erp_value(row) and clean(row.get("draft_no"))
}
rows = [
row for row in cleaned
if has_wehago_value(row)
or not has_erp_value(row)
or erp_voucher_base(row.get("draft_no")) in direct_drafts
]
elif status_key == "voucher_recheck":
cleaned = []
for row in rows:
if clean(row.get("voucher_account_name")) and not has_erp_value(row):
if has_wehago_value(row):
cleaned.append(blank_erp_side(row, "PROJECTION_RECONCILE_RECHECK_NO_REAL_ERP_CANDIDATE"))
continue
cleaned.append(row)
rows = apply_same_draft_split_revenue_matches(cleaned, summary)
seen_ledger: set[tuple[Any, ...]] = set()
seen_voucher: set[tuple[Any, ...]] = set()
deduped: list[dict[str, Any]] = []
for row in rows:
ledger_key = ledger_row_identity(row) if clean(row.get("ledger_account_name")) else None
voucher_key = voucher_row_identity(row) if clean(row.get("voucher_account_name")) else None
if ledger_key and ledger_key in seen_ledger:
continue
if voucher_key and voucher_key in seen_voucher:
continue
if ledger_key:
seen_ledger.add(ledger_key)
if voucher_key:
seen_voucher.add(voucher_key)
if clean(row.get("ledger_account_name")) or clean(row.get("voucher_account_name")):
deduped.append(row)
group = {"summary": dict(group.get("summary") or {}), "rows": deduped, "source_group_index": group.get("source_group_index")}
group["summary"] = rebuild_summary(group, status_key)
return group
def split_group_by_wehago_voucher(group: dict[str, Any], status_key: str) -> dict[str, dict[str, Any]]:
rows_by_identity: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in group.get("rows") or []:
identity = row_wehago_identity(row, group)
if identity:
rows_by_identity[identity].append(dict(row))
if not rows_by_identity:
identity = group_identity(group)
if identity:
rows_by_identity[identity] = [dict(row) for row in group.get("rows") or []]
split_groups: dict[str, dict[str, Any]] = {}
old_summary = dict(group.get("summary") or {})
for identity, rows in rows_by_identity.items():
if not rows:
continue
ledger_date = display_date_from_db_key(identity)
voucher_no = display_voucher_from_db_key(identity)
normalized_rows: list[dict[str, Any]] = []
for row in rows:
payload = dict(row)
if clean(payload.get("ledger_account_name")):
payload["ledger_date"] = clean(payload.get("ledger_date")) or ledger_date
payload["voucher_no"] = clean(payload.get("voucher_no")) or voucher_no
elif clean(payload.get("voucher_account_name")):
payload["ledger_date"] = clean(payload.get("ledger_date")) or ledger_date
payload["voucher_no"] = clean(payload.get("voucher_no")) or voucher_no
normalized_rows.append(payload)
summary = {
**old_summary,
"fiscal_year": YEAR,
"ledger_date": ledger_date,
"voucher_no": voucher_no,
"draft_no": "",
"ledger_accounts": "",
"voucher_accounts": "",
"ledger_vendors": "",
"voucher_vendors": "",
"review_reason": clean(old_summary.get("review_reason")),
"search_text": "",
}
split_group = {
"summary": summary,
"rows": normalized_rows,
"source_group_index": group.get("source_group_index"),
}
split_group["summary"] = rebuild_summary(split_group, status_key)
split_groups[identity] = split_group
return split_groups
def normalized_wehago_status(status_key: str, group: dict[str, Any]) -> str:
if group_identity(group) in MANUAL_OFFSET_EXCEPTED_IDENTITIES:
return "voucher_excepted"
if group_has_excepted_reason(group):
return "voucher_excepted"
is_excepted, _reason = _is_wehago_excepted_voucher_group(group)
if is_excepted:
return "voucher_excepted"
has_wehago = group_has_wehago_rows(group)
has_real_erp_candidate = group_has_real_erp_candidate(group)
if not has_wehago:
return "voucher_unmatched"
review_text = group_review_text(group)
if status_key == "voucher_matched":
recheck_tokens = (
"PROJECTION_RECONCILE_INVALID_ACCOUNT_NATURE",
"PROJECTION_RECONCILE_UNMATCHED_ERP_OVER_CAP",
"PROJECTION_RECONCILE_PARTIAL_MATCH_WEHAGO_ROW_UNMATCHED",
)
if has_real_erp_candidate and not any(token in review_text for token in recheck_tokens):
return "voucher_matched"
return "voucher_recheck" if has_real_erp_candidate else "voucher_unmatched"
if status_key in {"voucher_unmatched", "voucher_excepted"}:
return "voucher_recheck" if has_real_erp_candidate else "voucher_unmatched"
if status_key != "voucher_recheck":
return status_key
if any(
reason in review_text
for reason in (
"MATCHED_CANCEL_TARGET_RECHECK",
"CANCEL_TARGET_ALREADY_MATCHED_RECHECK",
"CANCEL_REISSUE_RETARGET_RECHECK",
)
):
return "voucher_recheck"
if (
"PROJECTION_RECONCILE_SPLIT_REVENUE_SAME_DRAFT_PROOF_DATE" in review_text
and has_real_erp_candidate
and not group_has_unmatched_wehago_rows(group)
):
return "voucher_matched"
return "voucher_recheck" if has_real_erp_candidate else "voucher_unmatched"
def mark_excepted(group: dict[str, Any], reason: str) -> dict[str, Any]:
summary = dict(group.get("summary") or {})
for field in ("proof_date", "draft_no", "voucher_accounts", "voucher_vendors"):
summary[field] = ""
for field in ("voucher_row_count", "voucher_debit", "voucher_credit"):
summary[field] = 0
rows: list[dict[str, Any]] = []
for row in group.get("rows") or []:
if not clean(row.get("ledger_account_name")):
continue
row_payload = dict(row)
for field in ("proof_date", "draft_no", "voucher_account_name", "voucher_vendor", "voucher_desc", "voucher_row_key", "match_identity_key"):
row_payload[field] = ""
row_payload["voucher_debit"] = 0
row_payload["voucher_credit"] = 0
rows.append(row_payload)
payload = {"summary": summary, "rows": rows}
payload["summary"]["status_label"] = "Excepted"
existing_reason = clean(payload["summary"].get("review_reason"))
payload["summary"]["review_reason"] = dedup_review_reason_text(existing_reason, reason)
for row in payload["rows"]:
row["status_label"] = "Excepted"
row_reason = clean(row.get("review_reason"))
row["review_reason"] = dedup_review_reason_text(row_reason, reason)
payload["summary"] = rebuild_summary(payload, "voucher_excepted")
return payload
def retag_group(group: dict[str, Any], status_key: str, reason: str = "") -> dict[str, Any]:
payload = {
"summary": dict(group.get("summary") or {}),
"rows": [dict(row) for row in group.get("rows") or []],
}
label = "Matched" if status_key == "voucher_matched" else "Recheck" if status_key == "voucher_recheck" else "Unmatched"
existing_reason = clean(payload["summary"].get("review_reason"))
if reason and reason not in existing_reason:
payload["summary"]["review_reason"] = " / ".join(item for item in (existing_reason, reason) if item)
for row in payload["rows"]:
row["status_label"] = label
row_reason = clean(row.get("review_reason"))
if reason and reason not in row_reason:
row["review_reason"] = " / ".join(item for item in (row_reason, reason) if item)
payload["summary"] = rebuild_summary(payload, status_key)
return payload
def group_ledger_net_by_account(group: dict[str, Any]) -> dict[str, float]:
result: dict[str, float] = defaultdict(float)
for row in group.get("rows") or []:
account = clean(row.get("ledger_account_name")).replace(" ", "")
if not account or not has_wehago_value(row):
continue
result[account] += parse_amount(row.get("ledger_debit")) - parse_amount(row.get("ledger_credit"))
return {key: value for key, value in result.items() if abs(value) >= 0.5}
def group_erp_net_mapped_by_ledger_account(group: dict[str, Any]) -> dict[str, float]:
result: dict[str, float] = defaultdict(float)
for row in group.get("rows") or []:
account = clean(row.get("ledger_account_name")).replace(" ", "")
if not account or not has_wehago_value(row) or not has_erp_value(row):
continue
result[account] += parse_amount(row.get("voucher_debit")) - parse_amount(row.get("voucher_credit"))
return {key: value for key, value in result.items() if abs(value) >= 0.5}
def group_ledger_vendors(group: dict[str, Any]) -> set[str]:
return {
clean(row.get("ledger_vendor")).replace(" ", "")
for row in group.get("rows") or []
if clean(row.get("ledger_vendor"))
}
def groups_within_days(left: dict[str, Any], right: dict[str, Any], days: int) -> bool:
def as_date(group: dict[str, Any]) -> datetime | None:
summary = group.get("summary") or {}
year = int(summary.get("fiscal_year") or YEAR)
text_value = clean(summary.get("ledger_date"))
try:
return datetime.strptime(f"{year}-{text_value}", "%Y-%m-%d")
except ValueError:
return None
left_date = as_date(left)
right_date = as_date(right)
return bool(left_date and right_date and abs((left_date - right_date).days) <= days)
def apply_net_adjustment_component_matches(status_groups: dict[str, list[dict[str, Any]]]) -> None:
matched_groups = list(status_groups.get("voucher_matched") or [])
candidate_statuses = ("voucher_unmatched", "voucher_recheck")
candidate_groups = [
(status_key, group)
for status_key in candidate_statuses
for group in list(status_groups.get(status_key) or [])
]
matched_index: dict[tuple[tuple[str, ...], str], list[tuple[dict[str, Any], dict[str, float], dict[str, float]]]] = defaultdict(list)
for matched in matched_groups:
matched_net = group_ledger_net_by_account(matched)
erp_net = group_erp_net_mapped_by_ledger_account(matched)
vendors = group_ledger_vendors(matched)
if not matched_net or set(erp_net) != set(matched_net) or not vendors:
continue
account_key = tuple(sorted(matched_net))
for vendor in vendors:
matched_index[(account_key, vendor)].append((matched, matched_net, erp_net))
moves: dict[int, tuple[dict[str, Any], str]] = {}
for candidate_status, candidate in candidate_groups:
candidate_net = group_ledger_net_by_account(candidate)
if not candidate_net or not any(value < -0.5 for value in candidate_net.values()):
continue
candidate_vendors = group_ledger_vendors(candidate)
if not candidate_vendors:
continue
possible: dict[int, tuple[dict[str, Any], dict[str, float], dict[str, float]]] = {}
account_key = tuple(sorted(candidate_net))
for vendor in candidate_vendors:
for matched, matched_net, erp_net in matched_index.get((account_key, vendor), []):
possible[id(matched)] = (matched, matched_net, erp_net)
matches: list[dict[str, Any]] = []
for matched, matched_net, erp_net in possible.values():
if not groups_within_days(matched, candidate, 93):
continue
if all(abs(matched_net[key] + candidate_net[key] - erp_net[key]) < 0.5 for key in matched_net):
matches.append(matched)
if len(matches) != 1:
continue
matched_summary = matches[0].get("summary") or {}
draft_no = clean(matched_summary.get("draft_no"))
reason = "NET_ADJUSTMENT_COMPONENT_MATCH"
adjusted = retag_group(candidate, "voucher_matched", reason)
if draft_no:
adjusted["summary"]["draft_no"] = draft_no
adjusted["summary"]["search_text"] = f"{clean(adjusted['summary'].get('search_text'))} {draft_no} {reason}".strip()
moves[id(candidate)] = (adjusted, candidate_status)
if not moves:
return
for status_key in candidate_statuses:
status_groups[status_key] = [
group for group in list(status_groups.get(status_key) or []) if id(group) not in moves
]
status_groups["voucher_matched"] = list(status_groups.get("voucher_matched") or []) + [
adjusted for adjusted, _source_status in moves.values()
]
def move_excepted_groups(status_groups: dict[str, list[dict[str, Any]]]) -> None:
excepted: list[dict[str, Any]] = []
relaxed_from_excepted: dict[str, list[dict[str, Any]]] = defaultdict(list)
for group in list(status_groups.get("voucher_excepted") or []):
existing_reason = clean((group.get("summary") or {}).get("review_reason"))
if group_identity(group) in MANUAL_OFFSET_EXCEPTED_IDENTITIES:
excepted.append(mark_excepted(group, "MANUAL_OFFSET_PAIR_EXCEPTED"))
continue
if "WEHAGO_EXCEPTED_OFFSET" in existing_reason or "WEHAGO_EXCEPTED_CONFIRMED_REVERSAL_PAIR" in existing_reason:
excepted.append(mark_excepted(group, ""))
continue
is_excepted, reason = _is_wehago_excepted_voucher_group(group)
if is_excepted:
excepted.append(mark_excepted(group, reason))
else:
next_status = normalized_wehago_status("voucher_recheck", group)
relaxed_from_excepted[next_status].append(
retag_group(group, next_status, "PROJECTION_RECONCILE_EXCEPTED_RULE_RELAXED")
)
retained_by_status: dict[str, list[dict[str, Any]]] = {}
for status_key in ("voucher_matched", "voucher_unmatched", "voucher_recheck"):
retained: list[dict[str, Any]] = []
for group in list(status_groups.get(status_key) or []):
if group_identity(group) in MANUAL_OFFSET_EXCEPTED_IDENTITIES:
excepted.append(mark_excepted(group, "MANUAL_OFFSET_PAIR_EXCEPTED"))
continue
is_excepted, reason = _is_wehago_excepted_voucher_group(group)
if is_excepted:
excepted.append(mark_excepted(group, reason))
else:
retained.append(group)
retained_by_status[status_key] = retained
for relaxed_status, relaxed_groups in relaxed_from_excepted.items():
if relaxed_status == "voucher_excepted":
excepted.extend(mark_excepted(group, "") for group in relaxed_groups)
else:
retained_by_status[relaxed_status].extend(relaxed_groups)
confirmed = _move_wehago_confirmed_reversal_pairs_to_excepted(
{
"voucher_matched": retained_by_status["voucher_matched"],
"voucher_unmatched": retained_by_status["voucher_unmatched"],
"voucher_recheck": retained_by_status["voucher_recheck"],
"voucher_excepted": excepted,
}
)
retained_by_status["voucher_matched"] = list(confirmed.get("voucher_matched") or [])
retained_by_status["voucher_unmatched"] = list(confirmed.get("voucher_unmatched") or [])
retained_by_status["voucher_recheck"] = list(confirmed.get("voucher_recheck") or [])
excepted = list(confirmed.get("voucher_excepted") or [])
apply_net_adjustment_component_matches(retained_by_status)
moved = move_offset_tax_invoice_groups_to_excepted_fast(
{
"voucher_unmatched": retained_by_status["voucher_unmatched"],
"voucher_recheck": retained_by_status["voucher_recheck"],
"voucher_excepted": excepted,
}
)
moved = move_exact_wehago_reversal_pairs_to_excepted(moved)
status_groups["voucher_matched"] = retained_by_status["voucher_matched"]
status_groups["voucher_unmatched"] = list(moved.get("voucher_unmatched") or [])
status_groups["voucher_recheck"] = list(moved.get("voucher_recheck") or [])
status_groups["voucher_excepted"] = [
mark_excepted(group, "") for group in list(moved.get("voucher_excepted") or [])
]
def offset_vector_key(vector: dict[tuple[str, str, str], float], *, sign: int = 1) -> tuple[tuple[tuple[str, str, str], float], ...]:
return tuple(sorted((row_key, round(sign * amount, 4)) for row_key, amount in vector.items()))
def offset_vector_abs_key(vector: dict[tuple[str, str, str], float]) -> tuple[tuple[tuple[str, str, str], float], ...]:
return tuple(sorted((row_key, round(abs(amount), 4)) for row_key, amount in vector.items()))
def group_date_key(group: dict[str, Any]) -> str:
summary = group.get("summary") or {}
fiscal_year = int(summary.get("fiscal_year") or YEAR)
date_text = clean(summary.get("ledger_date")) or clean(summary.get("proof_date"))
if re.fullmatch(r"\d{1,2}[-./]\d{1,2}", date_text):
month, day = re.split(r"[-./]", date_text)
return f"{fiscal_year:04d}-{int(month):02d}-{int(day):02d}"
if re.fullmatch(r"\d{4}[-./]\d{1,2}[-./]\d{1,2}", date_text):
year, month, day = re.split(r"[-./]", date_text)
return f"{int(year):04d}-{int(month):02d}-{int(day):02d}"
return date_text
def group_vendor_key(group: dict[str, Any]) -> str:
vendors = {
normalized_compact(row.get("ledger_vendor"))
for row in group.get("rows") or []
if clean(row.get("ledger_vendor"))
}
if not vendors:
vendors = {normalized_compact((group.get("summary") or {}).get("ledger_vendors"))}
vendors.discard("")
return "|".join(sorted(vendors))
def group_desc_key(group: dict[str, Any]) -> str:
descs = {
normalized_compact(row.get("ledger_desc"))
for row in group.get("rows") or []
if clean(row.get("ledger_desc"))
}
descs.discard("")
return "|".join(sorted(descs))
def group_reversal_context_key(group: dict[str, Any]) -> tuple[str, str, str]:
return group_date_key(group), group_vendor_key(group), group_desc_key(group)
def group_sequence_key(group: dict[str, Any]) -> tuple[str, int]:
identity = group_identity(group)
if re.fullmatch(r"\d{8}-\d{5}", identity):
return identity[:8], int(identity.split("-", 1)[1])
summary = group.get("summary") or {}
return group_date_key(group).replace("-", ""), int(re.sub(r"\D+", "", clean(summary.get("voucher_no"))) or 0)
def group_ledger_direction(group: dict[str, Any]) -> int:
total = 0.0
for row in group.get("rows") or []:
if not clean(row.get("ledger_account_name")):
continue
total += parse_amount(row.get("ledger_debit"))
total += parse_amount(row.get("ledger_credit"))
if total > 0.5:
return 1
if total < -0.5:
return -1
return 0
def group_has_erp_rows(group: dict[str, Any]) -> bool:
return any(has_wehago_value(row) and has_erp_value(row) for row in group.get("rows") or [])
def copy_erp_side(source: dict[str, Any], target: dict[str, Any], reason: str) -> dict[str, Any]:
payload = dict(target)
for field in (
"proof_date",
"draft_no",
"voucher_account_name",
"voucher_vendor",
"voucher_debit",
"voucher_credit",
"voucher_desc",
"voucher_row_key",
):
payload[field] = source.get(field)
payload["status_label"] = "Matched"
payload["review_reason"] = reason
payload["match_identity_key"] = "|".join(
clean(part)
for part in (
payload.get("fiscal_year"),
payload.get("voucher_no"),
payload.get("ledger_row_key"),
payload.get("draft_no"),
payload.get("voucher_row_key"),
)
if clean(part)
)
return payload
def retarget_erp_rows_to_final_reissue(final_group: dict[str, Any], donor_groups: list[dict[str, Any]]) -> dict[str, Any]:
if group_has_erp_rows(final_group):
return final_group
donor_rows = [
row
for donor in donor_groups
for row in donor.get("rows") or []
if has_erp_value(row)
]
if not donor_rows:
return final_group
used: set[int] = set()
updated_rows: list[dict[str, Any]] = []
changed = False
for row in final_group.get("rows") or []:
if not has_wehago_value(row) or has_erp_value(row):
updated_rows.append(dict(row))
continue
ledger_amount = row_side_amount(row, "ledger")
ledger_side = effective_account_side(row, "ledger")
best_index = -1
best_score: tuple[int, int, float] | None = None
for index, donor_row in enumerate(donor_rows):
if index in used:
continue
if abs(row_side_amount(donor_row, "voucher") - ledger_amount) >= 0.5:
continue
if effective_account_side(donor_row, "voucher") != ledger_side:
continue
if not _account_category_pair_allowed("", row.get("ledger_account_name"), "", donor_row.get("voucher_account_name")):
continue
if not _nature_compatible(
"",
row.get("ledger_account_name"),
ledger_side,
"",
donor_row.get("voucher_account_name"),
ledger_side,
):
continue
vendor_match = int(
not clean(row.get("ledger_vendor"))
or not clean(donor_row.get("voucher_vendor"))
or normalized_compact(row.get("ledger_vendor")) in normalized_compact(donor_row.get("voucher_vendor"))
or normalized_compact(donor_row.get("voucher_vendor")) in normalized_compact(row.get("ledger_vendor"))
)
desc_match = int(
not clean(row.get("ledger_desc"))
or not clean(donor_row.get("voucher_desc"))
or normalized_compact(row.get("ledger_desc")) in normalized_compact(donor_row.get("voucher_desc"))
or normalized_compact(donor_row.get("voucher_desc")) in normalized_compact(row.get("ledger_desc"))
)
score = (vendor_match, desc_match, ledger_amount)
if best_score is None or score > best_score:
best_index = index
best_score = score
if best_index < 0:
updated_rows.append(dict(row))
continue
used.add(best_index)
updated_rows.append(copy_erp_side(donor_rows[best_index], row, "PROJECTION_RECONCILE_CANCEL_REISSUE_RETARGET_FINAL"))
changed = True
if not changed:
return final_group
payload = {
"summary": dict(final_group.get("summary") or {}),
"rows": updated_rows,
"source_group_index": final_group.get("source_group_index"),
}
payload["summary"] = rebuild_summary(payload, "voucher_matched")
return payload
def apply_cancel_reissue_final_match(status_groups: dict[str, list[dict[str, Any]]]) -> None:
candidates: list[tuple[str, dict[str, Any], tuple[str, str, tuple[tuple[tuple[str, str, str], float], ...]]]] = []
for status_key in WEHAGO_STATUSES:
if status_key == "voucher_excepted":
continue
for group in status_groups.get(status_key) or []:
vector = _offset_group_vector(group)
if not vector:
continue
vendor_key = group_vendor_key(group)
desc_key = group_desc_key(group)
if not vendor_key or not desc_key:
continue
direction = group_ledger_direction(group)
if direction == 0:
continue
candidates.append((status_key, group, (vendor_key, desc_key, offset_vector_abs_key(vector))))
if not candidates:
return
by_chain: dict[tuple[str, str, tuple[tuple[tuple[str, str, str], float], ...]], list[tuple[str, dict[str, Any]]]] = defaultdict(list)
for status_key, group, chain_key in candidates:
by_chain[chain_key].append((status_key, group))
move_reasons: dict[int, str] = {}
retargeted: dict[int, dict[str, Any]] = {}
for chain_groups in by_chain.values():
positives = [(status_key, group) for status_key, group in chain_groups if group_ledger_direction(group) > 0]
negatives = [(status_key, group) for status_key, group in chain_groups if group_ledger_direction(group) < 0]
if not positives or not negatives:
continue
positives_sorted = sorted(positives, key=lambda item: group_sequence_key(item[1]))
final_status, final_group = positives_sorted[-1]
if final_status == "voucher_excepted":
continue
donor_groups = [group for _status_key, group in positives_sorted[:-1] if group_has_erp_rows(group)]
adjusted_final = retarget_erp_rows_to_final_reissue(final_group, donor_groups)
if adjusted_final is not final_group:
retargeted[id(final_group)] = adjusted_final
final_has_erp = group_has_erp_rows(adjusted_final)
if not final_has_erp and not any(group_has_erp_rows(group) for _status_key, group in positives_sorted):
continue
for _status_key, group in negatives:
move_reasons[id(group)] = "WEHAGO_EXCEPTED_CANCEL_REISSUE_CANCEL"
for _status_key, group in positives_sorted[:-1]:
move_reasons[id(group)] = "WEHAGO_EXCEPTED_CANCEL_REISSUE_SUPERSEDED"
if not move_reasons and not retargeted:
return
rewritten: dict[str, list[dict[str, Any]]] = defaultdict(list)
for status_key in WEHAGO_STATUSES:
for group in status_groups.get(status_key) or []:
if id(group) in move_reasons:
rewritten["voucher_excepted"].append(mark_excepted(group, move_reasons[id(group)]))
continue
adjusted = retargeted.get(id(group), group)
final_status = normalized_wehago_status(status_key, adjusted)
rewritten[final_status].append(retag_group(adjusted, final_status))
for status_key in WEHAGO_STATUSES:
status_groups[status_key] = rewritten.get(status_key, [])
def accrual_pair_score(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> tuple[int, int, int, float]:
if not row_principle_pair_allowed(ledger_row, voucher_row):
return (-1, -1, -1, 0.0)
if abs(row_side_amount(ledger_row, "ledger") - row_side_amount(voucher_row, "voucher")) >= 0.5:
return (-1, -1, -1, 0.0)
same_account = int(
compact_account_name(ledger_row.get("ledger_account_name")) == compact_account_name(voucher_row.get("voucher_account_name"))
or compact_account_name(ledger_row.get("ledger_account_name")) in compact_account_name(voucher_row.get("voucher_account_name"))
or compact_account_name(voucher_row.get("voucher_account_name")) in compact_account_name(ledger_row.get("ledger_account_name"))
)
vendor_match = int(
not clean(ledger_row.get("ledger_vendor"))
or not clean(voucher_row.get("voucher_vendor"))
or normalized_compact(ledger_row.get("ledger_vendor")) in normalized_compact(voucher_row.get("voucher_vendor"))
or normalized_compact(voucher_row.get("voucher_vendor")) in normalized_compact(ledger_row.get("ledger_vendor"))
)
desc_match = int(
not clean(ledger_row.get("ledger_desc"))
or not clean(voucher_row.get("voucher_desc"))
or normalized_compact(ledger_row.get("ledger_desc")) in normalized_compact(voucher_row.get("voucher_desc"))
or normalized_compact(voucher_row.get("voucher_desc")) in normalized_compact(ledger_row.get("ledger_desc"))
)
return same_account, vendor_match, desc_match, row_side_amount(ledger_row, "ledger")
def merge_accrual_pair(ledger_row: dict[str, Any], voucher_row: dict[str, Any], reason: str) -> dict[str, Any]:
payload = dict(ledger_row)
for field in (
"proof_date",
"draft_no",
"voucher_account_name",
"voucher_vendor",
"voucher_debit",
"voucher_credit",
"voucher_desc",
"voucher_row_key",
):
payload[field] = voucher_row.get(field)
payload["status_label"] = "Matched"
payload["review_reason"] = reason
payload["match_identity_key"] = "|".join(
clean(part)
for part in (
payload.get("fiscal_year"),
payload.get("voucher_no"),
payload.get("ledger_row_key"),
payload.get("draft_no"),
payload.get("voucher_row_key"),
)
if clean(part)
)
return payload
def remove_recheck_reason_tokens(row: dict[str, Any], reason: str) -> dict[str, Any]:
blocked = {
"PROJECTION_RECONCILE_UNMATCHED_ERP_OVER_CAP",
"PROJECTION_RECONCILE_PARTIAL_MATCH_WEHAGO_ROW_UNMATCHED",
"PROJECTION_RECONCILE_INVALID_ACCOUNT_NATURE",
}
kept: list[str] = []
append_review_reason_parts(kept, row.get("review_reason"))
kept = [part for part in kept if part not in blocked]
append_review_reason_parts(kept, reason)
payload = dict(row)
payload["review_reason"] = " / ".join(kept)
payload["status_label"] = "Matched"
return payload
def strip_review_reason_tokens_from_group(group: dict[str, Any], blocked: set[str]) -> dict[str, Any]:
payload = {
"summary": dict(group.get("summary") or {}),
"rows": [dict(row) for row in group.get("rows") or []],
"source_group_index": group.get("source_group_index"),
}
for target in [payload["summary"], *payload["rows"]]:
parts: list[str] = []
append_review_reason_parts(parts, target.get("review_reason"))
target["review_reason"] = " / ".join(part for part in parts if part not in blocked)
return payload
def build_accrual_principle_match(group: dict[str, Any], reason: str) -> dict[str, Any] | None:
rows = [dict(row) for row in group.get("rows") or []]
ledger_business = [
row for row in rows
if has_wehago_value(row) and (is_business_row(row, "ledger") or is_vat_row(row, "ledger"))
]
voucher_business = [
row for row in rows
if has_erp_value(row) and (is_business_row(row, "voucher") or is_vat_row(row, "voucher"))
]
if not ledger_business or not voucher_business:
return None
pair_candidates: list[tuple[tuple[int, int, int, float], int, int]] = []
for ledger_index, ledger_row in enumerate(ledger_business):
for voucher_index, voucher_row in enumerate(voucher_business):
score = accrual_pair_score(ledger_row, voucher_row)
if score[0] < 0:
continue
pair_candidates.append((score, ledger_index, voucher_index))
if not pair_candidates:
return None
pair_candidates.sort(reverse=True)
used_ledger: set[int] = set()
used_voucher: set[int] = set()
merged_rows: list[dict[str, Any]] = []
covered_ledger_keys: set[tuple[str, str, str, str]] = set()
covered_voucher_keys: set[tuple[str, str, str, str]] = set()
for _score, ledger_index, voucher_index in pair_candidates:
if ledger_index in used_ledger or voucher_index in used_voucher:
continue
ledger_row = ledger_business[ledger_index]
voucher_row = voucher_business[voucher_index]
used_ledger.add(ledger_index)
used_voucher.add(voucher_index)
covered_ledger_keys.add(row_business_match_key(ledger_row, "ledger"))
covered_voucher_keys.add(row_business_match_key(voucher_row, "voucher"))
merged_rows.append(merge_accrual_pair(ledger_row, voucher_row, reason))
if not merged_rows:
return None
unmatched_voucher_business = [
row for index, row in enumerate(voucher_business)
if index not in used_voucher and row_business_match_key(row, "voucher") not in covered_voucher_keys
]
if unmatched_voucher_business:
return None
for index, ledger_row in enumerate(ledger_business):
if index in used_ledger:
continue
ledger_key = row_business_match_key(ledger_row, "ledger")
if ledger_key in covered_ledger_keys:
continue
if row_principle_signature(ledger_row, "ledger").endswith(":decrease"):
continue
return None
context_rows: list[dict[str, Any]] = []
seen_context: set[tuple[Any, ...]] = set()
for row in rows:
if has_wehago_value(row) and (is_business_row(row, "ledger") or is_vat_row(row, "ledger")):
key = row_business_match_key(row, "ledger")
if key in covered_ledger_keys or row_principle_signature(row, "ledger").endswith(":decrease"):
continue
return None
if has_erp_value(row) and (is_business_row(row, "voucher") or is_vat_row(row, "voucher")):
key = row_business_match_key(row, "voucher")
if key in covered_voucher_keys:
continue
return None
ledger_context = has_wehago_value(row) and is_settlement_row(row, "ledger")
voucher_context = has_erp_value(row) and is_settlement_row(row, "voucher")
if not ledger_context and not voucher_context:
continue
context = remove_recheck_reason_tokens(row, reason)
context_key = (
ledger_row_identity(context) if has_wehago_value(context) else None,
voucher_row_identity(context) if has_erp_value(context) else None,
)
if context_key in seen_context:
continue
seen_context.add(context_key)
context_rows.append(context)
payload = {
"summary": dict(group.get("summary") or {}),
"rows": merged_rows + context_rows,
"source_group_index": group.get("source_group_index"),
}
payload["summary"] = rebuild_summary(payload, "voucher_matched")
return payload
def index_erp_matched_accrual_donors(groups: dict[str, list[dict[str, Any]]]) -> dict[str, list[dict[str, Any]]]:
donors: dict[str, list[dict[str, Any]]] = defaultdict(list)
for group in groups.get("erp_voucher_matched") or []:
identity = group_identity(group)
if not identity:
continue
donors[identity].append(group)
return donors
def build_requested_draft_donor_match(group: dict[str, Any], donor: dict[str, Any]) -> dict[str, Any] | None:
requested_bases = row_draft_bases(group.get("rows") or [], group.get("summary") or {})
if not requested_bases:
return None
filtered_rows = [
dict(row)
for row in donor.get("rows") or []
if clean(row.get("draft_no")) and erp_voucher_base(row.get("draft_no")) in requested_bases
]
if not filtered_rows:
return None
filtered = {
"summary": dict(donor.get("summary") or {}),
"rows": filtered_rows,
"source_group_index": donor.get("source_group_index"),
}
filtered["summary"] = rebuild_summary(filtered, "voucher_matched")
return build_accrual_principle_match(filtered, "PROJECTION_RECONCILE_ACCRUAL_PRINCIPLE_ERP_DONOR_MATCH")
def apply_accrual_principle_matches(
status_groups: dict[str, list[dict[str, Any]]],
source_groups: dict[str, list[dict[str, Any]]],
) -> None:
donors_by_identity = index_erp_matched_accrual_donors(source_groups)
rewritten: dict[str, list[dict[str, Any]]] = defaultdict(list)
for status_key in WEHAGO_STATUSES:
for group in status_groups.get(status_key) or []:
if status_key != "voucher_recheck":
rewritten[status_key].append(group)
continue
promoted = build_accrual_principle_match(group, "PROJECTION_RECONCILE_ACCRUAL_PRINCIPLE_BUSINESS_MATCH")
if promoted is None:
donors = [
donor_match
for donor in donors_by_identity.get(group_identity(group), [])
for donor_match in [build_requested_draft_donor_match(group, donor)]
if donor_match is not None
]
if donors:
promoted = max(
donors,
key=lambda donor: (
sum(1 for row in donor.get("rows") or [] if has_wehago_value(row) and has_erp_value(row)),
parse_amount((donor.get("summary") or {}).get("ledger_debit")) + parse_amount((donor.get("summary") or {}).get("ledger_credit")),
),
)
if promoted is None:
rewritten[status_key].append(
strip_review_reason_tokens_from_group(
group,
{
"PROJECTION_RECONCILE_ACCRUAL_PRINCIPLE_BUSINESS_MATCH",
"PROJECTION_RECONCILE_ACCRUAL_PRINCIPLE_ERP_DONOR_MATCH",
},
)
)
continue
rewritten["voucher_matched"].append(retag_group(promoted, "voucher_matched"))
for status_key in WEHAGO_STATUSES:
status_groups[status_key] = rewritten.get(status_key, [])
def erp_entry_draft_year(entry: dict[str, Any]) -> int:
for value in (entry.get("draft_no"), entry.get("confirmed_no")):
match = re.search(r"11-(\d{4})\d{4}-", clean(value))
if match:
return int(match.group(1))
return int(entry.get("fiscal_year") or 0)
def raw_erp_entry_identity(entry: dict[str, Any]) -> tuple[Any, ...]:
return (
entry.get("id"),
clean(entry.get("draft_no")),
clean(entry.get("confirmed_no")),
int(entry.get("row_number") or 0),
clean(entry.get("account_code")),
)
def raw_erp_entry_draft_identity(entry: dict[str, Any]) -> tuple[str, str]:
return ("draft", clean(entry.get("draft_no")) or clean(entry.get("confirmed_no")))
def raw_erp_entry_text(entry: dict[str, Any]) -> str:
return " ".join(
clean(part)
for part in (
entry.get("account_name"),
entry.get("vendor_name"),
entry.get("desc1"),
entry.get("desc2"),
entry.get("management_item"),
)
if clean(part)
)
def compact_text_tokens(value: Any) -> set[str]:
tokens = {
token
for token in re.split(r"[^0-9a-z가-힣]+", normalized_compact(value))
if len(token) >= 2
}
return tokens
def compact_meaningful_parts(value: Any) -> list[str]:
parts: list[str] = []
for token in re.split(r"[^0-9a-zA-Z가-힣]+", clean(value)):
compact = normalized_compact(token)
if len(compact) >= 4:
parts.append(compact)
return parts
def row_desc_matches_raw_entry(row: dict[str, Any], entry: dict[str, Any]) -> bool:
ledger_parts = compact_meaningful_parts(row.get("ledger_desc"))
entry_text = normalized_compact(" ".join([clean(entry.get("desc1")), clean(entry.get("desc2")), clean(entry.get("management_item"))]))
if not ledger_parts or not entry_text:
return False
return any(part in entry_text or entry_text in part for part in ledger_parts)
def row_text_matches_raw_entry(row: dict[str, Any], entry: dict[str, Any]) -> bool:
if row_desc_matches_raw_entry(row, entry):
return True
ledger_vendor = normalized_compact(row.get("ledger_vendor"))
entry_vendor = normalized_compact(entry.get("vendor_name"))
if ledger_vendor and entry_vendor and (ledger_vendor in entry_vendor or entry_vendor in ledger_vendor):
return True
ledger_text = " ".join(
clean(part)
for part in (row.get("ledger_account_name"), row.get("ledger_vendor"), row.get("ledger_desc"))
if clean(part)
)
return bool(compact_text_tokens(ledger_text) & compact_text_tokens(raw_erp_entry_text(entry)))
def raw_entry_candidate_row(entry: dict[str, Any], ledger_row: dict[str, Any], reason: str) -> dict[str, Any] | None:
category = _classify_account_category(entry.get("account_code"), entry.get("account_name"))
signed_amount, side = raw_erp_entry_signed_amount_side(entry, category)
if abs(signed_amount) <= 0:
return None
ledger_debit = parse_amount(ledger_row.get("ledger_debit"))
ledger_credit = parse_amount(ledger_row.get("ledger_credit"))
if side == "debit" and abs(ledger_debit - signed_amount) >= 0.5:
return None
if side == "credit" and abs(ledger_credit - signed_amount) >= 0.5:
return None
payload = raw_erp_entry_to_row(entry, ledger_row, side, reason)
payload["voucher_debit"] = signed_amount if side == "debit" else 0
payload["voucher_credit"] = signed_amount if side == "credit" else 0
payload["voucher_row_key"] = build_voucher_row_key(payload)
if not row_principle_pair_allowed(ledger_row, payload):
return None
return payload
def split_draft_raw_candidate_score(
ledger_row: dict[str, Any],
entry: dict[str, Any],
fiscal_year: int,
) -> tuple[int, int, int, int, int, float]:
proof_match = int(dates_match_wehago(entry.get("proof_date"), ledger_row.get("ledger_date"), fiscal_year))
desc_match = int(row_desc_matches_raw_entry(ledger_row, entry))
same_account = int(
compact_account_name(ledger_row.get("ledger_account_name")) == compact_account_name(entry.get("account_name"))
or compact_account_name(ledger_row.get("ledger_account_name")) in compact_account_name(entry.get("account_name"))
or compact_account_name(entry.get("account_name")) in compact_account_name(ledger_row.get("ledger_account_name"))
)
text_match = int(row_text_matches_raw_entry(ledger_row, entry))
current_year = int(erp_entry_draft_year(entry) == fiscal_year)
return proof_match, desc_match, same_account, text_match, current_year, row_side_amount(ledger_row, "ledger")
def find_split_draft_raw_match(
ledger_row: dict[str, Any],
draft_bases: set[str],
used_entries: set[tuple[Any, ...]],
fiscal_year: int,
) -> dict[str, Any] | None:
best: tuple[tuple[int, int, int, int, float], dict[str, Any]] | None = None
for base in sorted(draft_bases):
for entry in RAW_ERP_ROWS_BY_DRAFT_BASE.get(base, []):
entry_identity = raw_erp_entry_identity(entry)
if entry_identity in used_entries or raw_erp_entry_draft_identity(entry) in used_entries:
continue
entry_year = erp_entry_draft_year(entry)
if entry_year and entry_year != fiscal_year:
continue
candidate = raw_entry_candidate_row(
entry,
ledger_row,
"PROJECTION_RECONCILE_SPLIT_DRAFT_ROW_MATCH",
)
if candidate is None:
continue
score = split_draft_raw_candidate_score(ledger_row, entry, fiscal_year)
if not any(score[:4]):
continue
if best is None or score > best[0]:
best = (score, candidate)
if best is None:
return None
return best[1]
def row_existing_erp_is_material_match(row: dict[str, Any]) -> bool:
if not (has_wehago_value(row) and has_erp_value(row)):
return False
if abs(row_side_amount(row, "ledger") - row_side_amount(row, "voucher")) >= 0.5:
return False
return row_principle_pair_allowed(row, row)
def build_split_draft_row_match(group: dict[str, Any]) -> dict[str, Any] | None:
rows = [dict(row) for row in group.get("rows") or []]
summary = dict(group.get("summary") or {})
draft_bases = row_draft_bases(rows, summary)
if not draft_bases:
return None
fiscal_year = int(summary.get("fiscal_year") or YEAR)
material_rows = [
row
for row in rows
if has_wehago_value(row) and (is_business_row(row, "ledger") or is_vat_row(row, "ledger"))
]
if not material_rows:
return None
matched_rows: list[dict[str, Any]] = []
used_entries: set[tuple[Any, ...]] = set()
covered_material_indexes: set[int] = set()
covered_material_keys: set[tuple[str, str, str, str]] = set()
raw_needed: list[tuple[int, dict[str, Any]]] = []
for material_index, row in enumerate(material_rows):
if row_existing_erp_is_material_match(row):
matched = remove_recheck_reason_tokens(row, "PROJECTION_RECONCILE_SPLIT_DRAFT_EXISTING_ROW_MATCH")
matched_rows.append(matched)
covered_material_indexes.add(material_index)
covered_material_keys.add(row_business_match_key(row, "ledger"))
if clean(row.get("draft_no")):
used_entries.add(("draft", clean(row.get("draft_no"))))
continue
raw_needed.append((material_index, row))
raw_pair_candidates: list[tuple[tuple[int, int, int, int, int, float], int, dict[str, Any], tuple[Any, ...]]] = []
for material_index, row in raw_needed:
for base in sorted(draft_bases):
for entry in RAW_ERP_ROWS_BY_DRAFT_BASE.get(base, []):
entry_identity = raw_erp_entry_identity(entry)
draft_identity = raw_erp_entry_draft_identity(entry)
if entry_identity in used_entries or draft_identity in used_entries:
continue
entry_year = erp_entry_draft_year(entry)
if entry_year and entry_year != fiscal_year:
continue
candidate = raw_entry_candidate_row(
entry,
row,
"PROJECTION_RECONCILE_SPLIT_DRAFT_ROW_MATCH",
)
if candidate is None:
continue
score = split_draft_raw_candidate_score(row, entry, fiscal_year)
if not any(score[:4]):
continue
raw_pair_candidates.append((score, material_index, candidate, draft_identity))
raw_pair_candidates.sort(key=lambda item: (item[0], -item[1]), reverse=True)
for _score, material_index, candidate, draft_identity in raw_pair_candidates:
if material_index in covered_material_indexes or draft_identity in used_entries:
continue
matched_rows.append(candidate)
covered_material_indexes.add(material_index)
covered_material_keys.add(row_business_match_key(material_rows[material_index], "ledger"))
used_entries.add(draft_identity)
for material_index, row in enumerate(material_rows):
if material_index in covered_material_indexes:
continue
if row_business_match_key(row, "ledger") in covered_material_keys:
continue
return None
context_rows: list[dict[str, Any]] = []
seen_context: set[tuple[Any, ...]] = set()
for row in rows:
if has_wehago_value(row) and (is_business_row(row, "ledger") or is_vat_row(row, "ledger")):
continue
if has_erp_value(row) and not has_wehago_value(row):
continue
if not has_wehago_value(row):
continue
context = remove_recheck_reason_tokens(row, "PROJECTION_RECONCILE_SPLIT_DRAFT_CONTEXT_ROW")
context_key = ledger_row_identity(context)
if context_key in seen_context:
continue
seen_context.add(context_key)
context_rows.append(context)
payload = {
"summary": summary,
"rows": matched_rows + context_rows,
"source_group_index": group.get("source_group_index"),
}
payload["summary"] = rebuild_summary(payload, "voucher_matched")
return payload
def apply_split_draft_row_matches(status_groups: dict[str, list[dict[str, Any]]]) -> None:
rewritten: dict[str, list[dict[str, Any]]] = defaultdict(list)
for status_key in WEHAGO_STATUSES:
for group in status_groups.get(status_key) or []:
if status_key != "voucher_recheck":
rewritten[status_key].append(group)
continue
promoted = build_split_draft_row_match(group)
if promoted is None:
rewritten[status_key].append(
strip_review_reason_tokens_from_group(
group,
{
"PROJECTION_RECONCILE_SPLIT_DRAFT_EXISTING_ROW_MATCH",
"PROJECTION_RECONCILE_SPLIT_DRAFT_ROW_MATCH",
"PROJECTION_RECONCILE_SPLIT_DRAFT_CONTEXT_ROW",
},
)
)
continue
rewritten["voucher_matched"].append(retag_group(promoted, "voucher_matched"))
for status_key in WEHAGO_STATUSES:
status_groups[status_key] = rewritten.get(status_key, [])
def extract_dates_from_text(value: Any) -> set[str]:
text = clean(value)
dates: set[str] = set()
for year, month, day in re.findall(r"((?:19|20)\d{2})[-./년\s]*(\d{1,2})[-./월\s]*(\d{1,2})", text):
dates.add(f"{int(year):04d}-{int(month):02d}-{int(day):02d}")
return dates
def raw_erp_unique_entries() -> list[dict[str, Any]]:
seen: set[Any] = set()
entries: list[dict[str, Any]] = []
for rows in RAW_ERP_ROWS_BY_DRAFT_BASE.values():
for row in rows:
key = row.get("id") or (
row.get("draft_no"),
row.get("confirmed_no"),
row.get("account_code"),
row.get("row_number"),
)
if key in seen:
continue
seen.add(key)
entries.append(row)
return entries
def group_has_management_item_trace(group: dict[str, Any]) -> bool:
date_key, vendor_key, desc_key = group_reversal_context_key(group)
if not date_key or not vendor_key:
return False
group_amounts = {
amount_key(abs(parse_amount(row.get("ledger_debit")) or parse_amount(row.get("ledger_credit"))))
for row in group.get("rows") or []
if abs(parse_amount(row.get("ledger_debit")) or parse_amount(row.get("ledger_credit"))) >= 0.5
}
for entry in raw_erp_unique_entries():
management_text = clean(entry.get("management_item"))
if not management_text:
continue
entry_vendor = normalized_compact(entry.get("vendor_name"))
if entry_vendor and vendor_key and entry_vendor not in vendor_key and vendor_key not in entry_vendor:
continue
entry_dates = extract_dates_from_text(management_text)
proof_date = clean(entry.get("proof_date"))
if proof_date:
entry_dates.add(proof_date)
confirmed = clean(entry.get("confirmed_no"))
matched = re.search(r"11-((?:19|20)\d{6})-", confirmed)
if matched:
raw = matched.group(1)
entry_dates.add(f"{raw[:4]}-{raw[4:6]}-{raw[6:8]}")
if date_key not in entry_dates:
continue
entry_amounts = {
amount_key(abs(parse_amount(entry.get(field))))
for field in ("debit_supply", "debit_tax", "credit_supply", "credit_tax")
if abs(parse_amount(entry.get(field))) >= 0.5
}
if group_amounts and entry_amounts and group_amounts.isdisjoint(entry_amounts):
if desc_key and normalized_compact(entry.get("desc1")) not in desc_key and desc_key not in normalized_compact(entry.get("desc1")):
continue
return True
return False
def move_exact_wehago_reversal_pairs_to_excepted(
voucher_sections: dict[str, list[dict[str, Any]]],
) -> dict[str, list[dict[str, Any]]]:
candidate_statuses = ("voucher_unmatched", "voucher_recheck")
candidate_groups: list[tuple[str, dict[str, Any]]] = [
(status_key, group)
for status_key in candidate_statuses
for group in list(voucher_sections.get(status_key) or [])
]
if len(candidate_groups) < 2:
return voucher_sections
vectors: dict[int, dict[tuple[str, str, str], float]] = {}
indexed: dict[tuple[tuple[str, str, str], tuple[tuple[tuple[str, str, str], float], ...]], list[tuple[str, dict[str, Any]]]] = defaultdict(list)
for status_key, group in candidate_groups:
vector = _offset_group_vector(group)
context_key = group_reversal_context_key(group)
if not vector or not all(context_key):
continue
vectors[id(group)] = vector
indexed[(context_key, offset_vector_key(vector))].append((status_key, group))
moved_ids: set[int] = set()
move_reasons: dict[int, str] = {}
for status_key, group in candidate_groups:
if id(group) in moved_ids:
continue
vector = vectors.get(id(group))
if not vector:
continue
context_key = group_reversal_context_key(group)
for _other_status, other in indexed.get((context_key, offset_vector_key(vector, sign=-1)), []):
if other is group or id(other) in moved_ids:
continue
reason = (
"WEHAGO_EXCEPTED_MANAGEMENT_ITEM_REVERSAL_PAIR"
if group_has_management_item_trace(group) or group_has_management_item_trace(other)
else "WEHAGO_EXCEPTED_EXACT_REVERSAL_PAIR"
)
moved_ids.update((id(group), id(other)))
move_reasons[id(group)] = reason
move_reasons[id(other)] = reason
break
if not moved_ids:
return voucher_sections
retained_by_status: dict[str, list[dict[str, Any]]] = {status_key: [] for status_key in candidate_statuses}
moved_excepted: list[dict[str, Any]] = []
for status_key, group in candidate_groups:
if id(group) not in moved_ids:
retained_by_status[status_key].append(group)
continue
moved_excepted.append(mark_excepted(group, move_reasons.get(id(group), "WEHAGO_EXCEPTED_EXACT_REVERSAL_PAIR")))
for status_key in candidate_statuses:
voucher_sections[status_key] = retained_by_status[status_key]
voucher_sections["voucher_excepted"] = list(voucher_sections.get("voucher_excepted") or []) + moved_excepted
return voucher_sections
def move_offset_tax_invoice_groups_to_excepted_fast(
voucher_sections: dict[str, list[dict[str, Any]]],
) -> dict[str, list[dict[str, Any]]]:
candidate_statuses = ("voucher_unmatched", "voucher_recheck")
candidate_groups: list[tuple[str, dict[str, Any]]] = [
(status_key, group)
for status_key in candidate_statuses
for group in list(voucher_sections.get(status_key) or [])
]
if len(candidate_groups) < 2:
return voucher_sections
candidate_vectors: dict[int, dict[tuple[str, str, str], float]] = {}
tax_offset_candidate_ids: set[int] = set()
indexed: dict[tuple[tuple[tuple[str, str, str], float], ...], list[tuple[str, dict[str, Any]]]] = defaultdict(list)
for status_key, group in candidate_groups:
if _voucher_group_has_review_reason(
group,
"MATCHED_CANCEL_TARGET_RECHECK",
"CANCEL_TARGET_ALREADY_MATCHED_RECHECK",
"CANCEL_REISSUE_RETARGET_RECHECK",
):
continue
if _group_has_tax_invoice_cancel_signal(group) or _group_has_offset_tax_invoice_structure(group):
tax_offset_candidate_ids.add(id(group))
vector = _offset_group_vector(group)
if not vector:
continue
candidate_vectors[id(group)] = vector
indexed[offset_vector_key(vector)].append((status_key, group))
if not candidate_vectors:
return voucher_sections
moved_ids: set[int] = set()
for status_key, group in candidate_groups:
if id(group) in moved_ids:
continue
left_vector = candidate_vectors.get(id(group))
if not left_vector:
continue
left_dates = _voucher_group_month_days(group)
for _other_status_key, other in indexed.get(offset_vector_key(left_vector, sign=-1), []):
if other is group or id(other) in moved_ids:
continue
same_date = bool(left_dates & _voucher_group_month_days(other))
is_tax_offset_pair = id(group) in tax_offset_candidate_ids and id(other) in tax_offset_candidate_ids
is_near_reversal_pair = _voucher_groups_within_days(group, other, 62)
if not (same_date or is_tax_offset_pair or is_near_reversal_pair):
continue
moved_ids.add(id(group))
moved_ids.add(id(other))
break
if not moved_ids:
return voucher_sections
retained_by_status: dict[str, list[dict[str, Any]]] = {status_key: [] for status_key in candidate_statuses}
moved_excepted: list[dict[str, Any]] = []
for status_key, group in candidate_groups:
if id(group) not in moved_ids:
retained_by_status[status_key].append(group)
continue
moved_excepted.append(mark_excepted(group, "WEHAGO_EXCEPTED_OFFSET_REVERSAL_PAIR"))
for status_key in candidate_statuses:
voucher_sections[status_key] = retained_by_status[status_key]
voucher_sections["voucher_excepted"] = list(voucher_sections.get("voucher_excepted") or []) + moved_excepted
return voucher_sections
def apply_split_revenue_matches_to_status_groups(status_groups: dict[str, list[dict[str, Any]]]) -> None:
updated: dict[str, list[dict[str, Any]]] = defaultdict(list)
for status_key in WEHAGO_STATUSES:
for group in list(status_groups.get(status_key) or []):
if status_key not in {"voucher_matched", "voucher_recheck"}:
updated[status_key].append(group)
continue
summary = dict(group.get("summary") or {})
rows = apply_same_draft_split_revenue_matches([dict(row) for row in group.get("rows") or []], summary)
adjusted = {"summary": summary, "rows": rows, "source_group_index": group.get("source_group_index")}
adjusted["summary"] = rebuild_summary(adjusted, status_key)
final_status = normalized_wehago_status(status_key, adjusted)
updated[final_status].append(retag_group(adjusted, final_status))
for status_key in WEHAGO_STATUSES:
status_groups[status_key] = updated.get(status_key, [])
def apply_exact_reversal_pairs_to_status_groups(status_groups: dict[str, list[dict[str, Any]]]) -> None:
moved = move_exact_wehago_reversal_pairs_to_excepted(
{
"voucher_unmatched": list(status_groups.get("voucher_unmatched") or []),
"voucher_recheck": list(status_groups.get("voucher_recheck") or []),
"voucher_excepted": list(status_groups.get("voucher_excepted") or []),
}
)
status_groups["voucher_unmatched"] = list(moved.get("voucher_unmatched") or [])
status_groups["voucher_recheck"] = list(moved.get("voucher_recheck") or [])
status_groups["voucher_excepted"] = list(moved.get("voucher_excepted") or [])
def enforce_wehago_status_invariants(status_groups: dict[str, list[dict[str, Any]]]) -> dict[str, int]:
diagnostics = Counter()
reclassified: dict[str, list[dict[str, Any]]] = defaultdict(list)
for status_key in WEHAGO_STATUSES:
for group in list(status_groups.get(status_key) or []):
final_status = normalized_wehago_status(status_key, group)
if final_status != status_key:
diagnostics[f"{status_key}_to_{final_status}"] += 1
if final_status == "voucher_excepted":
is_excepted, reason = _is_wehago_excepted_voucher_group(group)
if group_identity(group) in MANUAL_OFFSET_EXCEPTED_IDENTITIES:
reason = reason or "MANUAL_OFFSET_PAIR_EXCEPTED"
reclassified[final_status].append(mark_excepted(group, reason))
else:
reclassified[final_status].append(retag_group(group, final_status))
for status_key in WEHAGO_STATUSES:
status_groups[status_key] = reclassified.get(status_key, [])
diagnostics["voucher_recheck_without_erp"] = sum(
1 for group in status_groups.get("voucher_recheck") or [] if not group_has_real_erp_candidate(group)
)
diagnostics["voucher_matched_partial_wehago"] = sum(
1 for group in status_groups.get("voucher_matched") or [] if group_has_unmatched_wehago_rows(group)
)
diagnostics["voucher_unmatched_with_erp"] = sum(
1 for group in status_groups.get("voucher_unmatched") or [] if group_has_real_erp_candidate(group)
)
return {key: int(value) for key, value in diagnostics.items()}
def append_unique(values: list[str], value: Any) -> None:
text = clean(value)
if text and text not in values:
values.append(text)
def append_review_reason_parts(values: list[str], value: Any) -> None:
for part in re.split(r"\s*/\s*", clean(value)):
append_unique(values, part)
def dedup_review_reason_text(*values: Any) -> str:
parts: list[str] = []
for value in values:
append_review_reason_parts(parts, value)
return " / ".join(parts)
def rebuild_summary(group: dict[str, Any], status_key: str) -> dict[str, Any]:
old = dict(group.get("summary") or {})
rows = list(group.get("rows") or [])
ledger_accounts: list[str] = []
voucher_accounts: list[str] = []
ledger_vendors: list[str] = []
voucher_vendors: list[str] = []
draft_nos: list[str] = []
reasons: list[str] = []
summary = {
**old,
"ledger_row_count": 0,
"voucher_row_count": 0,
"ledger_debit": 0.0,
"ledger_credit": 0.0,
"voucher_debit": 0.0,
"voucher_credit": 0.0,
}
for row in rows:
if clean(row.get("ledger_account_name")):
summary["ledger_row_count"] += 1
summary["ledger_debit"] += parse_amount(row.get("ledger_debit"))
summary["ledger_credit"] += parse_amount(row.get("ledger_credit"))
append_unique(ledger_accounts, row.get("ledger_account_name"))
append_unique(ledger_vendors, row.get("ledger_vendor"))
if clean(row.get("voucher_account_name")):
summary["voucher_row_count"] += 1
summary["voucher_debit"] += parse_amount(row.get("voucher_debit"))
summary["voucher_credit"] += parse_amount(row.get("voucher_credit"))
append_unique(voucher_accounts, row.get("voucher_account_name"))
append_unique(voucher_vendors, row.get("voucher_vendor"))
append_unique(draft_nos, row.get("draft_no"))
append_review_reason_parts(reasons, row.get("review_reason"))
if not clean(summary.get("ledger_date")) and clean(row.get("ledger_date")):
summary["ledger_date"] = clean(row.get("ledger_date"))
if not clean(summary.get("proof_date")) and clean(row.get("proof_date")):
summary["proof_date"] = clean(row.get("proof_date"))
summary["status_label"] = "Matched" if status_key in {"voucher_matched", "erp_voucher_matched"} else "Recheck" if status_key == "voucher_recheck" else "Unmatched"
summary["ledger_accounts"] = ", ".join(ledger_accounts)
summary["voucher_accounts"] = ", ".join(voucher_accounts)
summary["ledger_vendors"] = ", ".join(ledger_vendors)
summary["voucher_vendors"] = ", ".join(voucher_vendors)
summary["draft_no"] = ", ".join(draft_nos) or clean(old.get("draft_no"))
if not reasons:
append_review_reason_parts(reasons, old.get("review_reason"))
summary["review_reason"] = " / ".join(reasons)
summary["search_text"] = " ".join(
clean(part)
for part in [
summary.get("voucher_no"),
summary.get("draft_no"),
summary.get("ledger_accounts"),
summary.get("voucher_accounts"),
summary.get("ledger_vendors"),
summary.get("voucher_vendors"),
summary.get("review_reason"),
]
if clean(part)
)
return summary
def group_quality(group: dict[str, Any], status_key: str) -> tuple[int, int, float, int]:
priority = {"voucher_excepted": 4, "voucher_matched": 3, "voucher_recheck": 2, "voucher_unmatched": 1}.get(status_key, 0)
rows = list(group.get("rows") or [])
direct_rows = sum(1 for row in rows if has_wehago_value(row) and has_erp_value(row))
amount = max(
parse_amount((group.get("summary") or {}).get("ledger_debit")),
parse_amount((group.get("summary") or {}).get("ledger_credit")),
parse_amount((group.get("summary") or {}).get("voucher_debit")),
parse_amount((group.get("summary") or {}).get("voucher_credit")),
)
return priority, direct_rows, amount, -len(rows)
def build_missing_db_group(db_row: dict[str, Any], ledger_rows: list[sqlite3.Row]) -> dict[str, Any]:
key = clean(db_row.get("voucher_no"))
rows: list[dict[str, Any]] = []
for index, row in enumerate(ledger_rows):
rows.append(
{
"fiscal_year": YEAR,
"status_label": "Unmatched",
"ledger_date": clean(row["ledger_date"]) or display_date_from_db_key(key),
"proof_date": "",
"voucher_no": display_voucher_from_db_key(key),
"draft_no": "",
"ledger_account_name": clean(row["account_name"]),
"voucher_account_name": "",
"ledger_vendor": clean(row["vendor_name"]),
"voucher_vendor": "",
"ledger_debit": parse_amount(row["debit"]),
"ledger_credit": parse_amount(row["credit"]),
"voucher_debit": 0,
"voucher_credit": 0,
"ledger_desc": clean(row["description"]),
"voucher_desc": "",
"review_reason": "PROJECTION_RECONCILE_DB_ONLY_WEHAGO",
"matched_case": "",
"ledger_row_key": f"ledger:{row['id']}",
"voucher_row_key": "",
"match_identity_key": "",
}
)
if not rows:
rows.append(
{
"fiscal_year": YEAR,
"status_label": "Unmatched",
"ledger_date": display_date_from_db_key(key),
"proof_date": "",
"voucher_no": display_voucher_from_db_key(key),
"draft_no": "",
"ledger_account_name": clean(db_row.get("ledger_accounts")),
"voucher_account_name": "",
"ledger_vendor": clean(db_row.get("ledger_vendors")),
"voucher_vendor": "",
"ledger_debit": parse_amount(db_row.get("ledger_debit")),
"ledger_credit": parse_amount(db_row.get("ledger_credit")),
"voucher_debit": 0,
"voucher_credit": 0,
"ledger_desc": clean(db_row.get("notes")),
"voucher_desc": "",
"review_reason": "PROJECTION_RECONCILE_DB_ONLY_WEHAGO",
"matched_case": "",
"ledger_row_key": "",
"voucher_row_key": "",
"match_identity_key": "",
}
)
summary = {
"fiscal_year": YEAR,
"status_label": "Unmatched",
"ledger_date": display_date_from_db_key(key),
"proof_date": "",
"voucher_no": display_voucher_from_db_key(key),
"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": "PROJECTION_RECONCILE_DB_ONLY_WEHAGO",
"search_text": "",
}
group = {"summary": summary, "rows": rows, "source_group_index": 0}
group["summary"] = rebuild_summary(group, "voucher_unmatched")
return group
def ledger_db_row_identity(row: sqlite3.Row) -> tuple[Any, ...]:
return (
"side",
clean(row["account_name"]),
clean(row["vendor_name"]),
amount_key(row["debit"]),
amount_key(row["credit"]),
clean(row["description"]),
)
def build_unmatched_ledger_row(row: sqlite3.Row, db_key: str, reason: str) -> dict[str, Any]:
return {
"fiscal_year": YEAR,
"status_label": "Unmatched",
"ledger_date": clean(row["ledger_date"]) or display_date_from_db_key(db_key),
"proof_date": "",
"voucher_no": display_voucher_from_db_key(db_key),
"draft_no": "",
"ledger_account_name": clean(row["account_name"]),
"voucher_account_name": "",
"ledger_vendor": clean(row["vendor_name"]),
"voucher_vendor": "",
"ledger_debit": parse_amount(row["debit"]),
"ledger_credit": parse_amount(row["credit"]),
"voucher_debit": 0,
"voucher_credit": 0,
"ledger_desc": clean(row["description"]),
"voucher_desc": "",
"review_reason": reason,
"matched_case": "",
"ledger_row_key": f"ledger:{row['id']}",
"voucher_row_key": "",
"match_identity_key": "",
}
def supplement_group_with_missing_wehago_rows(
group: dict[str, Any],
status_key: str,
db_key: str,
ledger_rows: list[sqlite3.Row],
) -> dict[str, Any]:
if status_key not in {"voucher_matched", "voucher_recheck"} or not ledger_rows:
return group
rows = [dict(row) for row in group.get("rows") or []]
seen = {
ledger_row_identity(row)
for row in rows
if clean(row.get("ledger_account_name"))
}
added = False
for ledger_row in ledger_rows:
identity = ledger_db_row_identity(ledger_row)
if identity in seen:
continue
rows.append(build_unmatched_ledger_row(ledger_row, db_key, "PROJECTION_RECONCILE_PARTIAL_MATCH_WEHAGO_ROW_UNMATCHED"))
seen.add(identity)
added = True
if not added:
return group
supplemented = {
"summary": dict(group.get("summary") or {}),
"rows": rows,
"source_group_index": group.get("source_group_index"),
}
supplemented["summary"] = rebuild_summary(supplemented, status_key)
return supplemented
def raw_erp_context_row(entry: dict[str, Any], group: dict[str, Any]) -> dict[str, Any]:
summary = group.get("summary") or {}
category = _classify_account_category(entry.get("account_code"), entry.get("account_name"))
amount, side = raw_erp_entry_signed_amount_side(entry, category)
description = " ".join(
part for part in (clean(entry.get("desc1")), clean(entry.get("desc2"))) if part
)
row = {
"fiscal_year": int(summary.get("fiscal_year") or YEAR),
"status_label": clean(summary.get("status_label")),
"ledger_date": clean(summary.get("ledger_date")),
"proof_date": clean(entry.get("proof_date")),
"voucher_no": clean(summary.get("voucher_no")),
"draft_no": clean(entry.get("draft_no")) or clean(entry.get("confirmed_no")),
"ledger_account_name": "",
"voucher_account_name": clean(entry.get("account_name")),
"ledger_vendor": "",
"voucher_vendor": clean(entry.get("vendor_name")),
"ledger_debit": 0.0,
"ledger_credit": 0.0,
"voucher_debit": amount if side == "debit" else 0.0,
"voucher_credit": amount if side == "credit" else 0.0,
"ledger_desc": "",
"voucher_desc": description,
"review_reason": "ERP_FULL_DRAFT_VOUCHER_CONTEXT",
"matched_case": "ERP_CONTEXT_ROW",
"ledger_row_key": "",
"voucher_row_key": "",
"match_identity_key": "",
}
row["voucher_row_key"] = build_voucher_row_key(row)
row["match_identity_key"] = row["voucher_row_key"]
return row
def supplement_group_with_full_erp_vouchers(
group: dict[str, Any],
status_key: str,
) -> dict[str, Any]:
if status_key not in {"voucher_matched", "voucher_recheck"}:
return group
rows = [dict(row) for row in group.get("rows") or []]
requested_bases = row_draft_bases(rows, group.get("summary") or {})
if not requested_bases:
return group
seen_voucher_rows = {
voucher_row_identity(row)
for row in rows
if has_erp_value(row)
}
seen_draft_rows = {
clean(row.get("draft_no"))
for row in rows
if has_erp_value(row) and clean(row.get("draft_no"))
}
added = False
for draft_base in sorted(requested_bases):
for entry in RAW_ERP_ROWS_BY_DRAFT_BASE.get(draft_base, []):
context_row = raw_erp_context_row(entry, group)
draft_no = clean(context_row.get("draft_no"))
if draft_no and draft_no in seen_draft_rows:
continue
identity = voucher_row_identity(context_row)
if identity in seen_voucher_rows:
continue
rows.append(context_row)
seen_voucher_rows.add(identity)
if draft_no:
seen_draft_rows.add(draft_no)
added = True
if not added:
return group
supplemented = {
"summary": dict(group.get("summary") or {}),
"rows": rows,
"source_group_index": group.get("source_group_index"),
}
supplemented["summary"] = rebuild_summary(supplemented, status_key)
return supplemented
def insert_group(conn: sqlite3.Connection, signature: str, status_key: str, group_index: int, group: dict[str, Any]) -> None:
summary = dict(group["summary"])
summary.update(
{
"start_year": YEAR,
"end_year": YEAR,
"status_key": status_key,
"signature": signature,
"group_index": group_index,
}
)
conn.execute(
f"""
INSERT INTO wehago_compare_query_groups ({', '.join(GROUP_COLUMNS)}, created_at, updated_at)
VALUES ({', '.join('?' for _ in GROUP_COLUMNS)}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
[summary.get(column, "") for column in GROUP_COLUMNS],
)
for row_index, row in enumerate(group.get("rows") or []):
row = dict(row)
row.update(
{
"start_year": YEAR,
"end_year": YEAR,
"status_key": status_key,
"signature": signature,
"group_index": group_index,
"row_index": row_index,
}
)
conn.execute(
f"""
INSERT INTO wehago_compare_query_rows ({', '.join(ROW_COLUMNS)}, created_at, updated_at)
VALUES ({', '.join('?' for _ in ROW_COLUMNS)}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
[row.get(column, "") for column in ROW_COLUMNS],
)
def update_snapshot_row_counts(conn: sqlite3.Connection, counts: dict[str, int]) -> None:
row = conn.execute(
"""
SELECT row_counts_json
FROM wehago_snapshot_status
WHERE fiscal_year = ?
LIMIT 1
""",
(YEAR,),
).fetchone()
merged: dict[str, int] = {}
if row:
try:
parsed = json.loads(row["row_counts_json"] or "{}")
if isinstance(parsed, dict):
merged = {str(key): int(value or 0) for key, value in parsed.items()}
except Exception:
merged = {}
for status_key, count in counts.items():
merged[status_key] = int(count or 0)
payload = json.dumps(merged, ensure_ascii=False)
if row:
conn.execute(
"""
UPDATE wehago_snapshot_status
SET state = 'ready',
row_counts_json = ?,
updated_at = CURRENT_TIMESTAMP,
last_built_at = CURRENT_TIMESTAMP,
error_message = ''
WHERE fiscal_year = ?
""",
(payload, YEAR),
)
else:
conn.execute(
"""
INSERT INTO wehago_snapshot_status (
fiscal_year, snapshot_signature, state, row_counts_json,
error_message, created_at, updated_at, last_requested_at, last_built_at
)
VALUES (?, ?, 'ready', ?, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
(YEAR, "", payload),
)
def ensure_compare_settings(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS wehago_compare_settings (
setting_key TEXT PRIMARY KEY,
setting_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
def activate_projection_signature(
conn: sqlite3.Connection,
signature: str,
counts: dict[str, int],
diagnostics: dict[str, int],
) -> None:
ensure_compare_settings(conn)
payload = {
"start_year": YEAR,
"end_year": YEAR,
"signature": signature,
"logic_version": RECONCILED_PROJECTION_VERSION,
"counts": {key: int(counts.get(key, 0) or 0) for key in ALL_VOUCHER_STATUSES},
"diagnostics": diagnostics,
"activated_at": datetime.now().isoformat(timespec="seconds"),
}
conn.execute(
"""
INSERT INTO wehago_compare_settings (setting_key, setting_json, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(setting_key) DO UPDATE SET
setting_json = excluded.setting_json,
updated_at = CURRENT_TIMESTAMP
""",
(
f"wehago_active_query_projection:{YEAR}:{YEAR}",
json.dumps(payload, ensure_ascii=False),
),
)
def store_query_metric_projection(conn: sqlite3.Connection, signature: str, counts: dict[str, int]) -> None:
conn.execute(
"""
INSERT INTO wehago_compare_query_metrics (
start_year, end_year, signature, counts_json, snapshot_state_json, source_state_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(start_year, end_year, signature) DO UPDATE SET
counts_json = excluded.counts_json,
snapshot_state_json = excluded.snapshot_state_json,
source_state_json = excluded.source_state_json,
updated_at = CURRENT_TIMESTAMP
""",
(
YEAR,
YEAR,
signature,
json.dumps({key: int(counts.get(key, 0) or 0) for key in ALL_VOUCHER_STATUSES}, ensure_ascii=False),
json.dumps({"ready": [YEAR], "missing": [], "stale": [], "queued": [], "running": []}, ensure_ascii=False),
json.dumps({"projection_signature": signature, "source": "reconcile_wehago_projection_to_db"}, ensure_ascii=False),
),
)
def backfill_final_status_projection(conn: sqlite3.Connection, signature: str) -> int:
priority = {
"voucher_excepted": 0,
"voucher_matched": 1,
"voucher_recheck": 2,
"voucher_unmatched": 3,
}
priority_case = " ".join(f"WHEN '{status}' THEN {rank}" for status, rank in priority.items())
status_case = " ".join(f"WHEN {rank} THEN '{status}'" for status, rank in priority.items())
conn.execute(
"""
DELETE FROM wehago_compare_final_status_projection
WHERE start_year = ? AND end_year = ? AND signature = ?
""",
(YEAR, YEAR, signature),
)
conn.execute(
f"""
INSERT INTO wehago_compare_final_status_projection (
start_year, end_year, signature, identity_key, compare_voucher_no,
fiscal_year, ledger_date, voucher_no, final_status, final_rank,
source_statuses, source_group_count, created_at, updated_at
)
WITH source_groups AS (
SELECT
fiscal_year,
status_key,
CASE status_key {priority_case} ELSE 99 END AS status_rank,
COALESCE(NULLIF(TRIM(ledger_date), ''), NULLIF(TRIM(proof_date), ''), '') AS raw_date,
COALESCE(NULLIF(TRIM(voucher_no), ''), CAST(group_index AS TEXT)) AS raw_voucher_no
FROM wehago_compare_query_groups
WHERE start_year = ?
AND end_year = ?
AND signature = ?
AND status_key IN ('voucher_matched', 'voucher_recheck', 'voucher_unmatched', 'voucher_excepted')
),
normalized AS (
SELECT
fiscal_year,
status_key,
status_rank,
CASE
WHEN raw_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' THEN raw_date
WHEN raw_date GLOB '[0-9][0-9]-[0-9][0-9]' THEN printf('%04d-%s', fiscal_year, raw_date)
WHEN raw_date GLOB '[0-9]-[0-9][0-9]' THEN printf('%04d-0%s', fiscal_year, raw_date)
ELSE raw_date
END AS ledger_date,
TRIM(CASE WHEN INSTR(raw_voucher_no, ',') > 0 THEN SUBSTR(raw_voucher_no, 1, INSTR(raw_voucher_no, ',') - 1) ELSE raw_voucher_no END) AS voucher_no
FROM source_groups
),
classified AS (
SELECT
status_key,
status_rank,
fiscal_year,
ledger_date,
voucher_no,
CAST(fiscal_year AS TEXT) || '|' || ledger_date || '|' || voucher_no AS identity_key,
CASE WHEN ledger_date <> '' AND voucher_no <> '' THEN REPLACE(ledger_date, '-', '') || '-' || voucher_no ELSE '' END AS compare_voucher_no
FROM normalized
WHERE COALESCE(voucher_no, '') <> ''
),
resolved AS (
SELECT
identity_key,
MIN(status_rank) AS final_rank,
MIN(fiscal_year) AS fiscal_year,
MIN(ledger_date) AS ledger_date,
MIN(voucher_no) AS voucher_no,
MIN(compare_voucher_no) AS compare_voucher_no,
GROUP_CONCAT(DISTINCT status_key) AS source_statuses,
COUNT(*) AS source_group_count
FROM classified
WHERE COALESCE(identity_key, '') <> ''
GROUP BY identity_key
)
SELECT
?, ?, ?, identity_key, compare_voucher_no,
fiscal_year, ledger_date, voucher_no,
CASE final_rank {status_case} ELSE '' END,
final_rank, COALESCE(source_statuses, ''), source_group_count,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM resolved
WHERE final_rank < 99
""",
(YEAR, YEAR, signature, YEAR, YEAR, signature),
)
return int(
conn.execute(
"""
SELECT COUNT(*)
FROM wehago_compare_final_status_projection
WHERE start_year = ? AND end_year = ? AND signature = ?
""",
(YEAR, YEAR, signature),
).fetchone()[0]
or 0
)
def clear_projection_caches(conn: sqlite3.Connection, signature: str) -> None:
for table_name in (
"wehago_compare_query_page_cache",
"wehago_metric_count_cache",
"wehago_summary_range_cache",
):
conn.execute(
f"""
DELETE FROM {table_name}
WHERE start_year = ? AND end_year = ?
AND signature = ?
""",
(YEAR, YEAR, signature),
)
def main() -> None:
global MANUAL_OFFSET_EXCEPTED_IDENTITIES, RAW_ERP_ROWS_BY_DRAFT_BASE, YEAR
parser = argparse.ArgumentParser(description="Reconcile a WEHAGO comparison query projection to DB rows.")
parser.add_argument("--year", type=int, default=YEAR)
args = parser.parse_args()
YEAR = int(args.year)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
log_step(f"selecting source projection for {YEAR}")
source_signature = latest_projection_signature(cur)
reconciled_prefix = f"{QUERY_PROJECTION_VERSION}|{RECONCILED_PROJECTION_VERSION}|"
target_signature = source_signature if source_signature.startswith(reconciled_prefix) else f"{reconciled_prefix}{source_signature}"
log_step(f"source={source_signature[:120]} target={target_signature[:120]}")
log_step("loading ERP source rows")
RAW_ERP_ROWS_BY_DRAFT_BASE = load_raw_erp_rows_by_draft_base(conn)
log_step("loading manual excepted identities")
MANUAL_OFFSET_EXCEPTED_IDENTITIES = load_manual_offset_excepted_identities(conn)
log_step("loading WEHAGO comparison rows")
db_wehago = load_db_wehago(conn)
log_step(f"loading query groups for source signature ({len(db_wehago)} WEHAGO vouchers)")
groups = load_groups(conn, source_signature)
log_step("loading raw WEHAGO ledger rows")
ledger_rows_by_key: dict[str, list[sqlite3.Row]] = defaultdict(list)
for row in conn.execute(
"""
SELECT *
FROM wehago_ledger_rows
WHERE fiscal_year = ? AND COALESCE(compare_voucher_no, '') <> ''
ORDER BY compare_voucher_no, row_number
""",
(YEAR,),
).fetchall():
ledger_rows_by_key[clean(row["compare_voucher_no"])].append(row)
selected_by_key: dict[str, tuple[str, dict[str, Any]]] = {}
duplicate_counter = 0
removed_non_db = 0
log_step("selecting best WEHAGO groups")
for status_key in WEHAGO_STATUSES:
for group in groups.get(status_key, []):
for identity, split_group in split_group_by_wehago_voucher(group, status_key).items():
split_group = clean_group_rows(split_group, status_key)
if not identity or identity not in db_wehago:
removed_non_db += 1
continue
final_status_key = normalized_wehago_status(status_key, split_group)
if final_status_key != status_key:
split_group["summary"] = rebuild_summary(split_group, final_status_key)
current = selected_by_key.get(identity)
if current is None or group_quality(split_group, final_status_key) > group_quality(current[1], current[0]):
if current is not None:
duplicate_counter += 1
selected_by_key[identity] = (final_status_key, split_group)
else:
duplicate_counter += 1
missing_db = sorted(set(db_wehago) - set(selected_by_key))
log_step(f"adding DB-only WEHAGO groups: {len(missing_db)}")
for key in missing_db:
selected_by_key[key] = (
"voucher_unmatched",
build_missing_db_group(db_wehago[key], ledger_rows_by_key.get(key, [])),
)
status_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
log_step("supplementing raw WEHAGO rows and normalizing statuses")
for key in sorted(selected_by_key):
status_key, group = selected_by_key[key]
group = supplement_group_with_missing_wehago_rows(
group,
status_key,
key,
ledger_rows_by_key.get(key, []),
)
final_status_key = normalized_wehago_status(status_key, group)
status_groups[final_status_key].append(retag_group(group, final_status_key))
log_step("applying excepted rules")
move_excepted_groups(status_groups)
log_step("reapplying split revenue, cancel/reissue, and exact reversal rules")
apply_split_revenue_matches_to_status_groups(status_groups)
apply_cancel_reissue_final_match(status_groups)
apply_accrual_principle_matches(status_groups, groups)
apply_split_draft_row_matches(status_groups)
apply_exact_reversal_pairs_to_status_groups(status_groups)
log_step("enforcing final WEHAGO status invariants")
invariant_diagnostics = enforce_wehago_status_invariants(status_groups)
log_step("rechecking cancel/reissue and exact reversal rules after invariants")
apply_cancel_reissue_final_match(status_groups)
apply_accrual_principle_matches(status_groups, groups)
apply_split_draft_row_matches(status_groups)
apply_exact_reversal_pairs_to_status_groups(status_groups)
invariant_diagnostics = enforce_wehago_status_invariants(status_groups)
log_step("expanding matched ERP vouchers for display")
for display_status_key in ("voucher_matched", "voucher_recheck"):
status_groups[display_status_key] = [
supplement_group_with_full_erp_vouchers(group, display_status_key)
for group in status_groups.get(display_status_key) or []
]
excepted_wehago_keys = {
group_identity(group)
for group in status_groups.get("voucher_excepted") or []
if group_identity(group)
}
allowed_drafts_by_wehago = {
key: allowed_erp_drafts_for_group(group.get("rows") or [])
for group in status_groups.get("voucher_matched") or []
for key in [group_identity(group)]
if key
}
for status_key in ERP_STATUSES:
log_step(f"normalizing ERP side groups: {status_key}")
seen_erp: set[str] = set()
for group in groups.get(status_key, []):
cleaned = clean_group_rows(group, status_key)
if status_key == "erp_voucher_matched":
normalized_rows: list[dict[str, Any]] = []
for row in cleaned.get("rows") or []:
identity = row_wehago_identity(row, cleaned)
draft = erp_voucher_base(row.get("draft_no"))
allowed_drafts = allowed_drafts_by_wehago.get(identity)
if has_wehago_value(row) and has_erp_value(row) and not direct_row_matches_account_nature(row):
normalized_rows.append(blank_wehago_side(row, "PROJECTION_RECONCILE_INVALID_ACCOUNT_NATURE"))
continue
if has_wehago_value(row) and identity in excepted_wehago_keys:
if has_erp_value(row):
normalized_rows.append(blank_wehago_side(row, "PROJECTION_RECONCILE_WEHAGO_EXCEPTED"))
continue
if has_wehago_value(row) and allowed_drafts is not None and draft not in allowed_drafts:
if has_erp_value(row):
normalized_rows.append(blank_wehago_side(row, "PROJECTION_RECONCILE_UNMATCHED_WEHAGO_OVER_CAP"))
continue
normalized_rows.append(row)
cleaned["rows"] = normalized_rows
final_status_key = (
"erp_voucher_matched"
if any(has_wehago_value(row) and has_erp_value(row) for row in normalized_rows)
else "erp_voucher_unmatched"
)
cleaned["summary"] = rebuild_summary(cleaned, final_status_key)
else:
if group_identity(cleaned) in excepted_wehago_keys:
continue
normalized_rows = [
row
for row in cleaned.get("rows") or []
if not (has_wehago_value(row) and row_wehago_identity(row, cleaned) in excepted_wehago_keys)
]
if not normalized_rows:
continue
cleaned["rows"] = normalized_rows
cleaned["summary"] = rebuild_summary(cleaned, status_key)
final_status_key = status_key
summary = cleaned.get("summary") or {}
identity = "|".join(
clean(part)
for part in (summary.get("fiscal_year"), summary.get("proof_date"), erp_voucher_base(summary.get("draft_no")) or summary.get("voucher_no"))
if clean(part)
)
if not identity or identity in seen_erp:
continue
seen_erp.add(identity)
status_groups[final_status_key].append(cleaned)
log_step("writing reconciled projection")
conn.execute("BEGIN")
try:
conn.execute(
"DELETE FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND signature = ?",
(YEAR, YEAR, target_signature),
)
conn.execute(
"DELETE FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ?",
(YEAR, YEAR, target_signature),
)
clear_projection_caches(conn, target_signature)
counts: dict[str, int] = {}
for status_key in ALL_VOUCHER_STATUSES:
log_step(f"inserting {status_key}: {len(status_groups.get(status_key, []))}")
for group_index, group in enumerate(status_groups.get(status_key, []), start=1):
insert_group(conn, target_signature, status_key, group_index, group)
counts[status_key] = len(status_groups.get(status_key, []))
log_step("storing query metric projection")
store_query_metric_projection(conn, target_signature, counts)
log_step("backfilling final status projection")
final_projection_rows = backfill_final_status_projection(conn, target_signature)
log_step("activating projection signature")
activate_projection_signature(conn, target_signature, counts, invariant_diagnostics)
log_step("updating snapshot row counts")
update_snapshot_row_counts(conn, counts)
conn.execute(
"""
INSERT INTO wehago_action_history (action_type, payload_json, created_at)
VALUES ('reconcile_wehago_projection', ?, CURRENT_TIMESTAMP)
""",
(
json.dumps(
{
"start_year": YEAR,
"end_year": YEAR,
"signature": target_signature,
"source_signature": source_signature,
"db_wehago_count": len(db_wehago),
"counts": counts,
"removed_duplicate_groups": duplicate_counter,
"removed_non_db_groups": removed_non_db,
"added_db_only_groups": len(missing_db),
"final_projection_rows": final_projection_rows,
"invariant_diagnostics": invariant_diagnostics,
"created_at": datetime.now().isoformat(timespec="seconds"),
},
ensure_ascii=False,
),
),
)
conn.commit()
log_step("commit complete")
except Exception:
conn.rollback()
raise
print(
json.dumps(
{
"source_signature": source_signature,
"target_signature": target_signature,
"db_wehago_count": len(db_wehago),
"counts": counts,
"wehago_sum": sum(counts.get(status, 0) for status in WEHAGO_STATUSES),
"removed_duplicate_groups": duplicate_counter,
"removed_non_db_groups": removed_non_db,
"added_db_only_groups": len(missing_db),
"final_projection_rows": final_projection_rows,
"invariant_diagnostics": invariant_diagnostics,
},
ensure_ascii=False,
)
)
conn.close()
if __name__ == "__main__":
main()