diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 4b900c47d..1874df552 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -367,11 +367,3 @@ .m02-hopyo-side > .m02-side__row { margin-top: var(--spacing-12); } - -/* 고름 대기 후보 줄 비교(746-2) — 고르기 칸 아래 · 후보 단추 줄 + 표 */ -.m02-hopyo__cmp { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - margin-top: var(--spacing-4); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index a0848502b..1c0d6a27d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -234,27 +234,6 @@ export interface SangeunCand { 글: string; /** 729-4 — 기준단위 ↔ 엑셀 단위 성격 다름 = 환산 계수 필요(가 1 = 나 몇) */ 계수?: { 가: string; 나: string; 갈래: string } | null; - /** 746-1 — 그 기초로 견준 줄 비교(앞 셋) · null = 견줌 못함(단위 성격 다름) */ - 줄비교?: SangeunCompare | null; -} - -/** 줄 비교 꼴(746-1) */ -export type CompareLook = "같음" | "품다름" | "엑셀만" | "기초만"; - -/** 고름 대기 후보 줄 비교(746-1) — 줄마다 엑셀 · 기초 1 단위 품 */ -export interface SangeunCompare { - 줄: { - 갈래: "인력" | "장비" | "자재"; - 이름: string; - 규격?: string; - 단위?: string; - 엑셀: number | null; - 기초: number | null; - 꼴: CompareLook; - }[]; - 수: Record; - 못옮김: SangeunLost[]; - 환산?: unknown; } /** M01 요소 후보(746-1) — 못옮김 · 안 이은 기계 줄 · 고르면 `{노임|기계|자재: 키}` 로 이음 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Compare.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Compare.ts index 827e5cdf5..9779109f0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Compare.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Compare.ts @@ -1,111 +1,15 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Compare.ts - * 불러오기 고름 창 — 후보 블록 줄 비교 · M01 요소 후보(PLAN 746-2 · 요구 585 · 서버 746-1 `줄비교` · `후보`) — - * · `compareView` = 고름 대기 후보(앞 셋)마다 줄 비교 표(줄 · 엑셀 품 · 기초 품 · 꼴 배지) + 못옮김 수 · 후보 바꿈 단추 - * · `elemChoices` = 못옮김 · 안 이은 기계 줄의 M01 요소 후보 셋 단추(누르면 부르는 쪽이 이음) - * 보이기만 — 판정 · 순위 · 미리 고름은 서버 그대로 · 고르기는 부르는 쪽. + * 불러오기 M01 요소 후보 단추(PLAN 746-2 · 요구 585 · 서버 746-1 `후보`) — + * · `elemChoices` = 안 이은 줄의 M01 요소 후보 셋 단추(누르면 부르는 쪽이 이음) + * · 옛 고름 대기 기초 줄 비교(`compareView`)는 778-5 D-5 줄 고름으로 걷음 + * 보이기만 — 판정 · 순위는 서버 그대로 · 고르기는 부르는 쪽. * ========================================================================== */ -import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; import { createButton, el } from "@ui/ui_template_elements"; -import { t as L, type LocaleKey } from "@ui/ui_template_locale"; -import { createSegment } from "@ui/ui_template_segment"; -import { createTable } from "@ui/ui_template_table"; -import { createText } from "@ui/ui_template_text"; -import { formatNumber, formatPrice } from "../common_util/common_util_money"; -import type { - CompareLook, - ElemCand, - SangeunCand, - SangeunCompare, -} from "./M02_MasterTemplete_Hopyo_Api"; - -const LOOK: Record = { - 같음: { tone: "success", key: "M02_CmpSame" }, - 품다름: { tone: "warning", key: "M02_CmpDiff" }, - 엑셀만: { tone: "info", key: "M02_CmpExcel" }, - 기초만: { tone: "neutral", key: "M02_CmpBase" }, -}; -const LOOKS = Object.keys(LOOK) as CompareLook[]; -const num = (n: number | null): string => (n === null ? "—" : formatNumber(n)); - -/** 선택 칸 글 뒤 요약 — 「같음 3 · 품 다름 1」 · 견줌 못함 = "" */ -export const compareBrief = (c: SangeunCompare | null | undefined): string => - c - ? LOOKS.filter((k) => c.수[k]) - .map((k) => `${L(LOOK[k].key)} ${c.수[k]}`) - .join(" · ") - : ""; - -/** 줄 비교 표 하나 */ -function compareTable(c: SangeunCompare): HTMLElement { - const rows = c.줄.map((x) => [ - `${x.갈래} ${x.이름}${x.규격 ? ` ${x.규격}` : ""}`, - `${num(x.엑셀)} ${x.단위 ?? ""}`.trim(), - `${num(x.기초)} ${x.단위 ?? ""}`.trim(), - createBadge(L(LOOK[x.꼴].key), LOOK[x.꼴].tone), - ]); - return createTable( - [ - { label: L("M02_SangeunColName") }, - { label: L("M02_CmpColExcel"), width: "112px", align: "end" }, - { label: L("M02_CmpColBase"), width: "112px", align: "end" }, - { label: L("M02_CmpColLook"), width: "84px" }, - ], - rows, - { density: "data", empty: L("M02_CmpNone") }, - ); -} - -/** - * 고름 대기 후보 줄 비교 칸 — 앞 후보 줄 비교를 후보 단추로 바꿔 봄 · `show(로직)` 으로 밖(고르기 칸)이 바꿈. - * 줄 비교 있는 후보가 하나도 없으면 null. - */ -export function compareView( - cands: SangeunCand[], -): { root: HTMLElement; show: (로직: string) => void } | null { - const has = cands.filter((c) => c.줄비교); - if (!has.length) return null; - const root = el("div", { className: "m02-hopyo__cmp", attrs: { "data-cmp": "" } }); - const body = el("div"); - const seg = createSegment({ - options: has.map((c, i) => ({ value: c.로직, label: `${i + 1}. ${c.이름}`, title: c.이름 })), - value: has[0]!.로직, - size: 24, - ariaLabel: L("M02_CmpTitle").replace("{name}", ""), - onChange: (v) => paint(v), - }); - const paint = (로직: string): void => { - const c = has.find((x) => x.로직 === 로직) ?? has[0]!; - const cmp = c.줄비교!; - body.dataset.cmpOf = c.로직; - const lost = cmp.못옮김.length; - body.replaceChildren( - createText( - [ - L("M02_CmpTitle").replace("{name}", c.이름), - compareBrief(cmp), - lost ? L("M02_CmpLost").replace("{n}", String(lost)) : "", - ] - .filter(Boolean) - .join(" · "), - "muted", - 12, - ), - compareTable(cmp), - ); - }; - root.append(seg.root, body); - paint(has[0]!.로직); - return { - root, - show: (로직) => { - if (!has.some((c) => c.로직 === 로직)) return; - seg.set(로직); - paint(로직); - }, - }; -} +import { t as L } from "@ui/ui_template_locale"; +import { formatPrice } from "../common_util/common_util_money"; +import type { ElemCand } from "./M02_MasterTemplete_Hopyo_Api"; /** 요소 후보 단추 줄(셋까지) — 누르면 `pick(후보)` */ export function elemChoices(cands: ElemCand[], pick: (c: ElemCand) => void): HTMLElement | null { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Lost.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Lost.ts index 620e3b6de..0b52cfeef 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Lost.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Lost.ts @@ -1,180 +1,93 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Lost.ts - * 단가산출 못옮김 잇기 창(PLAN 729-3 · 729-4 이음 · 요구서 내역19 2장 3 · 4) — 실무 블록을 SK 로 옮길 때 우리 M01 에 - * 바로 못 옮긴 엑셀 줄(노임 · 기계 · 자재)을 줄마다 M01 요소로 이음 → SK 더한줄(엑셀 수량) · [뺌] = 놓음(까닭 남김). - * · 고름 = 캐시 `m02-hopyo-lost:{층}:{주인}:{키}:{번}` `{id: 잇기}` — 서버는 부를 때마다 그 블록 잇기 전부로 다시 셈 - * (앞 잇기 안 남음) → [잇기 만들기] 는 캐시 모두를 보냄 · 바탕 = 그 SK 문서 바탕(로직 · 입력값 · 환산 그대로) - * · 줄마다 M01 요소 후보 셋 단추(746-2 · 서버 `후보`) — 누르면 그 줄에 이음 고름(찾기와 같은 길) - * · 남은 못옮김 = 서버 답 그대로 표 · 0 이면 그 SK 를 쓰는 일위대가 줄이 이어짐(남으면 만들기 409) - * 계산은 서버 — 화면은 고름 · 보이기만 · 저장은 [잇기 만들기] 누를 때만. + * 단가산출 줄 찾기 단추 · 옛 못옮김 창(PLAN 729-4 · 778-5 D-5). + * · `lineFind` = 줄 묶음대로 찾아 넣기 창(`Find_Pick` 탭 하나 · 노임 = 인력 · 기계 = 기계 · 자재 = 재료) → + * 고름 `{노임|기계|자재: 키}` — 마법사 단가산출 줄 표(`Hopyo_SkLines`)가 씀 + * · `openLost` = 옛 SK(기초 + 엑셀 차이)의 남은 못옮김 보기만 — 잇기는 마법사 단가산출 칸에서 그 블록 줄 고름 → + * [확인] → [확인한 것 옮기기](서버 778-5 D-3 · 옛 `바탕` 몸 걷음) * ========================================================================== */ -import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createButton, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; import { createTable } from "@ui/ui_template_table"; import { createText } from "@ui/ui_template_text"; -import { readTemplate } from "./M02_MasterTemplete_Api_Fetch"; import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; -import { - makeSangeun, - type HopyoScope, - type SangeunBuilt, - type SangeunLink, - type SangeunLost, -} from "./M02_MasterTemplete_Hopyo_Api"; -import { elemChoices } from "./M02_MasterTemplete_Hopyo_Compare"; +import type { HopyoScope, SangeunBuilt, SangeunLost } from "./M02_MasterTemplete_Hopyo_Api"; +import type { SkPick } from "./logic/M02_MasterTemplete_Hopyo_SkPick"; -const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); -const NONE = "우리에 없음"; +const KIND = { 노임: "labor", 기계: "machine", 자재: "material" } as const; -export const lostKey = (scope: HopyoScope, key: string, no: number): string => - `m02-hopyo-lost:${scope.layer}:${scope.owner ?? ""}:${key}:${no}`; - -/** 캐시 잇기 `{id: 잇기}` */ -export function loadLinks(k: string): Record { - try { - const got = JSON.parse(sessionStorage.getItem(k) ?? "{}") as unknown; - return got && typeof got === "object" ? (got as Record) : {}; - } catch { - return {}; - } +/** 줄 찾기 단추 — 묶음 갈래 탭 하나 · 고르면 `done({갈래: 키})` */ +export function lineFind(opt: { + scope: HopyoScope; + 갈래: "노임" | "기계" | "자재"; + 이름: string; + 규격?: string; + done: (p: SkPick) => void; +}): HTMLButtonElement { + const go = createButton({ + label: opt.갈래 === "기계" ? L("M02_LeftMachine") : L("M02_LeftFind"), + size: 24, + variant: "ghost", + onClick: () => + openFindPicker({ + layer: opt.scope.layer, + owner: opt.scope.owner, + title: `${opt.이름} ${opt.규격 ?? ""}`.trim(), + kinds: [KIND[opt.갈래]], + query: opt.이름.split(/[\s(]/)[0] ?? "", + logic: () => showToast(L("M02_GridLinkNoLogic"), "info"), + element: (e) => opt.done({ [opt.갈래]: e.코드 } as SkPick), + machine: (e) => opt.done({ 기계: e.코드 }), + }), + }); + go.dataset.skFind = opt.갈래; + return go; } -const saveLinks = (k: string, v: Record): void => - sessionStorage.setItem(k, JSON.stringify(v)); - -/** 고른 것 글 — 키 또는 뺌 */ -const linkText = (x: SangeunLink | undefined): string => - !x ? "" : x.뺌 ? `${L("M02_LostDrop")} · ${x.뺌}` : (x.노임 ?? x.기계 ?? x.자재 ?? ""); export interface LostOptions { scope: HopyoScope; key: string; /** 블록 번(단산N) */ 번: number; - /** 그 블록 SK 키 — 바탕을 읽음 */ + /** 그 블록 SK 키 */ sk: string; 이름?: string; lost: SangeunLost[]; - /** 잇기 만든 뒤(서버 답) */ + /** 옛 꼴 그대로(부르는 쪽) — 보기만이라 안 부름 */ done?: (got: SangeunBuilt) => void; } -/** 못옮김 잇기 창 */ +/** 옛 못옮김 창 — 보기만 · 잇기 = 마법사 단가산출 칸 */ export function openLost(opt: LostOptions): void { - const k = lostKey(opt.scope, opt.key, opt.번); - let lost = opt.lost; openModal({ title: `${L("M02_LostTitle")} — 단산${opt.번} ${opt.이름 ?? ""}`.trim(), closeLabel: L("M02_Close"), width: "960px", dialogClass: "m02-modal", - mount: (body, _close, actions) => { - const list = el("div", { attrs: { "data-lost": "" } }); - const put = (id: string, link: SangeunLink | null): void => { - const all = loadLinks(k); - if (link) all[id] = link; - else delete all[id]; - saveLinks(k, all); - draw(); - }; - const make = createButton({ - label: L("M02_LostMake"), - variant: "filled", - onClick: () => void run(), - }); - make.dataset.lostMake = ""; - actions.append(make); - body.append(createText(L("M02_LostHint"), "hint", 14), list); - - async function run(): Promise { - const links = Object.values(loadLinks(k)); - if (!links.length) return showToast(L("M02_WizSkPickFirst"), "warning"); - make.disabled = true; - try { - const doc = (await readTemplate(opt.scope.layer, "sangeun", opt.sk, null, opt.scope.owner)) - .문서 as { 바탕?: { 로직?: string } | null; 입력값?: Record; 환산?: { 계수?: number } }; // prettier-ignore - const pick = { - ...(doc.바탕?.로직 ? { 로직: doc.바탕.로직, 입력값: doc.입력값 ?? {} } : {}), - ...(doc.환산?.계수 ? { 환산: { 계수: doc.환산.계수 } } : {}), - 잇기: links, - }; - const got = await makeSangeun(opt.scope, opt.key, { [opt.번]: pick }); - lost = got.만듦.find((x) => x.번 === opt.번)?.못옮김 ?? []; - showToast(L("M02_LostLeft").replace("{n}", String(lost.length)), lost.length ? "warning" : "success"); // prettier-ignore - opt.done?.(got); - draw(); - } catch (error) { - showToast(why(error), "error"); - } finally { - make.disabled = false; - } - } - - function draw(): void { - const all = loadLinks(k); - list.dataset.lost = String(lost.length); - const rows = lost.map((x) => { - const kind = x.갈래; - const find = createButton({ - label: kind === "기계" ? L("M02_LeftMachine") : L("M02_LeftFind"), - size: 24, - variant: "ghost", - onClick: () => - // 736-2 줄 갈래대로 탭 하나(노임 = 인력 · 기계 = 기계 · 자재 = 재료) → 이음 칸 `노임` · `기계` · `자재` - openFindPicker({ - layer: opt.scope.layer, - owner: opt.scope.owner, - title: `${x.이름} ${x.규격 ?? ""}`.trim(), - kinds: [kind === "노임" ? "labor" : kind === "기계" ? "machine" : "material"], - query: x.이름.split(/[\s(]/)[0] ?? "", - logic: () => showToast(L("M02_GridLinkNoLogic"), "info"), - element: (e) => put(x.id, { id: x.id, [kind]: e.코드 }), - machine: (e) => put(x.id, { id: x.id, 기계: e.코드 }), - }), - }); - const drop = createButton({ - label: NONE, - size: 24, - variant: "ghost", - onClick: () => put(x.id, { id: x.id, 뺌: NONE }), - }); - drop.title = L("M02_LostDropTip"); - const clear = createButton({ - label: L("M02_LostClear"), - size: 24, - variant: "link", - onClick: () => put(x.id, null), - }); - return [ - kind, - x.이름, - x.규격 ?? "", - `${x.수량 ?? ""} ${x.단위 ?? ""}`.trim(), - x.글 ?? "", - elemChoices(x.후보 ?? [], (c) => put(x.id, { id: x.id, [kind]: c.키 })) ?? "", - linkText(all[x.id]), - el("div", { className: "ui-toolbar", children: [find, drop, ...(all[x.id] ? [clear] : [])] }), // prettier-ignore - ]; - }); - list.replaceChildren( - createTable( - [ - { label: L("M02_LeftKind"), width: "56px" }, - { label: L("M02_SangeunColName") }, - { label: L("M02_LeftSpec") }, - { label: L("M02_SangeunColQty"), width: "90px" }, - { label: L("M02_LeftWhy") }, - { label: L("M02_ElemTitle") }, - { label: L("M02_LeftPicked") }, - { label: L("M02_LeftPick"), width: "220px" }, - ], - rows, - { density: "data", empty: L("M02_LostZero") }, - ), - ); - } - draw(); + mount: (body) => { + const rows = opt.lost.map((x) => [ + x.갈래, + x.이름, + x.규격 ?? "", + `${x.수량 ?? ""} ${x.단위 ?? ""}`.trim(), + x.글 ?? "", + ]); + body.append( + createText(L("M02_LostHint").replace("{n}", String(opt.번)), "hint", 14), + createTable( + [ + { label: L("M02_LeftKind"), width: "56px" }, + { label: L("M02_SangeunColName") }, + { label: L("M02_LeftSpec") }, + { label: L("M02_SangeunColQty"), width: "90px" }, + { label: L("M02_LeftWhy") }, + ], + rows, + { density: "data", empty: L("M02_LostZero") }, + ), + ); }, }); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SkApi.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SkApi.ts new file mode 100644 index 000000000..a27bea442 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SkApi.ts @@ -0,0 +1,21 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_SkApi.ts + * 마법사 단가산출 길(PLAN 778-5 D-5 · 서버 D-3 `M02_Hopyo_Sangeun.build`) — `POST …/hopyo/{키}/sangeun` + * · 몸 `{확인: [{번, 고름?: {차례: 고름}}]}` — 빈 확인 = 판정만(안 만듦) · 확인한 블록만 단위 SK + * · 답 = 블록마다 줄 판정 · 만듦 · 못만듦(꼴 = `logic/M02_MasterTemplete_Hopyo_SkPick`) + * ========================================================================== */ + +import { call } from "./M02_MasterTemplete_Api_Fetch"; +import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; +import type { SkJudged, SkSure } from "./logic/M02_MasterTemplete_Hopyo_SkPick"; + +/** 줄 판정 · 확인한 블록 옮기기 — 맞춤 셈 전 409 · 틀린 고름 400 · 없는 블록 404 */ +export const judgeSangeun = ( + scope: HopyoScope, + key: string, + 확인: SkSure[] = [], +): Promise => + call(at(scope, `/hopyo/${encodeURIComponent(key)}/sangeun`), { + method: "POST", + body: JSON.stringify({ 확인 }), + }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SkLines.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SkLines.ts new file mode 100644 index 000000000..88cc8a3bb --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SkLines.ts @@ -0,0 +1,163 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_SkLines.ts + * 마법사 단가산출 블록 펼침 = 줄 표(PLAN 778-5 D-5 · 요구 623 · 624) — 서버 줄 판정(D-3) + 캐시 고름 겹침. + * · 칸 = 묶음 · 이름 · 규격 · 매개값 · 상태 칩(이어짐 · 고를 것 · 못 이음 · 밖 · 뺌 — 엑셀 보기 칠과 한 규칙 + * `SheetMap.unitTone`) + 꼬리표(「기본 — 건설업 작업반장」 · 「내가 고름」) · 방법 글 · 우리 금액 · 엑셀 금액 · 차(참고) + * · 고름(밖 줄 빼고) = 서버 후보 단추 · [찾기](`Hopyo_Lost.lineFind` = 찾아 넣기 창) · 율 = 기준 고르기 · + * 하위 · 값 = 뺌만 · [뺌] + 까닭 · [고름 지움] — 고름은 부르는 쪽 캐시(`onPick`) + * · [보기] = 엑셀 보기 단가산출 상세 그 행(779-4 뛰기) + * 금액 · 판정은 서버 — 고른 줄 금액은 [확인한 것 옮기기] 뒤 서버가 다시 셈(그 전 「—」). + * ========================================================================== */ + +import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { createTable, type Cell } from "@ui/ui_template_table"; +import { formatNumber, formatPrice } from "../common_util/common_util_money"; +import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; +import { lineFind } from "./M02_MasterTemplete_Hopyo_Lost"; +import { unitTone, type Tone } from "./logic/M02_MasterTemplete_Hopyo_SheetMap"; +import { + BASES, + GROUP, + candPick, + candText, + dropOnly, + lineView, + pickable, + type SkBlock, + type SkLine, + type SkPick, +} from "./logic/M02_MasterTemplete_Hopyo_SkPick"; + +const BADGE: Record = { + success: "success", + warning: "warning", + info: "info", + danger: "danger", + muted: "neutral", +}; +const FIND = { L: "노임", X: "기계", M: "자재" } as const; +const won = (v: number | null | undefined): string => + v === null || v === undefined ? "—" : formatPrice(v); + +export interface SkLinesOptions { + scope: HopyoScope; + block: SkBlock; + picks: Record; + /** 고름 넣기 · 지움(null) */ + onPick: (차례: number, p: SkPick | null) => void; + /** 엑셀 보기 그 행 */ + onSheet?: (행: number) => void; +} + +/** 상태 칩 + 꼬리표 */ +function stateCell(line: SkLine, pick: SkPick | undefined): HTMLElement { + const v = lineView(line, pick); + const diff = !v.mine && line.차 !== null && line.차 !== undefined && Math.abs(line.차) >= 1; + const tone = v.상태 === "뺌" ? "muted" : unitTone(v.상태, diff); + const chip = createBadge(v.상태, BADGE[tone]); + chip.dataset.skState = v.상태; + const tags = v.꼬리표.map((t) => { + const tag = createBadge(t, t === "내가 고름" ? "accent" : "neutral"); + tag.dataset.skTag = t; + return tag; + }); + return el("div", { className: "ui-toolbar", children: [chip, ...tags] }); +} + +/** 고름 칸 — 후보 · 찾기 · 율 기준 · 뺌 + 까닭 · 지움 */ +function pickCell(opt: SkLinesOptions, line: SkLine, pick: SkPick | undefined): HTMLElement { + const box = el("div", { className: "ui-toolbar", attrs: { "data-sk-pick": String(line.차례) } }); + if (!pickable(line)) return box; + const put = (p: SkPick | null): void => opt.onPick(line.차례, p); + if (!dropOnly(line)) { + for (const c of line.후보.slice(0, 3)) { + const p = candPick(line.묶음, c); + if (!p) continue; + const b = createButton({ label: candText(c), size: 24, variant: "ghost", onClick: () => put(p) }); // prettier-ignore + b.dataset.skCand = String(line.차례); + b.title = c.키 ?? c.자재 ?? c.글 ?? ""; + box.append(b); + } + const kind = FIND[line.묶음 as keyof typeof FIND]; + if (kind) box.append(lineFind({ scope: opt.scope, 갈래: kind, 이름: line.이름 ?? "", 규격: line.규격, done: put })); // prettier-ignore + if (line.묶음 === "R") { + const base = createSelectField({ + options: [{ value: "", text: L("M02_SkBasePick") }].concat( + BASES.map((b) => ({ value: b, text: b })), + ), + compact: true, + size: 24, + ariaLabel: L("M02_SkBasePick"), + onChange: (v) => v && put({ 기준: v }), + }); + box.append(base.root); + } + } + const why = createInputField({ compact: true, size: 24, placeholder: L("M02_SkDropWhy") }); + why.input.dataset.skWhy = String(line.차례); + const drop = createButton({ + label: L("M02_SkDrop"), + size: 24, + variant: "ghost", + onClick: () => put({ 뺌: why.input.value.trim() || L("M02_SkDropDefault") }), + }); + drop.dataset.skDrop = String(line.차례); + box.append(why.root, drop); + if (pick) { + const clear = createButton({ label: L("M02_SkClear"), size: 24, variant: "link", onClick: () => put(null) }); // prettier-ignore + clear.dataset.skClear = String(line.차례); + box.append(clear); + } + return box; +} + +/** 블록 줄 표 */ +export function skLineTable(opt: SkLinesOptions): HTMLElement { + const rows: Cell[][] = opt.block.줄.map((line) => { + const pick = opt.picks[String(line.차례)]; + const v = lineView(line, pick); + const param = Object.entries(line.매개값) + .map(([k, n]) => `${k} ${formatNumber(n)}`) + .join(" · "); + const how = [line.방법, v.글].filter(Boolean).join(" · "); + const sheet = + line.행 !== null && opt.onSheet + ? createButton({ label: L("M02_SkSheet"), size: 24, variant: "link", onClick: () => opt.onSheet!(line.행!) }) // prettier-ignore + : ""; + if (sheet) sheet.dataset.skSheet = String(line.행); + return [ + GROUP[line.묶음] ?? line.묶음, + line.이름 ?? "", + line.규격, + param, + stateCell(line, pick), + how, + v.mine ? "—" : won(line.우리금액), + won(line.엑셀금액), + v.mine || line.차 === null || line.차 === undefined ? "—" : won(line.차), + pickCell(opt, line, pick), + sheet, + ]; + }); + const table = createTable( + [ + { label: L("M02_SkColGroup"), width: "48px" }, + { label: L("M02_SangeunColName") }, + { label: L("M02_LeftSpec") }, + { label: L("M02_SkColParam") }, + { label: L("M02_SkColState") }, + { label: L("M02_SkColHow") }, + { label: L("M02_SkColOurs"), align: "end" }, + { label: L("M02_SkColExcel"), align: "end" }, + { label: L("M02_SkColDiff"), align: "end" }, + { label: L("M02_SkColPick") }, + { label: L("M02_SkSheet"), width: "52px" }, + ], + rows, + { density: "data", empty: L("M02_SkNoLines") }, + ); + table.dataset.skLines = String(opt.block.번); + return table; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts index 019a69dac..a1ce89673 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts @@ -5,7 +5,7 @@ * (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 새로 올림) · 작업 중 파일 이어서 * ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] → * 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 · - * 단가산출 체크면 단가산출 칸(`Hopyo_WizardSk` — 기초 · SK · 고름 대기 기초 고름 · 729-3) · + * 단가산출 체크면 단가산출 칸(`Hopyo_WizardSk` — 블록 줄 고름 · [확인] · 확인한 것만 SK · 778-5 D-5) · * 안 이은 줄 띠(`Hopyo_Left` — 안 만든 호표의 남은 줄 · [고르기] = 줄마다 이을 것 · 729-3) * ③ [끝내기] = 남은 줄 다시 셈 → 있으면 막음(띠 · 남은 수 · 표) · 없으면 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · * 만든 일위대가 · 단가산출(엑셀 사본 걷고 출처만) · 별칭 장부 남음 @@ -38,7 +38,7 @@ import { type LeftMap, type LeftScope, } from "./M02_MasterTemplete_Hopyo_Left"; -import { sangeunStep } from "./M02_MasterTemplete_Hopyo_WizardSk"; +import { sangeunStep, type SkStep } from "./M02_MasterTemplete_Hopyo_WizardSk"; import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer"; const LAYERS: { layer: ManageLayer; text: string }[] = [ @@ -211,6 +211,7 @@ export function buildHopyoWizard(): HopyoWizard { const band = el("div", { attrs: { "data-hopyo-left": "" } }); let nos: number[] = []; let map: LeftMap | null = null; + let sk: SkStep | null = null; // 단가산출 칸 — 막힘 글 「확인 안 한 블록 n · 고를 줄 m」 const pick = createButton({ label: L("M02_LeftOpen"), variant: "ghost", @@ -222,8 +223,10 @@ export function buildHopyoWizard(): HopyoWizard { function paint(got: LeftMap | null): void { map = got; const n = got ? leftCount(got) : null; + const w = sk?.count(); + const skLeft = w?.블록 ? `${L("M02_SkLeft").replace("{n}", String(w.블록)).replace("{m}", String(w.줄))} · ` : ""; // prettier-ignore finish.disabled = !!n?.줄; - finish.title = n?.줄 ? L("M02_LeftBlocked") : ""; + finish.title = n?.줄 ? skLeft + L("M02_LeftBlocked") : ""; band.dataset.left = n ? String(n.줄) : ""; band.replaceChildren( createBanner({ @@ -231,7 +234,7 @@ export function buildHopyoWizard(): HopyoWizard { text: !n ? L("M02_LeftUnknown") : n.줄 - ? `${L("M02_LeftBand").replace("{n}", String(n.줄)).replace("{h}", String(n.호표))} · ${L("M02_LeftBlocked")}` + ? `${skLeft}${L("M02_LeftBand").replace("{n}", String(n.줄)).replace("{h}", String(n.호표))} · ${L("M02_LeftBlocked")}` : L("M02_LeftZero"), actions: [pick], }), @@ -248,14 +251,12 @@ export function buildHopyoWizard(): HopyoWizard { } const back = createButton({ label: "처음으로", variant: "link", onClick: () => void first() }); back.title = "작업은 그대로 보관 — 처음 화면 「작업 중」에서 이어서"; + if (wantSangeun) sk = sangeunStep(scope, file.키, file.파일, () => paint(map)); const wrong = file.어긋남 ? ` · 검산 어긋남 ${file.어긋남}` : ""; body.replaceChildren( createText(`${file.파일} · ${textOf(layer)} 층 · 호표 ${file.호표수}${wrong}`, "muted", 14), ...(wantSangeun - ? [ - createPanelHead({ title: L("M02_WizSkHead"), level: 4, divider: true }).root, - sangeunStep(scope, file.키).root, - ] + ? [createPanelHead({ title: L("M02_WizSkHead"), level: 4, divider: true }).root, sk!.root] : []), createPanelHead({ title: L("M02_WizIlwiHead"), level: 4, divider: true }).root, createText( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts index 5bdf80fcf..6c569d8a8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts @@ -1,238 +1,250 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_WizardSk.ts - * 일위대가 불러오기 마법사 ② 단가산출 단계(PLAN 729-3 · 요구서 내역19 1장 7 · 2장 3) — - * · [단가산출 만들기] = 서버 `sangeun`(산근 블록마다 매칭 → 같으면 기초 · 다르면 SK = 기초 + 엑셀 차이 · 맞는 기초 없음 = 고름 대기) - * · 만든 줄 표 = 블록 · 이름 · 키 · 갈래(기초 · SK 차이 있음) · 이미 만든 블록은 「그대로」 수만 - * · 고름 대기 표 = 블록마다 후보(판정 · 글) 고르기 칸 → [고른 기초로 만들기](바탕 = 고른 기초 · 엑셀 품 견줘 SK) · - * 단위 성격 다른 후보(729-4 `계수`) = 계수 칸(가 1 = 나 몇) 함께 → `환산 {계수}` - * 후보마다 줄 비교 표(746-2 · `Hopyo_Compare` · 서버 `줄비교`) — 고르기 칸 아래 · 후보 단추로 바꿔 봄 - * · 못옮김(729-4) = 만든 줄마다 수 · [잇기] = 못옮김 잇기 창(`Hopyo_Lost`) · 만든 줄은 이 칸이 모아 둠(누를 때마다 더함) · - * 이미 만든 SK 의 남은 못옮김(729-5 `남은못옮김`)도 같은 표 - * · 같은 내용(733-1) = 갈래 칸 꼬리표 「같은 것 씀」(이 층 같은 SK 를 씀) · 「위 층에 같은 것 — 연결 권함」(회사 · 마스터 · 알림만) · - * 수는 요약 글에 - * · 별칭 제안 = 이 층 별칭 장부에 남음 · 모두에게 쓰이려면 시스템 관리자가 M01 › 별칭 장부에서 승인(링크 · 716 길 그대로) - * 계산 · 판정은 서버 — 화면은 보이기 · 고름만 · 자동 저장 없음(누를 때만 서버). + * 일위대가 불러오기 마법사 ② 단가산출 칸(PLAN 778-5 D-5 · 요구 621 · 622 · 623 · 624) — 모두 줄로만 · 자동 없음. + * · 열면 서버 줄 판정(`POST …/sangeun` 빈 확인 = 안 만듦 · D-3) → 블록 표: 단산N · 이름 · 판정(「옮길 수 있음」 · + * 「고를 줄 n」 — 캐시 고름 겹침) · 우리 금액 · 엑셀 금액(참고) · 근거 꼬리표(가까운 기초 이름 · 참고) · 만든 SK · + * [확인] 칩(고를 줄 0 일 때만 켜짐) · [줄] 펼침 = 줄 표(`Hopyo_SkLines`) · [보기] = 엑셀 보기 그 블록 첫 행 + * · 고름 · 확인 = 캐시 `m02-hopyo-sk:{층}:{주인}:{키}`(`SkPick` 꼴) — 누를 때 서버에 안 감 · 자동 저장 없음 + * · [확인한 것 옮기기 (n)] = 몸 `{확인: [{번, 고름}]}` → 서버가 M01 지금 값으로 다시 셈 · 단위 SK(확인 안 한 블록 안 만듦) · + * 만든 블록은 확인에서 뺌 · 못만듦 = 띠(까닭 그대로) · 「모두 확인」 단추 없음 + * · `count()` = 아직 안 옮긴 블록 · 고를 줄(마법사 [끝내기] 막힘 글) * ========================================================================== */ -import { ROUTES } from "@config/config_frontend"; import { createBadge } from "@ui/ui_template_badge"; -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; +import { createBanner } from "@ui/ui_template_banner"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; -import { createTable } from "@ui/ui_template_table"; +import { tableRow, tableShell, type Column } from "@ui/ui_template_table"; import { createText } from "@ui/ui_template_text"; +import { createToggle } from "@ui/ui_template_toggle"; +import { formatPrice } from "../common_util/common_util_money"; +import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; +import { judgeSangeun } from "./M02_MasterTemplete_Hopyo_SkApi"; +import { skLineTable } from "./M02_MasterTemplete_Hopyo_SkLines"; import { - makeSangeun, - type HopyoScope, - type SangeunBuilt, - type SangeunPick, -} from "./M02_MasterTemplete_Hopyo_Api"; -import { compareBrief, compareView } from "./M02_MasterTemplete_Hopyo_Compare"; -import { openLost } from "./M02_MasterTemplete_Hopyo_Lost"; - -type Made = SangeunBuilt["만듦"][number]; + GOOD, + canConfirm, + picksOf, + readDraft, + setPick, + setSure, + sureBody, + tidy, + verdictOf, + waiting, + type SkBlock, + type SkDraft, + type SkJudged, +} from "./logic/M02_MasterTemplete_Hopyo_SkPick"; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const won = (v: number | null | undefined): string => + v === null || v === undefined ? "—" : formatPrice(v); -/** 고름 대기 후보 글 — 「이름 [단위] · 판정 · 글」 */ -export const candLabel = (c: { 이름: string; 단위: string; 판정: string; 글: string }): string => - `${c.이름} [${c.단위}] · ${c.판정}${c.글 && c.글 !== c.판정 ? ` · ${c.글}` : ""}`; +export const skDraftKey = (scope: HopyoScope, key: string): string => + `m02-hopyo-sk:${scope.layer}:${scope.owner ?? ""}:${key}`; -/** 만든 갈래 꼬리표 — 기초 = 초록 · SK = 차이 있음 */ -const kindBadge = (kind: string): HTMLElement => - kind === "SK" - ? createBadge(L("M02_WizSkDiff"), "accent") - : createBadge(L("M02_WizSkBase"), "success"); +export interface SkStep { + root: HTMLElement; + /** 아직 안 옮긴 블록 · 고를 줄 — 판정 전 null */ + count: () => { 블록: number; 줄: number } | null; +} -/** ② 단가산출 단계 칸 — `root` 를 마법사 본 칸에 붙임 */ -export function sangeunStep(scope: HopyoScope, key: string): { root: HTMLElement } { +/** ② 단가산출 칸 — `파일` = 엑셀 보기 창 머리 · `onChange` = 판정 · 옮김 뒤(끝내기 글) */ +export function sangeunStep( + scope: HopyoScope, + key: string, + 파일: string, + onChange?: () => void, +): SkStep { + const dk = skDraftKey(scope, key); + const load = (): SkDraft => readDraft(sessionStorage.getItem(dk)); + const save = (d: SkDraft): void => sessionStorage.setItem(dk, JSON.stringify(d)); + let got: SkJudged | null = null; + let fails: SkJudged["못만듦"] = []; + const open = new Set(); // 펼친 블록 const out = el("div", { className: "m02-hopyo", attrs: { "data-wiz-sk": "" } }); - const make = createButton({ - label: L("M02_WizSkMake"), - variant: "filled", - onClick: () => void run(), - }); - make.title = L("M02_WizSkMakeTip"); - make.dataset.hopyoSangeunMake = ""; + const again = createButton({ label: L("M02_SkJudge"), variant: "ghost", size: 28, onClick: () => void judge() }); // prettier-ignore + again.dataset.skJudge = ""; + const move = createButton({ label: L("M02_SkMove").replace("{n}", "0"), variant: "filled", size: 28, disabled: true, onClick: () => void run() }); // prettier-ignore + move.title = L("M02_SkMoveTip"); + move.dataset.skMove = ""; const root = el("div", { className: "m02-hopyo", - children: [el("div", { className: "ui-toolbar", children: [make] }), out], + children: [ + createText(L("M02_SkHint"), "hint", 14), + out, + el("div", { className: "ui-toolbar", children: [move, again] }), + ], }); - const made = new Map(); // 이 칸에서 만든 줄(누를 때마다 더함) - const reused = new Set(); // 733-1 같은 것 씀 블록 - const above = new Map(); // 733-1 위 층 같은 것 「층 키」 - async function run(picks?: SangeunPick): Promise { - make.disabled = true; + const sheet = (행: number): void => + void import("./M02_MasterTemplete_Hopyo_Sheet") + .then((m) => + m.openHopyoSheet({ scope, key, 파일, canWrite: true, target: scope, 단산행: 행 }), + ) + .catch((error: unknown) => showToast(why(error), "error")); + + async function judge(): Promise { + again.disabled = true; + out.replaceChildren(createText(L("M02_SkJudging"), "muted", 14)); try { - const got = await makeSangeun(scope, key, picks); - draw(got); - showToast(summary(got), got.고름대기.length ? "warning" : "success"); + got = await judgeSangeun(scope, key); + fails = []; + draw(); + } catch (error) { + out.replaceChildren(createBanner({ tone: "warning", text: why(error) })); + } finally { + again.disabled = false; + onChange?.(); + } + } + + async function run(): Promise { + if (!got) return; + const body = sureBody(load(), got.판정); + if (!body.length) return showToast(L("M02_SkSureFirst"), "warning"); + move.disabled = true; + try { + const next = await judgeSangeun(scope, key, body); + const made = new Set(next.만듦.map((x) => x.번)); + const d = load(); + save({ ...d, 확인: d.확인.filter((n) => !made.has(n)) }); + got = next; + fails = next.못만듦; + showToast( + L("M02_SkMoved") + .replace("{made}", String(made.size)) + .replace("{bad}", String(fails.length)), + fails.length ? "warning" : "success", + ); + draw(); } catch (error) { showToast(why(error), "error"); + draw(); } finally { - make.disabled = false; + onChange?.(); } } - const summary = (got: SangeunBuilt): string => - L("M02_WizSkSummary") - .replace("{b}", String(got.블록)) - .replace("{base}", String(got.기초)) - .replace("{sk}", String(got.SK)) - .replace("{keep}", String(got.그대로)) - .replace("{wait}", String(got.고름대기.length)) + - (got.같은것씀?.length || got.위층같음?.length - ? L("M02_WizSkSameSum") - .replace("{same}", String(got.같은것씀?.length ?? 0)) - .replace("{above}", String(new Set(got.위층같음?.map((x) => x.번)).size)) - : ""); + const cols: Column[] = [ + { label: L("M02_SkColBlock"), width: "72px" }, + { label: L("M02_SangeunColName") }, + { label: L("M02_SangeunColUnit"), width: "56px" }, + { label: L("M02_SkColVerdict") }, + { label: L("M02_SkColOurs"), align: "end" }, + { label: L("M02_SkColExcel"), align: "end" }, + { label: L("M02_SkColBasis") }, + { label: L("M02_SkColMade"), width: "110px" }, + { label: L("M02_SkSure"), width: "72px" }, + { label: "", width: "96px" }, + ]; - /** 갈래 칸 — 갈래 + 같은 것 씀 · 위 층 같은 것 꼬리표 */ - const kindCell = (x: Made): HTMLElement => { - const tags: HTMLElement[] = [kindBadge(x.갈래)]; - if (reused.has(x.번)) { - const tag = createBadge(L("M02_WizSkSame"), "info"); - tag.title = L("M02_WizSkSameTip"); - tag.dataset.wizSame = String(x.번); - tags.push(tag); - } - const up = above.get(x.번); - if (up?.length) { - const tag = createBadge(L("M02_WizSkAbove"), "warning"); - tag.title = up.join(" · "); - tag.dataset.wizAbove = String(x.번); - tags.push(tag); - } - return el("div", { className: "ui-toolbar", children: tags }); - }; - - /** 못옮김 칸 — 수 + [잇기](남은 것 있을 때) */ - const lostCell = (x: Made, got: SangeunBuilt): HTMLElement => { - const n = x.못옮김?.length ?? 0; - if (!n) return el("span", { text: "0" }); - const go = createButton({ - label: `${L("M02_LostOpen")} (${n})`, + /** 블록 한 줄 + 펼친 줄 표 */ + function blockRows(b: SkBlock, d: SkDraft): HTMLTableRowElement[] { + const picks = picksOf(d, b.번); + const word = verdictOf(b, picks); + const verdict = createBadge(word, word === GOOD ? "success" : "info"); + verdict.dataset.skVerdict = String(b.번); + const basis = b.근거 ? createBadge(`${L("M02_SkBasisTag")} ${b.근거.이름 ?? b.근거.로직}`, "neutral") : "—"; // prettier-ignore + if (basis instanceof HTMLElement) basis.title = `${b.근거!.로직} · ${b.근거!.판정 ?? ""}`; + const made = b.만든 ? createBadge(b.만든, "success") : "—"; + const ok = canConfirm(b, picks); + const sure = createToggle({ + label: L("M02_SkSure"), + kind: "chip", size: 24, - onClick: () => - openLost({ scope, key, 번: x.번, sk: x.키, 이름: x.이름, lost: x.못옮김 ?? [], - done: (next) => draw({ ...got, 만듦: next.만듦, 별칭: got.별칭 }) }), // prettier-ignore - }); - go.dataset.wizLost = String(x.번); - return go; - }; - - function draw(got: SangeunBuilt): void { - for (const x of got.남은못옮김 ?? []) if (!made.has(x.번)) made.set(x.번, { ...x, 갈래: "SK" }); - for (const x of got.만듦) made.set(x.번, x); - for (const x of got.같은것씀 ?? []) reused.add(x.번); - for (const x of got.위층같음 ?? []) { - const at = x.층 === "system" ? L("M02_PickerSystem") : L("M02_PickerCompany"); - above.set(x.번, [...(above.get(x.번) ?? []), `${at} ${x.키}`]); - } - const table = createTable( - [ - { label: L("M02_WizSkBlock"), width: "80px" }, - { label: L("M02_SangeunColName") }, - { label: L("M02_WizSkKey"), width: "110px" }, - { label: L("M02_WizSkKind"), width: "260px" }, - { label: L("M02_LostCol"), width: "120px" }, - ], - [...made.values()] - .sort((a, b) => a.번 - b.번) - .map((x) => [`단산${x.번}`, x.이름, x.키, kindCell(x), lostCell(x, got)]), - { density: "data", empty: L("M02_WizSkNoneMade") }, - ); - const selects = new Map(); - const coefs = new Map(); - const wait = createTable( - [ - { label: L("M02_WizSkBlock"), width: "80px" }, - { label: L("M02_SangeunColName") }, - { label: L("M02_SangeunColUnit"), width: "64px" }, - { label: L("M02_LeftWhy") }, - { label: L("M02_LeftBase"), width: "62%" }, - ], - got.고름대기.map((x) => { - const pick = createSelectField({ - options: [{ value: "", text: L("M02_WizSkNoPick") }].concat( - x.후보.map((c) => ({ - value: c.로직, - text: candLabel(c) + (c.줄비교 ? ` · ${compareBrief(c.줄비교)}` : ""), - })), - ), - compact: true, - size: 24, - ariaLabel: `${L("M02_LeftBase")} 단산${x.번}`, - }); - pick.select.dataset.wizWait = String(x.번); - selects.set(x.번, pick.select); - const coef = createInputField({ type: "number", min: 0, compact: true, size: 24, - placeholder: L("M02_WizSkCoef") }); // prettier-ignore - coef.input.dataset.wizCoef = String(x.번); - const need = (): void => { - const c = x.후보.find((y) => y.로직 === pick.select.value)?.계수; - coef.root.hidden = !c; - coef.input.title = c ? `${c.가} 1 = ${c.나} ?` : ""; - }; - const cmp = compareView(x.후보); - pick.select.addEventListener("change", () => { - need(); - cmp?.show(pick.select.value); - }); - need(); - coefs.set(x.번, coef.input); - return [`단산${x.번}`, x.이름, x.단위 ?? "", x.까닭, el("div", { children: [el("div", { className: "ui-toolbar", children: [pick.root, coef.root] }), ...(cmp ? [cmp.root] : [])] })]; // prettier-ignore - }), - { density: "data", empty: L("M02_WizSkNoWait") }, - ); - const again = createButton({ - label: L("M02_WizSkRemake"), - variant: "filled", - size: 28, - onClick: () => { - const picks: SangeunPick = {}; - for (const [no, s] of selects) { - if (!s.value) continue; - const c = Number(coefs.get(no)?.value); - const need = got.고름대기.find((x) => x.번 === no)?.후보.find((y) => y.로직 === s.value)?.계수; // prettier-ignore - if (need && !(c > 0)) return showToast(L("M02_WizSkCoefNeed").replace("{n}", String(no)), "warning"); // prettier-ignore - picks[no] = { 로직: s.value, ...(need ? { 환산: { 계수: c } } : {}) }; - } - if (!Object.keys(picks).length) return showToast(L("M02_WizSkPickFirst"), "warning"); - void run(picks); + checked: d.확인.includes(b.번), + disabled: !ok, + onChange: (on) => { + save(setSure(load(), b, on)); + draw(); }, }); - again.dataset.wizRemake = ""; - const ledger = createButton({ - label: L("M02_WizSkLedger"), - variant: "link", - onClick: () => void window.open(`#/${ROUTES.M01_MASTER_DATA}`, "_blank"), + sure.root.dataset.skSure = String(b.번); + sure.root.title = ok ? L("M02_SkSureTip") : L("M02_SkSureOff"); + const lines = createButton({ + label: `${L("M02_SkOpen")} ${b.줄.length}`, + size: 24, + variant: open.has(b.번) ? "filled" : "ghost", + onClick: () => { + if (open.has(b.번)) open.delete(b.번); + else open.add(b.번); + draw(); + }, }); - out.replaceChildren( - createText(summary(got), "muted", 14), - table, - ...(got.고름대기.length - ? [ - createText(L("M02_WizSkWaitHint"), "hint", 14), - wait, - el("div", { className: "ui-toolbar", children: [again] }), - ] - : []), - el("div", { - className: "ui-toolbar", - children: [ - createText(L("M02_WizSkAlias").replace("{n}", String(got.별칭 ?? 0)), "muted", 14), - ledger, - ], - }), - ); + lines.dataset.skOpen = String(b.번); + const first = b.줄.find((l) => l.행 !== null)?.행 ?? null; + const view = first !== null ? createButton({ label: L("M02_SkSheet"), size: 24, variant: "link", onClick: () => sheet(first) }) : ""; // prettier-ignore + const head = tableRow(cols, [ + `단산${b.번}`, + b.이름 ?? "", + b.단위 ?? "", + verdict, + won(b.우리금액), + won(b.엑셀금액), + basis, + made, + sure.root, + el("div", { className: "ui-toolbar", children: [lines, view] }), + ]); + head.dataset.skBlock = String(b.번); + if (!open.has(b.번)) return [head]; + const table = skLineTable({ + scope, + block: b, + picks, + onPick: (at, p) => { + save(tidy(setPick(load(), b.번, at, p), got!.판정)); + draw(); + }, + onSheet: sheet, + }); + const sub = tableRow(cols, [{ content: table, colSpan: cols.length }]); + sub.className = "is-sub"; + return [head, sub]; } - return { root }; + function draw(): void { + if (!got) return; + const d = tidy(load(), got.판정); + save(d); + const blocks = got.판정; + const good = blocks.filter((b) => canConfirm(b, picksOf(d, b.번))).length; + const made = blocks.filter((b) => b.만든).length; + const sum = L("M02_SkSummary") + .replace("{b}", String(blocks.length)) + .replace("{ok}", String(good)) + .replace("{wait}", String(blocks.length - good)) + .replace("{made}", String(made)) + .replace("{sure}", String(d.확인.length)); + const { root: table, tbody } = tableShell(cols, { density: "data" }); + for (const b of blocks) tbody.append(...blockRows(b, d)); + const band = fails.length + ? [ + createBanner({ + tone: "warning", + text: L("M02_SkFailed").replace("{n}", String(fails.length)), + details: fails.map((x) => `단산${x.번} ${x.이름 ?? ""} — ${x.까닭}`).join("\n"), + }), + ] + : []; + out.replaceChildren( + createText(sum, "muted", 14), + ...band, + blocks.length ? table : createText(L("M02_SkNone"), "muted", 14), + ); + move.disabled = !d.확인.length; + move.querySelector(".ui-btn__label")!.textContent = L("M02_SkMove").replace( + "{n}", + String(d.확인.length), + ); + onChange?.(); + } + + void judge(); + return { + root, + count: () => (got ? waiting(load(), got.판정) : null), + }; } diff --git a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SkPick.ts b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SkPick.ts new file mode 100644 index 000000000..7b5da67b5 --- /dev/null +++ b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SkPick.ts @@ -0,0 +1,223 @@ +/* ============================================================================= + * M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SkPick.ts + * 마법사 단가산출 줄 고름(PLAN 778-5 D-5 · 요구 621 · 622 · 623 · 624) — 순수 셈. + * · 재료 = 서버 `POST …/hopyo/{키}/sangeun` 답 `판정 [{번 … 줄 […]}]`(D-3 `M02_Hopyo_Sangeun.build`) + * · 고름 캐시 `{고름 {번: {차례: 고름}}, 확인 [번]}` — 줄 고름이 서버 상태를 덮음(꼬리표 「내가 고름」) + * · 블록 [확인] = 남은 고를 줄 0 일 때만 · 몸 = `{확인: [{번, 고름?}]}`(확인한 블록만 · 자동 확인 없음) + * · 금액 · 판정 정본은 서버 — 여기는 캐시를 겹쳐 보이기 · 몸 짓기만 + * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). + * ========================================================================== */ + +export type SkGroup = "L" | "X" | "M" | "R" | "S" | "E"; + +/** 줄 고름 — 노임 LB · 기계 EQ · 자재 MT · 율 기준 · 뺌 + 까닭 */ +export type SkPick = + | { 노임: string } + | { 기계: string } + | { 자재: string; 별도계상?: boolean } + | { 기준: SkBase; 비목?: string } + | { 뺌: string }; + +/** 율 기준 — 묶음 · 비목 · 줄 목록(그 엑셀 행 줄) */ +export type SkBase = string | { 행: number[] }; + +/** 서버 후보 — 노임 · 기계 = `키` · 자재 = `자재` · 율 = `기준` */ +export interface SkCand { + 키?: string; + 자재?: string; + 기준?: SkBase; + 비목?: string; + 갈래?: string; + 이름?: string | null; + 규격?: string; + 단위?: string; + 구분?: string; + 단가?: number | null; + 글?: string; +} + +export interface SkLine { + 차례: number; + 행: number | null; + 묶음: SkGroup; + 이름: string | null; + 규격: string; + 매개값: Record; + 엑셀금액: number | null; + 상태: string; + 방법: string; + 단위: unknown; + 후보: SkCand[]; + 꼬리표: string[]; + 글: string; + 우리금액?: number | null; + 차?: number | null; +} + +export interface SkBlock { + 번: number; + 이름: string | null; + 단위: string | null; + 판정: string; + 고를줄: number; + 우리금액: number | null; + 엑셀금액: number | null; + 근거: { 로직: string; 이름: string | null; 판정: string | null } | null; + 만든: string | null; + 줄: SkLine[]; +} + +export interface SkJudged { + 블록: number; + 판정: SkBlock[]; + 만듦: { 번: number; 이름: string | null; 키: string; 갈래: string; 못옮김: unknown[] }[]; + 못만듦: { 번: number; 이름: string | null; 까닭: string }[]; + 그대로: number; + 같은것씀: { 번: number; 키: string }[]; + 위층같음: { 번: number; 층: string; 주인: string | null; 키: string }[]; +} + +/** 확인 목록 한 칸(서버 몸) */ +export interface SkSure { + 번: number; + 고름?: Record; +} + +/** 캐시 꼴 — 블록 번 · 줄 차례는 JSON 열쇠라 글 */ +export interface SkDraft { + 고름: Record>; + 확인: number[]; +} + +export const MINE = "내가 고름"; +export const GOOD = "옮길 수 있음"; +export const LINKED = "이어짐"; +export const PICK = "고를 것"; +export const OUT = "밖"; +export const DROP = "뺌"; +export const GROUP: Record = { + L: "노무", + X: "기계", + M: "자재", + R: "율", + S: "하위", + E: "값", +}; +/** 율 기준 고르기 칸 */ +export const BASES = ["인력", "재료", "노무비", "재료비", "경비"]; +const DONE = new Set([LINKED, OUT, DROP]); + +export const emptyDraft = (): SkDraft => ({ 고름: {}, 확인: [] }); + +/** 캐시 글 → 꼴(틀리면 빈 것) */ +export function readDraft(raw: string | null): SkDraft { + try { + const got = JSON.parse(raw ?? "") as Partial | null; + const picks = got?.고름 && typeof got.고름 === "object" ? got.고름 : {}; + const sure = Array.isArray(got?.확인) ? got.확인.filter((n) => Number.isInteger(n)) : []; + return { 고름: picks, 확인: sure }; + } catch { + return emptyDraft(); + } +} + +/** 고른 것 글 — 키 · 율 기준 · 뺌 까닭 */ +export function pickText(p: SkPick): string { + if ("뺌" in p) return `${DROP} · ${p.뺌}`; + if ("기준" in p) { + const base = typeof p.기준 === "string" ? p.기준 : `${p.기준.행.join(" · ")} 행`; + return `기준 ${base}${p.비목 ? ` · ${p.비목}` : ""}`; + } + return "노임" in p ? p.노임 : "기계" in p ? p.기계 : p.자재; +} + +/** 줄 보이기 — 캐시 고름이 서버 상태를 덮음 */ +export function lineView( + line: SkLine, + pick: SkPick | undefined, +): { 상태: string; 꼬리표: string[]; 글: string; mine: boolean } { + if (!pick) return { 상태: line.상태, 꼬리표: line.꼬리표, 글: line.글, mine: false }; + return { 상태: "뺌" in pick ? DROP : LINKED, 꼬리표: [MINE], 글: pickText(pick), mine: true }; +} + +/** 그 블록 캐시 고름 `{차례: 고름}` */ +export const picksOf = (d: SkDraft, 번: number): Record => d.고름[String(번)] ?? {}; + +/** 남은 고를 줄 수 — 이어짐 · 밖 · 뺌 아닌 줄(캐시 고름 겹침) */ +export const leftLines = (b: SkBlock, picks: Record): number => + b.줄.filter((l) => !DONE.has(lineView(l, picks[String(l.차례)]).상태)).length; + +/** [확인] 켜짐 — 고를 줄 0 */ +export const canConfirm = (b: SkBlock, picks: Record): boolean => + leftLines(b, picks) === 0; + +/** 블록 판정 글 — 캐시 겹친 뒤 「옮길 수 있음」 · 「고를 줄 n」 */ +export function verdictOf(b: SkBlock, picks: Record): string { + const n = leftLines(b, picks); + return n ? `고를 줄 ${n}` : GOOD; +} + +/** 줄 고를 수 있나 — 밖 줄 = 아님 · 하위 · 값 = 뺌만 */ +export const pickable = (l: SkLine): boolean => l.상태 !== OUT; +export const dropOnly = (l: SkLine): boolean => l.묶음 === "S" || l.묶음 === "E"; + +/** 서버 후보 → 고름(그 줄 묶음 꼴 · 아니면 null) */ +export function candPick(group: SkGroup, c: SkCand): SkPick | null { + if (group === "L" && c.키?.startsWith("LB")) return { 노임: c.키 }; + if (group === "X" && c.키?.startsWith("EQ")) return { 기계: c.키 }; + if (group === "M" && c.자재) return { 자재: c.자재 }; + if (group === "R" && c.기준 !== undefined && c.기준 !== null) + return { 기준: c.기준, ...(c.비목 ? { 비목: c.비목 } : {}) }; + return null; +} + +/** 후보 글 — 「이름 규격 [단위]」 · 율 = 기준 */ +export function candText(c: SkCand): string { + if (c.기준 !== undefined) return pickText({ 기준: c.기준, ...(c.비목 ? { 비목: c.비목 } : {}) }); + const spec = c.규격 ? ` ${c.규격}` : ""; + const unit = c.단위 ? ` [${c.단위}]` : ""; + return `${c.이름 ?? c.키 ?? c.자재 ?? ""}${spec}${unit}${c.구분 ? ` · ${c.구분}` : ""}`; +} + +/** 캐시 정리 — 고를 줄 남은 블록 · 이미 옮겨 판정 없는 블록은 확인에서 뺌 */ +export function tidy(d: SkDraft, blocks: SkBlock[]): SkDraft { + const by = new Map(blocks.map((b) => [b.번, b])); + const sure = d.확인.filter((n) => { + const b = by.get(n); + return !!b && canConfirm(b, picksOf(d, n)); + }); + return { ...d, 확인: [...new Set(sure)].sort((a, b) => a - b) }; +} + +/** [확인한 것 옮기기] 몸 — 확인한 블록만 · 고름은 그 블록 줄에 있는 차례만 */ +export function sureBody(d: SkDraft, blocks: SkBlock[]): SkSure[] { + const by = new Map(blocks.map((b) => [b.번, b])); + return tidy(d, blocks).확인.map((n) => { + const have = new Set(by.get(n)!.줄.map((l) => l.차례)); + const picks = Object.entries(picksOf(d, n)).filter(([at]) => have.has(Number(at))); + return picks.length + ? { 번: n, 고름: Object.fromEntries(picks.map(([at, p]) => [Number(at), p])) } + : { 번: n }; + }); +} + +/** 마법사 [끝내기] 글 재료 — 아직 안 옮긴 블록 수 · 그 블록 고를 줄 합 */ +export function waiting(d: SkDraft, blocks: SkBlock[]): { 블록: number; 줄: number } { + const open = blocks.filter((b) => !b.만든); + return { 블록: open.length, 줄: open.reduce((n, b) => n + leftLines(b, picksOf(d, b.번)), 0) }; +} + +/** 줄 고름 넣기 · 지움(null) — 새 캐시 */ +export function setPick(d: SkDraft, 번: number, 차례: number, p: SkPick | null): SkDraft { + const mine = { ...picksOf(d, 번) }; + if (p) mine[String(차례)] = p; + else delete mine[String(차례)]; + return { ...d, 고름: { ...d.고름, [String(번)]: mine } }; +} + +/** [확인] 켬 · 끔 — 고를 줄 남으면 안 켬 */ +export function setSure(d: SkDraft, b: SkBlock, on: boolean): SkDraft { + const rest = d.확인.filter((n) => n !== b.번); + const ok = on && canConfirm(b, picksOf(d, b.번)); + return { ...d, 확인: ok ? [...rest, b.번].sort((x, y) => x - y) : rest }; +} diff --git a/resources/tester/helper_729_3_hopyo_left.cjs b/resources/tester/helper_729_3_hopyo_left.cjs index 55c1c97cc..494b14420 100644 --- a/resources/tester/helper_729_3_hopyo_left.cjs +++ b/resources/tester/helper_729_3_hopyo_left.cjs @@ -1,4 +1,4 @@ -/* 729-3 안 이은 줄 화면 헬퍼 — `Hopyo_Left` · `Hopyo_Check` · `Api_Fetch` · `Labor_Unlinked` · `Hopyo_WizardSk` 도우미를 +/* 729-3 안 이은 줄 화면 헬퍼 — `Hopyo_Left` · `Hopyo_Check` · `Api_Fetch` · `Labor_Unlinked` 도우미를 * 트랜스파일해 Node 로 돌림 · fetch 대역(부른 길 · 몸 모음 · 409 몸) · sessionStorage 대역 · * stdin = {one, made, left, fixes} · stdout = JSON. */ const fs = require("fs"); @@ -54,7 +54,6 @@ global.document = { dispatchEvent: () => true }; // 캐시 고침 알림(779-4) const left = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Left.ts")); const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts")); const grid = loadTs(path.join(M02, "M02_MasterTemplete_Labor_Unlinked.ts")); - const sk = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_WizardSk.ts")); const scope = { layer: "personal", owner: null }; const at = { scope, target: scope, key: "HP-000001" }; @@ -87,13 +86,6 @@ global.document = { dispatchEvent: () => true }; // 캐시 고침 알림(779-4) unlinkedCalls: calls.filter((x) => x.url.includes("/hopyo-unlinked")).map((x) => x.url), lastBody: calls.filter((x) => x.url.includes("/hopyo-unlinked")).pop().body, fixes: grid.unlinkedFixes({ 담은로직: input.fixes }).map((x) => x.이름), - cand: sk.candLabel({ 이름: "되메우기", 단위: "㎥", 판정: "후보", 글: "후보" }), - cand2: sk.candLabel({ - 이름: "되메우기", - 단위: "㎥", - 판정: "다른 점", - 글: "보통인부 품 다름", - }), }), ); })().catch((error) => { diff --git a/resources/tester/helper_778_5_sk_pick.cjs b/resources/tester/helper_778_5_sk_pick.cjs new file mode 100644 index 000000000..3b8870fa7 --- /dev/null +++ b/resources/tester/helper_778_5_sk_pick.cjs @@ -0,0 +1,84 @@ +/* 778-5 D-5 마법사 단가산출 줄 고름 헬퍼 — `logic/Hopyo_SkPick`(순수) · `Hopyo_SkApi`(몸) 를 트랜스파일해 Node 로 돌림 · + * fetch 대역(부른 길 · 몸 모음) · stdin = {blocks} · stdout = JSON. */ +const fs = require("fs"); +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api", ROUTES: {} } }; +const stub = new Proxy({}, { get: () => () => {} }); +const { loadTs } = makeLoader({ + root: ROOT, + requireFor: (full) => (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".") && !request.endsWith(".css")) + return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return stub; + }, +}); +global.sessionStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; + +(async () => { + const { blocks } = JSON.parse(fs.readFileSync(0, "utf8")); + const calls = []; + global.fetch = async (url, init = {}) => { + calls.push({ + url, + method: init.method ?? "GET", + body: init.body ? JSON.parse(init.body) : null, + }); + return { status: 200, ok: true, json: async () => ({ 판정: blocks, 만듦: [], 못만듦: [] }) }; + }; + const M02 = path.join(ROOT, "M02_MasterTemplete"); + const sk = loadTs(path.join(M02, "logic", "M02_MasterTemplete_Hopyo_SkPick.ts")); + const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_SkApi.ts")); + const [a, b, c] = blocks; // a = 고를 줄 2 · b = 옮길 수 있음 · c = 못 이음(하위) + const out = {}; + let d = sk.emptyDraft(); + out.start = blocks.map((x) => ({ + 번: x.번, + 판정: sk.verdictOf(x, {}), + 확인켜짐: sk.canConfirm(x, {}), + })); + out.emptyBody = sk.sureBody(d, blocks); // 아무것도 안 눌렀으면 몸 비움(자동 확인 없음) + d = sk.setSure(d, a, true); // 고를 줄 남은 블록 = 안 켜짐 + out.sureBlocked = d.확인; + d = sk.setPick(d, a.번, 1, { 노임: "LB000099" }); // 작업반장 → 다른 노임 + out.half = { + 판정: sk.verdictOf(a, sk.picksOf(d, a.번)), + 켜짐: sk.canConfirm(a, sk.picksOf(d, a.번)), + }; + d = sk.setPick(d, a.번, 2, { 자재: "MT000123" }); + d = sk.setPick(d, a.번, 3, { 뺌: "우리에 없음" }); + out.view = a.줄.map((l) => sk.lineView(l, sk.picksOf(d, a.번)[String(l.차례)])); + out.full = { + 판정: sk.verdictOf(a, sk.picksOf(d, a.번)), + 켜짐: sk.canConfirm(a, sk.picksOf(d, a.번)), + }; + d = sk.setSure(d, a, true); + d = sk.setSure(d, b, true); + d = sk.setSure(d, c, true); // 못 이음 남음 = 안 켜짐 + out.sure = d.확인; + out.body = sk.sureBody(d, blocks); + out.wait = sk.waiting(d, blocks); + // 고름 지우면 확인에서 빠짐(tidy) + out.tidy = sk.tidy(sk.setPick(d, a.번, 2, null), blocks).확인; + // 캐시 글 왕복 · 틀린 글 + out.round = sk.readDraft(JSON.stringify(d)); + out.bad = sk.readDraft("{엉터리"); + out.cand = { + L: sk.candPick("L", { 키: "LB000001", 이름: "작업반장" }), + X: sk.candPick("X", { 키: "EQ000019", 이름: "굴착기" }), + M: sk.candPick("M", { 자재: "MT000001", 이름: "시멘트" }), + R: sk.candPick("R", { 기준: "노무비", 비목: "경비" }), + Mbad: sk.candPick("M", { 키: "LB000001" }), + text: sk.candText({ 이름: "합판", 규격: "12mm", 단위: "장" }), + }; + await api.judgeSangeun({ layer: "personal", owner: null }, "HP-000001"); + await api.judgeSangeun({ layer: "personal", owner: null }, "HP-000001", out.body); + out.calls = calls; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/test_729_3_hopyo_left_screen.py b/resources/tester/test_729_3_hopyo_left_screen.py index 2575e28d8..d78d62a23 100644 --- a/resources/tester/test_729_3_hopyo_left_screen.py +++ b/resources/tester/test_729_3_hopyo_left_screen.py @@ -84,8 +84,6 @@ def test_남은_줄_고름_몸_막힘_판정(): assert got["lastBody"]["줄"] == got["body"] and got["lastBody"]["번"] == 2 # 일위대가표 띠 = 이음 · 비율 없는 고정 줄만 assert got["fixes"] == ["안이음"] - assert got["cand"] == "되메우기 [㎥] · 후보" - assert got["cand2"] == "되메우기 [㎥] · 다른 점 · 보통인부 품 다름" def _text(name: str) -> str: @@ -98,15 +96,13 @@ def test_마법사_끝내기_막음_단가산출_칸_목록_열(): assert end.index("await recount()") < end.index("showConfirmDialog(") # 끝내기 전 다시 셈 assert "if (leftCount(got).줄)" in end and "openLeft(" in end # 남으면 막음 · 표 assert "finish.disabled = !!n?.줄;" in wizard # 띠 · 남은 수 · [끝내기] 막음 - assert "sangeunStep(scope, file.키)" in wizard and "/sangeun`" not in wizard - sk = _text("M02_MasterTemplete_Hopyo_WizardSk.ts") - assert "makeSangeun(scope, key, picks)" in sk - assert "picks[no] = { 로직: s.value, ...(need ? { 환산: { 계수: c } } : {}) };" in sk # 계수 칸 - lost = _text( - "M02_MasterTemplete_Hopyo_Lost.ts" - ) # 못옮김 잇기 = 캐시 모두 · 바탕 = SK 문서 바탕 - assert "const links = Object.values(loadLinks(k));" in lost and "잇기: links," in lost - assert "로직: doc.바탕.로직, 입력값: doc.입력값 ?? {}" in lost + assert "sangeunStep(scope, file.키, file.파일" in wizard and "/sangeun`" not in wizard + sk = _text( + "M02_MasterTemplete_Hopyo_WizardSk.ts" + ) # 778-5 D-5 — 줄 고름 · 확인한 것만(옛 기초 고름 걷음) + assert "judgeSangeun(scope, key, body)" in sk + lost = _text("M02_MasterTemplete_Hopyo_Lost.ts") # 옛 못옮김 창 = 보기만(옛 `바탕` 몸 걷음) + assert "makeSangeun" not in lost and "loadLinks" not in lost assert "row.못옮김?.length && row.단산 !== undefined && row.SK" in _text( "M02_MasterTemplete_Hopyo_Left.ts" ) diff --git a/resources/tester/test_746_2_hopyo_compare_screen.py b/resources/tester/test_746_2_hopyo_compare_screen.py index b3ce060ca..b9d45f72e 100644 --- a/resources/tester/test_746_2_hopyo_compare_screen.py +++ b/resources/tester/test_746_2_hopyo_compare_screen.py @@ -1,6 +1,6 @@ """746-2 지킴 — 불러오기 고르기 창 후보 블록 줄 비교 · 요소 후보 화면(요구 585). -서버 없이 소스로 잡음: 고름 대기 칸이 줄 비교 표를 붙임 · 못옮김 창 · 안 이은 줄 창이 요소 후보 단추를 붙임 · +서버 없이 소스로 잡음: 안 이은 줄 창이 요소 후보 단추를 붙임(고름 대기 줄 비교 · 못옮김 잇기는 778-5 D-5 줄 고름으로 걷음) · 안 이은 줄 창의 기초 후보 칸에 요소 후보(`키`)가 섞여 들어가지 않음 · 서버 응답 칸 이름 · 부품 상자만 · 700 줄. """ @@ -14,14 +14,7 @@ def _src(name: str) -> str: return (M02 / f"M02_MasterTemplete_Hopyo_{name}.ts").read_text(encoding="utf-8") -def test_고름_대기_칸이_줄_비교_표를_붙임(): - sk = _src("WizardSk") - assert "compareView(x.후보)" in sk and "cmp?.show(pick.select.value)" in sk - assert "compareBrief(c.줄비교)" in sk # 선택 칸 글에 같음 · 품다름 수 - - -def test_못옮김_창_안_이은_줄_창이_요소_후보_단추를_붙임(): - assert "elemChoices(x.후보" in _src("Lost") +def test_안_이은_줄_창이_요소_후보_단추를_붙임(): left = _src("Left") assert "elemChoices(row.후보.filter(isElem)" in left assert ( @@ -31,14 +24,8 @@ def test_못옮김_창_안_이은_줄_창이_요소_후보_단추를_붙임(): def test_서버_응답_칸_이름_그대로(): api = _src("Api") - for name in ( - "줄비교?: SangeunCompare | null", - "엑셀: number | null", - "기초: number | null", - "못옮김: SangeunLost[]", - ): - assert name in api - assert 'CompareLook = "같음" | "품다름" | "엑셀만" | "기초만"' in api + assert "못옮김: SangeunLost[]" in api and "export interface ElemCand" in api + assert "SangeunCompare" not in api and "export function compareView" not in _src("Compare") def test_부품_상자만_날_요소_없음_700줄_안(): diff --git a/resources/tester/test_778_5_sk_pick_screen.py b/resources/tester/test_778_5_sk_pick_screen.py new file mode 100644 index 000000000..ffa2f64bc --- /dev/null +++ b/resources/tester/test_778_5_sk_pick_screen.py @@ -0,0 +1,128 @@ +"""778-5 D-5 지킴 — 마법사 단가산출 줄 고름(제안 · 확인 뒤 옮김 · 요구 621 · 622 · 623 · 624). + +서버 없이 잡음: 줄 고름 캐시가 서버 상태를 덮음(「내가 고름」) · [확인] = 고를 줄 0 일 때만 · 몸 = 이음 줄 +`{확인: [{번, 고름?: {차례: 고름}}]}`(확인한 블록만) · 자동 확인 없음(빈 캐시 = 빈 몸 · 「모두 확인」 단추 없음) · +화면 = 부품 상자만 · 700 줄. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +HERE = os.path.dirname(__file__) +M02 = Path(__file__).resolve().parents[2] / "M02_MasterTemplete" + + +def _line(at, kind, state, **more): + row = {"차례": at, "행": 100 + at, "묶음": kind, "이름": f"줄{at}", "규격": "", "매개값": {}} + row |= {"엑셀금액": 1000.0, "상태": state, "방법": "", "단위": None, "후보": [], "꼬리표": []} + return {**row, "글": "", "우리금액": None, "차": None, **more} + + +def _block(no, lines, made=None): + head = {"번": no, "이름": f"블록{no}", "단위": "㎥", "판정": "", "고를줄": 0, "우리금액": None} + return head | {"엑셀금액": 5000.0, "근거": None, "만든": made, "줄": lines} + + +BOSS = ["기본 — 건설업 작업반장"] +BLOCKS = [ + _block( + 2, + [ + _line(0, "L", "이어짐", 매개값={"품": 0.2}), + _line(1, "L", "이어짐", 꼬리표=BOSS, 후보=[{"키": "LB000099"}]), + _line(2, "M", "고를 것", 후보=[{"자재": "MT000123"}]), + _line(3, "M", "못 이음"), + _line(4, "R", "밖"), + ], + ), + _block(5, [_line(0, "X", "이어짐"), _line(1, "R", "이어짐")]), + _block(7, [_line(0, "S", "못 이음")], made=None), +] + + +def _run() -> dict: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_778_5_sk_pick.cjs")], + input=json.dumps({"blocks": BLOCKS}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_판정_확인_켜짐_조건(): + got = _run() + assert got["start"] == [ + {"번": 2, "판정": "고를 줄 2", "확인켜짐": False}, + {"번": 5, "판정": "옮길 수 있음", "확인켜짐": True}, + {"번": 7, "판정": "고를 줄 1", "확인켜짐": False}, + ] + assert got["sureBlocked"] == [] # 고를 줄 남은 블록 = [확인] 안 켜짐 + assert got["half"] == {"판정": "고를 줄 2", "켜짐": False} # 이어진 줄 바꿈은 고를 줄 안 줄임 + assert got["full"] == {"판정": "옮길 수 있음", "켜짐": True} + assert got["sure"] == [2, 5] # 하위 못 이음 블록 7 = 안 켜짐 + + +def test_줄_표_꼴_내가_고름이_덮음(): + view = _run()["view"] + assert view[0] == {"상태": "이어짐", "꼬리표": [], "글": "", "mine": False} + assert view[1] == {"상태": "이어짐", "꼬리표": ["내가 고름"], "글": "LB000099", "mine": True} + assert view[2]["상태"] == "이어짐" and view[2]["글"] == "MT000123" + assert view[3] == { + "상태": "뺌", + "꼬리표": ["내가 고름"], + "글": "뺌 · 우리에 없음", + "mine": True, + } + assert view[4]["상태"] == "밖" + + +def test_몸_꼴_이음_줄_자동_확인_없음(): + got = _run() + assert got["emptyBody"] == [] # 누른 것 없음 = 몸 비움 + picks = {"1": {"노임": "LB000099"}, "2": {"자재": "MT000123"}, "3": {"뺌": "우리에 없음"}} + assert got["body"] == [{"번": 2, "고름": picks}, {"번": 5}] + first, second = got["calls"] + assert first["method"] == "POST" and first["body"] == {"확인": []} # 판정만 + assert first["url"].endswith("/layers/personal/hopyo/HP-000001/sangeun") + assert second["body"] == {"확인": got["body"]} + assert got["wait"] == {"블록": 3, "줄": 1} # 안 옮긴 블록 셋 · 블록 7 하위 줄 + assert got["tidy"] == [5] # 고름 지우면 그 블록 확인 빠짐 + assert got["round"]["확인"] == [2, 5] and got["bad"] == {"고름": {}, "확인": []} + + +def test_후보_고름_꼴(): + cand = _run()["cand"] + assert cand["L"] == {"노임": "LB000001"} and cand["X"] == {"기계": "EQ000019"} + assert cand["M"] == {"자재": "MT000001"} and cand["R"] == {"기준": "노무비", "비목": "경비"} + assert cand["Mbad"] is None and cand["text"] == "합판 12mm [장]" + + +def _src(name: str) -> str: + return (M02 / f"M02_MasterTemplete_Hopyo_{name}.ts").read_text(encoding="utf-8") + + +def test_화면_자동_없음_부품_상자만(): + wiz = _src("WizardSk") + assert "judgeSangeun(scope, key)" in wiz # 열 때 = 판정만 + assert "judgeSangeun(scope, key, body)" in wiz and "sureBody(load(), got.판정)" in wiz + assert "모두" not in wiz.split("*/", 1)[1] and "setSure(" in wiz # 확인은 블록마다만 + assert 'kind: "chip"' in wiz and "disabled: !ok" in wiz # [확인] 칩 · 고를 줄 0 일 때만 + assert "단산행: 행" in wiz # 엑셀 보기 뛰기 + lines = _src("SkLines") + assert "lineFind(" in lines and "unitTone(" in lines # 찾기 창 · 엑셀 칠과 한 규칙 + wizard = _src("Wizard") + assert 'L("M02_SkLeft")' in wizard and "sk?.count()" in wizard # [끝내기] 막힘 글 + for name in ("WizardSk", "SkLines", "SkApi", "Lost", "Compare"): + src = _src(name) + assert len(src.splitlines()) <= 700, name + assert not re.search(r'el\("(button|input|select)"|