fix(M01): 조합 메모·이름 검사 흠 둘 고침

메모 칸이 글자 아닌 값(수량 등)을 받아도 저장되던 것 · 이름이 글자 아니어도
저장되던 것을 막음(sub2 검증 `ref/_검증_조합.md` 일감 24). 메모는 글자·null만,
이름은 서버 400(글자 아님) + check_combo_form 422(빈 이름·60자 넘음·겹침)로
까닭과 함께 거절. _틀.md 10장 갱신 · 재현 시험 포함 4건 추가(21 통과).
This commit is contained in:
2026-09-22 00:54:20 +09:00
parent 2e14beec8a
commit db519dcbd9
6 changed files with 70 additions and 7 deletions
+5 -2
View File
@@ -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 ("", "원문번호", "소유")}
+3 -2
View File
@@ -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`) — 조합은 관리자가 그때그때 만드는 것이라 박아 두면 낡음. 소요량 「용도」 와 결은 같되 자리가 다름.
@@ -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건.
+11 -2
View File
@@ -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("출처")):
@@ -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}")
+42
View File
@@ -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"]