3196 lines
131 KiB
Python
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()
|