From 90d27128e1ea739ea94716e1509e644527c89dc0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 18:59:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=2050-3=20=E2=91=A3=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B0=BD=20=EC=84=9C?= =?UTF-8?q?=EA=B3=A0=20=ED=83=AD=20=C2=B7=20=EC=B0=B8=EC=A1=B0=20=EC=A4=84?= =?UTF-8?q?=20=ED=91=9C=20=C2=B7=20=EC=A4=84=20=EC=83=81=EC=84=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [로직 추가] 창 탭 단가산출 · 단가승격 · 사전조합 — 서고 탭은 층 단추(위 층만 · 기본 = 가장 가까운 층) + 그 층 목록 · 고르면 참조 줄 더함(같은 참조 두 번 막음) - 담은 로직 표 참조 줄 — 줄 가름 lineId · 키 · 이름 · 단위 = 가리킨 문서 것 · 구분 = 참조 키 머리 · [옵션] 없음(넣을 값은 승격 때 고정) - 줄 상세 — 단가승격 = 원 로직 키 + 원 로직 표 + 기준(1 단위) · 사전조합 = 안 줄 목록 한 층(구분 · 이름 · 수량 · 금액 넷) + 기준 · 아래 반영 합 - 문서 정리 · 보기 패널 · 다이어그램이 참조 줄을 받음(다이어그램은 단가산출 줄만) - 시험: 승격 줄 원 로직 · 표 · 사전조합 안 줄 · ui_checks 50-3-4 시나리오(목록 · calc 는 탭 안 가짜 · 공유 마스터 안 씀) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Labor_Api.ts | 28 ++- .../M02_MasterTemplete_Labor_Calc.ts | 11 ++ .../M02_MasterTemplete_Labor_Detail.ts | 149 +++++++++++---- .../M02_MasterTemplete_Labor_Pick.ts | 152 +++++++++++++-- .../M02_MasterTemplete_Labor_Rows.ts | 35 ++-- .../M02_MasterTemplete_Labor_Text.ts | 7 + .../M02_MasterTemplete_Labor_View.ts | 11 +- resources/tester/test_m02_labor_ref.py | 11 ++ .../tester/ui_checks/50-3-4_고르기_서고탭.js | 174 ++++++++++++++++++ 9 files changed, 511 insertions(+), 67 deletions(-) create mode 100644 resources/tester/ui_checks/50-3-4_고르기_서고탭.js diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 5e7b50172..2fc3cb6a7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -11,8 +11,9 @@ import { type LogicSummary, type SubBrief, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import type { LaborCalc } from "./M02_MasterTemplete_Labor_Calc"; +import type { LaborCalc, LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import { + call, getActiveLayer, getActiveOwner, type ItemLogic, @@ -118,9 +119,11 @@ export const fetchLibLogicSubs = (): Promise => /** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */ export type LaborQty = { 손: number } | null; -/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) */ +/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) · + * 참조 줄(단가승격 · 사전조합 · 50-3) = `참조` 있고 `로직` 빈 글 · 줄 가름은 `lineId` */ export interface LaborRow { 로직: string; + 참조?: LaborRef; /** 사용자가 지은 이름(44-6 · 빈 것 = null) */ 별칭?: string | null; 메모: string | null; @@ -154,7 +157,8 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { 열: d.열 ?? column, 단위: d.단위 ?? null, 담은로직: (d.담은로직 ?? []).map((r) => ({ - 로직: r.로직, + 로직: r.로직 ?? "", + ...(r.참조 ? { 참조: r.참조 } : {}), 별칭: r.별칭 ?? null, 메모: r.메모 ?? null, 입력값: r.입력값 ?? {}, @@ -223,6 +227,24 @@ export const calcLabor = ( ...(수량 ? { 수량 } : {}), }); +/** 서고 한 줄(50-3) — 키 = SP-· SC-###### · 제목 · 단위 = 문서 것 */ +export interface LibraryRow { + 이름: string; + 제목?: string | null; + 단위?: string | null; +} +/** 서고 목록 — 그 층(주인) 단가승격 `promote` · 사전조합 `precombo` 문서 */ +export const fetchLibrary = async ( + ref: Omit, + kind: "promote" | "precombo", +): Promise => { + const q = ref.주인 && ref.층 !== "system" ? `?owner=${enc(ref.주인)}` : ""; + const got = await call<{ 양식: (LibraryRow & { 종류: string })[] }>( + `/layers/${ref.층}/templates${q}`, + ); + return got.양식.filter((r) => r.종류 === kind); +}; + /** 담은 로직 입력 목록 + 자동값(입력 모달 · `M02_Labor_Inputs`) */ export const fetchLogicInputs = ( keys: string[], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index ca2f5b6c9..0e8ee2a49 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -36,6 +36,17 @@ export interface CalcLine { 결과?: unknown; /** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */ 줄?: LogicLine[]; + /** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④) */ + 이름?: string | null; + 원로직?: string | null; + 안?: { + 참조: LaborRef; + 수량: number; + 이름?: string | null; + 단위?: string | null; + 기준: Money | null; + 까닭?: string | null; + }[]; } export interface CalcMaterial { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index c02cc57ac..93a083a06 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -22,19 +22,23 @@ 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 { + getActiveLayer, + getActiveOwner, readTemplate, saveTemplate, type ItemLogic, type Layer, } from "./M02_MasterTemplete_Api_Fetch"; import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick"; -import { MONEY_KEYS, settle } from "./M02_MasterTemplete_Labor_Calc"; +import { lineId, MONEY_KEYS, settle, type LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import { areaLinks, calcLabor, fetchLogicInputs, normalizeLabor, type LaborCalc, + type LaborCalcLine, + type LaborCost, type LaborDoc, type LaborQty, type LaborRow, @@ -47,7 +51,7 @@ import { import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input"; import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"; +import { kindOf, logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { keepKnown } from "./M02_Labor_Values_Model"; import { pickMaterial } from "./M02_Material_Pick"; @@ -135,53 +139,116 @@ export function mountLaborDetail( if (sourceHost.firstChild !== source.root) sourceHost.replaceChildren(source.root); } - const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key); + const lineOf = (key: string) => calc?.줄.find((l) => lineId(l) === key); const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code); - /** 고른 줄 상세(50-4) — M01 시험계산 표(재료 · 노무 · 경비 줄 + 기준 합) + 반영 합 · 고른 줄 없으면 비움 */ + /** 고른 줄 상세(50-4) — 단가산출 = M01 시험계산 표(재료 · 노무 · 경비 줄 + 기준 합) · + * 단가승격 = 원 로직 표 + 원 로직 키 + 기준(1 단위) · 사전조합 = 안 줄 목록 한 층 + 기준 · + * 셋 다 아래 반영 합 · 고른 줄 없으면 비움 */ const lineDet = el("div", { className: "m02-labor__linedet", attrs: { "data-labor-linedet": "" }, }); + const sums = (title: string, money: Partial | null | undefined): HTMLElement[] => [ + el("h4", { text: title }), + 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: money ? formatMoney(money[k] ?? 0) : "—", + }), + ]), + }), + ]; + /** 사전조합 안 줄 — 구분 · 이름 · 수량 · 금액 넷(= 그 줄 1 단위 기준 × 수량) */ + const innerTable = (inner: NonNullable): HTMLElement => + el("table", { + className: "m01-logic__grid", + attrs: { "data-labor-inner": "" }, + children: [ + el("thead", { + children: [ + el("tr", { + children: [tc("Sub"), tc("Col_Name"), tc("Labor_Qty"), ...MONEY_KEYS].map((h) => + el("th", { text: h === "계" ? tc("Prev_Sum") : h }), + ), + }), + ], + }), + el("tbody", { + children: inner.map((x) => + el("tr", { + children: [ + el("td", { text: kindOf({ 로직: "", 참조: x.참조 }) }), + el("td", { + text: `${x.참조.키} ${x.이름 ?? ""}${x.까닭 ? ` · ${x.까닭}` : ""}`.trim(), + }), + el("td", { text: `${x.수량} ${x.단위 ?? ""}`.trim() }), + ...MONEY_KEYS.map((k) => + el("td", { + className: "m01-logic__money", + text: x.기준 ? formatMoney(x.기준[k] * x.수량) : "—", + }), + ), + ], + }), + ), + }), + ], + }); 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 row = cur.담은로직.find((r) => lineId(r) === picked); const line = lineOf(picked); + const logic = logics.get(picked); + const name = row?.참조 ? (line?.이름 ?? "") : (logic?.이름 ?? ""); + const head = el("h4", { + text: tc("Labor_LineDetail").replace("{value}", `${row?.참조?.키 ?? picked} ${name}`.trim()), + }); 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 stop: CalcAnswer | null = line.까닭 ? { ok: false, reason: line.까닭 } : null; + const unit = `${tc("Labor_LineBase")} (1 ${line.분모 ?? ""})`.trim(); + const body: HTMLElement[] = !row?.참조 + ? answerView( + stop ?? { + ok: true, + lines: line.줄, + sums: line.기준 ?? undefined, + result: line.결과, + middle: {}, + }, + false, + (logic?.결과단위 ?? "").startsWith("원"), + ) + : line.안 + ? [ + innerTable(line.안), + ...(stop ? answerView(stop, false, true) : []), + ...sums(unit, line.기준), + ] + : [ + el("p", { + className: "m01-logic__muted", + text: `${tc("Labor_FromLogic")} ${line.원로직 ?? "—"}`, + }), + ...answerView(stop ?? { ok: true, lines: line.줄, middle: {} }, false, true), + ...sums(unit, line.기준), + ]; // 반영 = 기준 × 수량 ÷ 배(표 아래 줄과 같음) · 수량 없으면 「—」 · 돈 아닌 로직은 없음 - 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 applied = line.기준 ? sums(tc("Labor_LineApplied"), line.반영) : []; + lineDet.replaceChildren(head, ...body, ...applied); } const drawDg = (): void => { if (opt.card || !dgBox.open) return; // 카드 갈래엔 다이어그램 없음 const data = buildLaborDiagram({ - rows: cur.담은로직, + rows: cur.담은로직.filter((r) => !r.참조), // ponytail: 참조 줄은 다이어그램에 안 그림 — 그릴 때 카드 꼴 더함 logics: [...logics.values()], calc, mats: cur.자재.length, @@ -323,7 +390,7 @@ export function mountLaborDetail( /** 아직 안 받은 로직 입력 목록만 받음 */ const loadLogics = async (): Promise => { - const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); + const want = cur.담은로직.map((r) => r.로직).filter((k) => k && !logics.has(k)); if (!want.length) return; try { const got = await fetchLogicInputs(want); @@ -342,7 +409,7 @@ export function mountLaborDetail( /** 줄 고름(47-5) — 하이라이트(표만 · 다이어그램 안 다시 그림) + 줄 상세(50-4) + 원문 패널(47-4) · * 고른 줄 다시 누름 = 닫음 · 화면 캐시 안 바뀜 */ function pick(i: number): void { - const key = cur.담은로직[i].로직; + const key = lineId(cur.담은로직[i]) ?? ""; picked = picked === key ? null : key; for (const tr of logicHost.querySelectorAll("[data-labor-logic],[data-labor-opt]")) tr.classList.toggle("is-selected", (tr.dataset.laborLogic ?? tr.dataset.laborOpt) === picked); @@ -350,7 +417,7 @@ export function mountLaborDetail( showSource(); } function removeRow(i: number): void { - if (cur.담은로직[i].로직 === picked) picked = null; + if (lineId(cur.담은로직[i]) === picked) picked = null; cur.담은로직.splice(i, 1); changed(); } @@ -386,9 +453,9 @@ export function mountLaborDetail( /** 수량산출 칸 누름 — 로직 줄 */ function openQty(i: number): void { const row = cur.담은로직[i]; - const line = lineOf(row.로직); + const line = lineOf(lineId(row) ?? ""); openQtyFor( - `${row.로직} ${logics.get(row.로직)?.이름 ?? ""}`.trim(), + `${row.참조?.키 ?? row.로직} ${(row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? ""}`.trim(), row, line, line?.분모 ?? "", @@ -401,6 +468,18 @@ export function mountLaborDetail( openQtyFor(`${mat.코드} ${mat.이름 ?? ""}`.trim(), mat, matOf(mat.코드), mat.단위 ?? ""); } + /** 서고 탭(50-3 ④) — 참조 줄 더함(같은 참조 두 번 막음 · 위 층만은 서버도 막음) */ + const library = { + layer: opt.layer, + owner: opt.layer === getActiveLayer() ? getActiveOwner() : null, + pick: (ref: LaborRef, name: string) => { + 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 }); + changed(); + }, + }; const addLogic = createButton({ label: tc("AddLogic"), variant: "filled", @@ -410,7 +489,7 @@ export function mountLaborDetail( return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null }); void loadLogics().then(() => changed()); - }), + }, library), }); const addMat = createButton({ label: tc("Labor_AddMat"), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index ee9acecd0..d6a92347d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -1,6 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_Labor_Pick.ts - * [로직 더하기] 창 — 구분 → 상세구분 → 찾기로 사용자가 로직을 하나씩 고름(자동 추천 없음) + * [로직 더하기] 창 — 구분 → 상세구분 → 찾기로 사용자가 로직을 하나씩 고름(자동 추천 없음) · + * 탭 단가산출 · 단가승격 · 사전조합(50-3 ④) — 서고 탭은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) + + * 그 층 목록 · 고르면 참조 {층 · 주인 · 키} · 프로젝트 · 카드엔 서고 탭 없음 * ========================================================================== */ import { @@ -12,10 +14,16 @@ import { } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import type { LogicSummary, SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; import { fetchLibLogics as fetchLogics, fetchLibLogicSubs as fetchLogicSubs, + fetchLibrary, + type LibraryRow, } from "./M02_MasterTemplete_Labor_Api"; +import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import { ownOwner } from "./M02_MasterTemplete_Layer"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 import "./M02_MasterTemplete_Labor.css"; @@ -23,7 +31,33 @@ import "./M02_MasterTemplete_Labor.css"; const opts = (values: string[]): { value: string; text: string }[] => [L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v })); -export function openLogicPicker(done: (logic: LogicSummary) => void): void { +type Scope = Omit; +/** 서고 탭 — 일위대가가 있는 층 · 주인(null = 자기 층) · 고르면 참조 */ +export interface LibraryPick { + layer: Layer; + owner: string | null; + pick: (ref: LaborRef, name: string) => void; +} + +/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ +async function upper(layer: Layer, owner: string | null): Promise { + if (layer === "project") return []; + const own = + layer === "system" + ? null + : (owner ?? ownOwner(layer, await fetchSessionUser().catch(() => null))); + const out: Scope[] = [{ 층: "system", 주인: null }]; + if (own) out.push({ 층: "company", 주인: own.split("/")[0] }); + if (own && layer === "personal") out.push({ 층: "personal", 주인: own }); + return out; +} +const LAYER_TEXT = { + system: "Lib_System", + company: "Lib_Company", + personal: "Lib_Personal", +} as const; + +export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: LibraryPick): void { const subs: SubBrief[] = []; let all: LogicSummary[] = []; const sub = createSelectField({ options: opts([]), compact: true, label: tc("Sub") }); @@ -76,6 +110,108 @@ export function openLogicPicker(done: (logic: LogicSummary) => void): void { detail.select.disabled = d.length === 0; }); + /* 서고 탭(50-3 ④) — 탭 · 층 단추 · 목록 */ + const filters = el("div", { + className: "m01-logic__filters", + children: [ + sub.root, + detail.root, + searchField.root, + createButton({ label: tc("Pick_Find"), variant: "filled", onClick: find }), + ], + }); + const tabs = el("div", { className: "ui-btn-group", attrs: { "data-pick-tabs": "" } }); + const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } }); + let tab: "logic" | "promote" | "precombo" = "logic"; + let scopes: Scope[] = []; + let scope: Scope | null = null; + let seq = 0; + const libList = async (): Promise => { + if (!lib || !scope || tab === "logic") return; + const mine = ++seq; + const at = scope; + list.replaceChildren(); + hint.textContent = tc("Loading"); + let rows: LibraryRow[] = []; + try { + rows = await fetchLibrary(at, tab); + } catch (error) { + if (mine === seq) + hint.textContent = error instanceof Error ? error.message : tc("Load_Failed"); + return; + } + if (mine !== seq) return; + hint.textContent = rows.length ? `${rows.length}` : tc("Pick_None"); + list.replaceChildren( + ...rows.map((x) => { + const row = el("button", { + className: "m01-logic__pick-row m01-logic__pick-row--button", + attrs: { type: "button", "data-pick": x.이름 }, + children: [ + el("div", { + className: "m01-logic__stack", + children: [ + el("strong", { text: x.제목 || x.이름 }), + el("span", { className: "m01-logic__muted", text: x.이름 }), + ], + }), + el("span", { text: x.단위 ?? "" }), + ], + }); + row.addEventListener("click", () => { + close(); + lib.pick({ ...at, 키: x.이름 }, x.제목 || x.이름); + }); + return row; + }), + ); + }; + const drawLayers = (): void => + layerBar.replaceChildren( + ...scopes.map((x) => + createButton({ + label: tc(LAYER_TEXT[x.층 as keyof typeof LAYER_TEXT]), + variant: x === scope ? "filled" : "ghost", + onClick: () => { + scope = x; + drawLayers(); + void libList(); + }, + }), + ), + ); + const drawTabs = (): void => { + tabs.replaceChildren( + ...(["logic", "promote", "precombo"] as const).map((k) => + createButton({ + label: tc( + k === "logic" ? "Kind_Logic" : k === "promote" ? "Kind_Promote" : "Lib_Precombo", + ), + variant: k === tab ? "filled" : "ghost", + onClick: () => { + tab = k; + drawTabs(); + filters.style.display = k === "logic" ? "" : "none"; // 공용 꼴 display 가 hidden 을 이김 + layerBar.style.display = k === "logic" ? "none" : ""; + if (k === "logic") { + list.replaceChildren(); + hint.textContent = tc("Pick_Hint"); + } else void libList(); + }, + }), + ), + ); + }; + layerBar.style.display = "none"; + if (lib) + void upper(lib.layer, lib.owner).then((got) => { + scopes = got; + scope = got[got.length - 1] ?? null; // 기본 = 가장 가까운 층 + if (!got.length) return; + drawTabs(); + drawLayers(); + }); + const dialog = el("div", { className: "m01-logic__pick m01-combo", attrs: { role: "dialog", "aria-label": tc("Pick_Title") }, @@ -87,15 +223,9 @@ export function openLogicPicker(done: (logic: LogicSummary) => void): void { createButton({ label: tc("Pick_Close"), variant: "ghost", onClick: close }), ], }), - el("div", { - className: "m01-logic__filters", - children: [ - sub.root, - detail.root, - searchField.root, - createButton({ label: tc("Pick_Find"), variant: "filled", onClick: find }), - ], - }), + tabs, + filters, + layerBar, hint, list, ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index cdae92877..f57b1747f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -21,6 +21,7 @@ import type { LaborMat, LaborRow, } from "./M02_MasterTemplete_Labor_Api"; +import { lineId } from "./M02_MasterTemplete_Labor_Calc"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import "@ui/ui_template_general_layout.css"; import "./M02_MasterTemplete_Labor.css"; @@ -37,9 +38,13 @@ const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { /** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */ 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"); +/** 구분(50-4 · 저장 안 함) — 줄 모양(참조 키 머리)에서 읽음 · SP- = 단가승격 · SC- = 일위조합(50-3) */ +export const kindOf = (row: Pick): string => { + const key = row.참조?.키 ?? row.로직; + return 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 }); @@ -230,11 +235,13 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement // 옵션 · 삭제만(구조물 조합 카드 · 43-4)이어도 끝 칸 — 순서 단추는 `move` 가 있을 때만 const tools = !!(move || remove || option); const lineOf = (key: string): LaborCalcLine | undefined => - data.calc?.줄.find((l) => l.로직 === key); + data.calc?.줄.find((l) => lineId(l) === key); const trs = data.rows.flatMap((row, i) => { + const id = lineId(row) ?? ""; const logic = data.logics.get(row.로직); - const line = lineOf(row.로직); - const unit = logic?.결과단위 ?? ""; + const line = lineOf(id); + // 참조 줄 — 키 · 이름 · 단위 = 가리킨 문서 것(calc 답) · 넣을 값 없음(승격 때 고정) + const unit = row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? ""); const values = valueLine(logic?.입력 ?? [], row.입력값); const note = values.text; const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null); @@ -251,19 +258,21 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement run(); }, }); - const sel = data.selected === row.로직 ? " is-selected" : ""; + const sel = data.selected === id ? " is-selected" : ""; const tr = el("tr", { className: (on ? "m02-labrow__row is-open" : "m02-labrow__row") + sel, - attrs: { "data-labor-logic": row.로직 }, + attrs: { "data-labor-logic": id }, children: [ el("td", { className: "m02-labrow__no", text: String(i + 1) }), - el("td", { className: "m02-labrow__kind", text: kindOf(row.로직) }), + el("td", { className: "m02-labrow__kind", text: kindOf(row) }), titled( el("td", { className: "m02-labrow__name", children: [ - el("span", { className: "m02-labrow__key", text: row.로직 }), - el("strong", { text: logic?.이름 ?? row.로직 }), + el("span", { className: "m02-labrow__key", text: row.참조?.키 ?? row.로직 }), + el("strong", { + text: (row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직, + }), ...(values.empty ? [ el("small", { @@ -299,7 +308,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement ...(move ? [small(tc("Up"), () => move(i, -1)), small(tc("Down"), () => move(i, 1))] : []), - ...(option ? [small(tc("Labor_Option"), () => option(i))] : []), + ...(option && !row.참조 ? [small(tc("Labor_Option"), () => option(i))] : []), ...(remove ? [small(tc("Remove"), () => remove(i), "danger")] : []), ], }), @@ -314,7 +323,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement // 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐 const opt = el("tr", { className: (on ? "m02-labrow__optrow is-open" : "m02-labrow__optrow") + sel, - attrs: { "data-labor-opt": row.로직 }, + attrs: { "data-labor-opt": id }, children: [ el("td", { className: "m02-labrow__no", text: "" }), el("td", { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index ed3e78489..f266adfcb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -90,6 +90,13 @@ const TEXT = { Kind_Combo: ["일위조합", "Combination"], Labor_LineDetail: ["상세 — {value}", "Detail — {value}"], Labor_LineApplied: ["반영(수량 곱함)", "Applied (× quantity)"], + Labor_LineBase: ["기준", "Base"], + Labor_FromLogic: ["원 로직", "Source logic"], + Lib_Precombo: ["사전조합", "Pre-combination"], + Lib_System: ["마스터", "Master"], + Lib_Company: ["회사", "Company"], + Lib_Personal: ["개인", "Personal"], + Labor_AlreadyRef: ["이미 담은 일위대가 조합 — {value}", "Already added — {value}"], Labor_Empty: ["빈 값 {n}", "{n} empty"], Labor_Lost: [ "로직 입력이 바뀌어 저장 때 빠지는 값 — {value}", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts index 95a6aae82..d5ec07a01 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts @@ -19,7 +19,7 @@ import { type LaborCalc, type LaborDoc, } from "./M02_MasterTemplete_Labor_Api"; -import { settle } from "./M02_MasterTemplete_Labor_Calc"; +import { lineId, settle } from "./M02_MasterTemplete_Labor_Calc"; import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import "./M02_MasterTemplete_Labor.css"; @@ -64,12 +64,13 @@ export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewH onRow ? () => onRow(row) : undefined; const logicOpen = (i: number): void => { const row = d.담은로직[i]; - const line = calc?.줄.find((l) => l.로직 === row.로직); + const id = lineId(row) ?? ""; + const line = calc?.줄.find((l) => lineId(l) === id); pick({ kind: "logic", - key: row.로직, + key: id, pill: line?.알약 ?? null, - name: logics.get(row.로직)?.이름 ?? row.로직, + name: (row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? row.참조?.키 ?? row.로직, unit: line?.분모 ?? "", })?.(); }; @@ -111,7 +112,7 @@ export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewH const got = await readTemplate(layer, "labor", column, null); if (mine !== seq) return; doc = normalizeLabor(got.문서, column); - const want = doc.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); + const want = doc.담은로직.map((r) => r.로직).filter((k) => k && !logics.has(k)); const [inputs, area] = await Promise.all([ fetchLogicInputs(want), readTemplate(layer, "area", column, null).then( diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index 29d115eac..176e4c6af 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -163,6 +163,17 @@ def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합 assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "", "로직": [], "참조": ref}] +def test_승격_줄_상세는_원_로직_키_표_사전조합은_안_줄만(world): + sp = _promote(world["system"]) + sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}}], "하나") + with store.at(world["system"]): + doc = labor.clean({"열": "rv", "담은로직": [{"참조": {**SYS, "키": k}} for k in (sp, sc)]}) + promote, combo = labor.calc(doc, {})["줄"] + assert promote["원로직"] == TEN and promote["줄"] + assert sum(x["금액"] for x in promote["줄"]) == pytest.approx(float(_base(TEN)["계"])) + assert "줄" not in combo and [x["참조"]["키"] for x in combo["안"]] == [sp] + + def test_펼침은_순환_깊이_없는_참조를_까닭으로_멈춤(world): folder = world["system"] / "precombo" for key, child in (("SC-000001", "SC-000002"), ("SC-000002", "SC-000001")): diff --git a/resources/tester/ui_checks/50-3-4_고르기_서고탭.js b/resources/tester/ui_checks/50-3-4_고르기_서고탭.js new file mode 100644 index 000000000..edf1a56ec --- /dev/null +++ b/resources/tester/ui_checks/50-3-4_고르기_서고탭.js @@ -0,0 +1,174 @@ +/* 50-3 ④ — 일위대가 상세 [로직 추가] 창 탭 단가산출 · 단가승격 · 사전조합 + 층 단추 · 참조 줄 표 · 줄 상세 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · + * 나무에 item1(구조물 조합 · 일위대가 첫 줄 GC000198)이 있음 + * 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌) + * 공유 마스터에 서고 문서를 안 씀 — 이 탭 fetch 만 바꿔 끼움(끝나면 되돌림): + * 층 목록 = 가짜 SP-990001 · SC-990001 더함 · calc = 참조 줄 빼고 진짜 셈 + 참조 줄 답(승격 = GC000198 줄 · 사전조합 안 줄 하나) + * 하는 일: item1 → 일위대가 [수정] → [로직 추가] 탭 셋 → 단가승격 탭 · 마스터 → SP 고름 → 사전조합 탭 → SC 고름 → + * 승격 줄 누름 · 조합 줄 누름 → 두 줄 삭제(저장 안 함) → item1 다시 + * 기대 수치: + * - tabs = ["단가산출","단가승격","사전조합"] · layers = ["마스터"](마스터 층은 위 층 없음) · listed = ["SP-990001"] · ["SC-990001"] + * - added.kinds 끝 둘 = ["단가승격","일위조합"] · added.keys 끝 둘 = ["SP-990001","SC-990001"] · 옵션 단추 없음(optBtn = 0) + * - promote.from = "원 로직 GC000198" · promote.rows = GC000198 줄 수(4) · promote.sum = first 줄 기준 계 + * - combo.inner = 1 · combo.innerKind = "단가승격" + * - removed = true(원래 줄 수) · askedLeave = false(저장 안 한 고침 없음) · 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 SP = { 층: "system", 주인: null, 키: "SP-990001" }; + const SC = { 층: "system", 주인: null, 키: "SC-990001" }; + const rid = (r) => `참조|${r.층}|${r.주인 ?? ""}|${r.키}`; + const real = window.fetch; + let first = null; + window.fetch = async (url, init) => { + const u = String(url); + if (/\/layers\/system\/templates$/.test(u)) { + const got = await (await real(url, init)).json(); + got.양식.push( + { 종류: "promote", 이름: SP.키, 제목: "시험 승격", 단위: "㎡" }, + { 종류: "precombo", 이름: SC.키, 제목: "시험 조합", 단위: "식" }, + ); + return new Response(JSON.stringify(got), { status: 200 }); + } + if (/\/labor\/[^/]+\/calc/.test(u) && init?.body) { + const body = JSON.parse(init.body); + const refs = (body.doc?.담은로직 ?? []).filter((r) => r.참조); + if (!refs.length) return real(url, init); + body.doc.담은로직 = body.doc.담은로직.filter((r) => !r.참조); + const got = await (await real(url, { ...init, body: JSON.stringify(body) })).json(); + first = got.줄[0]; + for (const r of refs) { + const base = { ...first.기준 }; + const line = { + 참조: r.참조, + 이름: r.참조.키 === SP.키 ? "시험 승격" : "시험 조합", + 알약: rid(r.참조), + 분모: "㎡", + 배: 1, + 기준: base, + 반영: null, + 수량: null, + 수량출처: "없음", + 까닭: null, + }; + if (r.참조.키 === SP.키) Object.assign(line, { 원로직: first.로직, 줄: first.줄 }); + else + line.안 = [{ 참조: SP, 수량: 1, 이름: "시험 승격", 단위: "㎡", 기준: base, 까닭: null }]; + got.줄.push(line); + } + return new Response(JSON.stringify(got), { status: 200 }); + } + return real(url, init); + }; + const out = {}; + try { + 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: "일위대가 카드 없음" }); + [...card.querySelectorAll(".m02-struct__card-tools button")] + .find((b) => b.innerText.trim() === "수정") + ?.click(); + const table = () => + [...document.querySelectorAll("table.m02-labrow")].find( + (t) => !t.closest(".m02-struct__card") && t.querySelector("tr[data-labor-logic]"), + ); + await until(() => table() && !/…/.test(table().querySelector("tbody").innerText), 12000); + const host = table().closest("[data-labor]"); + const before = table().querySelectorAll("tbody tr[data-labor-logic]").length; + const add = async (tabLabel, key) => { + [...host.querySelectorAll(".m01-logic__section-head button")] + .find((b) => b.innerText.trim() === "로직 추가") + .click(); + const dlg = await until( + () => + document.querySelector(".m01-logic__pick [data-pick-tabs] button") && + document.querySelector(".m01-logic__pick"), + ); + const tabs = [...dlg.querySelectorAll("[data-pick-tabs] button")]; + out.tabs = tabs.map((b) => b.innerText.trim()); + tabs.find((b) => b.innerText.trim() === tabLabel).click(); + await until(() => dlg.querySelector(`[data-pick="${key}"]`)); + out.layers = [...dlg.querySelectorAll("[data-pick-layers] button")].map((b) => + b.innerText.trim(), + ); + (out.listed ??= []).push([...dlg.querySelectorAll("[data-pick]")].map((b) => b.dataset.pick)); + dlg.querySelector(`[data-pick="${key}"]`).click(); + await until( + () => + table()?.querySelector(`tr[data-labor-logic="${rid({ ...SP, 키: key })}"]`) && + !/…/.test(table().querySelector("tbody").innerText), + 12000, + ); + }; + await add("단가승격", SP.키); + await add("사전조합", SC.키); + const rows = [...table().querySelectorAll("tbody tr[data-labor-logic]")]; + out.added = { + kinds: rows.slice(-2).map((r) => r.querySelector(".m02-labrow__kind").innerText.trim()), + keys: rows.slice(-2).map((r) => r.querySelector(".m02-labrow__key").innerText.trim()), + optBtn: rows + .slice(-2) + .flatMap((r) => + [...r.querySelectorAll("button")].filter((b) => b.innerText.trim() === "옵션"), + ).length, + }; + const open = async (ref) => { + table() + .querySelector(`tr[data-labor-logic="${rid(ref)}"] .m02-labrow__name`) + .click(); + await w(300); + return host.querySelector("[data-labor-linedet]"); + }; + let det = await open(SP); + const grid = det.querySelector("table.m01-logic__grid"); + out.promote = { + from: det.querySelector("p")?.innerText.trim(), + rows: grid?.querySelectorAll("tbody tr").length ?? 0, + sum: [...(grid?.querySelectorAll("tbody tr") ?? [])].reduce( + (a, r) => a + n(r.lastElementChild.innerText), + 0, + ), + firstBase: first?.기준?.계 ?? null, + }; + det = await open(SC); + const inner = det.querySelector("[data-labor-inner]"); + out.combo = { + inner: inner?.querySelectorAll("tbody tr").length ?? 0, + innerKind: inner?.querySelector("tbody td")?.innerText.trim(), + }; + await open(SC); /* 닫음 */ + for (const ref of [SC, SP]) { + const tr = table().querySelector(`tr[data-labor-logic="${rid(ref)}"]`); + [...tr.querySelectorAll("button")].find((b) => b.innerText.trim() === "삭제").click(); + await w(600); + } + out.removed = table().querySelectorAll("tbody tr[data-labor-logic]").length === before; + } finally { + window.fetch = real; + } + document.querySelector('[data-node="i|item1"]')?.click(); + await w(800); + const ask = [...document.querySelectorAll(".ui-modal button, dialog button")].find((b) => + /확인|버림|떠나기/.test(b.innerText), + ); + out.askedLeave = !!ask; /* 저장 안 한 고침이 남았으면 물음 — 지운 뒤라 없어야 함 */ + ask?.click(); + await w(1200); + out.back = !!document.querySelector(".m02-struct__card table.m02-labrow"); + return JSON.stringify(out); +})();