feat(821-2): M01 기계 화면 — 목록 열 줄이기 · 상세 페이지 · 새 기계(비슷한 기계에서 시작) · 저장 때 계수 서버 재계산

- 목록 = 이름 · 규격 · 구분 · 가격 · 손료계수 · 주연료 · 조종원 · 시간당 경비(서버 값) · 별칭 · 비고 (단위 · 출처 열 없음 · 가로 넘침 없음) · 다른 요소 표 그대로
- 상세 = 기계 양식 + 원문번호 · 원문 · 구분 · 상세구분 · 별칭 · 비고 · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인) · 주소 ?machine= 로 바로 열림 · 뒤로 가기 = 목록 · 저장 안 한 고침 나가기 경고
- 고침 = 초안 → 페이지 [저장](자동저장 없음) · 서버 M01_MasterData_Machine.fix = 품셈 입력이 바뀌면 계수 짝으로 다시 셈 · 단위 · 출처 칸 채움(새 칸 = 마스터 단위 · 「자체」)
- API /api/m01/machine/lists · hourly · uses · find · row (시스템 관리자만) · 시험 test_821_2_machine_save.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 14:02:04 +09:00
co-authored by Claude Opus 5.5
parent 6e0d86c961
commit 086f6d214a
10 changed files with 1078 additions and 4 deletions
+222
View File
@@ -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}
@@ -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<string, string>;
}
export interface MachineHourly {
노무비?: number;
재료비?: number;
경비?: number;
계?: number;
reason?: string;
}
async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
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<MachineLists> | null = null;
/** 한 번 받아 둠(페이지 다시 그리면 다시 받음) */
export const fetchMachineLists = (fresh = false): Promise<MachineLists> => {
if (!lists || fresh)
lists = get<MachineLists>("/lists").catch((e) => ((lists = null), Promise.reject(e)));
return lists;
};
export const fetchMachineHourly = (keys: string[]): Promise<Record<string, MachineHourly>> =>
keys.length ? get("/hourly", { keys: keys.join(",") }) : Promise.resolve({});
export const fetchMachineUses = async (key: string): Promise<string[]> =>
(await get<{ uses: string[] }>("/uses", { key })).uses;
export const findMachines = async (q: string): Promise<Row[]> =>
(await get<{ rows: Row[] }>("/find", { q })).rows;
export const fetchMachineRow = (key: string): Promise<{ version: string; row: Row }> =>
get("/row", { key });
+29
View File
@@ -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.고름):
@@ -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:
@@ -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;
}
+177
View File
@@ -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<string, unknown> => (r["값"] ?? {}) as Record<string, unknown>;
/** 돌려받은 함수 = 걷기. `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<string, string>();
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<void> => {
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<HTMLElement>("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<void> => {
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<HTMLElement>(".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();
};
}
@@ -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<string, Row>();
const findSimilar = async (q: string): Promise<MachineSimilar[]> => {
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<boolean> =>
!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<HTMLDetailsElement>("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["상세구분"] });
+55 -3
View File
@@ -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<void> {
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<Pick>;
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;
}
+156
View File
@@ -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
+8 -1
View File
@@ -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(),
}),