feat(M02): 서고 사전조합 상세 줄 넣기 — [추가] · 수량 손 입력 · 자기 참조 422(51-6)

- 사전조합 상세 머리 [추가] = 고르기 창 서고 줄만(단가승격 · 사전조합 · 위 층만 · 구분 거름 없음)
- 새 줄 수량 기본 손 1 · 수량 창은 손 입력만(면적산출 시트 없음) · 위로 · 아래로 · 삭제 · [저장]
- 서버: 사전조합이 자기 자신을 담으면 422 「순환 — 자기 자신」 · 단가승격 상세는 한 줄 고정 그대로
- 시험 한 개 · 화면 시나리오 51-6(가짜 응답 · 서고 쓰기 없음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-05 19:42:39 +09:00
co-authored by Claude Opus 5.5
parent 2818b5ff99
commit a1673aeb10
7 changed files with 258 additions and 8 deletions
+2
View File
@@ -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
@@ -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]),
],
@@ -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);
@@ -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
@@ -123,6 +123,7 @@ const TEXT = {
Lib_Unit: ["단위", "Unit"],
Lib_Base: ["기준 금액", "Base amount"],
Lib_Date: ["고친 날", "Modified"],
Lib_AddRow: ["추가", "Add"],
} as const satisfies Record<string, readonly [string, string]>;
export type ComboTextKey = keyof typeof TEXT;
+19
View File
@@ -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
@@ -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;
}
})();