diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 6e3c9d0e0..12ecac51d 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -183,6 +183,8 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: ) me = {"층": at_[0], "주인": at_[1], "키": key} for one in lines: + if ref_id(one["참조"]) == ref_id(me): + raise _bad(f"순환 — 자기 자신을 담음 「{key}」", 422) _no_loop(one["참조"], [me, one["참조"]]) return lines diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 53c8f16de..aec4101b0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -9,7 +9,7 @@ * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 * · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문 * · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) · - * 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) + * 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) · 사전조합 = [추가] · 수량 손 입력(기본 1) · 순서 · 삭제 * ========================================================================== */ import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; @@ -380,6 +380,7 @@ export function mountLaborDetail( readOnly: !edit, sheet: { doc: area?.doc ?? null, layer: opt.layer, column: opt.column, pill }, goArea: opt.goArea, + ...(lib ? { handOnly: true } : {}), done: (r) => { if (r.kind === "keep") return; if (r.kind === "hand") target.수량 = { 손: r.손 }; @@ -482,10 +483,17 @@ export function mountLaborDetail( const id = lineId({ 참조: ref }); if (cur.담은로직.some((r) => lineId(r) === id)) return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error"); - cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량: null }); + const 수량 = lib === "precombo" ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6) + cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량 }); changed(); }, }; + /** 사전조합 [추가] — 서고 줄만 고르기(위 층만 · 자기 자신 · 순환은 [저장] 때 서버 422) */ + const addRef = createButton({ + label: tc("Lib_AddRow"), + variant: "filled", + onClick: () => openLogicPicker(() => undefined, { ...library, only: true }), + }); const addLogic = createButton({ label: tc("AddLogic"), variant: "filled", @@ -580,8 +588,12 @@ export function mountLaborDetail( ], }), ]), - // 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 고르기는 고르기 창 탭(50-3 ④) - section(tc("Logics_Title"), opt.card || lib ? null : addLogic, logicHost), + // 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 = [추가] 서고 줄만 고르기(51-6) + section( + tc("Logics_Title"), + opt.card || promote ? null : lib ? addRef : addLogic, + logicHost, + ), ...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]), ...(opt.card || lib ? [] : [dgBox]), ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index c8cac3e90..5477edeb0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -37,6 +37,8 @@ export interface LibraryPick { layer: Layer; owner: string | null; pick: (ref: LaborRef, name: string) => void; + /** 서고 줄만(사전조합 상세 [추가] · 51-6) — 단가산출 로직 · 구분 거름 없음 */ + only?: boolean; } /** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ @@ -193,7 +195,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra const filters = el("div", { className: "m01-logic__filters", - children: [sub.root, detail.root, searchField.root], + children: [...(lib?.only ? [] : [sub.root, detail.root]), searchField.root], }); layerBar.style.display = "none"; if (lib) @@ -229,6 +231,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra document.body.append(backdrop); searchField.input.focus(); + if (lib?.only) return; void Promise.all([fetchLogicSubs(), fetchLogics()]) .then(([s, l]) => { subs.push(...s); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_QtyModal.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_QtyModal.ts index 3e466130f..8bd58dc80 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_QtyModal.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_QtyModal.ts @@ -42,6 +42,8 @@ export interface QtyModalOptions { sheet: { doc: Workbook | null; layer: Layer; column: string; pill: string | null }; /** [면적산출 상세로] — 없으면 단추 없음 */ goArea: GoArea | null; + /** 손 입력만(서고 사전조합 줄 · 51-6) — 면적산출 시트 없음 */ + handOnly?: boolean; done: (result: QtyResult) => void; } @@ -104,7 +106,9 @@ export function openQtyModal(opt: QtyModalOptions): void { className: "m02-cellpick__sheet", attrs: { "data-labor-qty-sheet": "" }, }); - if (opt.sheet.pill) { + if (opt.handOnly) { + // 시트 없음 + } else if (opt.sheet.pill) { sheet = mountAreaSheet(host, { doc: opt.sheet.doc, layer: opt.sheet.layer, @@ -153,8 +157,9 @@ export function openQtyModal(opt: QtyModalOptions): void { ], }), state, - el("p", { className: "m02-cellpick__hint", text: tc("Labor_SheetHint") }), - host, + ...(opt.handOnly + ? [] + : [el("p", { className: "m02-cellpick__hint", text: tc("Labor_SheetHint") }), host]), el("div", { className: "m02-labin__foot", children: opt.readOnly diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 3f47ac612..21272f3c5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -123,6 +123,7 @@ const TEXT = { Lib_Unit: ["단위", "Unit"], Lib_Base: ["기준 금액", "Base amount"], Lib_Date: ["고친 날", "Modified"], + Lib_AddRow: ["추가", "Add"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index d3b75fe12..913a9877c 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -338,3 +338,22 @@ def test_층_목록_서고_줄은_이름_단위_기준금액을_실음(world): assert rows[0]["기준금액"] == pytest.approx(one) assert (rows[1]["제목"], rows[1]["기준금액"]) == ("셋", pytest.approx(one * 3)) assert "기준금액" not in rows[2] + + +def test_사전조합이_자기_자신을_담으면_422_수량_기본_1(world): + sp = _promote(world["system"]) + a = _combo(world["system"], [{"참조": {**SYS, "키": sp}}], "가") + with store.at(world["system"]): + have = store.read("precombo", a) + assert have["문서"]["담은로직"][0]["수량"] == {"손": 1} + with pytest.raises(store.StoreError) as e: + store.write( + "precombo", + a, + have["판"], + { + **have["문서"], + "담은로직": [*have["문서"]["담은로직"], {"참조": {**SYS, "키": a}}], + }, + ) + assert e.value.status == 422 and "자기 자신" in e.value.detail diff --git a/resources/tester/ui_checks/51-6_사전조합_줄넣기.js b/resources/tester/ui_checks/51-6_사전조합_줄넣기.js new file mode 100644 index 000000000..213b08cc8 --- /dev/null +++ b/resources/tester/ui_checks/51-6_사전조합_줄넣기.js @@ -0,0 +1,208 @@ +/* 51-6 — 서고 사전조합 상세(일위대가 상세 서고 모드) 줄 넣는 길 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=일위대가조합&root=master) · 시스템 관리자 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 쓰기 없음 — 서고 목록 · 문서 읽기 · [저장] PUT 은 가짜 응답(fetch 바꿔 끼움 · 끝에 되돌림) · calc 만 서버 + * 기대 수치: + * - promote = { add: false, rows: 1, move: 1 }(단가승격 상세는 한 줄 고정 · [옵션]만 · 위로 · 아래로 · 삭제 없음) + * - precombo.add = true · picker = { filters: 0, kinds: ["단가승격", "단가승격", "일위조합"], layers: 1 } + * - added = 2 · again = 「이미 담은」 알림 + * - moved = ["SP-999902", "SP-999901"](위로) · qty = { start: "1"(새 줄 기본), sheet: false, after: { 손: 3 } } · addedQty = { 손: 3 } + * - removed = 1 · selfSave = 「순환」 알림(PUT 422) · putBody 에 자기 키 · left = 버림 확인 뒤 목록 + */ +(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 4000) => { + for (let t = 0; t < ms; t += 100) { + const v = fn(); + if (v) return v; + await wait(100); + } + return null; + }; + const SYS = { 층: "system", 주인: null }; + const rows = [ + { + 종류: "promote", + 이름: "SP-999901", + 판: "a", + 수정일: "2026-10-05T10:00:00", + 제목: "시험 승격", + 단위: "㎡", + 기준금액: 100, + }, + { + 종류: "promote", + 이름: "SP-999902", + 판: "b", + 수정일: "2026-10-05T11:00:00", + 제목: "시험 승격 둘", + 단위: "㎡", + 기준금액: 200, + }, + { + 종류: "precombo", + 이름: "SC-999901", + 판: "c", + 수정일: "2026-10-05T12:00:00", + 제목: "시험 묶음", + 단위: "개소", + 기준금액: 100, + }, + ]; + const docs = { + "promote/SP-999901": { + 종류: "단가승격", + 키: "SP-999901", + 이름: "시험 승격", + 단위: "㎡", + 담은로직: [{ 로직: "GC000421", 입력값: {} }], + }, + "precombo/SC-999901": { + 종류: "사전조합", + 키: "SC-999901", + 이름: "시험 묶음", + 단위: "개소", + 담은로직: [{ 참조: { ...SYS, 키: "SP-999901" }, 수량: { 손: 1 } }], + }, + }; + const json = (body, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + const real = window.fetch; + let putBody = null; + window.fetch = async (input, init = {}) => { + const url = String(input?.url ?? input); + const method = (init.method ?? "GET").toUpperCase(); + if (method === "GET" && /\/m02\/layers\/system\/templates(\?|$)/.test(url)) { + const got = await (await real(input, init)).json(); + return json({ ...got, 양식: [...got.양식.filter((r) => !/^S[PC]-/.test(r.이름)), ...rows] }); + } + const hit = Object.keys(docs).find((k) => url.includes(`/templates/${k}`)); + if (hit && method === "GET") + return json({ 종류: hit.split("/")[0], 이름: docs[hit].키, 판: "a", 문서: docs[hit] }); + if (hit && method === "PUT") { + putBody = JSON.parse(init.body); + const self = JSON.stringify(putBody).includes('"키":"SC-999901"}'); + return self + ? json({ detail: "순환 — 자기 자신을 담음 「SC-999901」" }, 422) + : json({ 이름: "SC-999901", 판: "z" }); + } + if (method !== "GET" && !url.includes("/calc")) + return json({ detail: "시험 — 쓰기 막음" }, 400); + return real(input, init); + }; + const sec = [...document.querySelectorAll(".m02-side__lists > section")].find( + (s) => s.querySelector(".ui-collapsible__title")?.textContent?.trim() === "일위대가 조합", + ); + const items = [...sec.querySelectorAll("[data-precombo]")]; + const btn = (root, text) => + [...root.querySelectorAll("button")].find((b) => b.textContent === text); + const edit = () => document.querySelector(".m02-main__host [data-labor]"); + const lines = () => [...(edit()?.querySelectorAll("[data-labor-logic]") ?? [])]; + const keys = () => lines().map((tr) => tr.querySelector(".m02-labrow__key")?.textContent); + const toast = (re) => + until(() => + [...document.querySelectorAll(".ui-toast")] + .map((x) => x.textContent) + .find((t) => re.test(t ?? "")), + ); + const openDoc = async (i, key) => { + items[i].click(); + const row = await until(() => + document.querySelector(`[data-lib-key="${key}"] td:nth-child(3)`), + ); + row.click(); + await until(() => lines().length); + await wait(300); + }; + const pickerRow = async (key) => { + btn(edit(), "추가").click(); + const row = await until(() => document.querySelector(`.m01-logic__pick [data-pick="${key}"]`)); + return row; + }; + try { + // 단가승격 상세 — 한 줄 고정 + await openDoc(0, "SP-999901"); + const promote = { + add: !!btn(edit(), "추가"), + rows: lines().length, + move: edit().querySelectorAll(".m02-labrow__move button").length, + }; + // 사전조합 상세 + await openDoc(1, "SC-999901"); + const precombo = { add: !!btn(edit(), "추가"), rows: lines().length }; + const first = await pickerRow("SP-999902"); + const pick = document.querySelector(".m01-logic__pick"); + const picker = { + filters: pick.querySelectorAll(".m01-logic__filters select").length, + kinds: [...pick.querySelectorAll("[data-pick-kind]")].map((x) => x.textContent), + layers: pick.querySelectorAll("[data-pick-layers] button").length, + }; + first.click(); + await until(() => lines().length === 2); + await wait(300); + const added = lines().length; + // 같은 참조 두 번 — 막힘 + (await pickerRow("SP-999901")).click(); + const again = await toast(/이미 담은/); + // 위로 — 둘째 줄을 첫째로 + btn(lines()[1], "위로").click(); + await wait(300); + const moved = keys(); + // 수량 손 입력 — 시트 없음 · 3 + lines()[0].querySelector("[data-labor-qty-open]").click(); + const hand = await until(() => document.querySelector('.m02-labqty [data-labor-qty="hand"]')); + const qty = { + start: hand?.value, + sheet: !!document.querySelector(".m02-labqty [data-labor-qty-sheet]"), + }; + hand.value = "3"; + hand.dispatchEvent(new Event("input")); + btn(document.querySelector(".m02-labqty"), "확인").click(); + await wait(400); + // 저장 본문으로 수량 확인(가짜 PUT) + const save = [...document.querySelectorAll(".m02-main__head button")].find( + (b) => b.textContent === "저장", + ); + save.click(); + await until(() => putBody); + const sent = putBody.문서.담은로직; + const addedQty = sent.find((x) => x.참조.키 === "SP-999902")?.수량 ?? null; + qty.after = sent[0]?.수량 ?? null; + // 삭제 — 줄 하나 뺌 + btn(lines()[1], "삭제").click(); + await wait(300); + const removed = lines().length; + // 자기 자신 담고 [저장] → 서버 422 알림 + putBody = null; + (await pickerRow("SC-999901")).click(); + await until(() => lines().length === 2); + save.click(); + const selfSave = await toast(/순환/); + const selfSent = JSON.stringify(putBody ?? {}).includes("SC-999901"); + // 떠남 — 버림 확인 → 목록 + items[1].click(); + const ok = await until(() => + document.querySelector( + ".ui-confirm__actions .ui-button--danger, .ui-confirm__actions button:last-child", + ), + ); + ok?.click(); + const left = !!(await until(() => document.querySelector('[data-lib-list="precombo"]'))); + return JSON.stringify({ + promote, + precombo, + picker, + added, + addedQty, + again, + moved, + qty, + removed, + selfSave, + selfSent, + left, + }); + } finally { + window.fetch = real; + } +})();