Files
HM_project_Viewer_Board/scripts/analyze_satis_project_mapping.py
T

373 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()