From 85743ee754c8b162fd56180cf154486b2b0e224a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 11:28:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(z01):=20=EB=8D=AE=EA=B0=9C=EC=97=90=20?= =?UTF-8?q?=EB=84=B7=EC=A7=B8=20=EB=82=B1=EB=A7=90=20meaning=5Fchanged=20?= =?UTF-8?q?=E2=80=94=20=EA=B3=A0=EC=B9=A0=20=EB=95=8C=20=EA=B7=B8=20?= =?UTF-8?q?=EC=A4=84=EC=9D=98=20=EA=B0=92=20=EC=95=84=EB=8B=8C=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=20=EC=B9=B8(=EC=9D=B4=EB=A6=84=C2=B7=EA=B7=9C?= =?UTF-8?q?=EA=B2=A9=C2=B7=EB=8B=A8=EC=9C=84=C2=B7=EC=9D=B8=EB=8F=84?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4)=EC=9D=84=20=ED=95=A8=EA=BB=98=20=EC=A0=81?= =?UTF-8?q?=EA=B3=A0=20=EA=B0=B1=EC=8B=A0=20=EB=92=A4=20=EB=8B=AC=EB=9D=BC?= =?UTF-8?q?=EC=A7=80=EB=A9=B4=20=E3=80=8C=EC=A4=84=EC=9D=98=20=EB=9C=BB?= =?UTF-8?q?=EC=9D=B4=20=EB=B0=94=EB=80=9C=E3=80=8D(=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EC=97=90=20=EB=8B=A4=EB=A5=B8=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=E2=80=94=20=EB=85=B8=EC=9E=84=20=EA=B5=AD=EA=B0=80?= =?UTF-8?q?=EC=9C=A0=EC=82=B0=EC=A7=81=EC=A2=85=20=EA=B0=9C=ED=8E=B8=20?= =?UTF-8?q?=C2=B7=20=EC=9E=90=EC=9E=AC=20KS=20=EA=B0=9C=EC=A0=95)=20=C2=B7?= =?UTF-8?q?=20was/now=20=EB=8A=94=20=EC=84=9C=EB=B2=84=EA=B0=80=20?= =?UTF-8?q?=ED=95=9C=20=EC=A4=84=EB=A1=9C=20=ED=95=A9=EC=B3=90=20=EB=83=84?= =?UTF-8?q?(=EC=9D=B4=EB=A6=84=20=EC=B9=B8=EC=9D=B4=20=EC=97=AC=EB=9F=BF?= =?UTF-8?q?=EC=9D=B8=20=EC=9E=90=EB=A3=8C=EA=B0=80=20=EC=9E=88=EC=96=B4=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=B4=20=ED=95=A9=EC=B9=98=EB=A9=B4=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=EB=91=90=20=EB=B2=8C)=20=C2=B7=20?= =?UTF-8?q?=EB=AA=A8=EC=95=84=20=EB=B3=B4=EA=B8=B0=20=EC=B0=A8=EB=A1=80=20?= =?UTF-8?q?=EB=A7=A8=20=EC=9C=84=20=C2=B7=20=ED=8C=90=C2=B7=EA=B3=B5?= =?UTF-8?q?=ED=91=9C=20=EB=82=A0=EC=A7=9C=EB=8A=94=20=EB=9C=BB=20=EC=B9=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=BA=8C(=EB=B8=8C=EB=A0=88=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_BasePrices.py | 20 +++++++- Z01_MasterData/Z01_MasterData_Overrides.py | 54 +++++++++++++++++---- resources/tester/test_z01_base_prices.py | 53 ++++++++++++++++++++ 3 files changed, 117 insertions(+), 10 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index c5a74fbd..615730c1 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -366,6 +366,24 @@ def column_meta(kind: str, key: str) -> dict[str, Any]: } +#: 뜻 칸에서 뺄 자리 — 판·공표 날짜는 갱신 때마다 바뀌어 「뜻 바뀜」과 구별이 안 됨 +_NOT_MEANING = ("effective_date", "date", "notice_datetime") + + +def meaning_of(kind: str, row: dict[str, Any], editable: list[str]) -> dict[str, Any]: + """그 줄의 **값이 아닌 글자 칸**(이름·규격·단위·인도조건 …) — 이름표가 숨김 아니라 한 열만. + + 갱신으로 같은 코드에 다른 기준이 붙으면(노임 국가유산직종 개편 · 자재 KS 개정) 값만으로는 못 잡음. + """ + out = {} + for key, value in row.items(): + if key.startswith("@") or key in editable or key in _NOT_MEANING or key.endswith("_date"): + continue + if isinstance(value, str) and value.strip() and not column_meta(kind, key)["hidden"]: + out[key] = value + return out + + def public(row: dict[str, Any]) -> dict[str, Any]: """내보낼 줄 — 안쪽 칸(`@source`)은 뺌.""" return {k: v for k, v in row.items() if k != "@source"} @@ -422,7 +440,7 @@ def edit(kind: str, row_id: str, values: dict[str, Any], by: Any) -> tuple[int, problem = overrides.check_value(value) if problem: return 400, {"message": f"{column}: {problem}"} - overrides.write(kind, base, values, by) + overrides.write(kind, base, values, by, meaning_of(kind, base, info["editable"])) return 200, public(next(r for r in rows(kind) if r["@id"] == row_id)) diff --git a/Z01_MasterData/Z01_MasterData_Overrides.py b/Z01_MasterData/Z01_MasterData_Overrides.py index 5beeb6db..9cdc5a8e 100644 --- a/Z01_MasterData/Z01_MasterData_Overrides.py +++ b/Z01_MasterData/Z01_MasterData_Overrides.py @@ -25,7 +25,10 @@ from common_util.common_util_json import atomic_write_json OVERRIDE_DIR = Path(__file__).resolve().parent.parent / "resources" / "data_master_override" EDITED, SOURCE_CHANGED, ORPHAN = "edited", "source_changed", "orphan" -STATE_ORDER = (SOURCE_CHANGED, ORPHAN, EDITED) # 모아 보기 차례 — 확인할 것이 맨 위 +#: ⚠ 넷째 — 값이 아니라 **줄의 뜻**이 바뀜(같은 코드에 다른 기준: 노임 문화재→국가유산 · 자재 KS 개정 규격). +#: 값이 틀린 것보다 **다른 물건에 값이 붙은 것**이 더 나쁨 → 맨 위(브레인 2026-09-16). +MEANING_CHANGED = "meaning_changed" +STATE_ORDER = (MEANING_CHANGED, SOURCE_CHANGED, ORPHAN, EDITED) # 모아 보기 차례 — 센 것이 맨 위 _LOCK = threading.Lock() # ponytail: 한 프로세스 잠금 — 서버를 여럿 띄우면 파일 잠금이 필요 @@ -61,9 +64,33 @@ def check_value(value: Any) -> str | None: return None -def mark(original: Any, value: Any, current: Any) -> dict[str, Any]: - state = EDITED if same(original, current) else SOURCE_CHANGED - return {"original": original, "value": value, "state": state, "current": current} +#: 이름 칸이 여럿인 자료가 있어(자재 품명·규격·단위·인도조건) **서버가 한 줄로 합침** — 화면이 합치면 판정이 두 벌. +_JOIN = " · " + + +def meaning_line(entry: dict[str, Any], row: dict[str, Any] | None) -> tuple[str, str]: + """(고칠 때 그 줄 · 지금 그 줄) 한 줄씩 — 달라진 데가 없으면 빈 둘.""" + stored = entry.get("meaning") or {} + keys = [k for k in stored if row is not None and k in row] + if not keys or all(row[k] == stored[k] for k in keys): + return "", "" + return ( + _JOIN.join(str(stored[k]) for k in keys), + _JOIN.join(str(row[k]) for k in keys), + ) + + +def mark(entry: dict[str, Any], current: Any, row: dict[str, Any] | None = None) -> dict[str, Any]: + original, value = entry.get("original"), entry.get("value") + was, now = meaning_line(entry, row) + if was: + state = MEANING_CHANGED + else: + state = EDITED if same(original, current) else SOURCE_CHANGED + out = {"original": original, "value": value, "state": state, "current": current} + if was: # ⚠ meaning_changed 인데 비어 오면 서버 버그 — 늘 함께 나감 + out["was"], out["now"] = was, now + return out def overlay(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -80,7 +107,7 @@ def overlay(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: for column, entry in entries.items(): if column not in row: continue # 주인 없는 칸 — 모아 보기에만 - marks[column] = mark(entry.get("original"), entry.get("value"), row[column]) + marks[column] = mark(entry, row[column], row) row[column] = entry.get("value") if marks: row["@overrides"] = marks @@ -88,7 +115,13 @@ def overlay(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: return out -def write(kind: str, base_row: dict[str, Any], values: dict[str, Any], by: Any) -> None: +def write( + kind: str, + base_row: dict[str, Any], + values: dict[str, Any], + by: Any, + meaning: dict[str, Any] | None = None, +) -> None: """고친 값을 덮개에 씀 — null 이거나 지금 원본과 같으면 그 칸을 뺌. 원본은 안 건드림.""" row_id = base_row["@id"] with _LOCK: @@ -103,6 +136,8 @@ def write(kind: str, base_row: dict[str, Any], values: dict[str, Any], by: Any) "value": value, "original": current, # 이 값을 보고 고침 — 갱신 뒤 원본과 달라지면 source_changed "source": base_row.get("@source", ""), + # 값이 아닌 글자 칸 — 갱신으로 **줄의 뜻**이 바뀌면 이것으로 잡음 + "meaning": meaning or {}, "at": datetime.now().astimezone().isoformat(timespec="seconds"), "by": by, } @@ -148,10 +183,10 @@ def items(kind: str, base_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: row = by_id.get(row_id) for column, entry in entries.items(): if row is None or column not in row: - current, state = None, ORPHAN + current, marked = None, {"state": ORPHAN} else: current = row[column] - state = mark(entry.get("original"), entry.get("value"), current)["state"] + marked = mark(entry, current, row) out.append( { "kind": kind, @@ -160,7 +195,8 @@ def items(kind: str, base_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: "original": entry.get("original"), "current": current, "value": entry.get("value"), - "state": state, + "state": marked["state"], + **({"was": marked["was"], "now": marked["now"]} if "was" in marked else {}), } ) return out diff --git a/resources/tester/test_z01_base_prices.py b/resources/tester/test_z01_base_prices.py index e8324e3e..c1648c95 100644 --- a/resources/tester/test_z01_base_prices.py +++ b/resources/tester/test_z01_base_prices.py @@ -548,3 +548,56 @@ def test_덮개_얹은_값으로_세움(client: TestClient) -> None: def test_sort_가_없으면_원본_차례(client: TestClient) -> None: assert _get(client, "rate", size=1)["rows"][0]["@id"] == "rate_sanjae" assert _get(client, "machine", size=1)["rows"][0]["@id"] == "0101-0007" + + +def test_줄의_뜻이_바뀌면_넷째_낱말(client: TestClient, store: Path) -> None: + """브레인 — 같은 코드에 다른 기준이 붙음(노임 3001~ 문화재→국가유산 · 자재 KS 개정 규격). + + 값만 보면 안 잡힘 → 덮개를 쓸 때 **값이 아닌 글자 칸**(이름·규격·단위…)을 함께 적어 두고 견줌. + """ + saved = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}).json() + assert saved["@overrides"]["daily_wage_krw"]["state"] == overrides.EDITED + entry = json.loads((store / "labor.json").read_text(encoding="utf-8"))["overrides"] + meaning = entry["labor_const/1001"]["daily_wage_krw"]["meaning"] + assert meaning["occupation_name"] == "작업반장" # 이름·규격 같은 글자 칸만 + assert "daily_wage_krw" not in meaning and "hours_per_day" not in meaning # 값 칸은 안 적음 + assert "effective_date" not in meaning # 판 날짜는 갱신마다 바뀜 — 뜻 바뀜과 구별이 안 됨 + _put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 1900}) + oil = json.loads((store / "oil.json").read_text(encoding="utf-8"))["overrides"] + assert "date" not in oil["national_average/oil_diesel"]["price_krw_per_l"]["meaning"] + entry["labor_const/1001"]["daily_wage_krw"]["meaning"]["occupation_name"] = "옛 직종 이름" + (store / "labor.json").write_text( + json.dumps({"schema_version": "1.0", "kind": "labor", "overrides": entry}), encoding="utf-8" + ) + mark = _row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"] + assert mark["state"] == overrides.MEANING_CHANGED # 값은 그대로인데 줄의 뜻이 바뀜 + # 이름 칸이 여럿인 자료가 있어 **서버가 한 줄로 합쳐** 냄(화면이 합치면 판정이 두 벌) + assert mark["was"] == "labor_const · 1001 · 옛 직종 이름 · KRW/day · published" + assert mark["now"] == "labor_const · 1001 · 작업반장 · KRW/day · published" + listed = client.get("/api/master-data/overrides").json() + top = listed["items"][0] + assert top["state"] == overrides.MEANING_CHANGED # 값 틀림보다 더 세게 + assert (top["was"], top["now"]) == (mark["was"], mark["now"]) # 모아 보기에도 같은 둘 + assert overrides.STATE_ORDER[0] == overrides.MEANING_CHANGED + assert all(i.get("was") for i in listed["items"] if i["state"] == overrides.MEANING_CHANGED) + + +def test_뜻_칸은_이름표가_가림_옛_덮개는_그대로(client: TestClient, store: Path) -> None: + _put(client, "machine", "0101-0007", {"price_thousand_krw": 80000}) + meaning = json.loads((store / "machine.json").read_text(encoding="utf-8"))["overrides"][ + "0101-0007" + ]["price_thousand_krw"]["meaning"] + assert meaning["machine_name"] == "불도저(무한궤도)" and meaning["specification"] == "7" + assert not [k for k in meaning if k in ("price_thousand_krw", "loss_coefficient_per_hour")] + store.mkdir( + exist_ok=True + ) # 뜻 칸이 없던 옛 덮개 — 그대로 edited 로 둠(없는 것을 지어내지 않음) + (store / "material.json").write_text( + json.dumps( + {"overrides": {"10023392": {"price_krw": {"value": 30, "original": 22, "source": "x"}}}} + ), + encoding="utf-8", + ) + assert ( + _row(client, "material", "10023392")["@overrides"]["price_krw"]["state"] == overrides.EDITED + )