diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 8036a0d01..60be6edbe 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -125,6 +125,28 @@ def clean(doc: Any) -> dict[str, Any]: return out +def from_combo(key: str) -> dict[str, Any]: + """옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 — + 담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키). + 없는 조합은 404.""" + from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos + + row = combos.find(key) + if row is None: + raise store.StoreError(404, f"없는 일위대가 조합 「{key}」") + layer, owner = combos.where(key) or (None, None) + return { + "단위": row.get("단위"), + "본뜬": {"층": layer, "주인": owner, "키": key}, + "담은로직": [ + {"로직": one.get("로직"), "메모": one.get("메모"), "입력값": {}, "수량": None} + for one in row.get("담은로직") or [] + if isinstance(one, dict) + ], + "자재": [], + } + + # ── 알약 · 면적 값 ──────────────────────────────────────────────────── def material_key(code: str) -> str: return f"{MATERIAL_HEAD}{code}" diff --git a/M02_MasterTemplete/M02_Labor_Migrate.py b/M02_MasterTemplete/M02_Labor_Migrate.py new file mode 100644 index 000000000..37527b31b --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Migrate.py @@ -0,0 +1,90 @@ +"""옛 일위대가 조합 → 구조물 열마다 일위대가 문서 — 한 번 돌리는 도구(PLAN 39-1b · 설계_정함 1장). + +층마다 구조물 열마다 `labor/<열id>.json`: + - 열 `일위대가` 키가 있으면 그 조합의 담은로직 · 단위 복사(`본뜬.키` = 옛 키 · 입력값 {} · + 수량 null · 자재 []) — 여러 열이 쓰던 조합도 열마다 따로 복사 + - 키 없는 열 = 빈 일위대가 · 번호 SU 는 저장소가 줌(도번과 같은 뒷자리) + - 몇 번 돌려도 같음 — 이미 그 키를 본뜬 문서 · 사람이 채운 문서(담은로직 · 자재가 있음)는 그대로 +옛 조합 파일 · 집계표 열 `일위대가` 칸은 손대지 않음(실무문서 · 은퇴는 39-1b B9). + + ./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py # 시스템 층 + ./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py --storage # 회사 · 개인 층 + [--dry-run] +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from M02_MasterTemplete import M02_Labor_Doc as labor # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 +from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402 + + +def _filled(doc: dict) -> bool: + return bool(doc.get("담은로직") or doc.get("자재")) + + +def migrate(folder: Path, dry: bool = False) -> dict[str, list[str]]: + """`folder` 층 하나 — `{만듦, 옮김, 그대로, 못함}`(열 id · 못함은 까닭 붙임).""" + out: dict[str, list[str]] = {"만듦": [], "옮김": [], "그대로": [], "못함": []} + with store.at(folder): + for col in store._structure_columns(): + cid, key = col["id"], str(col.get("일위대가") or "") + path = store._path("labor", cid) + have = json.loads(path.read_bytes()) if path.is_file() else None + done = have is not None and (have.get("본뜬") or {}).get("키") == key + if have is not None and (_filled(have) or not key or done): + out["그대로"].append(cid) + continue + if not key: + out["만듦"].append(cid) + if not dry: + store._ensure("labor", cid) + continue + try: + body = labor.from_combo(key) + except store.StoreError as e: + out["못함"].append(f"{cid} · {e.detail}") + continue + out["옮김"].append(f"{cid} ← {key}") + if dry: + continue + store._ensure("labor", cid) # 번호(SU · 같은 뒷자리) + got = store.read("labor", cid) + store.write("labor", cid, got["판"], {**got["문서"], **body}) + return out + + +def layer_folders(storage: bool) -> list[Path]: + """시스템 층 하나 · 또는 storage 회사 · 개인 층(구조물집계표가 있는 것 · 프로젝트 빼고).""" + if not storage: + return [layers.system_dir()] + root = layers.storage_root() + table = Path("table") / f"{store.STRUCTURE_TABLE}.json" + found = [ + *root.glob(f"*/{layers.TEMPLATES_DIRNAME}"), + *root.glob(f"*/*/{layers.TEMPLATES_DIRNAME}"), + ] + return sorted(p for p in found if (p / table).is_file()) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--storage", action="store_true", help="회사 · 개인 층(storage)") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + report: dict[str, Any] = {} + for folder in layer_folders(args.storage): + report[str(folder)] = migrate(folder, args.dry_run) + print(json.dumps(report, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py index ef0abae0d..935336213 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -184,12 +184,6 @@ def pills(key: str) -> dict[str, Any]: return combo_store.combo_pills(_find(_load()[0], key)) -def preview(combo: dict | str, inputs: dict | None = None) -> dict[str, Any]: - """저장 안 함 — `combo` 는 조합 줄 통째 또는 지금 층 키.""" - row = _find(_load()[0], combo) if isinstance(combo, str) else combo - return _m01(combo_store.combo_preview, row, inputs) - - # ── 쓰기(회사 · 개인) ────────────────────────────────────────────────── def _clean(row: Any, key: str, was: dict | None = None) -> dict[str, Any]: """서버가 잡는 칸 — 키 · 열 한 벌 · 소유 · 본뜬키는 있는 다른 조합(어느 층이든).""" diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index f6791ee2e..6f435b42d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -21,3 +21,105 @@ .m02-labor__list tbody tr:hover { background: var(--color-mist-violet); } + +/* 일위대가 상세(39-3) — 담은 로직 · 자재목록 표 · 금액 칸 두 줄(위 기준 작게 · 아래 반영) */ +.m02-labrow { + width: 100%; +} + +.m02-labrow__row.is-open { + cursor: pointer; +} + +.m02-labrow__row.is-open:hover { + background: var(--color-mist-violet); +} + +.m02-labrow__name { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 12rem; +} + +.m02-labrow__key, +.m02-labrow__note, +.m02-labrow__muted { + color: var(--color-text-secondary); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-labrow__empty { + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m02-labrow__money, +.m02-labrow__num { + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.m02-labrow__money { + vertical-align: top; +} + +.m02-labrow__base { + display: block; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-labrow__applied { + display: block; +} + +.m02-labrow__sum td { + font-weight: 600; + border-top: 2px solid var(--color-border); +} + +.m02-labrow__move { + white-space: nowrap; +} + +/* 입력 모달 */ +.m02-labin { + width: min(40rem, calc(100vw - 32px)); +} + +.m02-labin__qty { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.m02-labin__qty-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-body-sm); +} + +.m02-labin__muted { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-labin__lost { + margin: 0; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m02-labin__foot { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); + margin-top: var(--spacing-12); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 64dd9784c..50f8907af 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -12,7 +12,12 @@ import { type LogicSummary, type SubBrief, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { + getActiveLayer, + getActiveOwner, + type ItemLogic, + type Layer, +} from "./M02_MasterTemplete_Api_Fetch"; /** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */ export interface ComboLogic { @@ -56,28 +61,6 @@ export interface ComboOne { combo: Combo; 줄: ComboLine[]; } -export interface PreviewLine { - 차례: number; - 로직: string; - 이름: string; - ok: boolean; - 까닭?: string; - 갈래?: string; - 결과?: number; - 노무비?: number; - 재료비?: number; - 경비?: number; - 계?: number; -} -export interface PreviewAnswer { - 줄: PreviewLine[]; - 노무비: number; - 재료비: number; - 경비: number; - 계: number; - 멈춤: string[]; -} - /** 조합 층 — 층 + 주인(회사 `7` · 개인 `7/42` · 없으면 내 것) */ export interface LaborScope { layer: Exclude; @@ -188,13 +171,6 @@ export const deleteCombo = ( scope: LaborScope = SYSTEM_SCOPE, ): Promise => request(at(scope, `/${enc(key)}`, { 판: version }), "DELETE"); -/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */ -export const previewCombo = ( - combo: Combo | string, - inputs: Record>, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => request(at(scope, "/preview"), "POST", { combo, inputs }); - /* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */ export const fetchLibLogics = (): Promise => request<{ logics: LogicSummary[] }>("/logic-lib/logics").then((d) => d.logics); @@ -209,3 +185,92 @@ export const fetchLibAuto = (key: string): Promise> => request<{ 값?: Record }>(`/logic-lib/auto?key=${enc(key)}`) .then((d) => d.값 ?? {}) .catch(() => ({})); + +/* --- 일위대가 문서(39 · 구조물 열마다 `labor/<열>.json` · 틀 = sub7 39-1a) --- */ + +/** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */ +export type LaborQty = { 손: number } | null; +/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) */ +export interface LaborRow { + 로직: string; + 메모: string | null; + 입력값: Record; + 수량: LaborQty; +} +export interface LaborMat { + 코드: string; + 이름: string | null; + 규격: string | null; + 단위: string | null; + 수량: LaborQty; +} +export interface LaborDoc { + 종류: "일위대가"; + 판?: number; + 열: string; + 일위대가번?: string; + 단위: string | null; + 본뜬?: { 층: string; 주인: string | null; 키: string } | null; + 담은로직: LaborRow[]; + 자재: LaborMat[]; + 계산값?: unknown; +} +export interface LaborCost { + 노무비: number; + 재료비: number; + 경비: number; + 계: number; +} +/** calc 줄 — 기준 = 결과단위 그대로 · 반영 = 기준 × 수량 ÷ 배(수량 없거나 돈 아닌 로직은 null) */ +export interface LaborCalcLine { + 로직: string; + 분모: string | null; + 배: number | null; + 수량: number | null; + 수량출처: string | null; + 기준: LaborCost | null; + 반영: LaborCost | null; + 결과?: number | null; + 까닭: string | null; +} +export interface LaborCalcMat { + 코드: string; + 기준단가: number | null; + 수량: number | null; + 수량출처: string | null; + 반영: number | null; + 까닭: string | null; +} +export interface LaborCalc { + 줄: LaborCalcLine[]; + 자재: LaborCalcMat[]; + 기준합: Partial; + 반영합: Partial; +} + +/** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) */ +const laborAt = (layer: Layer, column: string, tail: string): string => { + const owner = layer === getActiveLayer() ? getActiveOwner() : null; + const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : ""; + return `/layers/${layer}/labor/${enc(column)}${tail}${q}`; +}; + +/** 계산(서버 · 저장 안 함) — `doc` = 화면 캐시(없으면 저장본) · `수량` = 칸 값 바꿔 끼우기 */ +export const calcLabor = ( + layer: Layer, + column: string, + doc: LaborDoc | null, + 수량?: Record, +): Promise => + request(laborAt(layer, column, "/calc"), "POST", { + ...(doc ? { doc } : {}), + ...(수량 ? { 수량 } : {}), + }); + +/** 담은 로직 입력 목록 + 자동값(입력 모달 · `M02_Labor_Inputs`) */ +export const fetchLogicInputs = ( + keys: string[], +): Promise<{ 로직: ItemLogic[]; 빠진로직: string[] }> => + keys.length + ? request(`/logic-lib/inputs?keys=${enc(keys.join(","))}`) + : Promise.resolve({ 로직: [], 빠진로직: [] }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts new file mode 100644 index 000000000..dfddeb889 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -0,0 +1,323 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Detail.ts + * 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임 + * (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램. + * · 담은 로직 줄 누름 = 입력 모달(`Labor_Input`) · [확인] = 화면 캐시만 · 저장은 [저장] + * · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈 + * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { ROUTES } from "@config/config_frontend"; +import { navigateTo } from "../A00_Common/router"; +import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; +import type { ItemLogic, Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { + calcLabor, + fetchLogicInputs, + type LaborCalc, + type LaborDoc, + type LaborRow, +} from "./M02_MasterTemplete_Labor_Api"; +import { + buildLaborDiagram, + diagramTitle, + renderComboDiagram, +} from "./M02_MasterTemplete_Labor_Diagram"; +import { openLaborInput, openQtyModal } from "./M02_MasterTemplete_Labor_Input"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { logicTable, matTable, qtyText } from "./M02_MasterTemplete_Labor_Rows"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { keepKnown } from "./M02_Labor_Values_Model"; +import { pickMaterial } from "./M02_Material_Pick"; +import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; +import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; +import "@ui/ui_template_general_layout.css"; +import "./M02_MasterTemplete_Labor.css"; + +/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */ +export function normalizeLabor(doc: unknown, column: string): LaborDoc { + const d = (doc && typeof doc === "object" ? doc : {}) as Partial; + return { + ...d, + 종류: "일위대가", + 열: d.열 ?? column, + 단위: d.단위 ?? null, + 담은로직: (d.담은로직 ?? []).map((r) => ({ + 로직: r.로직, + 메모: r.메모 ?? null, + 입력값: r.입력값 ?? {}, + 수량: r.수량 ?? null, + })), + 자재: (d.자재 ?? []).map((m) => ({ + 코드: m.코드, + 이름: m.이름 ?? null, + 규격: m.규격 ?? null, + 단위: m.단위 ?? null, + 수량: m.수량 ?? null, + })), + }; +} + +export interface LaborDetailOptions { + layer: Layer; + column: string; + readOnly: boolean; + /** 저장 안 한 고침이 생기거나 사라짐 */ + onChanged: (dirty: boolean) => void; + /** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(없으면 단추 없음) */ + goArea: (() => void) | null; +} + +export interface LaborDetailHandle { + getDoc: () => LaborDoc; + destroy: () => void; +} + +export function mountLaborDetail( + host: HTMLElement, + doc: unknown, + opt: LaborDetailOptions, +): LaborDetailHandle { + const cur = normalizeLabor(doc, opt.column); + const saved = JSON.stringify(cur); + const edit = !opt.readOnly; + const logics = new Map(); + let calc: LaborCalc | null = null; + let fail: string | null = null; + let seq = 0; + let timer = 0; + let alive = true; + + const logicHost = el("div"); + const matHost = el("div"); + const dgHost = el("div"); + const dgBox = el("details", { + className: "m01lab__box m01-logic__section m02-labor__dg", + attrs: { open: "" }, + children: [el("summary", { text: diagramTitle() }), dgHost], + }); + + const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key); + const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code); + + const drawDg = (): void => { + if (!dgBox.open) return; + const data = buildLaborDiagram({ + rows: cur.담은로직, + logics: [...logics.values()], + calc, + mats: cur.자재.length, + }); + renderComboDiagram(dgHost, data, { + focusInput: (key) => { + const i = cur.담은로직.findIndex((r) => r.로직 === key); + if (i >= 0) openRow(i); + }, + // 로직 카드 → 단가산출 로직 화면(떠날 때 물음은 UI_Main 의 leave guard) + openLogic: (key) => { + setPendingLogicKey(key); + navigateTo(ROUTES.M01_MASTER_DATA); + }, + }); + }; + dgBox.addEventListener("toggle", drawDg); + + const draw = (): void => { + logicHost.replaceChildren( + logicTable( + { rows: cur.담은로직, logics, calc, fail }, + // 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠) + edit ? { open: openRow, move, remove: removeRow } : { open: openRow }, + ), + ); + matHost.replaceChildren( + matTable( + { mats: cur.자재, calc, fail }, + edit ? { open: openMat, remove: removeMat } : { open: openMat }, + ), + ); + drawDg(); + }; + + /** 서버 다시 셈 — 늦게 온 답은 버림(최신 응답만) */ + const recalc = (): void => { + window.clearTimeout(timer); + timer = window.setTimeout(async () => { + const mine = ++seq; + calc = null; + fail = null; + draw(); + try { + const got = await calcLabor(opt.layer, opt.column, cur); + if (!alive || mine !== seq) return; + calc = got; + } catch (error) { + if (!alive || mine !== seq) return; + fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail"); + } + draw(); + }, 150); + }; + + const changed = (): void => { + opt.onChanged(JSON.stringify(cur) !== saved); + recalc(); + }; + + /** 아직 안 받은 로직 입력 목록만 받음 */ + const loadLogics = async (): Promise => { + const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); + if (!want.length) return; + try { + const got = await fetchLogicInputs(want); + for (const l of got.로직) logics.set(l.로직, l); + } catch (error) { + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + } + }; + + function move(i: number, d: number): void { + const j = i + d; + if (j < 0 || j >= cur.담은로직.length) return; + [cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]]; + changed(); + } + function removeRow(i: number): void { + cur.담은로직.splice(i, 1); + changed(); + } + function removeMat(i: number): void { + cur.자재.splice(i, 1); + changed(); + } + + function openRow(i: number): void { + const row: LaborRow = cur.담은로직[i]; + const logic = logics.get(row.로직) ?? null; + const line = lineOf(row.로직); + openLaborInput({ + title: `${row.로직} ${logic?.이름 ?? ""}`.trim(), + logic, + values: row.입력값, + memo: row.메모, + seat: { + qty: row.수량, + linked: line && line.수량출처 !== "손" ? qtyText(line, line.분모 ?? "") || null : null, + unit: line?.분모 ?? "", + }, + readOnly: !edit, + goArea: opt.goArea, + done: (next) => { + row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; + row.메모 = next.메모; + row.수량 = next.수량; + changed(); + }, + }); + } + + function openMat(i: number): void { + const mat = cur.자재[i]; + const line = matOf(mat.코드); + openQtyModal( + `${mat.코드} ${mat.이름 ?? ""}`.trim(), + { + qty: mat.수량, + linked: + line && line.수량출처 && line.수량출처 !== "손" && line.수량 !== null + ? `${line.수량출처} = ${line.수량}` + : null, + unit: mat.단위 ?? "", + }, + !edit, + opt.goArea, + (qty) => { + mat.수량 = qty; + changed(); + }, + ); + } + + const addLogic = createButton({ + label: tc("AddLogic"), + variant: "filled", + size: "sm", + onClick: () => + openLogicPicker((x) => { + if (cur.담은로직.some((r) => r.로직 === x.키)) + return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); + cur.담은로직.push({ 로직: x.키, 메모: null, 입력값: {}, 수량: null }); + void loadLogics().then(changed); + }), + }); + const addMat = createButton({ + label: tc("Labor_AddMat"), + variant: "filled", + size: "sm", + onClick: () => + pickMaterial((m) => { + if (cur.자재.some((x) => x.코드 === m.코드)) + return showToast( + tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), + "error", + ); + cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null }); + changed(); + }), + }); + + const unit = createInputField({ type: "text", label: tc("Labor_Unit") }); + unit.input.value = cur.단위 ?? ""; + unit.input.readOnly = !edit; + unit.input.maxLength = 20; + unit.input.setAttribute("data-labor-field", "단위"); + unit.input.addEventListener("input", () => { + cur.단위 = unit.input.value.trim() || null; + opt.onChanged(JSON.stringify(cur) !== saved); + }); + const origin = createInputField({ type: "text", label: tc("Labor_Origin") }); + origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self"); + origin.input.readOnly = true; + + const section = (title: string, button: HTMLElement | null, body: HTMLElement): HTMLElement => + el("section", { + className: "m01lab__box m01-logic__section", + children: [ + el("div", { + className: "m01-logic__section-head", + children: [el("h3", { text: title }), ...(edit && button ? [button] : [])], + }), + body, + ], + }); + + host.replaceChildren( + el("div", { + className: "m01-logic__editor m01-combo m02-labor__edit", + attrs: { "data-labor": opt.column }, + children: [ + el("section", { + className: "m01lab__box m01-logic__section", + children: [el("div", { className: "m01lab__info", children: [unit.root, origin.root] })], + }), + section(tc("Logics_Title"), addLogic, logicHost), + section(tc("Labor_Mats"), addMat, matHost), + dgBox, + ], + }), + ); + draw(); + void loadLogics().then(() => { + if (alive) recalc(); + }); + + return { + getDoc: () => cur, + destroy: () => { + alive = false; + window.clearTimeout(timer); + host.replaceChildren(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css index fa21deb41..67a16d23a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css @@ -309,3 +309,13 @@ cursor: pointer; font-weight: var(--font-weight-bold); } + +/* 39-3 — 자동값 알약(넣은 값 없음 · 기울임) · 자재목록 카드(누름 없음) */ +.m02-labordg__pill.is-auto .m02-labordg__pill-value { + font-style: italic; + color: var(--color-text-secondary); +} + +.m02-labordg__card.is-material { + cursor: default; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index 0348ae7b4..9125b3042 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -1,8 +1,9 @@ /* ============================================================================= * M02_MasterTemplete_Labor_Diagram.ts * 일위대가 조합 다이어그램(PLAN 37) — 왼쪽 로직별 넣을 값 묶음 → 가운데 로직 카드 → 오른쪽 비용 구분 칸(노무비 · 재료비 · 경비 · 값) - * 조합은 계산하지 않음 — 미리 보기 결과를 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색) - * 값은 미리 보기와 한 벌(`buildDiagramData` 로 미리 보기가 넘김) · 글 [한, 영] 짝은 아래 TEXT + * 다이어그램은 계산하지 않음 — 일위대가 calc 답을 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색) + * 값 = 저장된 줄 입력값 + calc 답(수량 있으면 반영 · 없으면 기준 · 카드에 표기 · `buildLaborDiagram` · 39-3) · + * 자재목록은 카드 하나로 재료비 칸에 이음 · 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -29,7 +30,9 @@ const TEXT = { "누르면 이 로직을 단가산출 로직 화면에서 엶", "Open this logic in the unit-price logic screen", ], - Goto: ["미리 보기 입력 칸으로 감", "Go to this input in the preview"], + Goto: ["누르면 이 로직 입력 모달을 엶", "Open this logic's inputs"], + Mats: ["자재목록", "Materials"], + Auto: ["자동값", "auto"], Same: ["같은 이름의 넣을 값", "Same input name"], Won: ["원", "KRW"], Title: ["다이어그램", "Diagram"], @@ -46,8 +49,10 @@ type Cost = (typeof COST_BINS)[number]; export interface DiagramInput { name: string; unit: string; - /** 미리 보기에 넣은 값 — 빈 글 = 값 없음 */ + /** 넣은 값 — 빈 글 = 값 없음 */ value: string; + /** 넣은 값이 없어 자동값을 보임 */ + auto?: boolean; } /** 로직 하나의 미리 보기 결과 — 돈이면 비용 구분별 · 값이면 값 하나 · 멈추면 까닭 · 아직이면 null */ @@ -64,6 +69,10 @@ export interface DiagramLogic { unit: string; inputs: DiagramInput[]; out: DiagramOut; + /** 금액이 어느 값인지 — 「기준」 · 「반영」(없으면 안 보임) */ + basis?: string; + /** 자재목록 카드 — 누름 없음 */ + material?: boolean; } export interface DiagramData { @@ -73,53 +82,86 @@ export interface DiagramData { export interface DiagramHandlers { /** 로직 카드 누름 — 단가산출 로직 화면에서 엶 */ openLogic(key: string): void; - /** 넣을 값 알약 누름 — 미리 보기 그 입력 칸으로 */ + /** 넣을 값 알약 누름 — 그 로직 입력 모달 */ focusInput(key: string, name: string): void; } -/* ── 값 만들기 (미리 보기가 부름 · DOM 없음) ──────────────────────────────── */ +/* ── 값 만들기 (일위대가 상세가 부름 · DOM 없음 · 39-3) ───────────────────── */ -interface PreviewLineLike { - ok: boolean; - 까닭?: string; - 갈래?: string; - 결과?: number; - 노무비?: number; - 재료비?: number; - 경비?: number; - 계?: number; -} +type Money = { 노무비: number; 재료비: number; 경비: number; 계: number }; -export interface DiagramSource { +export interface LaborDiagramSource { /** 담은 차례대로 */ - rows: { key: string; name: string; unit: string; inputs: { name: string; unit: string }[] }[]; - /** 로직 키 → 입력 이름 → 넣은 값 */ - values: Record>; - /** 서버 미리 보기 줄(담은 차례) · 아직 안 셌으면 null */ - lines: PreviewLineLike[] | null; + rows: { 로직: string; 입력값: Record }[]; + /** 로직 입력 목록(`/logic-lib/inputs`) */ + logics: { + 로직: string; + 이름: string; + 결과단위?: string; + 입력: { 이름: string; 단위?: string; 자동값?: unknown }[]; + }[]; + /** calc 답 · 아직이면 null */ + calc: { + 줄: { + 로직: string; + 기준: Money | null; + 반영: Money | null; + 결과?: number | null; + 까닭: string | null; + }[]; + 자재: { 기준단가: number | null; 반영: number | null }[]; + } | null; + /** 담은 자재 수 — 0 이면 자재 카드 없음 */ + mats: number; } -const outOf = (l: PreviewLineLike | undefined): DiagramOut => { - if (!l) return null; - if (!l.ok) return { 멈춤: l.까닭 ?? "" }; - if (l.갈래 === "결과") return { 값: l.결과 ?? 0 }; - return { 노무비: l.노무비 ?? 0, 재료비: l.재료비 ?? 0, 경비: l.경비 ?? 0, 계: l.계 ?? 0 }; -}; +const autoOf = (v: unknown): string => + typeof v === "string" || typeof v === "number" ? String(v) : ""; -export function buildDiagramData(src: DiagramSource): DiagramData { - return { - logics: src.rows.map((r, i) => ({ - key: r.key, - name: r.name, - unit: r.unit, - inputs: r.inputs.map((x) => ({ - name: x.name, - unit: x.unit, - value: src.values[r.key]?.[x.name] ?? "", - })), - out: outOf(src.lines?.[i]), - })), - }; +export function buildLaborDiagram(src: LaborDiagramSource): DiagramData { + const logics: DiagramLogic[] = src.rows.map((r) => { + const def = src.logics.find((l) => l.로직 === r.로직); + const line = src.calc?.줄.find((l) => l.로직 === r.로직); + let out: DiagramOut = null; + let basis: string | undefined; + if (line?.까닭) out = { 멈춤: line.까닭 }; + else if (line && !line.기준 && typeof line.결과 === "number") out = { 값: line.결과 }; + else if (line && (line.반영 || line.기준)) { + out = { ...((line.반영 ?? line.기준) as Money) }; + basis = line.반영 ? "반영" : "기준"; + } + return { + key: r.로직, + name: def?.이름 ?? r.로직, + unit: def?.결과단위 ?? "", + inputs: (def?.입력 ?? []).map((x) => { + const mine = r.입력값[x.이름]; + const has = mine !== undefined && mine !== ""; + return { + name: x.이름, + unit: x.단위 ?? "", + value: has ? String(mine) : autoOf(x.자동값), + ...(has || !autoOf(x.자동값) ? {} : { auto: true }), + }; + }), + out, + ...(basis ? { basis } : {}), + }; + }); + if (src.mats > 0) { + const mats = src.calc?.자재 ?? null; + const sum = mats?.reduce((t, m) => t + (m.반영 ?? m.기준단가 ?? 0), 0) ?? 0; + logics.push({ + key: "", + name: `${td("Mats")} ${src.mats}`, + unit: "", + inputs: [], + out: mats ? { 노무비: 0, 재료비: sum, 경비: 0, 계: sum } : null, + ...(mats ? { basis: mats.every((m) => m.반영 !== null) ? "반영" : "기준" } : {}), + material: true, + }); + } + return { logics }; } /* ── 글 ─────────────────────────────────────────────────────────────────── */ @@ -134,7 +176,7 @@ const isStop = (o: DiagramOut): o is { 멈춤: string } => !!o && "멈춤" in o; const amountOf = (o: DiagramOut, c: Cost): number => (isMoney(o) ? o[c] : 0); function summary(l: DiagramLogic): string { - if (isMoney(l.out)) return `${td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`; + if (isMoney(l.out)) return `${l.basis ?? td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`; if (isValue(l.out)) return `${num(l.out.값)} ${l.unit}`.trim(); if (isStop(l.out)) return `${td("Stop")}: ${l.out.멈춤}`; return td("Wait"); @@ -360,8 +402,12 @@ export function renderComboDiagram( ? l.inputs.map((x) => { const empty = x.value.trim() === ""; const pill = el("button", { - className: `m02-labordg__pill${empty ? " is-empty" : ""}`, - attrs: { type: "button", "data-name": x.name, title: td("Goto") }, + className: `m02-labordg__pill${empty ? " is-empty" : ""}${x.auto ? " is-auto" : ""}`, + attrs: { + type: "button", + "data-name": x.name, + title: x.auto ? `${td("Auto")} · ${td("Goto")}` : td("Goto"), + }, children: [ el("span", { className: "m02-labordg__pill-name", text: x.name }), el("span", { @@ -376,8 +422,12 @@ export function renderComboDiagram( : [], }); const card = el("button", { - className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}`, - attrs: { type: "button", style: `grid-column:3;grid-row:${row}`, title: td("Open") }, + className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}${l.material ? " is-material" : ""}`, + attrs: { + type: "button", + style: `grid-column:3;grid-row:${row}`, + ...(l.material ? {} : { title: td("Open") }), + }, children: [ el("span", { className: "m02-labordg__card-key", text: l.key }), el("strong", { className: "m02-labordg__card-name", text: l.name }), @@ -385,7 +435,7 @@ export function renderComboDiagram( el("span", { className: "m02-labordg__card-out", text: summary(l) }), ], }); - card.addEventListener("click", () => on.openLogic(l.key)); + if (!l.material) card.addEventListener("click", () => on.openLogic(l.key)); const mine = bins.map((b, c) => { const amount = b === "값" ? (isValue(l.out) ? l.out.값 : 0) : amountOf(l.out, b); sums.set(b, (sums.get(b) ?? 0) + (b === "값" ? 0 : amount)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts index f09b181de..dfa1780f1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_Labor_Edit.ts * 일위대가 라이브러리 오른쪽 — 조합 목록(찾기) / 상세(이름 · 구분 · 상세구분 · 단위 · 비고 + 담은 로직 표 - * (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b). + * (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서)). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b) · 미리 보기 · + * 다이어그램은 39-3 에 구조물 열 일위대가 상세(`Labor_Detail`)로 감. * 저장 · 지우기 = 고른 층 조합 표(마스터 UA · 회사 UC · 개인 UP · `Labor_Api`) · 읽기만 층은 단추 없음. * ========================================================================== */ @@ -12,10 +13,7 @@ import { showConfirmDialog, showToast, } from "@ui/ui_template_elements"; -import { ROUTES } from "@config/config_frontend"; -import { navigateTo } from "../A00_Common/router"; import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; import { deleteCombo, editCombo, @@ -26,9 +24,7 @@ import { type ComboSummary, type LaborScope, } from "./M02_MasterTemplete_Labor_Api"; -import { diagramTitle, renderComboDiagram } from "./M02_MasterTemplete_Labor_Diagram"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { buildPreview } from "./M02_MasterTemplete_Labor_Preview"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { matchText } from "./M02_MasterTemplete_TreePanel_Model"; import { loadingError, showLoading } from "@ui/ui_template_loading"; @@ -215,31 +211,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { }); return f.root; }; - const preview = buildPreview(() => combo); - // 다이어그램 — 미리 보기와 값 한 벌(넣은 값 · 결과) · 보기만 · 로직 카드 누르면 단가산출 로직 화면 - const dgHost = el("div"); - const dgBox = el("details", { - className: "m01lab__box m01-logic__section m02-labor__dg", - attrs: { open: "" }, - children: [el("summary", { text: diagramTitle() }), dgHost], - }); - const drawDg = (): void => { - if (dgBox.open) - renderComboDiagram(dgHost, preview.data(), { - focusInput: preview.focusInput, - openLogic: async (logic) => { - if (dirty() && !(await showConfirmDialog(tc("LeaveAsk")))) return; - setPendingLogicKey(logic); - navigateTo(ROUTES.M01_MASTER_DATA); - }, - }); - }; - let dgTimer = 0; - preview.onUpdate(() => { - window.clearTimeout(dgTimer); - dgTimer = window.setTimeout(drawDg, 120); - }); - dgBox.addEventListener("toggle", drawDg); const table = el("div", { className: "m01-master__grid-wrap" }); const drawTable = (): void => { const move = (i: number, d: number): void => { @@ -306,7 +277,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { }), ...(trs.length ? [] : [el("p", { className: "m01-logic__muted", text: tc("NoLogic") })]), ); - void preview.redraw(); }; const add = createButton({ @@ -409,8 +379,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { table, ], }), - dgBox, - preview.root, ); drawTable(); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts new file mode 100644 index 000000000..e5d913bff --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -0,0 +1,241 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Input.ts + * 일위대가 입력 모달(39-3) — 담은 로직 줄을 누르면 그 로직 입력 전부(빈 칸 = 자동값) · 메모 · 수량 + * (면적산출 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」). 자재 줄은 수량만(`openQtyModal`). + * [확인] = 줄 값만 바꿈(화면 캐시) · 저장은 상세 [저장]. 값 칸 규칙 = `M02_Labor_Values`(한 벌). + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { ItemInput, ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborQty } from "./M02_MasterTemplete_Labor_Api"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { createValueControl } from "./M02_Labor_Values"; +import { keepKnown, valueHint, type LaborValues } from "./M02_Labor_Values_Model"; +import "./M02_Labor_Values.css"; +import "./M02_MasterTemplete_Labor.css"; + +const fill = (text: string, value: string): string => text.replace("{value}", value); + +export interface QtySeat { + /** 지금 수량 */ + qty: LaborQty; + /** 면적산출 이은 칸 글(「B6 = 12.5 ㎡」) — 없으면 null */ + linked: string | null; + /** 손 입력 단위 표기(분모 · 자재 단위) */ + unit: string; +} + +interface QtyPart { + root: HTMLElement; + /** 확인 때 값 — 손 입력이 수가 아니면 undefined(막음) */ + read: () => LaborQty | undefined; +} + +/** 수량 칸 — 「면적산출 칸」 / 「손 입력」 · 손이 칸보다 앞섬 */ +function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): QtyPart { + const name = `m02-labin-qty-${Math.random().toString(36).slice(2)}`; + const radio = (value: "cell" | "hand"): HTMLInputElement => { + const input = el("input", { attrs: { type: "radio", name, value } }) as HTMLInputElement; + input.disabled = readOnly; + return input; + }; + const cell = radio("cell"); + const hand = radio("hand"); + const number = el("input", { + className: "ui-input m02-inputval__control", + attrs: { type: "number", step: "any", "data-labor-qty": "hand" }, + }) as HTMLInputElement; + number.readOnly = readOnly; + if (seat.qty) number.value = String(seat.qty.손); + (seat.qty ? hand : cell).checked = true; + number.disabled = !hand.checked; + for (const r of [cell, hand]) + r.addEventListener("change", () => { + number.disabled = !hand.checked; + if (hand.checked) number.focus(); + }); + const go = goArea + ? createButton({ label: tc("In_QtyGoArea"), variant: "ghost", size: "sm", onClick: goArea }) + : null; + const line = (input: HTMLInputElement, label: string, ...rest: HTMLElement[]): HTMLElement => + el("label", { + className: "m02-labin__qty-line", + children: [input, el("span", { text: label }), ...rest], + }); + const root = el("section", { + className: "m02-labin__qty", + children: [ + el("h4", { className: "m02-inputval__logic-name", text: tc("In_Qty") }), + line( + cell, + tc("In_QtyCell"), + el("small", { + className: "m02-labin__muted", + text: seat.linked ? fill(tc("In_QtyLinked"), seat.linked) : tc("In_QtyNoLink"), + }), + ...(go ? [go] : []), + ), + line(hand, tc("In_QtyHand"), number, el("small", { text: seat.unit })), + ], + }); + return { + root, + read: () => { + if (!hand.checked) return null; + const v = Number(number.value); + return number.value.trim() !== "" && Number.isFinite(v) ? { 손: v } : undefined; + }, + }; +} + +function footer(readOnly: boolean, ok: () => void, close: () => void): HTMLElement { + return el("div", { + className: "m02-labin__foot", + children: readOnly + ? [createButton({ label: tc("In_Close"), variant: "ghost", onClick: close })] + : [ + createButton({ label: tc("In_Cancel"), variant: "ghost", onClick: close }), + createButton({ label: tc("In_Ok"), variant: "filled", onClick: ok }), + ], + }); +} + +export interface LaborInputResult { + 입력값: LaborValues; + 메모: string | null; + 수량: LaborQty; +} + +export interface LaborInputOptions { + /** 「GF000219 메쌓기」 */ + title: string; + /** 로직 입력 목록(없으면 = 못 읽음 · 넣을 값 없이 수량만) */ + logic: ItemLogic | null; + values: LaborValues; + memo: string | null; + seat: QtySeat; + readOnly: boolean; + /** 「면적산출에서 잇기」 — 모달을 닫고 면적산출 상세로(없으면 단추 없음) */ + goArea: (() => void) | null; + done: (next: LaborInputResult) => void; +} + +/** 담은 로직 줄 입력 모달 */ +export function openLaborInput(opt: LaborInputOptions): void { + const inputs: ItemInput[] = opt.logic?.입력 ?? []; + const { kept, lost } = keepKnown(inputs, opt.values); + const draft: LaborValues = { ...kept }; + const bad = new Set(); + openModal({ + title: fill(tc("In_Title"), opt.title), + closeLabel: tc("In_Close"), + dialogClass: "m02-modal m02-labin", + mount: (body, close) => { + const rows = inputs.map((input) => { + const field = createValueControl(input, draft[input.이름] ?? null, (value, isBad) => { + if (isBad) bad.add(input.이름); + else bad.delete(input.이름); + if (value === null) delete draft[input.이름]; + else draft[input.이름] = value; + }); + field.control.disabled = opt.readOnly; + field.control.setAttribute("data-labor-input", input.이름); + const hint = valueHint(input); + return el("div", { + className: "m02-inputval__row", + children: [ + el("span", { + className: "m02-inputval__name", + text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, + attrs: { title: [input.설명, hint].filter(Boolean).join(" · ") }, + }), + el("div", { className: "m02-inputval__cell", children: [field.control, field.error] }), + ], + }); + }); + const memo = el("input", { + className: "ui-input m02-inputval__control", + attrs: { type: "text", maxlength: "200", "data-labor-memo": "" }, + }) as HTMLInputElement; + memo.value = opt.memo ?? ""; + memo.readOnly = opt.readOnly; + const qty = qtyPart( + opt.seat, + opt.readOnly, + opt.goArea && + (() => { + close(); + opt.goArea?.(); + }), + ); + const ok = (): void => { + if (bad.size) return showToast(tc("In_Bad"), "error"); + const 수량 = qty.read(); + if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error"); + opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null, 수량 }); + close(); + }; + body.append( + el("div", { + className: "m02-inputval", + children: [ + ...(lost.length + ? [ + el("p", { + className: "m02-labin__lost", + text: fill(tc("Labor_Lost"), lost.join(", ")), + }), + ] + : []), + ...(rows.length + ? rows + : [el("p", { className: "m02-labin__muted", text: tc("In_None") })]), + el("div", { + className: "m02-inputval__row", + children: [ + el("span", { className: "m02-inputval__name", text: tc("In_Memo") }), + memo, + ], + }), + qty.root, + ], + }), + footer(opt.readOnly, ok, close), + ); + }, + }); +} + +/** 자재 줄 수량 모달 */ +export function openQtyModal( + title: string, + seat: QtySeat, + readOnly: boolean, + goArea: (() => void) | null, + done: (qty: LaborQty) => void, +): void { + openModal({ + title, + closeLabel: tc("In_Close"), + dialogClass: "m02-modal m02-labin", + mount: (body, close) => { + const qty = qtyPart( + seat, + readOnly, + goArea && + (() => { + close(); + goArea(); + }), + ); + const ok = (): void => { + const 수량 = qty.read(); + if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error"); + done(수량); + close(); + }; + body.append(qty.root, footer(readOnly, ok, close)); + }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts deleted file mode 100644 index 68cc72e01..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts +++ /dev/null @@ -1,244 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Labor_Preview.ts - * 조합 미리 보기 — 담은 로직 저마다 받은 입력값을 서버 미리 보기(`POST …/combos/preview`)에 실어 비목별 합계를 받음 - * 입력값(수량)은 이 화면 안에만 두고 조합에 저장하지 않음(계약 9장) - * ========================================================================== */ - -import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; -import type { LogicInput } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import { - fetchLibAuto as fetchAuto, - fetchLibLogic as fetchLogic, - previewCombo, - type Combo, -} from "./M02_MasterTemplete_Labor_Api"; -import { buildDiagramData, type DiagramData } from "./M02_MasterTemplete_Labor_Diagram"; -import { tc, type ComboTextKey } from "./M02_MasterTemplete_Labor_Text"; - -const COSTS = ["노무비", "재료비", "경비"] as const; -const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = { - 노무비: "Prev_Labor", - 재료비: "Prev_Material", - 경비: "Prev_Expense", -}; - -const num = (v: string): number | string => (v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v); -/** 조합 미리 보기는 모두 돈 자리(노무비·재료비·경비·계) */ -const fmt = formatMoney; - -/** 로직 입력 정의 → 처음 값(고르기 첫째 · 범위 아래끝) */ -const initial = (i: LogicInput): string => String(i.고르기?.[0] ?? i.범위?.[0] ?? ""); - -/** 입력 칸 — 바꾼 값은 `values`(이 화면 메모)에만 적음 */ -function inputCell( - input: LogicInput, - key: string, - values: Record>, - onEdit: () => void, - onInput: () => void, -): HTMLElement { - const mine = (values[key] ??= {}); - const value = mine[input.이름] ?? initial(input); - mine[input.이름] = value; - const choices = input.고르기?.map((v) => ({ value: String(v), text: String(v) })); - const field = choices - ? createSelectField({ options: choices, value, compact: true, label: input.이름 }) - : createInputField({ type: "text", label: input.이름, compact: true }); - const control = "select" in field ? field.select : field.input; - control.value = value; - control.setAttribute("data-input", `${key}|${input.이름}`); - const edit = (): void => { - mine[input.이름] = control.value; - onInput(); - }; - control.addEventListener("input", edit); - control.addEventListener("change", () => { - edit(); - onEdit(); // 바꾸면 다시 미리 보기 계산 - }); - return field.root; -} - -export interface PreviewHandle { - root: HTMLElement; - /** 담은 로직이 바뀐 뒤 입력 칸을 다시 그림 */ - redraw: () => Promise; - /** 다이어그램이 쓰는 값 한 벌 — 넣은 값 · 로직 이름 · 결과(저장 안 함) */ - data: () => DiagramData; - /** 입력 칸이 바뀌거나 결과가 나올 때마다 부름(돌려받은 함수 = 그만 받기) */ - onUpdate: (fn: () => void) => () => void; - /** 그 입력 칸으로 가서 깜빡임 */ - focusInput: (key: string, name: string) => void; -} - -export function buildPreview(combo: () => Combo): PreviewHandle { - const values: Record> = {}; - /** 서버가 받을 꼴 — 원래 숫자·고르기 값으로 되돌림 */ - const inputs = new Map(); - /** 로직마다 자동값은 한 번만 받음 */ - const asked = new Set(); - /** 다이어그램에 넘길 것 — 로직 이름 · 결과 단위 · 마지막 미리 보기 줄 */ - const meta = new Map(); - let lines: Parameters[0]["lines"] = null; - const listeners = new Set<() => void>(); - const notify = (): void => listeners.forEach((f) => f()); - const form = el("div", { className: "m01-logic__stack" }); - const out = el("div", { - className: "m01-master__grid-wrap", - attrs: { "data-combo": "preview" }, - }); - const rowOf = (cells: string[], note = "", bold = false): HTMLElement => - el("tr", { - className: bold ? "m01lab__subtotal" : "", - children: cells.map((t, i) => { - const text = i === 0 && note ? `${t} — ${note}` : t; - return el("td", { text, attrs: { title: text } }); - }), - }); - - const run = async (): Promise => { - out.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" })); - lines = null; - notify(); - const send: Record> = {}; - for (const [key, defs] of inputs) { - send[key] = {}; - for (const i of defs) { - const raw = values[key]?.[i.이름] ?? initial(i); - if (raw.trim() === "") continue; // 빈 칸은 안 보냄(로직 화면과 같음) - send[key][i.이름] = i.고르기?.find((o) => String(o) === raw) ?? num(raw); - } - } - let got; - try { - got = await previewCombo(combo(), send); - lines = got.줄; - notify(); - } catch (error) { - out.replaceChildren( - el("p", { text: `${tc("Prev_Fail")}: ${error instanceof Error ? error.message : ""}` }), - ); - return; - } - const trs = got.줄.map((l) => - rowOf( - [ - `${l.로직} ${l.이름}`, - fmt(l.노무비 ?? 0), - fmt(l.재료비 ?? 0), - fmt(l.경비 ?? 0), - fmt(l.계 ?? 0), - ], - l.ok ? "" : `${tc("Prev_Fail")}: ${l.까닭 ?? ""}`, - ), - ); - trs.push( - rowOf( - [tc("Prev_Sum"), fmt(got.노무비), fmt(got.재료비), fmt(got.경비), fmt(got.계)], - "", - true, - ), - ); - const heads = [tc("Prev_Row"), ...COSTS.map((c) => tc(LABEL[c])), tc("Prev_Sum")]; - out.replaceChildren( - el("table", { - className: "m01-master__grid", - children: [ - el("thead", { - children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], - }), - el("tbody", { children: trs }), - ], - }), - ); - }; - - const button = el("button", { - className: "ui-btn ui-btn--filled", - text: tc("Prev_Run"), - attrs: { type: "button", "data-combo": "run" }, - }); - button.addEventListener("click", () => void run()); - - const redraw = async (): Promise => { - const blocks: HTMLElement[] = []; - inputs.clear(); - for (const row of combo().담은로직) { - try { - const [one, auto] = await Promise.all([ - fetchLogic(row.로직), - asked.has(row.로직) ? Promise.resolve({}) : fetchAuto(row.로직), - ]); - asked.add(row.로직); - const mine = (values[row.로직] ??= {}); - for (const i of one.logic.입력) { - const got = (auto as Record)[i.이름]; - if (got === undefined || got === null || (mine[i.이름] ?? "").trim() !== "") continue; - mine[i.이름] = String(got); - } - inputs.set(row.로직, one.logic.입력); - meta.set(row.로직, { name: one.logic.이름, unit: one.logic.결과단위 ?? "" }); - blocks.push( - el("div", { - className: "m01lab__info", - children: [ - el("strong", { - className: "m01lab__cell--wide", - text: `${row.로직} ${one.logic.이름}`, - }), - ...one.logic.입력.map((i) => - inputCell(i, row.로직, values, () => void run(), notify), - ), - ], - }), - ); - } catch { - /* 못 읽은 로직은 미리 보기에서 그 줄만 까닭이 뜸 */ - } - } - form.replaceChildren(...blocks); - notify(); - if (blocks.length) - await run(); // 열자마자 한 번 미리 보기 계산 - else out.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Prev_Wait") })); - }; - - const root = el("section", { - className: "m01lab__box m01-logic__section", - children: [ - el("div", { - className: "m01-logic__section-head", - children: [el("h3", { text: tc("Prev_Title") })], - }), - el("h4", { text: tc("Prev_Inputs") }), - form, - button, - out, - ], - }); - const data = (): DiagramData => - buildDiagramData({ - rows: combo().담은로직.map((r) => ({ - key: r.로직, - name: meta.get(r.로직)?.name ?? r.로직, - unit: meta.get(r.로직)?.unit ?? "", - inputs: (inputs.get(r.로직) ?? []).map((i) => ({ name: i.이름, unit: i.단위 ?? "" })), - })), - values, - lines, - }); - const focusInput = (key: string, name: string): void => { - const box = root.querySelector(`[data-input="${CSS.escape(`${key}|${name}`)}"]`); - if (!box) return; - box.scrollIntoView({ block: "center", behavior: "smooth" }); - box.focus({ preventScroll: true }); - box.classList.add("m02-labor__flash"); - window.setTimeout(() => box.classList.remove("m02-labor__flash"), 1600); - }; - const onUpdate = (fn: () => void): (() => void) => { - listeners.add(fn); - return () => listeners.delete(fn); - }; - return { root, redraw, data, onUpdate, focusInput }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts new file mode 100644 index 000000000..f8c637ccc --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -0,0 +1,283 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Rows.ts + * 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀. + * 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) · + * 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄. + * 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액. 금액은 calc 답(없으면 「…」). + * 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음). + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; +import type { + LaborCalc, + LaborCalcLine, + LaborCalcMat, + LaborCost, + LaborMat, + LaborRow, +} from "./M02_MasterTemplete_Labor_Api"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import "./M02_MasterTemplete_Labor.css"; +import { valueLine } from "./M02_Labor_Values_Model"; + +const COSTS = ["노무비", "재료비", "경비", "계"] as const; +const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { + 노무비: "Prev_Labor", + 재료비: "Prev_Material", + 경비: "Prev_Expense", + 계: "Prev_Sum", +}; + +const num = (v: number): string => + Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); + +/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」 · 없으면 빈 글 */ +export function qtyText( + line: { 수량: number | null; 수량출처: string | null } | undefined, + unit: string, +): string { + if (!line || line.수량 === null || line.수량 === undefined) return ""; + const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`; + if (!line.수량출처 || line.수량출처 === "손") + return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`; + return `${tc("Labor_Qty")} ${line.수량출처} = ${v}`; +} + +/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 */ +function moneyCell( + base: number | null | undefined, + applied: number | null | undefined, + wait: boolean, +): HTMLElement { + const show = (v: number | null | undefined): string => + wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); + return el("td", { + className: "m02-labrow__money", + children: [ + el("small", { + className: "m02-labrow__base", + text: show(base), + attrs: { title: tc("Labor_Base") }, + }), + el("span", { + className: "m02-labrow__applied", + text: show(applied), + attrs: { title: tc("Labor_Applied") }, + }), + ], + }); +} + +const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement => + el("div", { + className: "ui-general-block__table-wrap", + children: [ + el("table", { + className: "ui-general-block__table m02-labrow", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: rows }), + ...(foot.length ? [el("tfoot", { children: foot })] : []), + ], + }), + ], + }); + +/** 줄 누름 · 순서 · 빼기 — 순서 · 빼기가 없으면 그 칸 없음 */ +export interface RowHandlers { + open: (i: number) => void; + move?: (i: number, d: number) => void; + remove?: (i: number) => void; +} + +export interface LogicTableData { + rows: LaborRow[]; + logics: Map; + /** calc 답 — null = 계산 중 · 못 셈은 `fail` */ + calc: LaborCalc | null; + fail?: string | null; +} + +/** 담은 로직 표 — 보기 갈래는 `on` 없이 */ +export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement { + const wait = !data.calc && !data.fail; + const { move, remove } = on ?? {}; + const tools = !!(move && remove); + const lineOf = (key: string): LaborCalcLine | undefined => + data.calc?.줄.find((l) => l.로직 === key); + const trs = data.rows.map((row, i) => { + const logic = data.logics.get(row.로직); + const line = lineOf(row.로직); + const unit = logic?.결과단위 ?? ""; + const values = valueLine(logic?.입력 ?? [], row.입력값); + const qty = qtyText(line, line?.분모 ?? ""); + const note = [values.text, qty].filter(Boolean).join(" · "); + const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null); + const small = (label: string, run: () => void): HTMLElement => + createButton({ + label, + variant: "ghost", + size: "sm", + onClick: (e) => { + e.stopPropagation(); // 줄 누름(입력 모달)과 따로 + run(); + }, + }); + const tr = el("tr", { + className: on ? "m02-labrow__row is-open" : "m02-labrow__row", + attrs: { "data-labor-logic": row.로직 }, + children: [ + el("td", { text: String(i + 1) }), + el("td", { + className: "m02-labrow__name", + children: [ + el("span", { className: "m02-labrow__key", text: row.로직 }), + el("strong", { text: logic?.이름 ?? row.로직 }), + ...(note ? [el("small", { className: "m02-labrow__note", text: note })] : []), + ...(values.empty + ? [ + el("small", { + className: "m02-labrow__empty", + text: tc("Labor_Empty").replace("{n}", String(values.empty)), + }), + ] + : []), + ...(row.메모 ? [el("small", { className: "m02-labrow__note", text: row.메모 })] : []), + ...(why + ? [ + el("small", { + className: "m02-labrow__empty", + text: `${tc("Labor_Calc_Fail")}: ${why}`, + }), + ] + : []), + ], + }), + el("td", { text: unit }), + ...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait)), + ...(move && remove + ? [ + el("td", { + className: "m02-labrow__move", + children: [ + small(tc("Up"), () => move(i, -1)), + small(tc("Down"), () => move(i, 1)), + small(tc("Remove"), () => remove(i)), + ], + }), + ] + : []), + ], + }); + if (on) tr.addEventListener("click", () => on.open(i)); + return tr; + }); + const sum = (part: Partial | undefined, c: (typeof COSTS)[number]): number | null => + part?.[c] ?? null; + const foot = data.rows.length + ? [ + el("tr", { + className: "m02-labrow__sum", + children: [ + el("td", { text: "" }), + el("td", { text: tc("Prev_Sum") }), + el("td", { text: "" }), + ...COSTS.map((c) => + moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait), + ), + ...(tools ? [el("td", { text: "" })] : []), + ], + }), + ] + : []; + const heads = [ + tc("Col_Order"), + tc("Col_Name"), + tc("Col_Unit"), + ...COSTS.map((c) => tc(HEAD[c])), + ...(tools ? [tc("Col_Move")] : []), + ]; + return data.rows.length + ? table(heads, trs, foot) + : el("p", { className: "m02-labrow__muted", text: tc("NoLogic") }); +} + +export interface MatTableData { + mats: LaborMat[]; + calc: LaborCalc | null; + fail?: string | null; +} + +/** 자재목록 표 — 보기 갈래는 `on` 없이 */ +export function matTable(data: MatTableData, on?: Omit): HTMLElement { + const wait = !data.calc && !data.fail; + const remove = on?.remove; + const show = (v: number | null | undefined): string => + wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); + const trs = data.mats.map((mat, i) => { + const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드); + const qty = line?.수량 === null || line?.수량 === undefined ? "" : num(line.수량); + const tr = el("tr", { + className: on ? "m02-labrow__row is-open" : "m02-labrow__row", + attrs: { "data-labor-mat": mat.코드 }, + children: [ + el("td", { text: mat.코드 }), + el("td", { + className: "m02-labrow__name", + children: [ + el("strong", { text: mat.이름 ?? "" }), + ...(line?.까닭 + ? [el("small", { className: "m02-labrow__empty", text: line.까닭 })] + : []), + ], + }), + el("td", { text: mat.규격 ?? "" }), + el("td", { text: mat.단위 ?? "" }), + el("td", { + className: "m02-labrow__num", + text: qty, + attrs: { title: line?.수량출처 ?? "" }, + }), + el("td", { className: "m02-labrow__num", text: show(line?.기준단가) }), + el("td", { className: "m02-labrow__num", text: show(line?.반영) }), + ...(remove + ? [ + el("td", { + className: "m02-labrow__move", + children: [ + createButton({ + label: tc("Remove"), + variant: "ghost", + size: "sm", + onClick: (e) => { + e.stopPropagation(); + remove(i); + }, + }), + ], + }), + ] + : []), + ], + }); + if (on) tr.addEventListener("click", () => on.open(i)); + return tr; + }); + const heads = [ + tc("Labor_Code"), + tc("Col_Name"), + tc("Labor_Spec"), + tc("Col_Unit"), + tc("Labor_Qty"), + tc("Labor_BasePrice"), + tc("Labor_AppliedSum"), + ...(remove ? [tc("Col_Move")] : []), + ]; + return data.mats.length + ? table(heads, trs, []) + : el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 962d3eab9..5521c55d9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -65,16 +65,48 @@ const TEXT = { Pick_None: ["찾은 로직 없음", "No logics found"], Pick_Close: ["닫기", "Close"], Pick_Blocked: ["막힘", "Blocked"], - Prev_Title: ["미리 보기 (보기만 · 저장 안 함)", "Preview (view only)"], - Prev_Inputs: ["넣을 값", "Inputs"], - Prev_Run: ["미리 보기 계산", "Run preview"], Prev_Labor: ["노무비", "Labor"], Prev_Material: ["재료비", "Material"], Prev_Expense: ["경비", "Expense"], Prev_Sum: ["합계", "Total"], - Prev_Row: ["로직", "Logic"], - Prev_Fail: ["못 셈", "Failed"], - Prev_Wait: ["미리 보기 계산을 누르면 값이 나옴", "Press the button to see values"], + // 일위대가 상세(39-3) — 구조물 열마다 하나 + Labor_Title: ["일위대가", "Unit price"], + Labor_Unit: ["단위", "Unit"], + Labor_Origin: ["본뜬 곳", "Copied from"], + Labor_Mats: ["자재목록", "Materials"], + Labor_NoMat: ["담은 자재 없음", "No materials yet"], + Labor_AddMat: ["자재 추가", "Add material"], + Labor_Qty: ["수량", "Quantity"], + Labor_Base: ["기준", "Base"], + Labor_Applied: ["반영", "Applied"], + Labor_BasePrice: ["기준 단가", "Base price"], + Labor_AppliedSum: ["반영 금액", "Applied amount"], + Labor_Calc_Wait: ["계산 중…", "Calculating…"], + Labor_Calc_Fail: ["계산 못 함", "Calculation failed"], + Labor_Empty: ["빈 값 {n}", "{n} empty"], + Labor_Lost: [ + "로직 입력이 바뀌어 저장 때 빠지는 값 — {value}", + "Inputs no longer in the logic (dropped on save) — {value}", + ], + Labor_Code: ["코드", "Code"], + Labor_Spec: ["규격", "Spec"], + Labor_Already: ["이미 담은 로직 — {value}", "Already added — {value}"], + Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"], + In_Title: ["넣을 값 — {value}", "Inputs — {value}"], + In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"], + In_Memo: ["메모", "Memo"], + In_Qty: ["수량", "Quantity"], + In_QtyCell: ["면적산출 칸", "Area sheet cell"], + In_QtyHand: ["손 입력", "Manual"], + In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"], + In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"], + In_QtyGoArea: ["면적산출에서 잇기", "Link in area sheet"], + In_QtyNeed: ["손 입력 수량을 수로 적음", "Enter a number"], + In_Bad: ["범위 밖 값이 있음 — 고친 뒤 확인", "Out-of-range values — fix first"], + In_Ok: ["확인", "OK"], + In_Cancel: ["취소", "Cancel"], + In_Close: ["닫기", "Close"], + In_Loading: ["입력 목록 받는 중…", "Loading inputs…"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 14b2f8c26..d42771c08 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -5,7 +5,7 @@ company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(키 UC{회사}-######) personal 읽기 · 쓰기 = 본인(키 UP{사용자}-######) 저장소 = `M02_MasterTemplete_Combo_Store`. -담을 로직 고르기 · 미리 보기가 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은 +담을 로직 고르기 · 일위대가 입력 모달이 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은 `/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음. 39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌). """ @@ -41,13 +41,6 @@ class ComboBody(BaseModel): 판: str | None = None # 새로 만들 때 빈 글 = 지금 판 · 고칠 때 어긋나면 409 -class PreviewBody(BaseModel): - """저장 전 미리 보기 — `combo` 는 조합 줄 통째 또는 그 층 키 · `inputs` 는 로직마다.""" - - combo: dict[str, Any] | str - inputs: dict[str, dict[str, Any]] = {} - - @router.get("/combos/all") def get_all(session: Session = Depends(verify_session)) -> list[dict]: """고르기(use-combo · 갈라 내기) — 세 층 `[{layer, owner, 판, 폴더, 줄}]` · 회사 없음 빈 층.""" @@ -146,17 +139,6 @@ def delete_practice(판: str = "", session: Session = Depends(verify_session)) - return _call(_root(session, "system", None, write=True), combos.drop_practice, 판) -@router.post("/layers/{layer}/combos/preview") -def post_preview( - layer: Layer, - body: PreviewBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - """보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 · 읽기 권한.""" - return _call(_root(session, layer, owner), combos.preview, body.combo, body.inputs) - - # ── 담을 로직(읽기만) — M01 `/logics` · `/subs?group=로직` · `/logic` · `/logic/auto` 모양 ── def _read(fn, *args): try: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index 57eaacbce..6b54347fd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -252,3 +252,9 @@ .m02-struct__sheet.is-pills-off .ss-links { display: none; } + +/* [알약] 켬 — 눌린 단추를 강조색으로(밝음 · 어두움 같은 토큰) */ +.m02-struct__card-tools .ui-btn[aria-pressed="true"] { + border-color: var(--color-accent); + color: var(--color-accent); +} diff --git a/resources/master_template/labor/item1.json b/resources/master_template/labor/item1.json new file mode 100644 index 000000000..b9889e9e3 --- /dev/null +++ b/resources/master_template/labor/item1.json @@ -0,0 +1,340 @@ +{ + "종류": "일위대가", + "판": 1, + "열": "item1", + "단위": "개소", + "본뜬": { + "층": "system", + "주인": null, + "키": "UA000008" + }, + "담은로직": [ + { + "로직": "GC000198", + "메모": "레미콘타설(장비) 무근,소형", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000214", + "메모": "유로폼 설치 및 해체 보통", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000206", + "메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000207", + "메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000218", + "메모": "기초다짐 및 뒤채움 소형 장비사용 시공 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000225", + "메모": "깬잡석찰붙임 L3=30cm내외", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000156", + "메모": "콘크리트(기계비빔) 무근구조물 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000226", + "메모": "큰돌메쌓기 뒷길이 0.6~0.8m", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000214", + "메모": "야면석채집 L3=45cm내외 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000222", + "메모": "찰쌓기(켜쌓기) L3=55cm이하 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000677", + "메모": "모르터바르기 1:3", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000221", + "메모": "돌쌓기 찰(켜)쌓기,L3=55cm이하 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000088", + "메모": "구조물터파기(토사) 굴착기0.6m3", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000113", + "메모": "구조물되메우기 굴착기0.6m3 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + } + ], + "자재": [], + "일위대가번": "SU-000001", + "계산값": { + "줄": [ + { + "로직": "GC000198", + "알약": "레디믹스트콘크리트 타설|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 58122.782608695656, + "재료비": 0, + "경비": 1162.455652173913, + "계": 59285.23826086956 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000214", + "알약": "유로폼 설치 및 해체|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 51524.36363636364, + "재료비": 0, + "경비": 1545.730909090909, + "계": 53070.09454545454 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000206", + "알약": "철근 현장가공|ton", + "분모": "ton", + "배": 1, + "기준": { + "노무비": 217028.66666666666, + "재료비": 0, + "경비": 19532.58, + "계": 236561.24666666667 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000207", + "알약": "철근 현장조립|ton", + "분모": "ton", + "배": 1, + "기준": { + "노무비": 574487.6470588235, + "재료비": 11765, + "경비": 11489.75294117647, + "계": 597742.4 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000218", + "알약": "기초다짐 및 뒷채움|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 12869.246375, + "재료비": 1530.2328689, + "경비": 1315.382, + "계": 15714.8612439 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000225", + "알약": "돌붙임 찰붙임|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 49447.04583333333, + "재료비": 4806.2270256, + "경비": 6425.98, + "계": 60679.25285893333 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000156", + "알약": "기계비빔타설|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 144348.25222222222, + "재료비": 1647.113, + "경비": 378, + "계": 146373.36522222223 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000226", + "알약": "큰돌쌓기 메쌓기|㎡", + "분모": "㎡", + "배": 10, + "기준": { + "노무비": 819958.01, + "재료비": 132344.5084416, + "경비": 111948.7001, + "계": 1064251.2185416 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000214", + "알약": "야면석 채집|㎡ · ㎥", + "분모": "㎡ · ㎥", + "배": 1, + "기준": { + "노무비": 18927.48, + "재료비": 0, + "경비": 0, + "계": 18927.48 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000222", + "알약": "돌쌓기 찰쌓기|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 51101.38458333333, + "재료비": 6772.4108088, + "경비": 9054.79, + "계": 66928.58539213333 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000677", + "알약": "모르타르 배합|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 113564.88, + "재료비": 166136, + "경비": 0, + "계": 279700.88 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000221", + "알약": "돌쌓기 찰쌓기|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 52917.12, + "재료비": 1469.88, + "경비": 0, + "계": 54387 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000088", + "알약": "터파기(기계) 보통토사|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 843.1458333333334, + "재료비": 627.50883, + "경비": 423.15714285714284, + "계": 1893.811806190476 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000113", + "알약": "되메우기|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 2660.6419100706057, + "재료비": 416.1508405797101, + "경비": 391.04953017996496, + "계": 3467.8422808302807 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + } + ], + "자재": [], + "기준합": { + "노무비": 2167800.6667278423, + "재료비": 327515.0318154797, + "경비": 163667.5782754784, + "계": 2658983.2768188003 + }, + "반영합": { + "노무비": 0, + "재료비": 0, + "경비": 0, + "계": 0 + } + } +} diff --git a/resources/master_template/labor/item2.json b/resources/master_template/labor/item2.json new file mode 100644 index 000000000..a59dd165c --- /dev/null +++ b/resources/master_template/labor/item2.json @@ -0,0 +1,11 @@ +{ + "종류": "일위대가", + "판": 1, + "열": "item2", + "단위": null, + "본뜬": null, + "담은로직": [], + "자재": [], + "계산값": {}, + "일위대가번": "SU-000002" +} diff --git a/resources/master_template/labor/item3.json b/resources/master_template/labor/item3.json new file mode 100644 index 000000000..b512db52a --- /dev/null +++ b/resources/master_template/labor/item3.json @@ -0,0 +1,98 @@ +{ + "종류": "일위대가", + "판": 1, + "열": "item3", + "단위": "식", + "본뜬": { + "층": "system", + "주인": null, + "키": "UA000001" + }, + "담은로직": [ + { + "로직": "GF000219", + "메모": "메쌓기 — 돌쌓기 인력", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000119", + "메모": "노체포설 — 굴착기 0.7㎥", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000137", + "메모": "운반 — 중기운반 트레일러(20ton)", + "입력값": {}, + "수량": null + } + ], + "자재": [], + "일위대가번": "SU-000003", + "계산값": { + "줄": [ + { + "로직": "GF000219", + "알약": "돌쌓기 메쌓기|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 79279.668, + "재료비": 0, + "경비": 0, + "계": 79279.668 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000119", + "알약": "노체포설|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 2433.321308321308, + "재료비": 1077.3082366522367, + "경비": 1012.3273551844981, + "계": 4522.956900158043 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000137", + "알약": "중기운반 트레일러|회", + "분모": "회", + "배": 1, + "기준": { + "노무비": 38181.82530864197, + "재료비": 32481.682622277778, + "경비": 13280.479259259258, + "계": 83943.98719017902 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + } + ], + "자재": [], + "기준합": { + "노무비": 119894.81461696328, + "재료비": 33558.99085893002, + "경비": 14292.806614443756, + "계": 167746.61209033706 + }, + "반영합": { + "노무비": 0, + "재료비": 0, + "경비": 0, + "계": 0 + } + } +} diff --git a/resources/tester/test_m01_combo.py b/resources/tester/test_m01_combo.py index 6c793c1e2..0b747fe4d 100644 --- a/resources/tester/test_m01_combo.py +++ b/resources/tester/test_m01_combo.py @@ -25,6 +25,7 @@ from fastapi.testclient import TestClient from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Router as router_module from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as combos_router sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts")) @@ -285,6 +286,12 @@ def test_메모가_글자_아니면_막는다(client: TestClient) -> None: # ── 미리 보기 ───────────────────────────────────────────────────────── +# M02 미리 보기 길은 39-3 에 은퇴(일위대가 상세 = calc 길) — 같은 계산(`combo_preview`)을 바로 부름 +def _preview(body: dict) -> dict: + got = combo_store.combo_preview(body["combo"], body["inputs"]) + return json.loads(json.dumps(got, default=str, ensure_ascii=False)) + + def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별로_같다(client: TestClient) -> None: key = _make(client)["key"] each = {k: _inputs(k) for k in SAMPLE} @@ -293,7 +300,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별 for name in ("노무비", "재료비", "경비", "계") } - got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": each}) + got = _preview({"combo": key, "inputs": each}) assert got["멈춤"] == [], got["멈춤"] assert [one["로직"] for one in got["줄"]] == list(SAMPLE) for name in ("노무비", "재료비", "경비", "계"): @@ -308,7 +315,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별 def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하지_않는다(client: TestClient) -> None: before = _list(client) row = {"이름": "저장 안 한 것", "담은로직": [{"로직": STACK, "메모": None}]} - got = _post(client, f"{SYS}/preview", {"combo": row, "inputs": {STACK: _inputs(STACK)}}) + got = _preview({"combo": row, "inputs": {STACK: _inputs(STACK)}}) assert got["멈춤"] == [] and Decimal(str(got["계"])) > 0 assert _list(client) == before # 아무것도 안 생김 @@ -316,7 +323,7 @@ def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하 def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: TestClient) -> None: key = _make(client, keys=(STACK, LAYER))["key"] only = {STACK: _inputs(STACK)} # LAYER 입력은 안 줌 — 그 줄만 멈춤 - got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": only}) + got = _preview({"combo": key, "inputs": only}) stuck = [one for one in got["줄"] if not one["ok"]] assert len(stuck) == 1 and stuck[0]["로직"] == LAYER and stuck[0]["까닭"] assert len(got["멈춤"]) == 1 @@ -327,6 +334,6 @@ def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: T def test_정본_로직은_조합을_만들어도_안_바뀐다(client: TestClient) -> None: before = _get(client, "/api/m01/logic", key=STACK) key = _make(client)["key"] - _post(client, f"{SYS}/preview", {"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}}) + _preview({"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}}) after = _get(client, "/api/m01/logic", key=STACK) assert after["version"] == before["version"] and after["logic"] == before["logic"] diff --git a/resources/tester/test_m02_labor_diagram.py b/resources/tester/test_m02_labor_diagram.py index bc78539fe..9e07ca7b4 100644 --- a/resources/tester/test_m02_labor_diagram.py +++ b/resources/tester/test_m02_labor_diagram.py @@ -1,4 +1,4 @@ -"""일위대가 조합 다이어그램 값 만들기(`M02_MasterTemplete_Labor_Diagram.ts` 의 `buildDiagramData`) — 미리 보기 줄 → 로직 카드 출력. +"""일위대가 다이어그램 값 만들기(`Labor_Diagram.ts` 의 `buildLaborDiagram` · 39-3) — calc 줄 → 카드 출력. 화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS 는 이어 트랜스파일 · 화면 부품(@ui)은 빈 흉내 · 금액 모양(Logic_Money)도 빈 흉내(이 시험은 값 만들기만 봄). 견본 줄은 이 시험 안에 둠. @@ -39,7 +39,7 @@ function load(f) { } const m = load(file); const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -process.stdout.write(JSON.stringify(jobs.map((j) => m.buildDiagramData(j)))); +process.stdout.write(JSON.stringify(jobs.map((j) => m.buildLaborDiagram(j)))); """ @@ -57,54 +57,76 @@ def run(jobs: list[dict]) -> list: return json.loads(done.stdout) -ROWS = [ +LOGICS = [ { - "key": "GC000001", - "name": "돌쌓기", - "unit": "원/㎡", - "inputs": [{"name": "높이", "unit": "m"}, {"name": "지역", "unit": ""}], + "로직": "GC000001", + "이름": "돌쌓기", + "결과단위": "원/㎡", + "입력": [{"이름": "높이", "단위": "m"}, {"이름": "지역", "자동값": "서울"}], }, - { - "key": "GF000002", - "name": "작업량", - "unit": "㎥/시간", - "inputs": [{"name": "지역", "unit": ""}], - }, - {"key": "GC000003", "name": "막힌 로직", "unit": "원/m", "inputs": []}, + {"로직": "GF000002", "이름": "작업량", "결과단위": "㎥/시간", "입력": [{"이름": "지역"}]}, + {"로직": "GC000003", "이름": "막힌 로직", "결과단위": "원/m", "입력": []}, ] +ROWS = [ + {"로직": "GC000001", "입력값": {"높이": 4}}, + {"로직": "GF000002", "입력값": {"지역": "부산"}}, + {"로직": "GC000003", "입력값": {}}, +] +MONEY = {"노무비": 100, "재료비": 50, "경비": 0, "계": 150} +APPLIED = {"노무비": 300, "재료비": 150, "경비": 0, "계": 450} -def test_돈_값_멈춤_세_갈래(): - lines = [ - {"ok": True, "갈래": "돈", "노무비": 100, "재료비": 50, "경비": 0, "계": 150}, - {"ok": True, "갈래": "결과", "결과": 8.946}, - {"ok": False, "까닭": "표에 맞는 줄이 없음"}, - ] - (got,) = run([{"rows": ROWS, "values": {"GC000001": {"높이": "4"}}, "lines": lines}]) +def _line(key: str, **more) -> dict: + return {"로직": key, "기준": None, "반영": None, "까닭": None, **more} + + +def test_돈_값_멈춤_세_갈래_반영이_있으면_반영(): + calc = { + "줄": [ + _line("GC000001", 기준=MONEY, 반영=APPLIED), + _line("GF000002", 결과=8.946), + _line("GC000003", 까닭="표에 맞는 줄이 없음"), + ], + "자재": [], + } + (got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": calc, "mats": 0}]) outs = [x["out"] for x in got["logics"]] - assert outs[0] == {"노무비": 100, "재료비": 50, "경비": 0, "계": 150} + assert outs[0] == APPLIED and got["logics"][0]["basis"] == "반영" assert outs[1] == {"값": 8.946} assert outs[2] == {"멈춤": "표에 맞는 줄이 없음"} -def test_넣은_값은_로직별_빈_값은_빈_글(): - (got,) = run( - [ - { - "rows": ROWS, - "values": {"GC000001": {"높이": "4"}, "GF000002": {"지역": "서울"}}, - "lines": None, - } - ] - ) +def test_수량_없으면_기준(): + calc = {"줄": [_line("GC000001", 기준=MONEY)], "자재": []} + (got,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 0}]) + assert got["logics"][0]["out"] == MONEY and got["logics"][0]["basis"] == "기준" + + +def test_넣은_값은_로직별_빈_값은_자동값_또는_빈_글(): + (got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": None, "mats": 0}]) first, second, third = got["logics"] - assert [(i["name"], i["value"]) for i in first["inputs"]] == [("높이", "4"), ("지역", "")] + assert [(i["name"], i["value"]) for i in first["inputs"]] == [("높이", "4"), ("지역", "서울")] + assert first["inputs"][1].get("auto") is True and "auto" not in first["inputs"][0] assert first["inputs"][0]["unit"] == "m" - assert [(i["name"], i["value"]) for i in second["inputs"]] == [("지역", "서울")] + assert [(i["name"], i["value"]) for i in second["inputs"]] == [("지역", "부산")] assert third["inputs"] == [] def test_아직_안_셌으면_출력_없음(): - (got,) = run([{"rows": ROWS, "values": {}, "lines": None}]) + (got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": None, "mats": 0}]) assert [x["out"] for x in got["logics"]] == [None, None, None] assert [x["key"] for x in got["logics"]] == ["GC000001", "GF000002", "GC000003"] + + +def test_자재목록은_카드_하나_재료비로(): + calc = { + "줄": [_line("GC000001", 기준=MONEY)], + "자재": [{"기준단가": 1000, "반영": 5000}, {"기준단가": 200, "반영": None}], + } + (got,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 2}]) + card = got["logics"][-1] + assert card["material"] is True and card["inputs"] == [] + assert card["out"] == {"노무비": 0, "재료비": 5200, "경비": 0, "계": 5200} + assert card["basis"] == "기준" # 반영 없는 자재가 있음 + (none,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 0}]) + assert not any(x.get("material") for x in none["logics"]) diff --git a/resources/tester/test_m02_labor_migrate.py b/resources/tester/test_m02_labor_migrate.py new file mode 100644 index 000000000..1098822af --- /dev/null +++ b/resources/tester/test_m02_labor_migrate.py @@ -0,0 +1,119 @@ +"""옛 일위대가 조합 → 열마다 일위대가 문서 옮기기(`M02_Labor_Migrate` · PLAN 39-1b). + +시스템 층 = 견본 표(임시 폴더) · 조합은 M01 마스터(읽기만) · 개인 층 = 임시 storage. +""" + +import json +from pathlib import Path + +import pytest + +from config import config_system +from M02_MasterTemplete import M02_Labor_Migrate as migrate +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +SHARED = "UP42-000001" + + +def _point(folder: Path, keys: dict[str, str]) -> None: + path = folder / "table" / f"{store.STRUCTURE_TABLE}.json" + doc = json.loads(path.read_bytes()) + for col in doc["열"]: + if col["id"] in keys: + col["일위대가"] = keys[col["id"]] + path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + + +@pytest.fixture() +def world(tmp_path, monkeypatch): + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + storage = tmp_path / "storage" + storage.mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + _point(system, {"rv": "UA000008", "c1": "UA000008", "pv_b": "UA999999"}) + personal = layers.personal_dir(7, 42) + items_store.ensure(personal) + with store.at(personal): + got = store.read("table", store.STRUCTURE_TABLE) + doc = got["문서"] + doc["열"] = [ + {"id": "a", "머리": ["돌", "가"], "꼴": "수", "일위대가": SHARED}, + {"id": "b", "머리": ["돌", "나"], "꼴": "수", "일위대가": SHARED}, + ] + store.write("table", store.STRUCTURE_TABLE, got["판"], doc) + combo = { + "그룹": "일위대가조합", + "다음": 2, + "줄": [ + { + "키": SHARED, + "이름": "개인 조합", + "단위": "개소", + "담은로직": [{"로직": "GF000219", "메모": "메쌓기"}], + } + ], + } + (personal / combos.FILE).write_text(json.dumps(combo, ensure_ascii=False), encoding="utf-8") + return {"system": system, "personal": personal, "storage": storage} + + +def _labor(folder: Path, cid: str) -> dict: + return json.loads((folder / "labor" / f"{cid}.json").read_bytes()) + + +def test_시스템_층_열마다_복사_같은_조합도_따로_없는_조합은_못함(world): + system = world["system"] + got = migrate.migrate(system) + assert got["옮김"] == ["rv ← UA000008", "c1 ← UA000008"] + assert got["못함"] == ["pv_b · 없는 일위대가 조합 「UA999999」"] + rv, c1 = _labor(system, "rv"), _labor(system, "c1") + assert rv["본뜬"] == {"층": "system", "주인": None, "키": "UA000008"} + assert rv["담은로직"] == c1["담은로직"] and len(rv["담은로직"]) == 14 + assert all(one["입력값"] == {} and one["수량"] is None for one in rv["담은로직"]) + assert rv["일위대가번"] != c1["일위대가번"] # 열마다 자기 번호 + with store.at(system): # 나중에 만든 도면도 같은 뒷자리 + store.create_structure("rv") + doc = json.loads((system / "structure" / "rv.json").read_bytes()) + assert doc["도번"][2:] == rv["일위대가번"][2:] + assert rv["계산값"]["줄"] # 서버가 계산값까지 + assert _labor(system, "ms")["담은로직"] == [] # 키 없는 열 = 빈 일위대가 + + +def test_개인_층_여러_열이_쓰던_조합은_열마다_본뜬은_그_층(world): + personal = world["personal"] + assert migrate.layer_folders(True) == [personal] + got = migrate.migrate(personal) + assert got["옮김"] == [f"a ← {SHARED}", f"b ← {SHARED}"] + a, b = _labor(personal, "a"), _labor(personal, "b") + assert a["본뜬"] == b["본뜬"] == {"층": "personal", "주인": "7/42", "키": SHARED} + assert (a["단위"], a["담은로직"][0]["메모"]) == ("개소", "메쌓기") + assert a["일위대가번"] != b["일위대가번"] + + +def test_다시_돌려도_같음_사람이_고친_문서는_그대로(world): + system = world["system"] + migrate.migrate(system) + before = {p.name: p.read_bytes() for p in (system / "labor").glob("*.json")} + assert migrate.migrate(system)["옮김"] == [] + after = {p.name: p.read_bytes() for p in (system / "labor").glob("*.json")} + assert before == after + with store.at(system): # 사람이 비운 문서도 다시 안 채움 + got = store.read("labor", "rv") + store.write("labor", "rv", got["판"], {**got["문서"], "담은로직": []}) + assert "rv" in migrate.migrate(system)["그대로"] + assert _labor(system, "rv")["담은로직"] == [] + + +def test_미리_보기는_파일을_안_씀(world): + system = world["system"] + got = migrate.migrate(system, dry=True) + assert "rv ← UA000008" in got["옮김"] + assert not list((system / "labor").glob("*.json"))