feat(M02): 50-4 일위대가 줄 누르면 표 아래 상세 · 구분 열

- 서버 calc 답 줄마다 그 로직 재료 · 노무 · 경비 「줄」 실음(버리던 것)
- 상세 · 카드 표 줄 누름 = 표 아래 M01 시험계산 표(answerView 다시 씀) + 반영 합 · 한 줄만 · 같은 줄 다시 누름 = 닫힘
- 이름 왼쪽 「구분」 열 — 키 머리에서 읽음(SP- 단가승격 · SC- 일위조합 · 그 밖 단가산출 · 저장 안 함)
- 시험: 줄 실림 · 합 = 기준 · ui_checks 50-4 시나리오

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 18:41:23 +09:00
co-authored by Claude Opus 5.5
parent 3e572ef210
commit 7b28cb42d3
9 changed files with 235 additions and 9 deletions
@@ -203,7 +203,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
}
/** `money` = 결과가 돈(원) — 그때만 결과 값도 정수 · 수량·비율 결과는 소수 그대로 */
function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLElement[] {
export function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLElement[] {
const note = draft ? [el("p", { className: "m01-logic__muted", text: tx("Calc_Draft") })] : [];
if (!answer.ok) {
return [
+4 -1
View File
@@ -302,7 +302,10 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
if "결과" in got: # 돈 아닌 로직 — 반영 없음
lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None})
continue
lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None})
# 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4)
lines.append(
{**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "줄": got["줄"], "까닭": None}
)
mats = [
{
"코드": one["코드"],
@@ -130,6 +130,17 @@
box-shadow: inset 3px 0 0 var(--color-primary);
}
/* 구분 칸(50-4) — 넉 자 · 흐린 글 */
.m02-labrow__kind {
white-space: nowrap;
color: var(--color-text-secondary);
}
/* 줄 상세(50-4) — 표 아래 · 고른 줄만 */
.m02-labor__linedet {
margin-top: var(--spacing-8);
}
/* 별칭 칸(44-6) — 칸 안 글상자 · 누르면 바로 고침 */
.m02-labrow__alias {
min-width: 7rem;
@@ -259,6 +270,12 @@
max-width: none;
}
.m02-labrow .m02-labrow__kind {
grid-column: 1 / -1;
order: 1;
font-size: var(--text-caption);
}
.m02-labrow .m02-labrow__no,
.m02-labrow .m02-labrow__unit:not([data-label]),
.m02-labrow .m02-labrow__code,
@@ -322,6 +339,7 @@
/* 옵션 값 줄 — 한 줄 통째 */
.m02-labrow__optrow .m02-labrow__no,
.m02-labrow__sum .m02-labrow__kind,
.m02-labrow__sum .m02-labrow__alias {
display: none;
}
@@ -5,6 +5,8 @@
* 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. */
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const;
export type Money = Record<(typeof MONEY_KEYS)[number], number>;
export type QtySource = "손" | "칸" | "없음";
@@ -22,6 +24,8 @@ export interface CalcLine {
반영?: Money | null;
까닭?: string | null;
결과?: unknown;
/** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */
줄?: LogicLine[];
}
export interface CalcMaterial {
@@ -2,7 +2,8 @@
* M02_MasterTemplete_Labor_Detail.ts
* 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임
* (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램.
* · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) · [옵션] = 입력 모달(`Labor_Input`) ·
* · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) + 표 아래 그 줄 상세(50-4 · M01 시험계산 표) ·
* 같은 줄 다시 누르면 닫힘 · [옵션] = 입력 모달(`Labor_Input`) ·
* [확인] = 화면 캐시만 · 저장은 [저장]
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
@@ -15,6 +16,9 @@ import { ROUTES } from "@config/config_frontend";
import { navigateTo } from "../A00_Common/router";
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
import { answerView } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc";
import type { CalcAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc";
import {
@@ -24,7 +28,7 @@ import {
type Layer,
} from "./M02_MasterTemplete_Api_Fetch";
import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick";
import { settle } from "./M02_MasterTemplete_Labor_Calc";
import { MONEY_KEYS, settle } from "./M02_MasterTemplete_Labor_Calc";
import {
areaLinks,
calcLabor,
@@ -134,6 +138,46 @@ export function mountLaborDetail(
const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key);
const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code);
/** 고른 줄 상세(50-4) — M01 시험계산 표(재료 · 노무 · 경비 줄 + 기준 합) + 반영 합 · 고른 줄 없으면 비움 */
const lineDet = el("div", {
className: "m02-labor__linedet",
attrs: { "data-labor-linedet": "" },
});
function showLine(): void {
if (!picked) return void lineDet.replaceChildren();
const logic = logics.get(picked);
const head = el("h4", {
text: tc("Labor_LineDetail").replace("{value}", `${picked} ${logic?.이름 ?? ""}`.trim()),
});
const line = lineOf(picked);
if (!line)
return void lineDet.replaceChildren(
head,
el("p", { className: "m01-logic__muted", text: fail ?? "…" }),
);
const answer: CalcAnswer = line.까닭
? { ok: false, reason: line.까닭 }
: { ok: true, lines: line.줄, sums: line.기준 ?? undefined, result: line.결과, middle: {} };
// 반영 = 기준 × 수량 ÷ 배(표 아래 줄과 같음) · 수량 없으면 「—」 · 돈 아닌 로직은 없음
const applied = line.기준
? [
el("h4", { text: tc("Labor_LineApplied") }),
el("dl", {
className: "m01-logic__sums",
children: MONEY_KEYS.flatMap((k) => [
el("dt", { text: k === "계" ? tc("Prev_Sum") : k }),
el("dd", {
className: "m01-logic__money",
text: line.반영 ? formatMoney(line.반영[k]) : "—",
}),
]),
}),
]
: [];
const money = (logic?.결과단위 ?? "").startsWith("원");
lineDet.replaceChildren(head, ...answerView(answer, false, money), ...applied);
}
const drawDg = (): void => {
if (opt.card || !dgBox.open) return; // 카드 갈래엔 다이어그램 없음
const data = buildLaborDiagram({
@@ -172,7 +216,9 @@ export function mountLaborDetail(
}
: { open: pick, option: openRow },
),
lineDet,
);
showLine();
matHost.replaceChildren(
matTable(
{ mats: cur.자재, calc, fail, cells: cells() },
@@ -293,11 +339,14 @@ export function mountLaborDetail(
[cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]];
changed();
}
/** 줄 고름(47-5) — 하이라이트(표만 · 다이어그램 안 다시 그림) + 원문 패널(47-4) · 화면 캐시 안 바뀜 */
/** 줄 고름(47-5) — 하이라이트(표만 · 다이어그램 안 다시 그림) + 줄 상세(50-4) + 원문 패널(47-4) ·
* 고른 줄 다시 누름 = 닫음 · 화면 캐시 안 바뀜 */
function pick(i: number): void {
picked = cur.담은로직[i].로직;
const key = cur.담은로직[i].로직;
picked = picked === key ? null : key;
for (const tr of logicHost.querySelectorAll<HTMLElement>("[data-labor-logic],[data-labor-opt]"))
tr.classList.toggle("is-selected", (tr.dataset.laborLogic ?? tr.dataset.laborOpt) === picked);
showLine();
showSource();
}
function removeRow(i: number): void {
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Rows.ts
* 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀.
* 담은 로직 = 차례 · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) ·
* 담은 로직 = 차례 · 구분(키 머리에서 읽음 · 50-4) · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) ·
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) ·
* 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 이름 ~ 합계에 걸친 한 줄 · 맨 아래 합계 줄(전체 합계 강조).
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3).
@@ -34,8 +34,12 @@ const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
계: "Prev_Sum",
};
/** 옵션 값 줄이 걸치는 칸 수 — 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */
const SPAN = 4 + COSTS.length;
/** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */
const SPAN = 5 + COSTS.length;
/** 구분(50-4 · 저장 안 함) — 줄 모양(키 머리)에서 읽음 · SP- = 단가승격 · SC- = 일위조합(50-3) */
export const kindOf = (key: string): string =>
tc(key.startsWith("SP-") ? "Kind_Promote" : key.startsWith("SC-") ? "Kind_Combo" : "Kind_Logic");
const num = (v: number): string =>
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
@@ -253,6 +257,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
attrs: { "data-labor-logic": row.로직 },
children: [
el("td", { className: "m02-labrow__no", text: String(i + 1) }),
el("td", { className: "m02-labrow__kind", text: kindOf(row.로직) }),
titled(
el("td", {
className: "m02-labrow__name",
@@ -331,6 +336,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
className: "m02-labrow__sum",
children: [
el("td", { className: "m02-labrow__no", text: "" }),
el("td", { className: "m02-labrow__kind", text: "" }),
el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }),
el("td", { className: "m02-labrow__alias", text: "" }),
el("td", { className: "m02-labrow__qty", text: "" }),
@@ -352,6 +358,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
: [];
const heads = [
tc("Col_Order"),
tc("Sub"),
tc("Col_Name"),
tc("Col_Alias"),
tc("Labor_QtyCol"),
@@ -84,6 +84,12 @@ const TEXT = {
Labor_BasePrice: ["기준 단가", "Base price"],
Labor_AppliedSum: ["반영 금액", "Applied amount"],
Labor_Calc_Fail: ["계산 못 함", "Calculation failed"],
// 구분 · 줄 상세(50-4)
Kind_Logic: ["단가산출", "Logic"],
Kind_Promote: ["단가승격", "Promoted"],
Kind_Combo: ["일위조합", "Combination"],
Labor_LineDetail: ["상세 — {value}", "Detail — {value}"],
Labor_LineApplied: ["반영(수량 곱함)", "Applied (× quantity)"],
Labor_Empty: ["빈 값 {n}", "{n} empty"],
Labor_Lost: [
"로직 입력이 바뀌어 저장 때 빠지는 값 — {value}",
+8
View File
@@ -90,6 +90,14 @@ def test_반영은_기준_곱_수량_나누기_배_돈_아닌_로직은_반영_
assert got["반영합"]["계"] == pytest.approx(ten["반영"]["계"])
def test_돈_로직_줄에_재료_노무_경비_줄_실림_합은_기준():
got = labor.calc(labor.clean(_doc(담은로직=[{"로직": TEN}, {"로직": PLAIN}])), {})
ten, plain = got["줄"]
assert ten["줄"] and all({"이름", "수량", "단위", "금액"} <= set(x) for x in ten["줄"])
assert sum(x["금액"] for x in ten["줄"]) == pytest.approx(ten["기준"]["계"])
assert "줄" not in plain
def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로():
doc = labor.clean(
_doc(
@@ -0,0 +1,131 @@
/* 50-4 — 일위대가 단가산출 로직 표 줄 누름 = 표 아래 그 줄 상세(M01 시험계산 표) · 다시 누르면 닫힘 · 「구분」 열 — 카드 · 상세 두 곳
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 ·
* 나무에 item1(구조물 조합 · 일위대가 로직 둘 이상)이 있음
* 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌)
* 하는 일: item1 카드 표 줄 누름 · 다른 줄 · 같은 줄 다시 → 일위대가 카드 [수정]으로 상세 → 같은 차례 → item1 다시(값 안 바뀜)
* 기대 수치(카드 · 상세 같음):
* - head[1] = "구분" · kinds = 줄마다 "단가산출"
* - first.open = 1(상세 상자 하나) · first.rows ≥ 1 · first.sum = first.total = first.base(줄 합 = 상세 계 = 표 기준 합계)
* - first.applied = first.appliedRow(상세 반영 계 = 표 반영 합계 · 수량 없으면 둘 다 「—」 = 0)
* - second.title 가 둘째 줄 키 · second.open = 1(한 줄만)
* - again.open = 0 · again.selected = 0(같은 줄 다시 = 닫힘)
* - back = true(카드 화면으로 돌아감)
*/
(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 n = (s) => Number(String(s ?? "").replace(/[^0-9.-]/g, "")) || 0;
const rowsOf = (t) => [...t.querySelectorAll("tbody tr[data-labor-logic]")];
const settled = (get) =>
until(() => {
const t = get();
return t && !/…/.test(t.querySelector("tbody").innerText) ? t : null;
}, 10000);
/* 상세 상자 읽기 — 표 줄 금액 합 · 기준 계 · 반영 계 */
const det = (box) => {
const dl = [...box.querySelectorAll("dl.m01-logic__sums")];
const total = (d) => {
const dt = d && [...d.querySelectorAll("dt")].find((x) => /계|합계|Total/.test(x.innerText));
return dt ? n(dt.nextElementSibling.innerText) : null;
};
const rows = [...box.querySelectorAll("table.m01-logic__grid tbody tr")];
return {
title: box.querySelector("h4")?.innerText ?? null,
rows: rows.length,
sum: rows.reduce((a, r) => a + n(r.lastElementChild.innerText), 0),
total: total(dl[0]),
applied: dl[1] ? total(dl[1]) : "없음",
};
};
const measure = async (get, boxOf) => {
const t = await settled(get);
const rows = rowsOf(t);
const head = [...t.querySelectorAll("thead th")].map((th) => th.innerText.trim());
const kinds = rows.map((r) => r.querySelector(".m02-labrow__kind")?.innerText.trim());
const click = async (key) => {
get().querySelector(`tr[data-labor-logic="${key}"] .m02-labrow__name`).click();
await w(300);
};
const opened = () =>
[...boxOf().querySelectorAll("[data-labor-linedet]")].filter((b) => b.childElementCount);
const k0 = rows[0].getAttribute("data-labor-logic");
const k1 = rows[1]?.getAttribute("data-labor-logic") ?? k0;
const sumCell = (key) => {
const tds = get().querySelectorAll(`tr[data-labor-logic="${key}"] .m02-labrow__money`);
const last = tds[tds.length - 1];
return {
base: n(last.querySelector(".m02-labrow__base").innerText),
applied: n(last.querySelector(".m02-labrow__applied").innerText),
};
};
await click(k0);
const d0 = det(opened()[0]);
const row0 = sumCell(k0);
const first = {
key: k0,
open: opened().length,
...d0,
base: row0.base,
appliedRow: row0.applied,
};
await click(k1);
const second = {
key: k1,
open: opened().length,
title: opened()[0]?.querySelector("h4")?.innerText,
};
await click(k1);
const again = {
open: opened().length,
selected: rowsOf(get()).filter((r) => r.classList.contains("is-selected")).length,
};
return { head, kinds, first, second, again };
};
const node = await until(() => document.querySelector('[data-node="i|item1"]'));
if (!node) return JSON.stringify({ error: "item1 없음 — 폴더를 펼쳐 둘 것" });
node.click();
const card = await until(() =>
[...document.querySelectorAll(".m02-struct__card")].find((c) =>
c.querySelector("table.m02-labrow tbody tr[data-labor-logic]"),
),
);
if (!card) return JSON.stringify({ error: "일위대가 카드 표 없음" });
await w(1500);
const out = {
card: await measure(
() => card.querySelector("table.m02-labrow"),
() => card,
),
};
const edit = [...card.querySelectorAll(".m02-struct__card-tools button")].find(
(b) => b.innerText.trim() === "수정",
);
if (!edit) return JSON.stringify({ ...out, error: "일위대가 [수정] 없음" });
edit.click();
const dt = await until(() =>
[...document.querySelectorAll("table.m02-labrow")].find(
(t) => !t.closest(".m02-struct__card") && t.querySelector("tbody tr[data-labor-logic]"),
),
);
if (!dt) return JSON.stringify({ ...out, error: "상세 표 없음" });
await w(2000);
const table = () =>
[...document.querySelectorAll("table.m02-labrow")].find((t) => !t.closest(".m02-struct__card"));
out.detail = await measure(table, () => table().closest("[data-labor]"));
/* 카드 화면으로 돌아감(고름만 했으니 버림 물음 없음 — 뜨면 확인) */
document.querySelector('[data-node="i|item1"]')?.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);
})();