diff --git a/M01_MasterData/M01_MasterData_Machine.py b/M01_MasterData/M01_MasterData_Machine.py new file mode 100644 index 000000000..0972627af --- /dev/null +++ b/M01_MasterData/M01_MasterData_Machine.py @@ -0,0 +1,222 @@ +"""M01 기계 화면(PLAN 821-2) — 양식 목록 · 시간당 경비(서버 값) · 쓰는 곳 · 비슷한 기계 찾기 · +저장 전 기계 줄 손질(계수 서버 재계산 · 단위 · 출처 채움). + +계수 = `master_machine_coef`(TS 짝 `common_util/common_util_machine_coef.ts`) · +시간당 = 서버 기계경비식(`M02_Machine_Hourly.hourly` · 전국평균 · 기본 옵션) · +마스터 파일은 읽기만 — 쓰기는 `Store_Make.save`. +""" + +from __future__ import annotations + +import json +import re +from decimal import Decimal +from typing import Any + +import master_machine_coef as mc # noqa: E402 (`store` 가 scripts 를 길에 둠) + +from M01_MasterData import M01_MasterData_Store as store + +FILE = "기계.json" +OWN_SOURCE = "자체" +_EQ = re.compile(r"EQ\d{6}") +_cache: dict[str, Any] = {"ver": None, "hourly": {}, "uses": None} + + +def _rows() -> list[dict]: + return store.read(FILE)[0].get("줄") or [] + + +def _fresh() -> None: + """마스터를 다시 읽었으면(저장 뒤) 셈 보관을 비움.""" + ver = id(store.loaded()[1]) + if _cache["ver"] != ver: + _cache.update(ver=ver, hourly={}, uses=None) + + +def _plain(v): + if isinstance(v, Decimal): + return int(v) if v == v.to_integral_value() else float(v) + return v + + +# ── 양식 목록 ──────────────────────────────────────────────────────── +def lists() -> dict: + """연료(유가 전국평균) · 직종(인력) · 전력 단가 · 시간환산 · 원문 · 구분 — 양식 고르기.""" + files = store.loaded()[0] + oil = files.get("재료_유가전력.json", {}).get("줄") or [] + fuels = [ + { + "value": str(r["원문번호"]).split(":")[0], + "text": str(r["원문번호"]).split(":")[0], + "price": _plain(r.get("값")), + } + for r in oil + if r.get("구분") == "유류" and str(r.get("원문번호", "")).endswith(":전국평균") + ] + power = next( + (_plain(r.get("값")) for r in oil if r.get("원문번호") == "일반용갑I저압:전력량_평균"), 0 + ) + jobs = [ + { + "value": str(r["원문번호"]), + "text": f"{r['원문번호']} {r.get('이름') or ''}", + "price": _plain(r.get("값")), + } + for r in files.get("인력.json", {}).get("줄") or [] + if isinstance(r.get("값"), (int, float, Decimal)) + ] + head = store.read(FILE)[0] + hour = Decimal(1) / Decimal(8) + for t in files.get("계수_건설품셈_08장_건설기계.json", {}).get("표") or []: + if t.get("키") == "CC000336": + got = next((x for x in t.get("줄") or [] if x.get("구분") == "시간환산"), None) + if got: + hour = Decimal(got["분자"]) / Decimal(got["분모"]) + return { + "fuels": fuels, + "jobs": jobs, + "power": power, + "hour": float(hour), + "books": list(head.get("원문") or {}), + "groups": {k: (v or {}).get("원문") for k, v in (head.get("구분") or {}).items()}, + } + + +# ── 시간당 경비 ────────────────────────────────────────────────────── +def hourly(keys: list[str]) -> dict[str, dict]: + """키마다 {노무비 · 재료비 · 경비 · 계} 또는 {reason} — 마스터 판이 같으면 한 번만 셈.""" + from M02_MasterTemplete import M02_Machine_Hourly as mh # M02 가 M01 을 부름 — 늦게 + + _fresh() + out = {} + for key in keys[:100]: + if key not in _cache["hourly"]: + got = mh.hourly(key) + _cache["hourly"][key] = ( + {k: got[k] for k in ("노무비", "재료비", "경비", "계", "로직")} + if got.get("ok") + else {"reason": got.get("reason")} + ) + out[key] = _cache["hourly"][key] + return out + + +# ── 쓰는 곳 ───────────────────────────────────────────────────────── +def _logic_index() -> tuple[dict[str, list[str]], dict[str, list[str]]]: + """(원문번호 → 로직 「키 이름」 · 기계 키 → 로직) — 기계 고르기 · 식 안 EQ 키.""" + _fresh() + if _cache["uses"] is None: + by_code: dict[str, list[str]] = {} + by_key: dict[str, list[str]] = {} + for data in store.loaded()[0].values(): + if data.get("그룹") != "로직": + continue + for row in data.get("줄") or []: + label = f"로직 {row.get('키')} {row.get('이름') or ''}".strip() + for i in row.get("입력") or []: + if i.get("이름") == "기계": + for c in i.get("고르기") or []: + by_code.setdefault(str(c), []).append(label) + text = json.dumps(row, ensure_ascii=False, default=str) + for k in set(_EQ.findall(text)): + by_key.setdefault(k, []).append(label) + _cache["uses"] = (by_code, by_key) + return _cache["uses"] + + +def uses(key: str) -> list[str]: + """그 기계를 쓰는 곳 — 로직(기계 고르기 · 식) · M02 단가산출(사본 · 줄에 키).""" + from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게 + + row = next((r for r in _rows() if r.get("키") == key), None) + if row is None: + raise store.StoreError(404, f"없는 기계 「{key}」") + by_code, by_key = _logic_index() + code = str(row.get("원문번호") or "") + out = list(dict.fromkeys([*(by_code.get(code, []) if code else []), *by_key.get(key, [])])) + mark = f'"{key}'.encode() + for where, path in layers.layer_files("sangeun"): + try: + raw = path.read_bytes() + except OSError: + continue + if mark in raw: + try: + name = json.loads(raw).get("이름") or path.stem + except ValueError: + name = path.stem + out.append(f"M02 단가산출 {where} {name}") + return out + + +# ── 비슷한 기계 찾기 · 한 줄 ───────────────────────────────────────── +def find(q: str, limit: int = 30) -> list[dict]: + words = [w for w in q.lower().split() if w] + out = [] + for r in _rows(): + hay = " ".join(str(r.get(k) or "") for k in ("이름", "규격", "별칭", "상세구분", "키")) + hay = hay.lower() + if all(w in hay for w in words): + out.append(_brief(r)) + if len(out) >= limit: + break + return out + + +def _brief(r: dict) -> dict: + keep = ("키", "원문번호", "원문", "구분", "상세구분", "이름", "규격", "값", "별칭", "비고") + return {k: r[k] for k in keep if k in r} + + +def row(key: str) -> dict: + r = next((x for x in _rows() if x.get("키") == key), None) + if r is None: + raise store.StoreError(404, f"없는 기계 「{key}」") + return {"version": store.read(FILE)[1], "row": r} + + +# ── 저장 전 손질(`Store_Make.save` 가 부름) ────────────────────────── +def _units() -> dict[str, str]: + out: dict[str, str] = {} + for r in _rows(): + for k, u in (r.get("단위") or {}).items(): + out.setdefault(k, u) + return out + + +def _same_inputs(a: dict, b: dict) -> bool: + return all( + float(a[k]) == float(b[k]) if isinstance(b.get(k), (int, float, Decimal)) else False + for k in mc.INPUTS + ) + + +def fix(part: dict) -> None: + """기계 줄 고침 · 더함 — 계수는 서버가 짝으로 다시 셈(품셈 입력이 저장 값과 같으면 + 저장 값 그대로) · 단위 · 출처 묶음 = 값 칸과 같은 칸 + (있던 칸 그대로 · 새 칸 = 마스터 단위 · 출처 「자체」).""" + if part.get("file") != FILE: + return + stored = {str(r.get("키")): r for r in _rows()} + units = _units() + for ch in part.get("changes") or []: + row = ch.get("row") + if ( + ch.get("op") == "delete" + or not isinstance(row, dict) + or not isinstance(row.get("값"), dict) + ): + continue + old = stored.get(str(ch.get("key") or "")) if ch.get("op") == "edit" else None + values = row["값"] + old_values = (old or {}).get("값") or {} + if all(isinstance(values.get(k), (int, float)) for k in mc.INPUTS): + if old and _same_inputs(values, old_values): + values.update({k: old_values[k] for k in mc.COEFS if k in old_values}) + elif (got := mc.coef_of(values)) is not None: + values.update(got) + was_unit = (old or {}).get("단위") or {} + was_src = (old or {}).get("출처") or {} + row["단위"] = {k: was_unit.get(k, units.get(k, "")) for k in values} + row["출처"] = {k: was_src.get(k, OWN_SOURCE) for k in values} diff --git a/M01_MasterData/M01_MasterData_Machine_Api.ts b/M01_MasterData/M01_MasterData_Machine_Api.ts new file mode 100644 index 000000000..6598fec05 --- /dev/null +++ b/M01_MasterData/M01_MasterData_Machine_Api.ts @@ -0,0 +1,57 @@ +/* ============================================================================= + * M01_MasterData_Machine_Api.ts + * M01 기계 화면 서버 호출(PLAN 821-2) — 양식 목록 · 시간당 경비 · 쓰는 곳 · 비슷한 기계 찾기 · 한 줄. + * 서버 = `M01_MasterData_Machine.py` · 시스템 관리자만. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { MachinePick } from "@ui/ui_template_machine_form"; +import type { Row } from "./M01_MasterData_Api_Fetch"; + +export interface MachineLists { + fuels: MachinePick[]; + jobs: MachinePick[]; + power: number; + hour: number; + books: string[]; + /** 구분 → 원문 */ + groups: Record; +} + +export interface MachineHourly { + 노무비?: number; + 재료비?: number; + 경비?: number; + 계?: number; + reason?: string; +} + +async function get(path: string, params: Record = {}): Promise { + const query = new URLSearchParams(params); + const res = await fetch(`${API_BASE_URL}/m01/machine${path}?${query}`, { + credentials: "include", + }); + const body = (await res.json()) as T & { detail?: unknown }; + if (!res.ok) throw new Error(String(body.detail ?? `HTTP ${res.status}`)); + return body; +} + +let lists: Promise | null = null; +/** 한 번 받아 둠(페이지 다시 그리면 다시 받음) */ +export const fetchMachineLists = (fresh = false): Promise => { + if (!lists || fresh) + lists = get("/lists").catch((e) => ((lists = null), Promise.reject(e))); + return lists; +}; + +export const fetchMachineHourly = (keys: string[]): Promise> => + keys.length ? get("/hourly", { keys: keys.join(",") }) : Promise.resolve({}); + +export const fetchMachineUses = async (key: string): Promise => + (await get<{ uses: string[] }>("/uses", { key })).uses; + +export const findMachines = async (q: string): Promise => + (await get<{ rows: Row[] }>("/find", { q })).rows; + +export const fetchMachineRow = (key: string): Promise<{ version: string; row: Row }> => + get("/row", { key }); diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index c18bd242a..c9398209d 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -14,6 +14,7 @@ from pydantic import BaseModel from M01_MasterData import M01_MasterData_Doc as doc_screen from M01_MasterData import M01_MasterData_Equation as equation +from M01_MasterData import M01_MasterData_Machine as machine from M01_MasterData import M01_MasterData_Parts as parts from M01_MasterData import M01_MasterData_Sheet as line_sheet from M01_MasterData import M01_MasterData_Store as store @@ -259,6 +260,34 @@ def get_materials( return _call(store.materials, sub, detail, spec, region, unit, limit) +@router.get("/machine/lists") +def get_machine_lists() -> dict: + """기계 양식 고르기 — 연료 · 직종 · 단가 · 원문 · 구분(821-2).""" + return _call(machine.lists) + + +@router.get("/machine/hourly") +def get_machine_hourly(keys: str) -> dict: + """기계 키(쉼표) → 시간당 경비(서버 기계경비식 · 전국평균).""" + return _call(machine.hourly, [k for k in keys.split(",") if k]) + + +@router.get("/machine/uses") +def get_machine_uses(key: str) -> dict: + return {"uses": _call(machine.uses, key)} + + +@router.get("/machine/find") +def get_machine_find(q: str = "") -> dict: + """비슷한 기계에서 시작 — 이름 · 규격 · 별칭 찾기(값 묶음 같이).""" + return {"rows": _call(machine.find, q)} + + +@router.get("/machine/row") +def get_machine_row(key: str) -> dict: + return _call(machine.row, key) + + @router.post("/calc") def post_calc(body: CalcBody) -> dict: with store.cm.mf.mp.choosing(body.고름): diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 142a9bae1..291b7b78c 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -11,6 +11,7 @@ import re from pathlib import Path from common_util.common_util_time import kst_today +from M01_MasterData import M01_MasterData_Machine as machine from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Revise as revise @@ -291,6 +292,7 @@ def save(batch: list[dict], confirmed: bool = False) -> dict: ): # 별칭(815-2) = 앞뒤 빈칸만 뗌 · 빈 글 = 칸 없음 text = str(row["별칭"] or "").strip() row.update(별칭=text) if text else row.pop("별칭") + machine.fix(part) # 기계 줄 = 계수 서버 재계산 · 단위 · 출처 채움(821-2) _keep_same(part) fresh = False for part in batch: diff --git a/M01_MasterData/M01_MasterData_UI_Machine.css b/M01_MasterData/M01_MasterData_UI_Machine.css new file mode 100644 index 000000000..1a4c5f741 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Machine.css @@ -0,0 +1,68 @@ +/* M01 기계 목록 · 상세(821-2) */ +.m01-machine__row { + cursor: pointer; +} + +.m01-machine__row:hover td { + background: var(--color-hover); +} + +.m01-master__grid tr.is-new td { + background: color-mix(in srgb, var(--color-success) 12%, var(--color-canvas)); +} + +.m01-machine__grid td.m01-machine__money { + text-align: end; + font-variant-numeric: tabular-nums; +} + +/* 목록 열은 화면 폭 안에 — 가로 넘침 없음 · 긴 글은 말줄임(마우스 글 = 전체) */ +table.m01-master__grid.m01-machine__grid { + width: 100%; + table-layout: fixed; +} + +.m01-machine__grid th:nth-child(1) { + width: 16%; +} + +.m01-machine__grid th:nth-child(3), +.m01-machine__grid th:nth-child(10) { + width: 14%; +} + +.m01-machine__grid th:nth-child(2), +.m01-machine__grid th:nth-child(9) { + width: 10%; +} + +.m01-machine-detail { + display: flex; + flex-direction: column; + gap: var(--spacing-16); + padding-bottom: var(--spacing-16); +} + +.m01-machine-detail__meta { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: var(--spacing-8) var(--spacing-12); +} + +.m01-machine-detail__uses { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m01-machine-detail__uses ul { + margin: 0; + padding-left: var(--spacing-16); + font-size: var(--text-body-sm); + color: var(--color-text-secondary); +} + +.m01-machine-detail__uses ul { + max-height: 240px; + overflow: auto; +} diff --git a/M01_MasterData/M01_MasterData_UI_Machine.ts b/M01_MasterData/M01_MasterData_UI_Machine.ts new file mode 100644 index 000000000..5e0e3f6b8 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Machine.ts @@ -0,0 +1,177 @@ +/* ============================================================================= + * M01_MasterData_UI_Machine.ts + * M01 기계 목록(PLAN 821-2 · 요구 790-2) — 표는 목록용 열만(이름 · 규격 · 구분 · 가격 · 손료계수 · + * 주연료 · 조종원 · 시간당 경비(서버 값) · 별칭 · 비고) · 줄을 누르면 상세 페이지 · [새 기계] = 양식. + * 단위 · 출처 열 없음 · 고친 것 · 새 줄 · 지울 줄은 초안(노랑 · 새 · 지움 표시) — 저장은 페이지 [저장]. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { createEmpty } from "@ui/ui_template_empty"; +import { guessRows, pageKeepFirst, watchFit } from "@ui/ui_template_pager"; +import { fetchRows, type Row, type RowsPage } from "./M01_MasterData_Api_Fetch"; +import { onDraftChange, peek, same } from "./M01_MasterData_Draft"; +import { fetchMachineHourly } from "./M01_MasterData_Machine_Api"; +import { buildPager } from "./M01_MasterData_UI_Cells"; +import { saveView, type Pick } from "./M01_MasterData_UI_Side"; + +const BAR_H = 52; +const num = (v: unknown): string => + typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : ""; + +/** 목록 열 — 이름 · 줄 → 글 */ +const COLS: [string, (r: Row) => string][] = [ + ["이름", (r) => String(r["이름"] ?? "")], + ["규격", (r) => String(r["규격"] ?? "")], + ["구분", (r) => [r["구분"], r["상세구분"]].filter(Boolean).join(" · ")], + ["가격(천원)", (r) => num(val(r)["가격"])], + ["손료계수", (r) => num(val(r)["손료계수"])], + [ + "주연료", + (r) => + val(r)["주연료"] ? `${val(r)["주연료종류"] ?? ""} ${num(val(r)["주연료"])}ℓ`.trim() : "", + ], + [ + "조종원", + (r) => + val(r)["조종원"] ? `${num(val(r)["조종원"])}인 ${val(r)["조종원직종"] ?? ""}`.trim() : "", + ], +]; +const val = (r: Row): Record => (r["값"] ?? {}) as Record; + +/** 돌려받은 함수 = 걷기. `open(target)` = 상세(키 · 새 줄 `add:<차례>` · 새 기계 `new`). */ +export function renderMachines( + host: HTMLElement, + pick: Pick, + q: string, + open: (target: string) => void, +): () => void { + const file = pick.file.file; + let size = guessRows(host, BAR_H); + let page = pick.view?.page ?? 1; + let data: RowsPage | null = null; + const hourly = new Map(); + + const hourText = (key: string): string => hourly.get(key) ?? "…"; + const line = (row: Row, cur: Row, cls: string, target: string, key = ""): HTMLElement => { + const tr = el("tr", { + className: `m01-machine__row ${cls}`.trim(), + attrs: { "data-key": key || target }, + children: [ + ...COLS.map(([, get]) => { + const text = get(cur); + const td = el("td", { text, attrs: { title: text } }); + td.classList.toggle("is-changed", cls !== "is-new" && text !== get(row)); + return td; + }), + el("td", { + className: "m01-machine__money", + text: key ? hourText(key) : "", + attrs: key ? { "data-hourly": key } : {}, + }), + el("td", { text: String(cur["별칭"] ?? ""), attrs: { title: String(cur["별칭"] ?? "") } }), + el("td", { text: String(cur["비고"] ?? ""), attrs: { title: String(cur["비고"] ?? "") } }), + ], + }); + tr.addEventListener("click", () => open(target)); + return tr; + }; + + const paint = (): void => { + if (!data) return; + const d = data; + const draft = peek(file); + const body = el("tbody"); + (draft?.adds ?? []).forEach((r, i) => body.append(line(r, r, "is-new", `add:${i}`))); + for (const row of d.rows) { + const key = String(row["키"]); + const cur = draft?.edits[key] ?? row; + const cls = draft?.deletes.includes(key) ? "is-deleted" : same(cur, row) ? "" : "is-changed"; + body.append(line(row, cur, cls, key, key)); + } + const add = createButton({ label: L("M01_MachineNew"), variant: "ghost", iconStart: "plus" }); + add.addEventListener("click", () => open("new")); + const empty = + d.rows.length || draft?.adds.length + ? [] + : [createEmpty({ text: L("M01_NoRows"), small: true })]; + host.replaceChildren( + el("div", { className: "ui-toolbar m01-master__bar", children: [add] }), + el("div", { + className: "ui-table-wrap m01-master__grid-wrap m01-machine__wrap", + children: [ + el("table", { + className: "ui-table m01-master__grid m01-machine__grid", + children: [ + el("thead", { + children: [ + el("tr", { + children: [ + ...COLS.map(([h]) => el("th", { text: h })), + el("th", { text: `${L("M01_MachineHourly")}(원)` }), + el("th", { text: "별칭" }), + el("th", { text: "비고" }), + ], + }), + ], + }), + body, + ], + }), + ], + }), + buildPager(d.total, size, d.page, go), + ...empty, + ); + fit.ask(); + }; + + /** 시간당 경비 — 표를 그린 뒤 서버 값으로 채움(마스터 판마다 서버가 한 번 셈) */ + const fillHourly = async (keys: string[]): Promise => { + const want = keys.filter((k) => !hourly.has(k)); + if (!want.length) return; + try { + const got = await fetchMachineHourly(want); + for (const [k, h] of Object.entries(got)) hourly.set(k, h.계 === undefined ? "—" : num(h.계)); + } catch { + for (const k of want) hourly.set(k, "—"); + } + host.querySelectorAll("td[data-hourly]").forEach((td) => { + const text = hourly.get(td.dataset.hourly!); + if (text) td.textContent = text; + }); + }; + + const go = (next: number): void => { + page = next; + void load(); + }; + + const load = async (): Promise => { + try { + data = await fetchRows(file, page, size, q, false, pick.sub, pick.detail, pick.state); + saveView(pick.group, { page: data.page }); + paint(); + void fillHourly(data.rows.map((r) => String(r["키"]))); + } catch (error) { + showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); + } + }; + + const fit = watchFit( + host, + () => host.querySelector(".m01-master__grid-wrap"), + (rows) => { + if (rows === size) return; + page = pageKeepFirst(data?.page ?? page, size, rows); + size = rows; + void load(); + }, + ); + const off = onDraftChange(paint); + void load(); + return () => { + fit.stop(); + off(); + }; +} diff --git a/M01_MasterData/M01_MasterData_UI_MachineDetail.ts b/M01_MasterData/M01_MasterData_UI_MachineDetail.ts new file mode 100644 index 000000000..d1cad8a5f --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_MachineDetail.ts @@ -0,0 +1,304 @@ +/* ============================================================================= + * M01_MasterData_UI_MachineDetail.ts + * M01 기계 상세 페이지(PLAN 821-2 · 요구 790-2 · 791) — 기계 양식 부품 + 원문번호 · 원문 · 구분 · + * 상세구분 · 별칭 · 비고 · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인). + * 고침은 초안(sessionStorage)에 바로 쌓임 → 페이지 [저장] 한 번에 서버로(자동저장 없음) · + * 서버가 계수를 계수 짝으로 다시 셈 · 단위 · 출처 칸을 채움(`M01_MasterData_Machine.fix`). + * 대상 = 키(EQ…) · 저장 전 새 줄 `add:<차례>` · 새 기계 `new`(비슷한 기계에서 시작이 펼쳐짐). + * ========================================================================== */ + +import { + createButton, + createInputField, + createSelectField, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { createMachineForm, type MachineSimilar } from "@ui/ui_template_machine_form"; +import { createText } from "@ui/ui_template_text"; +import { setLeaveGuard } from "../A00_Common/router"; +import type { Row } from "./M01_MasterData_Api_Fetch"; +import { addRow, editRow, peek, removeAdd, setAdd, toggleDelete } from "./M01_MasterData_Draft"; +import { + fetchMachineLists, + fetchMachineRow, + fetchMachineUses, + findMachines, + type MachineLists, +} from "./M01_MasterData_Machine_Api"; +import type { Pick } from "./M01_MasterData_UI_Side"; + +export interface MachineDetailOptions { + target: string; + pick: Pick; + back: () => void; + /** 새 기계가 초안 새 줄이 되면 — 주소를 `add:<차례>` 로 */ + onAdded?: (target: string) => void; +} + +const META = ["원문번호", "원문", "구분", "상세구분", "별칭", "비고"] as const; +type Meta = Record<(typeof META)[number], string>; +const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v)); + +/** 돌려받은 함수 = 걷기 */ +export function openMachineDetail(host: HTMLElement, opts: MachineDetailOptions): () => void { + let alive = true; + const stop = (): void => { + alive = false; + setLeaveGuard(null); + }; + void build(host, opts, () => alive).catch((error) => { + showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); + opts.back(); + }); + return stop; +} + +async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () => boolean) { + const file = opts.pick.file.file; + const lists = await fetchMachineLists(); + let version = opts.pick.file.version; + let key = ""; + let addAt: number | null = null; + let orig: Row | null = null; + let cur: Row; + if (opts.target.startsWith("add:")) { + addAt = Number(opts.target.slice(4)); + const row = peek(file)?.adds[addAt]; + if (!row) throw new Error(L("M01_MachineNotFound")); + cur = row; + } else if (opts.target === "new") { + cur = blankRow(lists); + } else { + key = opts.target; + const got = await fetchMachineRow(key); + version = peek(file)?.version ?? got.version; + orig = got.row; + cur = peek(file)?.edits[key] ?? got.row; + } + if (!alive()) return; + let base: Row = orig ?? cur; // 줄 칸 차례 — 고침은 원래 줄 · 새 줄은 지금 줄 + const meta: Meta = Object.fromEntries(META.map((m) => [m, text(cur[m])])) as Meta; + const metaOrig: Meta = Object.fromEntries(META.map((m) => [m, text((orig ?? {})[m])])) as Meta; + const isNew = !key; + + /* --- 비슷한 기계 찾기(양식이 고르면 값 · 이름 · 규격 복사 · 여기서 원문 · 구분 복사) --- */ + const found = new Map(); + const findSimilar = async (q: string): Promise => { + const rows = await findMachines(q); + return rows.map((r) => { + found.set(String(r["키"]), r); + return { + key: String(r["키"]), + name: text(r["이름"]), + spec: text(r["규격"]), + tag: text(r["상세구분"] || r["구분"]), + values: (r["값"] ?? {}) as Row, + }; + }); + }; + + const notice = el("div"); + const form = createMachineForm({ + name: text(cur["이름"]), + spec: text(cur["규격"]), + values: (cur["값"] ?? {}) as Row, + coefMode: "pumsem", + fuels: lists.fuels, + jobs: lists.jobs, + power: lists.power, + hour: lists.hour, + findSimilar, + onSimilar: (item) => { + const r = found.get(item.key); + if (!r || !isNew) return; + base = { ...blankRow(lists), ...pickMeta(r), 값: r["값"] ?? {}, 비고: r["비고"] ?? null }; + for (const m of ["원문", "구분", "상세구분"] as const) meta[m] = text(r[m]); + paintMeta(); + }, + onChange: () => commit(), + }); + + /** 양식 · 머리 칸 → 초안(이름 빈 칸 · 숫자 아닌 칸이 있으면 안 넣음) */ + const commit = (): void => { + const s = form.state(); + const errors = [ + ...s.errors, + ...(s.name ? [] : [`${L("MF_Name")} · ${L("M01_MachineErrors")}`]), + ]; + notice.replaceChildren(...errors.map((e) => createText(e, "error"))); + if (errors.length) return; + const row: Row = { ...base, ...cur, 이름: s.name, 규격: s.spec, 값: s.values }; + for (const m of META) { + if (orig && meta[m] === metaOrig[m]) { + if (m in orig) row[m] = orig[m]; + else delete row[m]; // 안 고친 칸 = 원래 그대로 + } else if (meta[m]) row[m] = meta[m]; + else if (m === "별칭") + delete row[m]; // 빈 별칭 = 칸 없음 + else row[m] = m === "비고" ? null : ""; + } + const ordered: Row = Object.fromEntries( + [...Object.keys(base), ...Object.keys(row)] + .filter((k, i, all) => all.indexOf(k) === i && k in row) + .map((k) => [k, row[k]]), + ); + if (key && orig) editRow(file, version, key, orig, ordered); + else if (addAt !== null) setAdd(file, addAt, ordered); + else { + addRow(file, version, { 키: "", ...ordered }); + addAt = (peek(file)?.adds.length ?? 1) - 1; + opts.onAdded?.(`add:${addAt}`); + } + cur = ordered; + checkSame(s.name, s.spec); + }; + + /* --- 같은 이름 · 규격 알림 --- */ + let sameTimer: number | undefined; + const checkSame = (name: string, spec: string): void => { + window.clearTimeout(sameTimer); + sameTimer = window.setTimeout(async () => { + const rows = name ? await findMachines(name).catch(() => [] as Row[]) : []; + const hit = rows.find( + (r) => r["키"] !== key && text(r["이름"]) === name && text(r["규격"]) === spec, + ); + form.setNotice(hit ? L("M01_MachineSame").replace("{value}", text(hit["키"])) : ""); + }, 300); + }; + + /* --- 머리 칸 --- */ + const metaBox = el("div", { className: "m01-machine-detail__meta" }); + const paintMeta = (): void => { + const field = (m: (typeof META)[number], label: string): HTMLElement => { + const options = m === "원문" ? lists.books : m === "구분" ? Object.keys(lists.groups) : null; + const mark = (root: HTMLElement): void => { + root.classList.toggle("is-changed", !isNew && meta[m] !== metaOrig[m]); + }; + if (options) { + const list = options.includes(meta[m]) || !meta[m] ? options : [meta[m], ...options]; + const f = createSelectField({ + label, + options: list.map((v) => ({ value: v, text: v })), + value: meta[m], + size: 28, + }); + f.select.addEventListener("change", () => { + meta[m] = f.select.value; + if (m === "구분" && lists.groups[meta[m]]) meta["원문"] = lists.groups[meta[m]]; + mark(f.root); + commit(); + if (m === "구분") paintMeta(); + }); + mark(f.root); + return f.root; + } + const f = createInputField({ label, value: meta[m], size: 28 }); + f.input.addEventListener("input", () => { + meta[m] = f.input.value; + mark(f.root); + commit(); + }); + mark(f.root); + return f.root; + }; + metaBox.replaceChildren( + field("원문번호", L("M01_MachineSourceNo")), + field("원문", "원문"), + field("구분", "구분"), + field("상세구분", L("M01_MachineDetailGroup")), + field("별칭", "별칭"), + field("비고", "비고"), + ); + }; + paintMeta(); + + /* --- 쓰는 곳 --- */ + const usesBox = el("section", { className: "m01-machine-detail__uses" }); + let uses: string[] = []; + const paintUses = (): void => { + usesBox.replaceChildren( + el("strong", { text: `${L("M01_MachineUses")} ${uses.length}` }), + uses.length + ? el("ul", { children: uses.map((u) => el("li", { text: u })) }) + : createText(L("M01_MachineNoUses")), + ); + }; + if (key) + void fetchMachineUses(key) + .then((got) => { + uses = got; + paintUses(); + }) + .catch(() => paintUses()); + else paintUses(); + + /* --- 단추 · 떠남 --- */ + const dirty = (): boolean => addAt !== null || Boolean(key && peek(file)?.edits[key]); + const leave = async (): Promise => + !dirty() || (await showConfirmDialog(L("M01_MachineLeave"))); + setLeaveGuard(leave); + const backBtn = createButton({ + label: L("M01_MachineBack"), + variant: "ghost", + iconStart: "chevronLeft", + }); + backBtn.addEventListener("click", async () => { + if (await leave()) opts.back(); + }); + const del = createButton({ label: L("M01_MachineDelete"), variant: "danger-ghost" }); + del.addEventListener("click", async () => { + const ask = L("M01_MachineDeleteAsk").replace("{value}", String(uses.length)); + if (!(await showConfirmDialog(uses.length ? `${ask}\n${uses.join("\n")}` : ask))) return; + if (key) toggleDelete(file, version, key); + else if (addAt !== null) removeAdd(file, addAt); + setLeaveGuard(null); + opts.back(); + }); + if (!key && addAt === null) del.hidden = true; + const title = isNew ? L("M01_MachineNew") : `${text(cur["이름"])} ${text(cur["규격"])}`.trim(); + host.replaceChildren( + el("div", { + className: "m01-machine-detail", + children: [ + el("div", { + className: "ui-toolbar m01-master__bar", + children: [ + backBtn, + el("strong", { text: title }), + el("span", { className: "ui-text ui-text--muted", text: key }), + del, + ], + }), + notice, + metaBox, + form.root, + usesBox, + ], + }), + ); + if (opts.target === "new") + host.querySelector("details")?.setAttribute("open", ""); +} + +/** 새 기계 바탕 — 칸 차례는 기계 줄과 같음 · 단위 · 출처는 서버가 채움 */ +function blankRow(lists: MachineLists): Row { + const group = Object.keys(lists.groups)[0] ?? ""; + return { + 원문번호: "", + 원문: lists.groups[group] ?? lists.books[0] ?? "", + 구분: group, + 상세구분: "", + 이름: "", + 규격: "", + 단위: {}, + 값: {}, + 출처: {}, + 비고: null, + }; +} + +/** 고른 기계의 원문 · 구분 · 상세구분(새 기계 바탕) */ +const pickMeta = (r: Row): Row => ({ 원문: r["원문"], 구분: r["구분"], 상세구분: r["상세구분"] }); diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index c46d06ab6..78eae7c43 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -33,6 +33,9 @@ import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch"; import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft"; import { buildHeadBar, buildHeadFilters } from "./M01_MasterData_UI_Head"; import { renderRows } from "./M01_MasterData_UI_Rows"; +import { renderMachines } from "./M01_MasterData_UI_Machine"; +import { openMachineDetail } from "./M01_MasterData_UI_MachineDetail"; +import "./M01_MasterData_UI_Machine.css"; import { renderRateRows } from "./M01_MasterData_UI_RateSheet"; import { buildSide, @@ -54,6 +57,11 @@ import type { LogicHandle } from "./M01_MasterData_UI_LogicLab"; import "./M01_MasterData_UI_Style.css"; const TABLE_GROUPS = ["소요량", "계수"]; +// 기계 = 목록 + 상세 페이지(821-2) · 주소 `?machine=키|add:n|new` · 목록 거름은 되돌아올 때 다시 엶 +const MACHINE_FILE = "기계.json"; +const MACHINE_BACK = "m01.machine.back"; +const machineUrl = (target?: string): string => + `#/${ROUTES.M01_MASTER_DATA}${target ? `?machine=${encodeURIComponent(target)}` : ""}`; export async function renderM01MasterData(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); @@ -171,15 +179,39 @@ function buildPage(): HTMLElement { current = { ...pick, view: undefined }; const groupTitle = pick.group === pick.label ? [pick.group] : [pick.group, pick.label]; title.textContent = groupTitle.join(" · "); + const machine = pick.file.file === MACHINE_FILE; + if (machine) sessionStorage.setItem(MACHINE_BACK, JSON.stringify(pick)); + else sessionStorage.removeItem(MACHINE_BACK); + if (currentQuery().has("machine")) history.replaceState(null, "", machineUrl()); dispose = TABLE_GROUPS.includes(pick.group) ? renderTables(body, pick, query, openLogicTab) - : pick.group === "요율" - ? renderRateRows(body, pick, query) - : renderRows(body, pick, query); + : machine + ? renderMachines(body, pick, query, (target) => openMachine(target, true)) + : pick.group === "요율" + ? renderRateRows(body, pick, query) + : renderRows(body, pick, query); renderParts(partsHost, pick, (key) => openLogicTab(key)); showNotice(isStale(pick.file.file, pick.file.version) ? [L("M01_FileStale")] : []); }; + /** 기계 상세 — 목록에서 열면 주소를 쌓아 뒤로 가기 = 목록(페이지를 다시 그려 목록 거름을 되엶) */ + const openMachine = (target: string, push: boolean): void => { + if (!current) return; + const pick = current; + if (push) history.pushState({ m01Machine: true }, "", machineUrl(target)); + else history.replaceState(history.state, "", machineUrl(target)); + dispose(); + dispose = openMachineDetail(body, { + target, + pick, + back: () => { + if ((history.state as { m01Machine?: boolean } | null)?.m01Machine) return history.back(); + openFile({ ...pick, view: loadView(pick.group) }); + }, + onAdded: (next) => history.replaceState(history.state, "", machineUrl(next)), + }); + }; + let searchTimer: number | undefined; search.addEventListener("input", () => { window.clearTimeout(searchTimer); @@ -342,5 +374,25 @@ function buildPage(): HTMLElement { // 주소 `?open=` — 대시보드 설계 기본값 줄 · 옛 M02 `open=옵션` 즐겨찾기가 그 칸을 고른 채 엶 const want = currentQuery().get("open"); if (want === "로직" || want === "옵션" || want === "별칭") side.reveal(want); + const machine = currentQuery().get("machine"); + const backTo = sessionStorage.getItem(MACHINE_BACK); + if (machine || (backTo && !want)) { + void side.refresh("기계").then((files) => { + const saved = (backTo ? JSON.parse(backTo) : {}) as Partial; + const file = files.find((f) => f.file === MACHINE_FILE); + if (!file) return; + const label = saved.label ?? "기계"; + openFile({ + group: "기계", + sub: "", + detail: "", + label, + ...saved, + file, + view: loadView("기계"), + }); + if (machine) openMachine(machine, false); + }); + } return layout; } diff --git a/resources/tester/test_821_2_machine_save.py b/resources/tester/test_821_2_machine_save.py new file mode 100644 index 000000000..02986f139 --- /dev/null +++ b/resources/tester/test_821_2_machine_save.py @@ -0,0 +1,156 @@ +"""PLAN 821-2 M01 기계 저장 — 지킴 시험(임시 사본 폴더만 · 정본 안 씀). + +① 새 기계 = 단위 · 출처 자동(마스터 단위 · 「자체」) · 계수 서버 재계산 · 다른 줄 그대로 +② 고침 = 그 칸 한 줄만 · 품셈 입력이 같으면 저장 계수 그대로(점보드릴 777) · 바뀌면 다시 셈 +③ 기계 길은 시스템 관리자만(아니면 403) · 양식 목록 · 비슷한 기계 찾기 · 쓰는 곳 +""" + +from __future__ import annotations + +import difflib +import json +import shutil +import sys +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from common_util import common_util_auth as auth +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store + +sys.path.insert( + 0, str(Path(__file__).resolve().parents[2] / "resources" / "master_data" / "scripts") +) +import master_machine_coef as mc # noqa: E402 + +REAL = store.FOLDER +MACHINE = "기계.json" + + +@pytest.fixture +def copy(tmp_path: Path, monkeypatch) -> Path: + for path in REAL.glob("*.json"): + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + return tmp_path + + +def _app(role: str = "SYSTEM_ADMIN") -> TestClient: + app = FastAPI() + app.dependency_overrides[auth.verify_session] = lambda: { + "user_id": 1, + "name": "시험", + "role": role, + "company_id": 1, + } + deps = [Depends(auth.verify_session), Depends(auth.require_system_admin)] + app.include_router(router_module.router, dependencies=deps) + return TestClient(app) + + +def _save(client: TestClient, version: str, change: dict) -> None: + res = client.post( + "/api/m01/save", + json={"files": [{"file": MACHINE, "version": version, "changes": [change]}]}, + ) + assert res.status_code == 200, res.text + + +def _diff(before: str, after: str) -> list[str]: + return [ + x + for x in difflib.unified_diff(before.splitlines(), after.splitlines(), lineterm="", n=0) + if x[:1] in "+-" and not x.startswith(("+++", "---")) + ] + + +def test_새_기계_단위_출처_자동_계수_서버_재계산(copy: Path) -> None: + client = _app() + lists = client.get("/api/m01/machine/lists").json() + assert {"경유", "휘발유"} <= {f["value"] for f in lists["fuels"]} + assert any(j["value"] == "1048" for j in lists["jobs"]) and lists["hour"] == 0.125 + found = client.get("/api/m01/machine/find", params={"q": "굴착기 1.0㎥"}).json()["rows"] + seed = next(r for r in found if r["키"] == "EQ000022") + before = (copy / MACHINE).read_text(encoding="utf-8") + version = client.get("/api/m01/machine/row", params={"key": "EQ000022"}).json()["version"] + values = {**seed["값"], "내용시간": 8000, "손료계수": 1, "상각비계수": 1} # 계수 = 틀린 값 + row = { + "키": "", + "원문번호": "", + **{k: seed[k] for k in ("원문", "구분", "상세구분")}, + "이름": "시험기계sub1", + "규격": "9.9㎥", + "단위": {}, + "값": values, + "출처": {}, + "비고": None, + } + _save(client, version, {"op": "add", "row": row}) + after = (copy / MACHINE).read_text(encoding="utf-8") + rows = json.loads(after)["줄"] + new = rows[-1] + assert new["이름"] == "시험기계sub1" and new["키"].startswith("EQ") + assert new["값"] | mc.coef(8000, 1250, 0.9, 0.7, 0.1) == new["값"] # 서버가 다시 셈 + assert new["값"]["손료계수"] == 1125 + 875 + 496 + assert set(new["단위"]) == set(new["값"]) == set(new["출처"]) + assert new["단위"] == { + k: seed_unit for k, seed_unit in _units(before).items() if k in new["값"] + } + assert set(new["출처"].values()) == {"자체"} + assert json.loads(before)["줄"] == rows[:-1] # 다른 줄 그대로 + assert all(x.startswith("+") for x in _diff(before, after)) # 글은 더하기만 + + +def _units(text: str) -> dict: + return next(r for r in json.loads(text)["줄"] if r["키"] == "EQ000022")["단위"] + + +def _edit(client: TestClient, key: str, change) -> tuple[dict, dict]: + got = client.get("/api/m01/machine/row", params={"key": key}).json() + row = got["row"] + _save(client, got["version"], {"op": "edit", "key": key, "row": change(row)}) + now = client.get("/api/m01/machine/row", params={"key": key}).json()["row"] + return row, now + + +def test_고침_그_줄_그_칸만(copy: Path) -> None: + client = _app() + before = (copy / MACHINE).read_text(encoding="utf-8") + row, now = _edit(client, "EQ000022", lambda r: {**r, "값": {**r["값"], "가격": 150000}}) + assert now == {**row, "값": {**row["값"], "가격": 150000}} + lines = _diff(before, (copy / MACHINE).read_text(encoding="utf-8")) + assert len(lines) == 2 and "142069" in lines[0] and "150000" in lines[1] # 그 칸 한 줄만 + + +def test_입력_같으면_저장_계수_그대로_바뀌면_다시_셈(copy: Path) -> None: + client = _app() + # 가격만 고침 + 화면이 계수를 틀리게 보내도 — 입력이 같으면 원문 표 값(정비비계수 777) 그대로 + row, now = _edit( + client, + "EQ000347", + lambda r: {**r, "값": {**r["값"], "가격": 999, "정비비계수": 778, "손료계수": 1}}, + ) + assert now["값"] == {**row["값"], "가격": 999} + _, now = _edit(client, "EQ000347", lambda r: {**r, "값": {**r["값"], "내용시간": 10000}}) + assert {k: now["값"][k] for k in mc.COEFS} == mc.coef(10000, 800, 0.9, 0.7, 0.1) + + +def test_기계_길은_시스템_관리자만() -> None: + for role in ("USER", "COMPANY_ADMIN"): + client = _app(role) + assert client.get("/api/m01/machine/lists").status_code == 403 + assert client.get("/api/m01/machine/hourly", params={"keys": "EQ000022"}).status_code == 403 + assert client.post("/api/m01/save", json={"files": []}).status_code == 403 + + +def test_쓰는_곳_찾기(copy: Path, monkeypatch) -> None: + from M02_MasterTemplete import M02_Template_Layers as layers + + monkeypatch.setattr(layers, "layer_files", lambda kind: []) # 공유 storage 안 읽음 + uses = _app().get("/api/m01/machine/uses", params={"key": "EQ000022"}).json()["uses"] + assert any("GC000267" in u for u in uses) + assert _app().get("/api/m01/machine/uses", params={"key": "EQ999999"}).status_code == 404 diff --git a/ui_template/ui_template_machine_form.ts b/ui_template/ui_template_machine_form.ts index 494b51714..e6b8558bb 100644 --- a/ui_template/ui_template_machine_form.ts +++ b/ui_template/ui_template_machine_form.ts @@ -497,7 +497,14 @@ export function createMachineForm(opts: MachineFormOptions): MachineFormHandle { state: () => ({ name: name.trim(), spec: spec.trim(), - values: structuredClone(values), + // 칸 차례 = 처음 값 묶음 차례(방식을 바꿨다 되돌려도 같은 줄) · 새 칸은 뒤에 + values: structuredClone( + Object.fromEntries( + [...Object.keys(init), ...Object.keys(values)] + .filter((k, i, all) => all.indexOf(k) === i && k in values) + .map((k) => [k, values[k]]), + ), + ), errors: [...errors.values()], changed: changed(), }),