Update wehago matching logic and exclude reports
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a read-only Satis-to-local project mapping review report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import unicodedata
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROUND_SUFFIX_RE = re.compile(r"\s*[\(\[]\s*(\d+)\s*차\s*[\)\]]\s*$")
|
||||
CODE_RE = re.compile(r"^\d{6}$")
|
||||
|
||||
|
||||
def text(value: Any) -> str:
|
||||
return "" if value is None else str(value).strip()
|
||||
|
||||
|
||||
def number(value: Any) -> float:
|
||||
try:
|
||||
return float(str(value).replace(",", "").strip() or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def normalize_name(value: Any, *, remove_round: bool = False) -> str:
|
||||
value = unicodedata.normalize("NFKC", text(value)).lower()
|
||||
value = re.sub(r"^\[[0-9]{6}\]\s*", "", value)
|
||||
value = re.sub(r"^\((?:범한|공동|분담|주관)\)\s*", "", value)
|
||||
if remove_round:
|
||||
value = ROUND_SUFFIX_RE.sub("", value)
|
||||
value = value.replace("~", "~").replace("〜", "~")
|
||||
return re.sub(r"[^0-9a-z가-힣]", "", value)
|
||||
|
||||
|
||||
def local_round(name: str) -> int | None:
|
||||
match = ROUND_SUFFIX_RE.search(text(name))
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def transformed_code(local_code: str) -> str:
|
||||
if re.fullmatch(r"[YZ]\d{5}", local_code):
|
||||
return f"0{local_code[1:]}"
|
||||
if re.fullmatch(r"X\d{5}", local_code):
|
||||
return f"9{local_code[1:]}"
|
||||
return local_code if CODE_RE.fullmatch(local_code) else ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ErpProject:
|
||||
code: str
|
||||
name: str = ""
|
||||
start_date: str = ""
|
||||
end_date: str = ""
|
||||
amount: float = 0.0
|
||||
revisions: set[str] = field(default_factory=set)
|
||||
statuses: set[str] = field(default_factory=set)
|
||||
sources: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
def extract_erp_row(source_table: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if source_table.startswith("SCREEN_03:"):
|
||||
code = text(payload.get("item03"))
|
||||
name = text(payload.get("item04"))
|
||||
revision = text(payload.get("item17") or payload.get("degree"))
|
||||
status = text(payload.get("item18"))
|
||||
start_date = text(payload.get("item08"))
|
||||
end_date = text(payload.get("item09"))
|
||||
amount = number(payload.get("item10"))
|
||||
else:
|
||||
code = text(payload.get("item01") or payload.get("project_code"))
|
||||
name = text(payload.get("item02"))
|
||||
revision = text(payload.get("degree") or payload.get("item10"))
|
||||
status = text(payload.get("item11") or payload.get("item12"))
|
||||
start_date = text(payload.get("item04") or payload.get("item07"))
|
||||
end_date = text(payload.get("item05") or payload.get("item08"))
|
||||
amount = number(payload.get("item06") or payload.get("item07") or payload.get("item09"))
|
||||
if not CODE_RE.fullmatch(code) or not name or CODE_RE.fullmatch(name):
|
||||
return None
|
||||
return {
|
||||
"code": code,
|
||||
"name": name,
|
||||
"revision": revision,
|
||||
"status": re.sub(r"<[^>]+>", "", status),
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"amount": amount,
|
||||
}
|
||||
|
||||
|
||||
def load_erp_projects(conn: sqlite3.Connection) -> dict[str, ErpProject]:
|
||||
projects: dict[str, ErpProject] = {}
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT source_table, raw_payload_json
|
||||
FROM satis_project_budget_raw_rows
|
||||
WHERE source_database = 'satis_web'
|
||||
"""
|
||||
)
|
||||
for row in rows:
|
||||
try:
|
||||
payload = json.loads(text(row["raw_payload_json"]))
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
extracted = extract_erp_row(text(row["source_table"]), payload)
|
||||
if not extracted:
|
||||
continue
|
||||
project = projects.setdefault(extracted["code"], ErpProject(code=extracted["code"]))
|
||||
candidate_name = extracted["name"]
|
||||
if not project.name or len(candidate_name) > len(project.name):
|
||||
project.name = candidate_name
|
||||
if not project.start_date and extracted["start_date"]:
|
||||
project.start_date = extracted["start_date"]
|
||||
if not project.end_date and extracted["end_date"]:
|
||||
project.end_date = extracted["end_date"]
|
||||
if extracted["amount"] > project.amount:
|
||||
project.amount = extracted["amount"]
|
||||
if extracted["revision"]:
|
||||
project.revisions.add(extracted["revision"])
|
||||
if extracted["status"]:
|
||||
project.statuses.add(extracted["status"])
|
||||
project.sources.add(text(row["source_table"]))
|
||||
return projects
|
||||
|
||||
|
||||
def candidate_score(local: sqlite3.Row, erp: ErpProject) -> tuple[float, list[str]]:
|
||||
local_code = text(local["support_dept_code"])
|
||||
local_name = text(local["support_dept_name"])
|
||||
reasons: list[str] = []
|
||||
score = 0.0
|
||||
if CODE_RE.fullmatch(local_code) and local_code == erp.code:
|
||||
score = 1.0
|
||||
reasons.append("코드 완전일치")
|
||||
elif transformed_code(local_code) == erp.code:
|
||||
score = 0.99
|
||||
reasons.append(f"코드 규칙일치({local_code}→{erp.code})")
|
||||
|
||||
strict_local = normalize_name(local_name)
|
||||
strict_erp = normalize_name(erp.name)
|
||||
base_local = normalize_name(local_name, remove_round=True)
|
||||
base_erp = normalize_name(erp.name, remove_round=True)
|
||||
strict_similarity = SequenceMatcher(None, strict_local, strict_erp).ratio()
|
||||
base_similarity = SequenceMatcher(None, base_local, base_erp).ratio()
|
||||
if strict_local and strict_local == strict_erp:
|
||||
score = max(score, 0.98)
|
||||
reasons.append("프로젝트명 완전일치")
|
||||
elif base_local and base_local == base_erp:
|
||||
score = max(score, 0.95)
|
||||
reasons.append("차수 제거 프로젝트명 일치")
|
||||
elif base_similarity >= 0.72:
|
||||
score = max(score, base_similarity * 0.92)
|
||||
reasons.append(f"명칭 유사도 {base_similarity:.3f}")
|
||||
|
||||
local_amount = number(local["contract_amount"])
|
||||
if local_amount and erp.amount:
|
||||
amount_ratio = min(local_amount, erp.amount) / max(local_amount, erp.amount)
|
||||
if amount_ratio >= 0.98:
|
||||
score = min(1.0, score + 0.02)
|
||||
reasons.append("계약금액 일치")
|
||||
elif amount_ratio < 0.5:
|
||||
score -= 0.06
|
||||
reasons.append("계약금액 차이 큼")
|
||||
return score, reasons
|
||||
|
||||
|
||||
def classify(score: float, margin: float, reasons: list[str]) -> tuple[str, str]:
|
||||
code_match = any(reason.startswith(("코드 완전", "코드 규칙")) for reason in reasons)
|
||||
exact_name = any("프로젝트명 완전" in reason for reason in reasons)
|
||||
base_name = any("차수 제거" in reason for reason in reasons)
|
||||
if code_match and score >= 0.95:
|
||||
return "auto_safe", "자동매핑 가능"
|
||||
if exact_name and score >= 0.96 and margin >= 0.03:
|
||||
return "auto_safe", "자동매핑 가능"
|
||||
if base_name and score >= 0.93 and margin >= 0.03:
|
||||
return "auto_1_to_many", "1:N 자동연계 가능"
|
||||
if score >= 0.84 and margin >= 0.04:
|
||||
return "review_high", "검토 후 매핑"
|
||||
if score >= 0.72:
|
||||
return "review_low", "수동 확인 필요"
|
||||
return "unmatched", "매핑 불가"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--db", default="/home/b17301/intranet-runtime/db/data.db")
|
||||
parser.add_argument("--output-dir", default="reports")
|
||||
args = parser.parse_args()
|
||||
|
||||
db_path = Path(args.db).resolve()
|
||||
output_dir = Path(args.output_dir).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
locals_ = conn.execute(
|
||||
"""
|
||||
SELECT support_dept_code, support_dept_name, contract_amount,
|
||||
project_start_date, project_end_date
|
||||
FROM project_status
|
||||
WHERE COALESCE(support_dept_code, '') <> ''
|
||||
ORDER BY support_dept_code
|
||||
"""
|
||||
).fetchall()
|
||||
erp_projects = load_erp_projects(conn)
|
||||
existing = {
|
||||
row["support_dept_code"]: dict(row)
|
||||
for row in conn.execute(
|
||||
"""
|
||||
SELECT erp_project_code, erp_project_name, support_dept_code,
|
||||
mapping_status, mapping_basis, manual_override
|
||||
FROM satis_project_mapping
|
||||
WHERE COALESCE(support_dept_code, '') <> ''
|
||||
"""
|
||||
)
|
||||
}
|
||||
for old in existing.values():
|
||||
erp_code = text(old.get("erp_project_code"))
|
||||
erp_name = text(old.get("erp_project_name"))
|
||||
if CODE_RE.fullmatch(erp_code) and erp_name:
|
||||
project = erp_projects.setdefault(erp_code, ErpProject(code=erp_code))
|
||||
if not project.name:
|
||||
project.name = erp_name
|
||||
project.sources.add("satis_project_mapping")
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for local in locals_:
|
||||
scored: list[tuple[float, ErpProject, list[str]]] = []
|
||||
for erp in erp_projects.values():
|
||||
score, reasons = candidate_score(local, erp)
|
||||
if score >= 0.55:
|
||||
scored.append((score, erp, reasons))
|
||||
scored.sort(key=lambda item: (item[0], item[1].code), reverse=True)
|
||||
best = scored[0] if scored else (0.0, ErpProject(code=""), [])
|
||||
second_score = scored[1][0] if len(scored) > 1 else 0.0
|
||||
score, erp, reasons = best
|
||||
margin = score - second_score
|
||||
category, decision = classify(score, margin, reasons)
|
||||
old = existing.get(text(local["support_dept_code"]), {})
|
||||
results.append(
|
||||
{
|
||||
"local_code": text(local["support_dept_code"]),
|
||||
"local_name": text(local["support_dept_name"]),
|
||||
"local_round": local_round(text(local["support_dept_name"])) or "",
|
||||
"erp_code": erp.code,
|
||||
"erp_name": erp.name,
|
||||
"erp_revisions": "|".join(sorted(erp.revisions)),
|
||||
"score": f"{score:.3f}",
|
||||
"margin": f"{margin:.3f}",
|
||||
"category": category,
|
||||
"decision": decision,
|
||||
"basis": ", ".join(reasons),
|
||||
"existing_erp_code": text(old.get("erp_project_code")),
|
||||
"existing_basis": text(old.get("mapping_basis")),
|
||||
"existing_consistent": (
|
||||
"Y"
|
||||
if old and text(old.get("erp_project_code")) == erp.code
|
||||
else ("N" if old else "")
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
accepted_by_erp: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for result in results:
|
||||
if result["erp_code"] and result["category"] != "unmatched":
|
||||
accepted_by_erp[result["erp_code"]].append(result)
|
||||
for linked_results in accepted_by_erp.values():
|
||||
if len(linked_results) < 2:
|
||||
continue
|
||||
for result in linked_results:
|
||||
has_code_basis = "코드 완전일치" in result["basis"] or "코드 규칙일치" in result["basis"]
|
||||
if not has_code_basis and result["category"] == "auto_safe":
|
||||
result["category"] = "auto_1_to_many"
|
||||
result["decision"] = "1:N 자동연계 가능"
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
csv_path = output_dir / f"satis_project_mapping_review_{timestamp}.csv"
|
||||
with csv_path.open("w", encoding="utf-8-sig", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=list(results[0].keys()))
|
||||
writer.writeheader()
|
||||
writer.writerows(results)
|
||||
|
||||
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for result in results:
|
||||
grouped[result["category"]].append(result)
|
||||
report_path = output_dir / f"satis_project_mapping_review_{timestamp}.md"
|
||||
category_order = ["auto_safe", "auto_1_to_many", "review_high", "review_low", "unmatched"]
|
||||
labels = {
|
||||
"auto_safe": "자동매핑 가능",
|
||||
"auto_1_to_many": "1:N 자동연계 가능",
|
||||
"review_high": "검토 후 매핑",
|
||||
"review_low": "수동 확인 필요",
|
||||
"unmatched": "매핑 불가",
|
||||
}
|
||||
one_to_many = defaultdict(list)
|
||||
for result in results:
|
||||
if result["erp_code"] and result["category"] != "unmatched":
|
||||
one_to_many[result["erp_code"]].append(result["local_code"])
|
||||
|
||||
lines = [
|
||||
"# Satis 프로젝트 자동매핑 검토",
|
||||
"",
|
||||
f"- 분석 DB: `{db_path}`",
|
||||
f"- 로컬 예산 대상 프로젝트: {len(locals_)}개",
|
||||
f"- 저장 원본에서 재구성한 ERP 프로젝트: {len(erp_projects)}개",
|
||||
f"- 기존 확정 매핑: {len(existing)}개",
|
||||
"- 범위: 예산 입력 대상인 `project_status` 38개(거래전표에만 존재하는 코드는 제외)",
|
||||
"",
|
||||
"## 판정 요약",
|
||||
"",
|
||||
"| 판정 | 건수 |",
|
||||
"|---|---:|",
|
||||
]
|
||||
for category in category_order:
|
||||
lines.append(f"| {labels[category]} | {len(grouped[category])} |")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## 연계 후보",
|
||||
"",
|
||||
"| 로컬 코드 | 로컬 프로젝트 | ERP 코드 | ERP 프로젝트 | 점수 | 판정 | 근거 |",
|
||||
"|---|---|---|---|---:|---|---|",
|
||||
]
|
||||
)
|
||||
for result in results:
|
||||
lines.append(
|
||||
"| {local_code} | {local_name} | {erp_code} | {erp_name} | {score} | {decision} | {basis} |".format(
|
||||
**{key: text(value).replace("|", "/") for key, value in result.items()}
|
||||
)
|
||||
)
|
||||
multi_links = {code: codes for code, codes in one_to_many.items() if len(codes) > 1}
|
||||
lines.extend(["", "## 1:N 연계 후보", ""])
|
||||
if multi_links:
|
||||
for code, codes in sorted(multi_links.items()):
|
||||
lines.append(f"- ERP `{code}` → 로컬 {', '.join(f'`{item}`' for item in codes)}")
|
||||
else:
|
||||
lines.append("- 없음")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## 적용 판단",
|
||||
"",
|
||||
"- `auto_safe`는 코드 또는 고유한 완전일치 명칭을 근거로 자동 반영할 수 있습니다.",
|
||||
"- `auto_1_to_many`는 ERP 총괄 프로젝트 하나와 로컬 차수 프로젝트 여러 개를 연결할 별도 링크 테이블이 필요합니다.",
|
||||
"- `review_high/review_low`는 계약금액·기간·발주처를 추가 대조한 뒤 확정해야 합니다.",
|
||||
"- 기존 `satis_project_mapping`은 ERP 코드가 UNIQUE라 1:N 구조를 저장할 수 없으므로 자동 확장 전에 스키마 개선이 필요합니다.",
|
||||
]
|
||||
)
|
||||
report_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
print(json.dumps({
|
||||
"db": str(db_path),
|
||||
"erp_projects": len(erp_projects),
|
||||
"local_projects": len(locals_),
|
||||
"counts": {category: len(grouped[category]) for category in category_order},
|
||||
"csv": str(csv_path),
|
||||
"report": str(report_path),
|
||||
}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user