Merge branch 'sub_laptop_1' of C:/Aislo_wt/sub_laptop_1 into main_laptop_1
This commit is contained in:
@@ -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 });
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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["상세구분"] });
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -32,6 +32,7 @@ import { ui_locales_b3 } from "./ui_template_locale_b3";
|
||||
import { ui_locales_e04 } from "./ui_template_locale_e04";
|
||||
import { ui_locales_b5 } from "./ui_template_locale_b5";
|
||||
import { ui_locales_m1 } from "./ui_template_locale_m1";
|
||||
import { ui_locales_machine } from "./ui_template_locale_machine";
|
||||
import { ui_locales_m2 } from "./ui_template_locale_m2";
|
||||
import { ui_locales_m2_judge } from "./ui_template_locale_m2_judge";
|
||||
import { ui_locales_de } from "./ui_template_locale_de";
|
||||
@@ -77,6 +78,7 @@ export const ui_locales = {
|
||||
...ui_locales_e04,
|
||||
...ui_locales_b5,
|
||||
...ui_locales_m1,
|
||||
...ui_locales_machine,
|
||||
...ui_locales_m2,
|
||||
...ui_locales_m2_judge,
|
||||
...ui_locales_de,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/* =============================================================================
|
||||
* ui_template_locale_machine.ts
|
||||
* 기계 양식 부품(`ui_template_machine_form.ts`) · M01 기계 목록 · 상세 사전(PLAN 821-2) — 신규 문구는 최하단에.
|
||||
* ========================================================================== */
|
||||
|
||||
export const ui_locales_machine = {
|
||||
MF_Name: ["이름", "Name"],
|
||||
MF_Spec: ["규격", "Spec"],
|
||||
MF_Price: ["가격", "Price"],
|
||||
MF_Coef: ["손료계수", "Ownership coef."],
|
||||
MF_CoefDirect: ["바로 넣기", "Enter directly"],
|
||||
MF_CoefPumsem: ["품셈 방식", "By standard"],
|
||||
MF_Life: ["내용시간", "Service life"],
|
||||
MF_Yearly: ["연간표준가동시간", "Annual hours"],
|
||||
MF_Dep: ["상각비율", "Depreciation ratio"],
|
||||
MF_Rep: ["정비비율", "Maintenance ratio"],
|
||||
MF_Mgmt: ["연간관리비율", "Annual mgmt ratio"],
|
||||
MF_DepCoef: ["상각비계수", "Depreciation coef."],
|
||||
MF_RepCoef: ["정비비계수", "Maintenance coef."],
|
||||
MF_MgmtCoef: ["관리비계수", "Mgmt coef."],
|
||||
MF_Fuel: ["주연료", "Main fuel"],
|
||||
MF_FuelKind: ["연료 종류", "Fuel"],
|
||||
MF_Misc: ["잡품", "Sundries"],
|
||||
MF_Crew: ["조종원", "Operator"],
|
||||
MF_Job: ["직종", "Trade"],
|
||||
MF_Power: ["전력", "Power"],
|
||||
MF_None: ["없음", "None"],
|
||||
MF_Build: ["구성", "Components"],
|
||||
MF_HourlyDirect: ["시간당 값 직접", "Hourly values"],
|
||||
MF_Labor: ["노무비", "Labor"],
|
||||
MF_Material: ["재료비", "Material"],
|
||||
MF_Expense: ["경비", "Expense"],
|
||||
MF_Total: ["계", "Total"],
|
||||
MF_Loss: ["손료", "Ownership"],
|
||||
MF_NumberOnly: ["숫자만", "Numbers only"],
|
||||
MF_Range: ["보통 범위 {value} 밖", "Outside usual range {value}"],
|
||||
MF_Preview: ["시간당 경비 미리보기", "Hourly cost preview"],
|
||||
MF_PreviewNote: [
|
||||
"전국평균 단가 · 넣는 즉시 셈 · 저장 때 서버가 다시 셈",
|
||||
"National average · instant · server recalculates on save",
|
||||
],
|
||||
MF_NoPrice: ["단가 없음", "no price"],
|
||||
MF_Similar: ["비슷한 기계에서 시작", "Start from a similar machine"],
|
||||
MF_SimilarFind: ["기계 이름 · 규격으로 찾기", "Find by name or spec"],
|
||||
MF_SimilarNone: ["찾은 기계 없음", "No machines found"],
|
||||
MF_SimilarCopied: ["{value} 값을 복사함", "Copied values from {value}"],
|
||||
MF_Changed: ["고친 칸", "edited"],
|
||||
M01_MachineNew: ["새 기계", "New machine"],
|
||||
M01_MachineBack: ["목록으로", "Back to list"],
|
||||
M01_MachineHourly: ["시간당 경비", "Hourly cost"],
|
||||
M01_MachineMeta: ["원문 · 구분", "Source · group"],
|
||||
M01_MachineUses: ["쓰는 곳", "Used by"],
|
||||
M01_MachineNoUses: ["쓰는 곳 없음", "Not used"],
|
||||
M01_MachineSame: [
|
||||
"같은 이름 · 규격 기계가 있음 — {value}",
|
||||
"A machine with the same name and spec exists — {value}",
|
||||
],
|
||||
M01_MachineDelete: ["지우기", "Delete"],
|
||||
M01_MachineDeleteAsk: [
|
||||
"이 기계를 지울까? 쓰는 곳 {value} — [저장] 해야 지워짐",
|
||||
"Delete this machine? Used by {value} — applied on [Save]",
|
||||
],
|
||||
M01_MachineLeave: [
|
||||
"저장 안 한 고침이 있음. 나갈까? (고침은 초안에 남음)",
|
||||
"Unsaved edits. Leave? (edits stay in the draft)",
|
||||
],
|
||||
M01_MachineNotFound: ["그 기계가 없음", "Machine not found"],
|
||||
M01_MachineReadOnly: ["읽기만 — 시스템 관리자만 고침", "Read only — system admins edit"],
|
||||
M01_MachineErrors: ["고칠 칸이 있음", "Fix the marked fields"],
|
||||
M01_MachineDetailGroup: ["상세구분", "Sub-group"],
|
||||
M01_MachineSourceNo: ["원문번호", "Source no."],
|
||||
} as const;
|
||||
@@ -0,0 +1,112 @@
|
||||
/* 기계 양식(821-2) — 왼쪽 칸 · 오른쪽 시간당 계산식 · 좁은 폭(720 아래)은 한 줄로 */
|
||||
.ui-machine-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 1fr);
|
||||
gap: var(--spacing-16);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.ui-machine-form__fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-machine-form__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
|
||||
.ui-machine-form__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-machine-form__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.ui-machine-form__coefs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-machine-form__coefs .ui-input {
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ui-machine-form .is-changed .ui-input,
|
||||
.ui-machine-form .is-changed .ui-select {
|
||||
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.ui-machine-form__warn {
|
||||
min-height: 0;
|
||||
color: var(--color-warning);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.ui-machine-form__warn:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-machine-form__notice:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-machine-form__similar {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
|
||||
.ui-machine-form__similar > summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.ui-machine-form__similar .ui-find__list {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ui-machine-form__preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.ui-machine-form__preview .ui-calc-row--formula {
|
||||
grid-template-columns: 64px minmax(0, 1fr) 96px;
|
||||
}
|
||||
|
||||
.ui-machine-form__rule {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: var(--spacing-4) 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ui-machine-form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ui-machine-form__preview {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
/* =============================================================================
|
||||
* ui_template_machine_form.ts
|
||||
* 부품 「기계 양식」(PLAN 821-2 · 요구서 796 1 장) — M01 기계 상세 · M02 내 기계 · 불러오기가 같이 씀.
|
||||
*
|
||||
* 칸 = 이름 · 규격 / 가격(천원) / 손료계수(바로 넣기 · 품셈 방식) / 주연료(종류 고르기 + ℓ/hr) /
|
||||
* 잡품 %(주연료의) / 조종원(직종 고르기 + 인) / 전력(kWh · 선택) · 「시간당 값 직접」 꼴(allowHourly)
|
||||
* 옆 = 시간당 경비 계산식 즉시(`common_util_machine_coef` · 정본은 서버 식)
|
||||
* 찾기 = 「비슷한 기계에서 시작」(findSimilar 를 주면) — 고르면 값 · 이름 · 규격 복사
|
||||
* 상태 = 숫자만(아니면 오류) · 범위 밖 경고 · 고친 칸 노랑 · 같은 이름 알림 자리(setNotice) · readOnly
|
||||
* 자료 = 목록(연료 · 직종 · 단가) · 찾기 · 저장은 부르는 쪽이 줌 — 이 부품은 M01 · M02 에 안 묶임
|
||||
* 값 묶음 = 기계 줄 `값` 꼴 그대로(칸 이름 같음) · 안 보이는 칸(선원 등)은 손대지 않음
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
MACHINE_COEFS,
|
||||
MACHINE_INPUTS,
|
||||
machineCoefOf,
|
||||
machineHourly,
|
||||
} from "../common_util/common_util_machine_coef";
|
||||
import { createCalcFormula, createCalcFormulaRow } from "./ui_template_calc_row";
|
||||
import { el, createInputField, createSelectField } from "./ui_template_elements";
|
||||
import { createFindList } from "./ui_template_find_list";
|
||||
import { t as L } from "./ui_template_locale";
|
||||
import { createSegment } from "./ui_template_segment";
|
||||
import { createText } from "./ui_template_text";
|
||||
import "./ui_template_machine_form.css";
|
||||
|
||||
export type MachineValues = Record<string, unknown>;
|
||||
|
||||
/** 고르기 목록 한 칸 — 연료(값 = 종류 · 값 단가 원/ℓ) · 직종(값 = 코드 · 단가 원/일) */
|
||||
export interface MachinePick {
|
||||
value: string;
|
||||
text: string;
|
||||
price: number | null;
|
||||
}
|
||||
|
||||
export interface MachineSimilar {
|
||||
key: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
/** 흐린 꼬리표(구분 · 내 기계 등) */
|
||||
tag?: string;
|
||||
values: MachineValues;
|
||||
}
|
||||
|
||||
export interface MachineFormOptions {
|
||||
name?: string;
|
||||
spec?: string;
|
||||
values?: MachineValues;
|
||||
/** 손료계수 방식 기본 — 값에 품셈 입력 다섯이 있으면 늘 품셈 방식 */
|
||||
coefMode?: "direct" | "pumsem";
|
||||
/** 「시간당 값 직접」 꼴 허용(내 기계) */
|
||||
allowHourly?: boolean;
|
||||
fuels: MachinePick[];
|
||||
jobs: MachinePick[];
|
||||
/** 전력 단가(원/kWh) · 시간환산(조종원 하루 → 한 시간) */
|
||||
power?: number;
|
||||
hour?: number;
|
||||
readOnly?: boolean;
|
||||
findSimilar?: (q: string) => Promise<MachineSimilar[]>;
|
||||
onSimilar?: (item: MachineSimilar) => void;
|
||||
onChange?: () => void;
|
||||
}
|
||||
|
||||
export interface MachineFormState {
|
||||
name: string;
|
||||
spec: string;
|
||||
values: MachineValues;
|
||||
/** 숫자 아닌 칸 등 — 있으면 저장 막음 */
|
||||
errors: string[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface MachineFormHandle {
|
||||
root: HTMLElement;
|
||||
state: () => MachineFormState;
|
||||
/** 같은 이름 · 규격 알림 등(빈 글 = 지움) */
|
||||
setNotice: (text: string) => void;
|
||||
}
|
||||
|
||||
/** 시간당 값 직접 칸 · 비목 */
|
||||
export const HOURLY_KEY = "시간당";
|
||||
const HOURLY_COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
const HOURLY_LABEL = { 노무비: "MF_Labor", 재료비: "MF_Material", 경비: "MF_Expense" } as const;
|
||||
|
||||
interface NumSpec {
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
const n = (key: string, label: string, unit: string, min: number, max: number): NumSpec => ({
|
||||
key,
|
||||
label,
|
||||
unit,
|
||||
min,
|
||||
max,
|
||||
});
|
||||
const PRICE = n("가격", "MF_Price", "천원", 0, 5_000_000);
|
||||
const COEF = n("손료계수", "MF_Coef", "10⁻⁷/hr", 1, 100_000);
|
||||
const PUMSEM = [
|
||||
n("내용시간", "MF_Life", "시간", 100, 100_000),
|
||||
n("연간표준가동시간", "MF_Yearly", "시간", 100, 8_760),
|
||||
n("상각비율", "MF_Dep", "", 0, 1),
|
||||
n("정비비율", "MF_Rep", "", 0, 3),
|
||||
n("연간관리비율", "MF_Mgmt", "", 0, 1),
|
||||
];
|
||||
const FUEL = n("주연료", "MF_Fuel", "ℓ/hr", 0, 2_000);
|
||||
const MISC = n("잡재료율", "MF_Misc", "%", 0, 100);
|
||||
const CREW = n("조종원", "MF_Crew", "인", 0, 20);
|
||||
const POWER = n("전력", "MF_Power", "kWh", 0, 10_000);
|
||||
const COEF_LABEL: Record<string, string> = {
|
||||
상각비계수: "MF_DepCoef",
|
||||
정비비계수: "MF_RepCoef",
|
||||
관리비계수: "MF_MgmtCoef",
|
||||
손료계수: "MF_Coef",
|
||||
};
|
||||
|
||||
const tr = (key: string): string => L(key as "MF_Name");
|
||||
const fmt = (v: number): string => v.toLocaleString("ko-KR", { maximumFractionDigits: 4 });
|
||||
const shown = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
|
||||
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
/** 글 → 수 · 빈 글 = null · 수 아님 = NaN */
|
||||
function parseNum(text: string): number | null {
|
||||
const s = text.replace(/,/g, "").trim();
|
||||
if (!s) return null;
|
||||
return /^-?\d+(\.\d+)?$/.test(s) ? Number(s) : Number.NaN;
|
||||
}
|
||||
|
||||
export function createMachineForm(opts: MachineFormOptions): MachineFormHandle {
|
||||
const init: MachineValues = structuredClone(opts.values ?? {});
|
||||
let values: MachineValues = structuredClone(init);
|
||||
let name = opts.name ?? "";
|
||||
let spec = opts.spec ?? "";
|
||||
const startName = name;
|
||||
const startSpec = spec;
|
||||
const hasPumsem = MACHINE_INPUTS.some((k) => typeof init[k] === "number");
|
||||
let mode: "direct" | "pumsem" = hasPumsem ? "pumsem" : (opts.coefMode ?? "direct");
|
||||
let hourlyMode = init[HOURLY_KEY] !== undefined && Boolean(opts.allowHourly);
|
||||
const errors = new Map<string, string>();
|
||||
|
||||
const root = el("div", { className: "ui-machine-form" });
|
||||
const fields = el("div", { className: "ui-machine-form__fields" });
|
||||
const side = el("aside", { className: "ui-machine-form__preview" });
|
||||
const notice = el("div", { className: "ui-machine-form__notice" });
|
||||
|
||||
/** 칸 값을 값 묶음에 — 빈 값은 원래 칸이 있었으면 null · 없었으면 칸을 뺌 */
|
||||
const put = (key: string, value: unknown): void => {
|
||||
if (value === null || value === "") {
|
||||
if (key in init) values[key] = null;
|
||||
else delete values[key];
|
||||
} else values[key] = value;
|
||||
};
|
||||
|
||||
const changed = (): boolean => name !== startName || spec !== startSpec || !same(values, init);
|
||||
|
||||
/** 품셈 입력이 처음과 같으면 저장된 계수 그대로(원문 표 값) · 다르면 계수 짝으로 셈 */
|
||||
const syncCoefs = (): void => {
|
||||
if (mode !== "pumsem") return;
|
||||
const sameInputs = MACHINE_INPUTS.every((k) => same(values[k], init[k]));
|
||||
const got = sameInputs ? null : machineCoefOf(values);
|
||||
for (const k of MACHINE_COEFS) {
|
||||
if (sameInputs) put(k, init[k] ?? null);
|
||||
else put(k, got ? got[k] : null);
|
||||
}
|
||||
};
|
||||
|
||||
const fire = (): void => {
|
||||
syncCoefs();
|
||||
paintPreview();
|
||||
paintCoefs();
|
||||
opts.onChange?.();
|
||||
};
|
||||
|
||||
/** 숫자 칸 — 숫자만 · 범위 밖 경고 · 고친 칸 노랑 */
|
||||
const numField = (spec_: NumSpec, read = false, hold?: MachineValues): HTMLElement => {
|
||||
const bag = hold ?? values;
|
||||
const field = createInputField({
|
||||
label: tr(spec_.label),
|
||||
value: shown(bag[spec_.key]),
|
||||
unit: spec_.unit || undefined,
|
||||
size: 28,
|
||||
});
|
||||
field.input.inputMode = "decimal";
|
||||
field.input.dataset.key = spec_.key;
|
||||
const warn = el("span", { className: "ui-machine-form__warn" });
|
||||
field.root.append(warn);
|
||||
const mark = (): void => {
|
||||
const now = hold ? hold[spec_.key] : values[spec_.key];
|
||||
const was = hold
|
||||
? (init[HOURLY_KEY] as MachineValues | undefined)?.[spec_.key]
|
||||
: init[spec_.key];
|
||||
field.root.classList.toggle("is-changed", !same(now ?? null, was ?? null));
|
||||
};
|
||||
if (read || opts.readOnly) field.input.readOnly = true;
|
||||
field.input.addEventListener("input", () => {
|
||||
const v = parseNum(field.input.value);
|
||||
warn.textContent = "";
|
||||
if (Number.isNaN(v)) {
|
||||
errors.set(spec_.key, `${tr(spec_.label)} · ${L("MF_NumberOnly")}`);
|
||||
field.setError(L("MF_NumberOnly"));
|
||||
} else {
|
||||
errors.delete(spec_.key);
|
||||
field.setError();
|
||||
if (v !== null && (v < spec_.min || v > spec_.max))
|
||||
warn.textContent = L("MF_Range").replace(
|
||||
"{value}",
|
||||
`${fmt(spec_.min)}~${fmt(spec_.max)}`,
|
||||
);
|
||||
if (hold) {
|
||||
if (v === null) delete hold[spec_.key];
|
||||
else hold[spec_.key] = v;
|
||||
values[HOURLY_KEY] = { ...hold };
|
||||
} else put(spec_.key, v);
|
||||
}
|
||||
mark();
|
||||
fire();
|
||||
});
|
||||
mark();
|
||||
return field.root;
|
||||
};
|
||||
|
||||
/** 고르기 칸 — 없음 + 목록 · 목록에 없는 지금 값도 한 칸(단가 없음) */
|
||||
const pickField = (key: string, label: string, list: MachinePick[]): HTMLElement => {
|
||||
const now = shown(values[key]);
|
||||
const options = [
|
||||
{ value: "", text: L("MF_None") },
|
||||
...list.map((p) => ({ value: p.value, text: p.text })),
|
||||
];
|
||||
if (now && !list.some((p) => p.value === now))
|
||||
options.push({ value: now, text: `${now} (${L("MF_NoPrice")})` });
|
||||
const field = createSelectField({ label: tr(label), options, value: now, size: 28 });
|
||||
field.select.dataset.key = key;
|
||||
field.select.disabled = Boolean(opts.readOnly);
|
||||
const mark = (): void => {
|
||||
field.root.classList.toggle("is-changed", !same(values[key] ?? null, init[key] ?? null));
|
||||
};
|
||||
field.select.addEventListener("change", () => {
|
||||
put(key, field.select.value || null);
|
||||
mark();
|
||||
fire();
|
||||
});
|
||||
mark();
|
||||
return field.root;
|
||||
};
|
||||
|
||||
const textField = (
|
||||
label: string,
|
||||
get: () => string,
|
||||
set: (v: string) => void,
|
||||
was: string,
|
||||
): HTMLElement => {
|
||||
const field = createInputField({ label: tr(label), value: get(), size: 28 });
|
||||
field.input.readOnly = Boolean(opts.readOnly);
|
||||
field.input.addEventListener("input", () => {
|
||||
set(field.input.value);
|
||||
field.root.classList.toggle("is-changed", field.input.value !== was);
|
||||
opts.onChange?.();
|
||||
});
|
||||
return field.root;
|
||||
};
|
||||
|
||||
/* --- 손료계수 꼴 --- */
|
||||
const coefBox = el("div", { className: "ui-machine-form__group" });
|
||||
const coefOut = el("div", { className: "ui-machine-form__coefs" });
|
||||
const paintCoefs = (): void => {
|
||||
if (mode !== "pumsem") return;
|
||||
coefOut.replaceChildren(
|
||||
...MACHINE_COEFS.map((k) => {
|
||||
const f = createInputField({ label: tr(COEF_LABEL[k]), value: shown(values[k]), size: 28 });
|
||||
f.input.readOnly = true;
|
||||
f.input.tabIndex = -1;
|
||||
f.root.classList.toggle("is-changed", !same(values[k] ?? null, init[k] ?? null));
|
||||
return f.root;
|
||||
}),
|
||||
);
|
||||
};
|
||||
const paintCoefBox = (): void => {
|
||||
const pumsem = mode === "pumsem";
|
||||
coefBox.replaceChildren(
|
||||
el("div", {
|
||||
className: "ui-machine-form__row",
|
||||
children: pumsem ? PUMSEM.map((s) => numField(s)) : [numField(COEF)],
|
||||
}),
|
||||
...(pumsem ? [coefOut] : []),
|
||||
);
|
||||
paintCoefs();
|
||||
};
|
||||
const coefSeg = createSegment({
|
||||
options: [
|
||||
{ value: "direct", label: L("MF_CoefDirect") },
|
||||
{ value: "pumsem", label: L("MF_CoefPumsem") },
|
||||
],
|
||||
value: mode,
|
||||
size: 24,
|
||||
ariaLabel: L("MF_Coef"),
|
||||
onChange: (v) => {
|
||||
mode = v as "direct" | "pumsem";
|
||||
if (mode === "direct") {
|
||||
for (const k of [...MACHINE_INPUTS, ...MACHINE_COEFS.slice(0, 3)]) delete values[k];
|
||||
if (values["손료계수"] === undefined && init["손료계수"] !== undefined)
|
||||
values["손료계수"] = init["손료계수"];
|
||||
} else for (const k of MACHINE_INPUTS) if (init[k] !== undefined) values[k] = init[k];
|
||||
paintCoefBox();
|
||||
fire();
|
||||
},
|
||||
});
|
||||
if (opts.readOnly) coefSeg.root.querySelectorAll("button").forEach((b) => (b.disabled = true));
|
||||
|
||||
/* --- 구성 / 시간당 값 직접 --- */
|
||||
const body = el("div", { className: "ui-machine-form__body" });
|
||||
const paintBody = (): void => {
|
||||
if (hourlyMode) {
|
||||
const hold = { ...((values[HOURLY_KEY] as MachineValues | undefined) ?? {}) };
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "ui-machine-form__row",
|
||||
children: HOURLY_COSTS.map((k) =>
|
||||
numField(n(k, HOURLY_LABEL[k], "원/hr", 0, 10_000_000), false, hold),
|
||||
),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
paintCoefBox();
|
||||
body.replaceChildren(
|
||||
numField(PRICE),
|
||||
el("div", {
|
||||
className: "ui-machine-form__group",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "ui-machine-form__head",
|
||||
children: [el("strong", { text: L("MF_Coef") }), coefSeg.root],
|
||||
}),
|
||||
coefBox,
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ui-machine-form__row",
|
||||
children: [
|
||||
pickField("주연료종류", "MF_FuelKind", opts.fuels),
|
||||
numField(FUEL),
|
||||
numField(MISC),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ui-machine-form__row",
|
||||
children: [pickField("조종원직종", "MF_Job", opts.jobs), numField(CREW), numField(POWER)],
|
||||
}),
|
||||
);
|
||||
};
|
||||
const kindSeg = opts.allowHourly
|
||||
? createSegment({
|
||||
options: [
|
||||
{ value: "build", label: L("MF_Build") },
|
||||
{ value: "hourly", label: L("MF_HourlyDirect") },
|
||||
],
|
||||
value: hourlyMode ? "hourly" : "build",
|
||||
size: 24,
|
||||
onChange: (v) => {
|
||||
hourlyMode = v === "hourly";
|
||||
if (hourlyMode) values[HOURLY_KEY] ??= {};
|
||||
else if (init[HOURLY_KEY] === undefined) delete values[HOURLY_KEY];
|
||||
paintBody();
|
||||
fire();
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
/* --- 비슷한 기계에서 시작 --- */
|
||||
const similarBox = (): HTMLElement[] => {
|
||||
if (!opts.findSimilar || opts.readOnly) return [];
|
||||
const find = createFindList({ placeholder: L("MF_SimilarFind") });
|
||||
const wrap = el("details", {
|
||||
className: "ui-machine-form__similar",
|
||||
children: [el("summary", { text: L("MF_Similar") }), find.root],
|
||||
});
|
||||
let seq = 0;
|
||||
let timer: number | undefined;
|
||||
const look = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
const got = await opts.findSimilar!(find.search.value.trim()).catch(() => []);
|
||||
if (mine !== seq) return;
|
||||
find.hint.textContent = got.length ? "" : L("MF_SimilarNone");
|
||||
find.setRows(
|
||||
got.map((m) => ({
|
||||
key: m.key,
|
||||
name: `${m.name} ${m.spec}`.trim(),
|
||||
tag: m.tag,
|
||||
sub: m.key,
|
||||
onPick: () => {
|
||||
values = { ...structuredClone(m.values) };
|
||||
name = m.name;
|
||||
spec = m.spec;
|
||||
mode = MACHINE_INPUTS.some((k) => typeof values[k] === "number") ? "pumsem" : mode;
|
||||
coefSeg.set(mode);
|
||||
hourlyMode = values[HOURLY_KEY] !== undefined && Boolean(opts.allowHourly);
|
||||
kindSeg?.set(hourlyMode ? "hourly" : "build");
|
||||
wrap.open = false;
|
||||
paintAll();
|
||||
setNotice(L("MF_SimilarCopied").replace("{value}", `${m.name} ${m.spec}`.trim()));
|
||||
opts.onSimilar?.(m);
|
||||
fire();
|
||||
},
|
||||
})),
|
||||
);
|
||||
};
|
||||
find.search.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void look(), 250);
|
||||
});
|
||||
wrap.addEventListener("toggle", () => wrap.open && void look());
|
||||
return [wrap];
|
||||
};
|
||||
|
||||
/* --- 미리보기 --- */
|
||||
const paintPreview = (): void => {
|
||||
const out = (v: number, key = false): HTMLElement =>
|
||||
el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: fmt(v) });
|
||||
const line = (label: string, text: string, v: number, key = false): HTMLElement =>
|
||||
createCalcFormulaRow(label, text ? createCalcFormula(text) : null, out(v, key));
|
||||
const head = [
|
||||
el("strong", { text: L("MF_Preview") }),
|
||||
createText(L("MF_PreviewNote"), "muted"),
|
||||
];
|
||||
if (hourlyMode) {
|
||||
const h = (values[HOURLY_KEY] as Record<string, number> | undefined) ?? {};
|
||||
const [a, b, c] = HOURLY_COSTS.map((k) => Number(h[k]) || 0);
|
||||
side.replaceChildren(
|
||||
...head,
|
||||
line(L("MF_Labor"), "", a),
|
||||
line(L("MF_Material"), "", b),
|
||||
line(L("MF_Expense"), "", c),
|
||||
line(L("MF_Total"), "", a + b + c, true),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const fuel = opts.fuels.find((f) => f.value === values["주연료종류"])?.price ?? 0;
|
||||
const wage = opts.jobs.find((j) => j.value === String(values["조종원직종"] ?? ""))?.price ?? 0;
|
||||
const hour = opts.hour ?? 1 / 8;
|
||||
const p = machineHourly(values, fuel, wage, opts.power ?? 0, hour);
|
||||
const num = (k: string): string => fmt(Number(values[k]) || 0);
|
||||
const rows = [
|
||||
line(L("MF_Loss"), `${num("가격")}천원 × 1,000 × ${num("손료계수")} × 10^-7^`, p.손료),
|
||||
line(L("MF_Fuel"), `${num("주연료")}ℓ × ${fmt(fuel)}원`, p.주연료),
|
||||
line(L("MF_Misc"), `${fmt(p.주연료)} × ${num("잡재료율")}%`, p.잡품),
|
||||
line(L("MF_Crew"), `${num("조종원")}인 × ${fmt(wage)}원 × ${fmt(hour)}`, p.조종원),
|
||||
...(values["전력"]
|
||||
? [line(L("MF_Power"), `${num("전력")}kWh × ${fmt(opts.power ?? 0)}원`, p.전력)]
|
||||
: []),
|
||||
];
|
||||
side.replaceChildren(
|
||||
...head,
|
||||
...rows,
|
||||
el("hr", { className: "ui-machine-form__rule" }),
|
||||
line(L("MF_Labor"), "", p.노무비),
|
||||
line(L("MF_Material"), "", p.재료비),
|
||||
line(L("MF_Expense"), "", p.경비),
|
||||
line(L("MF_Total"), "", p.계, true),
|
||||
);
|
||||
};
|
||||
|
||||
const setNotice = (text: string): void => {
|
||||
notice.replaceChildren(...(text ? [createText(text, "warning")] : []));
|
||||
};
|
||||
|
||||
const top = el("div", { className: "ui-machine-form__row" });
|
||||
const paintAll = (): void => {
|
||||
errors.clear();
|
||||
syncCoefs();
|
||||
top.replaceChildren(
|
||||
textField(
|
||||
"MF_Name",
|
||||
() => name,
|
||||
(v) => (name = v),
|
||||
startName,
|
||||
),
|
||||
textField(
|
||||
"MF_Spec",
|
||||
() => spec,
|
||||
(v) => (spec = v),
|
||||
startSpec,
|
||||
),
|
||||
);
|
||||
paintBody();
|
||||
paintPreview();
|
||||
};
|
||||
fields.append(...similarBox(), notice, top, ...(kindSeg ? [kindSeg.root] : []), body);
|
||||
root.append(fields, side);
|
||||
root.classList.toggle("is-readonly", Boolean(opts.readOnly));
|
||||
paintAll();
|
||||
|
||||
return {
|
||||
root,
|
||||
state: () => ({
|
||||
name: name.trim(),
|
||||
spec: spec.trim(),
|
||||
// 칸 차례 = 처음 값 묶음 차례(방식을 바꿨다 되돌려도 같은 줄) · 새 칸은 뒤에
|
||||
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(),
|
||||
}),
|
||||
setNotice,
|
||||
};
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import { createRangeBar } from "./ui_template_range_bar";
|
||||
import { createStat, statGrid } from "./ui_template_stat";
|
||||
import { toastNode } from "./ui_template_toast";
|
||||
import { FRAME_DEMOS } from "./ui_template_ui_kit_frames";
|
||||
import { createMachineForm } from "./ui_template_machine_form";
|
||||
|
||||
/** 상태 줄 다섯 칸 */
|
||||
export const KIT_STATES = ["default", "hover", "active", "disabled", "error"] as const;
|
||||
@@ -321,6 +322,22 @@ function tabsDemo(s: KitState): HTMLElement | null {
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 기계 양식 견본 — 굴착기 1.0㎥ 값(821-2) */
|
||||
const kitMachine = (readOnly: boolean): HTMLElement =>
|
||||
createMachineForm({
|
||||
name: "굴착기(무한궤도)",
|
||||
spec: "1.0㎥",
|
||||
values: {
|
||||
...{ 가격: 142069, 내용시간: 10000, 연간표준가동시간: 1250, 상각비율: 0.9, 정비비율: 0.7 },
|
||||
...{ 연간관리비율: 0.1, 상각비계수: 900, 정비비계수: 700, 관리비계수: 485, 손료계수: 2085 },
|
||||
...{ 주연료: 19.5, 주연료종류: "경유", 잡재료율: 22, 조종원: 1, 조종원직종: "1048" },
|
||||
},
|
||||
fuels: [{ value: "경유", text: "경유", price: 1846.39 }],
|
||||
jobs: [{ value: "1048", text: "건설기계운전사", price: 283297 }],
|
||||
readOnly,
|
||||
findSimilar: async () => [],
|
||||
}).root;
|
||||
|
||||
// prettier-ignore
|
||||
export const KIT_PARTS: KitPart[] = [
|
||||
// ── 552-2 기본 ─────────────────────────────────────────────
|
||||
@@ -386,6 +403,8 @@ export const KIT_PARTS: KitPart[] = [
|
||||
demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) },
|
||||
{ key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 시트 줄(이름 · 식 · 값 · 꼬리) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts",
|
||||
demo: (s) => (s === "default" ? kitCalc() : null) },
|
||||
{ key: "machine-form", name: "기계 양식", group: "compound", kinds: "이름 · 규격 · 가격 · 손료계수(바로 넣기 · 품셈 방식) · 주연료 · 잡품 · 조종원 · 전력 · 시간당 값 직접 · 옆 시간당 계산식 · 비슷한 기계에서 시작 · 고친 칸 · 숫자만", file: "ui_template_machine_form.ts",
|
||||
demo: (s) => (s === "default" || s === "disabled" ? kitMachine(s === "disabled") : null) },
|
||||
{ key: "equation", name: "수식", group: "compound", kinds: "분수(가로 줄 안 세로 쌓기) · 위 · 아래첨자 · × ÷ · 괄호 · % · 조건 칩 · 하위 로직 블록(접힘) · 한 줄 = 결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이", file: "ui_template_equation.ts",
|
||||
demo: (s) => (s === "default" ? kitEquation() : null) },
|
||||
{ key: "hopyo-grid", name: "일위대가 격자", group: "compound", kinds: "호표 머리(이름 · 규격 · 단위 · 1 단위 합계) · 구성 줄(품명 · 규격 · 수량 · 단위 · 비목 단가 / 금액 · 비고) · 줄 꼬리표 품셈 · 고정 · 합계 줄 빈 줄 붉게", file: "ui_template_hopyo_grid.ts", demo: (s) => (s === "default" ? createHopyoGrid([{ key: "D", no: 17, name: "돌기슭막이 H=2.0", unit: "m", total: { sum: 121748.6, labor: 94447.5, material: 0, expense: 27301.1 }, lines: [{ name: "돌쌓기 찰쌓기", spec: "뒷길이 35cm", qty: 2.09, unit: "㎡", prices: { sum: 52995, labor: 41250, expense: 11745 }, amounts: { sum: 110759.6, labor: 86212.5, expense: 24547.1 }, note: "산근 1호표", tag: "품셈" }, { name: "야면석 채집 운반", spec: "계곡 내", qty: 0.45, unit: "㎥", prices: { sum: 24420, labor: 18300, expense: 6120 }, amounts: { sum: 10989, labor: 8235, expense: 2754 }, tag: "고정" }, { name: "잡재료", spec: "노무비의", qty: null, unit: "%", prices: {}, amounts: {}, note: "수량 없음", tag: "품셈" }] }]).root : null) },
|
||||
|
||||
Reference in New Issue
Block a user