fix(M01): 조합 메모·이름 검사 흠 둘 고침
메모 칸이 글자 아닌 값(수량 등)을 받아도 저장되던 것 · 이름이 글자 아니어도 저장되던 것을 막음(sub2 검증 `ref/_검증_조합.md` 일감 24). 메모는 글자·null만, 이름은 서버 400(글자 아님) + check_combo_form 422(빈 이름·60자 넘음·겹침)로 까닭과 함께 거절. _틀.md 10장 갱신 · 재현 시험 포함 4건 추가(21 통과).
This commit is contained in:
@@ -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 ("키", "원문번호", "소유")}
|
||||
|
||||
@@ -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건.
|
||||
|
||||
@@ -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}」")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user