From db519dcbd9d1a30348389c5b26ea3d0afd26701c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 00:54:20 +0900 Subject: [PATCH] =?UTF-8?q?fix(M01):=20=EC=A1=B0=ED=95=A9=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=C2=B7=EC=9D=B4=EB=A6=84=20=EA=B2=80=EC=82=AC=20?= =?UTF-8?q?=ED=9D=A0=20=EB=91=98=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메모 칸이 글자 아닌 값(수량 등)을 받아도 저장되던 것 · 이름이 글자 아니어도 저장되던 것을 막음(sub2 검증 `ref/_검증_조합.md` 일감 24). 메모는 글자·null만, 이름은 서버 400(글자 아님) + check_combo_form 422(빈 이름·60자 넘음·겹침)로 까닭과 함께 거절. _틀.md 10장 갱신 · 재현 시험 포함 4건 추가(21 통과). --- M01_MasterData/M01_MasterData_Store_Combo.py | 7 +++- resources/master_data/_틀.md | 5 ++- resources/master_data/ref/_검증_조합.md | 4 ++ resources/master_data/scripts/check_master.py | 13 +++++- resources/master_data/scripts/master_combo.py | 6 ++- resources/tester/test_m01_combo.py | 42 +++++++++++++++++++ 6 files changed, 70 insertions(+), 7 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Store_Combo.py b/M01_MasterData/M01_MasterData_Store_Combo.py index 52656739..e8ae61bb 100644 --- a/M01_MasterData/M01_MasterData_Store_Combo.py +++ b/M01_MasterData/M01_MasterData_Store_Combo.py @@ -39,8 +39,11 @@ def _read() -> tuple[dict, str]: def _clean(combo: dict, owner: str, key: str | None = None) -> dict: """서버가 잡는 칸 — 키 · 소유. 화면이 보낸 키·소유는 안 씀 · 열 차례는 `_틀.md` 10장.""" - if not isinstance(combo, dict) or not str(combo.get("이름") or "").strip(): - raise store.StoreError(400, "조합 줄에 「이름」 이 없음") + if not isinstance(combo, dict): + raise store.StoreError(400, "조합 줄이 묶음 아님") + name = combo.get("이름") + if not isinstance(name, str) or not name.strip(): + raise store.StoreError(400, "조합 줄에 「이름」 이 없거나 글자 아님") if owner not in OWNERS: raise store.StoreError(400, f"모르는 소유 「{owner}」 — {' · '.join(OWNERS)}") rest = {k: v for k, v in combo.items() if k not in ("키", "원문번호", "소유")} diff --git a/resources/master_data/_틀.md b/resources/master_data/_틀.md index 366c2010..6b1fd884 100644 --- a/resources/master_data/_틀.md +++ b/resources/master_data/_틀.md @@ -286,8 +286,9 @@ - 열 한 벌 — `키` · `이름` · `구분` · `상세구분` · `단위` · `담은로직` · `출처` · `소유` · `비고`(차례까지 이대로 · 안 채운 칸은 `null`). - **담은 로직 목록만 둠** — 수량 · 비율 · 계산 칸을 두지 않음. 로직마다 하는 일이 달라 수량은 로직마다 따로 들어가고, 그 대입과 합계는 프로젝트 몫(B08 · B09). -- `담은로직` 줄 = `{로직, 메모}` 뿐 · 목록 차례가 곧 조합 안 차례. +- `담은로직` 줄 = `{로직, 메모}` 뿐 · 목록 차례가 곧 조합 안 차례. `메모` = 글자 또는 `null` — 수량 같은 값을 넣는 자리가 아님. - `키` = `UA` + 6자리 — 대장 `_키대장.json` 의 `UA` 다음 번호(서버가 줌). 원문이 없어 **줄에 `원문번호` 를 두지 않음**(대장 열쇠는 조합 이름). - `소유` = `현장`(기본) · `공용` · `출처` = 「자체」. -- 검사(`check_master.py 조합` · 엔진 `scripts/master_combo.py`) — 없는 로직 키 · 단가산출 로직이 아닌 키 · 같은 로직 두 번 · 빈 조합 · **조합이 조합을 담음**(1단만). +- `이름` = 글자 · 빈 이름 안 됨 · 60자까지(`master_combo.MAX_이름`) · 같은 파일 안에서 안 겹침. +- 검사(`check_master.py 조합` · 엔진 `scripts/master_combo.py`) — 없는 로직 키 · 단가산출 로직이 아닌 키 · 같은 로직 두 번 · 빈 조합 · **조합이 조합을 담음**(1단만) · 메모가 글자·`null` 아님 · 이름이 글자 아니거나 없음·너무 김·겹침. - 「이 로직을 쓰는 조합」 은 로직 줄에 칸으로 박지 않고 물을 때 훑어 찾음(`master_combo.using`) — 조합은 관리자가 그때그때 만드는 것이라 박아 두면 낡음. 소요량 「용도」 와 결은 같되 자리가 다름. diff --git a/resources/master_data/ref/_검증_조합.md b/resources/master_data/ref/_검증_조합.md index be73126d..3dd54a60 100644 --- a/resources/master_data/ref/_검증_조합.md +++ b/resources/master_data/ref/_검증_조합.md @@ -18,3 +18,7 @@ - 담은 로직이 나중에 사라지면: 조회는 200 + `blocked` true + 까닭 · 미리 보기는 그 줄만 멈추고 나머지 합계는 냄. - ③ 미리 보기 합계: 로직 셋(GF000219 산림 찾기 · GC000996 덧줄 있음 · GC000994 로직 부르기 있음)을 담아 각 로직의 시험 계산과 비목별로 대조 — 줄마다 노무비·재료비·경비가 같고, 합계도 노무·재료·경비 비목별 합과 같음(계 729,595.598). 저장 전 조합 줄로 보내도 같은 합계 · 미리 보기는 저장하지 않음(줄 수 그대로). 입력에 없는 이름을 주면 그 줄만 「입력 … 없음」으로 멈춤. `/logic/combos` 거꾸로 찾기 · 목록의 `count` · 소유·찾기 거름도 맞음. - ④ `check_master`: 조합 검사가 들어 있음(`조합` 모드 · `전부` 모드에 포함 · 틀은 `check_combo_form`) — 정본에서 틀 0 · 로직 0 · 조합 0건. 시험 `test_m01_combo.py` 17 통과. + +## 고친 뒤 (일감 26) + +어긋난 것 둘 다 고침 — 「메모」 는 글자 또는 `null` 만(`master_combo.check_row`) · 「이름」 은 글자만(서버 400) + 빈 이름·60자 넘음·같은 파일 안 겹침을 `check_combo_form` 이 422 로 막음(까닭 글 포함). `_틀.md` 10장에 두 규칙 적음. 재현 두 가지 + 새 규칙 셋 시험 넷을 더해 `test_m01_combo.py` 21 통과 · `check_master 조합` 정본 0건. diff --git a/resources/master_data/scripts/check_master.py b/resources/master_data/scripts/check_master.py index 38fb9805..8745073b 100644 --- a/resources/master_data/scripts/check_master.py +++ b/resources/master_data/scripts/check_master.py @@ -233,6 +233,7 @@ def check_combo_form(name: str, data: dict) -> list[str]: rows_ = data.get("줄") if not isinstance(rows_, list): return out + [f"{name} · 머리 「줄」 목록 없음"] + seen_names: dict[str, str] = {} for row in rows_: key = str(row.get("키")) where = f"{name} · {key} {row.get('이름') or ''}".strip() @@ -242,8 +243,16 @@ def check_combo_form(name: str, data: dict) -> list[str]: out.append(f"{where} · 원문번호는 줄에 두지 않음 — 대장에만") if tuple(row) != mcb.COLS: out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(mcb.COLS)}」") - if not str(row.get("이름") or "").strip(): - out.append(f"{where} · 「이름」 없음") + name_ = row.get("이름") + if not isinstance(name_, str) or not name_.strip(): + out.append(f"{where} · 「이름」 이 글자 아니거나 없음") + else: + plain = name_.strip() + if len(plain) > mcb.MAX_이름: + out.append(f"{where} · 「이름」 이 너무 김({len(plain)}자 — {mcb.MAX_이름}자까지)") + if plain in seen_names: + out.append(f"{where} · 「이름」 이 같은 조합과 겹침 — {seen_names[plain]}") + seen_names.setdefault(plain, key) if row.get("소유") not in mcb.OWNERS: out.append(f"{where} · 소유 「{row.get('소유')}」 — {' · '.join(mcb.OWNERS)} 아님") if not _source_ok(row.get("출처")): diff --git a/resources/master_data/scripts/master_combo.py b/resources/master_data/scripts/master_combo.py index 6438448c..54d6fa2f 100644 --- a/resources/master_data/scripts/master_combo.py +++ b/resources/master_data/scripts/master_combo.py @@ -17,6 +17,7 @@ GROUP = "일위대가조합" FILE = "일위대가조합.json" TID = "UA" OWNERS = ("현장", "공용") +MAX_이름 = 60 # 조합 이름 최대 글자 수 — 관리자가 짓는 짧은 이름이라 이보다 길면 저장 막음 # 줄 열 한 벌 — 차례까지 이대로(`_틀.md` 10장) COLS = ("키", "이름", "구분", "상세구분", "단위", "담은로직", "출처", "소유", "비고") ITEM_COLS = ("로직", "메모") @@ -42,7 +43,7 @@ def logic_keys(row: dict) -> list[str]: # ── 검사 ────────────────────────────────────────────────────────────── def check_row(whole: mf.Master, row: dict) -> list[str]: - """조합 한 줄 — 빈 조합 · 없는 로직 키 · 같은 로직 두 번 · 조합을 담음(1단만).""" + """조합 한 줄 — 빈 조합 · 없는 로직 키 · 같은 로직 두 번 · 조합을 담음(1단만) · 메모 모양.""" where = f"{row.get('키')} {row.get('이름') or ''}".strip() got = items(row) out = [] if got else [f"{where} · 빈 조합 — 담은 로직이 없음"] @@ -56,6 +57,9 @@ def check_row(whole: mf.Master, row: dict) -> list[str]: extra = set(one) - set(ITEM_COLS) if extra: out.append(f"{where} 줄{at} · 모르는 칸 {sorted(extra)}") + memo = one.get("메모") + if memo is not None and not isinstance(memo, str): + out.append(f"{where} 줄{at} · 「메모」 가 글자 아님 「{memo}」 — 수량·계산은 두지 않음") key = str(one.get("로직") or "") if not mk.KEY.fullmatch(key): out.append(f"{where} 줄{at} · 로직 키 모양 아님 「{key}」") diff --git a/resources/tester/test_m01_combo.py b/resources/tester/test_m01_combo.py index 27f3568f..dbca8ef9 100644 --- a/resources/tester/test_m01_combo.py +++ b/resources/tester/test_m01_combo.py @@ -217,6 +217,48 @@ def test_이름_없는_조합과_모르는_소유는_400(client: TestClient) -> ) +def test_이름이_글자_아니면_400(client: TestClient) -> None: + """재현: 이름 ["x"] 가 200 으로 저장됨(일감 24 어긋난 것 2) — 이제 400.""" + _post( + client, + "/api/m01/combo/new", + {"combo": {"이름": ["x"], "담은로직": [{"로직": STACK}]}}, + want=400, + ) + + +def test_이름이_너무_길면_막는다(client: TestClient) -> None: + got = _post( + client, + "/api/m01/combo/new", + {"combo": {"이름": "가" * 61, "담은로직": [{"로직": STACK}]}}, + want=422, + ) + assert any("너무 김" in x for x in got["detail"]["errors"]), got + + +def test_같은_이름_두_번_담으면_막는다(client: TestClient) -> None: + _make(client, keys=(STACK,), 이름="돌쌓기 한 벌") + got = _post( + client, + "/api/m01/combo/new", + {"combo": {"이름": "돌쌓기 한 벌", "담은로직": [{"로직": LAYER}]}}, + want=422, + ) + assert any("겹침" in x for x in got["detail"]["errors"]), got + + +def test_메모가_글자_아니면_막는다(client: TestClient) -> None: + """재현: 메모 {"수량": 3} 이 200 으로 저장됨(일감 24 어긋난 것 1) — 이제 422.""" + got = _post( + client, + "/api/m01/combo/new", + {"combo": {"이름": "메모 흠", "담은로직": [{"로직": STACK, "메모": {"수량": 3}}]}}, + want=422, + ) + assert any("메모」 가 글자 아님" in x for x in got["detail"]["errors"]), got + + # ── 미리 보기 ───────────────────────────────────────────────────────── def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별로_같다(client: TestClient) -> None: key = _make(client)["key"]