fix(M02): 51-13 일위조합 줄 하위 행 — 세부 항목 한 층(참조 줄 · 로직 줄) 노무비 · 재료비 · 경비 = 안 줄 기준 × 안 줄 수량

- 하위 행 이름이 참조 키만 읽어 로직 안 줄(51-11)에서 표가 깨짐 → 참조 키 없으면 로직 키
- calc 답 「안」 타입에 로직 줄 자리 추가
- 확인 시나리오 resources/tester/ui_checks/51-13_일위조합_하위행.js (calc 답 바꿔 끼움 · 서고 저장 없음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
2026-10-05 20:55:11 +09:00
co-authored by Claude Opus 5.5
parent c7a84a0ed9
commit 86d4f3e882
3 changed files with 148 additions and 9 deletions
@@ -36,11 +36,13 @@ export interface CalcLine {
결과?: unknown;
/** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */
줄?: LogicLine[];
/** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④) */
/** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④ ·
* 참조 줄 = `참조` · 로직 줄(51-11) = `로직`) */
이름?: string | null;
원로직?: string | null;
안?: {
참조: LaborRef;
참조?: LaborRef;
로직?: string;
수량: number;
이름?: string | null;
단위?: string | null;
@@ -229,17 +229,22 @@ export interface LogicTableData {
}
/** 고른 줄 하위 행(51-2 · 같은 표 · 같은 칸) — 단가산출 · 단가승격 = 그 로직 재료 · 노무 · 경비 줄 ·
* 사전조합 = 안 줄 · 금액 칸 = 위 기준(위 줄 기준과 같은 바탕) / 아래 반영(× 위 줄 수량 ÷ 배) ·
* 일위조합 = 세부 항목 한 층(51-13 · 참조 줄 · 로직 줄 · 안을 다시 펼치지 않음 · 금액 = 안 줄 기준 × 안 줄 수량) ·
* 금액 칸 = 위 기준(위 줄 기준과 같은 바탕) / 아래 반영(× 위 줄 수량 ÷ 배) ·
* 하위 합 = 위 줄 금액 · 줄이 없으면 빈 한 줄(「…」 · 까닭 · 「—」) */
function subRows(line: LaborCalcLine | undefined, wait: boolean, tools: boolean): HTMLElement[] {
const times = line?.수량 === null || line?.수량 === undefined ? null : line.수량 / (line.배 || 1);
const items = line?.안
? line.안.map((x) => ({
name: `${x.참조.키} ${x.이름 ?? ""}${x.까닭 ? ` · ${x.까닭}` : ""}`.trim(),
qty: x.수량,
unit: x.단위 ?? "",
cost: x.기준 ? Object.fromEntries(COSTS.map((c) => [c, x.기준![c] * x.수량])) : null,
}))
? line.안.map((x) => {
const key = x.참조?.키 ?? x.로직 ?? "";
const label = x.이름 && x.이름 !== key ? `${key} ${x.이름}` : key; // 로직 줄 이름 없으면 키만
return {
name: `${label}${x.까닭 ? ` · ${x.까닭}` : ""}`,
qty: x.수량,
unit: x.단위 ?? "",
cost: x.기준 ? Object.fromEntries(COSTS.map((c) => [c, x.기준![c] * x.수량])) : null,
};
})
: (line?.줄 ?? []).map((x) => ({
name: x.출처 ? `${x.이름} (${x.출처.replace(/[A-Z]{2}\d{6}\./g, "")})` : x.이름,
qty: x.수량,
@@ -0,0 +1,132 @@
/* 51-13 — 일위대가 표 구분 「일위조합」 줄 누름 = 하위 행 = 그 조합 세부 항목 한 층(참조 줄 · 로직 줄 51-11) — 카드 · 상세 두 곳
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 ·
* 나무에 item3(단가승격 SP-000001 · 일위조합 SC-000001 줄이 있음)
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 쓰기 없음 — item3 calc 답만 바꿔 끼움(fetch · 끝에 되돌림): SC 줄 「안」 = [SP-000001 × 2 · 첫 단가산출 로직 줄 × 1.5]
* (안 줄 기준 = 같은 답의 그 줄 기준 · SC 기준 = 안 기준 × 수량 합 · SC 수량 손 3 · 반영 = 기준 × 3 · 서고 저장 없음)
* 기대 수치(카드 · 상세 같음):
* - sc.subs = sc.inner(2) · sc.cells = [head] · sc.next = true
* - sc.base ≈ sc.subBase(위 줄 기준 = 하위 합 · 반올림 차 ≤ 2) · sc.applied ≈ sc.subApplied ≈ sc.base × 3
* - sc.names[0] 「SP-000001 …」 · sc.names[1] 「<로직 키>」(로직 줄 · 깨지지 않음)
* - sc.qty = ["2", "1.5"](안 줄 수량) · sp.subs ≥ 1(단가승격 하위 행 그대로)
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 8000) => {
for (let t = 0; t < ms; t += 150) {
const v = fn();
if (v) return v;
await w(150);
}
return null;
};
const SC = "참조|system||SC-000001";
const SP = "참조|system||SP-000001";
const KEYS = ["노무비", "재료비", "경비", "계"];
const real = window.fetch;
let inner = 0;
window.fetch = async (input, init) => {
const res = await real(input, init);
const url = typeof input === "string" ? input : input.url;
if (!/\/labor\/item3\/calc/.test(url) || !res.ok) return res;
const j = await res.clone().json();
const sc = j.줄.find((l) => l.참조?.키 === "SC-000001");
const sp = j.줄.find((l) => l.참조?.키 === "SP-000001");
const lg = j.줄.find((l) => !l.참조 && l.기준);
if (sc && sp?.기준 && lg) {
sc.안 = [
{ 참조: sp.참조, 수량: 2, 이름: sp.이름, 단위: sp.분모, 기준: sp.기준, 까닭: null },
{ 로직: lg.로직, 수량: 1.5, 이름: lg.로직, 단위: null, 기준: lg.기준, 까닭: null },
];
sc.기준 = Object.fromEntries(KEYS.map((k) => [k, sp.기준[k] * 2 + lg.기준[k] * 1.5]));
Object.assign(sc, { 수량: 3, 수량출처: "손" }); // 위 줄 수량 3 — 반영 = 기준 × 3
sc.반영 = Object.fromEntries(KEYS.map((k) => [k, sc.기준[k] * 3]));
inner = sc.안.length;
}
return new Response(JSON.stringify(j), { status: 200, headers: res.headers });
};
const n = (s) => Number(String(s ?? "").replace(/[^0-9.-]/g, "")) || 0;
const total = (tr, part) => {
const tds = tr.querySelectorAll(".m02-labrow__money");
return n(tds[tds.length - 1].querySelector(part).innerText);
};
const settled = (get) =>
until(() => {
const t = get();
return t && !/…/.test(t.querySelector("tbody").innerText) ? t : null;
}, 10000);
const open = async (get, key) => {
get().querySelector(`tr[data-labor-logic="${key}"] .m02-labrow__name`).click();
await w(300);
const tb = get();
const tr = tb.querySelector(`tr[data-labor-logic="${key}"]`);
const subs = [...tb.querySelectorAll("tbody tr[data-labor-sub]")];
let at = tr.nextElementSibling;
if (at?.hasAttribute("data-labor-opt")) at = at.nextElementSibling;
return { tb, tr, subs, next: at === subs[0] };
};
const measure = async (get) => {
const t = await settled(get);
const head = t.querySelectorAll("thead th").length;
const sp = await open(get, SP);
const { tr, subs, next } = await open(get, SC);
const out = {
head,
sp: { subs: sp.subs.length },
sc: {
inner,
subs: subs.length,
next,
cells: [
...new Set(subs.map((s) => [...s.children].reduce((a, c) => a + (c.colSpan || 1), 0))),
],
base: total(tr, ".m02-labrow__base"),
subBase: Math.round(subs.reduce((a, s) => a + total(s, ".m02-labrow__base"), 0)),
applied: total(tr, ".m02-labrow__applied"),
subApplied: Math.round(subs.reduce((a, s) => a + total(s, ".m02-labrow__applied"), 0)),
names: subs.map((s) => s.querySelector(".m02-labrow__subname").innerText),
qty: subs.map((s) => s.querySelector(".m02-labrow__qty").innerText),
units: subs.map((s) => s.querySelector(".m02-labrow__unit").innerText),
},
};
await open(get, SC); // 같은 줄 다시 = 닫힘
return out;
};
try {
const node = await until(() => document.querySelector('[data-node="i|item3"]'));
if (!node) return JSON.stringify({ error: "item3 없음 — 폴더를 펼쳐 둘 것" });
document.querySelector('[data-node="i|item1"]')?.click(); // 다시 읽게 다른 항목 거침
await w(1500);
node.click();
const card = await until(() =>
[...document.querySelectorAll(".m02-struct__card")].find((c) =>
c.querySelector(`table.m02-labrow tbody tr[data-labor-logic="${SC}"]`),
),
);
if (!card) return JSON.stringify({ error: "일위조합 줄 카드 없음" });
await w(1500);
const out = { card: await measure(() => card.querySelector("table.m02-labrow")) };
const edit = [...card.querySelectorAll(".m02-struct__card-tools button")].find(
(b) => b.innerText.trim() === "수정",
);
if (!edit) return JSON.stringify({ ...out, error: "일위대가 [수정] 없음" });
edit.click();
const detail = () =>
[...document.querySelectorAll("table.m02-labrow")].find(
(t) => !t.closest(".m02-struct__card") && t.querySelector(`tr[data-labor-logic="${SC}"]`),
);
if (!(await until(detail))) return JSON.stringify({ ...out, error: "상세 표 없음" });
await w(2000);
out.detail = await measure(detail);
document.querySelector('[data-node="i|item3"]')?.click();
await w(800);
[...document.querySelectorAll(".ui-modal button, dialog button")]
.find((b) => /확인|버림|떠나기/.test(b.innerText))
?.click();
await w(1200);
out.back = !!document.querySelector(".m02-struct__card table.m02-labrow");
return JSON.stringify(out);
} finally {
window.fetch = real;
}
})();