diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 9d608315a..8f1da31b9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -31,8 +31,15 @@ export interface TemplateDoc { 문서: unknown; } -/** 서버가 판 불일치로 막았을 때 */ -export class StaleError extends Error {} +/** 서버가 판 불일치로 막았을 때 · `detail` = 서버 몸 그대로(729-3 `{까닭, 남은줄}`) */ +export class StaleError extends Error { + constructor( + message: string, + readonly detail?: unknown, + ) { + super(message); + } +} /** `/api/m02` 아래 길 부름 — 409 = StaleError · 그 밖 실패 = 서버 까닭 글(하위 폴더도 씀) */ export async function call(path: string, init: RequestInit = {}): Promise { @@ -42,7 +49,8 @@ export async function call(path: string, init: RequestInit = {}): Promise ...init, }); const body = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; - if (response.status === 409) throw new StaleError(reason(body.detail, response.status)); + if (response.status === 409) + throw new StaleError(reason(body.detail, response.status), body.detail); if (!response.ok) throw new Error(reason(body.detail, response.status)); return body; } @@ -50,6 +58,8 @@ export async function call(path: string, init: RequestInit = {}): Promise /** 서버가 막은 까닭 그대로 — 문장 · `{errors: [...]}` · 입력 검사 목록(`[{msg}]`) */ function reason(detail: unknown, status: number): string { if (typeof detail === "string") return detail; + const why = (detail as { 까닭?: unknown } | null)?.까닭; + if (typeof why === "string") return why; const list = Array.isArray(detail) ? detail : (detail as { errors?: unknown } | null)?.errors; const lines = Array.isArray(list) ? list.map((x) => (typeof x === "string" ? x : String((x as { msg?: unknown })?.msg ?? ""))) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index d9f8188c9..830e4444c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -92,6 +92,10 @@ export interface HopyoTarget { 호표?: number; /** 「전국」 자리표(361-1) — 지역 변형만 있는 자재 · 키 · 단가 없음 */ 전국?: boolean; + /** 729-2 사용자 이음 — 노임 LB · 기계 EQ · % 줄 밑수(엑셀 숫자 + 이음 · 하나만) */ + 노임?: string; + 기계?: string; + 비율?: LinkBase; /** 같은 품목 지역 · 관급/사급 변형(361-1 · 고르기 창이 접음) */ 변형?: boolean; 이름?: string; @@ -205,11 +209,74 @@ export interface BuildTarget { 호표?: number; /** 「전국」 자재(361-1) — 이름 · 규격 · 단위는 서버가 줄에서 다시 셈 */ 전국?: boolean; + /** 729-2 사용자 이음(하나만 · 입력값 없음) */ + 노임?: string; + 기계?: string; + 비율?: LinkBase; 입력값?: Record; 배: number; 몫?: number | null; } +/** % 줄 밑수 비목(729-2) */ +export type LinkBase = "노무비" | "재료비" | "경비"; + +/** 단가산출 기초 후보(729-1 고름 대기) — 판정 · 글 */ +export interface SangeunCand { + 로직: string; + 이름: string; + 단위: string; + 판정: string; + 글: string; +} + +/** 안 이은 줄(729-2) — 만들면 남을 엑셀 줄 · 단가산출 줄 후보 = 고름 대기 기초 후보 */ +export interface HopyoLeft { + 차례: number; + 이름: string; + 규격: string; + 단위: string; + 수량: number | null; + 갈래: string; + 까닭: string; + 후보: SangeunCand[]; +} + +/** 옮기지 못한 엑셀 줄(729-4) — M01 요소로 이으면 SK 더한줄 */ +export interface SangeunLost { + id: string; + 이름: string; + 규격?: string; + 단위?: string; + 수량?: number | null; + 갈래: "자재" | "기계" | "노임"; + 후보?: unknown[]; +} + +/** [단가산출 만들기](729-1) 답 — 만듦 = 기초 · SK · 고름 대기 = 블록마다 후보 */ +export interface SangeunBuilt { + 블록: number; + 만듦: { 번: number; 이름: string; 키: string; 갈래: "기초" | "SK"; 못옮김?: SangeunLost[] }[]; + 그대로: number; + 기초: number; + SK: number; + 고름대기: { + 번: number; + 이름: string; + 단위: string; + 갈래: string; + 까닭: string; + 후보: SangeunCand[]; + }[]; + 별칭: number; +} + +/** 고른 기초(블록 번 → 로직) · 729-4 잇기 */ +export type SangeunPick = Record< + number, + { 로직: string; 입력값?: Record; 잇기?: Record[] } +>; + /** 확인한 줄 — 대상 목록 또는 뺌 까닭 */ export interface BuildLine { 차례: number; @@ -396,6 +463,31 @@ export const buildHopyoAll = ( ): Promise => call(at(target, "/hopyo-build-all"), { method: "POST", body: JSON.stringify(from) }); +/** 안 이은 줄 미리 보기(729-2 · 저장 없음) — 몸 = 만들기 몸 */ +export const leftHopyo = ( + target: HopyoScope, + from: { 층: Layer; 주인: string | null; 키: string; 번: number }, + 줄: BuildLine[], +): Promise<{ 남은줄: HopyoLeft[]; 수: number }> => + call(at(target, "/hopyo-unlinked"), { method: "POST", body: JSON.stringify({ ...from, 줄 }) }); + +/** 409 몸의 남은 줄(729-2 · 만들기 막힘) — 아니면 null */ +export const leftOf = (error: unknown): HopyoLeft[] | null => { + const got = (error as { detail?: { 남은줄?: unknown } } | null)?.detail?.남은줄; + return Array.isArray(got) ? (got as HopyoLeft[]) : null; +}; + +/** [단가산출 만들기](729-1) — 산근 블록마다 기초 · SK · 고름 대기 · `바탕` = 고른 기초 */ +export const makeSangeun = ( + scope: HopyoScope, + key: string, + 바탕?: SangeunPick, +): Promise => + call(at(scope, `/hopyo/${enc(key)}/sangeun`), { + method: "POST", + body: JSON.stringify(바탕 ? { 바탕 } : {}), + }); + export const listHopyoPicks = async (scope: HopyoScope): Promise => (await call<{ 줄: HopyoPickRow[] }>(at(scope, "/hopyo-picks"))).줄; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index 09f4e5166..c756a5617 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -52,6 +52,7 @@ import { calcLabor, fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; import { draftDoc } from "./logic/M02_MasterTemplete_Hopyo_Draft"; import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem"; +import { openLeftOf } from "./M02_MasterTemplete_Hopyo_Left"; import "./M02_MasterTemplete_Hopyo.css"; export const POLL_MS = 3000; @@ -114,8 +115,8 @@ export const isChecked = (line: HopyoLine, d: CheckDraft): boolean => d.체크[line.차례] ?? !!line.맞춤.체크; /** 체크할 수 있음 — % 줄 아님 · 뺌 까닭 또는 대상(배 0 넘음) */ export function checkable(line: HopyoLine, d: CheckDraft): boolean { - if (line.맞춤.상태 === "%") return false; if (d.뺌[line.차례]) return true; + if (line.맞춤.상태 === "%") return !!partsOf(line, d)[0]?.비율; // 729-3 밑수 고른 % 줄만 const parts = partsOf(line, d); return parts.length > 0 && parts.every((p) => p.배 !== null && p.배 > 0); } @@ -123,6 +124,8 @@ export function checkable(line: HopyoLine, d: CheckDraft): boolean { /** 대상 → 만들기 몸 대상(이름 · 품 같은 보이기 칸은 안 보냄) */ export function toTarget(p: HopyoPart): BuildTarget { if (p.호표 !== undefined) return { 호표: p.호표, 배: p.배 ?? 1 }; + const link = (["노임", "기계", "비율"] as const).find((x) => p[x]); // 729-3 사용자 이음(엑셀 숫자 + 이음) + if (link) return { [link]: p[link], 배: 1 } as BuildTarget; const head = p.로직 ? { 로직: p.로직, 입력값: p.입력값 ?? {} } : p.전국 @@ -450,7 +453,8 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { opt.onBuilt?.(res, h); await load(true); } catch (error) { - fail(error); + const at = { scope: opt.scope, target, key: opt.key, 파일: got?.파일 ?? "", 번: opt.no }; + if (!openLeftOf(error, at, () => ((d = loadDraft(k)), draw()))) fail(error); // 729-3 남은 줄 } finally { hideLoadingOverlay(); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Left.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Left.ts new file mode 100644 index 000000000..a12aa6b09 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Left.ts @@ -0,0 +1,378 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Left.ts + * 일위대가 불러오기 안 이은 줄(PLAN 729-3 · 요구서 내역19 1장 8 · 2장 4) — 만들면 남을 엑셀 줄 표 · 줄마다 고름. + * · 줄 표 = 서버 `/hopyo-unlinked`(저장 없음 · 몸 = 확인 창 캐시로 지을 만들기 몸) · 409 `{까닭, 남은줄}` 도 그 표 + * · 고름 = 확인 창 캐시(`Hopyo_Check` draft) 대상 · 뺌 — [저장] · [생성] 몸이 그대로 씀(새 저장 꼴 없음) + * [고르기] = 확인 창 고르기 창(후보 · 기초 · 자재 찾기 · 옵션은 서버 `guess`) · [찾기] = 기초 · 노임(LB) · 자재(MT) · + * [기계] = M01 기계(EQ) · % 줄 = 밑수 비목 · [우리에 없음] = 엑셀 숫자 그대로(사용자가 고른 것) + * · 단가산출 고름 대기 줄 = 후보 기초 고름 → [SK 만들기](서버 `sangeun` 바탕 · 엑셀 차이 보존) → 그 줄은 SK 참조 + * · 남은 수 = 안 만든 호표만(만든 호표는 서버가 남은 줄 있으면 안 만듦) — 마법사 [끝내기] 막음 · 띠 + * 계산 · 판정은 서버 — 화면은 보이기 · 고름만. + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createBanner } from "@ui/ui_template_banner"; +import { createFindList } from "@ui/ui_template_find_list"; +import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; +import { createTable, type Cell } from "@ui/ui_template_table"; +import { createText } from "@ui/ui_template_text"; +import { searchElements } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { + guessHopyo, + leftHopyo, + leftOf, + makeSangeun, + readHopyoOne, + type BuildLine, + type HopyoLeft, + type HopyoLine, + type HopyoOne, + type HopyoPart, + type HopyoScope, + type LinkBase, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { bodyOf, draftKey, loadDraft, saveDraft, toTarget } from "./M02_MasterTemplete_Hopyo_Check"; +import { openHopyoChoose } from "./M02_MasterTemplete_Hopyo_Choose"; +import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; + +/** 파일 층 · 만들 층 · 파일 키 */ +export interface LeftScope { + scope: HopyoScope; + target: HopyoScope; + key: string; +} +/** 호표마다 남은 줄(그 호표 읽은 것과 함께) */ +export type LeftMap = Map; + +const NONE = "우리에 없음"; +const BASES: LinkBase[] = ["노무비", "재료비", "경비"]; +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +/** 남은 줄 수 · 호표 수 */ +export const leftCount = (map: LeftMap): { 줄: number; 호표: number } => { + const nos = [...map.values()].filter((x) => x.left.length); + return { 줄: nos.reduce((n, x) => n + x.left.length, 0), 호표: nos.length }; +}; + +/** 캐시로 지을 만들기 몸 — 확인 창 · [저장]과 같은 규칙(통째 호표는 줄 없음) */ +export function draftLines(at: LeftScope, one: HopyoOne): BuildLine[] { + const h = one.호표; + const d = loadDraft(draftKey(at.scope, at.key, h.번)); + return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d); +} + +/** 호표 하나 다시 셈 — 서버 미리 보기(만든 호표 = 남은 줄 없음) */ +async function recount(at: LeftScope, no: number, map: LeftMap): Promise { + const one = await readHopyoOne(at.scope, at.key, no, at.target); + const from = { 층: at.scope.layer, 주인: at.scope.owner, 키: at.key, 번: no }; + const made = !!one.호표.만든?.확인; + const left = made ? [] : (await leftHopyo(at.target, from, draftLines(at, one))).남은줄; + map.set(no, { one, left }); +} + +/** 호표 여럿 남은 줄(차례로 · `step` = 몇째) — 실패한 호표는 까닭 글로 알림 */ +export async function countLeft( + at: LeftScope, + nos: number[], + step?: (done: number, all: number) => void, +): Promise { + const map: LeftMap = new Map(); + for (const [i, no] of nos.entries()) { + step?.(i, nos.length); + try { + await recount(at, no, map); + } catch (error) { + showToast(`제${no}호표 — ${why(error)}`, "warning"); + } + } + return map; +} + +/** 캐시에 한 줄 고름 — 대상 또는 뺌 「우리에 없음」(parts = null · 체크 켬) */ +export function put(at: LeftScope, no: number, 차례: number, parts: HopyoPart[] | null): void { + const k = draftKey(at.scope, at.key, no); + const d = loadDraft(k); + d.체크[차례] = true; + if (parts) { + d.대상[차례] = parts; + delete d.뺌[차례]; + } else { + d.뺌[차례] = NONE; + delete d.대상[차례]; + } + saveDraft(k, d); +} + +/** 고른 것 글 — 캐시 대상 · 뺌 */ +function pickedText(at: LeftScope, no: number, 차례: number): string { + const d = loadDraft(draftKey(at.scope, at.key, no)); + if (d.뺌[차례]) return d.뺌[차례]; + const parts = d.대상[차례] ?? []; + return parts + .map((p) => p.이름 ?? p.로직 ?? p.자재 ?? p.노임 ?? p.기계 ?? p.비율 ?? "") + .filter(Boolean) + .join(" + "); +} + +/** M01 기계(EQ) 하나 고르기 — 이름 · 규격 낱말 */ +export function pickMachine( + line: { 이름: string; 규격?: string }, + done: (code: string, name: string) => void, +): void { + openModal({ + title: `${L("M02_LeftMachine")} — ${line.이름} ${line.규격 ?? ""}`.trim(), + closeLabel: L("M02_Close"), + dialogClass: "m02-modal", + mount: (body, close) => { + const find = createFindList({ placeholder: L("M02_LeftMachineFind") }); + let seq = 0; + const run = (q: string): void => { + const mine = ++seq; + searchElements("기계", q).then( + (got) => { + if (mine !== seq) return; + find.hint.textContent = `${got.total}`; + find.setRows( + got.items.map((x) => ({ + key: x.ref, + name: `${x.이름 ?? ""} ${x.규격 ?? ""}`.trim(), + sub: x.ref, + onPick: () => { + close(); + done(x.ref, `${x.이름 ?? ""} ${x.규격 ?? ""}`.trim()); + }, + })), + ); + }, + (error) => showToast(why(error), "error"), + ); + }; + find.search.addEventListener("input", () => run(find.search.value)); + find.search.value = line.이름.split(/[\s(]/)[0] ?? ""; + run(find.search.value); + body.append(find.root); + }, + }); +} + +/** 남은 줄 창 — `map` 이 없으면 `nos` 를 서버에 물어 채움 · 닫을 때 `onClose(map)` */ +export function openLeft( + at: LeftScope & { 파일: string; nos?: number[]; map?: LeftMap }, + onClose?: (map: LeftMap) => void, +): void { + const map: LeftMap = at.map ?? new Map(); + openModal({ + title: `${L("M02_LeftTitle")} — ${at.파일}`, + closeLabel: L("M02_Close"), + width: "1100px", + wide: true, + dialogClass: "m02-modal", + beforeClose: () => { + onClose?.(map); + return true; + }, + mount: (body) => { + const head = el("div"); + const list = el("div"); + body.append(createText(L("M02_LeftHint"), "hint", 14), head, list); + + const again = async (no: number): Promise => { + try { + await recount(at, no, map); + } catch (error) { + showToast(why(error), "error"); + } + draw(); + }; + const choose = (no: number, line: HopyoLine): void => + openHopyoChoose({ + line, + done: (ts) => { + const parts = ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })); + guessHopyo(at.scope, at.key, no, line.차례, parts.map(toTarget)) + .then((g) => (Array.isArray(g.대상) ? g.대상 : parts)) + .catch(() => parts) + .then((got) => { + put(at, no, line.차례, got); + void again(no); + }); + }, + }); + const link = (no: number, 차례: number, part: Partial, name: string): void => { + put(at, no, 차례, [{ ...part, 이름: name, 입력값: {}, 배: 1 }]); + void again(no); + }; + const find = (no: number, line: HopyoLine): void => + openFindPicker({ + layer: at.target.layer, + owner: at.target.owner, + title: `${line.이름} ${line.규격}`.trim(), + logic: (x) => link(no, line.차례, { 로직: x.키 }, x.이름), + element: (e) => + link(no, line.차례, e.종류 === "인력" ? { 노임: e.코드 } : { 자재: e.코드 }, e.이름), + }); + const makeSk = async (no: number, block: number, 로직: string): Promise => { + try { + const got = await makeSangeun(at.scope, at.key, { [block]: { 로직 } }); + const sk = got.만듦.find((x) => x.번 === block); + showToast(sk ? `${sk.키} · ${sk.갈래}` : L("M02_LeftSkNone"), sk ? "success" : "warning"); + } catch (error) { + showToast(why(error), "error"); + } + await again(no); + }; + + const actions = (no: number, row: HopyoLeft, line: HopyoLine | undefined): HTMLElement => { + const box = el("div", { className: "ui-toolbar" }); + const block = (line as { 참조?: { 종류?: string; 번?: number } } | undefined)?.참조; + if (row.후보.length && block?.종류 === "단산" && block.번 !== undefined) { + const pick = createSelectField({ + options: row.후보.map((c) => ({ + value: c.로직, + text: `${c.이름} [${c.단위}] · ${c.판정}${c.글 && c.글 !== c.판정 ? ` · ${c.글}` : ""}`, + })), + compact: true, + size: 24, + ariaLabel: L("M02_LeftBase"), + }); + const go = createButton({ + label: L("M02_LeftMakeSk"), + size: 24, + onClick: () => void makeSk(no, block.번!, pick.select.value), + }); + go.dataset.leftSk = String(block.번); + box.append(pick.root, go); + } + if (row.갈래 === "%" && line) { + const base = createSelectField({ + options: [{ value: "", text: L("M02_LeftBase") }].concat( + BASES.map((b) => ({ value: b, text: b })), + ), + compact: true, + size: 24, + ariaLabel: L("M02_LeftBase"), + onChange: (v) => v && link(no, row.차례, { 비율: v as LinkBase }, v), + }); + box.append(base.root); + } + if (line && row.갈래 !== "%") + box.append( + createButton({ label: L("M02_LeftChoose"), size: 24, variant: "ghost", onClick: () => choose(no, line) }), + createButton({ label: L("M02_LeftFind"), size: 24, variant: "ghost", onClick: () => find(no, line) }), + ); // prettier-ignore + if (line && row.갈래 === "기계") + box.append( + createButton({ + label: L("M02_LeftMachine"), + size: 24, + variant: "ghost", + onClick: () => + pickMachine(line, (code, name) => link(no, row.차례, { 기계: code }, name)), + }), + ); + const none = createButton({ + label: NONE, + size: 24, + variant: "ghost", + onClick: () => { + put(at, no, row.차례, null); + void again(no); + }, + }); + none.title = L("M02_LeftNoneTip"); + box.append(none); + return box; + }; + + const draw = (): void => { + const n = leftCount(map); + head.replaceChildren( + n.줄 + ? createBanner({ + tone: "warning", + text: L("M02_LeftBand").replace("{n}", String(n.줄)).replace("{h}", String(n.호표)), + }) + : createBanner({ tone: "success", text: L("M02_LeftZero") }), + ); + head.dataset.left = String(n.줄); + const rows: Cell[][] = []; + for (const [no, { one, left }] of [...map].sort((a, b) => a[0] - b[0])) + for (const row of left) { + const line = one.호표.줄.find((l) => l.차례 === row.차례); + rows.push([ + `제${no}호표`, + row.이름, + row.규격, + `${row.수량 ?? ""} ${row.단위}`.trim(), + createBadge(row.갈래, "warning"), + row.까닭, + pickedText(at, no, row.차례), + actions(no, row, line), + ]); + } + list.replaceChildren( + createTable( + [ + { label: L("M02_LeftHopyo"), width: "88px" }, + { label: L("M02_SangeunColName") }, + { label: L("M02_LeftSpec") }, + { label: L("M02_SangeunColQty"), width: "80px" }, + { label: L("M02_LeftKind"), width: "80px" }, + { label: L("M02_LeftWhy") }, + { label: L("M02_LeftPicked") }, + { label: L("M02_LeftPick") }, + ], + rows, + { density: "data", empty: L("M02_LeftZero") }, + ), + ); + }; + + if (at.map) draw(); + else { + list.replaceChildren(createText(L("M02_LeftCounting"), "muted", 14)); + void countLeft(at, at.nos ?? [], (i, all) => { + list.replaceChildren( + createText(`${L("M02_LeftCounting")} ${i + 1} / ${all}`, "muted", 14), + ); + }).then((got) => { + for (const [k, v] of got) map.set(k, v); + draw(); + }); + } + }, + }); +} + +/** 만들기 실패 까닭이 남은 줄이면 그 표를 엶(참) · 아니면 거짓(부르는 쪽이 까닭 글) */ +export function openLeftOf( + error: unknown, + at: LeftScope & { 파일: string; 번: number }, + onClose?: (map: LeftMap) => void, +): boolean { + const left = leftOf(error); + if (!left) return false; + void readHopyoOne(at.scope, at.key, at.번, at.target).then( + (one) => openLeft({ ...at, map: new Map([[at.번, { one, left }]]) }, onClose), + (e) => showToast(why(e), "error"), + ); + return true; +} + +/** 결과 표 칸 단추 — 남은 줄로 막힌 호표면 [고르기 (n)] · 아니면 null */ +export function leftButton( + error: unknown, + at: LeftScope & { 파일: string; 번: number }, +): HTMLButtonElement | null { + const left = leftOf(error); + if (!left) return null; + const go = createButton({ + label: `${L("M02_LeftChoose")} (${left.length})`, + size: 24, + onClick: () => void openLeftOf(error, at), + }); + go.dataset.leftOpen = String(at.번); + return go; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index 8fadc13bf..f8f5b65c5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -57,6 +57,7 @@ import { } from "./M02_MasterTemplete_Hopyo_Check"; import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose"; import { openRelink, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink"; +import { leftButton } from "./M02_MasterTemplete_Hopyo_Left"; import "./M02_MasterTemplete_Hopyo.css"; export interface HopyoSheetOptions { @@ -499,11 +500,12 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { }), ); } catch (error) { + const left = leftButton(error, { scope: opt.scope, target, key: opt.key, 파일: opt.파일, 번: no }); // prettier-ignore const text = - error instanceof StaleError - ? `안내 — ${why(error)} · 하위 호표를 함께 고르거나 먼저 저장` - : `실패 — ${why(error)}`; - rows.push(el("tr", { children: [cell, el("td", { text })] })); + left || !(error instanceof StaleError) + ? `실패 — ${why(error)}` + : `안내 — ${why(error)} · 하위 호표를 함께 고르거나 먼저 저장`; + rows.push(el("tr", { children: [cell, el("td", { text }), ...(left ? [el("td", { children: [left] })] : [])] })); // prettier-ignore } } } finally { @@ -557,9 +559,10 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { ], }), ), - ...got.실패.map((x) => - el("tr", { children: [el("td", { text: `제${x.번}호표` }), el("td", { text: x.까닭 })] }), - ), + ...got.실패.map((x) => { + const left = leftButton({ detail: x }, { scope: opt.scope, target, key: opt.key, 파일: opt.파일, 번: x.번 }); // prettier-ignore + return el("tr", { children: [el("td", { text: `제${x.번}호표` }), el("td", { text: x.까닭 }), ...(left ? [el("td", { children: [left] })] : [])] }); // prettier-ignore + }), ]; const relink = createButton({ label: "다음 — 이 공사 단가로 다시 잇기", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts index 302eccf0e..d93bc1087 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts @@ -5,8 +5,10 @@ * (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 서버 409 안내) · 작업 중 파일 이어서 * ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] → * 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 · - * 단가산출 체크면 [단가산출 만들기] = 산근 블록마다 바탕 없는 단가산출(서버 `M02_Hopyo_Sangeun` · 겹침 없음) - * ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 단가산출 · 별칭 장부 남음 · 지문 기록 + * 단가산출 체크면 단가산출 칸(`Hopyo_WizardSk` — 기초 · SK · 고름 대기 기초 고름 · 729-3) · + * 안 이은 줄 띠(`Hopyo_Left` — 안 만든 호표의 남은 줄 · [고르기] = 줄마다 이을 것 · 729-3) + * ③ [끝내기] = 남은 줄 다시 셈 → 있으면 막음(띠 · 남은 수 · 표) · 없으면 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · + * 만든 일위대가 · 단가산출 · 별칭 장부 남음 · 지문 기록 * 작업 중 = 서버에 보관(떠났다 와도 ① 목록에서 이어서) · 옛 HP(지문 없음)는 서버 목록에서 숨김. * ========================================================================== */ @@ -24,7 +26,18 @@ import { type HopyoFile, type HopyoScope, } from "./M02_MasterTemplete_Hopyo_Api"; -import { call } from "./M02_MasterTemplete_Api_Fetch"; +import { createBanner } from "@ui/ui_template_banner"; +import { t as L } from "@ui/ui_template_locale"; +import { readHopyo } from "./M02_MasterTemplete_Hopyo_Api"; +import { hasDraft, draftKey } from "./M02_MasterTemplete_Hopyo_Check"; +import { + countLeft, + leftCount, + openLeft, + type LeftMap, + type LeftScope, +} from "./M02_MasterTemplete_Hopyo_Left"; +import { sangeunStep } from "./M02_MasterTemplete_Hopyo_WizardSk"; import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer"; const LAYERS: { layer: ManageLayer; text: string }[] = [ @@ -112,7 +125,7 @@ export function buildHopyoWizard(): HTMLElement { }); seg.root.dataset.hopyoLayer = ""; const ilwi = createCheckbox("일위대가(고정 줄)", { checked: true, disabled: true }); - const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", { + const sangeun = createCheckbox(L("M02_WizSkCheck"), { checked: wantSangeun, onChange: (on) => (wantSangeun = on), }); @@ -171,54 +184,89 @@ export function buildHopyoWizard(): HTMLElement { ) .catch((error: unknown) => showToast(why(error), "error")), }); - const sangeun = createButton({ - label: "단가산출 만들기", - variant: "filled", - onClick: () => void makeSangeun(), - }); - sangeun.title = - "엑셀 산근 블록마다 바탕 없는 단가산출(엑셀 단가 그대로 · 이미 만든 블록은 그대로)"; - sangeun.dataset.hopyoSangeunMake = ""; const finish = createButton({ label: "끝내기", variant: "ghost", onClick: () => void end() }); finish.dataset.hopyoFinish = ""; + const at: LeftScope = { scope, target: scope, key: file.키 }; + const band = el("div", { attrs: { "data-hopyo-left": "" } }); + let nos: number[] = []; + let map: LeftMap | null = null; + const pick = createButton({ + label: L("M02_LeftOpen"), + variant: "ghost", + size: 28, + onClick: () => openLeft({ ...at, 파일: file.파일, nos, ...(map ? { map } : {}) }, paint), + }); + pick.dataset.hopyoLeftOpen = ""; + /** 띠 — 남은 줄 있으면 [끝내기] 막음 · 까닭 · 남은 수 */ + function paint(got: LeftMap | null): void { + map = got; + const n = got ? leftCount(got) : null; + finish.disabled = !!n?.줄; + finish.title = n?.줄 ? L("M02_LeftBlocked") : ""; + band.dataset.left = n ? String(n.줄) : ""; + band.replaceChildren( + createBanner({ + tone: !n ? "info" : n.줄 ? "warning" : "success", + text: !n + ? L("M02_LeftUnknown") + : n.줄 + ? `${L("M02_LeftBand").replace("{n}", String(n.줄)).replace("{h}", String(n.호표))} · ${L("M02_LeftBlocked")}` + : L("M02_LeftZero"), + actions: [pick], + }), + ); + } + paint(null); + async function recount(): Promise { + if (!nos.length) nos = (await readHopyo(scope, file.키)).호표.map((h) => h.번); + const got = await countLeft(at, nos, (i, all) => + band.replaceChildren(createText(`${L("M02_LeftCounting")} ${i + 1} / ${all}`, "muted", 14)), + ); + paint(got); + return got; + } const back = createButton({ label: "처음으로", variant: "link", onClick: () => void first() }); back.title = "작업은 그대로 보관 — 처음 화면 「작업 중」에서 이어서"; 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_WizIlwiHead"), level: 4, divider: true }).root, createText( "엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가 · 단가산출은 고른 층에 들어감", "hint", ), - el("div", { - className: "ui-toolbar", - children: [view, ...(wantSangeun ? [sangeun] : []), finish, back], - }), + band, + el("div", { className: "ui-toolbar", children: [view, finish, back] }), ); - async function makeSangeun(): Promise { - sangeun.disabled = true; - try { - const got = await call<{ 블록: number; 만듦: unknown[]; 그대로: number }>( - `/layers/${layer}/hopyo/${encodeURIComponent(file.키)}/sangeun`, - { method: "POST" }, - ); - const kind = got.블록 ? "success" : "warning"; - showToast( - `단가산출 ${got.만듦.length} 만듦 · 그대로 ${got.그대로} · 산근 블록 ${got.블록}`, - kind, - ); - } catch (error) { - showToast(why(error), "error"); - } finally { - sangeun.disabled = false; - } - } - async function end(): Promise { steps.set("3"); + finish.disabled = true; + let got: LeftMap; + try { + got = await recount(); + } catch (error) { + showToast(why(error), "error"); + finish.disabled = false; + return steps.set("2"); + } + if (leftCount(got).줄) { + showToast(L("M02_LeftBlocked"), "warning"); + openLeft({ ...at, 파일: file.파일, nos, map: got }, paint); + return steps.set("2"); + } + const loose = [...got.values()].filter( + (x) => !x.one.호표.만든?.확인 && hasDraft(draftKey(scope, file.키, x.one.호표.번)), + ).length; const ok = await showConfirmDialog( - `「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출과 별칭 장부는 남음 · 하나라도 만들었으면 같은 엑셀은 다시 올리지 못함`, + `「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출과 별칭 장부는 남음 · 하나라도 만들었으면 같은 엑셀은 다시 올리지 못함` + + (loose ? ` · ${L("M02_LeftLoose").replace("{n}", String(loose))}` : ""), "끝내기", ); if (!ok) return steps.set("2"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts new file mode 100644 index 000000000..adf0bb19f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts @@ -0,0 +1,145 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_WizardSk.ts + * 일위대가 불러오기 마법사 ② 단가산출 단계(PLAN 729-3 · 요구서 내역19 1장 7 · 2장 3) — + * · [단가산출 만들기] = 서버 `sangeun`(산근 블록마다 매칭 → 같으면 기초 · 다르면 SK = 기초 + 엑셀 차이 · 맞는 기초 없음 = 고름 대기) + * · 만든 줄 표 = 블록 · 이름 · 키 · 갈래(기초 · SK 차이 있음) · 이미 만든 블록은 「그대로」 수만 + * · 고름 대기 표 = 블록마다 후보(판정 · 글) 고르기 칸 → [고른 기초로 만들기](바탕 = 고른 기초 · 엑셀 품 견줘 SK) + * · 별칭 제안 = 이 층 별칭 장부에 남음 · 모두에게 쓰이려면 시스템 관리자가 M01 › 별칭 장부에서 승인(링크 · 716 길 그대로) + * 계산 · 판정은 서버 — 화면은 보이기 · 고름만 · 자동 저장 없음(누를 때만 서버). + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import { createBadge } from "@ui/ui_template_badge"; +import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { createTable } from "@ui/ui_template_table"; +import { createText } from "@ui/ui_template_text"; +import { + makeSangeun, + type HopyoScope, + type SangeunBuilt, + type SangeunPick, +} from "./M02_MasterTemplete_Hopyo_Api"; + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +/** 고름 대기 후보 글 — 「이름 [단위] · 판정 · 글」 */ +export const candLabel = (c: { 이름: string; 단위: string; 판정: string; 글: string }): string => + `${c.이름} [${c.단위}] · ${c.판정}${c.글 && c.글 !== c.판정 ? ` · ${c.글}` : ""}`; + +/** 만든 갈래 꼬리표 — 기초 = 초록 · SK = 차이 있음 */ +const kindBadge = (kind: string): HTMLElement => + kind === "SK" + ? createBadge(L("M02_WizSkDiff"), "accent") + : createBadge(L("M02_WizSkBase"), "success"); + +/** ② 단가산출 단계 칸 — `root` 를 마법사 본 칸에 붙임 */ +export function sangeunStep(scope: HopyoScope, key: string): { root: HTMLElement } { + 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 root = el("div", { + className: "m02-hopyo", + children: [el("div", { className: "ui-toolbar", children: [make] }), out], + }); + + async function run(picks?: SangeunPick): Promise { + make.disabled = true; + try { + const got = await makeSangeun(scope, key, picks); + draw(got); + showToast(summary(got), got.고름대기.length ? "warning" : "success"); + } catch (error) { + showToast(why(error), "error"); + } finally { + make.disabled = false; + } + } + + 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)); + + function draw(got: SangeunBuilt): void { + const made = createTable( + [ + { label: L("M02_WizSkBlock"), width: "80px" }, + { label: L("M02_SangeunColName") }, + { label: L("M02_WizSkKey"), width: "110px" }, + { label: L("M02_WizSkKind"), width: "120px" }, + ], + got.만듦.map((x) => [`단산${x.번}`, x.이름, x.키, kindBadge(x.갈래)]), + { density: "data", empty: L("M02_WizSkNoneMade") }, + ); + const selects = 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: "40%" }, + ], + got.고름대기.map((x) => { + const pick = createSelectField({ + options: [{ value: "", text: L("M02_WizSkNoPick") }].concat( + x.후보.map((c) => ({ value: c.로직, text: candLabel(c) })), + ), + compact: true, + size: 24, + ariaLabel: `${L("M02_LeftBase")} 단산${x.번}`, + }); + pick.select.dataset.wizWait = String(x.번); + selects.set(x.번, pick.select); + return [`단산${x.번}`, x.이름, x.단위 ?? "", x.까닭, pick.root]; + }), + { 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) picks[no] = { 로직: s.value }; + if (!Object.keys(picks).length) return showToast(L("M02_WizSkPickFirst"), "warning"); + void run(picks); + }, + }); + again.dataset.wizRemake = ""; + const ledger = createButton({ + label: L("M02_WizSkLedger"), + variant: "link", + onClick: () => void window.open(`#/${ROUTES.M01_MASTER_DATA}`, "_blank"), + }); + out.replaceChildren( + createText(summary(got), "muted", 14), + made, + ...(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, + ], + }), + ); + } + + return { root }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts index 8c05e65c5..78070cdfa 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts @@ -7,6 +7,7 @@ * 자재 줄 = 이름 · 규격 · 수량 · 단위 · 재료비 단가 = 기준단가 · 금액 = 반영 · 별도계상은 비고 「합계 밖」 * 빈 줄(금액 모름 · 수량 없음) = 합계 줄 「빈 줄 n」 붉게 — 701-4 경고와 같은 줄 * 규격 열(704-4 · `Labor_Spec`) = 수량 칸 열마다 · 고칠 때 격자 위 규격 줄 + 수량 칸 고침 + * 안 이은 고정 줄(729-3 · `Labor_Unlinked`) = 격자 위 띠 「M01 연결 없음 n 줄」 · 고칠 때 [연결] * ========================================================================== */ import { @@ -23,6 +24,7 @@ import { tc } from "./M02_MasterTemplete_Labor_Text"; import { valueLine } from "./M02_Labor_Values_Model"; import { specBar, specCells, withSpecs, type SpecEdit } from "./M02_MasterTemplete_Labor_Spec"; import { pasteInto } from "./M02_MasterTemplete_Labor_Paste"; +import { unlinkedBar } from "./M02_MasterTemplete_Labor_Unlinked"; const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney => m @@ -121,6 +123,21 @@ export const laborGrid = ( logics: ReadonlyMap, edit?: SpecEdit | false, spec = true, +): HTMLElement => { + const grid = gridOf(doc, calc, logics, edit, spec); + const bar = unlinkedBar(doc, edit); + if (!bar) return grid; + const box = document.createElement("div"); + box.append(bar, grid); + return box; +}; + +const gridOf = ( + doc: LaborDoc, + calc: LaborCalc | null, + logics: ReadonlyMap, + edit?: SpecEdit | false, + spec = true, ): HTMLElement => { const block = laborBlock(doc, calc, logics); if (!edit) return createHopyoGrid([block]).root; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Unlinked.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Unlinked.ts new file mode 100644 index 000000000..920f98363 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Unlinked.ts @@ -0,0 +1,110 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Unlinked.ts + * 일위대가표 안 이은 줄 띠 · [연결](PLAN 729-3 · 요구서 내역19 1장 8 · 분석 19-B D-2) — + * · 안 이은 줄 = 고정 줄 중 비율 줄 아님 · 이음(노임 · 자재 · 기계) 모두 빈 것(서버 목록 summary `안이은줄` 과 같은 규칙) + * · 띠 = 「M01 연결 없음 n 줄(옛 자료)」 · 고칠 수 있으면 [연결] → 줄마다 [찾기](노임 LB · 자재 MT · 721 찾아 넣기 창) · + * [기계](M01 기계 EQ) — 고정 줄 이름 · 규격 · 엑셀 숫자(출처)는 그대로 · 이음만 붙임 → 상세 고침(저장은 [저장]) + * 계산은 서버(이음 = 단가 책 값) — 화면은 고름만. + * ========================================================================== */ + +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 { openModal } from "@ui/ui_template_modal"; +import { createTable } from "@ui/ui_template_table"; +import { getActiveLayer, getActiveOwner } from "./M02_MasterTemplete_Api_Fetch"; +import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; +import { pickMachine } from "./M02_MasterTemplete_Hopyo_Left"; +import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api"; +import type { LaborFixed } from "./M02_MasterTemplete_Labor_Calc"; + +/** 안 이은 고정 줄 */ +export const unlinkedFixes = (doc: LaborDoc): LaborFixed[] => + doc.담은로직 + .map((row) => row.고정) + .filter( + (fx): fx is LaborFixed => !!fx && !fx.비율 && !Object.values(fx.이음 ?? {}).some(Boolean), + ); + +/** 이음 붙임 — 하나만(노임 · 자재 · 기계) */ +const linkTo = (fx: LaborFixed, kind: "노임" | "자재" | "기계", code: string): void => { + fx.이음 = { 노임: null, 자재: null, 기계: null, [kind]: code }; +}; + +/** [연결] 창 — 줄마다 찾기 · 기계 · 고르면 `changed` */ +function openLink(doc: LaborDoc, changed: () => void): void { + openModal({ + title: L("M02_GridLinkTitle"), + closeLabel: L("M02_Close"), + width: "960px", + dialogClass: "m02-modal", + mount: (body) => { + const draw = (): void => { + const rows = unlinkedFixes(doc).map((fx) => { + const done = (kind: "노임" | "자재" | "기계", code: string): void => { + linkTo(fx, kind, code); + changed(); + draw(); + }; + const find = createButton({ + label: L("M02_LeftFind"), + size: 24, + variant: "ghost", + onClick: () => + openFindPicker({ + layer: getActiveLayer(), + owner: getActiveOwner(), + title: `${fx.이름} ${fx.규격 ?? ""}`.trim(), + logic: () => showToast(L("M02_GridLinkNoLogic"), "info"), + element: (e) => done(e.종류 === "인력" ? "노임" : "자재", e.코드), + }), + }); + const machine = createButton({ + label: L("M02_LeftMachine"), + size: 24, + variant: "ghost", + onClick: () => pickMachine(fx, (code) => done("기계", code)), + }); + return [ + fx.이름, + fx.규격 ?? "", + fx.단위 ?? "", + el("div", { className: "ui-toolbar", children: [find, machine] }), + ]; + }); + body.replaceChildren( + createTable( + [ + { label: L("M02_SangeunColName") }, + { label: L("M02_LeftSpec") }, + { label: L("M02_SangeunColUnit"), width: "64px" }, + { label: L("M02_LeftPick"), width: "180px" }, + ], + rows, + { density: "data", empty: L("M02_LeftZero") }, + ), + ); + }; + draw(); + }, + }); +} + +/** 띠 — 안 이은 줄이 없으면 null · `edit` 있으면 [연결] */ +export function unlinkedBar( + doc: LaborDoc, + edit?: ((qtyOnly: boolean) => void) | false, +): HTMLElement | null { + const n = unlinkedFixes(doc).length; + if (!n) return null; + const go = edit + ? [createButton({ label: L("M02_GridLink"), size: 24, onClick: () => openLink(doc, () => edit(false)) })] + : []; // prettier-ignore + const bar = createBanner({ + tone: "warning", + text: L("M02_GridUnlinked").replace("{n}", String(n)), + actions: go, + }); + bar.dataset.laborUnlinked = String(n); + return bar; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts index 7a3446ca4..d0f324a4a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts @@ -2,6 +2,7 @@ * M02_MasterTemplete_Library_List.ts * 서고 목록표(51-4 · 717-2a) — 좌측 일위대가 · 단가산출 층 단추를 누르면 상세 칸에 뜸. * 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세 · + * 일위대가 = 안 이은 줄 수(729-3 · 서버 `summary` · 0 은 빈칸). * 단가산출 = 구분 둘 대신 바탕(M01 로직 · 없으면 「바탕 없음」) · 고친 줄 수 · 쓰는 곳 수(서버 `summary`). * 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로). * 꼴 = M01 단가산출 로직 목록 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표(`m01-master__grid`) 를 M01 에서 import. @@ -44,6 +45,8 @@ type Row = TemplateInfo & { 바탕?: string | null; 고친줄수?: number; 쓰는곳수?: number; + /** 일위대가 안 이은 고정 줄 수(729-2 summary) */ + 안이은줄?: number; }; export interface LibraryListOptions { @@ -80,6 +83,7 @@ const COLUMNS: Record [string, (row: Row) => string][]> = { [tc("Lib_Name"), (r) => r.제목 ?? ""], [tc("Lib_Unit"), (r) => r.단위 ?? ""], [tc("Lib_Base"), (r) => money(r.기준금액)], + [L("M02_LibUnlinked"), (r) => (r.안이은줄 ? String(r.안이은줄) : "")], // 729-3 [tc("Lib_Date"), (r) => r.수정일], ], sangeun: () => [ diff --git a/resources/tester/helper_729_3_hopyo_left.cjs b/resources/tester/helper_729_3_hopyo_left.cjs new file mode 100644 index 000000000..8dc2ab7eb --- /dev/null +++ b/resources/tester/helper_729_3_hopyo_left.cjs @@ -0,0 +1,100 @@ +/* 729-3 안 이은 줄 화면 헬퍼 — `Hopyo_Left` · `Hopyo_Check` · `Api_Fetch` · `Labor_Unlinked` · `Hopyo_WizardSk` 도우미를 + * 트랜스파일해 Node 로 돌림 · fetch 대역(부른 길 · 몸 모음 · 409 몸) · sessionStorage 대역 · + * stdin = {one, made, left, fixes} · stdout = JSON. */ +const fs = require("fs"); +const path = require("path"); +const { makeLoader, memoryStorage } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api", ROUTES: {} } }; +const STUB_FILES = [ + "M02_MasterTemplete_Labor_Pick", + "M02_Material_Pick", + "M02_MasterTemplete_Labor_Api", + "M02_MasterTemplete_Labor_Input", + "M01_MasterData_UI_LogicLab_Source", + "M01_MasterData_UI_Logic_Api", + "M02_MasterTemplete_Hopyo_CheckRows", + "M02_MasterTemplete_Find_Pick", +]; +const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) }); +const { loadTs } = makeLoader({ + root: ROOT, + requireFor: (full) => (request) => { + if (STUBS[request]) return STUBS[request]; + if (STUB_FILES.some((name) => request.endsWith(name))) return stub; + if (request.startsWith(".") && !request.endsWith(".css")) + return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return stub; + }, +}); +global.sessionStorage = memoryStorage(); + +(async () => { + const input = JSON.parse(fs.readFileSync(0, "utf8")); + const calls = []; + global.fetch = async (url, init = {}) => { + const body = init.body ? JSON.parse(init.body) : null; + calls.push({ url, method: init.method ?? "GET", body }); + if (url.includes("/hopyo-build")) + return { status: 409, ok: false, json: async () => ({ detail: { 까닭: "남음", 남은줄: input.left } }) }; // prettier-ignore + if (url.includes("/hopyo-unlinked")) { + const done = new Set((body.줄 ?? []).map((x) => x.차례)); + const left = input.left.filter((x) => !done.has(x.차례)); + return { status: 200, ok: true, json: async () => ({ 남은줄: left, 수: left.length }) }; + } + const no = Number(url.split("?")[0].split("/").pop()); + const one = { ...input.one, 호표: { ...input.one.호표, 번: no, 만든: input.made.includes(no) ? { 키: "SC-1", 확인: true } : null } }; // prettier-ignore + return { status: 200, ok: true, json: async () => one }; + }; + const M02 = path.join(ROOT, "M02_MasterTemplete"); + const check = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Check.ts")); + 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" }; + + // 만들기 409 → 남은 줄 · 까닭 글 + let stale = null; + try { + await api.buildHopyo(scope, { 층: "personal", 주인: null, 키: "HP-000001", 번: 2 }, []); + } catch (error) { + stale = { message: error.message, left: api.leftOf(error), name: error.constructor.name }; + } + const before = await left.countLeft(at, [1, 2]); + // 줄마다 고름 — 노임 · 기계 · 비율 · 우리에 없음 · 로직 + const [a, b, c, d, e] = input.left; + left.put(at, 2, a.차례, [{ 노임: "LB000002", 이름: "보통인부", 입력값: {}, 배: 1 }]); + left.put(at, 2, b.차례, [{ 기계: "EQ000069", 이름: "덤프트럭", 입력값: {}, 배: 1 }]); + left.put(at, 2, c.차례, [{ 비율: "노무비", 이름: "노무비", 입력값: {}, 배: 1 }]); + left.put(at, 2, d.차례, null); + const half = await left.countLeft(at, [1, 2]); + left.put(at, 2, e.차례, [{ 로직: "GF000113", 이름: "되메우기", 입력값: {}, 배: 1 }]); + const after = await left.countLeft(at, [1, 2]); + const body = left.draftLines(at, after.get(2).one); + const count = (m) => left.leftCount(m); + process.stdout.write( + JSON.stringify({ + stale, + before: count(before), + half: count(half), + after: count(after), + body, + 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) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/helper_m02_hopyo_check.cjs b/resources/tester/helper_m02_hopyo_check.cjs index b83a8f709..df748f5bf 100644 --- a/resources/tester/helper_m02_hopyo_check.cjs +++ b/resources/tester/helper_m02_hopyo_check.cjs @@ -14,6 +14,7 @@ const STUB_FILES = [ "M02_MasterTemplete_Labor_Api", "M02_MasterTemplete_Labor_Input", "M01_MasterData_UI_LogicLab_Source", + "M02_MasterTemplete_Hopyo_Left", // 남은 줄 창(729-3 · DOM · 찾기 창) "M02_MasterTemplete_Hopyo_CheckRows", // 표 그리기(DOM · Labor_Rows) — 359-3 ]; const opened = []; diff --git a/resources/tester/helper_m02_hopyo_pick.cjs b/resources/tester/helper_m02_hopyo_pick.cjs index 7ae44299e..79ba22ba6 100644 --- a/resources/tester/helper_m02_hopyo_pick.cjs +++ b/resources/tester/helper_m02_hopyo_pick.cjs @@ -15,6 +15,7 @@ const STUB_FILES = [ "M02_MasterTemplete_Labor_Api", "M02_MasterTemplete_Labor_Input", "M01_MasterData_UI_LogicLab_Source", + "M02_MasterTemplete_Hopyo_Left", // 남은 줄 창(729-3 · DOM · 찾기 창) ]; const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) }); const { loadTs } = makeLoader({ diff --git a/resources/tester/helper_m02_hopyo_seam.cjs b/resources/tester/helper_m02_hopyo_seam.cjs index 675e1a957..11150be70 100644 --- a/resources/tester/helper_m02_hopyo_seam.cjs +++ b/resources/tester/helper_m02_hopyo_seam.cjs @@ -15,6 +15,7 @@ const STUB_FILES = [ "M02_MasterTemplete_Labor_Api", "M02_MasterTemplete_Labor_Input", "M01_MasterData_UI_LogicLab_Source", + "M02_MasterTemplete_Hopyo_Left", // 남은 줄 창(729-3 · DOM · 찾기 창) ]; const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) }); const { loadTs } = makeLoader({ diff --git a/resources/tester/test_729_3_hopyo_left_screen.py b/resources/tester/test_729_3_hopyo_left_screen.py new file mode 100644 index 000000000..b344e21da --- /dev/null +++ b/resources/tester/test_729_3_hopyo_left_screen.py @@ -0,0 +1,115 @@ +"""일위대가 불러오기 마법사 안 이은 줄 화면(PLAN 729-3 · 요구서 내역19 1장 8 · 2장 4). + +헬퍼(`helper_729_3_hopyo_left.cjs` · fetch · sessionStorage 대역)로 진짜 TS 를 돌림 — +만들기 409 `{까닭, 남은줄}` = StaleError 까닭 글 + 남은 줄 · 남은 수 = 안 만든 호표만(만든 호표는 안 물음) · +줄마다 고름(노임 · 기계 · 비율 · 우리에 없음 · 로직) = 확인 창 캐시 → 만들기 몸 그대로 · 0 될 때까지 · +일위대가표 안 이은 고정 줄(비율 · 이음 있는 줄 뺌) · 고름 대기 후보 글. +소스: [끝내기] = 남은 줄 다시 셈 → 있으면 막음(띠 · 표) · 단가산출 칸 · 목록 열 · 화면 요소 = 부품 상자 · 700 줄. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +M02 = Path(HERE).parents[1] / "M02_MasterTemplete" + + +def _line(no: int, kind: str = "자재", state: str = "후보") -> dict: + match = {"열쇠": f"줄{no}", "상태": state, "체크": False, "대상": []} + return {"차례": no, "이름": f"줄{no}", "규격": "", "수량": 1, "단위": "㎥", "갈래": kind, + "단가": {}, "금액": {}, "비고": "", "맞춤": match} # fmt: skip + + +def _left(no: int, kind: str) -> dict: + return {"차례": no, "이름": f"줄{no}", "규격": "", "단위": "㎥", "수량": 1, "갈래": kind, + "까닭": "이을 것 없음", "후보": []} # fmt: skip + + +LINES = [_line(0, "노임"), _line(1, "기계"), _line(2, "%", "%"), _line(3), _line(4, "단가산출")] +LEFT = [_left(0, "노임"), _left(1, "기계"), _left(2, "%"), _left(3, "자재"), _left(4, "단가산출")] +FIXES = [ + {"고정": {"이름": "안이음", "단가": {}, "이음": {"노임": None, "자재": None, "기계": None}}}, + { + "고정": { + "이름": "이음", + "단가": {}, + "이음": {"노임": "LB000002", "자재": None, "기계": None}, + } + }, + {"고정": {"이름": "비율", "단가": {}, "비율": {"기준": "노무비", "비목": "경비"}}}, + {"로직": "GF000113"}, +] + + +def _run() -> dict: + payload = { + "one": {"키": "HP-000001", "파일": "북부청.xls", "호표": {"이름": "x", "줄": LINES}}, + "made": [1], + "left": LEFT, + "fixes": FIXES, + } + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_729_3_hopyo_left.cjs")], + input=json.dumps(payload, 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() + # 만들기 409 = StaleError · 까닭 글 · 남은 줄 그대로 + assert got["stale"]["name"] == "StaleError" and got["stale"]["message"] == "남음" + assert got["stale"]["left"] == LEFT + # 만든 호표(1) 는 안 물음 · 안 만든 호표(2) 만 남은 줄 + assert got["before"] == {"줄": 5, "호표": 1} + assert all(u.endswith("/layers/personal/hopyo-unlinked") for u in got["unlinkedCalls"]) + assert len(got["unlinkedCalls"]) == 3 # 셈 세 번 × 안 만든 호표 하나 + assert got["half"] == {"줄": 1, "호표": 1} + assert got["after"] == {"줄": 0, "호표": 0} + # 고른 것 = 만들기 몸(서버 이음 대상 꼴 · 입력값 없음 · 배 1) + body = {x["차례"]: x for x in got["body"]} + assert body[0] == {"차례": 0, "대상": [{"노임": "LB000002", "배": 1}]} + assert body[1] == {"차례": 1, "대상": [{"기계": "EQ000069", "배": 1}]} + assert body[2] == {"차례": 2, "대상": [{"비율": "노무비", "배": 1}]} # % 줄 = 밑수 고른 것만 + assert body[3] == {"차례": 3, "뺌": "우리에 없음"} + assert body[4] == {"차례": 4, "대상": [{"로직": "GF000113", "입력값": {}, "배": 1}]} + assert got["lastBody"]["줄"] == got["body"] and got["lastBody"]["번"] == 2 + # 일위대가표 띠 = 이음 · 비율 없는 고정 줄만 + assert got["fixes"] == ["안이음"] + assert got["cand"] == "되메우기 [㎥] · 후보" + assert got["cand2"] == "되메우기 [㎥] · 다른 점 · 보통인부 품 다름" + + +def _text(name: str) -> str: + return (M02 / name).read_text(encoding="utf-8") + + +def test_마법사_끝내기_막음_단가산출_칸_목록_열(): + wizard = _text("M02_MasterTemplete_Hopyo_Wizard.ts") + end = wizard.split("async function end()")[1] + 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 and "picks[no] = { 로직: s.value }" in sk + lib = _text("M02_MasterTemplete_Library_List.ts") + assert 'L("M02_LibUnlinked"), (r) => (r.안이은줄' in lib + assert "unlinkedBar(doc, edit)" in _text("M02_MasterTemplete_Labor_Grid.ts") + for name in ( + "M02_MasterTemplete_Hopyo_Left.ts", + "M02_MasterTemplete_Hopyo_WizardSk.ts", + "M02_MasterTemplete_Labor_Unlinked.ts", + ): + src = _text(name) + assert len(src.splitlines()) <= 700, name + assert 'el("button"' not in src and 'el("input"' not in src, name # 부품 상자만 + assert not re.search(r"#[0-9a-fA-F]{3,6}|rgb\(", src), name # 색 = 공통 값 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 18f86b438..5fd08cda8 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -358,7 +358,9 @@ def test_좌측_판_자리와_권한(): assert 'accept: ".xls,.xlsx"' in wizard and "input.hidden = true;" in wizard assert "accessFor(layer, null, user).canWrite" in wizard # 결과 층 = 쓸 수 있는 층만 assert 'let pick: ManageLayer = "personal";' in wizard # 기본 개인 - assert "let wantSangeun = false;" in wizard and "/sangeun`" in wizard # 결과 단가산출 체크 + assert ( + "let wantSangeun = false;" in wizard and "sangeunStep(scope, file.키)" in wizard + ) # 결과 단가산출 체크 → 단가산출 칸(729-3) def test_로직_추가_한_곳과_파일_규칙(): diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index b8fb14c9c..7cd24189c 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -255,4 +255,76 @@ export const ui_locales_m2 = { M02_FindLogic: ["단가산출 기초", "Unit-cost logic"], M02_FindElementLater: ["일위대가 표 개편 뒤", "After the unit-cost table rework"], M02_FindOptions: ["옵션 {n}", "{n} options"], + // 729-3 불러오기 마법사 — 단가산출 단계 · 안 이은 줄 · 일위대가표 띠 + M02_WizSkCheck: ["단가산출(산근 블록 → 기초 · SK)", "Unit-cost calcs (blocks → base · SK)"], + M02_WizSkHead: ["단가산출", "Unit-cost calcs"], + M02_WizIlwiHead: ["일위대가", "Unit-price sheets"], + M02_WizSkMake: ["단가산출 만들기", "Make unit-cost calcs"], + M02_WizSkMakeTip: [ + "산근 블록마다 맞춤 → 같으면 기초 · 다르면 SK(기초 + 엑셀 차이) · 맞는 기초 없음 = 고름 대기 · 이미 만든 블록은 그대로", + "Each block: same = base · different = SK (base + Excel diff) · no match = waits for a pick · made blocks kept", + ], + M02_WizSkSummary: [ + "산근 블록 {b} · 기초 {base} · SK {sk} · 그대로 {keep} · 고름 대기 {wait}", + "Blocks {b} · base {base} · SK {sk} · kept {keep} · waiting {wait}", + ], + M02_WizSkBlock: ["블록", "Block"], + M02_WizSkKey: ["키", "Key"], + M02_WizSkKind: ["갈래", "Kind"], + M02_WizSkBase: ["기초", "Base"], + M02_WizSkDiff: ["SK · 차이 있음", "SK · differs"], + M02_WizSkNoneMade: ["새로 만든 단가산출 없음", "No new unit-cost calcs"], + M02_WizSkNoWait: ["고름 대기 없음", "Nothing waiting"], + M02_WizSkNoPick: ["고르지 않음", "Not picked"], + M02_WizSkWaitHint: [ + "고름 대기 — 맞는 기초 없음 · 블록마다 기초를 골라 [고른 기초로 만들기] = 그 기초 + 엑셀 차이 SK", + "Waiting — no matching base · pick a base per block, then make = that base + Excel diff", + ], + M02_WizSkRemake: ["고른 기초로 만들기", "Make with picked bases"], + M02_WizSkPickFirst: ["고른 기초 없음 — 블록마다 먼저 고름", "No base picked yet"], + M02_WizSkAlias: [ + "별칭 제안 {n} 줄 — 이 층 별칭 장부에 남음 · 모두에게 쓰임 = 시스템 관리자 승인", + "Alias proposals {n} — kept in this layer's ledger · system admin approves for everyone", + ], + M02_WizSkLedger: ["M01 별칭 장부", "M01 alias ledger"], + M02_LeftTitle: ["안 이은 줄", "Unlinked rows"], + M02_LeftHint: [ + "만들면 M01 · 기초 · SK 에 안 이어질 엑셀 줄 — 줄마다 이을 것을 고름 · 고른 뒤 엑셀 보기 창 [저장] 또는 호표 [수정] 생성", + "Excel rows that would stay unlinked — pick a link per row, then save in the sheet window", + ], + M02_LeftBand: ["안 이은 줄 {n} (호표 {h})", "Unlinked rows {n} (sheets {h})"], + M02_LeftZero: ["안 이은 줄 0", "No unlinked rows"], + M02_LeftUnknown: [ + "안 이은 줄 — 아직 안 셈 · [끝내기] 때 다시 셈", + "Unlinked rows — not counted yet", + ], + M02_LeftBlocked: ["안 이은 줄이 남아 [끝내기] 막힘", "Finish blocked — unlinked rows left"], + M02_LeftLoose: ["고른 뒤 안 만든 호표 {n} 개는 빠짐", "{n} picked but unmade sheets are dropped"], + M02_LeftOpen: ["안 이은 줄 고르기", "Pick unlinked rows"], + M02_LeftCounting: ["안 이은 줄 셈 중", "Counting unlinked rows"], + M02_LeftHopyo: ["호표", "Sheet"], + M02_LeftSpec: ["규격", "Spec"], + M02_LeftKind: ["갈래", "Kind"], + M02_LeftWhy: ["까닭", "Reason"], + M02_LeftPicked: ["고른 것", "Picked"], + M02_LeftPick: ["고름", "Pick"], + M02_LeftBase: ["기초", "Base"], + M02_LeftMakeSk: ["SK 만들기", "Make SK"], + M02_LeftSkNone: ["만든 단가산출 없음 — 고름 대기 그대로", "Nothing made — still waiting"], + M02_LeftChoose: ["고르기", "Pick"], + M02_LeftFind: ["찾기", "Find"], + M02_LeftMachine: ["기계", "Machine"], + M02_LeftMachineFind: ["기계 이름 · 규격으로 찾기", "Search machines by name or spec"], + M02_LeftNoneTip: [ + "우리 M01 에 없음 — 엑셀 숫자 그대로 고정 줄(사용자가 고른 것으로 남음)", + "Not in M01 — keep the Excel numbers as a fixed row", + ], + M02_GridUnlinked: ["M01 연결 없음 {n} 줄(옛 자료)", "{n} rows not linked to M01 (old data)"], + M02_GridLink: ["연결", "Link"], + M02_GridLinkTitle: ["안 이은 줄 연결", "Link unlinked rows"], + M02_GridLinkNoLogic: [ + "고정 줄은 노임 · 자재 · 기계로 이음 — 기초는 [로직 추가]로 줄을 바꿈", + "Fixed rows link to labor, material or machine only", + ], + M02_LibUnlinked: ["안 이은 줄", "Unlinked"], } as const;