373 lines
15 KiB
Python
373 lines
15 KiB
Python
#!/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()
|