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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user