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

This commit is contained in:
2026-11-12 18:06:24 +09:00
68 changed files with 5210 additions and 1010 deletions
+45 -4
View File
@@ -5,6 +5,9 @@
* [재생성](지금 M01 문서로 다시 찍음 · 버리는 것 알림) — 누를 때만(자동 갱신 없음)
* 흐름 = 미리 셈(`{길}/preview` {할일}) → 확인 창(계 전 → 후 · 바뀐 자원 · 버리는 것) → 저장(`{길}/save` {할일, 판})
* 층 쓰기 권한 없으면 「읽기만」 배지
* (821-3) 값 줄만 바뀜(M01 단가 · 내 기계 고침) = 「새 값 있음」(같은 자리 · 같은 꼴) · 지운 키(M01 · 내 기계) = 「지워짐 n」 ·
* [단가 갱신] 확인 창 = 바뀐 자원 줄(이름 · 바뀐 칸 전 → 후 · 지워짐 = 마지막 값 유지)
* (822-1) 값 사본에 없는 셈 줄(옛 저장본 빈틈) = 「사본 없는 줄 n」(지금 값으로 셈 · 저장하면 굳음)
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
@@ -21,6 +24,8 @@ export interface CopyMark {
M01새판?: boolean;
지운키?: string[];
단가새값?: boolean;
/** 값 사본에 없는 셈 줄 수(822-1) — 지금 값으로 셈 · 저장하면 굳음 */
사본없음?: number;
문서새판?: string[];
따로?: boolean;
/** 가져온 식(엑셀 불러오기 · 807-1) 단가기준 — 엑셀 = 엑셀 값 기억 · 우리 = 연결 값 */
@@ -50,9 +55,22 @@ export function isStale(m: CopyMark | undefined): boolean {
export function copyBadges(m: CopyMark | undefined, edit: boolean): HTMLElement[] {
const out: HTMLElement[] = [];
if (isStale(m)) {
const b = createBadge(ft("New_M01"), "danger");
b.dataset.copyStale = "";
const doc = !!m?.M01새판 || !!m?.문서새판?.length;
if (doc || m?.단가새값) {
const b = createBadge(ft(doc ? "New_M01" : "New_Value"), "danger");
b.dataset.copyStale = doc ? "doc" : "value";
out.push(b);
}
if (m?.지운키?.length) {
const b = createBadge(`${ft("Gone")} ${m.지운키.length}`, "warning");
b.title = `${m.지운키.join(" · ")} — ${ft("Gone_Note")}`;
b.dataset.copyGone = m.지운키.join(",");
out.push(b);
}
if (m?.사본없음) {
const b = createBadge(`${ft("Gap")} ${m.사본없음}`, "warning");
b.title = ft("Gap_Note");
b.dataset.copyGap = String(m.사본없음);
out.push(b);
}
if (!edit) out.push(createBadge(ft("Read_Only"), "neutral"));
@@ -76,17 +94,40 @@ export function copyTools(edit: boolean, run: (job: CopyJob) => void): HTMLEleme
const sum = (m: Money | undefined): string =>
typeof m?.계 === "number" ? `${eqText(m.계)} 원` : "—";
/** 바뀐 자원 한 줄 — 「이름 칸 전 → 후」(값 묶음에서 다른 칸만) · 지워짐 = 마지막 값 유지 */
export function changedLine(x: unknown): string {
const r = (x ?? {}) as {
키?: string;
이름?: string;
전?: unknown;
후?: unknown;
지워짐?: boolean;
};
const name = r.이름 || r.키 || "";
if (r.지워짐) return `${name} ${ft("Gone")}(${ft("Gone_Note")})`;
const show = (v: unknown): string => (typeof v === "number" ? eqText(v) : String(v ?? "—"));
const a = r.전 && typeof r.전 === "object" ? (r.전 as Record<string, unknown>) : null;
const b = r.후 && typeof r.후 === "object" ? (r.후 as Record<string, unknown>) : null;
if (!a || !b) return `${name} ${show(r.전)} → ${show(r.후)}`;
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].filter(
(k) => JSON.stringify(a[k]) !== JSON.stringify(b[k]),
);
return `${name} ${keys.map((k) => `${k} ${show(a[k])} → ${show(b[k])}`).join(", ")}`;
}
/** 할일 알림 — 계 전 → 후 · 바뀐 자원 수 · 재생성이 버리는 것(이름) → 확인이면 true */
export function askJob(job: CopyJob, got: JobReply, names: Map<string, string>): Promise<boolean> {
const bits = [
job === "단가갱신" ? ft("Price_Ask") : ft("Regen_Ask"),
`${ft("Before_After")} ${sum(got.전)} → ${sum(got.후)}`,
];
const lines = job === "단가갱신" ? (got.바뀐자원 ?? []).slice(0, 8).map(changedLine) : []; // 821-3
if (job === "단가갱신") bits.push(`${ft("Changed")} ${got.바뀐자원?.length ?? 0}`);
const lost = [
...(got.버림?.문서고침 ? [ft("Regen_Edit")] : []),
...(got.버림?.끊음 ?? []).map((k) => `${ft("Cut")} ${names.get(k) || k}`),
];
if (job === "재생성" && lost.length) bits.push(`${ft("Regen_Lost")} — ${lost.join(" · ")}`);
return showConfirmDialog(bits.join(" · "), job === "단가갱신" ? ft("Price") : ft("Regen"));
const text = [bits.join(" · "), ...lines].join("\n");
return showConfirmDialog(text, job === "단가갱신" ? ft("Price") : ft("Regen"));
}
+5
View File
@@ -63,6 +63,11 @@ const TEXT = {
"Discard edits to this basis and follow the copy again",
],
New_M01: ["M01 새 판 있음", "New M01 version"],
New_Value: ["새 값 있음", "New values"],
Gone: ["지워짐", "Deleted"],
Gone_Note: ["마지막 값 유지", "last value kept"],
Gap: ["사본 없는 줄", "Uncopied rows"],
Gap_Note: ["저장하면 지금 값으로 굳음", "saving freezes current values"],
Price: ["단가 갱신", "Refresh prices"],
Regen: ["재생성", "Regenerate"],
Price_Ask: ["자원 단가만 지금 M01 값으로 — 식 · 연결 그대로", "Prices only — equation unchanged"],
+10 -1
View File
@@ -46,6 +46,7 @@ import {
import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick";
import { reach } from "./formula_doc_render";
import { attachDrag } from "./line_stack_drag";
import { toMineButton } from "./line_stack_mine";
import { openStackPick, type StackTake } from "./line_stack_pick";
import { buildBlock, sourceTag } from "./line_stack_rows";
import { lt, type LsKey } from "./line_stack_text";
@@ -165,7 +166,7 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
openStackPick(
kind,
(p) => h.change((doc) => repick(doc, i, own, kind, p)),
undefined,
kind === "기계" ? h.take : undefined, // 821-3 개인 층 = 내 기계 묶음도
found?.찾기어 ?? String(mat?.검색어 ?? line.이름 ?? ""),
),
});
@@ -175,6 +176,14 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
head.push(pick);
const mark = machineMark(found, link);
if (mark) head.push(mark);
// 821-3 품셈 기계 줄 → 복사해 내 기계로(개인 층 SK)
const mine =
kind === "기계"
? toMineButton(h.take, link as { 갈래?: string; 입력?: Record<string, string> }, (p) =>
h.change((doc) => repick(doc, i, own, kind, p)),
)
: null;
if (mine) head.push(mine);
} else
head.push(
field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "m"),
+61
View File
@@ -0,0 +1,61 @@
/* =============================================================================
* line_stack_mine.ts
* 줄 쌓기 기계 줄 「복사해 내 기계로」(PLAN 821-3 · 요구서 796 3 장) — M02 개인 층 단가산출만.
* 품셈 기계 줄(기계='원문번호') → 그 기계 값을 복사한 내 기계 양식 창 → 저장하면 줄이 그 내 기계로(기계='개인 EQP…')
* M01 정본은 그대로 · 내 기계 줄(기계='개인 …') · M01 단가산출 기초 = 단추 없음
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { showToast } from "@ui/ui_template_elements";
import type { Picked } from "../common_util/common_util_line_stack_pick";
import { findSimilar, machineArg, mineElement } from "../M02_MasterTemplete/M02_MyMachine_Api";
import { openMyMachineModal } from "../M02_MasterTemplete/M02_MyMachine_Form";
import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text";
import type { StackTake } from "./line_stack_pick";
/** 기계 줄 연결 → 품셈 기계 원문번호(내 기계 · 기계 입력 없음 = null) */
export function m01MachineNo(link: {
갈래?: string;
입력?: Record<string, string>;
}): string | null {
const arg = link.갈래 === "단가산출" ? link.입력?.기계 : undefined;
const no = typeof arg === "string" ? arg.trim().replace(/^'|'$/g, "") : "";
return no && !no.startsWith("개인 ") ? no : null;
}
/** 「복사해 내 기계로」 단추 — 개인 층 SK 품셈 기계 줄만 · 고르면 `done(새 줄 고름)` */
export function toMineButton(
take: StackTake | undefined,
link: { 갈래?: string; 입력?: Record<string, string> },
done: (p: Picked) => void,
): HTMLElement | null {
const no = m01MachineNo(link);
if (!no || take?.layer !== "personal") return null;
const b = createButton({
label: mt("To_Mine"),
variant: "ghost",
size: 24,
iconStart: "copy",
onClick: async () => {
const rows = await findSimilar(take.owner, no).catch(() => []);
const seed = rows.find((r) => r.출처 === "M01" && r.원문번호 === no);
if (!seed) return showToast(`${mt("Hourly_None")} — ${no}`, "warning");
openMyMachineModal(
take.owner,
(got) => {
const e = mineElement(got.줄, got.시간당);
if (!e.로직) return showToast(mt("Pick_Direct"), "warning");
done({
키: e.로직,
이름: e.이름,
입력: { 기계: machineArg(got.줄.원문번호) },
...(e.규격 ? { 규격: e.규격 } : {}),
});
},
seed,
);
},
});
b.dataset.toMine = no;
return b;
}
+2
View File
@@ -3,6 +3,7 @@
* 줄 쌓기 고르기 창(PLAN 804-3 · 804-4 · 요구 678-1 · 678-3 · 680-1 · 681)
* 찾아 넣기 = M02 [항목 추가] 창 그대로(`openFindPicker` · 탭 M01 전체 · 찾기 칸 · 표) — 줄 갈래마다 그 종류만
* 인력(노임 LB) · 재료(자재 MT) · 기계(EQ → 시간당 경비 로직 + 기계='원문번호') · 다른 단가산출(M01 단가산출 로직)
* 기계 + M02 개인 층(821-3) = 품셈 기계 위 「내 기계」 묶음 · 머리 [새 기계 만들기] — 고름 = 로직 + 기계='개인 EQP…'
* M01 단가산출 기초 줄 쌓기 = 「M01 전체」 탭만 — M01 은 원천 기준값이라 M02 단가산출 · 일위대가를 부르지 않음(요구 680-1)
* M02 단가산출 줄 쌓기(`take` 있음) · 다른 단가산출 = 탭 [M01 전체 | M02 단가산출 | 일위대가](위 층 · 자기 SK 뺌) —
* 고름 = 복사해 펼침(`take(src)` · src = M01 키 | `참조|층|주인|키`) · 참조 줄 없음(요구 681)
@@ -56,6 +57,7 @@ export function openStackPick(
owner: copy?.owner ?? null,
title: `${lt("Find_Title")} — ${lt(copy ? "K_복사" : (`K_${kind}` as never))}`,
kinds: copy ? ["logic", "sk", "pre"] : [want], // M01 = M01 전체만(680-1)
mine: kind === "기계" && m02?.layer === "personal" ? { owner: m02.owner } : undefined,
query: query.trim() || undefined,
ref: copy
? (r) =>
+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;
}
+12 -1
View File
@@ -50,6 +50,9 @@ def pack(got: dict, name: str) -> dict[str, Any]:
"""파서 답 → 둘 꼴(셈 · 변환에 쓰는 칸만 · JSON)."""
out: dict[str, Any] = {"꼴": got["꼴"], "파일": name, "표지": got.get("표지") or []}
out |= {"엑셀": True} if got.get("엑셀") else {} # 812-1 엑셀 읽기기(`M02_San_Xlsx`) — 같은 꼴
out |= (
{"기계구성": got["기계구성"]} if got.get("기계구성") else {}
) # 821-4 EST 원파일 기계 구성
head = ("코드", "번", "이름", "규격", "단위", "저장")
if got.get(
"엑셀"
@@ -104,7 +107,15 @@ def numbers(saved: dict) -> dict[int, dict]:
def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]:
"""원파일 산근 블록 하나 → `{로직, 원본}`(원본 `원파일` True · 코드 · 확정 수) · 없는 번 = KeyError."""
"""원파일 산근 블록 하나 → `{로직, 원본}`(원본 `원파일` True · 코드 · 확정 수) · 없는 번 = KeyError ·
기계 찾기 = M01 만(821-4 — 못 이은 기계 → 내 기계는 `M02_San_MyMachine.attach`)."""
from M02_MasterTemplete.M02_Machine_Find import m01_only
with m01_only():
return _emit(saved, no, keys)
def _emit(saved: dict, no: int, keys: bool) -> dict[str, Any]:
blk = numbers(saved)[int(no)]
if saved["꼴"] == "EST":
names = {b["코드"]: n for n, b in numbers(saved).items()}
+66
View File
@@ -0,0 +1,66 @@
"""M02 단가산출(SK) 수식 사본 빈틈(PLAN 822-1 · 요구 797-1) — 서버 단독.
빈틈 = 문서 셈이 읽은 M01 요소 · 표 줄 가운데 값 사본에 없는 것(줄 쌓기 기계 · 바꿔 끼운 기계 ·
더한 자원 줄 · 단가산출 참조가 읽는 노임 · 유가 · 내 기계 …) — 사본 색인이 지금 M01 로 흘러
M01 이 바뀌면 금액이 바로 따라 바뀜.
`gaps` = 빈틈 키(셈 한 번 · 읽은 줄 적기) · `fill` = 빈틈을 지금 M01 닫힌 묶음으로 값 사본에 더함
(있는 줄 그대로 — `skc.grow`) — [저장] · [단가 갱신] 때만(열기 · 미리 셈은 지금 값으로 셈 + 표지).
"""
from __future__ import annotations
from typing import Any
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete import M02_Sangeun_Copy as skc
class _Seen:
"""셈 색인 겉 — `get` · `value` · `logic` 이 내준 줄 키를 적음(그 밖 = 속 색인 그대로)."""
def __init__(self, inner: Any) -> None:
self._inner, self.keys = inner, set()
def get(self, ref: str) -> dict:
return self._note(self._inner.get(ref))
def value(self, ref: str):
self.get(ref)
return self._inner.value(ref)
def logic(self, key: str) -> dict:
return self._note(self._inner.logic(key))
def _note(self, row: Any) -> Any:
if isinstance(row, dict) and row.get("키"):
self.keys.add(str(row["키"]))
return row
def __getattr__(self, name: str) -> Any:
return getattr(self._inner, name)
def _have(copy: dict) -> set[str]:
return {str(r.get("키")) for r in [*(copy.get("로직") or []), *(copy.get("단가") or [])]}
def gaps(copy: dict) -> list[str]:
"""값 사본에 없는데 문서 셈이 읽은 M01 줄 키 + 문서의 내 기계 키(셈 틀림 = 읽은 데까지)."""
if "로직" not in copy or not isinstance(copy.get("문서"), dict):
return []
seen = _Seen(dc.over(copy))
try:
dc.mc.calc(copy["문서"], dc.ml.Links(seen, dc.docs_of(copy)))
except dc._ERR:
pass
have = _have(copy)
return sorted({*seen.keys, *mym.keys_in(copy["문서"])} - have)
def fill(copy: dict) -> dict:
"""빈틈을 지금 M01(+ 지금 층 내 기계) 닫힌 묶음으로 값 사본에 더함 · 빈틈 없음 = 그대로."""
keys = gaps(copy)
if not keys:
return copy
return {**copy, **skc.grow(dc.values_of(copy), skc.snap([], keys))}
+14 -2
View File
@@ -51,6 +51,16 @@ def basis_of(sk: dict) -> str:
return "우리" if (sk.get(imp.KEY) or {}).get("단가기준") == "우리" else "엑셀"
def _index(sk: dict, master: Any = None) -> Any:
"""셈 색인 — 우리 = 로직 사본 위(822-1 · 이음 값이 M01 따라 안 바뀜 · [단가 갱신] 으로만) ·
엑셀 = 받은 색인 · 없으면 지금 M01."""
from M02_MasterTemplete import M02_Sangeun_Copy as skc
if basis_of(sk) == "우리":
return skc.over(sk, imp._whole())
return master or imp._whole()
# ── 엑셀 단가 그대로 엔진 줄(옛 셈 길 · SK 고침 얹은 것) ───────────────────
def _ctx(sk: dict, whole: Any) -> tuple:
"""엑셀 단가 기준 고친 줄 — 옛 셈 길(`OLD`)로 돌려 엔진에 넣은 (색인, 키, 입력, 답, 재료)."""
@@ -325,6 +335,8 @@ def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None:
if not item.get("참조") and var["연결"].get("값") != excel:
continue # SK 덮개 단가 = 사용자 값
link, note = _link_of(item)
if mine := (item.get("자원") or {}).get("내기계"): # 821-4 꼬리표 「내 기계」
note = f"내 기계 {mine}" + ("" if link else " · 시간당 값 직접 — 엑셀 단가 그대로")
if note:
var["설명"] = note
if link is None:
@@ -512,7 +524,7 @@ def record(doc: dict, sk: dict) -> dict[str, Any]:
import master_doc_record as mr # `M02_Doc_Copy` 가 scripts 를 길에 둔 뒤
basis = basis_of(sk)
links = Links(imp._whole(), basis)
links = Links(_index(sk), basis)
mine = view(doc, basis)
rec = mr.Rec(links)
try:
@@ -527,7 +539,7 @@ def record(doc: dict, sk: dict) -> dict[str, Any]:
def run(doc: dict, sk: dict, master: Any = None) -> dict:
"""가져온 식 문서 셈(SK 단가기준) — 계산기 = M01 과 같은 `master_doc_calc`."""
basis = basis_of(sk)
links = Links(master or imp._whole(), basis)
links = Links(_index(sk, master), basis)
mine = view(doc, basis)
try:
links.learn(mine)
+8 -2
View File
@@ -22,6 +22,7 @@ from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Doc_Gap as gap
from M02_MasterTemplete import M02_Doc_Import as dim
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
@@ -196,8 +197,10 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict:
"수식 문서로 고친 단가산출 — 옛 칸(덮개 · 줄) 고침은 수식 탭에서 · 또는 [재생성]",
409,
)
copy = dc.refresh(have)[0] if job == skc.PRICE and not dim.is_imported(out) else have
return {**out, KEY: norm(copy)}
if dim.is_imported(out):
return {**out, KEY: norm(have)}
copy = dc.refresh(have)[0] if job == skc.PRICE else have
return {**out, KEY: norm(gap.fill(copy))} # 사본 빈틈 = 지금 값으로 박음(822-1)
# 빈 SK(바탕 · 줄 없음) = 빈 문서(요구서_664 0 장) · 수식 길로 고친 빈 SK = 위 따로(804-4)
if not skc.has(out) and not dim.is_imported(out):
empty = seed(out)
@@ -207,4 +210,7 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict:
calls = {} if job == skc.REGEN else (have or {}).get("부름") or {}
first = {**(dc.values_of(out[skc.KEY]) if skc.has(out) else {}), "부름": calls}
copy = copy_of({**out, KEY: first}, box[-1] if box else None)
if copy and skc.has(out) and (keys := gap.gaps(copy)): # 파생 — 옛 로직 사본에 박음(822-1)
out = {**out, skc.KEY: skc.grow(out[skc.KEY], skc.snap([], keys))}
copy = {**copy, **dc.values_of(out[skc.KEY])}
return {**out, KEY: norm({k: v for k, v in (copy or first).items() if k != "따로"})}
+8 -3
View File
@@ -17,6 +17,7 @@ import json
from typing import Any
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Doc_Gap as gap
from M02_MasterTemplete import M02_Doc_Import as dim
from M02_MasterTemplete import M02_Doc_Screen as screen
from M02_MasterTemplete import M02_Doc_Sk as dsk
@@ -40,11 +41,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:
@@ -77,7 +77,11 @@ def answer(copy: dict, sk: dict) -> dict[str, Any]:
got = dsk.run(sk, copy)
head = {k: copy.get(k) for k in ("시점", "단가시점")}
mark = {**head, "따로": bool(copy.get("따로"))}
mark |= {"단가기준": dim.basis_of(sk)} if dim.is_imported(sk) else dc.state(copy)
mark |= (
{"단가기준": dim.basis_of(sk)}
if dim.is_imported(sk)
else {**dc.state(copy), "사본없음": len(gap.gaps(copy))} # 저장하면 지금 값으로 굳음
)
body = {
"문서": copy["문서"],
"부름": copy.get("부름") or {},
@@ -130,6 +134,7 @@ def edited(copy: dict, body: dict, key: str, sk: dict | None = None) -> dict:
]
if not dim.is_imported(sk):
out["부름"] = dc.closure(dc.over(out), calls, copy.get("부름"))
out = gap.fill(out) # 셈이 읽은 줄 가운데 사본 없는 것 = 지금 값으로 박음(822-1)
out["따로"] = True
return out
+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))
+30 -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(여럿 =
@@ -19,11 +19,12 @@ import math
import re
import time
import unicodedata
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from functools import lru_cache
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
# 소리만 다른 표기 → 한 글(양쪽에 같이 씀 · 차례대로)
SOUND = (
("로우더", "로더"), ("로울러", "롤러"), ("브레이카", "브레이커"), ("브레카", "브레이커"),
@@ -142,20 +143,39 @@ 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 초에 한 번
_M01_ONLY: ContextVar[bool] = ContextVar("m01_only", default=False)
@contextmanager
def m01_only() -> Iterator[None]:
"""이 안의 찾기 = M01 기계만(821-4 불러오기 — 내 기계 잇기는 `M02_San_MyMachine.attach` 한 곳)."""
token = _M01_ONLY.set(True)
try:
yield
finally:
_M01_ONLY.reset(token)
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 M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
if _M01_ONLY.get():
return _table(m01.loaded()[1])
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:
@@ -17,6 +17,8 @@
* · (728-2) 돈 아닌 기초(결과단위가 「원」 아님 · ㎥/시간 등 21 개) = 목록에서 뺌
* · (785-3) `oil` = M01 유가전력(MO · `/logic-lib/elements?group=재료` 의 MO 줄) 고름 · 고정 줄 꼴 = 이음 유가 MO ·
* 재료비 = 지금 값(셈은 M01 값) · 「M01 전체」 탭 재료 아래
* · (821-3) `mine` = 개인 층 내 기계 묶음(품셈 기계 위 · 구분 「내 기계」 · 출처 「개인 키」 · 금액 = 서버 시간당) ·
* 머리 [새 기계 만들기](양식 창 → 저장하면 그 기계로 고름) · 「시간당 값 직접」 내 기계 = 줄 쌓기 식 없음 → 회색
* ========================================================================== */
import { createDataTable } from "@ui/ui_template_data_table";
@@ -24,6 +26,9 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e
import { t as L } from "@ui/ui_template_locale";
import { aliasNote, matchLogic } from "@ui/ui_template_logic_find";
import { openNewMaterial } from "./M02_Material_Pick";
import { listMyMachines, mineElement, type MyMachineRow } from "./M02_MyMachine_Api";
import { openMyMachineModal } from "./M02_MyMachine_Form";
import { mt } from "./M02_MyMachine_Text";
import { openModal } from "@ui/ui_template_modal";
import { createTabs } from "@ui/ui_template_tabs";
import { createText } from "@ui/ui_template_text";
@@ -83,6 +88,8 @@ export interface FindPickOptions {
machine?: (e: M01Element) => void;
/** 785-3 M01 유가전력(MO) 고름 — 있을 때만 유가 줄이 보임 */
oil?: (e: M01Element) => void;
/** 821-3 개인 층 내 기계 묶음(`machine` 과 함께) — 주인(null = 자기 층) */
mine?: { owner: string | null };
}
/** 727-1 기초마다 한 줄(옛 창 `Labor_Pick` 도 씀) — 규격 칸 = 「옵션 N」 */
@@ -182,6 +189,7 @@ export function openFindPicker(opt: FindPickOptions): void {
let goods: PickItem[] = [];
let machines: PickItem[] = [];
let oils: PickItem[] = []; // 785-3 유가전력 MO
let mines: MyMachineRow[] = []; // 821-3 내 기계
const lists: Record<"sk" | "pre", SangeunRow[] | null> = { sk: null, pre: null };
let shown: Line[] = [];
let seq = 0;
@@ -280,6 +288,26 @@ export function openFindPicker(opt: FindPickOptions): void {
: take(() => put(e)),
};
});
/** 821-3 내 기계 줄 — 금액 = 서버 시간당 · 직접 = 회색(줄 쌓기 식 없음) */
const mineLines = (q: string): Line[] => {
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
return mines
.filter((r) => words.every((w) => `${r.이름} ${r.규격} ${r.키}`.toLowerCase().includes(w)))
.map((r) => {
const e = mineElement(r, r.시간당);
const off = !e.로직 || !opt.machine;
return {
품명: r.이름,
규격: r.규격,
단위: "시간",
금액: e.값,
구분: join(mt("Pick_Group"), mt(r.방식), off ? mt("Pick_Direct") : ""),
출처: r.원문번호,
dim: off,
go: off ? () => showToast(mt("Pick_Direct"), "warning") : take(() => opt.machine?.(e)),
};
});
};
const docLines = (rows: SangeunRow[], kind: string): Line[] =>
rows.map((x) => ({
품명: x.제목 || x.이름,
@@ -299,6 +327,7 @@ export function openFindPicker(opt: FindPickOptions): void {
...(ok("labor") ? elementLines("인력", people) : []),
...(ok("material") ? elementLines("재료", goods) : []),
...(opt.oil && ok("oil") ? elementLines("유가", oils) : []),
...(opt.machine && opt.mine && ok("machine") ? mineLines(q) : []),
...(opt.machine && ok("machine") ? elementLines("기계", machines) : []),
];
} else {
@@ -387,9 +416,25 @@ export function openFindPicker(opt: FindPickOptions): void {
});
newMat.dataset.materialNew = "";
const canNew = opt.layer === "personal" && !!opt.material && ok("material");
// 821-3 내 기계 묶음이면 머리 [새 기계 만들기] — 양식 창에서 저장하면 그 기계로 고름
const newMach = createButton({
label: mt("Pick_New"),
variant: "ghost",
onClick: () =>
openMyMachineModal(opt.mine?.owner ?? null, (got) => {
const e = mineElement(got.줄, got.시간당);
if (!e.로직) return showToast(mt("Pick_Direct"), "warning");
take(() => opt.machine?.(e))();
}),
});
newMach.dataset.machineNew = "";
const headBtns = [
...(canNew ? [newMat] : []),
...(opt.mine && opt.machine && ok("machine") ? [newMach] : []),
];
({ close } = openModal({
title: opt.title ?? L("M02_FindTitle"),
...(canNew ? { actions: [newMat] } : {}),
...(headBtns.length ? { actions: headBtns } : {}),
closeLabel: tc("Pick_Close"),
dialogClass: "m01-logic__pick m01-combo",
width: "960px", // 표 여섯 칸
@@ -415,6 +460,13 @@ export function openFindPicker(opt: FindPickOptions): void {
})
.catch(fail);
void findElements();
if (opt.mine && opt.machine && ok("machine"))
void listMyMachines(opt.mine.owner)
.then((rows) => {
mines = rows;
draw();
})
.catch(fail);
void upper(opt.layer, opt.owner)
.then((scopes) =>
Promise.all(
@@ -5,6 +5,7 @@
* 바꾸기 전후 금액 · [원본 글 보기]
* · 줄 2 = 이음 없는 자원 = 표지 + [이음 고르기]
* · 줄 2-2 = 소요량 · 계수 이음(797-2 · 후보 집계 + [이음 보기] → `Hopyo_QtyLinks` 모달)
* · 줄 2-3 = 못 이은 기계 → 내 기계(821-4) — 「내 기계 생성 n」 · 줄마다 「내 기계」 꼬리표 · 개인 층 밖 = 「만들어질 내 기계 n」
* · 경쟁사 로직 그대로 — 기초로 바꾸기 · 근거 기초 제안 없음(809-1 · 요구 703)
* 부품 = 단추 · 배지 · 글 · 입력 · 띠 · 표 · 모달(날 단추 · 입력 없음) · 서버 길은 부르는 쪽.
* ========================================================================== */
@@ -47,6 +48,12 @@ const TEXT = {
Gap: ["바꾸기 전 {a} → 후 {b} ({d})", "Before {a} → after {b} ({d})"],
QtyLinks: ["소요량 · 계수 이음 {n}", "Quantity / coefficient links {n}"],
QtyOpen: ["이음 보기", "Show links"],
MyMade: ["내 기계 생성 {n}", "My machines created {n}"],
MyUsed: ["내 기계 이음 {n}", "Linked to my machines {n}"],
MyPlan: ["만들어질 내 기계 {n}", "My machines to create {n}"],
MyTag: ["내 기계", "My machine"],
MyNew: ["이번에 만듦 · 값 확인 필요", "Created now · check values"],
MyOld: ["있던 내 기계에 이음", "Linked to an existing one"],
Close: ["닫기", "Close"],
} as const satisfies Record<string, readonly [string, string]>;
export const tx = (k: keyof typeof TEXT): string =>
@@ -116,6 +123,34 @@ function checkBadge(doc: ImportedDoc): HTMLElement {
return b;
}
/** 못 이은 기계 → 내 기계(821-4) — 생성 수 · 이은 수 · 줄마다 꼬리표 · 예정 수 */
function myMachines(src: NonNullable<ImportedDoc["가져온"]>["원본"]): HTMLElement | null {
const got = src?.내기계 ?? [];
const plan = src?.내기계예정 ?? [];
if (!got.length && !plan.length) return null;
const made = got.filter((m) => m.새로).length;
const items: HTMLElement[] = [];
if (made) items.push(createBadge(tx("MyMade").replace("{n}", String(made)), "info"));
if (got.length > made) items.push(createBadge(tx("MyUsed").replace("{n}", String(got.length - made)), "neutral")); // prettier-ignore
for (const m of got) {
const b = createBadge(
`${tx("MyTag")} · ${[m.이름, m.규격].filter(Boolean).join(" ")}`,
"accent",
);
b.title = `${m.키} · ${m.새로 ? tx("MyNew") : tx("MyOld")}`;
b.dataset.importedMine = m.키;
items.push(b);
}
if (plan.length) {
const b = createBadge(tx("MyPlan").replace("{n}", String(plan.length)), "warning");
b.title = plan.map((m) => [m.이름, m.규격].filter(Boolean).join(" ")).join("\n");
items.push(b);
}
const bar = createToolbar({ items });
bar.dataset.importedMineBar = String(made);
return bar;
}
/** 머리 판 */
export function importedPanel(s: PanelState, a: PanelActs): HTMLElement {
const meta = s.doc.가져온 ?? {};
@@ -163,6 +198,8 @@ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement {
];
kids.push(createToolbar({ items: [...tags, ...items] }));
}
const mine = myMachines(src);
if (here && mine) kids.push(mine);
if (here && s.qty?.자리) {
const open = createButton({ label: tx("QtyOpen"), size: 24, variant: "ghost", onClick: a.qty });
open.dataset.importedQty = String(s.qty.자리);
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` ·
* 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
* 50-3 · 134-3) › 「내 기계」 · 「내 품목」(821-3 · 개인 층 · 펼치면 가운데 화면) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기).
* 구조물 옵션 정의는 M01 로 옮김(716-1).
* 도면 양식 = 시스템 관리자면 늘(마스터 층 것 · 구조물 조합 층과 무관 · 720-4).
@@ -37,6 +37,9 @@ import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
import { buildPrecomboSection, type PrecomboOptions } from "./M02_MasterTemplete_UI_Side_Precombo";
import { buildSangeunSection, type SangeunOptions } from "./M02_MasterTemplete_UI_Side_Sangeun";
import { buildMyItemsView } from "./M02_MyItems_View";
import { buildMyMachineView } from "./M02_MyMachine_View";
import { mt } from "./M02_MyMachine_Text";
import { createEmpty } from "@ui/ui_template_empty";
import { createDivider } from "@ui/ui_template_divider";
@@ -154,6 +157,35 @@ export function buildSide(opt: SideOptions): SideHandle {
getOpen: opt.getOpen,
});
const sangeunSec = section(L("M02_SangeunTitle"), sangeun.body);
// 내 기계 · 내 품목(821-3) — 자기 개인 층 · 펼치면 가운데 화면(목록 · 양식)
const hint = (key: "Side_Hint" | "Items_Side_Hint"): HTMLElement =>
createEmpty({ text: mt(key), small: true });
const myMachSec = openOnExpand(section(mt("Title"), hint("Side_Hint")), async () => {
if (!(await opt.confirmLeave())) return;
let mark = (_d: boolean): void => {};
const node = buildMyMachineView({ owner: null, setDirty: (d) => mark(d) });
mark = opt.showView(mt("Title"), node);
});
myMachSec.dataset.myMachines = "";
const myItemsSec = openOnExpand(section(mt("Items_Title"), hint("Items_Side_Hint")), async () => {
if (!(await opt.confirmLeave())) return;
const node = buildMyItemsView({
owner: null,
openSk: async (u) => {
if (!(await opt.confirmLeave())) return;
opt.onOpen({
layer: "personal",
owner: null,
canWrite: true,
projectId: null,
kind: "sangeun",
name: u.키,
label: `${u.키} ${u.이름}`.trim(),
});
},
});
opt.showView(mt("Items_Title"), node);
});
// 실무자료로 일위대가를 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝
const onBuilt = (): void =>
precombo.body.isConnected
@@ -173,6 +205,8 @@ export function buildSide(opt: SideOptions): SideHandle {
children: [
sangeunSec,
precomboSec,
myMachSec,
myItemsSec,
summary,
createDivider(),
section(hopyo.title, hopyo.body),
+95
View File
@@ -0,0 +1,95 @@
/* =============================================================================
* M02_MyItems_View.ts
* M02 「내 품목」 모아 보기(PLAN 821-3 · 요구서 796 3 장 · 읽기만) — 좌측 「내 품목」 칸을 펼치면 가운데에 뜸(개인 층).
* 줄 = 그 층 단가산출 줄에 값을 바로 적은 재료(우리 자재에 안 이어진 것 · 서버 `M02_MyMachine_Look.items`)
* 칸 = 이름 · 규격 · 단위 · 값 · 쓰는 단가산출 수 · 같은 이름 수(같은 이름 묶음 = 이름 차례로 붙어 보임)
* 줄 누름 = 아래 쓰는 단가산출 목록 → 누르면 그 단가산출로(고치기는 거기서 · 여기선 저장 없음)
* ========================================================================== */
import { createDataTable } from "@ui/ui_template_data_table";
import { createInputField, el } from "@ui/ui_template_elements";
import { createFindList } from "@ui/ui_template_find_list";
import { createText } from "@ui/ui_template_text";
import { listMyItems, type MyItem, type Use } from "./M02_MyMachine_Api";
import { mt } from "./M02_MyMachine_Text";
export interface MyItemsViewOptions {
owner: string | null;
/** 그 단가산출 열기(떠남 확인은 부르는 쪽) */
openSk: (use: Use) => void;
}
export function buildMyItemsView(opt: MyItemsViewOptions): HTMLElement {
let rows: MyItem[] = [];
let shown: MyItem[] = [];
const search = createInputField({ type: "search", placeholder: mt("Find") });
const hint = createText("");
const fail = el("div");
const uses = createFindList({ placeholder: mt("Open_Sk") });
uses.root.hidden = true;
const table = createDataTable({
caption: mt("Items_Title"),
columns: [
{ key: "이름", label: mt("Name"), width: 200 },
{ key: "규격", label: mt("Spec"), width: 140 },
{ key: "단위", label: mt("Unit"), width: 60 },
{ key: "값", label: mt("Value"), kind: "number", decimals: 2, trim: true, width: 100 },
{ key: "쓰는곳", label: mt("Uses_Count"), kind: "number", decimals: 0, width: 70 },
{ key: "같은이름", label: mt("Same_Name"), kind: "number", decimals: 0, width: 70 },
],
rows: [],
empty: mt("Items_None"),
mergeSame: ["이름"],
onRowClick: (i) => pick(shown[i]),
});
const pick = (item: MyItem | undefined): void => {
if (!item) return;
uses.root.hidden = false;
uses.hint.textContent = `${item.이름} ${item.규격}`.trim();
uses.setRows(
item.쓰는곳.map((u) => ({
key: u.키,
name: u.이름 || u.키,
sub: u.키,
onPick: () => opt.openSk(u),
})),
);
};
const draw = (): void => {
const words = search.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const count = new Map<string, number>();
for (const r of rows) count.set(r.이름, (count.get(r.이름) ?? 0) + 1);
shown = rows.filter((r) => words.every((w) => `${r.이름} ${r.규격}`.toLowerCase().includes(w)));
hint.textContent = rows.length ? `${shown.length} / ${rows.length}` : "";
table.setRows(
shown.map((r) => ({
이름: r.이름,
규격: r.규격,
단위: r.단위,
값: typeof r.값 === "number" ? r.값 : Number(r.값) || null,
쓰는곳: r.쓰는곳.length,
같은이름: count.get(r.이름) ?? 1,
})),
);
};
search.input.addEventListener("input", draw);
void listMyItems(opt.owner)
.then((got) => (rows = got))
.catch((error) =>
fail.replaceChildren(
createText(error instanceof Error ? error.message : mt("Personal_Only"), "error"),
),
)
.finally(draw);
return el("div", {
className: "m02-mymach-view",
attrs: { "data-my-items": "" },
children: [
el("div", { className: "ui-toolbar", children: [search.root, hint] }),
createText(mt("Items_Note"), "muted"),
fail,
table.root,
uses.root,
],
});
}
+16
View File
@@ -0,0 +1,16 @@
/* M02 내 기계 · 내 품목 화면(PLAN 821-3) — 차림만 · 색 · 크기는 공통 값 */
.m02-mymach,
.m02-mymach-view,
.m02-mymach-view__list {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m02-mymach-view {
padding: var(--spacing-8);
}
.m02-mymach__uses ul {
margin: var(--spacing-4) 0 0;
padding-left: var(--spacing-16);
}
+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()
]
}
+156
View File
@@ -0,0 +1,156 @@
/* =============================================================================
* M02_MyMachine_Api.ts
* M02 내 기계 · 내 품목 서버 호출(PLAN 821-3) — 개인 층만.
* 내 기계 = `/layers/personal/my-machines`(821-1 · `M02_MyMachine_Router`) — 목록 · 하나 · 만들기 · 고치기 · 지우기
* 양식 목록 · 비슷한 기계 찾기 · 내 품목 = `M02_MyMachine_Look`(읽기만)
* ========================================================================== */
import type { MachinePick, MachineValues } from "@ui/ui_template_machine_form";
import { call } from "./M02_MasterTemplete_Api_Fetch";
import type { M01Element } from "./M02_MasterTemplete_Find_Pick";
export type MachineHow = "바로" | "품셈" | "직접";
type Costs = Record<"노무비" | "재료비" | "경비", number>;
/** 서버 시간당 셈 답 — ok 아님 = 까닭 */
export interface MyHourly extends Partial<Costs> {
ok: boolean;
계?: number;
로직?: string | null;
직접?: boolean;
reason?: string;
}
/** 내 기계 줄(저장 꼴) */
export interface MyMachine {
키: string;
원문번호: string;
이름: string;
규격: string;
방식: MachineHow;
값: MachineValues;
시간당?: Costs;
비고?: string;
넣은날: string;
고친날: string;
본뜬?: string;
}
export interface MyMachineRow extends Omit<MyMachine, "시간당"> {
시간당: MyHourly;
쓰는곳수: number;
}
export interface Use {
키: string;
이름: string;
}
export interface MyMachineOne {
줄: MyMachine;
시간당: MyHourly;
같은이름: { 키: string; 이름: string; 규격: string; 출처: string }[];
쓰는곳?: Use[];
}
/** 보내는 줄 — 방식 · 값 또는 시간당(직접) */
export interface MyMachineBody {
이름: string;
규격: string;
방식: MachineHow;
값?: MachineValues;
시간당?: Partial<Costs>;
}
export interface MachineLists {
fuels: MachinePick[];
jobs: MachinePick[];
power: number;
hour: number;
}
export interface SimilarRow {
키: string;
원문번호?: string;
이름: string;
규격: string;
구분: string;
값?: MachineValues;
방식?: MachineHow;
시간당?: Costs;
출처: "개인" | "M01";
}
export interface MyItem {
이름: string;
규격: string;
단위: string;
값: unknown;
쓰는곳: Use[];
}
const at = (owner: string | null, tail = "", q: Record<string, string> = {}): string => {
const ask = new URLSearchParams({ ...q, ...(owner ? { owner } : {}) }).toString();
return `/layers/personal/my-machines${tail}${ask ? `?${ask}` : ""}`;
};
const look = (owner: string | null, path: string, q: Record<string, string> = {}): string => {
const ask = new URLSearchParams({ ...q, ...(owner ? { owner } : {}) }).toString();
return `/layers/personal/${path}${ask ? `?${ask}` : ""}`;
};
const key = (k: string): string => `/${encodeURIComponent(k)}`;
export const listMyMachines = async (owner: string | null): Promise<MyMachineRow[]> =>
(await call<{ 줄: MyMachineRow[] }>(at(owner))).줄;
export const readMyMachine = (owner: string | null, k: string): Promise<MyMachineOne> =>
call(at(owner, key(k)));
export const createMyMachine = (
owner: string | null,
body: MyMachineBody,
base?: string,
): Promise<MyMachineOne> =>
call(at(owner), { method: "POST", body: JSON.stringify({ 줄: body, 본뜬: base ?? null }) });
export const updateMyMachine = (
owner: string | null,
k: string,
body: MyMachineBody,
): Promise<MyMachineOne> =>
call(at(owner, key(k)), { method: "PUT", body: JSON.stringify({ 줄: body }) });
export const deleteMyMachine = (owner: string | null, k: string): Promise<{ 쓰는곳: Use[] }> =>
call(at(owner, key(k)), { method: "DELETE" });
let lists: Promise<MachineLists> | null = null;
/** 양식 고르기 목록 — 한 번 받아 둠 */
export const fetchMyMachineLists = (): Promise<MachineLists> =>
(lists ??= call<MachineLists>("/my-machine-lists").catch((e) => {
lists = null;
return Promise.reject(e);
}));
export const findSimilar = async (owner: string | null, q: string): Promise<SimilarRow[]> =>
(await call<{ rows: SimilarRow[] }>(look(owner, "my-machine-find", { q }))).rows;
export const listMyItems = async (owner: string | null): Promise<MyItem[]> =>
(await call<{ rows: MyItem[] }>(look(owner, "my-items"))).rows;
/** 단가산출 줄 기계 입력 글(줄 쌓기 `기계='개인 EQP…'`) */
export const machineArg = (no: string): string => `'${no}'`;
/** 내 기계 → 기계 고르기 한 줄(`M01Element` 꼴 · 줄 쌓기 = 로직 + 기계='개인 EQP…') */
export const mineElement = (
row: Pick<MyMachine, "키" | "원문번호" | "이름" | "규격">,
h: MyHourly,
): M01Element => ({
종류: "기계",
코드: row.키,
이름: row.이름,
규격: row.규격,
단위: "시간",
값: h.ok ? (h.계 ?? null) : null,
단가: { 노무비: h.노무비 ?? 0, 재료비: h.재료비 ?? 0, 경비: h.경비 ?? 0 },
로직: h.로직 ?? undefined,
원문번호: row.원문번호,
});
+296
View File
@@ -0,0 +1,296 @@
/* =============================================================================
* M02_MyMachine_Form.ts
* M02 내 기계 양식 한 장(PLAN 821-3 · 요구서 796 1 · 3 장) — 기계 양식 부품(`ui_template_machine_form` · 821-2) +
* [저장] · [지우기] · 「개인 값」 표지 · 같은 이름 · 규격 알림 · 서버 정본 시간당 · 쓰는 곳.
* 기본 = 손료계수 「바로 넣기」 · 「시간당 값 직접」 꼴 · 직종 · 연료 = M01 목록 고르기 · 옆 계산식 즉시(계수 짝 TS)
* [저장] 때만 서버(자동저장 없음) — 서버가 정본으로 다시 셈한 시간당을 받아 보임
* 새 기계 = 「비슷한 기계에서 시작」(M01 · 내 기계 · 고른 키 = 서버 `본뜬`) 또는 빈 양식 · `seed` = 품셈 기계 복사
* 지우기 = 쓰는 단가산출 이름 보이고 확인 + 「이미 만든 단가산출은 마지막 값 유지」
* 부르는 곳 = 내 기계 화면(`M02_MyMachine_View`) · 기계 고르기 창 「새 기계 만들기」 · 줄 쌓기 「복사해 내 기계로」(창 `openMyMachineModal`)
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import {
createMachineForm,
HOURLY_KEY,
type MachineSimilar,
type MachineValues,
} from "@ui/ui_template_machine_form";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import {
createMyMachine,
deleteMyMachine,
fetchMyMachineLists,
findSimilar,
readMyMachine,
updateMyMachine,
type MyHourly,
type MyMachine,
type MyMachineBody,
type MyMachineOne,
type SimilarRow,
} from "./M02_MyMachine_Api";
import { mt } from "./M02_MyMachine_Text";
import "./M02_MyMachine.css";
export interface MyMachineFormOptions {
owner: string | null;
/** 고칠 내 기계 키 — 없으면 새 기계 */
key?: string;
/** 새 기계 바탕(복사해 내 기계로) — 값 · 이름 · 규격 · 본뜬 키 */
seed?: SimilarRow;
/** 새 기계면 「비슷한 기계에서 시작」 을 펼침 */
openSimilar?: boolean;
onSaved: (got: MyMachineOne) => void;
onDeleted?: () => void;
onBack?: () => void;
onDirty?: (dirty: boolean) => void;
}
export interface MyMachineFormHandle {
root: HTMLElement;
dirty: () => boolean;
}
const won = (v: number | undefined): string =>
`${(v ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 2 })}${mt("Won")}`;
/** 줄(저장 꼴) → 양식 값 묶음 — 직접 = 시간당 칸 하나 */
export function valuesOf(row: Pick<MyMachine, "방식" | "값" | "시간당">): MachineValues {
return row.방식 === "직접" ? { [HOURLY_KEY]: { ...(row.시간당 ?? {}) } } : { ...row.값 };
}
/** 서버 시간당 한 줄 글 */
export function hourlyText(h: MyHourly | undefined): string {
if (!h?.ok) return `${mt("Hourly_None")}${h?.reason ? ` — ${h.reason}` : ""}`;
return `${won(h.계)} (${mt("Labor")} ${won(h.노무비)} · ${mt("Material")} ${won(h.재료비)} · ${mt("Expense")} ${won(h.경비)})`;
}
export async function mountMyMachineForm(
host: HTMLElement,
opt: MyMachineFormOptions,
): Promise<MyMachineFormHandle> {
const [lists, one] = await Promise.all([
fetchMyMachineLists(),
opt.key ? readMyMachine(opt.owner, opt.key) : Promise.resolve(null),
]);
const row = one?.줄;
const seed = opt.seed;
let base = seed && seed.출처 === "M01" ? seed.키 : undefined; // 서버 `본뜬`
const found = new Map<string, SimilarRow>();
const notice = el("div", { className: "m02-mymach__notice" });
const server = el("div", { className: "m02-mymach__server" });
const usesBox = el("div", { className: "m02-mymach__uses" });
const form = createMachineForm({
name: row?.이름 ?? seed?.이름 ?? "",
spec: row?.규격 ?? seed?.규격 ?? "",
values: row
? valuesOf(row)
: seed
? valuesOf({ 방식: seed.방식 ?? "바로", 값: seed.값 ?? {}, 시간당: seed.시간당 })
: {},
coefMode: "direct",
allowHourly: true,
fuels: lists.fuels,
jobs: lists.jobs,
power: lists.power,
hour: lists.hour,
findSimilar: row
? undefined
: async (q): Promise<MachineSimilar[]> =>
(await findSimilar(opt.owner, q)).map((r) => {
found.set(r.키, r);
return {
key: r.키,
name: r.이름,
spec: r.규격,
tag: r.출처 === "개인" ? mt("Mine") : r.구분,
values: valuesOf({ 방식: r.방식 ?? "바로", 값: r.값 ?? {}, 시간당: r.시간당 }),
};
}),
onSimilar: (item) => {
base = item.key;
checkSame();
},
onChange: () => {
notice.replaceChildren(); // 지난 저장 막힘 글 지움(다시 [저장] 때 새로)
opt.onDirty?.(dirty());
checkSame();
},
});
let saved = !opt.key && seed ? false : true;
const dirty = (): boolean => form.state().changed || !saved;
/* --- 같은 이름 · 규격 알림(M01 · 내 기계) --- */
let timer: number | undefined;
let seq = 0;
const sameText = (hits: { 키: string; 출처: string }[]): string =>
hits.length
? mt("Same").replace("{value}", hits.map((h) => `${h.출처} ${h.키}`).join(" · "))
: "";
const checkSame = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(async () => {
const mine = ++seq;
const s = form.state();
const rows = s.name ? await findSimilar(opt.owner, s.name).catch(() => []) : [];
if (mine !== seq) return;
const hits = rows.filter(
(r) => r.키 !== opt.key && r.이름.trim() === s.name && (r.규격 ?? "").trim() === s.spec,
);
form.setNotice(sameText(hits));
}, 300);
};
/* --- 저장 --- */
const bodyOf = (): MyMachineBody | null => {
const s = form.state();
const errors = [...s.errors, ...(s.name ? [] : [mt("Name_Needed")])];
notice.replaceChildren(...errors.map((e) => createText(e, "error")));
if (errors.length) return null;
if (s.hourly)
return {
이름: s.name,
규격: s.spec,
방식: "직접",
시간당: (s.values[HOURLY_KEY] ?? {}) as MyMachineBody["시간당"],
};
const { [HOURLY_KEY]: _drop, ...vals } = s.values;
return { 이름: s.name, 규격: s.spec, 방식: s.mode === "pumsem" ? "품셈" : "바로", 값: vals };
};
const saveBtn = createButton({ label: mt("Save"), variant: "filled", size: 28 });
saveBtn.dataset.mymachSave = "";
saveBtn.addEventListener("click", async () => {
const body = bodyOf();
if (!body) return;
saveBtn.disabled = true;
try {
const got = opt.key
? await updateMyMachine(opt.owner, opt.key, body)
: await createMyMachine(opt.owner, body, base);
saved = true;
opt.onDirty?.(false);
showToast(mt("Saved"), "success");
opt.onSaved(got);
} catch (error) {
notice.replaceChildren(
createText(error instanceof Error ? error.message : mt("Failed"), "error"),
);
} finally {
saveBtn.disabled = false;
}
});
/* --- 지우기 --- */
const delBtn = createButton({ label: mt("Delete"), variant: "danger-ghost", size: 28 });
delBtn.hidden = !opt.key;
delBtn.addEventListener("click", async () => {
if (!opt.key) return;
const uses = (await readMyMachine(opt.owner, opt.key).catch(() => one))?.쓰는곳 ?? [];
const ask = [
mt("Delete_Ask").replace("{value}", String(uses.length)),
...uses.map((u) => `${u.키} ${u.이름}`),
mt("Delete_Keep"),
].join("\n");
if (!(await showConfirmDialog(ask, mt("Delete")))) return;
try {
await deleteMyMachine(opt.owner, opt.key);
saved = true;
opt.onDirty?.(false);
showToast(mt("Deleted"), "success");
opt.onDeleted?.();
} catch (error) {
showToast(error instanceof Error ? error.message : mt("Failed"), "error");
}
});
const back = opt.onBack
? [
createButton({
label: mt("Back"),
variant: "ghost",
size: 28,
iconStart: "chevronLeft",
onClick: opt.onBack,
}),
]
: [];
const title = row ? `${row.이름} ${row.규격}`.trim() : mt("New");
server.replaceChildren(
...(one ? [createText(`${mt("Server_Hourly")} ${hourlyText(one.시간당)}`)] : []),
);
if (one?.같은이름.length) form.setNotice(sameText(one.같은이름));
const uses = one?.쓰는곳 ?? [];
usesBox.replaceChildren(
el("strong", { text: `${mt("Uses")} ${uses.length}` }),
...(uses.length
? [el("ul", { children: uses.map((u) => el("li", { text: `${u.키} ${u.이름}` })) })]
: [createText(mt("No_Uses"), "muted")]),
);
const root = el("div", {
className: "m02-mymach",
attrs: { "data-mymach-form": opt.key ?? "new" },
children: [
el("div", {
className: "ui-toolbar m02-mymach__bar",
children: [
...back,
el("strong", { text: title }),
el("span", { className: "ui-text ui-text--muted", text: opt.key ?? "" }),
createBadge(mt("Own_Value"), "info"),
saveBtn,
delBtn,
],
}),
createText(mt("Own_Note"), "muted"),
notice,
server,
form.root,
...(opt.key ? [usesBox] : []),
],
});
host.replaceChildren(root);
if (!row && (opt.openSimilar ?? !seed))
root.querySelector<HTMLDetailsElement>("details")?.setAttribute("open", "");
if (seed) {
form.setNotice(mt("Copied").replace("{value}", `${seed.이름} ${seed.규격}`.trim()));
opt.onDirty?.(true);
}
return { root, dirty };
}
/** 창으로 띄움 — 저장하면 닫고 `done(답)` · 저장 안 하고 닫으면 물음 */
export function openMyMachineModal(
owner: string | null,
done: (got: MyMachineOne) => void,
seed?: SimilarRow,
): void {
let handle: MyMachineFormHandle | null = null;
const { close } = openModal({
title: seed ? mt("Copy_Title") : mt("New"),
closeLabel: mt("Close"),
width: "1100px",
wide: true,
beforeClose: async () => !handle?.dirty() || showConfirmDialog(mt("Leave_Ask")),
mount: (body) => {
void mountMyMachineForm(body, {
owner,
seed,
onSaved: (got) => {
close();
done(got);
},
})
.then((h) => (handle = h))
.catch((error) => {
showToast(error instanceof Error ? error.message : mt("Failed"), "error");
close();
});
},
});
}
+127
View File
@@ -0,0 +1,127 @@
"""M02 내 기계 화면 읽기 길(PLAN 821-3) — 양식 목록 · 비슷한 기계 찾기 · 내 품목 · 쓰기 없음.
GET `/api/m02/my-machine-lists` = 양식 고르기(연료 · 직종 · 전력 단가 · 시간환산) —
M01 기계 화면과 같은 값(`M01_MasterData_Machine.lists` · 그 길은 시스템 관리자만).
GET `/api/m02/layers/{층}/my-machine-find?q=&owner=` = 「비슷한 기계에서 시작」 ·
「복사해 내 기계로」 — 그 층 내 기계(앞) + M01 기계(`M01_MasterData_Machine.find`) ·
q 가 M01 원문번호와 같으면 그 기계 먼저.
GET `/api/m02/layers/{층}/my-items?owner=` = 내 품목 — 그 층 단가산출 수식 문서 재료비 줄 가운데
단가가 우리 자재에 안 이어진 것(단가 변수 연결 = 숫자 · 입력) ·
(이름 · 규격 · 단위 · 값) 묶음마다 쓰는 단가산출.
권한 = 내 기계 길과 같음(`M02_MyMachine_Router._folder` — 개인 층 · 본인 · 맡은 회사 관리자 읽기).
⚠ 등록은 `main.py`(로그인만).
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Depends
from common_util.common_util_auth import verify_session
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Session, _call
from M02_MasterTemplete.M02_MyMachine_Router import _folder
router = APIRouter(prefix="/api/m02", tags=["M02 My Machine Look"])
DIRECT = ("숫자", "입력") # 단가 변수 연결 — 값을 줄에 바로 적은 것
def _m01m():
from M01_MasterData import M01_MasterData_Machine as m01m # M01 store 가 scripts 를 길에 둔 뒤
return m01m
def lists() -> dict[str, Any]:
got = _m01m().lists()
return {k: got[k] for k in ("fuels", "jobs", "power", "hour")}
def find(q: str, limit: int = 30) -> list[dict]:
"""내 기계(앞) + M01 기계 — 줄 {키, 원문번호, 이름, 규격, 구분, 값, 방식?, 시간당?, 출처}."""
words = [w for w in q.lower().split() if w]
keep = ("키", "원문번호", "이름", "규격", "값", "방식", "시간당")
out = [
{**{k: r[k] for k in keep if k in r}, "구분": "내 기계", "출처": "개인"}
for r in mym.rows()
if all(w in f"{r.get('이름')} {r.get('규격')} {r.get('키')}".lower() for w in words)
]
exact = (m01.loaded()[1].numbers.get("EQ") or {}).get(q.strip())
head = [exact] if exact is not None and not mym.mine(exact.get("키")) else []
m01_rows = [*head, *_m01m().find(q, limit)]
seen = set()
for r in m01_rows:
if r["키"] in seen:
continue
seen.add(r["키"])
brief = {k: r[k] for k in ("키", "원문번호", "이름", "규격", "값") if k in r}
out.append({**brief, "구분": r.get("상세구분") or r.get("구분") or "", "출처": "M01"})
return json.loads(json.dumps(out[:limit], default=_m01m()._plain)) # Decimal → 수(글 아님)
def _items_of(doc: dict) -> list[dict]:
"""수식 문서 재료비 줄 — 단가 = 바로 적은 값 변수(숫자 · 입력)."""
import master_doc as md
body = ((doc.get("수식") or {}).get("문서")) or {}
vars_ = {md.var_id(v): v for v in body.get("변수") or [] if isinstance(v, dict)}
out = []
for line in body.get("금액") or []:
if not isinstance(line, dict) or line.get("비목") != "재료비":
continue
var = vars_.get(str(line.get("단가") or "").strip())
link = (var or {}).get("연결") or {}
if link.get("갈래") not in DIRECT:
continue
out.append(
{
"이름": str(line.get("이름") or var.get("이름") or ""),
"규격": str(line.get("규격") or ""),
"단위": str(line.get("단위") or ""),
"값": link.get("값"),
}
)
return out
def items() -> list[dict]:
"""내 품목 — (이름 · 규격 · 단위 · 값) 묶음 · 쓰는곳 [{키, 이름}] · 이름 차례."""
from M02_MasterTemplete import M02_Sangeun_Doc as sd
groups: dict[tuple, dict] = {}
for path in sorted((store.root() / sd.KIND).glob("SK-*.json")):
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
for it in _items_of(doc):
got = groups.setdefault(
(it["이름"], it["규격"], it["단위"], json.dumps(it["값"])), {**it, "쓰는곳": []}
)
use = {"키": doc.get("키") or path.stem, "이름": doc.get("이름") or ""}
if use not in got["쓰는곳"]:
got["쓰는곳"].append(use)
return sorted(groups.values(), key=lambda g: (g["이름"], g["규격"], g["단위"]))
@router.get("/my-machine-lists")
def get_lists() -> dict:
return lists()
@router.get("/layers/{layer}/my-machine-find")
def get_find(
layer: str, q: str = "", owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
return {"rows": _call(_folder(session, layer, owner), find, q)}
@router.get("/layers/{layer}/my-items")
def get_items(
layer: str, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
return {"rows": _call(_folder(session, layer, owner), items)}
@@ -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)
+67
View File
@@ -0,0 +1,67 @@
/* =============================================================================
* M02_MyMachine_Text.ts
* M02 내 기계 · 내 품목 화면 글(PLAN 821-3).
* ========================================================================== */
const TEXT = {
Title: "내 기계",
Items_Title: "내 품목",
New: "새 기계",
Copy_Title: "복사해 내 기계로",
Find: "이름 · 규격 · 키로 찾기",
Name: "이름",
Spec: "규격",
How: "방식",
Hourly: "시간당 경비(원)",
Uses_Count: "쓰는 곳",
Made: "넣은 날",
Edited: "고친 날",
None: "내 기계 없음 — [새 기계] 로 만듦",
Save: "저장",
Saved: "내 기계 저장 — 서버가 시간당을 다시 셈",
Delete: "지우기",
Deleted: "내 기계 지움",
Delete_Ask: "이 내 기계를 지울까? 쓰는 단가산출 {value}",
Delete_Keep: "지워도 이미 만든 단가산출은 마지막 값 유지(「지워짐」 표시)",
Back: "목록",
Close: "닫기",
Failed: "못 함",
Name_Needed: "이름 · 비어 있음",
Same: "같은 이름 · 규격 있음 — {value}",
Copied: "「{value}」 값을 복사 — 고친 뒤 [저장]",
Own_Value: "개인 값",
Own_Note:
"개인 값 = 이 기계 고유 값(가격 · 손료계수 · 연료 ℓ · 잡품 % · 조종원 수) · 단가(노임 · 유가)는 M01 값 · 단가산출엔 만들 때 복사 → 고친 뒤 「새 값」 → [단가 갱신]",
Server_Hourly: "서버 셈 시간당",
Hourly_None: "시간당 못 셈",
Uses: "쓰는 단가산출",
No_Uses: "쓰는 단가산출 없음",
Leave_Ask: "저장 안 한 고침을 버리고 닫을까?",
Mine: "내 기계",
Side_Hint: "펼치면 가운데에 목록 · 양식",
Items_Side_Hint: "펼치면 가운데에 모아 보기(읽기)",
Won: "원",
Labor: "노",
Material: "재",
Expense: "경",
바로: "바로 넣기",
품셈: "품셈 방식",
직접: "시간당 값 직접",
// 기계 고르기 창 · 줄 쌓기
Pick_Group: "내 기계",
Pick_New: "새 기계 만들기",
Pick_Direct: "시간당 값 직접 기계 — 줄 쌓기 식이 없어 못 넣음",
To_Mine: "복사해 내 기계로",
// 내 품목
Items_Note:
"단가산출 줄에 값을 바로 적은 재료(우리 자재에 안 이어진 것) — 고치기는 그 단가산출에서 · 줄 누르면 쓰는 단가산출",
Items_None: "내 품목 없음",
Value: "값(원)",
Unit: "단위",
Same_Name: "같은 이름",
Open_Sk: "쓰는 단가산출 — 누르면 그 단가산출로",
Personal_Only: "개인 층에서만 — 로그인 사용자 회사 · 개인 층이 없음",
} as const;
export type MtKey = keyof typeof TEXT;
export const mt = (key: MtKey): string => TEXT[key];
+127
View File
@@ -0,0 +1,127 @@
/* =============================================================================
* M02_MyMachine_View.ts
* M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「내 기계」 칸을 펼치면 가운데에 뜸(개인 층).
* 목록 = 이름 · 규격 · 방식 · 시간당 경비(서버 셈) · 쓰는 곳 수 · 넣은 날 · 고친 날 · 찾기 · [새 기계]
* 줄 누름 = 양식 한 장(`M02_MyMachine_Form`) · [목록] = 저장 안 한 고침이면 물음 · 저장 · 지우기 뒤 목록 다시
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { createDataTable } from "@ui/ui_template_data_table";
import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { createText } from "@ui/ui_template_text";
import { listMyMachines, type MyMachineRow } from "./M02_MyMachine_Api";
import { mountMyMachineForm } from "./M02_MyMachine_Form";
import { mt, type MtKey } from "./M02_MyMachine_Text";
import "./M02_MyMachine.css";
export interface MyMachineViewOptions {
owner: string | null;
/** 저장 안 한 고침 표시(페이지 떠남 물음) */
setDirty: (dirty: boolean) => void;
}
const day = (t: string | undefined): string => (t ?? "").slice(0, 10);
export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement {
const host = el("div", { className: "m02-mymach-view" });
let rows: MyMachineRow[] = [];
let shown: MyMachineRow[] = [];
let dirty = (): boolean => false;
const mark = (d: boolean): void => opt.setDirty(d);
const search = createInputField({ type: "search", placeholder: mt("Find") });
const hint = createText("");
const fail = el("div");
const table = createDataTable({
caption: mt("Title"),
columns: [
{ key: "이름", label: mt("Name"), width: 180 },
{ key: "규격", label: mt("Spec"), width: 120 },
{ key: "방식", label: mt("How"), width: 100 },
{ key: "시간당", label: mt("Hourly"), kind: "number", decimals: 0, width: 110 },
{ key: "쓰는곳", label: mt("Uses_Count"), kind: "number", decimals: 0, width: 70 },
{ key: "넣은날", label: mt("Made"), width: 96 },
{ key: "고친날", label: mt("Edited"), width: 96 },
],
rows: [],
empty: mt("None"),
onRowClick: (i) => shown[i] && void openForm(shown[i].키),
});
const draw = (): void => {
const words = search.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
shown = rows.filter((r) => {
const text = `${r.이름} ${r.규격} ${r.키}`.toLowerCase();
return words.every((w) => text.includes(w));
});
hint.textContent = rows.length ? `${shown.length} / ${rows.length}` : "";
table.setRows(
shown.map((r) => ({
이름: r.이름,
규격: r.규격,
방식: mt(r.방식 as MtKey),
시간당: r.시간당.ok ? (r.시간당.계 ?? null) : null,
쓰는곳: r.쓰는곳수,
넣은날: day(r.넣은날),
고친날: day(r.고친날),
})),
);
};
search.input.addEventListener("input", draw);
const newBtn = createButton({
label: mt("New"),
variant: "filled",
size: 28,
onClick: () => void openForm(),
});
newBtn.dataset.mymachNew = "";
const list = el("div", {
className: "m02-mymach-view__list",
children: [
el("div", { className: "ui-toolbar", children: [newBtn, search.root, hint] }),
createText(mt("Own_Note"), "muted"),
fail,
table.root,
],
});
async function showList(): Promise<void> {
dirty = () => false;
mark(false);
host.replaceChildren(list);
fail.replaceChildren();
try {
rows = await listMyMachines(opt.owner);
} catch (error) {
rows = [];
fail.replaceChildren(
createText(error instanceof Error ? error.message : mt("Personal_Only"), "error"),
);
}
draw();
}
async function openForm(key?: string): Promise<void> {
const slot = el("div");
host.replaceChildren(slot);
try {
const h = await mountMyMachineForm(slot, {
owner: opt.owner,
key,
onSaved: (got) => void openForm(got.줄.키),
onDeleted: () => void showList(),
onBack: async () => {
if (!dirty() || (await showConfirmDialog(mt("Leave_Ask")))) await showList();
},
onDirty: mark,
});
dirty = h.dirty;
} catch (error) {
showToast(error instanceof Error ? error.message : mt("Failed"), "error");
await showList();
}
}
void showList();
return host;
}
+1
View File
@@ -21,6 +21,7 @@ STM_SHEETS = (
"재료비목록표",
"경비목록표",
"중기목록표",
"중기사용료", # 821-4 기계 구성(못 이은 기계 → 내 기계 값)
)
EST_SHEETS = (
"단가산출총괄표",
+3
View File
@@ -444,5 +444,8 @@ def read(data: bytes) -> dict[str, Any]:
blk["줄합"] = head_sum(lines)
out[key].append({**blk, "줄": lines, "표지": tags})
out["자원"] = {f"{LINK[k[0]]}:{k[1]}": v for k, v in res.items()}
from M02_MasterTemplete.M02_San_MyMachine import est_makes
out["기계구성"] = est_makes(t) # 821-4 못 이은 기계 → 내 기계 값
out["표지"] = sorted(warn) + ["옵션(datestoption.opt) · 원가 서식(wongadat) 안 읽음"]
return out
+312
View File
@@ -0,0 +1,312 @@
"""불러오기 못 이은 기계 → 내 기계 자동 생성(PLAN 821-4 · 요구서_796 4 장 · 방향 장부 Q1 · S) — 서버 단독.
구성 읽기(그 파일 값 → 우리 틀 값 · 경쟁사 조종원 노임 환산 ×16/12 ×25/20 은 안 가져옴):
EST 원파일 MachMainDat(`est_makes` · 올릴 때 둘 꼴 칸 `기계구성`) · EST 엑셀 「기계경비」 ·
STmate 엑셀 「중기사용료」(`xlsx_makes` · 둔 산근 시트에서 그때 읽음) → {열쇠: 구성}
구성 = {이름, 규격, 손료 [[계수 10⁻⁷, 가격 원, 달러?]], 주연료종류, 주연료 ℓ, 잡재료율 %, 조종원 인,
조종원직종(글), 전력 kWh} · 열쇠 = 자원 코드 · 「이름|규격」(표기 정규화 `M02_Machine_Find.norm`).
고름(`plan`) = 가져온 로직 호표 기계 줄 가운데 이음 없음 · 찾기 판정 「못 찾음」(별칭 · 표기 · 같은 파일 대조 뒤 ·
후보 여럿 = 사람이 고름이라 안 만듦) · 조합 기계 = 조각마다(첫 조각 = 첫 손료 + 연료 · 조종원 ·
다음 조각 = 그 차례 손료만 · 구성 없는 조합 = 안 만듦).
몸 = 구성 있음 → 방식 바로(가격 천원 · 달러 = × 환율 FX000001 + 비고 · 손료계수 · 주연료 → 우리 유가 이름 ·
잡품 % · 조종원 → 우리 직종 원문번호 · 전력) · 못 이은 연료 · 직종 = 칸 비움 + 비고 「확인 필요」 ·
구성 없음 → 직접(그 파일 시간당 노 · 재 · 경).
잇기(`attach` · 층 저장 길 `M02_Sangeun_FromHopyo.make` 만) = 그 층 내 기계 같은 이름 · 규격이면 그 줄 · 없으면
새로(`M02_MyMachine.create`) · 바로 = 기계 줄 이음 키 · 로직 줄 = 시간당 부름 글 / 직접 = 이음 없이 자원
`내기계` 키(셈 = 그 파일 값 · [우리 단가로] = 내 기계 시간당 · `M02_Sangeun_Imported.row_of`) ·
원본 `내기계` [{키, 이름, 규격, 새로}] · 개인 층 아님 = 안 만듦(원본 `내기계예정` [{이름, 규격}] 만).
금액(엑셀 단가 그대로)은 줄 값을 안 건드려 그대로.
"""
from __future__ import annotations
import re
from typing import Any
from M02_MasterTemplete.M02_Machine_Find import NONE, norm
NOTE = "불러오기 자동 생성 — {file} · 값 확인 필요"
CHECK = "확인 필요"
COST = {"L": "노무비", "M": "재료비", "S": "경비"}
def key_of(name: Any, spec: Any) -> str:
return f"{norm(name)}|{norm(spec)}"
def _num(v: Any) -> float | None:
if isinstance(v, bool) or v is None:
return None
if isinstance(v, (int, float)):
return float(v)
s = str(v).strip().replace(",", "")
return float(s) if re.fullmatch(r"-?\d+(?:\.\d*)?|-?\.\d+", s) else None
def _blank(name: str = "", spec: str = "") -> dict[str, Any]:
return {"이름": name, "규격": spec, "손료": []}
def _fuel(make: dict, kind: Any, litre: Any) -> None:
"""주연료 종류 · ℓ(처음 것) — 종류 「전력」 = 전력 kWh 칸."""
if re.sub(r"\s", "", str(kind or "")) == "전력" and (_num(litre) or 0) > 0:
make["전력"] = _num(litre)
elif make.get("주연료") is None and (_num(litre) or 0) > 0:
make["주연료종류"], make["주연료"] = str(kind or "").strip(), _num(litre)
# ── 구성 읽기 ─────────────────────────────────────────────────────────
def est_makes(t: dict[str, list[dict]]) -> dict[str, dict]:
"""EST 원파일 MachMainDat → {자원 코드: 구성} — TitleCode 머리 다음 줄부터 빈 줄까지 ·
Index -3 손료(GN 계수 · TU 가격 · 단위 「$」 = 달러) · -2 「주연료」(Size 종류) · 연료 이름 줄(선박용경유 등 ℓ) ·
잡품 · 잡유 % · 전력 KWH · -1 조종원(직종 · GN 인)."""
heads = {r.get("Code"): r for r in t.get("MachTitleDat", [])}
out: dict[str, dict] = {}
cur = None
for r in t.get("MachMainDat", []):
if r.get("TitleCode"):
head = heads.get(r["TitleCode"]) or r
cur = out.setdefault(
r["TitleCode"], _blank(head.get("Name") or "", head.get("Size") or "")
)
elif not r.get("Name"):
cur = None
elif cur is not None:
i, gn, word = r.get("Index"), r.get("GN"), re.sub(r"\s", "", str(r["Name"]))
unit = str(r.get("Unite") or "").strip().upper()
if i == -3 and gn:
cur["손료"].append([gn, r.get("TU"), unit == "$"])
elif i == -2 and word == "주연료":
_fuel(cur, r.get("Size"), gn)
elif i == -2 and word in ("잡품", "잡유") and cur.get("잡재료율") is None:
cur["잡재료율"] = gn
elif i == -2 and word == "전력":
cur["전력"] = gn
elif i == -2 and unit == "ℓ":
_fuel(cur, r["Name"], gn)
elif i == -1 and (gn or 0) > 0 and not cur.get("조종원"):
cur["조종원"], cur["조종원직종"] = gn, str(r["Name"]).strip()
return {k: v for k, v in out.items() if v["손료"]}
def _est_sheet(rows: list[list]) -> dict[str, dict]:
"""EST 엑셀 「기계경비」 — 「N. 이름 · 규격」 머리 아래 빈 줄까지: 「손료 가격 ₩×(달러 $×) 계수 × 10(-7)」 ·
「주연료 종류 ℓ」 · 「잡품 주연료의 % 」 · 「직종 인수 인」 · 「전력 … kWh」."""
out: dict[str, dict] = {}
cur = None
for row in rows:
v = [c for c in row if c not in (None, "")]
head = re.match(r"\d+\.\s*(.+)", str(v[0])) if v else None
if head:
spec = v[1] if len(v) > 1 and isinstance(v[1], str) else ""
cur = out.setdefault(key_of(head.group(1), spec), _blank(head.group(1).strip(), spec))
elif not v:
cur = None
elif cur is not None:
word = re.sub(r"\s", "", str(v[0]))
mark = next((x for x in v if x in ("₩×", "$×")), None)
if "손료" in word and mark and (at := v.index(mark) + 1) < len(v):
cur["손료"].append([_num(v[at]), _num(v[1]), mark == "$×"])
elif word == "주연료" and len(v) > 2:
_fuel(cur, v[1], v[2])
elif word in ("잡품", "잡유") and len(v) > 2 and cur.get("잡재료율") is None:
cur["잡재료율"] = _num(v[2])
elif word == "전력" and len(v) > 2:
cur["전력"] = _num(v[1]) if _num(v[1]) is not None else _num(v[2])
elif len(v) > 2 and v[2] == "인" and (_num(v[1]) or 0) > 0 and not cur.get("조종원"):
cur["조종원"], cur["조종원직종"] = _num(v[1]), str(v[0]).strip()
elif len(v) > 3 and str(v[3]).upper() in ("ℓ", "L") and (_num(v[2]) or 0) > 0:
_fuel(cur, v[0], v[2])
return {k: m for k, m in out.items() if m["손료"] and None not in m["손료"][0][:2]}
def _stm_sheet(rows: list[list]) -> dict[str, dict]:
"""STmate 엑셀 「중기사용료」 — 「제 N 호표」 뒤 머리 [이름, 규격, 시간, 코드] · 손료 [이름, 규격, 계수(천원당),
「천원」, 가격 천원 …] · 조종원 [직종, 인, 「인」 …] · 연료 [이름, 등급, ℓ, 「L」 …] · 잡품 [잡품, 주연료의%, % …] ·
「합 계」 끝 → {코드 · 이름|규격: 구성}(손료계수 = 계수 × 10⁴ · 가격 = 천원 × 1000 원)."""
out: dict[str, dict] = {}
cur = None
for row in rows:
v = [c for c in row if c not in (None, "")]
first = re.sub(r"\s", "", str(v[0])) if v else ""
if re.fullmatch(r"제\d+호표", first):
cur = {}
continue
if cur is None or not v:
continue
if first == "합계":
cur = None
continue
if (
not cur
and len(v) >= 4
and str(v[2]) in ("시간", "hr")
and re.fullmatch(r"X\d+", str(v[-1]))
):
cur.update(_blank(str(v[0]), str(v[1])))
out[str(v[-1])] = out[key_of(v[0], v[1])] = cur
elif not cur:
continue
elif len(v) > 4 and str(v[3]) == "천원" and _num(v[2]) and _num(v[4]):
cur["손료"].append([_num(v[2]) * 10_000, _num(v[4]) * 1000, False])
elif len(v) > 2 and str(v[2]) == "인" and (_num(v[1]) or 0) > 0 and not cur.get("조종원"):
cur["조종원"], cur["조종원직종"] = _num(v[1]), str(v[0]).strip()
elif first == "잡품" and len(v) > 2 and cur.get("잡재료율") is None:
cur["잡재료율"] = _num(v[2])
elif len(v) > 3 and str(v[3]).upper() in ("L", "ℓ"):
_fuel(cur, v[0], v[2])
elif len(v) > 3 and str(v[3]).upper() == "KWH":
cur["전력"] = _num(v[2])
return {k: m for k, m in out.items() if m.get("손료")}
def xlsx_makes(sheets: dict[str, list]) -> dict[str, dict]:
"""엑셀 산근 시트 → 구성(EST 「기계경비」 · STmate 「중기사용료」 · 없음 = {})."""
if sheets.get("기계경비"):
return _est_sheet(sheets["기계경비"])
if sheets.get("중기사용료"):
return _stm_sheet(sheets["중기사용료"])
return {}
# ── 고름 · 몸 ─────────────────────────────────────────────────────────
def _todo(logic: dict) -> list[tuple[dict, int]]:
"""(줄, 조각 차례) — 기계 줄 · 이음 없음 · 찾기 판정 「못 찾음」."""
out, n = [], 0
for line in logic.get("호표") or []:
res = line.get("자원") or {}
if res.get("갈래") != "기계":
continue
n = n + 1 if res.get("조합") else 0
if line.get("이음") or res.get("조합식") or res.get("내기계"):
continue
if (res.get("찾기") or {}).get("판정") == NONE:
out.append((line, n))
return out
def _make_of(res: dict, makes: dict) -> dict | None:
return makes.get(str(res.get("코드") or "")) or makes.get(
key_of(res.get("이름"), res.get("규격"))
)
def _kinds(make: dict, notes: list[str]) -> dict[str, Any]:
"""연료 · 조종원 → 우리 유가 이름 · 직종 원문번호(못 이음 = 칸 비움 + 비고)."""
from M01_MasterData import M01_MasterData_Store as m01
index, out = m01.loaded()[1], {}
fuel, litre = str(make.get("주연료종류") or ""), make.get("주연료") or 0
if litre:
ours = next(
(n for n in ("경유", "휘발유", "실내등유") if n == re.sub(r"\s", "", fuel)), None
)
ours = ours or next((n for n in ("경유", "휘발유", "실내등유") if fuel.startswith(n)), None)
if ours and f"{ours}:전국평균" in (index.numbers.get("MO") or {}):
out |= {"주연료": litre, "주연료종류": ours}
out |= {"잡재료율": make["잡재료율"]} if make.get("잡재료율") else {}
else:
notes.append(f"주연료 「{fuel}」 {litre:g}ℓ — 우리 유가에 못 이음({CHECK})")
if make.get("조종원"):
job = norm(make.get("조종원직종"))
hit = [k for k, r in (index.numbers.get("LB") or {}).items() if norm(r.get("이름")) == job]
if len(hit) == 1:
out |= {"조종원": make["조종원"], "조종원직종": hit[0]}
else:
notes.append(
f"조종원 「{make.get('조종원직종')}」 {make['조종원']:g}인 — 우리 직종에 못 이음({CHECK})"
)
if make.get("전력"):
out["전력"] = make["전력"]
return out
def body(make: dict | None, n: int, line: dict, file: str) -> dict | None:
"""내 기계 몸(`M02_MyMachine.create`) — 구성 있음 = 바로 · 없음 = 직접(조합 조각 = 안 만듦)."""
from M01_MasterData import M01_MasterData_Store as m01
res = line.get("자원") or {}
found = res.get("찾기") or {}
name = str(found.get("이름") or res.get("이름") or line.get("이름") or "").strip()
spec = str((found.get("규격") if found.get("이름") else res.get("규격")) or "").strip()
head = {"이름": name, "규격": spec}
notes = [NOTE.format(file=file)]
if make is None:
if n or res.get("조합첫"):
return None
got = res.get("단가") or {}
pack = line.get("값묶음") or {}
hour = {v: _num(got.get(c)) or _num(pack.get(v)) or 0 for c, v in COST.items()}
return {**head, "방식": "직접", "시간당": hour, "비고": " · ".join(notes)}
if n >= len(make["손료"]):
return None
coef, price, usd = make["손료"][n]
if usd:
rate = float(m01.loaded()[1].index["FX"]["FX000001"]["값"])
notes.append(f"가격 ${price:g} × 환율 {rate:,} 원/USD")
price = price * rate
vals: dict[str, Any] = {"가격": round(float(price) / 1000, 4), "손료계수": coef}
if n == 0:
vals |= _kinds(make, notes)
return {**head, "방식": "바로", "값": vals, "비고": " · ".join(notes)}
def plan(logic: dict, makes: dict, file: str) -> list[dict]:
"""만들(또는 이을) 내 기계 [{줄, 열쇠, 몸}] — 글 · 층 안 건드림."""
out = []
for line, n in _todo(logic):
got = body(_make_of(line.get("자원") or {}, makes), n, line, file)
if got:
out.append({"줄": line, "열쇠": key_of(got["이름"], got["규격"]), "몸": got})
return out
def preview(out: dict, makes: dict) -> None:
"""미리 보기 · 분석 길 — 원본 `내기계예정` [{이름, 규격}](안 만듦)."""
todo = plan(out["로직"], makes, str(out["원본"].get("파일") or ""))
seen = {t["열쇠"]: {"이름": t["몸"]["이름"], "규격": t["몸"]["규격"]} for t in todo}
if seen:
out["원본"]["내기계예정"] = list(seen.values())
# ── 잇기(층 쓰기) ─────────────────────────────────────────────────────
def link(line: dict, row: dict) -> None:
"""가져온 줄 → 내 기계 — 바로 = 기계 줄 키 · 로직 줄 시간당 부름 글 / 직접 = 자원 `내기계` 만."""
from M02_MasterTemplete.M02_San_Link import hourly_text
line["자원"]["내기계"] = row["키"]
if row.get("방식") == "직접":
return
line["이음"] = hourly_text(row["키"]) if line.get("종류") == "로직" else row["키"]
def attach(out: dict, makes: dict) -> list[dict]:
"""층 저장 길 — 못 이은 기계 줄을 그 층 내 기계(같은 이름 · 규격) · 새 내 기계에 이음 · 원본 `내기계`."""
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
src = out["원본"]
todo = plan(out["로직"], makes, str(src.get("파일") or ""))
if not todo:
return []
try:
mym._user()
except store.StoreError: # 개인 층 아님 — 안 만듦(예정만)
preview(out, makes)
return []
done: dict[str, dict] = {}
for one in todo:
if one["열쇠"] not in done:
have = next(
(r for r in mym.rows() if key_of(r.get("이름"), r.get("규격")) == one["열쇠"]), None
)
row = have or mym.create(one["몸"])["줄"]
done[one["열쇠"]] = {"줄": row, "새로": have is None}
link(one["줄"], done[one["열쇠"]]["줄"])
made = [
{**{k: d["줄"][k] for k in ("키", "이름", "규격", "방식")}, "새로": d["새로"]}
for d in done.values()
]
src["내기계"] = made
src.pop("내기계예정", None)
return made
+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)
+17 -6
View File
@@ -25,6 +25,7 @@ from M02_MasterTemplete import M02_Doc_FromNative as fn
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_San_MyMachine as mm
DIRNAME = "산근"
RELOAD = "가져온 식 자료 없음 — 엑셀을 다시 올림"
@@ -109,7 +110,15 @@ def has(folder: str | Path, key: str) -> bool:
def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
"""HP 단산 블록 하나 → `{로직, 원본}`(원파일 우선 · 아니면 엑셀 읽기기 같은 길 · 이음 = M01 키)."""
"""HP 단산 블록 하나 → `{로직, 원본}`(원파일 우선 · 아니면 엑셀 읽기기 같은 길 · 이음 = M01 키) ·
못 이은 기계 = 원본 `내기계예정`(821-4 · 안 만듦)."""
out, makes = _emit(folder, key, no)
mm.preview(out, makes)
return out
def _emit(folder: str | Path, key: str, no: int) -> tuple[dict[str, Any], dict]:
"""(블록 `{로직, 원본}`, 그 파일 기계 구성 — 원파일 둘 꼴 `기계구성` · 엑셀 = 둔 시트 · 821-4)."""
hopyo_store.read(folder, key) # 없는 HP = 404
path = _path(folder, key)
try:
@@ -118,13 +127,13 @@ def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
raise store.StoreError(409, RELOAD) from e
native = saved.get("원파일")
if isinstance(native, dict) and int(no) in fn.numbers(native): # 원파일 우선(810-2)
return fn.emit(native, int(no))
return fn.emit(native, int(no)), native.get("기계구성") or {}
if not saved.get("시트"):
raise store.StoreError(404, f"없는 단산{no}")
excel = _excel_of(path, saved)
if int(no) not in fn.numbers(excel):
raise store.StoreError(404, f"없는 단산{no}")
return fn.emit(excel, int(no))
return fn.emit(excel, int(no)), mm.xlsx_makes(saved["시트"])
def _inner(
@@ -173,7 +182,7 @@ def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) ->
안쪽 산근 블록은 형제 SK 를 먼저 만들어 참조(797-1)."""
from M02_MasterTemplete import M02_Sangeun_Imported as imp
out = emit(source, key, no)
out, makes = _emit(source, key, no)
out["원본"]["가져온때"] = utc_now_text() # 원문 패널 「어디서 왔나」(807-1)
_link_inner(source, key, no, out, _trail)
try: # 소요량 · 계수 후보 표지(797-2 · 사람 확인) — 못 셈 = 표지 없이 만듦
@@ -182,13 +191,15 @@ def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) ->
out["원본"]["표지"] += qty.flags(out["로직"])
except Exception: # noqa: BLE001 — 역색인 짓기 실패가 가져온 식 만들기를 막지 않음
pass
made = mm.attach(out, makes) # 821-4 못 이은 기계 = 내 기계(그 층 같은 것 · 새로)
mine = {"내기계": made} if made else {}
title = str(name or "").strip() or str(out["로직"].get("이름") or f"단산{no}")
try:
got = imp.create(out["로직"], out["원본"], title)
return {**got, "다시씀": False}
return {**got, "다시씀": False, **mine}
except store.StoreError as e:
same = (e.detail or {}).get("같은것") if isinstance(e.detail, dict) else None
if e.status != 409 or not same:
raise
got = imp.create(out["로직"], out["원본"], title, use=same[0]["키"])
return {**got, "다시씀": True}
return {**got, "다시씀": True, **mine}
+19 -4
View File
@@ -63,9 +63,10 @@ def _mf() -> Any:
def _whole() -> Any:
from M01_MasterData import M01_MasterData_Store as m01
"""M01 + 지금 층 내 기계(821-4 · 못 이은 기계를 이은 개인 키 `EQP…`)."""
from M02_MasterTemplete import M02_MyMachine as mym
return m01.loaded()[1]
return mym.whole()
def is_imported(doc: Any) -> bool:
@@ -80,7 +81,7 @@ def hourly_of(key: str) -> dict:
"""이음 기계(EQ) 시간당 노 · 재 · 경 — M01 판이 바뀌면 다시(캐시 = M01 묶음 하나에 매임)."""
from M02_MasterTemplete import M02_Machine_Hourly as machine
whole = _whole()
whole = _whole() # 내 기계 고침 = 색인이 바뀜 → 다시
if _HOURLY["whole"] is not whole:
_HOURLY.update(whole=whole, rows={})
if key not in _HOURLY["rows"]:
@@ -112,6 +113,8 @@ def row_of(doc: Any) -> dict | None:
"참조"
): # 안쪽 산근 = 형제 SK 1 단위 값(셈이 채움 · 엑셀 값묶음은 얼린 사본으로 둠)
item |= {"요소": None, "값묶음": None, "기준": "우리" if ours else "엑셀"}
if ours and not link and (mine := (one.get("자원") or {}).get("내기계")):
_direct(item, one, mine) # 821-4 시간당 값 직접 내기계 = 그 줄 값
if ours and link:
item.pop("단가", None)
item.pop("값묶음", None)
@@ -124,6 +127,18 @@ def row_of(doc: Any) -> dict | None:
return {**row, "키": doc.get("키"), "호표": items}
def _direct(item: dict, one: dict, key: str) -> None:
"""[우리 단가로] 이음 없이 이은 내 기계(시간당 값 직접 · 821-4) — 단가 = 그 비목 · 값묶음 = 셋 ·
지운 내 기계 = 엑셀 값 그대로."""
got = hourly_of(key)
if not got.get("ok"):
return
if "단가" in item and one.get("비목") in COSTS:
item["단가"] = sd.plain(Decimal(str(got[one["비목"]])))
elif "값묶음" in item:
item["값묶음"] = {k: sd.plain(Decimal(str(got[k]))) for k in COSTS}
def base_row(doc: dict) -> dict[str, Any]:
"""바탕 없는 SK 바탕 줄(`sd.empty_row`) — 가져온 식 = 그 줄 · 아니면 입력 · 줄 없음."""
return row_of(doc) or {
@@ -314,7 +329,7 @@ def unlinked(doc: dict) -> list[dict]:
{"자리": ids[n], "이름": one.get("이름") or "", "까닭": link.why(one)}
| ({"견적": True} if (one.get("자원") or {}).get("갈래") == "고정" else {})
for n, one in enumerate(doc[KEY]["로직"].get("호표") or [])
if not one.get("이음") and not one.get("참조")
if not one.get("이음") and not one.get("참조") and not (one.get("자원") or {}).get("내기계")
]
@@ -27,6 +27,10 @@ export interface ImportedMeta {
갈래?: string;
엑셀금액?: Money4;
글줄?: { 행?: number | null; 글?: string; 자리?: string; 값?: number | null }[];
/** 못 이은 기계 → 내 기계(821-4 · 새로 = 이번에 만듦 · 아니면 그 층 같은 것) */
내기계?: { 키: string; 이름: string; 규격?: string; 방식?: string; 새로?: boolean }[];
/** 개인 층 밖에서 연 것 — 만들어질 내 기계(안 만듦) */
내기계예정?: { 이름: string; 규격?: string }[];
};
검산?: {
같음: boolean;
+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,35 @@
- 안 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 과 같은 규칙).
## V. 요구 797 조사(기계 시간당 식)
- 기계 시간당 식 = 목록 방식: 식마다 「기계」 고르기 목록(원문번호)이 있고 그 목록에 든 기계만 그 식으로 셈 — GC000267(굴착기 무한궤도 9) · 268(손료 보정 없는 기종 209) · 269(손료 보정 129) · 270(+집게 6) · 271(+브레이커 9) · 272(조종원 없는 기종 59) · 275(조종원직종 입력 39) · 276(연료 없는 기종 8) · 273(손료만 327) · 274(손료만 · 보정 121) · GF000145(덤프 1 톤) · GF000337(체인톱).
- M01 [새 기계] 로 만든 줄은 어느 목록에도 없어 시간당 「—」(내 기계는 821-1 이 채운 칸으로 식을 고름).
- 손료만 식(GC000273 · 비고 「원문 운전경비 표에 주연료 · 조종원이 없는 기종 — 동력비는 원문대로 별도」) 목록 327 대 가운데 **기계표에 연료나 조종원이 있는 것 33 대**: 타워크레인 5 · 크러셔(이동식) 4 · 골재세척설비 · 이동식 임목파쇄기 · 점보드릴 2(조종원 1 · 직종 빈 칸 · 연료 「-」 또는 kW) · 아스팔트 믹싱플랜트 5(연료 + 조종원 2 · 직종 있음) · 아스팔트 스프레어 2 · 현장가열 표층재생기(조종원 7) · 펌프 준설선 2(연료 + 조종원 2) · 전동 기계 9(TBM · 자동세륜기 2 · 모르타르 펌프 3 · 모르타르 믹서 · 양수기 · POWER TLOWEL — 주연료 칸에 kW 글 또는 휘발유) → 시간당 경비에 연료 · 조종원 · 전력이 빠짐.
- 조종원 값의 출처 = 건설품셈 8-4 운전경비 표 「조종원(인/일)」 열(타워크레인 8-4-3 「- | - | 1」 · 주⑥ 「타워크레인의 연료 소모량은 별도 계상」 · 크러셔(이동식) 8-4-6 「- | - | 1」 · 점보드릴 「135㎾ | 6 | 1」) · 원문 표에 직종 열은 없음.
- 원문 8-1-6 3: 운전경비 = 연료 · 전력 · 윤활유 + 운전 노무비 + 소모품비 · 8-1-7 4: 손료 산정에 동력이 포함되지 않은 경우 해당 엔진 · 모터의 손료 · 운전경비 적용 · 전동기 부하율 규정은 없음.
- POWER TROWEL 은 품셈에 있음(8-4-8 「7994-0050 POWER TROWEL 3.73㎾ 휘발유1 10 -」) · 우리 기계표 EQ000534 이름이 「POWER TLOWEL」(오자) → 별칭 「파워트로웰」 넣음(가져온 「파워트로웰 3.73kW」 = 품셈 기계로 이음).
- 안(사용자 「진행」 전): ㉰ 「시간당 값 직접」 식(노 · 재 · 경 = 기계 줄 값) ㉱ 전력 줄(전력 kW × 전력 단가 · 잡재료 % 있으면 같이) ㉲ M01 새 기계 = 저장 때 꼴에 맞는 식 목록에 등록 + 상세 페이지에 쓰는 식 표시 ㉳ 위 33 대를 맞는 식으로 옮김(직종 빈 칸 13 대는 직종 확인 필요).
@@ -993,3 +993,60 @@ 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 · 사용자 · 상세 계획서 작성하고 착수 진행)
상세계획서 작성하고 착수 진행해
## 요구 797 (11-12 · 사용자 · 1 사본 빈틈 있으면 안 됨 · 2 시간당 값 계산 로직을 만들어 넣을 수 없는지 확인 · 3 M01 은 기초 데이터 · 기초 로직 공간 — 시간당 경비가 없다는 건 문제 · 더 설명 · 4 조종원 1 인 14 대가 어디서 나온 정보인지 · 5 (전력) 시간당 계산식을 만들 수 없는지 · 6 옛 시험은 알아서 수정 · 삭제)
1번 사본 빈틈이 있으면 안됨.
2번 시간당 값 계산로직을 만들어 넣을수는 없는지 확인
3. m01은 기초 데이터와 기초로직을 다루는 공간임 시간당 경비가 없다는건 문제인것같은데 좀더설명 필여
4.조종원이 1인인 14대가 어디서 나온 정보인지 알려줘야 내가 알수 있음.
5. 시간당 계산식을 만들수 없는지.
6. 옛시험은 너가 알아서 수정이나 삭제
## 요구 798 (11-12 · 사용자 · 1 진행 · 2 13 대 — 원문에 조종원은 있는데 직종을 모른다는 말인지 · 그렇다면 다른 기계들은 어떻게 들어가 있는지 · 3 오자는 수정)
1. 진행
2. 13대의 조종원이 있다는 원문에는 있어야하는데 조종원이라고 해서 모른다는 말이지? 그렇다면 다른 기계들은 어떻게 들어가있는지 알려줘.
3. 오자는 수정
## 요구 799 (11-12 · 사용자 · 13 대 조종원 직종 — 브레인 판단이 맞아 보임)
너의 판단이 맞아보임.
## 요구 800 (11-12 · 사용자 · 1 SK-000048 — 다른 경쟁사 프로그램에서 가져오기 시험 중 · 기계를 변경했는데 산출근거 데이터가 업데이트 안 됨 · 조사 시작 · 2 단가산출 기계 우측 「복사해 내 기계로」 는 필요 없어 보임 · 시스템 관리자를 뺀 회사관리자 · 일반사용자는 항목 추가로 만들어 쓰는 것이 어떤지 검토)
1. 추가 확인사항 sk-000048 항목은 내가 다른 경쟁사의 프로그램에서 가져오기 테스트를 하고 있음. 정상적으로 가져온것같고 여기서 기계를 변경했는데 산출근거의 데이터가 업데이트 안됨. 조사 시작
2. 단가산출에서 기계의 우측에 복사해 내 기계로 항목이 있음. 복사해서 내기계로 가져갈 필요가 없어보임. 기계의 시스템 관리자를 제외한 회사관리자와 일반사용자는 에서 항목추가로 추가하는게 만들어서 하는게 어떤지. 검토 .
## 요구 801 (11-12 · 사용자 · 1 그 말이 아니라 단가산출 상세 화면에서 0.7㎥ 를 1.0㎥ 로 바꿨는데 우측 산출 로직 값이 안 바뀌어 확인하라는 것 · 다시 확인 · 2 새 기계 추가의 직종 = 내용이 많아 드롭다운에 검색 필요 · 검색되면 적당히만 보여 줌 · 지정된 경우 다시 열면 선택된 항목 주변을 보여 주는 기능은 유지)
1. 그말이 아니라 내가 단가산출 상세화면에 접근해서 0.7m2 을 1.0m2으로 변경해봤는데 우측 산출로직에서 값 변경이 없어서 확인하라고 한거야. 다시 확인
2. 새 기계 추가에서 직종의 경우 내용이 많아 드롭다운 항목들 중 검색도 있어야함. 검색이 되면 많은 항목을 보여줄필요없으니 적당히만 보여주고 추가로 지금은 지정된 경우 다시 열면 선택된 항목이 주변을 보여줌. 이 기능은 유지.
## 요구 802 (11-12 · 사용자 · M02 — 내 기계 · 내 품목을 「개인관리」 컨테이너로 병합 · 안에 단추 둘 · 이름 「기계」 · 「자재」 · 「구조물 조합」 컨테이너를 「일위대가」 바로 아래로)
M02에서 내 기계와 내 품목은 개인관리 컨테이너로 병합함. 내부에는 2개의 버튼으로 해당페이지를 열면됨. 내 기계의 이름은 기계, 내 품목은 자재로 변경, 그리고 일위대가 바로 아래에 구조물 조합 컨테이너가 들어보게 변경
@@ -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. 안 하는 것
- 재료 개인 표 · 회사 층 화면 · 마스터 승격 제안 흐름 · 화면 속도 · 설명서 고침.
+10
View File
@@ -578,6 +578,16 @@ 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,
)
# 내 기계 화면 읽기(PLAN 821-3) — 양식 목록 · 비슷한 기계 찾기 · 내 품목 · 로그인 · 층 권한은 길 안에서
app.include_router(
__import__("M02_MasterTemplete.M02_MyMachine_Look", fromlist=["router"]).router,
dependencies=protected,
)
# 단가산출(SK) 수식 문서 길(PLAN 802-4a) — 로그인 · 층 권한은 길 안에서
app.include_router(
__import__("M02_MasterTemplete.M02_Doc_Router", fromlist=["router"]).router,
@@ -97,21 +97,6 @@ last_updated: 2026-08-15
| 환율·유가 원천과 스냅샷 | ECOS·오피넷 실사 2026-08-14 | [원가_기초데이터_원천 §4](원가_기초데이터_원천.md) |
| ESTX 기계경비 조립 관측 | 2026-07 배포본 재검증 | [원가_기초데이터_원천 §3](원가_기초데이터_원천.md) |
## 7. 품셈에 없는 소형·보조 기계 — EST 구성 기준 · 가격 갱신 필요
## 7. 품셈에 없는 기계 = 사용자 내 기계(요구서 796)
현행 품셈 기계 목록(발전기 25㎾~ 등)에 없는 기계를 EST 단가 구성대로 우리 기계표에 둔 줄. 손료계수 · 연료 · 잡품 · 조종원 = 품셈 값(같은 계열 차용) · 가격 = EST 씨앗(임시) — 조달청 · 행안부 건설기계 표준시장가격 · 제조사 값으로 바꿀 것.
| 기종 | 규격 | 손료계수(10⁻⁷/hr) | 연료 · 잡품 · 전력 | 조종원 | 가격 씨앗(천원) | 시간당 식 | 출처 |
|---|---|---|---|---|---|---|---|
| 발전기 | 5.5㎾ | 2,362(발전기 계열) | 경유 0.625ℓ · 11 % | 일반기계운전사 1 | 1,063 | GC000268 | EST · 2016 사방품셈(안) 같은 꼴 |
| 발전기 | 7㎾ | 2,362 | — | — | 1,828.9 | GC000273(손료만) | 2016 사방품셈(안) 기계경비 |
| 발전기 | 10㎾ | 2,362 | — | — | 3,213 | GC000273 | 2016 사방품셈(안) 기계경비 |
| 대형 브레이커 | 0.18㎥ | 6,601(0.2㎥ 계수) | — | — | 4,559(0.2㎥ 값) | GC000273 | 굴착기 부속 · 우리 0.2㎥ 줄 |
| 에어 호스 | 1.91㎝ | 5,625 | — | — | 105 | GC000273 | EST |
| 모노펌프 | 5-20㎥/hr | 4,346 | — | — | 4,900 | GC000273 | EST |
| 파워트로웰 | 3.73㎾ | 5,313 | 휘발유 1.0ℓ · 10 % · 전력 3.73㎾h | — | 2,680 | GC001195 | EST |
| 콘크리트펌프용 파이프(압송관) | 150㎜ × 1 · 2 · 3m · 곡관 45 · 60 · 90° | 4,500 | — | — | $25 · 37 · 46 · 67 · 84 · 103 × 환율 | GC000273 | EST($ 표기) · 환율 FX000001 |
- 손료 = 버림(가격 × 1,000 × 손료계수 ÷ 10⁷) · 조종원 = 하루 노임 × 1/8(품셈 본문 · EST 의 16/12 · 25/20 뺌).
- 파워트로웰 잡품 = 주연료 줄의 % (전력 줄엔 안 붙임).
- 파이프 가격 = 달러 표기 → 환율 적용값 · 원화 갱신 필요.
품셈 기계표(M01)는 품셈 정본만 둠. 품셈에 없는 기계(발전기 5.5 · 7 · 10㎾ · 소형 브레이커 · 에어 호스 · 모노펌프 · 파워트로웰 · 압송관 등)는 사용자 개인 층의 「내 기계」 — 가격 · 손료계수 · 주연료 ℓ · 잡품 % · 조종원 인 · 직종(우리 노임) · 전력을 사용자가 넣고 시간당 경비는 M01 기계와 같은 식(손료 = 버림(가격 × 1,000 × 손료계수 ÷ 10⁷) · 조종원 = 하루 노임 × 1/8)으로 셈. 경쟁사 불러오기에서 끝내 못 이은 기계는 그 파일 구성(EST 원파일 구성표 · 엑셀 기계경비 · STmate 중기사용료)으로 내 기계를 자동으로 만들고(경쟁사 조종원 노임 환산 ×16/12 · ×25/20 은 안 가져옴 · 달러 표기 = 환율 적용 · 비고 「값 확인 필요」) · 구성 없는 파일은 시간당 값을 그대로 담음.
File diff suppressed because one or more lines are too long
@@ -1,241 +0,0 @@
"""품셈에 없는 소형 · 보조 기계를 우리 기계표 줄로(PLAN 820-1 · 요구 785 ~ 787 · 방향 장부 Q1 · R).
EST 단가 구성 → 우리 줄 — 손료계수 · 연료 · 잡품 · 조종원 = 품셈 값(발전기 계열 2,362 · 대형 브레이커 6,601) ·
가격 = EST 씨앗(출처 「자체」 · 비고 「갱신 필요」) · 콘크리트펌프용 파이프 = 달러값 × 한국은행 환율(FX000001).
계산식:
손료만(브레이커 0.18 · 에어 호스 · 모노펌프 · 파이프 · 발전기 7 · 10㎾) = GC000273 고르기에 더함
발전기 5.5㎾(경유 · 잡품 · 일반기계운전사) = GC000268 고르기에 더함
→ 고르기 더하기는 `build_기계목록_빠진기계_814_1`(로직 · 수식 문서 목록 같이)
파워트로웰(휘발유 · 잡품 · 전력 · 조종원 없음) = 새 기계경비식 GC001195 — 기계 줄 값을 바로 읽음
(`M02_Machine_Hourly._own` 이 고름) · 잡품 = 주연료 줄의 % (전력 줄엔 안 붙임)
더하기만 — 다른 줄 · 서식 그대로 · 두 번 돌려도 같음.
쓰기: venv python resources/master_data/scripts/build_기계_소형보조_820_1.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
DATA = HERE.parent
ROOT = DATA.parents[1]
MACHINE = DATA / "기계.json"
BOOK = DATA / "_키대장.json"
LOGIC = DATA / "로직_건설품셈_08장_건설기계.json"
DOC = DATA / "수식문서" / "수식_건설품셈_08장_건설기계.json"
USD = 1414.9 # 원/USD — 한국은행 환율 FX000001(2026-08-14)
SEED = "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요)"
GEN = {"상각비계수": 1125, "정비비계수": 563, "관리비계수": 674, "손료계수": 2362} # 발전기 계열
BRK = {"상각비계수": 3000, "정비비계수": 2833, "관리비계수": 768, "손료계수": 6601} # 0230-0002
UNIT = {
"가격": "천원", "상각비계수": "10^-7/시간", "정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간", "손료계수": "10^-7/시간", "주연료": "ℓ/hr", "주연료종류": "",
"잡재료율": "주연료의 %", "조종원": "인/일", "조종원직종": "", "전력": "㎾h/hr",
} # fmt: skip
FIX_SRC = {"발전기": "건설품셈 공통 8-3-8", "브레이커": "건설품셈 공통 8-3-1"}
WAGE = "건설품셈 공통 8-1-3 5 · 건설노임 1050"
SAB = "2016 사방사업표준품셈(안) 기계경비"
PIPE = {"150㎜×1m": 25, "150㎜×2m": 37, "150㎜×3m": 46,
"곡관 150㎜ 45°": 67, "곡관 150㎜ 60°": 84, "곡관 150㎜ 90°": 103} # fmt: skip
def _row(n, gubun, detail, name, spec, value, note, fix=None):
src = {k: (FIX_SRC[fix] if fix and k in GEN else "자체") for k in value}
if "조종원직종" in value:
src["조종원직종"] = WAGE
return {
"키": f"EQ{n:06d}",
"원문번호": f"자체 {name} {spec}",
"원문": "건설품셈",
"구분": gubun,
"상세구분": detail,
"이름": name,
"규격": spec,
"단위": {k: UNIT[k] for k in value},
"값": value,
"출처": src,
"비고": f"{SEED} · {note}",
}
def rows() -> list[dict]:
g, c, t = "[70]기타기계", "[40]콘크리트기계", "[00]토공기계"
fuel5 = {
"주연료": 0.625,
"주연료종류": "경유",
"잡재료율": 11,
"조종원": 1,
"조종원직종": "1050",
}
out = [
_row(656, g, "발전기", "발전기", "5.5㎾", {"가격": 1063, **GEN, **fuel5},
"EST 계 37,760 · 주연료 · 잡품 · 조종원(일반기계운전사) = " + SAB + "과 같음", "발전기"),
_row(657, g, "발전기", "발전기", "7㎾", {"가격": 1828.9, **GEN},
f"가격 = {SAB}(1,828,900 원) · 그 표 = 손료만(연료 · 조종원 없음)", "발전기"),
_row(658, g, "발전기", "발전기", "10㎾", {"가격": 3213, **GEN},
f"가격 = {SAB}(3,213,000 원) · 그 표 = 손료만(연료 · 조종원 없음)", "발전기"),
_row(659, t, "브레이커", "대형 브레이커", "0.18㎥", {"가격": 4559, **BRK},
"손료만(굴착기 부속) · 가격 = 0.2㎥(0230-0002) 값 · 계수 = 0.2㎥ 계수", "브레이커"),
_row(660, g, "에어 호스", "에어 호스", "1.91㎝", {"가격": 105, "손료계수": 5625},
"손료만 · EST 계 59"),
_row(661, g, "모노펌프", "모노펌프", "5-20㎥/hr", {"가격": 4900, "손료계수": 4346},
"손료만 · EST 계 2,129"),
_row(662, c, "파워트로웰", "파워트로웰", "3.73㎾",
{"가격": 2680, "손료계수": 5313, "주연료": 1.0, "주연료종류": "휘발유", "잡재료율": 10, "전력": 3.73},
"주연료 휘발유 · 잡품 · 전력 3.73 ㎾h · 조종원 없음 · EST 계 3,858 · 시간당 경비 = GC001195"),
] # fmt: skip
for i, (spec, usd) in enumerate(PIPE.items()):
out.append(
_row(663 + i, c, "콘크리트펌프용 파이프", "콘크리트펌프용 파이프", spec,
{"가격": round(usd * USD / 1000, 4), "손료계수": 4500},
f"압송관 · 손료만 · ${usd} 표기 · 환율 적용({USD:,} 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요")
) # fmt: skip
return out
EQ = "EQ000662"
LOGIC_ROW = {
"키": "GC001195",
"원문번호": "8",
"구분": "건설품셈 공통",
"상세구분": "08장 건설기계",
"이름": "파워트로웰 시간당 기계경비",
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"부품": {"갈래": "기계경비식"},
"소유": "공용",
"비고": f"품셈에 없는 기계 — {SEED} · 기계표 {EQ} 파워트로웰 3.73㎾ 값을 읽음 · 손료 = 가격 × 손료계수 · 잡품 = 주연료 줄의 % · 전력 = 기계표 전력 칸 · 조종원 없음",
"입력": [
{
"이름": "지역",
"고르기": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"],
"출처": "건설품셈 공통 8-1-7 5",
},
{
"이름": "전력 계약종별",
"고르기": ["주택용 저압", "일반용(갑)Ⅰ 저압"],
"출처": "유가전력 한전 전기요금표 2025-01-01",
"비고": "품셈·계약예규에 계약종별 규정 없음(전력비는 실비 계상) · 임시전력(갑)은 주택용 저압 요금 그대로(요금표 6.가)",
},
],
"중간": [
{"이름": "손료", "식": f"버림({EQ}.가격 * 1000 * {EQ}.손료계수 / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4"},
],
"호표": [
{"종류": "재료", "요소": "MO:휘발유:{지역}", "이름": "주연료", "단위": "ℓ", "수량": f"{EQ}.주연료", "비목": "재료비", "출처": "건설품셈 공통 8-4-1"},
{"종류": "재료", "요소": {"이름": "전력량요금", "계약종별": "입력"}, "이름": "전력", "단위": "㎾h", "수량": f"{EQ}.전력", "비목": "재료비", "출처": "자체"},
],
"덧줄": [
{"이름": "잡재료비", "식": f"줄.'주연료' * {EQ}.잡재료율 / 100", "비목": "재료비", "출처": "건설품셈 공통 8-4 주②"},
{"이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2"},
],
"끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음",
"변수표": {
"지역": {"기호": "S", "아래": "지역"},
"전력 계약종별": {"기호": "S", "아래": "전력계약종별"},
"손료": {"기호": "C", "아래": "손료"},
},
} # fmt: skip
def _block(obj: dict, nl: str) -> str:
"""줄 하나 — 두 칸 들여쓴 JSON 을 넷 칸 자리에."""
text = json.dumps(obj, ensure_ascii=False, indent=2)
return nl.join(" " + line for line in text.split("\n"))
def _insert_tail(path: Path, objs: list[dict]) -> None:
"""「줄」 목록 끝(`]` 앞)에 더함 — 이미 있는 키는 건너뜀."""
text = path.read_text("utf-8")
have = {r["키"] for r in json.loads(text)["줄"]}
new = [o for o in objs if o["키"] not in have]
if not new:
return
nl = "\r\n" if "\r\n" in text else "\n"
close = text.rstrip().rindex("]")
head = text[:close].rstrip()
body = "".join(f",{nl}{_block(o, nl)}" for o in new)
out = head + body + nl + " " + text[close:]
json.loads(out)
path.write_text(out, "utf-8", newline="")
def _insert_after(path: Path, after_key: str, obj: dict) -> None:
"""키 `after_key` 블록 뒤에 줄 하나 — 이미 있으면 건너뜀."""
text = path.read_text("utf-8")
if f'"키": "{obj["키"]}"' in text:
return
nl = "\r\n" if "\r\n" in text else "\n"
data = json.loads(text)
at = text.index(f'"키": "{after_key}"')
nxt = [r["키"] for r in data["줄"]]
follow = nxt[nxt.index(after_key) + 1]
cut = text.rindex("{", 0, text.index(f'"키": "{follow}"'))
cut = text.rindex(",", at, cut) + 1
out = text[:cut] + nl + _block(obj, nl) + "," + text[cut:]
json.loads(out)
path.write_text(out, "utf-8", newline="")
def _book(machines: list[dict]) -> None:
text = BOOK.read_text("utf-8")
nl = "\r\n" if "\r\n" in text else "\n"
adds = [(r["키"], r["원문번호"], "기계.json", "EQ000655") for r in machines]
adds.append(("GC001195", "공통 8 파워트로웰 시간당 경비", LOGIC.name, "GC001194"))
for key, number, file, after in adds:
if f'"{key}":' in text:
continue
line = f' "{key}": {json.dumps({"원문번호": number, "파일": file}, ensure_ascii=False)},'
prev = max((k for k, *_ in adds if k[:2] == key[:2] and f'"{k}":' in text), default=after)
at = text.index(f' "{prev}":')
end = text.index(nl, at) + len(nl)
text = text[:end] + line + nl + text[end:]
book = json.loads(text)
nums = {"EQ": 669, "GC": 1196}
for tid, n in nums.items():
old = book["다음"][tid]
if old < n:
text = text.replace(f'"{tid}": {old},', f'"{tid}": {n},', 1)
json.loads(text)
BOOK.write_text(text, "utf-8", newline="")
def _doc() -> None:
"""GC001195 수식 문서 — 옛 로직 변환(`master_doc_convert`) · 파일 끝에 더함."""
for p in (str(ROOT), str(HERE)):
if p not in sys.path:
sys.path.insert(0, p)
import master_doc_convert as cv
import master_doc_link as ml
from M01_MasterData import M01_MasterData_Store as m01
data = json.loads(DOC.read_text("utf-8"))
if any(d.get("키") == "GC001195" for d in data["줄"]):
return
files, whole = m01.loaded()
doc, notes = cv.convert(whole, LOGIC_ROW)
if notes:
print("변환 알림:", notes)
data["줄"].append(doc)
DOC.write_text(ml.dump(data), "utf-8", newline="")
def main() -> None:
machines = rows()
_insert_tail(MACHINE, machines)
_book(machines)
_insert_after(LOGIC, "GC000276", LOGIC_ROW)
_doc()
import build_기계목록_빠진기계_814_1 as b814
b814.main()
if __name__ == "__main__":
sys.path.insert(0, str(HERE))
main()
@@ -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,
}
+3 -353
View File
@@ -33404,10 +33404,9 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"값원문": {
"8-4-8 주연료": "휘발유1"
},
"비고": "주연료종류: 원문 글을 가름"
"값원문": { "8-4-8 주연료": "휘발유1" },
"비고": "주연료종류: 원문 글을 가름",
"별칭": "파워트로웰"
},
{
"키": "EQ000535",
@@ -39717,355 +39716,6 @@
"가격": "산림품셈 10-9"
},
"비고": "원문 이름: 4륜구동 1톤 덤프트럭 · 중기가격 = 조달청 등재가격(기본값 28,000 천원 = EST 2026-07 「덤프트럭 1.0Ton」 · 바꿀 수 있음) · 작업량은 건설품셈 · 손료 및 운전경비 = 2.5톤 덤프트럭(0602-0025) · 주 연료 = 2.5톤의 80% · 시간당 경비 = GF000145"
},
{
"키": "EQ000656",
"원문번호": "자체 발전기 5.5㎾",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "발전기",
"이름": "발전기",
"규격": "5.5㎾",
"단위": {
"가격": "천원",
"상각비계수": "10^-7/시간",
"정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간",
"손료계수": "10^-7/시간",
"주연료": "ℓ/hr",
"주연료종류": "",
"잡재료율": "주연료의 %",
"조종원": "인/일",
"조종원직종": ""
},
"값": {
"가격": 1063,
"상각비계수": 1125,
"정비비계수": 563,
"관리비계수": 674,
"손료계수": 2362,
"주연료": 0.625,
"주연료종류": "경유",
"잡재료율": 11,
"조종원": 1,
"조종원직종": "1050"
},
"출처": {
"가격": "자체",
"상각비계수": "건설품셈 공통 8-3-8",
"정비비계수": "건설품셈 공통 8-3-8",
"관리비계수": "건설품셈 공통 8-3-8",
"손료계수": "건설품셈 공통 8-3-8",
"주연료": "자체",
"주연료종류": "자체",
"잡재료율": "자체",
"조종원": "자체",
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1050"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · EST 계 37,760 · 주연료 · 잡품 · 조종원(일반기계운전사) = 2016 사방사업표준품셈(안) 기계경비과 같음"
},
{
"키": "EQ000657",
"원문번호": "자체 발전기 7㎾",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "발전기",
"이름": "발전기",
"규격": "7㎾",
"단위": {
"가격": "천원",
"상각비계수": "10^-7/시간",
"정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 1828.9,
"상각비계수": 1125,
"정비비계수": 563,
"관리비계수": 674,
"손료계수": 2362
},
"출처": {
"가격": "자체",
"상각비계수": "건설품셈 공통 8-3-8",
"정비비계수": "건설품셈 공통 8-3-8",
"관리비계수": "건설품셈 공통 8-3-8",
"손료계수": "건설품셈 공통 8-3-8"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 가격 = 2016 사방사업표준품셈(안) 기계경비(1,828,900 원) · 그 표 = 손료만(연료 · 조종원 없음)"
},
{
"키": "EQ000658",
"원문번호": "자체 발전기 10㎾",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "발전기",
"이름": "발전기",
"규격": "10㎾",
"단위": {
"가격": "천원",
"상각비계수": "10^-7/시간",
"정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 3213,
"상각비계수": 1125,
"정비비계수": 563,
"관리비계수": 674,
"손료계수": 2362
},
"출처": {
"가격": "자체",
"상각비계수": "건설품셈 공통 8-3-8",
"정비비계수": "건설품셈 공통 8-3-8",
"관리비계수": "건설품셈 공통 8-3-8",
"손료계수": "건설품셈 공통 8-3-8"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 가격 = 2016 사방사업표준품셈(안) 기계경비(3,213,000 원) · 그 표 = 손료만(연료 · 조종원 없음)"
},
{
"키": "EQ000659",
"원문번호": "자체 대형 브레이커 0.18㎥",
"원문": "건설품셈",
"구분": "[00]토공기계",
"상세구분": "브레이커",
"이름": "대형 브레이커",
"규격": "0.18㎥",
"단위": {
"가격": "천원",
"상각비계수": "10^-7/시간",
"정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 4559,
"상각비계수": 3000,
"정비비계수": 2833,
"관리비계수": 768,
"손료계수": 6601
},
"출처": {
"가격": "자체",
"상각비계수": "건설품셈 공통 8-3-1",
"정비비계수": "건설품셈 공통 8-3-1",
"관리비계수": "건설품셈 공통 8-3-1",
"손료계수": "건설품셈 공통 8-3-1"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 손료만(굴착기 부속) · 가격 = 0.2㎥(0230-0002) 값 · 계수 = 0.2㎥ 계수"
},
{
"키": "EQ000660",
"원문번호": "자체 에어 호스 1.91㎝",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "에어 호스",
"이름": "에어 호스",
"규격": "1.91㎝",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 105,
"손료계수": 5625
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 손료만 · EST 계 59"
},
{
"키": "EQ000661",
"원문번호": "자체 모노펌프 5-20㎥/hr",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "모노펌프",
"이름": "모노펌프",
"규격": "5-20㎥/hr",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 4900,
"손료계수": 4346
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 손료만 · EST 계 2,129"
},
{
"키": "EQ000662",
"원문번호": "자체 파워트로웰 3.73㎾",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "파워트로웰",
"이름": "파워트로웰",
"규격": "3.73㎾",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간",
"주연료": "ℓ/hr",
"주연료종류": "",
"잡재료율": "주연료의 %",
"전력": "㎾h/hr"
},
"값": {
"가격": 2680,
"손료계수": 5313,
"주연료": 1.0,
"주연료종류": "휘발유",
"잡재료율": 10,
"전력": 3.73
},
"출처": {
"가격": "자체",
"손료계수": "자체",
"주연료": "자체",
"주연료종류": "자체",
"잡재료율": "자체",
"전력": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 주연료 휘발유 · 잡품 · 전력 3.73 ㎾h · 조종원 없음 · EST 계 3,858 · 시간당 경비 = GC001195"
},
{
"키": "EQ000663",
"원문번호": "자체 콘크리트펌프용 파이프 150㎜×1m",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "150㎜×1m",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 35.3725,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $25 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
},
{
"키": "EQ000664",
"원문번호": "자체 콘크리트펌프용 파이프 150㎜×2m",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "150㎜×2m",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 52.3513,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $37 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
},
{
"키": "EQ000665",
"원문번호": "자체 콘크리트펌프용 파이프 150㎜×3m",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "150㎜×3m",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 65.0854,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $46 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
},
{
"키": "EQ000666",
"원문번호": "자체 콘크리트펌프용 파이프 곡관 150㎜ 45°",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "곡관 150㎜ 45°",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 94.7983,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $67 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
},
{
"키": "EQ000667",
"원문번호": "자체 콘크리트펌프용 파이프 곡관 150㎜ 60°",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "곡관 150㎜ 60°",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 118.8516,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $84 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
},
{
"키": "EQ000668",
"원문번호": "자체 콘크리트펌프용 파이프 곡관 150㎜ 90°",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "곡관 150㎜ 90°",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 145.7347,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $103 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
}
]
}
@@ -284,8 +284,7 @@
"9090-1300",
"9090-1400",
"9090-1500",
"9090-1600",
"자체 발전기 5.5㎾"
"9090-1600"
]
},
{
@@ -1089,18 +1088,7 @@
"9080-1500",
"9080-1750",
"9080-2000",
"9080-3000",
"자체 발전기 7㎾",
"자체 발전기 10㎾",
"자체 대형 브레이커 0.18㎥",
"자체 에어 호스 1.91㎝",
"자체 모노펌프 5-20㎥/hr",
"자체 콘크리트펌프용 파이프 150㎜×1m",
"자체 콘크리트펌프용 파이프 150㎜×2m",
"자체 콘크리트펌프용 파이프 150㎜×3m",
"자체 콘크리트펌프용 파이프 곡관 150㎜ 45°",
"자체 콘크리트펌프용 파이프 곡관 150㎜ 60°",
"자체 콘크리트펌프용 파이프 곡관 150㎜ 90°"
"9080-3000"
]
}
],
@@ -1416,114 +1404,6 @@
"시간환산": { "기호": "Q", "아래": "시간환산" }
}
},
{
"키": "GC001195",
"원문번호": "8",
"구분": "건설품셈 공통",
"상세구분": "08장 건설기계",
"이름": "파워트로웰 시간당 기계경비",
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"부품": {
"갈래": "기계경비식"
},
"소유": "공용",
"비고": "품셈에 없는 기계 — EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 기계표 EQ000662 파워트로웰 3.73㎾ 값을 읽음 · 손료 = 가격 × 손료계수 · 잡품 = 주연료 줄의 % · 전력 = 기계표 전력 칸 · 조종원 없음",
"입력": [
{
"이름": "지역",
"고르기": [
"전국평균",
"전국",
"서울",
"경기",
"강원",
"충북",
"충남",
"전북",
"경북",
"경남",
"부산",
"제주",
"대구",
"인천",
"대전",
"울산",
"세종",
"전남광주"
],
"출처": "건설품셈 공통 8-1-7 5"
},
{
"이름": "전력 계약종별",
"고르기": [
"주택용 저압",
"일반용(갑)Ⅰ 저압"
],
"출처": "유가전력 한전 전기요금표 2025-01-01",
"비고": "품셈·계약예규에 계약종별 규정 없음(전력비는 실비 계상) · 임시전력(갑)은 주택용 저압 요금 그대로(요금표 6.가)"
}
],
"중간": [
{
"이름": "손료",
"식": "버림(EQ000662.가격 * 1000 * EQ000662.손료계수 / 10000000, 0)",
"출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4"
}
],
"호표": [
{
"종류": "재료",
"요소": "MO:휘발유:{지역}",
"이름": "주연료",
"단위": "ℓ",
"수량": "EQ000662.주연료",
"비목": "재료비",
"출처": "건설품셈 공통 8-4-1"
},
{
"종류": "재료",
"요소": {
"이름": "전력량요금",
"계약종별": "입력"
},
"이름": "전력",
"단위": "㎾h",
"수량": "EQ000662.전력",
"비목": "재료비",
"출처": "자체"
}
],
"덧줄": [
{
"이름": "잡재료비",
"식": "줄.'주연료' * EQ000662.잡재료율 / 100",
"비목": "재료비",
"출처": "건설품셈 공통 8-4 주②"
},
{
"이름": "기계손료",
"식": "손료",
"비목": "경비",
"출처": "건설품셈 공통 8-1-6 2"
}
],
"끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음",
"변수표": {
"지역": {
"기호": "S",
"아래": "지역"
},
"전력 계약종별": {
"기호": "S",
"아래": "전력계약종별"
},
"손료": {
"기호": "C",
"아래": "손료"
}
}
},
{
"키": "GC001143",
"원문번호": "8-1-4",
@@ -62,7 +62,7 @@
"갈래": "기계경비식"
},
"변수": [
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["1730-0015", "7204-0055", "0602-0150", "2702-0020", "0101-0007", "0101-0010", "0101-0012", "0101-0019", "0101-0032", "2105-0005", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "4504-0032", "4504-0036", "4504-0041", "4504-0043", "4504-0047", "4504-0052", "4205-0010", "4205-0017", "4205-0020", "4205-0030", "4205-0040", "4205-0045", "2105-0002", "2105-0003", "2105-0010", "2105-0015", "2105-0018", "2502-0025", "0602-0025", "2502-0050", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "7204-0018", "7204-0160", "5205-0210", "5405-0110", "7505-0450", "5205-0255", "7505-0150", "5205-0103", "6701-0147", "6701-0184", "5205-0170", "7505-0350", "0302-0134", "7505-0100", "7505-0050", "5205-0071", "7505-0025", "0502-0036", "1306-0100", "1506-0032", "0121-0004", "1305-0007", "7204-0038", "7204-0065", "0302-0057", "5701-0020", "0302-0095", "3201-0003", "1106-0012", "1406-0015", "1206-0008", "3601-0102", "1306-0025", "0211-0018", "0602-0045", "0602-0060", "0602-0105", "1630-0080", "3901-0300", "7505-0250", "2502-0035", "1306-0120", "1106-0010", "3201-0001", "1406-0008", "3601-0202", "3601-0402", "3611-0142", "2502-0020", "2502-0075", "5205-0035", "2702-0030", "2702-0060", "0211-0060", "0211-0080", "0211-0100", "0221-0040", "0221-0070", "0302-0025", "0302-0172", "0302-0229", "0302-0287", "0302-0350", "0302-0500", "0406-0054", "0406-0115", "0406-0161", "0406-0206", "0503-0036", "0602-0200", "0602-0240", "0602-0320", "1106-0015", "1206-0010", "1206-0014", "1209-0001", "1209-0002", "1209-0004", "1209-0006", "1209-0007", "1209-0008", "1209-0013", "1306-0044", "1306-0060", "1406-0025", "1506-0011", "1506-0012", "1506-0015", "1506-0019", "1506-0025", "1506-0030", "1506-0037", "2602-0015", "2602-0025", "2602-0035", "2602-0045", "2702-0040", "3530-0015", "3530-0036", "3601-0204", "3601-0302", "3701-0200", "3805-0120", "4504-0021", "4504-0028", "5405-0150", "5701-0010", "7106-0035", "7505-0125", "7505-0200", "7505-0500", "7505-0700", "9010-0003", "9010-0006", "9010-0010", "9010-0012", "9010-0020", "9010-0022", "9010-0033", "9010-0040", "9010-0044", "9010-0060", "9010-0080", "9020-0010", "9020-0015", "9020-0016", "9020-0022", "9020-0035", "9020-0050", "9020-0072", "9020-0160", "9020-0180", "9020-0200", "9050-0075", "9050-0150", "9050-0450", "9050-0750", "9050-0850", "9090-0800", "9090-1000", "9090-1200", "9090-1300", "9090-1400", "9090-1500", "9090-1600", "자체 발전기 5.5㎾"], "값": "1730-0015"}},
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["1730-0015", "7204-0055", "0602-0150", "2702-0020", "0101-0007", "0101-0010", "0101-0012", "0101-0019", "0101-0032", "2105-0005", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "4504-0032", "4504-0036", "4504-0041", "4504-0043", "4504-0047", "4504-0052", "4205-0010", "4205-0017", "4205-0020", "4205-0030", "4205-0040", "4205-0045", "2105-0002", "2105-0003", "2105-0010", "2105-0015", "2105-0018", "2502-0025", "0602-0025", "2502-0050", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "7204-0018", "7204-0160", "5205-0210", "5405-0110", "7505-0450", "5205-0255", "7505-0150", "5205-0103", "6701-0147", "6701-0184", "5205-0170", "7505-0350", "0302-0134", "7505-0100", "7505-0050", "5205-0071", "7505-0025", "0502-0036", "1306-0100", "1506-0032", "0121-0004", "1305-0007", "7204-0038", "7204-0065", "0302-0057", "5701-0020", "0302-0095", "3201-0003", "1106-0012", "1406-0015", "1206-0008", "3601-0102", "1306-0025", "0211-0018", "0602-0045", "0602-0060", "0602-0105", "1630-0080", "3901-0300", "7505-0250", "2502-0035", "1306-0120", "1106-0010", "3201-0001", "1406-0008", "3601-0202", "3601-0402", "3611-0142", "2502-0020", "2502-0075", "5205-0035", "2702-0030", "2702-0060", "0211-0060", "0211-0080", "0211-0100", "0221-0040", "0221-0070", "0302-0025", "0302-0172", "0302-0229", "0302-0287", "0302-0350", "0302-0500", "0406-0054", "0406-0115", "0406-0161", "0406-0206", "0503-0036", "0602-0200", "0602-0240", "0602-0320", "1106-0015", "1206-0010", "1206-0014", "1209-0001", "1209-0002", "1209-0004", "1209-0006", "1209-0007", "1209-0008", "1209-0013", "1306-0044", "1306-0060", "1406-0025", "1506-0011", "1506-0012", "1506-0015", "1506-0019", "1506-0025", "1506-0030", "1506-0037", "2602-0015", "2602-0025", "2602-0035", "2602-0045", "2702-0040", "3530-0015", "3530-0036", "3601-0204", "3601-0302", "3701-0200", "3805-0120", "4504-0021", "4504-0028", "5405-0150", "5701-0010", "7106-0035", "7505-0125", "7505-0200", "7505-0500", "7505-0700", "9010-0003", "9010-0006", "9010-0010", "9010-0012", "9010-0020", "9010-0022", "9010-0033", "9010-0040", "9010-0044", "9010-0060", "9010-0080", "9020-0010", "9020-0015", "9020-0016", "9020-0022", "9020-0035", "9020-0050", "9020-0072", "9020-0160", "9020-0180", "9020-0200", "9050-0075", "9050-0150", "9050-0450", "9050-0750", "9050-0850", "9090-0800", "9090-1000", "9090-1200", "9090-1300", "9090-1400", "9090-1500", "9090-1600"], "값": "1730-0015"}},
{"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류"}},
@@ -306,7 +306,7 @@
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"변수": [
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["7620-0003", "7621-0100", "7995-0050", "5210-0013", "5210-0027", "5220-0015", "7730-0150", "6516-0060", "6516-0120", "6516-0250", "6516-0300", "6105-0190", "6202-0060", "0230-0002", "6410-0080", "6410-0100", "6410-0120", "6410-0150", "6410-0200", "6901-0010", "6530-0090", "6530-0120", "6517-0100", "6517-0150", "6517-0200", "6517-0250", "6517-0300", "6510-0100", "6510-0150", "6510-0200", "6510-0250", "6510-0300", "6518-0100", "6518-0150", "6518-0200", "6518-0250", "6518-0300", "5501-0080", "7750-0016", "7750-0025", "7730-0050", "5401-0017", "5202-0127", "5202-0240", "5202-0300", "7611-0200", "7612-0200", "5204-0200", "5204-0300", "5204-0400", "5204-0500", "5204-0600", "5702-0095", "3430-0400", "7431-1300", "7740-0080", "7740-0150", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "2208-5008", "2208-5010", "2208-5012", "2208-5016", "2208-5020", "2105-0002", "2105-0003", "2105-0005", "2105-0010", "2105-0015", "2105-0018", "0201-0012", "0201-0020", "0201-0040", "0201-0060", "0201-0070", "0201-0080", "0201-0100", "0201-0120", "0201-0200", "6105-0390", "6202-0125", "7830-0081", "7950-0149", "7951-0066", "7952-0246", "7953-3400", "7954-1119", "7611-0300", "7431-1100", "7431-2300", "7431-2500", "7611-0400", "7611-0500", "7614-0300", "0250-0100", "5210-0010", "5210-0019", "0103-0016", "0103-0019", "0103-0023", "0103-0027", "0103-0032", "0230-0004", "0230-0006", "0230-0007", "0230-0008", "0230-0010", "0240-0007", "0250-0080", "0407-0054", "0407-0092", "0407-0107", "0407-0161", "0407-0206", "0610-0150", "0610-0200", "0610-0240", "2115-0024", "2115-0031", "2115-0036", "2116-0031", "2116-0036", "2117-0022", "2117-0027", "2118-0010", "2210-0145", "3430-0300", "3450-0642", "4115-0100", "4115-0150", "4115-0200", "4115-0300", "4505-0015", "4505-0026", "4611-0075", "5105-0050", "5105-0100", "5105-0150", "5105-0200", "5111-0040", "5111-0050", "5111-0060", "5111-0076", "5111-0091", "5112-0001", "5112-0002", "5112-0003", "5112-0004", "5112-0005", "5113-0001", "5113-0002", "5113-0003", "5113-0004", "5113-0005", "5113-0006", "5113-0007", "5113-0008", "5113-0009", "5113-0010", "5113-0011", "5114-0001", "5114-0002", "5114-0003", "5114-0004", "5114-0005", "5114-0006", "5114-0007", "5114-0008", "5115-0030", "5115-0055", "5115-0075", "5115-0095", "5116-0001", "5116-0002", "5116-0003", "5116-0004", "5116-0005", "5116-0006", "5116-0007", "5116-0008", "5117-0001", "5117-0002", "5117-0003", "5117-0004", "5117-0005", "5117-0006", "5117-0007", "5117-0008", "5118-0001", "5118-0002", "5118-0003", "5118-0004", "5118-0005", "5118-0006", "5118-0007", "5119-0625", "5203-1800", "5203-2200", "5203-2700", "5203-3500", "5203-4500", "5330-0074", "5805-0002", "5805-0003", "5901-0006", "5901-0010", "5901-0016", "6202-0200", "6408-0015", "6408-0020", "6408-0030", "6408-0040", "6408-0050", "6408-0085", "6408-0100", "6515-0090", "6530-0030", "6530-0040", "6530-0045", "6530-0060", "6532-0220", "6550-0130", "7103-0010", "7104-0010", "7202-0008", "7202-0010", "7205-0125", "7206-0020", "7206-0070", "7206-0100", "7430-1100", "7430-1300", "7430-1500", "7430-2300", "7430-2500", "7612-0300", "7612-0400", "7613-0075", "7613-0150", "7613-0300", "7613-0400", "7613-0600", "7613-0900", "7620-0002", "7730-0080", "7730-0100", "7730-0125", "7740-0100", "7770-0004", "7930-0001", "7930-0002", "7930-0003", "7930-0005", "7930-0007", "7930-0010", "7930-0015", "7930-0020", "7930-0025", "7930-0030", "7930-0040", "7930-0050", "7930-0075", "7930-0100", "7935-0180", "7991-0050", "7991-0100", "7991-0500", "7993-0020", "8201-0100", "8202-0100", "8203-0019", "8204-0100", "8205-0100", "9060-0060", "9060-0100", "9060-0200", "9060-0300", "9060-0500", "9060-0600", "9080-0050", "9080-0080", "9080-0100", "9080-0120", "9080-0150", "9080-0200", "9080-0300", "9080-0500", "9080-0700", "9080-1000", "9080-1100", "9080-1400", "9080-1500", "9080-1750", "9080-2000", "9080-3000", "자체 발전기 7㎾", "자체 발전기 10㎾", "자체 대형 브레이커 0.18㎥", "자체 에어 호스 1.91㎝", "자체 모노펌프 5-20㎥/hr", "자체 콘크리트펌프용 파이프 150㎜×1m", "자체 콘크리트펌프용 파이프 150㎜×2m", "자체 콘크리트펌프용 파이프 150㎜×3m", "자체 콘크리트펌프용 파이프 곡관 150㎜ 45°", "자체 콘크리트펌프용 파이프 곡관 150㎜ 60°", "자체 콘크리트펌프용 파이프 곡관 150㎜ 90°"], "값": "7620-0003"}},
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["7620-0003", "7621-0100", "7995-0050", "5210-0013", "5210-0027", "5220-0015", "7730-0150", "6516-0060", "6516-0120", "6516-0250", "6516-0300", "6105-0190", "6202-0060", "0230-0002", "6410-0080", "6410-0100", "6410-0120", "6410-0150", "6410-0200", "6901-0010", "6530-0090", "6530-0120", "6517-0100", "6517-0150", "6517-0200", "6517-0250", "6517-0300", "6510-0100", "6510-0150", "6510-0200", "6510-0250", "6510-0300", "6518-0100", "6518-0150", "6518-0200", "6518-0250", "6518-0300", "5501-0080", "7750-0016", "7750-0025", "7730-0050", "5401-0017", "5202-0127", "5202-0240", "5202-0300", "7611-0200", "7612-0200", "5204-0200", "5204-0300", "5204-0400", "5204-0500", "5204-0600", "5702-0095", "3430-0400", "7431-1300", "7740-0080", "7740-0150", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "2208-5008", "2208-5010", "2208-5012", "2208-5016", "2208-5020", "2105-0002", "2105-0003", "2105-0005", "2105-0010", "2105-0015", "2105-0018", "0201-0012", "0201-0020", "0201-0040", "0201-0060", "0201-0070", "0201-0080", "0201-0100", "0201-0120", "0201-0200", "6105-0390", "6202-0125", "7830-0081", "7950-0149", "7951-0066", "7952-0246", "7953-3400", "7954-1119", "7611-0300", "7431-1100", "7431-2300", "7431-2500", "7611-0400", "7611-0500", "7614-0300", "0250-0100", "5210-0010", "5210-0019", "0103-0016", "0103-0019", "0103-0023", "0103-0027", "0103-0032", "0230-0004", "0230-0006", "0230-0007", "0230-0008", "0230-0010", "0240-0007", "0250-0080", "0407-0054", "0407-0092", "0407-0107", "0407-0161", "0407-0206", "0610-0150", "0610-0200", "0610-0240", "2115-0024", "2115-0031", "2115-0036", "2116-0031", "2116-0036", "2117-0022", "2117-0027", "2118-0010", "2210-0145", "3430-0300", "3450-0642", "4115-0100", "4115-0150", "4115-0200", "4115-0300", "4505-0015", "4505-0026", "4611-0075", "5105-0050", "5105-0100", "5105-0150", "5105-0200", "5111-0040", "5111-0050", "5111-0060", "5111-0076", "5111-0091", "5112-0001", "5112-0002", "5112-0003", "5112-0004", "5112-0005", "5113-0001", "5113-0002", "5113-0003", "5113-0004", "5113-0005", "5113-0006", "5113-0007", "5113-0008", "5113-0009", "5113-0010", "5113-0011", "5114-0001", "5114-0002", "5114-0003", "5114-0004", "5114-0005", "5114-0006", "5114-0007", "5114-0008", "5115-0030", "5115-0055", "5115-0075", "5115-0095", "5116-0001", "5116-0002", "5116-0003", "5116-0004", "5116-0005", "5116-0006", "5116-0007", "5116-0008", "5117-0001", "5117-0002", "5117-0003", "5117-0004", "5117-0005", "5117-0006", "5117-0007", "5117-0008", "5118-0001", "5118-0002", "5118-0003", "5118-0004", "5118-0005", "5118-0006", "5118-0007", "5119-0625", "5203-1800", "5203-2200", "5203-2700", "5203-3500", "5203-4500", "5330-0074", "5805-0002", "5805-0003", "5901-0006", "5901-0010", "5901-0016", "6202-0200", "6408-0015", "6408-0020", "6408-0030", "6408-0040", "6408-0050", "6408-0085", "6408-0100", "6515-0090", "6530-0030", "6530-0040", "6530-0045", "6530-0060", "6532-0220", "6550-0130", "7103-0010", "7104-0010", "7202-0008", "7202-0010", "7205-0125", "7206-0020", "7206-0070", "7206-0100", "7430-1100", "7430-1300", "7430-1500", "7430-2300", "7430-2500", "7612-0300", "7612-0400", "7613-0075", "7613-0150", "7613-0300", "7613-0400", "7613-0600", "7613-0900", "7620-0002", "7730-0080", "7730-0100", "7730-0125", "7740-0100", "7770-0004", "7930-0001", "7930-0002", "7930-0003", "7930-0005", "7930-0007", "7930-0010", "7930-0015", "7930-0020", "7930-0025", "7930-0030", "7930-0040", "7930-0050", "7930-0075", "7930-0100", "7935-0180", "7991-0050", "7991-0100", "7991-0500", "7993-0020", "8201-0100", "8202-0100", "8203-0019", "8204-0100", "8205-0100", "9060-0060", "9060-0100", "9060-0200", "9060-0300", "9060-0500", "9060-0600", "9080-0050", "9080-0080", "9080-0100", "9080-0120", "9080-0150", "9080-0200", "9080-0300", "9080-0500", "9080-0700", "9080-1000", "9080-1100", "9080-1400", "9080-1500", "9080-1750", "9080-2000", "9080-3000"], "값": "7620-0003"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
{"열쇠": "k_기계손료", "기호": "k", "아래": "기계손료", "위": "", "이름": "EQ:{기계}.손료계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "손료계수"}}
@@ -2213,43 +2213,6 @@
"비목": {"방법": "버림", "자리": 0},
"끝전": "비목버림"
}
},
{
"꼴": "수식문서",
"판": 1,
"키": "GC001195",
"이름": "파워트로웰 시간당 기계경비",
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"부품": {
"갈래": "기계경비식"
},
"변수": [
{"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}},
{"열쇠": "전력 계약종별", "기호": "S", "아래": "전력계약종별", "위": "", "이름": "전력 계약종별", "단위": "", "출처": "유가전력 한전 전기요금표 2025-01-01", "연결": {"갈래": "선택", "목록": ["주택용 저압", "일반용(갑)Ⅰ 저압"], "값": "주택용 저압"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "P_파워트로웰", "기호": "P", "아래": "파워트로웰", "위": "", "이름": "EQ000662.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ000662", "칸": "가격", "계약종별": "S_전력계약종별"}},
{"열쇠": "k_파워트로웰", "기호": "k", "아래": "파워트로웰", "위": "", "이름": "EQ000662.손료계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ000662", "칸": "손료계수", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_파워트로웰2", "기호": "P", "아래": "파워트로웰2", "위": "", "이름": "EQ000662.주연료", "단위": "", "연결": {"갈래": "요소", "키": "EQ000662", "칸": "주연료", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_휘발유", "기호": "P", "아래": "휘발유", "위": "", "이름": "휘발유단가", "단위": "", "연결": {"갈래": "요소", "키": "MO:휘발유:{S_지역}", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_파워트로웰3", "기호": "P", "아래": "파워트로웰3", "위": "", "이름": "EQ000662.전력", "단위": "", "연결": {"갈래": "요소", "키": "EQ000662", "칸": "전력", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_전력", "기호": "P", "아래": "전력", "위": "", "이름": "전력단가", "단위": "", "연결": {"갈래": "요소", "재료": {"이름": "전력량요금", "계약종별": "입력"}, "단위": "㎾h", "계약종별": "S_전력계약종별"}},
{"열쇠": "r_파워트로웰", "기호": "r", "아래": "파워트로웰", "위": "", "이름": "EQ000662.잡재료율", "단위": "", "연결": {"갈래": "요소", "키": "EQ000662", "칸": "잡재료율", "계약종별": "S_전력계약종별"}}
],
"식": [
{"기호": "C_손료", "식": "버림(P_파워트로웰 × 1000 × k_파워트로웰 ÷ 10000000, 0)"}
],
"금액": [
{"이름": "주연료", "단위": "ℓ", "비목": "재료비", "수량": "P_파워트로웰2", "단가": "P_휘발유", "출처": "건설품셈 공통 8-4-1", "기호": "C_주연료"},
{"이름": "전력", "단위": "㎾h", "비목": "재료비", "수량": "P_파워트로웰3", "단가": "P_전력", "출처": "자체"},
{"이름": "잡재료비", "단위": "식", "비목": "재료비", "단가": "C_주연료 × r_파워트로웰 ÷ 100", "출처": "건설품셈 공통 8-4 주②"},
{"이름": "기계손료", "단위": "식", "비목": "경비", "단가": "C_손료", "출처": "건설품셈 공통 8-1-6 2"}
],
"끝수": {
"줄": {"방법": "버림", "자리": 1},
"비목": {"방법": "버림", "자리": 0},
"끝전": "비목버림"
}
}
]
}
+3 -1
View File
@@ -55,7 +55,9 @@ def test_길_앞머리_M02_공용화면():
assert "cp ? cp.route(key, path) : opt.base + path" in scr
assert "{ 문서: doc, 끊음: cut, 판: version }" in scr # 저장 몸
detail = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts")
assert 'tabs.draw(/^SK-\\d{6}$/.test(doc.키 ?? "")' in detail # 저장한 SK 만 · 줄 모달 = 글
assert (
'tabs.draw(/^SK-(?!0{6})\\d{6}$/.test(doc.키 ?? "")' in detail
) # 저장한 SK 만(SK-000000 = 줄 문서)
assert not (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts").exists()
main = _src("main.py")
assert '__import__("M02_MasterTemplete.M02_Doc_Screen", fromlist=["router"]).router' in main
@@ -310,7 +310,7 @@ def test_표값_직접_침_별칭_그대로() -> None:
assert a.get("계") and a.get("계") != docs_sum.get(k), (k, a.get("계"))
rows = _src("A00_Common/line_stack_rows.ts")
assert 'if (link.갈래 === "숫자" && link.표)' in rows # 808-1 — 출처 꼬리표 한 곳(`sourceTag`)
assert "const tag = expr ? null : sourceTag(h, id, link);" in rows
assert "const tag = expr ? null : srcTag ? clipTag(srcTag) : sourceTag(h, id, link);" in rows
assert "const from = v?.연결?.표 ?" in _src("common_util/common_util_line_stack.ts")
@@ -1,173 +0,0 @@
"""820-1 지킴 — 품셈에 없는 소형 · 보조 기계 13 줄(EST 구성 씨앗 · 가격 갱신 필요 · 요구 785 ~ 787).
(a) 새 줄 · 값 — 발전기 손료계수 2362 공통(품셈 발전기 계열) · 브레이커 0.18 = 0.2㎥ 계수 · 가격 = 씨앗 ·
파이프 = 달러 × 환율(비고에 $ 값) · 비고 「갱신 필요」
(b) 계산식 — 손료 = 버림(가격 × 1000 × 손료계수 ÷ 10⁷) · 손료만 = GC000273 · 발전기 5.5 = GC000268 ·
파워트로웰 = GC001195(휘발유 + 잡품(주연료 줄의 %) + 전력) · 옛 엔진 = 새 수식 문서 · 시간당 이음 한 길
(c) 이음 — 가져온 이름 · 규격 → 새 줄 · 옛 기계 그대로
(d) 다른 문서 그대로 — 새 고르기는 목록 끝에만 · 기본값 그대로
"""
from __future__ import annotations
import importlib
import json
import sys
from decimal import Decimal
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
for p in (ROOT, ROOT / "resources/master_data/scripts"):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
from test_815_1_import_link import no_aliases # noqa: E402 — 진짜 마스터 별칭 빼고
from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402
from M02_MasterTemplete import M02_Doc_Import as di # noqa: E402
from M02_MasterTemplete import M02_Machine_Find as mf # noqa: E402
from M02_MasterTemplete import M02_Machine_Hourly as mh # noqa: E402
ml = importlib.import_module("master_doc_link")
mc = importlib.import_module("master_doc_calc")
b802 = importlib.import_module("build_수식문서_802_2")
meq = importlib.import_module("master_equation")
NEW = {f"EQ{n:06d}" for n in range(656, 669)}
SEED = { # 키 → (이름, 규격, 가격, 손료계수, 시간당 식)
"EQ000656": ("발전기", "5.5㎾", 1063, 2362, "GC000268"),
"EQ000657": ("발전기", "7㎾", 1828.9, 2362, "GC000273"),
"EQ000658": ("발전기", "10㎾", 3213, 2362, "GC000273"),
"EQ000659": ("대형 브레이커", "0.18㎥", 4559, 6601, "GC000273"),
"EQ000660": ("에어 호스", "1.91㎝", 105, 5625, "GC000273"),
"EQ000661": ("모노펌프", "5-20㎥/hr", 4900, 4346, "GC000273"),
"EQ000662": ("파워트로웰", "3.73㎾", 2680, 5313, "GC001195"),
}
USD = {"150㎜×1m": 25, "150㎜×2m": 37, "150㎜×3m": 46,
"곡관 150㎜ 45°": 67, "곡관 150㎜ 60°": 84, "곡관 150㎜ 90°": 103} # fmt: skip
def _eq() -> dict[str, dict]:
return m01.loaded()[1].index["EQ"]
def _docs():
docs = ml.load_all()
return docs, ml.Links(m01.loaded()[1], docs)
def _loss(price, factor) -> Decimal:
return (Decimal(str(price)) * 1000 * Decimal(factor) / 10_000_000).quantize(Decimal(1), "ROUND_DOWN") # fmt: skip
def test_rows_and_values() -> None:
eq = _eq()
assert NEW <= set(eq)
for key, (name, spec, price, factor, _) in SEED.items():
row = eq[key]
assert (row["이름"], row["규격"]) == (name, spec), key
assert row["값"]["가격"] == Decimal(str(price)) and row["값"]["손료계수"] == factor, key
assert "갱신 필요" in row["비고"] and row["출처"]["가격"] == "자체", key
gens = [r for r in eq.values() if r.get("상세구분") == "발전기"]
assert {int(r["값"]["손료계수"]) for r in gens} == {2362} # 발전기 계열 공통
small, big = eq["EQ000659"]["값"], eq["EQ000031"]["값"]
for k in ("가격", "상각비계수", "정비비계수", "관리비계수", "손료계수"):
assert small[k] == big[k], k
pipes = {r["규격"]: r for r in eq.values() if r.get("상세구분") == "콘크리트펌프용 파이프"}
assert set(pipes) == set(USD)
rate = m01.loaded()[1].index["FX"]["FX000001"]["값"]
for spec, usd in USD.items():
row = pipes[spec]
assert row["값"]["손료계수"] == 4500 and f"${usd} 표기" in row["비고"]
assert abs(float(row["값"]["가격"]) - usd * float(rate) / 1000) < 1e-3
trowel = eq["EQ000662"]["값"]
assert (trowel["주연료종류"], trowel["주연료"], trowel["잡재료율"], trowel["전력"]) == ("휘발유", 1, 10, Decimal("3.73")) # fmt: skip
def test_formula_loss_and_one_way() -> None:
docs, links = _docs()
files, whole = m01.loaded()
logic = {r["키"]: r for _, r in b802.rows_of(files)}
for key in sorted(NEW):
row = _eq()[key]
got = mh.hourly(key)
assert got["ok"], (key, got)
want = SEED.get(key, (None, None, None, None, "GC000273"))[4]
assert got["로직"] == want, key
lg = logic[want]
given = meq.given_of(whole, lg)
changed = {"기계": row["원문번호"]} if mh.takes_machine(lg) else None
new = b802.new_answer(links, docs[want], changed)
old = b802.old_answer(whole, lg, {**given, **(changed or {})})
assert old == new and float(new["계"]) == got["계"], key # 옛 엔진 = 새 문서 = 시간당 이음
assert new["경비"] == _loss(row["값"]["가격"], row["값"]["손료계수"]), (
key
) # 손료 = 가격 × 계수
if want == "GC000273":
assert new["노무비"] == new["재료비"] == 0, key
assert di.hourly_link("EQ000662", "재료비")["키"] == "GC001195"
assert di.hourly_link("EQ000656", "노무비")["입력"]["기계"] == "'자체 발전기 5.5㎾'"
def test_trowel_lines() -> None:
docs, links = _docs()
got = mc.calc(docs["GC001195"], links)
lines = {x["이름"]: x for x in got["줄"]}
assert set(lines) == {"주연료", "전력", "잡재료비", "기계손료"}
assert lines["전력"]["수량"] == Decimal("3.73") and lines["주연료"]["수량"] == 1
assert lines["잡재료비"]["금액"] == (lines["주연료"]["금액"] * Decimal("0.1")).quantize(Decimal("0.1"), "ROUND_DOWN") # fmt: skip
assert got["노무비"] == 0 and got["경비"] == _loss(2680, 5313)
def test_find_links() -> None:
cases = {
("발전기", "5.5kW"): "EQ000656", ("발전기", "7kW"): "EQ000657",
("발전기", "10kW"): "EQ000658", ("발전기", "25kW"): "EQ000452",
("에어 호스", "1.91cm"): "EQ000660", ("에어호스", "1.91cm"): "EQ000660",
("모노펌프", "5-20㎥/hr"): "EQ000661", ("모노펌프", ""): "EQ000661",
("브레이카", "0.18㎥"): "EQ000659", ("대형브레이카", "0.2㎥"): "EQ000031",
("파워트로웰", "3.73kW"): "EQ000662",
("콘크리트펌프용 파이프", "150mm×1m"): "EQ000663",
("콘크리트펌프용 파이프", "150㎜×3m"): "EQ000665",
("콘크리트펌프용 파이프", "곡관 45˚"): "EQ000666",
("콘크리트펌프용 파이프", "곡관 150㎜ 90°"): "EQ000668",
} # fmt: skip
with no_aliases("EQ"):
for (name, spec), key in cases.items():
got = mf.find(name, spec)
assert (got["판정"], got["키"]) == (mf.LINKED, key), (name, spec, got)
def test_other_docs_same() -> None:
"""새 고르기는 기계경비식 목록 끝에만 · 기본값은 옛 기계 그대로."""
text = (ROOT / "resources/master_data/로직_건설품셈_08장_건설기계.json").read_text("utf-8")
codes = {r["원문번호"] for k, r in _eq().items() if k in NEW}
for row in json.loads(text)["줄"]:
for spec in row.get("입력") or []:
pick = [str(c) for c in spec.get("고르기") or []]
mine = [i for i, c in enumerate(pick) if c in codes]
if mine:
assert mine == list(range(len(pick) - len(mine), len(pick))), row["키"]
assert pick[0] not in codes, row["키"]
old = [k for k, r in _eq().items() if k not in NEW]
assert not [k for k in old if str(_eq()[k].get("원문번호") or "").startswith("자체 ")]
def test_blank_input_name_link_roundtrip() -> None:
"""820-2 — 빈칸 든 입력 이름(「전력 계약종별」)이 이음 글 → 읽기 왕복 · EST main 504 번 = 저장 금액 · 우리 단가 셈."""
from M02_MasterTemplete import M02_San_Link as sl
from M02_MasterTemplete import M02_Sangeun_Imported as imp
text = sl.hourly_text("EQ000662")
assert "'전력 계약종별'=" in text
node = di.dc.cv.mf.parse(text)
assert node[0] == "logic" and "전력 계약종별" in [k for k, _ in node[2]]
assert imp._link(text, "로직", "시험", imp._whole()) == text
import test_810_2_native_doc as t
out = t.fn.emit(t.native("EST", "main"), 504)
sk, _, got = t.doc_and_money(out)
assert float(got["계"]) == 546 # 원파일 저장 금액
sk[imp.KEY]["단가기준"] = "우리"
ours = di.run(di.derive(sk, None, t.whole()), sk, t.whole())
assert ours.get("셈됨") is not False and ours["계"] is not None
+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
@@ -0,0 +1,144 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""821-3 내 기계 화면 읽기 길(`M02_MyMachine_Look`) — 임시 storage · 쓰기 없음.
① 양식 목록 = M01 기계 화면 목록과 같음(연료 · 직종 · 전력 · 시간환산)
② 비슷한 기계 찾기 = 내 기계(앞) + M01 · 원문번호 = 그 M01 기계 먼저 · 남의 층 내 기계 안 보임
③ 내 품목 = 값을 바로 적은 재료비 줄만 · 같은 품목 묶음 · 쓰는 단가산출 ④ 개인 층 밖 = 404.
"""
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_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete import M02_MyMachine_Look as look
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥
NAME = "서브원임시기계"
def _at(folder, fn, *args):
with store.at(folder):
return fn(*args)
def _mine(folder):
row = {"이름": NAME, "규격": "1㎾", "방식": "바로", "값": {"가격": 1000, "손료계수": 2000}}
return _at(folder, mym.create, row)["줄"]
def test_lists_same_as_m01():
"""① 양식 목록 = M01 기계 화면 값."""
from M01_MasterData import M01_MasterData_Machine as m01m
got, want = look.lists(), m01m.lists()
assert set(got) == {"fuels", "jobs", "power", "hour"}
for k in got:
assert got[k] == want[k], k
assert got["fuels"] and got["jobs"] and got["hour"] == 0.125
def test_find_mine_first_and_number(world):
"""② 내 기계 앞 · 원문번호 = 그 M01 기계 먼저 · 남의 층 = 내 기계 안 보임."""
me, other = world["personal"], layers.personal_dir(7, 43)
key = _mine(me)["키"]
rows = _at(me, look.find, NAME)
assert rows[0]["키"] == key and rows[0]["출처"] == "개인" and rows[0]["값"]["손료계수"] == 2000
assert all(r["출처"] == "M01" for r in _at(other, look.find, NAME))
no = str(m01.loaded()[1].get(DIG)["원문번호"])
first = _at(me, look.find, no)[0]
assert first["키"] == DIG and first["원문번호"] == no and first["출처"] == "M01"
assert isinstance(first["값"]["가격"], int | float) and first["구분"] # 수(글이면 미리보기 0)
dig = [r for r in _at(me, look.find, "굴착기") if r["출처"] == "M01"]
assert dig and len({r["키"] for r in dig}) == len(dig) # 겹침 없음
def _sk(folder, key, name, lines):
"""수식 문서 재료비 줄 — (이름, 연결) · 숫자 = 바로 적은 값 · 요소 = 우리 자재."""
vars_, money = [], []
for i, (item, link) in enumerate(lines):
vars_.append({"열쇠": f"P_x{i}", "기호": "P", "아래": f"x{i}", "위": "", "연결": link})
money.append(
{"이름": item, "규격": "10㎜", "단위": "개", "비목": "재료비", "단가": f"P_x{i}"}
)
money.append({"이름": "인부", "단위": "인", "비목": "노무비", "단가": "P_x0"})
doc = {"키": key, "이름": name, "수식": {"문서": {"변수": vars_, "금액": money}}}
path = folder / "sangeun" / f"{key}.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
def test_items_direct_only(world):
"""③ 바로 적은 값 재료만 · 같은 품목 = 한 묶음 · 쓰는 단가산출 · 값 다르면 따로."""
me = world["personal"]
_sk(me, "SK-000001", "가", [("볼트", {"갈래": "숫자", "값": 500}),
("시멘트", {"갈래": "요소", "키": "MT004176"})]) # fmt: skip
_sk(me, "SK-000002", "나", [("볼트", {"갈래": "숫자", "값": 500}),
("볼트", {"갈래": "입력", "값": 700})]) # fmt: skip
got = _at(me, look.items)
assert [(r["이름"], r["값"]) for r in got] == [("볼트", 500), ("볼트", 700)]
assert [u["키"] for u in got[0]["쓰는곳"]] == ["SK-000001", "SK-000002"]
assert got[1]["쓰는곳"] == [{"키": "SK-000002", "이름": "나"}]
assert got[0]["규격"] == "10㎜" and got[0]["단위"] == "개"
assert _at(layers.personal_dir(7, 43), look.items) == []
def test_personal_only(world):
"""④ 개인 층 밖 = 404 · 남의 개인 층 = 403(일반)."""
user = {"role": "USER", "company_id": 7, "user_id": 42}
for layer, owner, code in (("company", None, 404), ("personal", "7/43", 403)):
with pytest.raises(HTTPException) as e:
look.get_items(layer, owner, user)
assert e.value.status_code == code
assert look.get_items("personal", None, user) == {"rows": []}
def _stack_doc(key, no):
"""줄 쌓기 기계 줄 하나(화면이 저장하는 꼴) — 기계='개인 …'."""
var = {"기호": "P", "아래": "새", "위": "", "이름": NAME, "단위": "",
"연결": {"갈래": "단가산출", "키": "GC000268", "입력": {"기계": f"'{no}'"}}} # fmt: skip
line = {"이름": NAME, "단위": "hr", "비목": "묶음", "수량": "1", "단가": "P_새"}
return {"꼴": "수식문서", "판": 1, "키": key, "이름": "sub1임시", "결과단위": "원",
"변수": [var], "식": [], "금액": [line]} # fmt: skip
def test_stack_line_copy(world):
"""⑤ 줄 쌓기로 고른 내 기계 — 미리 셈 됨 · 저장 때 값 사본에 박힘 →
고쳐도 금액 그대로 + 단가새값 → [단가 갱신] 바뀐 자원 1 → 지워도 마지막 값 + 지운키."""
from M02_MasterTemplete import M02_Doc_Sk_Path as path
from M02_MasterTemplete import M02_Labor_Ref as refs
me = world["personal"]
made = _at(me, mym.create, {**_mine_row(), "값": {**_mine_row()["값"], "주연료": 2,
"주연료종류": "경유", "조종원": 1, "조종원직종": "1048"}}) # fmt: skip
key, no, was = made["줄"]["키"], made["줄"]["원문번호"], made["시간당"]["계"]
sk = _at(
me,
lambda: refs.create("sangeun", {"이름": "sub1임시", "단위": "시간", "더한줄": []})["이름"],
)
doc = _stack_doc(sk, no)
look_ = _at(me, path.preview, sk, {"문서": doc})
assert look_["셈"]["계"] == was # 사본에 아직 없는 내 기계도 셈
판 = _at(me, path.read, sk)["판"]
_at(me, path.save, sk, {"문서": doc, "판": 판})
copy = _at(me, lambda: store.read("sangeun", sk)["문서"]["수식"])
assert key in [r["키"] for r in copy["단가"]] # 값 사본에 박힘
_at(me, mym.update, key, {**made["줄"], "값": {**made["줄"]["값"], "가격": 3000}})
now = _at(me, path.read, sk)
assert now["셈"]["계"] == was and now["사본"]["단가새값"] is True
job = _at(me, path.preview, sk, {"할일": "단가갱신"})
assert [x["키"] for x in job["바뀐자원"]] == [key] and job["후"]["계"] > was
_at(me, mym.delete, key)
gone = _at(me, path.read, sk)
assert gone["셈"]["계"] == was and gone["사본"]["지운키"] == [key]
def _mine_row():
return {"이름": NAME, "규격": "1㎾", "방식": "바로", "값": {"가격": 1000, "손료계수": 2000}}
@@ -0,0 +1,238 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""821-4 지킴 — 불러오기에서 끝내 못 이은 기계 = 내 기계 자동 생성(요구서_796 4 · 5 장 · 임시 storage).
① 구성 → 우리 틀 값(EST 원파일 발전기 5.5kW — 손료계수 · 가격 · 경유 0.625ℓ · 잡품 11 % · 일반기계운전사 1 인 ·
경쟁사 노임 환산 안 가져옴) · 엑셀 「기계경비」 · STmate 「중기사용료」 · 구성 없음 = 직접 꼴 · 달러 = 환율
② 층 저장 길(`FromHopyo.make`) = 만듦 · 다시 불러와도 중복 없음 · 같은 이름 있으면 그것 · 미리 보기는 안 만듦 ·
금액(엑셀 단가 그대로) 그대로 · [우리 단가로] = 내 기계 값 · 「못 이음」 에서 빠짐 · 사본에 박힘
③ 820-1 정리 — 13 줄 · GC001195 없음(대장 폐기 · 결번) · 덤프 1 톤 · 체인톱 식 · 별칭 그대로 ·
빈칸 든 입력 이름 이음 글 왕복(820-2 지킴 옮김) · EST main 504 파워트로웰 블록 금액 546 그대로
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Doc_FromNative as fn
from M02_MasterTemplete import M02_Doc_Import as dim
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_San_Book as book
from M02_MasterTemplete import M02_San_Estx as estx
from M02_MasterTemplete import M02_San_MyMachine as mm
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
from M02_MasterTemplete import M02_Sangeun_Imported as imp
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
from resources.tester.test_815_1_import_link import aliases, no_aliases # M01 별칭 칸 임시
ROOT = Path(__file__).resolve().parents[2]
MAIN = ROOT / "resources/knowledge/original/경쟁사 프로그램/EST Plus 2/다산소프트/2026-07-main.estx"
GEN = "E0000001016" # EST main 발전기 5.5kW
GONE = [f"EQ{n:06d}" for n in range(656, 669)]
@pytest.fixture(autouse=True)
def _blank_aliases(request):
"""진짜 마스터 기계 별칭(파워트로웰 = EQ000534 등) 유무와 무관 — 별칭 없는 사본에서 잼(820-1 정리 시험만 진짜 마스터)."""
if request.node.name == "test_cleanup_820_1":
yield
return
with no_aliases("EQ"):
yield
@lru_cache(maxsize=1)
def _main_raw() -> bytes:
return MAIN.read_bytes()
@lru_cache(maxsize=1)
def _main() -> dict:
return fn.read(MAIN.name, _main_raw())
def _excel(tag: str) -> dict:
import test_797_1a_import_flags as practice
p = practice.FILES.get(tag)
if not p or not p.exists():
pytest.skip(f"실무 엑셀 없음 {tag}")
return book.sheets(p.name, p.read_bytes())
def _line(name: str, spec: str, **res) -> dict:
found = {"판정": "못 찾음", "이름": name, "규격": spec}
return {"종류": "기계", "이름": name, "자원": {"갈래": "기계", "이름": name, "규격": spec, "찾기": found, **res}} # fmt: skip
def _labor(name: str) -> str:
return next(k for k, r in m01.loaded()[1].numbers["LB"].items() if r["이름"] == name)
# ── ① 구성 → 우리 틀 값 ───────────────────────────────────────────────
def test_est_native_generator_values() -> None:
make = estx.makes # 816-2 대조 꼴은 그대로(손료 · 주연료 · 조종원 있음만)
assert make is not None
got = _main()["기계구성"][GEN]
assert got["손료"] == [[2362.0, 1063000.0, False]] and got["주연료"] == 0.625
assert (got["주연료종류"], got["잡재료율"], got["조종원"], got["조종원직종"]) == ("경유", 11.0, 1.0, "일반기계운전사") # fmt: skip
body = mm.body(got, 0, _line("발전기", "5.5kW", 코드=GEN), "main.estx")
want = {"가격": 1063.0, "손료계수": 2362.0, "주연료": 0.625, "주연료종류": "경유", "잡재료율": 11.0,
"조종원": 1.0, "조종원직종": _labor("일반기계운전사")} # fmt: skip
assert body["방식"] == "바로" and body["값"] == want
assert body["비고"].startswith("불러오기 자동 생성 — main.estx · 값 확인 필요")
def test_dollar_and_combo_pieces() -> None:
made = _main()["기계구성"]
pipe = mm.body(made["E0000000623"], 0, _line("콘크리트펌프용 파이프", "150㎜×1m"), "f")
rate = float(m01.loaded()[1].index["FX"]["FX000001"]["값"])
assert abs(pipe["값"]["가격"] - 25 * rate / 1000) < 1e-3 and "$25" in pipe["비고"]
combo = made["E0000001170"] # 대형브레이카 +굴착기(1.0㎥) — 조각 둘
first = mm.body(combo, 0, _line("굴착기", "1.0㎥"), "f")["값"]
second = mm.body(combo, 1, _line("대형브레이카", "1.0㎥"), "f")["값"]
assert first["손료계수"] == 2085 and first["주연료"] == 19.5 and first["조종원"] == 1
assert second == {"가격": 28692.0, "손료계수": 6601.0} # 다음 조각 = 손료만
assert mm.body(None, 1, _line("x", "1"), "f") is None # 구성 없는 조합 조각 = 안 만듦
def test_excel_sheets() -> None:
est = mm.xlsx_makes(_excel("2016사방"))[mm.key_of("발전기", "5.5kW")]
assert est["손료"] == [[2294.0, 1167000.0, False]] and est["주연료"] == 0.625
assert (est["잡재료율"], est["조종원"], est["조종원직종"]) == (11.0, 1.0, "일반기계운전사")
stm = mm.xlsx_makes(_excel("봉화"))["X00001"] # STmate 중기사용료 — 계수(천원당) × 10⁴
assert stm["손료"] == [[2085.0, 62550000.0, False]]
assert (stm["주연료종류"], stm["주연료"], stm["잡재료율"], stm["조종원직종"]) == ("경유", 5.0, 21.0, "건설기계운전사") # fmt: skip
def test_direct_without_make() -> None:
line = _line("소형기계", "1대", 단가={"L": 10, "M": 20, "S": 30})
got = mm.body(None, 0, line, "a.stc")
assert got["방식"] == "직접" and got["시간당"] == {"노무비": 10, "재료비": 20, "경비": 30}
# ── ② 층 저장 길 ──────────────────────────────────────────────────────
TROWEL = 504 # EST main 「콘크리트 바닥 마감(Power Trowel)」 — 파워트로웰 3.73kW = 별칭 없는 사본에서 못 이음(별칭 있으면 EQ000534)
WANT = ("파워트로웰", "3.73kW")
def test_generator_ours_formula(world) -> None:
"""발전기 5.5kW 내 기계 시간당 노무비 = 우리 노임 ÷ 8 × 1 인(EST 의 ×16/12 ×25/20 안 씀)."""
me = world["personal"]
me.mkdir(parents=True, exist_ok=True)
body = mm.body(_main()["기계구성"][GEN], 0, _line("발전기", "5.5kW"), "main.estx")
with store.at(me):
key = mym.create(body)["줄"]["키"]
hour = mh.hourly(key)
lb = m01.loaded()[1].numbers["LB"][_labor("일반기계운전사")]
assert hour["ok"] and hour["로직"] == "GC000268"
assert abs(hour["노무비"] - float(lb["값"]) / 8) < 1
def test_make_flow(world) -> None:
me = world["personal"]
me.mkdir(parents=True, exist_ok=True)
with store.at(me):
hp = fh.upload_native(me, MAIN.name, _main_raw(), {"이름": "sub3임시"})["키"]
out = fh.emit(me, hp, TROWEL) # 미리 보기 = 안 만듦 · 예정만
assert {"이름": WANT[0], "규격": WANT[1]} in out["원본"]["내기계예정"]
assert mym.rows() == []
got = fh.make(me, hp, TROWEL)
made = {(m["이름"], m["규격"]): m for m in got["내기계"]}
one = made[WANT]
assert one["새로"] and one["키"].startswith("EQP42-") and one["방식"] == "바로"
assert got["검산"]["같음"] # 금액(엑셀 단가 그대로) 그대로
n = len(mym.rows())
again = fh.make(me, hp, TROWEL) # 다시 불러옴 = 중복 없음 · 같은 SK
assert len(mym.rows()) == n and again["다시씀"]
assert not any(m["새로"] for m in again["내기계"])
doc = store.read("sangeun", got["이름"])["문서"]
assert doc[imp.KEY]["원본"]["내기계"][0]["키"] == one["키"]
items = doc[imp.KEY]["로직"]["호표"]
mine = [x for x in items if (x.get("자원") or {}).get("내기계") == one["키"]]
assert mine and all(f"개인 {one['키']}" in str(x.get("이음")) for x in mine)
assert not [
u for u in imp.unlinked(doc) if "파워트로웰" in u["이름"]
] # 「못 이음」 에서 빠짐
row = next(r for r in mym.rows() if r["키"] == one["키"])
assert row["값"]["가격"] == 2680 and row["값"]["주연료종류"] == "휘발유"
assert row["값"]["전력"] == 3.73 and "값 확인 필요" in row["비고"]
ours = imp.to_price(doc, "우리") # [우리 단가로] = 내 기계 값
assert ours["후"]["계"] is not None and ours["후"] != ours["전"]
assert not [u for u in ours["이음없음"] if "파워트로웰" in u["이름"]]
assert one["키"] in {r["키"] for r in (doc.get(skc.KEY) or {}).get("단가") or []} # 사본
conv = dim.derive(doc, None, imp._whole())
assert f"내 기계 {one['키']}" in json.dumps(
conv["변수"], ensure_ascii=False, default=str
) # 꼬리표
def test_reuse_same_name(world) -> None:
me = world["personal"]
me.mkdir(parents=True, exist_ok=True)
with store.at(me):
have = mym.create({"이름": WANT[0], "규격": WANT[1], "방식": "바로", "값": {"가격": 9, "손료계수": 9}}) # fmt: skip
out = fn.emit(_main(), TROWEL)
made = mm.attach(out, _main()["기계구성"])
assert [(m["키"], m["새로"]) for m in made] == [(have["줄"]["키"], False)]
assert len(mym.rows()) == 1
def test_not_personal_makes_nothing(world) -> None:
out = fn.emit(_main(), TROWEL)
with store.at(world["company"]):
assert mm.attach(out, _main()["기계구성"]) == []
assert out["원본"]["내기계예정"] == [{"이름": WANT[0], "규격": WANT[1]}]
# ── ③ 820-1 정리 ─────────────────────────────────────────────────────
def test_cleanup_820_1() -> None:
files, whole = m01.loaded()
eq = whole.index["EQ"]
assert not set(GONE) & set(eq) and "EQ000655" in eq and eq["EQ000655"]["규격"] == "1ton"
assert not [r for r in eq.values() if str(r.get("원문번호") or "").startswith("자체 ")]
logics = {r["키"] for d in files.values() if d.get("그룹") == "로직" for r in d.get("줄") or []}
assert "GC001195" not in logics and "GF000337" in logics and "GF000145" in logics
text = json.dumps(
[d for d in files.values() if d.get("그룹") == "로직"], ensure_ascii=False, default=str
)
assert "자체 발전기" not in text and "자체 콘크리트펌프용" not in text
led = json.loads((ROOT / "resources/master_data/_키대장.json").read_text("utf-8"))
assert all(k in led["폐기"] and k not in led["키"] for k in [*GONE, "GC001195"])
assert led["다음"]["EQ"] == 669 and led["다음"]["GC"] == 1196 # 결번 · 재사용 안 함
alias = [r for r in eq.values() if r.get("별칭")]
assert len(alias) >= 10
def test_blank_input_name_roundtrip() -> None:
"""820-2 지킴 옮김 — 빈칸 든 입력 이름은 따옴표로 이음 글 → 읽기 왕복."""
text = f"로직(GC000268, {dim._q('전력 계약종별')}='일반용(갑)Ⅰ 저압', 기계='0101-0007')"
got = dim._logic_link(text)
assert got and got["입력"]["전력 계약종별"] == "'일반용(갑)Ⅰ 저압'"
def test_main_504_amount_same(world) -> None:
"""EST main 504 = 원파일 저장 금액 546 그대로 — 별칭 없는 사본 = 내 기계 예정 · 별칭 있으면 품셈 EQ000534."""
import test_810_2_native_doc as t
def plan() -> tuple[dict, list]:
out = fn.emit(_main(), 504)
with store.at(world["company"]): # 개인 층 아님 = 안 만들고 예정만 적음
mm.attach(out, _main()["기계구성"])
return out, out["원본"].get("내기계예정") or []
out, todo = plan()
assert {"이름": WANT[0], "규격": WANT[1]} in todo
assert float(t.doc_and_money(out)[2]["계"]) == 546
with aliases("EQ", {"EQ000534": "파워트로웰"}): # 승인 별칭 = 품셈 기계로 이어짐
out, todo = plan()
assert not [m for m in todo if m["이름"] == WANT[0]]
assert "EQ000534" in json.dumps(out, ensure_ascii=False, default=str)
assert float(t.doc_and_money(out)[2]["계"]) == 546
+278
View File
@@ -0,0 +1,278 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""822-1 사본 빈틈 막기(요구 797-1) — 길마다: 만들기 → [저장] → M01 값(임시 · 메모리 덮개)을
기계 가격 · 노임 · 유가 · 자재 값으로 바꿈 → 금액 그대로 + 「단가새값」 → [단가 갱신] → 반영.
길 = 수식 길 줄 쌓기(품셈 기계 · 바꿔 끼운 기계 · 자원 줄 · 단가산출 참조) ·
옛 로직 길(더한줄 로직 · 재료 · 인력) · 가져온 식(단가기준 우리) · 층 복사 · [재생성] ·
빈틈 SK 표시(`사본없음`).
M01 바꿈 = `skc.Copy` 아닌 진짜 색인 사본(`dc.base` 가 못 벗김) · 정본 파일 안 고침.
"""
from __future__ import annotations
import copy
import json
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Doc_Sk_Path as path
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
DIG, DIG_NO, DIG_LOGIC = "EQ000022", "0201-0100", "GC000267" # 굴착기(무한궤도) 1.0㎥
DIG2_NO = "0201-0070" # 바꿔 끼울 굴착기
MAN = "LB000002" # 보통인부
PCT = "GC001109" # 금속기와 해체(인력 둘 + 노무비 3 %)
def _live():
return m01.loaded()[1]
def _move(monkeypatch, *keys: str) -> None:
"""M01 값 두 배 — 키마다 값 칸(기계 = 가격 · 자재 = 물가 칸) · 진짜 색인 사본(Copy 아님) ·
수식 문서 폴더 그대로."""
files, live = m01.loaded()
fake = copy.copy(live)
fake.index, fake.numbers = dict(live.index), dict(live.numbers)
for key in keys:
tid = key[:2]
row = copy.deepcopy(live.index[tid][key])
if tid == "EQ":
row["값"]["가격"] *= 2
elif tid == "MT":
for k in ("물가자료", "유통물가", "물가정보", "거래가격"):
row[k] = row[k] * 2 if row.get(k) else row.get(k)
else:
row["값"] *= 2
fake.index[tid] = {**fake.index[tid], key: row}
if row.get("원문번호"):
fake.numbers[tid] = {**fake.numbers[tid], str(row["원문번호"]): row}
monkeypatch.setattr(m01, "loaded", lambda: (files, fake))
def _at(folder, fn, *args):
with store.at(folder):
return fn(*args)
def _sk(folder, **body) -> str:
body = {"이름": "sub1임시 빈틈", "단위": "시간", "더한줄": [], **body}
return _at(folder, lambda: refs.create("sangeun", body)["이름"])
def _var(sym: str, link: dict, name: str = "") -> dict:
return {"기호": "P", "아래": sym, "위": "", "이름": name or sym, "단위": "", "연결": link}
def _line(sym: str, part: str = "경비") -> dict:
return {"이름": sym, "단위": "hr", "비목": part, "수량": "1", "단가": f"P_{sym}"}
def _stack(folder, key, rows: list[tuple[str, dict, str]]) -> None:
"""줄 쌓기 — 지금 문서에 (기호, 연결, 비목) 줄을 더해 수식 길로 저장."""
got = _at(folder, path.read, key)
doc = copy.deepcopy(got["문서"])
doc.setdefault("변수", []).extend(_var(s, link) for s, link, _ in rows)
doc.setdefault("금액", []).extend(_line(s, part) for s, _, part in rows)
_at(folder, path.save, key, {"문서": doc, "판": got["판"]})
def _sum(folder, key):
return _at(folder, path.read, key)["셈"]["계"]
def _price(folder, key) -> dict:
"""[단가 갱신] 저장 — 바뀐 자원 키 · 뒤 금액."""
look = _at(folder, path.preview, key, {"할일": skc.PRICE})
판 = _at(folder, path.read, key)["판"]
_at(folder, path.save, key, {"할일": skc.PRICE, "판": 판})
return {"바뀐": sorted(x["키"] for x in look["바뀐자원"]), "후": _sum(folder, key)}
MACHINE = {"갈래": "단가산출", "키": DIG_LOGIC, "입력": {"기계": f"'{DIG_NO}'"}}
def _fuel_and_crew(no: str = DIG_NO) -> tuple[str, str]:
"""기계 시간당 식이 읽는 유가(MO) · 조종원 노임(LB) 키."""
w = _live()
row = w.numbers["EQ"][no]
fuel = w.numbers["MO"][f"{row['값']['주연료종류']}:전국평균"]["키"]
crew = w.numbers["LB"][str(row["값"]["조종원직종"])]["키"]
return fuel, crew
# ── 수식 길 줄 쌓기 ───────────────────────────────────────────────────
@pytest.mark.parametrize("what", ["가격", "유가", "노임"])
def test_stack_m01_machine(world, monkeypatch, what):
"""품셈 기계(굴착기 1.0㎥) 줄 쌓기 → 저장 → 기계 가격 · 유가 · 조종원 노임 바뀜 →
금액 그대로 + 단가새값 → [단가 갱신] → 반영."""
me = world["personal"]
key = _sk(me)
_stack(me, key, [("기계", MACHINE, "묶음")])
was = _sum(me, key)
assert was > 0
fuel, crew = _fuel_and_crew()
moved = {"가격": DIG, "유가": fuel, "노임": crew}[what]
_move(monkeypatch, moved)
now = _at(me, path.read, key)
assert now["셈"]["계"] == was and now["사본"]["단가새값"] is True
got = _price(me, key)
assert got["바뀐"] == [moved] and got["후"] > was
def test_stack_swapped_machine(world, monkeypatch):
"""바꿔 끼운 기계 — 기계 입력을 다른 굴착기로 바꿔 저장 → 그 기계 값이 사본에."""
me = world["personal"]
key = _sk(me)
_stack(me, key, [("기계", MACHINE, "묶음")])
got = _at(me, path.read, key)
doc = copy.deepcopy(got["문서"])
for v in doc["변수"]:
if (v.get("연결") or {}).get("키") == DIG_LOGIC:
v["연결"]["입력"] = {"기계": f"'{DIG2_NO}'"}
_at(me, path.save, key, {"문서": doc, "판": got["판"]})
was = _sum(me, key)
other = _live().numbers["EQ"][DIG2_NO]["키"]
_move(monkeypatch, other)
assert _sum(me, key) == was
assert _price(me, key)["바뀐"] == [other]
@pytest.mark.parametrize("kind", ["인력", "재료"])
def test_stack_resource(world, monkeypatch, kind):
"""자원 줄 더하기(노임 · 자재 요소) — 저장 뒤 값 바뀌어도 그대로 · 갱신 뒤 반영."""
me = world["personal"]
res = MAN if kind == "인력" else next(iter(_live().index["MT"]))
key = _sk(me)
part = "노무비" if kind == "인력" else "재료비"
_stack(me, key, [("자원", {"갈래": "요소", "키": res}, part)])
was = _sum(me, key)
assert was > 0
_move(monkeypatch, res)
assert _sum(me, key) == was
assert _price(me, key)["바뀐"] == [res]
def test_stack_logic_ref(world, monkeypatch):
"""단가산출 참조(M01 단가산출을 부름) — 그 식이 읽는 노임도 사본에."""
me = world["personal"]
key = _sk(me)
_stack(me, key, [("참조", {"갈래": "단가산출", "키": PCT, "입력": {}}, "묶음")])
was = _sum(me, key)
assert was > 0
_move(monkeypatch, MAN)
assert _sum(me, key) == was
assert MAN in _price(me, key)["바뀐"]
# ── 옛 로직 길(더한줄 · 파생) ────────────────────────────────────────
OLD_ADDS = {
"로직 기계": ([{"종류": "로직", "로직": DIG_LOGIC, "입력값": {"기계": DIG_NO}, "수량": 1}],
{"기계": DIG_NO}, DIG),
"재료 요소": ([{"종류": "재료", "요소": "MT000008", "수량": 1}], {}, "MT000008"),
"인력 요소": ([{"종류": "인력", "요소": MAN, "수량": 1}], {}, MAN),
} # fmt: skip
@pytest.mark.parametrize("name", list(OLD_ADDS))
def test_old_added_lines(world, monkeypatch, name):
"""옛 로직 길 더한줄(로직 기계 · 재료 요소 · 인력 요소) — 저장 뒤 그대로 · 갱신 뒤 반영."""
me = world["personal"]
adds, given, moved = OLD_ADDS[name]
key = _sk(me, 더한줄=adds, 입력값=given)
was = _sum(me, key)
assert was > 0
assert _at(me, path.read, key)["사본"]["사본없음"] == 0
_move(monkeypatch, moved)
assert _sum(me, key) == was
got = _price(me, key)
assert moved in got["바뀐"] and got["후"] > was
# ── 가져온 식 · 층 복사 · 재생성 ──────────────────────────────────────
def test_imported_ours(world, monkeypatch):
"""가져온 식 단가기준 우리 — 이음 노임 바뀌어도 그대로 · 갱신 뒤 반영 · 엑셀 기준 = 엑셀 값."""
from M02_MasterTemplete import M02_Sangeun_Imported as imp
me = world["personal"]
man = {"종류": "인력", "이름": "보통인부", "단위": "인", "수량": "0.5", "비목": "노무비"}
row = {
"이름": "sub1임시 가져온",
"결과단위": "원/㎡",
"호표": [{**man, "단가": 165545, "이음": MAN}],
}
key = _at(me, lambda: imp.create(row, None, "sub1임시 가져온")["이름"])
assert _sum(me, key) == 82772 # 엑셀 단가 그대로
doc = _at(me, lambda: store.read("sangeun", key)["문서"])
ours = _at(me, lambda: imp.to_price(doc, "우리")["문서"])
_at(me, lambda: store.write("sangeun", key, store.read("sangeun", key)["판"], ours))
was = _sum(me, key)
_move(monkeypatch, MAN)
assert _sum(me, key) == was
doc = _at(me, lambda: store.read("sangeun", key)["문서"])
got = _at(me, skc.preview, doc, skc.PRICE)
assert [x["키"] for x in got["바뀐자원"]] == [MAN] and got["후"]["계"] > was
def test_layer_copy_keeps_copy(world, monkeypatch):
"""층 복사(개인 → 회사) — 줄 쌓기 기계 사본째 · 원본 M01 값 바뀐 뒤 복사해도 그 값."""
me, co = world["personal"], world["company"]
key = _sk(me)
_stack(me, key, [("기계", MACHINE, "묶음")])
was = _sum(me, key)
_move(monkeypatch, DIG)
ref = {"층": "personal", "주인": "7/42", "키": key}
with store.at(co):
doc = refs.read_ref(ref)
new = refs.create("sangeun", refs.carry(refs.origin_mark(doc, ref), ("personal", "7/42")))
assert _sum(co, new["이름"]) == was
assert _at(co, path.read, new["이름"])["사본"]["단가새값"] is True
def test_regen_follows_m01(world, monkeypatch):
"""[재생성] — 지금 M01 로 다시 찍음(따로 문서 버림) · 그 뒤 M01 바뀌어도 그대로."""
me = world["personal"]
key = _sk(me, 바탕={"로직": PCT}, 단위="㎡")
_stack(me, key, [("기계", MACHINE, "묶음")])
_move(monkeypatch, MAN)
판 = _at(me, path.read, key)["판"]
_at(me, path.save, key, {"할일": skc.REGEN, "판": 판})
after = _at(me, path.read, key)
assert after["사본"]["따로"] is False and after["사본"]["사본없음"] == 0
assert not after["사본"]["단가새값"]
was = after["셈"]["계"]
_move(monkeypatch, MAN) # 한 번 더(네 배)
assert _sum(me, key) == was
def test_gap_mark_then_save_fills(world, monkeypatch):
"""빈틈 있는 옛 SK — 열면 지금 값으로 셈 + 「사본없음 n」 · 일괄 고쳐 쓰지 않음 ·
[저장] 하면 지금 값으로 굳음(n = 0) · 그 뒤 M01 바뀌어도 그대로."""
me = world["personal"]
key = _sk(me)
_stack(me, key, [("기계", MACHINE, "묶음")])
file = me / "sangeun" / f"{key}.json"
raw = json.loads(file.read_text(encoding="utf-8"))
vals = raw["문서"]["수식"] if "문서" in raw else raw["수식"]
for part in ("로직", "단가"):
vals[part] = [] # 옛 저장본 흉내 — 줄 쌓기 기계를 사본에 안 박은 때
file.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8")
before = file.read_bytes()
got = _at(me, path.read, key)
assert got["사본"]["사본없음"] > 0
was = got["셈"]["계"]
assert file.read_bytes() == before # 열기 = 안 고쳐 씀
files, live = m01.loaded()
_move(monkeypatch, DIG)
moved = _sum(me, key)
assert moved > was # 빈틈 = 지금 값으로 셈
got = _at(me, path.read, key)
_at(me, path.save, key, {"문서": got["문서"], "판": got["판"]})
now = _at(me, path.read, key)
assert now["사본"]["사본없음"] == 0 and now["셈"]["계"] == moved
monkeypatch.setattr(m01, "loaded", lambda: (files, live)) # 앞 값(절반)
assert _sum(me, key) == moved
+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;
}
}
+518
View File
@@ -0,0 +1,518 @@
/* =============================================================================
* 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;
/** 손료계수 방식 · 「시간당 값 직접」 꼴인지(내 기계 저장 방식) */
mode: "direct" | "pumsem";
hourly: 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(),
mode,
hourly: hourlyMode,
}),
setNotice,
};
}
+5
View File
@@ -94,6 +94,11 @@
width: min(1100px, 94vw);
height: var(--modal-max);
}
/* 확인창 글 — 줄바꿈(
) 그대로(쓰는 곳 목록 · 바뀐 자원 줄) */
.ui-confirm__message {
white-space: pre-line;
}
.ui-confirm__title {
margin: 0 0 var(--spacing-8);
font-size: var(--text-body);
+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) },