Merge remote-tracking branches 'origin/CODEX' and 'origin/main_laptop_1' into sub_laptop_1

This commit is contained in:
2026-11-12 14:05:30 +09:00
30 changed files with 2864 additions and 25 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;
}
+1 -2
View File
@@ -40,11 +40,10 @@ def _file(key: str) -> dict[str, Any]:
def _settled(doc: dict, job: Any = None) -> tuple[dict, Any]:
"""옛 셈 길 첫머리(`calc_doc`)와 같게 — (정리한 문서, 사본 색인)."""
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Sangeun_Calc as calc
raw = {**doc, skc.JOB: job} if job else doc
whole = skc.over(doc := skc.settle(raw), m01.loaded()[1])
whole = skc.over(doc := skc.settle(raw), skc.whole_()) # 내 기계(821-1)
try:
return sd.clean(doc, None, whole), whole
except sd.DocError as e:
+5 -1
View File
@@ -23,6 +23,7 @@ from decimal import Decimal
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MyMachine as mym
mf = m01.cm.mf
mk = m01.mk
@@ -239,6 +240,8 @@ def collect(
`extra` = 마스터 밖 로직 줄(굳힌 단가산출 719-1) — 그 줄이 닿는 것만 모음(줄은 안 담음) ·
`over` = M02 로직 사본 줄(795-4) — 같은 키는 M01 줄 대신 · M01 에 없는 키 = `COPY_FILE`."""
files, whole = master()
whole = mym.whole(whole) # 지금 층 내 기계 줄(821-1) — 없으면 M01 그대로
own = {str(r.get("키")): r for r in mym.added(whole)}
mine = {str(r.get("키")): r for r in over or []}
if mine:
from M02_MasterTemplete import M02_Sangeun_Copy as skc
@@ -263,7 +266,8 @@ def collect(
seen.update(str(row.get("키")) for row in rows)
if rows:
out[name] = {**{k: v for k, v in data.items() if k != slot}, slot: rows}
if left := [r for k, r in mine.items() if k in collector.keys and k not in seen]:
left = [r for k, r in {**own, **mine}.items() if k in collector.keys and k not in seen]
if left:
out[COPY_FILE] = {"그룹": "사본", "줄": left}
if collector.missing:
out["_빠진"] = sorted(set(collector.missing))
+11 -10
View File
@@ -1,6 +1,6 @@
"""M02 기계 찾기(PLAN 812-9 · 요구 718 · 719) — 가져온 기계 글(이름 · 규격) → 우리 EQ · 서버 단독.
과녁 = M01 기계표 전체(EQ) · 부르는 곳 = 불러오기 이음(`M02_Hopyo_Row.m01_picks` · `M02_San_Emit._m01`) ·
과녁 = M01 기계표 전체(EQ) + 지금 층 내 기계(821-1 · `M02_MyMachine.whole`) · 부르는 곳 = 불러오기 이음(`M02_Hopyo_Row.m01_picks` · `M02_San_Emit._m01`) ·
일위대가 호표(`M02_Hopyo_Elem.machine(s)` · `M02_Hopyo_Fixed._eqs`).
차례:
1. 별칭(M01 기계 줄 「별칭」 칸 · 815-1 `M02_Elem_Alias`) — 「이름 규격」 통째 별칭 = 그 EQ(여럿 =
@@ -22,8 +22,6 @@ import unicodedata
from functools import lru_cache
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
# 소리만 다른 표기 → 한 글(양쪽에 같이 씀 · 차례대로)
SOUND = (
("로우더", "로더"), ("로울러", "롤러"), ("브레이카", "브레이커"), ("브레카", "브레이커"),
@@ -142,20 +140,23 @@ class Row:
) # 규격 글 낱말(「더블」 · 「엔진식」)
@lru_cache(maxsize=2)
def _table(_whole: int) -> tuple[list[Row], frozenset[str], dict[str, Row]]:
rows = [Row(r) for r in m01.loaded()[1].numbers.get("EQ", {}).values()]
@lru_cache(maxsize=4)
def _table(whole: Any) -> tuple[list[Row], frozenset[str], dict[str, Row]]:
rows = [Row(r) for r in whole.numbers.get("EQ", {}).values()]
words = frozenset(w for r in rows for w in r.words)
return rows, words, {r.key: r for r in rows}
_SEEN: list = [0.0, None] # (때, 표) — M01 판 확인(파일 도장)은 1 초에 한 번
_SEEN: list = [0.0, None, None] # (때, 표, 층) — M01 · 내 기계 판 확인은 층마다 1 초에 한 번
def table() -> tuple[list[Row], frozenset[str], dict[str, Row]]:
now = time.monotonic()
if _SEEN[1] is None or now - _SEEN[0] > 1.0:
_SEEN[:] = [now, _table(id(m01.loaded()[1]))]
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
now, here = time.monotonic(), store.root()
if _SEEN[1] is None or now - _SEEN[0] > 1.0 or _SEEN[2] != here:
_SEEN[:] = [now, _table(mym.whole()), here]
return _SEEN[1]
+9 -4
View File
@@ -5,6 +5,8 @@
(`로직(GC, 기계='원문번호')` · 옵션은 기본값). 짝 차례 · 셈은
`DataFlow/M02_DF_Values._machine_hourly` 와 같은 규칙(그 폴더는 시험 길이라 따로 둠).
마스터 파일은 읽기만 · 로그인 사용자 모두.
기계표 = M01 + 지금 층 내 기계(821-1 · `M02_MyMachine` — 식은 채운 칸으로 고른 같은 GC 식 · 직접 = 그 값) ·
층을 모르는 길(`/api/m02/logic-lib/machine-hourly`)은 M01 만.
"""
from __future__ import annotations
@@ -12,6 +14,7 @@ from __future__ import annotations
from typing import Any
from M01_MasterData import M01_MasterData_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
cm = store.cm
_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용
@@ -43,7 +46,7 @@ def _rank(row: dict) -> tuple:
def _operated(code: str) -> bool:
"""기계표(원문 8-4) 조종원 > 0 — 그 원문번호 기계 가운데 하나라도."""
rows = store.loaded()[1].index.get("EQ", {}).values()
rows = mym.whole().index.get("EQ", {}).values()
return any(
str(r.get("원문번호")) == code and float((r.get("값") or {}).get("조종원") or 0) > 0
for r in rows
@@ -56,7 +59,7 @@ def logic_of(code: str) -> dict | None:
(「조종원 없는 기종」 목록에 겹친 기계 — 진동롤러(핸드가이드식) · 고소작업차 …)."""
fit = [r for r in _family() if code in [str(c) for c in _codes(r)]]
if not fit:
return _own(code)
return _own(code) or mym.logic_for(code)
op = _operated(code)
def order(row: dict) -> tuple:
@@ -70,7 +73,7 @@ def logic_of(code: str) -> dict | None:
def _own(code: str) -> dict | None:
"""그 기계만의 시간당 경비 식(816-1 · 4륜구동 1톤 덤프트럭 = GF000145 · 816-3 체인톱 = GF000337) —
「기계」 입력 없는 기계경비식이 그 원문번호 기계 줄 값(`EQ….가격` · `EQ….손료계수`)을 읽으면 그 식."""
rows = store.loaded()[1].index.get("EQ", {}).values()
rows = mym.whole().index.get("EQ", {}).values()
keys = [f"{r.get('키')}." for r in rows if code and str(r.get("원문번호")) == code]
for data in store.loaded()[0].values():
for row in data.get("줄") or [] if data.get("그룹") == "로직" else []:
@@ -91,9 +94,11 @@ def hourly(key: str) -> dict[str, Any]:
if not key.startswith("EQ"):
return {"ok": False, "reason": f"「{key}」 는 기계 아님"}
try:
row = store.loaded()[1].get(key)
row = (whole := mym.whole()).get(key)
except cm.mf.FormulaError as e:
return {"ok": False, "reason": str(e)}
if mym.mine(key):
return mym.hourly(row, whole)
code = str(row.get("원문번호") or "")
logic = logic_of(code)
if not logic:
+363
View File
@@ -0,0 +1,363 @@
"""M02 내 기계(PLAN 821-1 · 요구 795 · 요구서_796 3 · 7 장) — 개인 층 기계 줄 목록 · 서버 단독.
저장 = 층 뿌리 `내기계.json` {줄: [줄], 다음: 수} — 줄 = M01 기계 줄 꼴
{키 `EQP{사용자}-####`, 원문번호 「개인 {키}」, 원문 · 구분 · 출처 = 개인, 상세구분 · 이름, 규격,
단위, 값, 비고, 방식 바로|품셈|직접, 시간당?(직접 {노무비, 재료비, 경비}), 넣은날, 고친날, 본뜬?}
값 칸 이름 = M01 기계 줄과 같음 · 바로 = 손료계수 한 칸 · 품셈 = 비율 다섯 → 계수 넷(저장 때 서버가
계수 짝 `master_machine_coef` 로 다시 셈) · 직접 = 시간당 값만.
셈 색인 `whole()` = 지금 층(`store.root()`) 줄을 M01 위에 얹은 `skc.Copy`(키 앞 두 글자 EQ ·
원문번호로도 찾음) — 바로 = 상각비계수 ← 손료계수 · 정비 · 관리 0(식은 M01 기계와 같은 GC 식) ·
내 기계 없는 층 = M01 그대로. 단가산출 사본 · [단가 갱신] · 「단가새값」(`M02_Sangeun_Copy`) ·
기계 찾기(`M02_Machine_Find`) · 시간당(`M02_Machine_Hourly`) · 사본 모으기(`M02_Item_Prices.collect`)가
이 색인을 읽음 — 층을 모르는 길(`/api/m02/logic-lib/machine-hourly` 등)은 M01 만.
식 고름(채운 칸) = 연료 · 조종원 둘 다 GC000268 · 연료만 GC000272 · 조종원만 GC000276 · 없음 GC000273.
전력 칸은 담기만(M01 기계경비 식에 전력 줄 없음).
권한(본인 개인 층 · 맡은 회사 관리자)은 길(`M02_MyMachine_Router`) 몫.
"""
from __future__ import annotations
import json
import re
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
FILE = "내기계.json"
PREFIX, NO = "EQP", "개인 "
FIND = re.compile(rf"{NO}({PREFIX}\d+-\d+)") # 문서 안 기계 입력 「개인 EQP…」
EASY, BOOK, DIRECT = "바로", "품셈", "직접"
COSTS = ("노무비", "재료비", "경비")
RATIOS = ("내용시간", "연간표준가동시간", "상각비율", "정비비율", "연간관리비율")
COEFS = ("상각비계수", "정비비계수", "관리비계수")
NUMS = ("가격", *RATIOS, *COEFS, "손료계수", "주연료", "잡재료율", "조종원", "전력")
UNITS = {
"가격": "천원", "내용시간": "시간", "연간표준가동시간": "시간", "상각비율": "", "정비비율": "",
"연간관리비율": "", **{k: "10^-7/시간" for k in (*COEFS, "손료계수")}, "주연료": "ℓ/hr",
"주연료종류": "", "잡재료율": "주연료의 %", "조종원": "인/일", "조종원직종": "", "전력": "kWh/hr",
} # fmt: skip
LOGIC = {(True, True): "GC000268", (True, False): "GC000272",
(False, True): "GC000276", (False, False): "GC000273"} # fmt: skip
def mine(key: Any) -> bool:
return str(key or "").startswith(PREFIX)
def _now() -> str:
return datetime.now().isoformat(timespec="seconds")
def _bad(text: str, status: int = 400) -> store.StoreError:
return store.StoreError(status, text)
# ── 저장 ──────────────────────────────────────────────────────────────
def _path(folder: Path | None = None) -> Path:
return Path(folder or store.root()) / FILE
def _read(folder: Path | None = None) -> dict[str, Any]:
try:
got = json.loads(_path(folder).read_bytes())
except (OSError, ValueError):
got = {}
rows = [r for r in got.get("줄") or [] if isinstance(r, dict) and mine(r.get("키"))]
return {"줄": rows, "다음": int(got.get("다음") or 1)}
def rows(folder: Path | None = None) -> list[dict]:
"""지금 층 내 기계 줄(저장 꼴)."""
return _read(folder)["줄"]
# ── 셈 색인 ──────────────────────────────────────────────────────────
def engine(row: dict) -> dict:
"""셈 꼴 — 바로 = 상각비계수 ← 손료계수 · 정비 · 관리 0 · 빈 연료 · 조종원 칸 = 0 · 수 = Decimal."""
vals = dict(row.get("값") or {})
if row.get("방식") == EASY:
vals |= {"상각비계수": vals.get("손료계수") or 0, "정비비계수": 0, "관리비계수": 0}
if row.get("방식") != DIRECT:
vals = {"주연료": 0, "잡재료율": 0, "조종원": 0, "주연료종류": "", "조종원직종": ""} | vals
raw = json.dumps({**row, "값": vals}, ensure_ascii=False)
return json.loads(raw, parse_float=Decimal, parse_int=Decimal)
_MEMO: dict[str, tuple] = {}
def whole(base: Any = None) -> Any:
"""M01 색인 + 지금 층 내 기계 줄 — 파일 · M01 이 그대로면 같은 객체(셈 메모가 붙음)."""
from M02_MasterTemplete import M02_Sangeun_Copy as skc
base = m01.loaded()[1] if base is None else base
path = _path()
try:
st = path.stat()
except OSError:
return base
sig = (st.st_mtime_ns, st.st_size)
got = _MEMO.get(str(path))
if got and got[0] == sig and got[1] is base:
return got[2]
own = [engine(r) for r in rows(path.parent)]
out = skc.Copy(base, own, ("내기계", sig)) if own else base
if len(_MEMO) > 16: # ponytail: 통째 비움 · 층이 많아지면 LRU 로
_MEMO.clear()
_MEMO[str(path)] = (sig, base, out)
return out
def added(index: Any) -> list[dict]:
"""`whole()` 이 얹은 내 기계 셈 줄(사본 모으기 `collect` 가 마스터 밖 줄로 담음)."""
return [r for k, r in (getattr(index, "index", {}).get("EQ") or {}).items() if mine(k)]
def keys_in(doc: Any) -> list[str]:
"""문서 안 기계 입력 「개인 EQP…」 → 내 기계 키(줄 쌓기 `로직(GC…, 기계='개인 …')` · 사본 찍기)."""
return sorted(set(FIND.findall(json.dumps(doc, ensure_ascii=False, default=str))))
def logic_key(row: dict) -> str:
vals = row.get("값") or {}
return LOGIC[(float(vals.get("주연료") or 0) > 0, float(vals.get("조종원") or 0) > 0)]
def logic_for(code: str) -> dict | None:
"""내 기계 원문번호 → 시간당 경비 식(채운 칸으로 고름) · 내 기계 아님 = None."""
index = whole()
row = (index.numbers.get("EQ") or {}).get(code)
if row is None or not mine(row.get("키")) or row.get("방식") == DIRECT:
return None
return index.logic(logic_key(row))
def hourly(row: dict, index: Any) -> dict[str, Any]:
"""`M02_Machine_Hourly.hourly` 꼴 — 직접 = 그 값 · 아니면 고른 GC 식(입력 기본값 · 기계 = 원문번호)."""
head = {"이름": row.get("이름"), "규격": row.get("규격"), "원문번호": row.get("원문번호")}
if row.get("방식") == DIRECT:
sums = {k: float((row.get("시간당") or {}).get(k) or 0) for k in COSTS}
return {"ok": True, **head, "로직": None, "직접": True, **sums, "계": sum(sums.values())}
import master_cut as mcut
mf = m01.cm.mf
logic = index.logic(logic_key(row))
given = {i["이름"]: (i.get("고르기") or [""])[0] for i in logic.get("입력") or []}
given["기계"] = str(row.get("원문번호"))
try:
got = mcut.engine(
index, logic["키"], mf.run(index, logic["키"], mf.fill_empty(logic, given))
)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return {"ok": False, "reason": str(e) or type(e).__name__}
sums = {k: float(got[k]) for k in (*COSTS, "계")}
return {"ok": True, **head, "로직": logic["키"], **sums}
# ── 검사 ──────────────────────────────────────────────────────────────
def _num(value: Any, name: str) -> float | None:
if value in (None, ""):
return None
try:
got = float(value)
except (TypeError, ValueError):
raise _bad(f"「{name}」 은 수") from None
if got < 0 or got != got:
raise _bad(f"「{name}」 은 0 이상")
return int(got) if got == int(got) else got
def _coef(vals: dict) -> dict:
"""품셈 방식 — 비율 다섯 → 계수 넷(계수 짝 `master_machine_coef` · 821-2)."""
import master_machine_coef as mmc
got = mmc.coef_of(vals)
if got is None:
raise _bad("품셈 방식 — 내용시간 · 연간표준가동시간은 0 보다 큼")
return got
def clean(raw: Any) -> dict[str, Any]:
"""받은 줄 → 저장 꼴 몸(키 · 날 빼고) — 틀리면 400."""
if not isinstance(raw, dict):
raise _bad("내 기계 줄이 묶음 아님")
name, spec = str(raw.get("이름") or "").strip(), str(raw.get("규격") or "").strip()
if not name:
raise _bad("이름 없음")
how = raw.get("방식") or EASY
if how not in (EASY, BOOK, DIRECT):
raise _bad("방식은 바로 · 품셈 · 직접")
given = raw.get("값") if isinstance(raw.get("값"), dict) else {}
out: dict[str, Any] = {"이름": name, "규격": spec, "방식": how}
if how == DIRECT:
hour = raw.get("시간당") if isinstance(raw.get("시간당"), dict) else {}
out["시간당"] = {k: _num(hour.get(k), k) or 0 for k in COSTS}
vals: dict[str, Any] = {}
else:
vals = {k: got for k in NUMS if (got := _num(given.get(k), k)) is not None}
need = ("가격", "손료계수") if how == EASY else ("가격", *RATIOS)
if lost := [k for k in need if not vals.get(k)]:
raise _bad(f"빈 칸 — {' · '.join(lost)}")
drop = (*RATIOS, *COEFS) if how == EASY else ()
vals = {k: v for k, v in vals.items() if k not in drop}
if how == BOOK:
vals |= _coef(vals)
vals |= _kinds(given, vals)
out["값"] = vals
out["단위"] = {k: UNITS[k] for k in vals if k in UNITS}
out["출처"] = {k: "개인" for k in vals}
if note := str(raw.get("비고") or "").strip():
out["비고"] = note
return out
def _kinds(given: dict, vals: dict) -> dict:
"""주연료종류 = M01 유가 이름 · 조종원직종 = M01 인력 원문번호(쓸 때만 · 고르기 목록 밖 = 400)."""
index, out = m01.loaded()[1], {}
fuel, job = str(given.get("주연료종류") or ""), str(given.get("조종원직종") or "")
if vals.get("주연료"):
if f"{fuel}:전국평균" not in (index.numbers.get("MO") or {}):
raise _bad(f"주연료종류 「{fuel}」 — 유가 목록에서 고름")
out["주연료종류"] = fuel
if vals.get("조종원"):
if job not in (index.numbers.get("LB") or {}):
raise _bad(f"조종원직종 「{job}」 — 인력 목록에서 고름")
out["조종원직종"] = job
return out
# ── 만들기 · 고치기 · 지우기 · 읽기 ─────────────────────────────────────
def _seed(key: str) -> dict:
"""「비슷한 기계에서 시작」 — M01 · 내 기계 줄 값 복사(품셈 비율 있으면 품셈 · 없으면 바로)."""
try:
src = whole().get(key)
except m01.cm.mf.FormulaError:
raise _bad(f"없는 기계 「{key}」", 404) from None
if not str(src.get("키")).startswith("EQ"):
raise _bad(f"「{key}」 는 기계 아님")
if mine(key):
got = next(r for r in rows() if r["키"] == key)
return {k: got[k] for k in ("이름", "규격", "방식", "값", "시간당", "비고") if k in got}
vals = json.loads(json.dumps(src.get("값") or {}, default=float))
how = BOOK if all(vals.get(k) for k in RATIOS) else EASY
return {"이름": src.get("이름"), "규격": src.get("규격"), "방식": how, "값": vals}
def _user() -> str:
"""개인 층(`storage/{회사}/{사용자}/templates`) 사용자 번호 — 그 밖 층 = 404."""
from M02_MasterTemplete import M02_Template_Layers as layers
here = store.root().resolve()
user, company = here.parent.name, here.parent.parent.name
if not (user.isdigit() and company.isdigit()) or here != layers.personal_dir(company, user):
raise _bad("내 기계는 개인 층만", 404)
return user
def create(raw: Any = None, base: str | None = None) -> dict[str, Any]:
_user()
seed = _seed(base) if base else {}
raw = raw if isinstance(raw, dict) else {}
body = clean({**seed, **raw, "값": {**seed.get("값", {}), **(raw.get("값") or {})}})
data = _read()
key = f"{PREFIX}{_user()}-{data['다음']:04d}"
when = _now()
row = {"키": key, "원문번호": f"{NO}{key}", "원문": "개인", "구분": "개인", "상세구분": body["이름"],
**body, "넣은날": when, "고친날": when, **({"본뜬": base} if base else {})} # fmt: skip
atomic_write_json(_path(), {"줄": [*data["줄"], row], "다음": data["다음"] + 1})
return answer(row)
def update(key: str, raw: Any) -> dict[str, Any]:
_user()
data = _read()
old = next((r for r in data["줄"] if r["키"] == key), None)
if old is None:
raise _bad(f"없는 내 기계 「{key}」", 404)
body = clean(raw)
keep = {k: old[k] for k in ("키", "원문번호", "원문", "구분", "넣은날", "본뜬") if k in old}
row = {**keep, "상세구분": body["이름"], **body, "고친날": _now()}
atomic_write_json(_path(), {**data, "줄": [row if r["키"] == key else r for r in data["줄"]]})
return answer(row)
def delete(key: str) -> dict[str, Any]:
"""지움 — 이미 만든 단가산출은 사본의 마지막 값 그대로(「지운키」)."""
_user()
data = _read()
if not any(r["키"] == key for r in data["줄"]):
raise _bad(f"없는 내 기계 「{key}」", 404)
used = uses().get(key, [])
atomic_write_json(_path(), {**data, "줄": [r for r in data["줄"] if r["키"] != key]})
return {"지움": key, "쓰는곳": used}
def uses() -> dict[str, list[dict]]:
"""{내 기계 키: [{키, 이름}]} — 이 층 단가산출 가운데 사본 단가 줄에 그 키를 가진 것."""
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
out: dict[str, list[dict]] = {}
for path in sorted((store.root() / sd.KIND).glob("SK-*.json")):
raw = path.read_bytes()
if PREFIX.encode() not in raw:
continue
try:
doc = json.loads(raw)
except ValueError:
continue
for row in (doc.get(skc.KEY) or {}).get("단가") or []:
if mine(row.get("키")):
out.setdefault(str(row["키"]), []).append(
{"키": doc.get("키"), "이름": doc.get("이름")}
)
return out
def same(name: Any, spec: Any, skip: str = "") -> list[dict]:
"""같은 이름 · 규격 알림 — M01 기계 · 이 층 내 기계(표기 정규화 `M02_Machine_Find.norm`)."""
from M02_MasterTemplete.M02_Machine_Find import norm
want = (norm(name), norm(spec))
return [
{
"키": r["키"],
"이름": r.get("이름"),
"규격": r.get("규격"),
"출처": "개인" if mine(r["키"]) else "M01",
} # fmt: skip
for r in (whole().index.get("EQ") or {}).values()
if r["키"] != skip and (norm(r.get("이름")), norm(r.get("규격"))) == want
]
def answer(row: dict, used: list | None = None) -> dict[str, Any]:
out = {
"줄": row,
"시간당": hourly(engine(row), whole()),
"같은이름": same(row["이름"], row["규격"], row["키"]),
}
return out | ({"쓰는곳": used} if used is not None else {})
def one(key: str) -> dict[str, Any]:
got = next((r for r in rows() if r["키"] == key), None)
if got is None:
raise _bad(f"없는 내 기계 「{key}」", 404)
return answer(got, uses().get(key, []))
def listing() -> dict[str, Any]:
"""목록 — 줄마다 시간당 · 쓰는곳수(넣은 차례)."""
index, used = whole(), uses()
return {
"줄": [
{**r, "시간당": hourly(engine(r), index), "쓰는곳수": len(used.get(r["키"], []))}
for r in rows()
]
}
@@ -0,0 +1,73 @@
"""M02 내 기계 길(PLAN 821-1 · `M02_MyMachine`) — `/api/m02/layers/personal/my-machines`(?owner=).
GET 목록(줄마다 시간당 · 쓰는곳수) · GET `/{키}` 하나(+시간당 · 쓰는곳 · 같은이름) ·
POST 만들기 몸 {줄?, 본뜬?: M01 · 내 기계 키(「비슷한 기계에서 시작」 · 「M01 기계 복사해 만들기」)} ·
PUT `/{키}` 몸 {줄} · DELETE `/{키}`(답 = 쓰는곳 — 그 단가산출은 사본의 마지막 값 그대로).
권한 = 층 권한 한 벌(`Router_Items._root` — 본인 개인 층 · 맡은 회사 관리자) · 개인 층만(그 밖 404).
⚠ 등록은 `main.py`(로그인만).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Session, _call, _root
router = APIRouter(prefix="/api/m02", tags=["M02 My Machine"])
PATH = "/layers/{layer}/my-machines"
class MachineBody(BaseModel):
줄: dict[str, Any] | None = None
본뜬: str | None = None
def _folder(session: Session, layer: str, owner: str | None, write: bool = False):
if layer != "personal":
raise HTTPException(status_code=404, detail="내 기계는 개인 층만")
return _root(session, layer, owner, write=write)
@router.get(PATH)
def my_machines(layer: str, owner: str | None = None, session: Session = Depends(verify_session)):
return _call(_folder(session, layer, owner), mym.listing)
@router.get(PATH + "/{key}")
def my_machine(
layer: str, key: str, owner: str | None = None, session: Session = Depends(verify_session)
):
return _call(_folder(session, layer, owner), mym.one, key)
@router.post(PATH)
def my_machine_create(
layer: str,
body: MachineBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
return _call(_folder(session, layer, owner, True), mym.create, body.줄, body.본뜬)
@router.put(PATH + "/{key}")
def my_machine_update(
layer: str,
key: str,
body: MachineBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
return _call(_folder(session, layer, owner, True), mym.update, key, body.줄)
@router.delete(PATH + "/{key}")
def my_machine_delete(
layer: str, key: str, owner: str | None = None, session: Session = Depends(verify_session)
):
return _call(_folder(session, layer, owner, True), mym.delete, key)
+2 -4
View File
@@ -549,10 +549,8 @@ def _check_refs(doc: dict, here_: refs.Here) -> None:
def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]:
"""저장 꼴(`Labor_Ref.clean_doc` 가 부름) — 검사 · 참조 · 순환 · 서버가 `원값` · `바탕판`."""
from M01_MasterData import M01_MasterData_Store as m01
raw, job = doc, doc.get(skc.JOB) if isinstance(doc, dict) else None # 수식 사본(802-4a)
whole = skc.over(doc := skc.settle(doc, True), m01.loaded()[1]) # 사본(795-1)
whole = skc.over(doc := skc.settle(doc, True), skc.whole_()) # 사본(795-1) · 내 기계(821-1)
try:
rough = sd.clean(doc, None, whole)
_check_refs(rough, here_)
@@ -604,7 +602,7 @@ def calc_doc(doc: Any, given: dict | None = None, seen: bool = True) -> dict[str
from M01_MasterData import M01_MasterData_Store as m01
with m01.cache.steady(): # 한 셈 동안 M01 파일 자국 한 번(792-2)
whole = skc.over(doc := skc.settle(doc), m01.loaded()[1]) # 사본(795-1)
whole = skc.over(doc := skc.settle(doc), skc.whole_()) # 사본(795-1) · 내 기계(821-1)
try:
clean = sd.clean(doc, None, whole)
_check_refs(clean, refs.here()) # 저장 전 문서도 위 층만 · 있는 문서 · 순환 422
+6 -1
View File
@@ -30,6 +30,7 @@ from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_Item_Prices as prices
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Imported as imp
@@ -41,7 +42,10 @@ _MASTER = prices.mf.Master
def whole_() -> Any:
return prices.m01.loaded()[1]
"""M01 색인 + 지금 층 내 기계 줄(821-1 · `M02_MyMachine.whole`)."""
from M02_MasterTemplete import M02_MyMachine as mym
return mym.whole()
def _fp(rows: Any) -> str:
@@ -149,6 +153,7 @@ def stamp(doc: dict) -> dict | None:
return None
keys, links = reach(doc.get("더한줄"))
keys = [str(base["로직"]), *keys] if base.get("로직") else keys
links += mym.keys_in(doc) # 줄 쌓기 기계 입력 「개인 EQP…」(821-1)
extra = [row] if (row := imp.row_of(doc)) else []
return snap(keys, links, extra)
+92
View File
@@ -0,0 +1,92 @@
/* =============================================================================
* common_util_machine_coef.ts
* 기계 손료계수 · 시간당 경비 미리보기(PLAN 821-2 · 요구서 796 1 장) — 기계 양식 즉시 셈.
*
* ⚠ 짝: `resources/master_data/scripts/master_machine_coef.py` — 같은 셈 · 같은 차례
* (거울 시험 `resources/tester/test_821_2_machine_coef.py`). 한쪽만 고치면 화면과 저장이 갈림.
* 계수 = 건설품셈 공통 8-1-5(계수마다 정수 반올림 · 손료계수 = 합) · 미리보기 끝수 = 서버
* 기계경비식(줄 0.1 원 아래 버림 · 잡품 = 버린 주연료 × % · 비목 합 원 미만 버림) · 정본은 서버 식.
* ========================================================================== */
export const MACHINE_INPUTS = [
"내용시간",
"연간표준가동시간",
"상각비율",
"정비비율",
"연간관리비율",
] as const;
export const MACHINE_COEFS = ["상각비계수", "정비비계수", "관리비계수", "손료계수"] as const;
export type MachineCoefs = Record<(typeof MACHINE_COEFS)[number], number>;
const E = 1e-6; // 끊는 경계 부동소수 흔들림 막음
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
const halfUp = (x: number): number => Math.floor(x + 0.5 + E);
/** 줄 금액 = 0.1 원 아래 버림(B09 끝수 줄 자리). */
const tenth = (x: number): number => Math.floor(x * 10 + E) / 10;
/** (내용시간 · 연간표준가동시간 · 상각비율 · 정비비율 · 연간관리비율) → 계수 넷 · 못 셈 = null. */
export function machineCoef(
life: unknown,
yearly: unknown,
dep: unknown,
rep: unknown,
mgmt: unknown,
): MachineCoefs | null {
const [t, h, a, m, k] = [life, yearly, dep, rep, mgmt].map(num);
if (t <= 0 || h <= 0) return null;
const n = t / h;
const s = halfUp((a / t) * 1e7);
const j = halfUp((m / t) * 1e7);
const g = halfUp(((k * (1.1 * n + 0.9)) / (2 * n) / h) * 1e7);
return { 상각비계수: s, 정비비계수: j, 관리비계수: g, 손료계수: s + j + g };
}
/** 값 묶음에서 품셈 입력 다섯이 다 수일 때만 계수 넷. */
export function machineCoefOf(values: Record<string, unknown>): MachineCoefs | null {
if (!MACHINE_INPUTS.every((k) => typeof values[k] === "number")) return null;
return machineCoef(
...(MACHINE_INPUTS.map((k) => values[k]) as [number, number, number, number, number]),
);
}
export interface MachineHourly {
손료: number;
주연료: number;
잡품: number;
전력: number;
조종원: number;
노무비: number;
재료비: number;
경비: number;
계: number;
}
/** 값 묶음 + 유가(원/ℓ) · 노임(원/일) · 전력(원/kWh) · 시간환산 → 시간당 줄 · 비목 · 계. */
export function machineHourly(
values: Record<string, unknown>,
fuel: number,
wage: number,
power = 0,
hour = 1 / 8,
): MachineHourly {
const loss = Math.floor((num(values["가격"]) * 1000 * num(values["손료계수"])) / 1e7 + E);
const oil = tenth(num(values["주연료"]) * num(fuel));
const misc = tenth((oil * num(values["잡재료율"])) / 100);
const elec = tenth(num(values["전력"]) * num(power));
const crew = tenth(num(values["조종원"]) * hour * num(wage));
const labor = Math.floor(crew + E);
const material = Math.floor(oil + misc + elec + E);
return {
손료: loss,
주연료: oil,
잡품: misc,
전력: elec,
조종원: crew,
노무비: labor,
재료비: material,
경비: loss,
계: labor + material + loss,
};
}
@@ -222,3 +222,25 @@
- 안 S4(4 번): 820-1 의 13 줄(EQ000656 ~ 668) · GC001195 는 S1 방향이면 지움 · 덤프 1 톤(산림품셈 10-9) · 체인톱 시간당 식(산림품셈 2 장) · 별칭 10 은 둠 · 시점 = S1 기능 넣는 묶음에서 같이(공백 없음).
- S5(5 번): 차례 = 표기 엔진 · 별칭 · 같은 파일 대조로 품셈 요소에 최대한 → 남는 것만 개인 장 → 반복되면 마스터 승격 제안 · 불러오기 결과에 「개인 생성 n」 표시.
- 참고: 현행 품셈 8 장에 조종원 노임 ×16/12 ×25/20 없음(우리 = ÷8) · EST 구성은 옛 환산 포함 → 자동 생성은 우리 틀에 값만 담는 쪽 권장.
## T. 기계 신규 생성·수정 — 쉽게(요구 791 · 조사 + 안 · 착수 대기)
- 사용자 결정(요구 791): S 장 정할 것 1·2·3 동의(모아 보기만 · 13 줄 삭제는 새 기능과 같이 · 자동 생성은 우리 틀에 값만) · 착수 대기.
- 지금(실측 · M01 › 기계): 표 55 열(기본 7 + 단위 15 + 값 15 + 출처 15 + 별칭 · 비고) · 표 폭 약 6,000 px(화면의 4.7 배) · 고정 열 없음 · [줄 추가] = 맨 위 빈 줄(모달 · 복사 · 기본값 없음) · 단위 · 출처 15 칸씩 손으로(출처 비면 저장 422) · 칸 = 글 입력(숫자 검증 없음) · 계수 넷 자동 계산 없음 · 주연료종류 · 조종원직종 자유 글 · 시간당 경비는 기계 화면에서 못 봄(저장 뒤 다른 화면) · 저장하면 새 줄이 파일 끝으로.
- 계수 식 확인(굴착기 1.0㎥): 상각비계수 = 상각비율 ÷ 내용시간 × 10⁷(900) · 정비비계수 = 정비비율 ÷ 내용시간 × 10⁷(700) · 관리비계수 = 연간관리비율 × 평균취득가격 비율(0.9(N+1)/2N + 0.1 · N = 내용시간 ÷ 연간표준가동시간) ÷ 연간표준가동시간 × 10⁷(485) · 손료계수 = 합(2,085).
- 안 T1 만들기: 「비슷한 기계에서 시작」(찾아 고르면 값 복사) 또는 빈 양식 · 묻는 칸 = 이름 · 규격 / 가격 / 손료계수 / 주연료(종류 고르기 + ℓ/hr) / 잡품 % / 조종원(직종 고르기 + 인) (+ 전력) · 단위 · 출처는 자동.
- 안 T2 손료계수 두 방식: 「바로 넣기」(한 칸) · 「품셈 방식」(내용시간 · 연간표준가동시간 · 비율 셋 → 계수 자동) · M02 기본 = 바로 넣기 · M01 기본 = 품셈 방식.
- 안 T3 미리보기: 양식 옆에 시간당 경비 계산식이 넣는 즉시(손료 · 주연료 · 잡품 · 조종원 · 계) — 브라우저 즉시 셈 · 저장은 서버 재계산.
- 안 T4 고치기: 표는 목록용(이름 · 규격 · 가격 · 손료계수 · 연료 · 조종원 · 시간당 경비 · 단위 · 출처 열 숨김) · 줄 누르면 같은 양식의 상세 페이지.
- 안 T5 안전: 숫자 칸 · 범위 경고 · 같은 이름 · 규격 알림 · 쓰는 곳 보이기(지우기 전) · 저장 전 바뀐 것 요약.
- 안 T6 불러오기: 못 이은 기계 = 경쟁사 구성으로 미리 채운 양식(확인만).
- 부품: `ui_template` 입력 · 고르기 · 모달 · `createCalcFormula` · `createKv` 재사용 · 숫자 전용 칸만 보탬 · M01 · M02 같은 양식 부품.
## U. 내 기계 = M01 기계처럼(요구 795 · 코드 확인 · 착수 대기)
- 확인(코드): 단가산출(SK)을 만들 때 사본을 박음 — 로직 · 부르는 로직(기계 시간당 경비식 포함) · 요소 값 줄 전체(LB · MT · EQ · MO · FX — 기계 가격 · 손료계수 · 주연료 · 조종원)를 만든 시점 그대로(`M02_Sangeun_Copy.snap`) · 셈은 사본에서 읽음(`calc_doc` → `Copy`) · M01 이 바뀌어도 그대로 · 「단가새값」 · 「M01새판」 배지만 · [단가 갱신] = 값 줄만 지금 M01 로(사용자 고침 보존) · [재생성] = 로직까지 다시 · 프로젝트(B09)도 사본 그대로 굳음 · 사본 없는 옛 SK 만 M01 현재값.
- 그래서 S1 고침: 내 기계 = 「단가산출 한 장 참조」 가 아니라 **개인 층의 기계 줄**(M01 기계 줄과 같은 꼴 · 개인 키) → 단가산출을 만들 때 M01 기계와 똑같이 사본에 복사 · 그 뒤 내 기계를 고쳐도 이미 만든 단가산출은 그대로 · 「새 값」 배지 → [단가 갱신] 으로 반영 · 지워도 기존 단가산출은 마지막 값 유지.
- 시간당 경비 식 = M01 기계와 같은 식(GC000267 ~ · 조종원 유무 등 채운 칸으로 고름) · 값만 개인 줄.
- S3 고침: 「모아 보기만」 → 개인 층에 **내 기계 줄 목록**(작은 저장 하나)을 둠 · 요소 표 전체를 층마다 두는 것은 여전히 안 함(기계만 · 재료는 줄에 든 품목 모아 보기) · 앞서 든 위험(프로젝트 동봉 · 삭제 깨짐 · 여러 곳 같이 바뀜)은 사본 방식이라 없어짐.
- 고칠 자리(코드 조사): 기계 찾기 · 고르기(`M02_Machine_Find.table` · `M02_Machine_Hourly`) 와 사본 모으기(`M02_Item_Prices._Collector`)가 개인 줄도 읽게 · [단가 갱신](`skc.refresh` · `state`)이 개인 키를 개인 목록에서 찾게(지금은 M01 만 봐 「지워짐」 으로 오판).
- 요구 794 답의 「모두 반영 / 이 단가산출만 따로」 물음은 뺌(M01 과 같은 규칙).
@@ -993,3 +993,27 @@ STmate 의 「굴삭기(1.0m3)은 무한궤도
4. 이전 반영 중 매칭이 안되서 추가한 기계 10여개는 삭제하는게 어떤지.
5. 하지만 특수한 경우를 제외하면 매칭을 높여 개인관리를 최소화해야하는건 맞아.
일단 정밀 검토해보자.
## 요구 791 (11-12 · 사용자 · 1·2·3 동의 · 착수 대기 · 추가 검토 — 기계 신규 생성·수정 기능이 사용자가 쓰기 편하고 쉬워야 함 · 조사·검토)
1,2,3은 동의하고 착수대기. 하나더 검토 현재 기계의 신규 생성 및 수정기능이 사용자가 사용하기 편하고 쉬워야함. 조사하고 검토해줘
## 요구 792 (11-12 · 사용자 · 확인 물음 — M01 기계 · M02 단가산출 내부 기계 · 경쟁사 불러오기 기계가 같게 입력 · 변경 · 생성된다는 뜻인지)
결과적으로는 m01 기계, m02 단가산출 내부의 기계 경쟁사 정보 불러오기의 기계가 하게 입력되고 변경이 가능하며 생성된다는 말이지?
## 요구 793 (11-12 · 사용자 · 확인 물음 — 단가산출 화면은 좌측 조작 · 우측 산출근거 · 로직은 복사하되 값은 M01 값 사용·갱신 · 매칭 안 돼 새로 만든 정보의 일부 계수·가격만 사용자가 갱신 · 인력 등 나머지는 M01 값)
단가산출의 화면은 좌측과 우측의 산출근거 영역이 나눠져있고 보통 조작은 좌측에서 하게됨. 대부분의 정보들은 로직은 복사하되 값은 m01의 값을 사용하거나 업데이트됨. 대신 매칭이 안되어서 새롭게만든정보 중 일부 계수나 가격은 사용자가 업데이트 하는게 맞고 나머지 인력이나 다른 항목은 당연히 m01값을 사용. 맞는거지?
## 요구 794 (11-12 · 사용자 · 물음 — 그럼 기계를 변경할 때는?)
그럼 기계를 변경할때는?
## 요구 795 (11-12 · 사용자 · 내 기계도 M01 기계처럼 동작해야 함 · 먼저 기계를 만들고 단가산출을 만들면 복사 개념 · 생성 시점에 복사하면 연결이 끊김 — 확인)
내 기계도 사실상 m01 의 기계처럼 동작해야함. 먼저 기계를 만들고 단가산출을 만들개되면 복사한 개념으로 접근하고 생성시점에 복사를 하면 연결을 끊게되잖아 확인해봐
## 요구 796 (11-12 · 사용자 · 상세 계획서 작성하고 착수 진행)
상세계획서 작성하고 착수 진행해
@@ -0,0 +1,74 @@
# 요구서 796 — 내 기계 · 기계 양식 (PLAN 821)
근거 = 사용자 요구 790 ~ 796 · 방향 장부 `_방향_불러오기.md` S · T · U 장.
## 0. 원칙
- M01 기계 = 품셈 정본만. 품셈에 없는 기계 = 사용자의 「내 기계」(개인 층).
- 내 기계는 M01 기계처럼 동작 — 먼저 기계를 만들고 · 단가산출을 만들 때 사본에 복사 · 그 뒤 연결 끊김 · [단가 갱신] 으로만 값 반영.
- 사용자 몫 값 = 그 기계 고유 값(가격 · 손료계수 · 연료 소비량 · 잡품율 · 조종원 수). 단가(노임 · 유가 · 전력 · 환율)는 M01 값.
- 매칭 먼저 — 표기 엔진 · 별칭 · 같은 파일 대조로 품셈 기계에 잇고 · 끝내 못 이은 것만 내 기계.
- 기계 만들기 · 고치기는 쉬워야 함 — 같은 양식 하나를 M01 · M02 · 불러오기가 같이 씀.
- 층마다 요소 표 전체를 두지 않음 — 기계만 개인 목록 · 재료는 줄에 든 품목 모아 보기.
## 1. 기계 양식(공통 부품)
- 묻는 칸: 이름 · 규격 / 가격(천원) / 손료계수 / 주연료(종류 고르기 + ℓ/hr) / 잡품 %(주연료의) / 조종원(직종 고르기 + 인) / 전력(kWh · 선택).
- 손료계수 두 방식: 「바로 넣기」(한 칸) · 「품셈 방식」(내용시간 · 연간표준가동시간 · 상각비율 · 정비비율 · 연간관리비율 → 상각비 · 정비비 · 관리비계수 · 손료계수 자동). M01 기본 = 품셈 방식 · 내 기계 기본 = 바로 넣기.
- 꼴은 채운 칸으로 정함(연료 비움 = 손료만 · 조종원 0 = 조종원 없는 식). 구성 없는 기계 = 「시간당 값 직접」(노무비 · 재료비 · 경비).
- 「비슷한 기계에서 시작」: 기계를 찾아 고르면 값 복사(M01 기계 · 내 기계 모두) · 빈 양식도 됨.
- 직종 · 연료 종류는 M01 인력 · 유가 목록에서 고름(자유 글 아님). 숫자 칸은 숫자만 · 범위 벗어나면 경고.
- 옆에 시간당 경비 계산식이 넣는 즉시: 손료 · 주연료 · 잡품 · 조종원 · (전력) · 계 — 브라우저 즉시 셈 · 저장은 서버가 정본으로 다시 셈.
- 단위 · 출처 칸은 프로그램이 채움(M01 = 정본 규칙에 맞는 값 · 내 기계 = 「개인」).
- 같은 이름 · 규격이 있으면 알림 · 지우기 전에 쓰는 곳 보임.
## 2. M01 기계 화면
- 표 = 목록용: 이름 · 규격 · 가격 · 손료계수 · 주연료 · 조종원 · 시간당 경비(전국평균) · 별칭 등 — 단위 · 출처 열 숨김(기계만 · 인력 · 재료 표는 그대로).
- 줄을 누르면 상세 페이지(1 장 양식) — 값 묶음 · 계산식 미리보기 · 별칭 · 쓰는 곳 · [저장] 은 지금 M01 저장 길(시스템 관리자).
- [새 기계] = 양식(비슷한 기계에서 시작 기본) · 빈 줄 추가 방식은 걷음.
## 3. 내 기계(M02 개인 층)
- 저장 = 개인 층에 기계 줄 목록 하나 · 줄 꼴은 M01 기계 줄과 같음 · 키 · 원문번호는 M01 과 안 겹치는 개인 꼴 · 사본 색인에는 기계(EQ)로 얹힘.
- 시간당 경비 식 = M01 기계와 같은 식(조종원 유무 등으로 고름) · 값만 개인 줄.
- 기계 찾기 · 고르기 · 사본 모으기 · [단가 갱신] · 「새 값」 표시가 내 기계도 M01 기계와 같게 동작(단가산출 만들 때 복사 · 고친 뒤 배지 → 갱신 · 지워도 기존 단가산출은 마지막 값).
- 화면: M02 왼쪽 「내 기계」 목록(이름 · 규격 · 시간당 · 쓰는 곳 수 · 넣은 날) → 양식 · 단가산출 좌측 기계 고르기 목록에 「내 기계」 묶음 · 「내 품목」(줄에 든 재료 품목) 모아 보기(읽기).
- 품셈 기계를 「복사해 내 기계로」 가져와 고칠 수 있음(M01 정본은 그대로).
## 4. 불러오기
- 끝내 못 이은 기계 → 내 기계 줄 자동 생성: 그 파일의 구성(EST 원파일 구성표 · 엑셀 기계경비 시트)을 우리 틀 값으로 담음(손료계수 · 가격 · 연료 ℓ · 잡품 % · 조종원 인 · 직종은 우리 직종에 이음) · 구성 없는 파일(STmate 원파일)은 「시간당 값 직접」.
- 같은 이름 · 규격 내 기계가 이미 있으면 그것을 씀(새로 안 만듦).
- 불러오기 결과에 「내 기계 생성 n」 표시 · 사용자는 양식으로 확인 · 고침.
- 금액(엑셀 단가 그대로) 100 % 그대로 · [우리 단가로] 는 내 기계 값으로 셈.
## 5. 정리
- 820-1 의 기계 13 줄(EQ000656 ~ 668) · 파워트로웰 식(GC001195) · 계산식 목록에 끼운 것 · 기술문서 절 = 지움(키는 결번 · 재사용 안 함). 덤프트럭 1 톤(EQ000655) · 체인톱 시간당 식 · 기계 별칭 10 은 둠.
- 다음 장 후보였던 데이터 흐름 파워트로웰 흠은 같이 없어짐.
## 6. 걸음(PLAN 821)
| 걸음 | 창 | 내용 | 먼저 있어야 할 것 |
|---|---|---|---|
| 821-1 | sub3 | 내 기계 저장 · API · 찾기 · 시간당 · 사본 · 단가 갱신이 개인 줄 읽기 | — |
| 821-2 | sub1 | 계수 계산 짝(파이썬 · TS) · 기계 양식 부품 · M01 표 열 줄이기 · 상세 페이지 · 새 기계 | — |
| 821-3 | sub1 | M02 내 기계 목록 · 양식 · 기계 고르기에 내 기계 · 내 품목 모아 보기 | 821-1 · 821-2 |
| 821-4 | sub3 | 불러오기 자동 생성 · 「내 기계 생성 n」 · 13 줄 정리 · 원천 셋 전후 전수 | 821-1 |
## 7. 두 창의 약속
- 내 기계 줄 값 칸 이름 = M01 기계 줄과 같음(가격 · 내용시간 · 연간표준가동시간 · 상각비율 · 정비비율 · 연간관리비율 · 상각비계수 · 정비비계수 · 관리비계수 · 손료계수 · 주연료 · 주연료종류 · 잡재료율 · 조종원 · 조종원직종 · 전력) · 「바로 넣기」 = 손료계수만 · 「시간당 값 직접」 = 따로 한 칸 묶음.
- 계수 계산 짝 = sub1 이 먼저 만들어 push(파이썬 `resources/master_data/scripts/master_machine_coef.py` · TS `common_util/common_util_machine_coef.ts` · 거울 시험 · 품셈 표 값 전수 대조) → sub3 은 저장 때 파이썬 것을 씀.
- 내 기계 API 길 · 키 꼴은 sub3 이 정해 PLAN 821 절 아래 한 줄로 먼저 적음 → sub1 821-3 이 그것을 씀.
- M01 정본(`기계.json`) 줄 내용은 821-4 의 13 줄 지우기 말고는 안 고침.
## 8. 완료 기준(걸음마다 자기 검증)
- 고친 대상 시험 + `npm run typecheck` 0 + ORCA 자기 포트 사용자 길(만들기 · 고치기 · 단가산출에 넣기 · 고친 뒤 배지 → 단가 갱신 · 지우기 · 빈 값 · 같은 이름 · 권한 없음 · 밝음 · 어두움) · 만든 것 되돌림.
- 821-4: 원천 셋(EST · STmate · 엑셀) 전후 표 — 금액 100 % · 기계 이음(품셈 기계 + 내 기계) · 블록 「모두 우리 값」.
## 9. 안 하는 것
- 재료 개인 표 · 회사 층 화면 · 마스터 승격 제안 흐름 · 화면 속도 · 설명서 고침.
+5
View File
@@ -578,6 +578,11 @@ app.include_router(
__import__("M02_MasterTemplete.M02_Sangeun_Router", fromlist=["router"]).router,
dependencies=protected,
)
# 내 기계(PLAN 821-1) — 로그인 · 개인 층 권한은 길 안에서
app.include_router(
__import__("M02_MasterTemplete.M02_MyMachine_Router", fromlist=["router"]).router,
dependencies=protected,
)
# 단가산출(SK) 수식 문서 길(PLAN 802-4a) — 로그인 · 층 권한은 길 안에서
app.include_router(
__import__("M02_MasterTemplete.M02_Doc_Router", fromlist=["router"]).router,
@@ -0,0 +1,78 @@
"""기계 손료계수 · 시간당 경비 미리보기(PLAN 821-2 · 요구서 796 1 장).
⚠ 짝: `common_util/common_util_machine_coef.ts` — 같은 셈 · 같은 차례(거울 시험
`resources/tester/test_821_2_machine_coef.py`). 한쪽만 고치면 화면과 저장이 갈림.
계수(건설품셈 공통 8-1-5 · 시간당 10^-7):
상각비계수 = 상각비율 ÷ 내용시간 · 정비비계수 = 정비비율 ÷ 내용시간 ·
관리비계수 = 연간관리비율 × 평균취득가격 비율 ÷ 연간표준가동시간 ·
평균취득가격 비율 = (1.1 N + 0.9) ÷ 2N · N = 내용시간 ÷ 연간표준가동시간 ·
계수마다 정수로 반올림 · 손료계수 = 셋의 합.
미리보기 = 서버 기계경비식(GC000267 ~)을 기본 옵션(보정 없음)으로 부른 값과 같은 끝수 —
손료 = 가격(천원) × 1000 × 손료계수 ÷ 10^7 원 미만 버림 · 줄 금액은 0.1 원 아래 버림 ·
잡품 = 버린 주연료 금액 × 잡재료율 % · 비목 합은 원 미만 버림(B09 끝수 `master_cut.engine`).
정본은 서버 식(`M02_Machine_Hourly`) — 이것은 화면 즉시 셈용.
"""
from __future__ import annotations
import math
INPUTS = ("내용시간", "연간표준가동시간", "상각비율", "정비비율", "연간관리비율")
COEFS = ("상각비계수", "정비비계수", "관리비계수", "손료계수")
_E = 1e-6 # 끊는 경계 부동소수 흔들림 막음
def _num(v) -> float:
return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else 0.0
def _half_up(x: float) -> int:
return math.floor(x + 0.5 + _E)
def _tenth(x: float) -> float:
"""줄 금액 = 0.1 원 아래 버림(B09 끝수 줄 자리)."""
return math.floor(x * 10 + _E) / 10
def coef(life, yearly, dep, rep, mgmt) -> dict | None:
"""(내용시간 · 연간표준가동시간 · 상각 · 정비 · 연간관리 비율) → 계수 넷 · 못 셈 = None."""
life, yearly, dep, rep, mgmt = (_num(x) for x in (life, yearly, dep, rep, mgmt))
if life <= 0 or yearly <= 0:
return None
n = life / yearly
s = _half_up(dep / life * 1e7)
j = _half_up(rep / life * 1e7)
g = _half_up(mgmt * (1.1 * n + 0.9) / (2 * n) / yearly * 1e7)
return {"상각비계수": s, "정비비계수": j, "관리비계수": g, "손료계수": s + j + g}
def coef_of(values: dict) -> dict | None:
"""값 묶음(기계 줄 `값`)에서 품셈 입력 다섯이 다 수일 때만 계수 넷."""
if not all(isinstance(values.get(k), (int, float)) for k in INPUTS):
return None
return coef(*(values[k] for k in INPUTS))
def preview(
values: dict, fuel: float, wage: float, power: float = 0.0, hour: float = 1 / 8
) -> dict:
"""값 묶음 + 유가(원/ℓ) · 노임(원/일) · 전력(원/kWh) · 시간환산 → 시간당 줄 · 비목 · 계."""
loss = math.floor(_num(values.get("가격")) * 1000 * _num(values.get("손료계수")) / 1e7 + _E)
oil = _tenth(_num(values.get("주연료")) * _num(fuel))
misc = _tenth(oil * _num(values.get("잡재료율")) / 100)
elec = _tenth(_num(values.get("전력")) * _num(power))
crew = _tenth(_num(values.get("조종원")) * hour * _num(wage))
labor, material = math.floor(crew + _E), math.floor(oil + misc + elec + _E)
return {
"손료": loss,
"주연료": oil,
"잡품": misc,
"전력": elec,
"조종원": crew,
"노무비": labor,
"재료비": material,
"경비": loss,
"계": labor + material + loss,
}
+178
View File
@@ -0,0 +1,178 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""821-1 내 기계(개인 층 기계 줄) — M01 기계처럼: 만들 때 사본에 복사 · 고쳐도 금액 그대로 + 「단가새값」 ·
[단가 갱신] 으로 반영 · 지워도 마지막 값(임시 storage · M01 정본 안 고침).
① 세 꼴(바로 · 품셈 · 직접) 저장 · 키 · 원문번호 M01 과 안 겹침 ② 시간당 = M01 같은 값 기계와 같음
③ 찾기 · 남의 층 안 보임 ④ SK 사본에 박힘 · 고친 뒤 그대로 + 새값 · 갱신 뒤 반영 · 지운 뒤 마지막 값 ·
층 사이 복사 ⑤ M01 만 쓰는 SK 금액 그대로 ⑥ 권한.
"""
from __future__ import annotations
import json
import pytest
from fastapi import HTTPException
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Machine_Find as mfind
from M02_MasterTemplete import M02_Machine_Hourly as mh
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete import M02_MyMachine_Router as route
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ — 연료 · 조종원 있음
PCT = "GC001109" # M01 만 쓰는 SK 바탕
NAME = "서브셋임시기계" # 찾기 기종 글(수 = 규격으로 읽음 · 창 표시는 글로)
def _at(folder, fn, *args):
with store.at(folder):
return fn(*args)
def _easy(**vals):
base = {"가격": 1000, "손료계수": 2000, "주연료": 2, "주연료종류": "경유", "잡재료율": 10,
"조종원": 1, "조종원직종": "1048"} # fmt: skip
return {"이름": NAME, "규격": "1㎾", "방식": "바로", "값": base | vals}
def _sk(folder, machine_no):
line = {"종류": "로직", "로직": "GC000268", "입력값": {"기계": machine_no, "지역": "전국평균"},
"수량": "1"} # fmt: skip
body = {"이름": "sub3임시 내기계", "단위": "시간", "더한줄": [line]}
return _at(folder, lambda: refs.create("sangeun", body)["이름"])
def _doc(folder, key):
return _at(folder, lambda: store.read("sangeun", key)["문서"])
def _calc(folder, doc):
return _at(folder, calc.calc_doc, doc, None, False)
def _sum(folder, key):
return _calc(folder, _doc(folder, key))["기준"]["계"]
def test_three_shapes_and_keys(world):
"""① 바로 · 품셈 · 직접 — 키 EQP{사용자}-#### · 원문번호 「개인 …」 · M01 과 안 겹침 · 날."""
me = world["personal"]
easy = _at(me, mym.create, _easy())["줄"]
assert easy["키"] == "EQP42-0001" and easy["원문번호"] == "개인 EQP42-0001"
assert set(easy["값"]) >= {"가격", "손료계수"} and "상각비계수" not in easy["값"]
assert easy["넣은날"] == easy["고친날"] and easy["출처"]["가격"] == "개인"
book = _at(me, mym.create, {"방식": "품셈"}, DIG)["줄"] # M01 기계 복사해 만들기(비율 → 계수)
m01_row = m01.loaded()[1].get(DIG)["값"]
for k in ("상각비계수", "정비비계수", "관리비계수", "손료계수"):
assert float(book["값"][k]) == float(m01_row[k]), k
direct = _at(me, mym.create, {"이름": NAME, "규격": "1㎾", "방식": "직접",
"시간당": {"노무비": 10, "재료비": 20, "경비": 30}}) # fmt: skip
assert direct["시간당"]["계"] == 60 and direct["시간당"]["직접"] is True
m01_eq = m01.loaded()[1]
for row in _at(me, mym.rows):
assert row["키"] not in m01_eq.index["EQ"] and row["원문번호"] not in m01_eq.numbers["EQ"]
assert [r["키"] for r in _at(me, mym.rows)] == ["EQP42-0001", "EQP42-0002", "EQP42-0003"]
assert {x["키"] for x in direct["같은이름"]} == {"EQP42-0001"} # 같은 이름 · 규격 알림
for bad in ({"이름": ""}, {**_easy(), "값": {"가격": 1}}, {**_easy(가격=-1)},
{**_easy(주연료종류="물")}, {**_easy(조종원직종="없음")}): # fmt: skip
with pytest.raises(store.StoreError):
_at(me, mym.create, bad)
def test_hourly_same_as_m01(world):
"""② 굴착기 1.0㎥ 값을 내 기계로(바로 · 품셈) → 시간당 = M01 기계 · 식은 채운 칸으로."""
me = world["personal"]
want = mh.hourly(DIG)
for how in ("바로", "품셈"):
got = _at(me, mym.create, {"방식": how}, DIG)
hour = _at(me, mh.hourly, got["줄"]["키"])
assert hour["ok"] and hour["로직"] == "GC000268", how
for k in ("노무비", "재료비", "경비", "계"):
assert hour[k] == want[k], (how, k)
loss = _at(me, mym.create, _easy(주연료=0, 조종원=0))["시간당"]
assert loss["로직"] == "GC000273" and loss["계"] == loss["경비"] == 200 # 1000×1000×2000/10⁷
assert _at(me, mym.create, _easy(조종원=0))["시간당"]["로직"] == "GC000272"
assert _at(me, mym.create, _easy(주연료=0))["시간당"]["로직"] == "GC000276"
def test_find_and_other_layer_hidden(world):
"""③ 기계 찾기 = 그 층 내 기계도 · 남의 층 · 층 모르는 길 = M01 만."""
me, other = world["personal"], layers.personal_dir(7, 43)
key = _at(me, mym.create, _easy())["줄"]["키"]
got = _at(me, mfind.find, NAME, "1㎾")
assert got["판정"] == mfind.LINKED and got["키"] == key
assert _at(other, mfind.find, NAME, "1㎾")["판정"] == mfind.NONE
assert _at(other, mh.hourly, key)["ok"] is False
assert mh.hourly(key)["ok"] is False # 층 모르는 길
assert key not in _at(other, skc.whole_).index["EQ"]
def test_sk_copy_edit_refresh_delete(world):
"""④ 만들 때 사본에 박힘 → 고쳐도 그대로 + 새값 → [단가 갱신] 반영 → 지워도 마지막 값 · 층 복사."""
me = world["personal"]
made = _at(me, mym.create, _easy())
key, no = made["줄"]["키"], made["줄"]["원문번호"]
sk = _sk(me, no)
copy = _doc(me, sk)["사본"]
assert key in {r["키"] for r in copy["단가"]}
was = _sum(me, sk)
assert float(was) == made["시간당"]["계"]
assert _at(me, mym.one, key)["쓰는곳"] == [{"키": sk, "이름": "sub3임시 내기계"}]
_at(me, mym.update, key, _easy(가격=3000)) # 고침
now = _calc(me, _doc(me, sk))
assert now["기준"]["계"] == was and now["사본"]["단가새값"] is True
assert now["사본"]["지운키"] == []
new = _at(me, mh.hourly, key)["계"]
look = _at(me, skc.preview, _doc(me, sk), skc.PRICE)
assert float(look["전"]["계"]) == float(was) and float(look["후"]["계"]) == new
assert [x["키"] for x in look["바뀐자원"]] == [key]
with store.at(me): # [저장] = 할일 적용
store.write("sangeun", sk, store.read("sangeun", sk)["판"], look["문서"])
assert float(_sum(me, sk)) == new
assert _calc(me, _doc(me, sk))["사본"]["단가새값"] is False
# 층 사이 복사 — 사본이 따라감(받은 층에 그 내 기계 없음 · 마지막 값)
other = layers.personal_dir(7, 43)
moved = _at(other, skc.settle, {**_doc(me, sk), "키": "SK-999999"})
assert float(_calc(other, moved)["기준"]["계"]) == new
gone = _at(me, mym.delete, key)
assert gone["쓰는곳"] == [{"키": sk, "이름": "sub3임시 내기계"}]
after = _calc(me, _doc(me, sk))
assert float(after["기준"]["계"]) == new and after["사본"]["지운키"] == [key]
look = _at(me, skc.preview, _doc(me, sk), skc.PRICE)
assert look["바뀐자원"] == [{"키": key, "이름": NAME, "지워짐": True}]
assert float(look["후"]["계"]) == new
def test_m01_only_sk_same(world):
"""⑤ 내 기계가 있는 층에서도 M01 만 쓰는 SK 금액 · 사본 같음 · 사본 모으기에 내 기계 안 낌."""
me, other = world["personal"], layers.personal_dir(7, 43)
base = {"이름": "sub3임시 M01", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []}
plain = _at(other, lambda: refs.create("sangeun", base)["이름"])
_at(me, mym.create, _easy())
mine = _at(me, lambda: refs.create("sangeun", base)["이름"])
a, b = _doc(other, plain)["사본"], _doc(me, mine)["사본"]
assert json.dumps(a["단가"], sort_keys=True) == json.dumps(b["단가"], sort_keys=True)
assert a["판"] == b["판"] and _sum(other, plain) == _sum(me, mine)
def test_rights(world):
"""⑥ 남의 개인 층 = 403(일반) · 회사 관리자 = 됨 · 개인 층 밖 = 404."""
user = {"role": "USER", "company_id": 7, "user_id": 42}
boss = {"role": "ADMIN", "company_id": 7, "user_id": 1}
with pytest.raises(HTTPException) as e:
route._folder(user, "personal", "7/43", True)
assert e.value.status_code == 403
with pytest.raises(HTTPException) as e:
route._folder(user, "company", None, True)
assert e.value.status_code == 404
assert route._folder(user, "personal", None, True) == world["personal"]
assert route._folder(boss, "personal", "7/42") == world["personal"] # 맡은 회사 관리자 읽기
with pytest.raises(store.StoreError): # 개인 층 아닌 자리에서 만들기
_at(world["company"], mym.create, _easy())
+133
View File
@@ -0,0 +1,133 @@
"""PLAN 821-2 기계 계수 계산 짝 — 지킴 시험.
① 계수 전수 대조: M01 기계표에서 품셈 입력 다섯 · 계수 넷이 다 있는 줄 = 파이썬 계수 셈과 같음
(다른 줄 = 원문 표 값 그대로인 점보드릴 2 줄만 · 원문 777 · 셈 777.8).
② 파이썬 · TS 거울: 같은 입력 → 같은 계수 · 같은 시간당 미리보기.
③ 미리보기 = 서버 기계경비식(`M02_Machine_Hourly.hourly`) 견본 스무 대 · 비목 셋 같음.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "resources" / "master_data" / "scripts"))
import master_machine_coef as mc # noqa: E402
DATA = ROOT / "resources" / "master_data"
TS = ROOT / "common_util" / "common_util_machine_coef.ts"
# 원문 표 값 ≠ 식(정비비계수 0.7 ÷ 9,000 × 10^7 = 777.8 → 원문 777 · 계 2,515) — 식을 비틀지 않음
ORIGINAL_ODD = {"EQ000347", "EQ000348"}
SAMPLES = (
"EQ000001 EQ000006 EQ000022 EQ000026 EQ000040 EQ000051 EQ000073 EQ000076 EQ000126 EQ000151 "
"EQ000176 EQ000201 EQ000226 EQ000229 EQ000246 EQ000326 EQ000401 EQ000434 EQ000455 EQ000544"
).split()
def _load(name: str) -> list[dict]:
return json.loads((DATA / name).read_text(encoding="utf-8"))["줄"]
MACHINES = _load("기계.json")
FUEL = {r["원문번호"]: r["값"] for r in _load("재료_유가전력.json")}
WAGE = {r["원문번호"]: r["값"] for r in _load("인력.json")}
def _full(row: dict) -> bool:
v = row["값"]
return all(isinstance(v.get(k), (int, float)) for k in (*mc.INPUTS, *mc.COEFS))
def _prices(v: dict) -> tuple[float, float]:
"""전국평균 유가 · 노임(직종 비면 기계경비식 조종원직종 첫 고르기 1048)."""
return FUEL.get(f"{v.get('주연료종류')}:전국평균", 0), WAGE.get(
str(v.get("조종원직종") or "1048"), 0
)
def test_coef_matches_master_table():
rows = [r for r in MACHINES if _full(r)]
assert len(rows) >= 590
odd = {r["키"] for r in rows if mc.coef_of(r["값"]) != {k: r["값"][k] for k in mc.COEFS}}
assert odd == ORIGINAL_ODD
def test_coef_excavator_example():
got = mc.coef(10000, 1250, 0.9, 0.7, 0.1)
assert got == {"상각비계수": 900, "정비비계수": 700, "관리비계수": 485, "손료계수": 2085}
assert mc.coef(0, 1250, 0.9, 0.7, 0.1) is None
assert mc.coef_of({"내용시간": 10000}) is None
@pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
def test_ts_mirror(tmp_path):
coef_cases = [[r["값"][k] for k in mc.INPUTS] for r in MACHINES if _full(r)]
coef_cases += [[10000, 1250, 0.9, 0.7, 0.1], [0, 1, 1, 1, 1], [3600, 540, 0.9, 1.15, 0.1]]
hour_cases = []
for r in MACHINES:
fuel, wage = _prices(r["값"])
hour_cases.append([r["값"], fuel, wage, 114.4, 0.125])
hour_cases.append(
[
{"가격": 2680, "손료계수": 5313, "주연료": 1.0, "잡재료율": 10, "전력": 3.73},
2000,
0,
114.4,
0.125,
]
)
src = tmp_path / "in.json"
src.write_text(
json.dumps({"coef": coef_cases, "hour": hour_cases}, ensure_ascii=False), encoding="utf-8"
)
script = f"""
const fs = require("fs");
const ts = require({json.dumps(str(ROOT / "config" / "node_modules" / "typescript"))});
const opt = {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }};
const src = fs.readFileSync({json.dumps(str(TS))}, "utf8");
const js = ts.transpileModule(src, {{ compilerOptions: opt }}).outputText;
const m = {{ exports: {{}} }};
new Function("exports", "module", js)(m.exports, m);
const inp = JSON.parse(fs.readFileSync({json.dumps(str(src))}, "utf8"));
process.stdout.write(JSON.stringify({{
coef: inp.coef.map((c) => m.exports.machineCoef(...c)),
hour: inp.hour.map((h) => m.exports.machineHourly(...h)),
}}));
"""
out = subprocess.run(
["node", "-e", script], capture_output=True, text=True, encoding="utf-8", check=True
)
got = json.loads(out.stdout)
want_coef = [mc.coef(*c) for c in coef_cases]
want_hour = [mc.preview(*h) for h in hour_cases]
assert got["coef"] == want_coef
assert len(got["hour"]) == len(want_hour)
for a, b in zip(got["hour"], want_hour):
assert a == pytest.approx(b, abs=1e-9)
def test_preview_equals_server_hourly():
from M02_MasterTemplete import M02_Machine_Hourly as mh
rows = {r["키"]: r for r in MACHINES}
for key in SAMPLES:
h = mh.hourly(key)
assert h["ok"], (key, h)
v = rows[key]["값"]
p = mc.preview(v, *_prices(v))
assert (p["노무비"], p["재료비"], p["경비"], p["계"]) == (
h["노무비"],
h["재료비"],
h["경비"],
h["계"],
), (key, h["로직"])
+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
+2
View File
@@ -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,
+72
View File
@@ -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;
+112
View File
@@ -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;
}
}
+513
View File
@@ -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,
};
}
+19
View File
@@ -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) },