diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 7383ceb32..aac015d41 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -283,6 +283,12 @@ def save(batch: list[dict], confirmed: bool = False) -> dict: """`/save` — 로직 정본 줄 고침은 `confirmed`(「원문 개정 반영」) 없으면 409(`Store_Revise`) · 자체 파일 새 줄(키 없음)은 서버가 칸을 잡고 GX 다음 키 — {files, added, warnings}.""" revise.guard(batch, confirmed) + for part in batch: # 별칭(815-2) = 글 그대로 · 앞뒤 빈칸만 뗌 · 빈 글이면 칸 없음 + for ch in part.get("changes") or []: + row = ch.get("row") + if isinstance(row, dict) and "별칭" in row: + text = str(row["별칭"] or "").strip() + row.update(별칭=text) if text else row.pop("별칭") fresh = False for part in batch: if part.get("file") != OWN: diff --git a/M01_MasterData/M01_MasterData_UI_Rows.ts b/M01_MasterData/M01_MasterData_UI_Rows.ts index a2ea6a063..89ec75704 100644 --- a/M01_MasterData/M01_MasterData_UI_Rows.ts +++ b/M01_MasterData/M01_MasterData_UI_Rows.ts @@ -37,6 +37,9 @@ const MARKET = "재료_자재품목.json"; const LINKED = "재료_품셈재료.json"; const JOB = ["인력.json"]; const NOTE = "비고"; // 끝 열(인력은 준용 앞) · 줄에 없으면 빈 칸 · 눌러 적음 +// 별칭(815-2 · 요구 764) = 비고 앞 한 열 · 콤마로 여러 개 · 빈 글이면 칸을 지움 · 이 넷만 +const ALIAS = "별칭"; +const ALIASED = ["인력.json", "기계.json", MARKET, "재료_유가전력.json"]; const FALLBACK: Row = { 키: "", 원문번호: "", 이름: "", 규격: "", 단위: "", 값: null, 출처: "" }; /** 새 줄 바탕 — 본 줄과 같은 칸 · 글 칸은 "" · 수·값 칸은 null. */ @@ -44,16 +47,16 @@ function blank(sample: Row): Row { const out: Row = {}; for (const [k, v] of Object.entries(sample)) { if (v && typeof v === "object" && !Array.isArray(v)) out[k] = blank(v as Row); - else out[k] = typeof v === "string" && k !== "값" ? "" : null; + else if (k !== ALIAS) out[k] = typeof v === "string" && k !== "값" ? "" : null; } return out; } -/** 비고 칸 고침 — 빈 글이면 칸을 지움 */ -function withNote(row: Row, text: string): Row { +/** 비고 · 별칭 칸 고침 — 빈 글이면 칸을 지움(별칭은 앞뒤 빈칸 뗌) */ +function withNote(row: Row, text: string, col = NOTE): Row { const out = { ...row }; - if (text.trim()) out[NOTE] = text; - else delete out[NOTE]; + if (text.trim()) out[col] = col === ALIAS ? text.trim() : text; + else delete out[col]; return out; } @@ -117,8 +120,9 @@ export function renderRows( const cols: string[] = []; for (const row of [...adds, ...d.rows]) { for (const k of Object.keys(flatten(row))) - if (!hidden(file, k) && k !== NOTE && !cols.includes(k)) cols.push(k); + if (!hidden(file, k) && k !== NOTE && k !== ALIAS && !cols.includes(k)) cols.push(k); } + const aliased = ALIASED.includes(file); const heads = (HEADS[file] ?? []).map((h) => el("th", { text: h.startsWith("M01_") ? L(h as "M01_PriceCol") : h }), ); @@ -127,6 +131,7 @@ export function renderRows( el("th"), ...cols.map((c) => el("th", { text: c })), ...(JOB.includes(file) ? [] : heads), + ...(aliased ? [el("th", { text: ALIAS })] : []), el("th", { text: NOTE }), ...(JOB.includes(file) ? heads : []), ], @@ -145,12 +150,13 @@ export function renderRows( }), ); } - tr.append( - buildCell(show(row[NOTE]), { - changed: true, - onEdit: (text) => setAdd(file, i, withNote(row, text)), - }), - ); + for (const col of aliased ? [ALIAS, NOTE] : [NOTE]) + tr.append( + buildCell(show(row[col]), { + changed: true, + onEdit: (text) => setAdd(file, i, withNote(row, text, col)), + }), + ); if (JOB.includes(file)) tr.append(el("td")); body.append(tr); }); @@ -186,15 +192,16 @@ export function renderRows( ? undefined : (next: Row): void => editRow(file, d.version, key, row, next); if (file === LINKED) tr.append(linkedCell(row, cur, put)); - tr.append( - buildCell(show(cur[NOTE]), { - changed: !same(cur[NOTE], row[NOTE]), - onEdit: - deleted || !isScalar(row[NOTE]) - ? undefined - : (text) => editRow(file, d.version, key, row, withNote(cur, text)), - }), - ); + for (const col of aliased ? [ALIAS, NOTE] : [NOTE]) + tr.append( + buildCell(show(cur[col]), { + changed: !same(cur[col], row[col]), + onEdit: + deleted || !isScalar(row[col]) + ? undefined + : (text) => editRow(file, d.version, key, row, withNote(cur, text, col)), + }), + ); if (JOB.includes(file)) tr.append(wageCell(row, cur, put)); body.append(tr); } diff --git a/resources/master_data/scripts/check_master.py b/resources/master_data/scripts/check_master.py index 656407821..fb7f260e1 100644 --- a/resources/master_data/scripts/check_master.py +++ b/resources/master_data/scripts/check_master.py @@ -149,6 +149,11 @@ COLS = { ), } COLS["한국은행환율"] = COLS["유가전력"] +ALIAS = "별칭" # 요소 줄 덧칸(815-2) — 열 한 벌 밖 · 있으면 빈 글 아님 + + +def _cols_of(row: dict) -> tuple: + return tuple(k for k in row if k != ALIAS) def _detail(name: str) -> str: @@ -310,7 +315,9 @@ def check_form(name: str, data: dict) -> list[str]: out.append(f"{where} · 출처 모양 「{row['출처']}」") if data.get("그룹") == "인력": out += _check_labor(where, row) - if cols and tuple(row) != cols: + if ALIAS in row and not (isinstance(row[ALIAS], str) and row[ALIAS].strip()): + out.append(f"{where} · 「{ALIAS}」 이 빈 글 · 글 아님(빈 칸이면 칸을 두지 않음)") + if cols and _cols_of(row) != cols: out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(cols)}」") if kind == "품셈재료": out += _check_linked(where, row) @@ -328,7 +335,7 @@ def check_form(name: str, data: dict) -> list[str]: def _check_labor(where: str, row: dict) -> list[str]: """인력 줄 — 열 한 벌(차례까지 같음) · 상태 값 · 옛이름 목록.""" out = [] - if tuple(row) != LABOR_COLS: + if _cols_of(row) != LABOR_COLS: out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(LABOR_COLS)}」") if row.get("상태") not in LABOR_STATUS: out.append(f"{where} · 상태 「{row.get('상태')}」 — {' · '.join(LABOR_STATUS)} 아님") diff --git a/resources/master_data/기계.json b/resources/master_data/기계.json index a9c562fa7..0377d698a 100644 --- a/resources/master_data/기계.json +++ b/resources/master_data/기계.json @@ -12574,7 +12574,8 @@ "조종원": "건설품셈 공통 8-4-4", "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름" + "비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름", + "별칭": "아스팔트페이버(피니셔)" }, { "키": "EQ000193", @@ -12635,7 +12636,8 @@ "조종원": "건설품셈 공통 8-4-4", "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름" + "비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름", + "별칭": "아스팔트페이버(피니셔)" }, { "키": "EQ000194", @@ -16031,9 +16033,8 @@ "조종원직종": "건설품셈 공통 8-1-3 5" }, "비고": "규격 단위: (m/m) · 주연료종류: 원문 글을 가름", - "값원문": { - "8-4-5 주연료": "휘발유1.0" - } + "값원문": { "8-4-5 주연료": "휘발유1.0" }, + "별칭": "바이브레이트 봉상후렉시블" }, { "키": "EQ000249", @@ -25212,7 +25213,8 @@ "조종원": "건설품셈 공통 8-4-7", "조종원직종": "건설품셈 공통 8-1-3 5" }, - "비고": "주연료종류: 원문 글을 가름" + "비고": "주연료종류: 원문 글을 가름", + "별칭": "워터제트" }, { "키": "EQ000401", @@ -26734,7 +26736,8 @@ "조종원": "건설품셈 공통 8-4-8", "조종원직종": "건설품셈 공통 8-1-3 5" }, - "비고": "주연료종류: 원문 글을 가름" + "비고": "주연료종류: 원문 글을 가름", + "별칭": "버킷준설기" }, { "키": "EQ000426", @@ -31131,7 +31134,8 @@ "조종원": "건설품셈 공통 8-4-8", "조종원직종": "건설품셈 공통 8-1-3 5" }, - "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름" + "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름", + "별칭": "디젤 엔진" }, { "키": "EQ000498", @@ -31192,7 +31196,8 @@ "조종원": "건설품셈 공통 8-4-8", "조종원직종": "건설품셈 공통 8-1-3 5" }, - "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름" + "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름", + "별칭": "디젤 엔진" }, { "키": "EQ000499", @@ -31253,7 +31258,8 @@ "조종원": "건설품셈 공통 8-4-8", "조종원직종": "건설품셈 공통 8-1-3 5" }, - "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름" + "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름", + "별칭": "디젤 엔진" }, { "키": "EQ000500", @@ -31314,7 +31320,8 @@ "조종원": "건설품셈 공통 8-4-8", "조종원직종": "건설품셈 공통 8-1-3 5" }, - "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름" + "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름", + "별칭": "디젤 엔진" }, { "키": "EQ000501", @@ -31375,7 +31382,8 @@ "조종원": "건설품셈 공통 8-4-8", "조종원직종": "건설품셈 공통 8-1-3 5" }, - "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름" + "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름", + "별칭": "디젤 엔진" }, { "키": "EQ000502", @@ -31436,7 +31444,8 @@ "조종원": "건설품셈 공통 8-4-8", "조종원직종": "건설품셈 공통 8-1-3 5" }, - "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름" + "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름", + "별칭": "디젤 엔진" }, { "키": "EQ000503", @@ -31497,7 +31506,8 @@ "조종원": "건설품셈 공통 8-4-8", "조종원직종": "건설품셈 공통 8-1-3 5" }, - "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름" + "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름", + "별칭": "디젤 엔진" }, { "키": "EQ000504", @@ -31558,7 +31568,8 @@ "조종원": "건설품셈 공통 8-4-8", "조종원직종": "건설품셈 공통 8-1-3 5" }, - "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름" + "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름", + "별칭": "디젤 엔진" }, { "키": "EQ000505", @@ -31619,7 +31630,8 @@ "조종원": "건설품셈 공통 8-4-8", "조종원직종": "건설품셈 공통 8-1-3 5" }, - "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름" + "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름", + "별칭": "디젤 엔진" }, { "키": "EQ000506", @@ -31680,7 +31692,8 @@ "조종원": "건설품셈 공통 8-4-8", "조종원직종": "건설품셈 공통 8-1-3 5" }, - "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름" + "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름", + "별칭": "디젤 엔진" }, { "키": "EQ000507", @@ -31741,7 +31754,8 @@ "조종원": "건설품셈 공통 8-4-8", "조종원직종": "건설품셈 공통 8-1-3 5" }, - "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름" + "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름", + "별칭": "디젤 엔진" }, { "키": "EQ000508", diff --git a/resources/master_template/실무자료/맞춤.json b/resources/master_template/실무자료/맞춤.json deleted file mode 100644 index 7b37a261a..000000000 --- a/resources/master_template/실무자료/맞춤.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "줄": { - "아스팔트페이버(피니셔)||시간": { - "원래": { - "품명": "아스팔트페이버(피니셔)", - "규격": "", - "단위": "hr" - }, - "갈래": "짝", - "짝": [ - { - "갈래": "기계", - "키": "EQ000193", - "입력값": {}, - "배": 1, - "몫": null - } - ], - "엑셀조항": [], - "누가": { - "id": "요구735-5·736", - "이름": "사용자 승인(시스템 관리자)" - }, - "때": "2026-11-09T11:13:18Z", - "처음파일": null - }, - "디젤엔진||시간": { - "원래": { - "품명": "디젤 엔진", - "규격": "", - "단위": "hr" - }, - "갈래": "짝", - "짝": [ - { - "갈래": "기계", - "키": "EQ000500", - "입력값": {}, - "배": 1, - "몫": null - } - ], - "엑셀조항": [], - "누가": { - "id": "요구735-5·736", - "이름": "사용자 승인(시스템 관리자)" - }, - "때": "2026-11-09T11:13:18Z", - "처음파일": null - }, - "워터제트||시간": { - "원래": { - "품명": "워터제트", - "규격": "", - "단위": "hr" - }, - "갈래": "짝", - "짝": [ - { - "갈래": "기계", - "키": "EQ000400", - "입력값": {}, - "배": 1, - "몫": null - } - ], - "엑셀조항": [], - "누가": { - "id": "요구735-5·736", - "이름": "사용자 승인(시스템 관리자)" - }, - "때": "2026-11-09T11:13:18Z", - "처음파일": null - }, - "버킷준설기||시간": { - "원래": { - "품명": "버킷준설기", - "규격": "", - "단위": "hr" - }, - "갈래": "짝", - "짝": [ - { - "갈래": "기계", - "키": "EQ000425", - "입력값": {}, - "배": 1, - "몫": null - } - ], - "엑셀조항": [], - "누가": { - "id": "요구735-5·736", - "이름": "사용자 승인(시스템 관리자)" - }, - "때": "2026-11-09T11:13:18Z", - "처음파일": null - }, - "바이브레이트|봉상후렉시블|시간": { - "원래": { - "품명": "바이브레이트", - "규격": "봉상후렉시블", - "단위": "hr" - }, - "갈래": "짝", - "짝": [ - { - "갈래": "기계", - "키": "EQ000248", - "입력값": {}, - "배": 1, - "몫": null - } - ], - "엑셀조항": [], - "누가": { - "id": "요구735-5·736", - "이름": "사용자 승인(시스템 관리자)" - }, - "때": "2026-11-09T11:13:18Z", - "처음파일": null - } - } -} diff --git a/resources/tester/test_815_2_alias_column.py b/resources/tester/test_815_2_alias_column.py new file mode 100644 index 000000000..f622bb4ae --- /dev/null +++ b/resources/tester/test_815_2_alias_column.py @@ -0,0 +1,104 @@ +"""PLAN 815-2 — M01 요소 「별칭」 한 열(요구 764 · 765). + +① 별칭 칸 저장 = 그 줄만 바뀜 · 앞뒤 빈칸 뗌 · 빈 글이면 칸 없음(임시 사본 폴더만) +② M01 길은 시스템 관리자만(아니면 403) +③ 마스터 기계 별칭 다섯 → 기계 줄 `별칭` · 옛 장부 파일 걷음 +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from common_util import common_util_auth as auth +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store + +ROOT = Path(__file__).resolve().parents[2] +REAL = store.FOLDER +MACHINE = "기계.json" +KEY = "EQ000001" +FIVE = { + "EQ000192": "아스팔트페이버(피니셔)", + "EQ000193": "아스팔트페이버(피니셔)", + **{f"EQ000{n}": "디젤 엔진" for n in range(497, 508)}, + "EQ000400": "워터제트", + "EQ000425": "버킷준설기", + "EQ000248": "바이브레이트 봉상후렉시블", +} + + +@pytest.fixture +def copy(tmp_path: Path, monkeypatch) -> Path: + for path in REAL.glob("*.json"): + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + return tmp_path + + +def _app(role: str = "SYSTEM_ADMIN") -> TestClient: + app = FastAPI() + app.dependency_overrides[auth.verify_session] = lambda: { + "user_id": 1, + "name": "시험", + "role": role, + "company_id": 1, + } + deps = [Depends(auth.verify_session), Depends(auth.require_system_admin)] + app.include_router(router_module.router, dependencies=deps) + return TestClient(app) + + +def _save_alias(client: TestClient, text: str) -> None: + one = client.get("/api/m01/rows", params={"file": MACHINE, "q": KEY}).json() + row = next(r for r in one["rows"] if r["키"] == KEY) + part = { + "file": MACHINE, + "version": one["version"], + "changes": [{"op": "edit", "key": KEY, "row": {**row, "별칭": text}}], + } + res = client.post("/api/m01/save", json={"files": [part]}) + assert res.status_code == 200, res.text + + +def _around(text: str) -> tuple[str, str, str]: + """(그 줄 앞 글, 그 줄 글, 뒤 글).""" + start = text.rfind("{", 0, text.index(f'"키": "{KEY}"')) + _, end = json.JSONDecoder().raw_decode(text, start) + return text[:start], text[start:end], text[end:] + + +def test_별칭_저장_그_줄만_빈칸이면_칸_없음(copy: Path) -> None: + client = _app() + before = (copy / MACHINE).read_bytes() + _save_alias(client, " 시험별칭A, 시험별칭B ") + after = (copy / MACHINE).read_bytes().decode("utf-8") + row = next(r for r in json.loads(after)["줄"] if r["키"] == KEY) + assert row["별칭"] == "시험별칭A, 시험별칭B" # 글 그대로 · 앞뒤 빈칸만 뗌 + old, new = _around(before.decode("utf-8")), _around(after) + assert old[0] == new[0] and old[2] == new[2] # 그 줄 밖 글 · 서식 그대로 + assert json.loads(new[1]) == {**json.loads(old[1]), "별칭": "시험별칭A, 시험별칭B"} + _save_alias(client, " ") + last = _around((copy / MACHINE).read_bytes().decode("utf-8")) + assert last[0] == old[0] and last[2] == old[2] + assert json.loads(last[1]) == json.loads(old[1]) # 빈 글 = 칸 없음 = 처음 값 + + +def test_M01_길은_시스템_관리자만() -> None: + for role in ("USER", "COMPANY_ADMIN"): + res = _app(role).post("/api/m01/save", json={"files": []}) + assert res.status_code == 403 + main = (ROOT / "main.py").read_text(encoding="utf-8") + assert "include_router(m01_master_data_router, dependencies=system_admin_only)" in main + + +def test_마스터_기계_별칭_다섯_옮김_장부_걷음() -> None: + rows = {r["키"]: r for r in json.loads((REAL / MACHINE).read_text(encoding="utf-8"))["줄"]} + assert {k: r.get("별칭") for k, r in rows.items() if r.get("별칭")} == FIVE + assert not (ROOT / "resources" / "master_template" / "실무자료" / "맞춤.json").exists()