fix(M02): 조합 갈라 내기 같은 내용 판정에 차례 · 메모 포함 · 서버 거절 까닭 그대로 알림

- 일위대가 조합: 담은 로직 차례 · 메모까지 같아야 있는 키(원래 키 먼저) — 차례 · 메모만 바꿔도 새 조합
- 자재 조합: 담은 자재 차례까지 비교
- M02 Api_Fetch: detail 문장 · errors 목록 · 입력 검사 목록을 알림 글로(HTTP 422 대신)
- 새 조합 자동 이름: 원래 이름 끝 「 (n)」 뗌(겹친 (2) (2) 막음)
- test_m02_combo_branch.py 새 규칙으로 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
This commit is contained in:
2026-09-28 22:24:08 +09:00
co-authored by Claude Opus 5.5
parent de7032bc3b
commit e4e2dc2aac
4 changed files with 60 additions and 23 deletions
@@ -38,15 +38,21 @@ async function call<T>(path: string, init: RequestInit = {}): Promise<T> {
...init,
});
const body = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (response.status === 409)
throw new StaleError(typeof body.detail === "string" ? body.detail : "409");
if (!response.ok) {
const detail = body.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
if (response.status === 409) throw new StaleError(reason(body.detail, response.status));
if (!response.ok) throw new Error(reason(body.detail, response.status));
return body;
}
/** 서버가 막은 까닭 그대로 — 문장 · `{errors: [...]}` · 입력 검사 목록(`[{msg}]`) */
function reason(detail: unknown, status: number): string {
if (typeof detail === "string") return detail;
const list = Array.isArray(detail) ? detail : (detail as { errors?: unknown } | null)?.errors;
const lines = Array.isArray(list)
? list.map((x) => (typeof x === "string" ? x : String((x as { msg?: unknown })?.msg ?? "")))
: [];
return lines.filter(Boolean).join(" · ") || `HTTP ${status}`;
}
const enc = encodeURIComponent;
const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : "");
const head = (layer: Layer): string =>
@@ -3,8 +3,8 @@
· 일위대가 조합(UA · `일위대가조합.json`) — 새 줄은 M01 조합 저장소가 만듦(부르기만)
· 자재 조합(UM · `자재조합.json`) — M01 마스터 저장(`save`)으로 더함(키 = 대장 다음 번호)
조합은 여러 열 · 모든 프로젝트가 같이 씀 — 제자리 고치지 않음. 담은 것의 집합(차례 · 메모 무시)이
이미 있는 조합과 같으면 새로 안 만들고 그 키 · 새 줄의 본뜬키 = 원래 키 · 소유 현장.
조합은 여러 열 · 모든 프로젝트가 같이 씀 — 제자리 고치지 않음. 담은 것(차례 · 메모까지)이
이미 있는 조합과 같으면 새로 안 만들고 그 키(원래 키 먼저) · 새 줄의 본뜬키 = 원래 키 · 소유 현장.
"""
from __future__ import annotations
@@ -41,14 +41,14 @@ def _branch(
field: str,
file: str,
slot: str,
ident: str,
ident: tuple[str, ...],
items: list[dict],
source: str,
make: Callable[[str, list[dict]], dict],
name: str,
) -> dict[str, Any]:
"""집계표 판 검사 → 같은 내용 찾기 · 없으면 `make(이름, 줄들)` 로 새 줄 → 집계표 그 열 `field`.
`slot` = 담은 칸(담은로직 · 담은자재) · `ident` = 담은 줄의 알맹이 칸(로직 · 코드)."""
`slot` = 담은 칸(담은로직 · 담은자재) · `ident` = 담은 줄에서 견주는 칸(로직 · 메모 / 코드)."""
table = store.read("table", store.STRUCTURE_TABLE)
if version and table["판"] != version:
raise store.StoreError(409, {"stale": [store.STRUCTURE_TABLE], "판": table["판"]})
@@ -58,8 +58,10 @@ def _branch(
if not isinstance(items, list) or not items:
raise store.StoreError(400, "담은 것이 비었음")
def body(got) -> frozenset[str]:
return frozenset(str(x.get(ident) or "") for x in got or [] if isinstance(x, dict))
def body(got) -> tuple: # 차례대로 · 빈 메모 = null
return tuple(
tuple(str(x.get(k) or "") for k in ident) for x in got or [] if isinstance(x, dict)
)
rows = _m01(m01.read, file)[0].get("줄") or []
same = [r for r in rows if body(r.get(slot)) == body(items)]
@@ -102,7 +104,16 @@ def branch_combo(
name = name.strip() or str(was.get("이름") or source)
return _branch(
column, version, "일위대가", combo_store.FILE, "담은로직", "로직", items, source, make, name
column,
version,
"일위대가",
combo_store.FILE,
"담은로직",
("로직", "메모"),
items,
source,
make,
name,
)
@@ -147,5 +158,5 @@ def branch_material_combo(
default = " ".join(h for h in store._heads(heads) if h) if heads else column
name = name.strip() or str(was.get("이름") or default or column)
return _branch(
column, version, "자재조합", MATERIAL_FILE, "담은자재", "코드", items, source, make, name
column, version, "자재조합", MATERIAL_FILE, "담은자재", ("코드",), items, source, make, name
)
@@ -116,8 +116,13 @@ export function askBranch(message: string, defaultName: string): Promise<string
});
}
/** 새 조합 이름 자동 — 원래 이름 · 구조물 이름(한쪽이 다른 쪽을 이미 품으면 긴 쪽 하나만) */
export const branchName = (original: string, label: string): string =>
/** 새 조합 이름 자동 — 원래 이름(끝 「 (n)」 뗌 · 서버가 새로 붙임) · 구조물 이름(한쪽이 다른 쪽을
* 이미 품으면 긴 쪽 하나만) */
export const branchName = (
named: string,
label: string,
original = named.replace(/ \(\d+\)$/, ""),
): string =>
!original || label.includes(original)
? label || original
: !label || original.includes(label)
+22 -7
View File
@@ -2,7 +2,8 @@
M02 견본 표(`write_sample_table`) + M01 마스터 사본(조합 줄은 비워 시작)으로만 봄.
· 일위대가(UA) — 새로 만듦(본뜬키 = 원래 키) · 이 열만 새 키 · 다른 열은 옛 키 · 원래 조합 그대로 ·
SS 장부 맞춤 · 담은 로직 집합(차례 · 메모 무시)이 같으면 있는 키 · 또 고치면 또 새로 · 같은 이름 (2)
SS 장부 맞춤 · 담은 로직(차례 · 메모까지)이 같으면 있는 키(되돌리면 원래 키) · 차례 · 메모만
바꿔도 새로 · 또 고치면 또 새로 · 같은 이름 (2)
· 자재 조합(UM) — 같은 규칙 · 처음 만드는 열(본뜬키 없음) · 읽기
· 집계표 판 어긋남 = 409 · 조합 안 만듦
· 분류 더하기로 복사한 열(`본뜬열`) — [저장] 때 원본 키 · 세 파일 내용을 새 번호로 받음
@@ -75,7 +76,9 @@ def _source(client) -> str:
def _branch(client, column, source, keys, name="", 판="", want=200):
body = {"본뜬키": source, "담은로직": [{"로직": k} for k in keys], "이름": name, "판": 판}
"""`keys` = 로직 키 또는 (로직, 메모)"""
lines = [{"로직": k[0], "메모": k[1]} if isinstance(k, tuple) else {"로직": k} for k in keys]
body = {"본뜬키": source, "담은로직": lines, "이름": name, "판": 판}
res = client.post(f"/api/m02/structures/{column}/combo", json=body)
assert res.status_code == want, res.text
return res.json()
@@ -119,17 +122,27 @@ def test_새로_만듦_이_열만_새_키_원래_조합_그대로(client):
assert combo_store.combo(got["키"])["combo"]["본뜬키"] == ua
def test_같은_로직_집합이면_있는_키_또_고치면_또_새로_같은_이름은_2(client):
def test_차례_메모까지_같으면_있는_키_또_고치면_또_새로_같은_이름은_2(client):
ua = _source(client)
same = _branch(client, "c1", ua, [B, A]) # 차례만 바꿈 = 원래 조합
same = _branch(client, "c1", ua, [(A, ""), (B, None)]) # 빈 메모 = null → 원래 조합
assert (same["키"], same["새로만듦"]) == (ua, False)
order = _branch(client, "c1", ua, [B, A]) # 차례만 바꿈 = 새로
assert order["새로만듦"] is True and order["키"] != ua
got = combo_store.combo(order["키"])["combo"]
assert ([x["로직"] for x in got["담은로직"]], got["본뜬키"]) == ([B, A], ua)
memo = _branch(client, "c1", order["키"], [B, (A, "사면 쪽")]) # 메모만 바꿈 = 또 새로
assert memo["새로만듦"] is True and memo["키"] not in (ua, order["키"])
assert combo_store.combo(memo["키"])["combo"]["담은로직"][1]["메모"] == "사면 쪽"
back = _branch(client, "c1", memo["키"], [A, B]) # 원래와 똑같이 되돌림 = 원래 키
assert (back["키"], back["새로만듦"]) == (ua, False)
assert _cols(client)["c1"] == ua and _ss(client)["c1"] == ua
first = _branch(client, "rv", ua, [A, B, C], "갈래")
again = _branch(client, "rv", first["키"], [A, C], "갈래") # 갈라 낸 것을 또 고침 = 또 새로
assert again["새로만듦"] is True and again["키"] not in (ua, first["키"])
assert again["이름"] == "갈래 (2)"
assert combo_store.combo(again["키"])["combo"]["본뜬키"] == first["키"]
back = _branch(client, "c1", ua, [C, A, B]) # 다른 열이 같게 고침 = 있는 키
assert (back["키"], back["새로만듦"]) == (first["키"], False)
twin = _branch(client, "c1", ua, [A, B, C]) # 다른 열이 똑같이 고침 = 있는 키
assert (twin["키"], twin["새로만듦"]) == (first["키"], False)
assert _cols(client)["c1"] == first["키"] and _ss(client)["c1"] == first["키"]
@@ -157,8 +170,10 @@ def test_자재_조합_처음_만듦_같은_내용이면_있는_키_고치면_
row = client.get(f"/api/m02/material-combos/{first['키']}").json()
assert tuple(row) == ("키", "이름", "담은자재", "본뜬키", "소유", "비고")
assert (row["본뜬키"], row["소유"], row["담은자재"]) == (None, "현장", [M1, M2])
same = _material(client, "c1", "", [M2, M1]) # 차례만 다름 = 있는 키
same = _material(client, "c1", "", [M1, M2]) # 같은 차례 = 있는 키
assert (same["키"], same["새로만듦"]) == (first["키"], False)
order = _material(client, "c1", first["키"], [M2, M1], "모래 먼저") # 차례만 다름 = 새로
assert order["새로만듦"] is True and order["키"] != first["키"]
fork = _material(client, "c1", first["키"], [M1], "시멘트만")
assert fork["새로만듦"] is True and fork["키"] != first["키"]
assert client.get(f"/api/m02/material-combos/{fork['키']}").json()["본뜬키"] == first["키"]