Merge remote-tracking branches 'origin/CODEX' and 'origin/main_laptop_1' into sub_laptop_2
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""M01 기계 화면(PLAN 821-2) — 양식 목록 · 시간당 경비(서버 값) · 쓰는 곳 · 비슷한 기계 찾기 ·
|
||||
저장 전 기계 줄 손질(계수 서버 재계산 · 단위 · 출처 채움).
|
||||
|
||||
계수 = `master_machine_coef`(TS 짝 `common_util/common_util_machine_coef.ts`) ·
|
||||
시간당 = 서버 기계경비식(`M02_Machine_Hourly.hourly` · 전국평균 · 기본 옵션) ·
|
||||
마스터 파일은 읽기만 — 쓰기는 `Store_Make.save`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import master_machine_coef as mc # noqa: E402 (`store` 가 scripts 를 길에 둠)
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
|
||||
FILE = "기계.json"
|
||||
OWN_SOURCE = "자체"
|
||||
_EQ = re.compile(r"EQ\d{6}")
|
||||
_cache: dict[str, Any] = {"ver": None, "hourly": {}, "uses": None}
|
||||
|
||||
|
||||
def _rows() -> list[dict]:
|
||||
return store.read(FILE)[0].get("줄") or []
|
||||
|
||||
|
||||
def _fresh() -> None:
|
||||
"""마스터를 다시 읽었으면(저장 뒤) 셈 보관을 비움."""
|
||||
ver = id(store.loaded()[1])
|
||||
if _cache["ver"] != ver:
|
||||
_cache.update(ver=ver, hourly={}, uses=None)
|
||||
|
||||
|
||||
def _plain(v):
|
||||
if isinstance(v, Decimal):
|
||||
return int(v) if v == v.to_integral_value() else float(v)
|
||||
return v
|
||||
|
||||
|
||||
# ── 양식 목록 ────────────────────────────────────────────────────────
|
||||
def lists() -> dict:
|
||||
"""연료(유가 전국평균) · 직종(인력) · 전력 단가 · 시간환산 · 원문 · 구분 — 양식 고르기."""
|
||||
files = store.loaded()[0]
|
||||
oil = files.get("재료_유가전력.json", {}).get("줄") or []
|
||||
fuels = [
|
||||
{
|
||||
"value": str(r["원문번호"]).split(":")[0],
|
||||
"text": str(r["원문번호"]).split(":")[0],
|
||||
"price": _plain(r.get("값")),
|
||||
}
|
||||
for r in oil
|
||||
if r.get("구분") == "유류" and str(r.get("원문번호", "")).endswith(":전국평균")
|
||||
]
|
||||
power = next(
|
||||
(_plain(r.get("값")) for r in oil if r.get("원문번호") == "일반용갑I저압:전력량_평균"), 0
|
||||
)
|
||||
jobs = [
|
||||
{
|
||||
"value": str(r["원문번호"]),
|
||||
"text": f"{r['원문번호']} {r.get('이름') or ''}",
|
||||
"price": _plain(r.get("값")),
|
||||
}
|
||||
for r in files.get("인력.json", {}).get("줄") or []
|
||||
if isinstance(r.get("값"), (int, float, Decimal))
|
||||
]
|
||||
head = store.read(FILE)[0]
|
||||
hour = Decimal(1) / Decimal(8)
|
||||
for t in files.get("계수_건설품셈_08장_건설기계.json", {}).get("표") or []:
|
||||
if t.get("키") == "CC000336":
|
||||
got = next((x for x in t.get("줄") or [] if x.get("구분") == "시간환산"), None)
|
||||
if got:
|
||||
hour = Decimal(got["분자"]) / Decimal(got["분모"])
|
||||
return {
|
||||
"fuels": fuels,
|
||||
"jobs": jobs,
|
||||
"power": power,
|
||||
"hour": float(hour),
|
||||
"books": list(head.get("원문") or {}),
|
||||
"groups": {k: (v or {}).get("원문") for k, v in (head.get("구분") or {}).items()},
|
||||
}
|
||||
|
||||
|
||||
# ── 시간당 경비 ──────────────────────────────────────────────────────
|
||||
def hourly(keys: list[str]) -> dict[str, dict]:
|
||||
"""키마다 {노무비 · 재료비 · 경비 · 계} 또는 {reason} — 마스터 판이 같으면 한 번만 셈."""
|
||||
from M02_MasterTemplete import M02_Machine_Hourly as mh # M02 가 M01 을 부름 — 늦게
|
||||
|
||||
_fresh()
|
||||
out = {}
|
||||
for key in keys[:100]:
|
||||
if key not in _cache["hourly"]:
|
||||
got = mh.hourly(key)
|
||||
_cache["hourly"][key] = (
|
||||
{k: got[k] for k in ("노무비", "재료비", "경비", "계", "로직")}
|
||||
if got.get("ok")
|
||||
else {"reason": got.get("reason")}
|
||||
)
|
||||
out[key] = _cache["hourly"][key]
|
||||
return out
|
||||
|
||||
|
||||
# ── 쓰는 곳 ─────────────────────────────────────────────────────────
|
||||
def _logic_index() -> tuple[dict[str, list[str]], dict[str, list[str]]]:
|
||||
"""(원문번호 → 로직 「키 이름」 · 기계 키 → 로직) — 기계 고르기 · 식 안 EQ 키."""
|
||||
_fresh()
|
||||
if _cache["uses"] is None:
|
||||
by_code: dict[str, list[str]] = {}
|
||||
by_key: dict[str, list[str]] = {}
|
||||
for data in store.loaded()[0].values():
|
||||
if data.get("그룹") != "로직":
|
||||
continue
|
||||
for row in data.get("줄") or []:
|
||||
label = f"로직 {row.get('키')} {row.get('이름') or ''}".strip()
|
||||
for i in row.get("입력") or []:
|
||||
if i.get("이름") == "기계":
|
||||
for c in i.get("고르기") or []:
|
||||
by_code.setdefault(str(c), []).append(label)
|
||||
text = json.dumps(row, ensure_ascii=False, default=str)
|
||||
for k in set(_EQ.findall(text)):
|
||||
by_key.setdefault(k, []).append(label)
|
||||
_cache["uses"] = (by_code, by_key)
|
||||
return _cache["uses"]
|
||||
|
||||
|
||||
def uses(key: str) -> list[str]:
|
||||
"""그 기계를 쓰는 곳 — 로직(기계 고르기 · 식) · M02 단가산출(사본 · 줄에 키)."""
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게
|
||||
|
||||
row = next((r for r in _rows() if r.get("키") == key), None)
|
||||
if row is None:
|
||||
raise store.StoreError(404, f"없는 기계 「{key}」")
|
||||
by_code, by_key = _logic_index()
|
||||
code = str(row.get("원문번호") or "")
|
||||
out = list(dict.fromkeys([*(by_code.get(code, []) if code else []), *by_key.get(key, [])]))
|
||||
mark = f'"{key}'.encode()
|
||||
for where, path in layers.layer_files("sangeun"):
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
if mark in raw:
|
||||
try:
|
||||
name = json.loads(raw).get("이름") or path.stem
|
||||
except ValueError:
|
||||
name = path.stem
|
||||
out.append(f"M02 단가산출 {where} {name}")
|
||||
return out
|
||||
|
||||
|
||||
# ── 비슷한 기계 찾기 · 한 줄 ─────────────────────────────────────────
|
||||
def find(q: str, limit: int = 30) -> list[dict]:
|
||||
words = [w for w in q.lower().split() if w]
|
||||
out = []
|
||||
for r in _rows():
|
||||
hay = " ".join(str(r.get(k) or "") for k in ("이름", "규격", "별칭", "상세구분", "키"))
|
||||
hay = hay.lower()
|
||||
if all(w in hay for w in words):
|
||||
out.append(_brief(r))
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _brief(r: dict) -> dict:
|
||||
keep = ("키", "원문번호", "원문", "구분", "상세구분", "이름", "규격", "값", "별칭", "비고")
|
||||
return {k: r[k] for k in keep if k in r}
|
||||
|
||||
|
||||
def row(key: str) -> dict:
|
||||
r = next((x for x in _rows() if x.get("키") == key), None)
|
||||
if r is None:
|
||||
raise store.StoreError(404, f"없는 기계 「{key}」")
|
||||
return {"version": store.read(FILE)[1], "row": r}
|
||||
|
||||
|
||||
# ── 저장 전 손질(`Store_Make.save` 가 부름) ──────────────────────────
|
||||
def _units() -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for r in _rows():
|
||||
for k, u in (r.get("단위") or {}).items():
|
||||
out.setdefault(k, u)
|
||||
return out
|
||||
|
||||
|
||||
def _same_inputs(a: dict, b: dict) -> bool:
|
||||
return all(
|
||||
float(a[k]) == float(b[k]) if isinstance(b.get(k), (int, float, Decimal)) else False
|
||||
for k in mc.INPUTS
|
||||
)
|
||||
|
||||
|
||||
def fix(part: dict) -> None:
|
||||
"""기계 줄 고침 · 더함 — 계수는 서버가 짝으로 다시 셈(품셈 입력이 저장 값과 같으면
|
||||
저장 값 그대로) · 단위 · 출처 묶음 = 값 칸과 같은 칸
|
||||
(있던 칸 그대로 · 새 칸 = 마스터 단위 · 출처 「자체」)."""
|
||||
if part.get("file") != FILE:
|
||||
return
|
||||
stored = {str(r.get("키")): r for r in _rows()}
|
||||
units = _units()
|
||||
for ch in part.get("changes") or []:
|
||||
row = ch.get("row")
|
||||
if (
|
||||
ch.get("op") == "delete"
|
||||
or not isinstance(row, dict)
|
||||
or not isinstance(row.get("값"), dict)
|
||||
):
|
||||
continue
|
||||
old = stored.get(str(ch.get("key") or "")) if ch.get("op") == "edit" else None
|
||||
values = row["값"]
|
||||
old_values = (old or {}).get("값") or {}
|
||||
if all(isinstance(values.get(k), (int, float)) for k in mc.INPUTS):
|
||||
if old and _same_inputs(values, old_values):
|
||||
values.update({k: old_values[k] for k in mc.COEFS if k in old_values})
|
||||
elif (got := mc.coef_of(values)) is not None:
|
||||
values.update(got)
|
||||
was_unit = (old or {}).get("단위") or {}
|
||||
was_src = (old or {}).get("출처") or {}
|
||||
row["단위"] = {k: was_unit.get(k, units.get(k, "")) for k in values}
|
||||
row["출처"] = {k: was_src.get(k, OWN_SOURCE) for k in values}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_Machine_Api.ts
|
||||
* M01 기계 화면 서버 호출(PLAN 821-2) — 양식 목록 · 시간당 경비 · 쓰는 곳 · 비슷한 기계 찾기 · 한 줄.
|
||||
* 서버 = `M01_MasterData_Machine.py` · 시스템 관리자만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { MachinePick } from "@ui/ui_template_machine_form";
|
||||
import type { Row } from "./M01_MasterData_Api_Fetch";
|
||||
|
||||
export interface MachineLists {
|
||||
fuels: MachinePick[];
|
||||
jobs: MachinePick[];
|
||||
power: number;
|
||||
hour: number;
|
||||
books: string[];
|
||||
/** 구분 → 원문 */
|
||||
groups: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MachineHourly {
|
||||
노무비?: number;
|
||||
재료비?: number;
|
||||
경비?: number;
|
||||
계?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
|
||||
const query = new URLSearchParams(params);
|
||||
const res = await fetch(`${API_BASE_URL}/m01/machine${path}?${query}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const body = (await res.json()) as T & { detail?: unknown };
|
||||
if (!res.ok) throw new Error(String(body.detail ?? `HTTP ${res.status}`));
|
||||
return body;
|
||||
}
|
||||
|
||||
let lists: Promise<MachineLists> | null = null;
|
||||
/** 한 번 받아 둠(페이지 다시 그리면 다시 받음) */
|
||||
export const fetchMachineLists = (fresh = false): Promise<MachineLists> => {
|
||||
if (!lists || fresh)
|
||||
lists = get<MachineLists>("/lists").catch((e) => ((lists = null), Promise.reject(e)));
|
||||
return lists;
|
||||
};
|
||||
|
||||
export const fetchMachineHourly = (keys: string[]): Promise<Record<string, MachineHourly>> =>
|
||||
keys.length ? get("/hourly", { keys: keys.join(",") }) : Promise.resolve({});
|
||||
|
||||
export const fetchMachineUses = async (key: string): Promise<string[]> =>
|
||||
(await get<{ uses: string[] }>("/uses", { key })).uses;
|
||||
|
||||
export const findMachines = async (q: string): Promise<Row[]> =>
|
||||
(await get<{ rows: Row[] }>("/find", { q })).rows;
|
||||
|
||||
export const fetchMachineRow = (key: string): Promise<{ version: string; row: Row }> =>
|
||||
get("/row", { key });
|
||||
@@ -14,6 +14,7 @@ from pydantic import BaseModel
|
||||
|
||||
from M01_MasterData import M01_MasterData_Doc as doc_screen
|
||||
from M01_MasterData import M01_MasterData_Equation as equation
|
||||
from M01_MasterData import M01_MasterData_Machine as machine
|
||||
from M01_MasterData import M01_MasterData_Parts as parts
|
||||
from M01_MasterData import M01_MasterData_Sheet as line_sheet
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
@@ -259,6 +260,34 @@ def get_materials(
|
||||
return _call(store.materials, sub, detail, spec, region, unit, limit)
|
||||
|
||||
|
||||
@router.get("/machine/lists")
|
||||
def get_machine_lists() -> dict:
|
||||
"""기계 양식 고르기 — 연료 · 직종 · 단가 · 원문 · 구분(821-2)."""
|
||||
return _call(machine.lists)
|
||||
|
||||
|
||||
@router.get("/machine/hourly")
|
||||
def get_machine_hourly(keys: str) -> dict:
|
||||
"""기계 키(쉼표) → 시간당 경비(서버 기계경비식 · 전국평균)."""
|
||||
return _call(machine.hourly, [k for k in keys.split(",") if k])
|
||||
|
||||
|
||||
@router.get("/machine/uses")
|
||||
def get_machine_uses(key: str) -> dict:
|
||||
return {"uses": _call(machine.uses, key)}
|
||||
|
||||
|
||||
@router.get("/machine/find")
|
||||
def get_machine_find(q: str = "") -> dict:
|
||||
"""비슷한 기계에서 시작 — 이름 · 규격 · 별칭 찾기(값 묶음 같이)."""
|
||||
return {"rows": _call(machine.find, q)}
|
||||
|
||||
|
||||
@router.get("/machine/row")
|
||||
def get_machine_row(key: str) -> dict:
|
||||
return _call(machine.row, key)
|
||||
|
||||
|
||||
@router.post("/calc")
|
||||
def post_calc(body: CalcBody) -> dict:
|
||||
with store.cm.mf.mp.choosing(body.고름):
|
||||
|
||||
@@ -11,6 +11,7 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
from common_util.common_util_time import kst_today
|
||||
from M01_MasterData import M01_MasterData_Machine as machine
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Revise as revise
|
||||
|
||||
@@ -291,6 +292,7 @@ def save(batch: list[dict], confirmed: bool = False) -> dict:
|
||||
): # 별칭(815-2) = 앞뒤 빈칸만 뗌 · 빈 글 = 칸 없음
|
||||
text = str(row["별칭"] or "").strip()
|
||||
row.update(별칭=text) if text else row.pop("별칭")
|
||||
machine.fix(part) # 기계 줄 = 계수 서버 재계산 · 단위 · 출처 채움(821-2)
|
||||
_keep_same(part)
|
||||
fresh = False
|
||||
for part in batch:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/* M01 기계 목록 · 상세(821-2) */
|
||||
.m01-machine__row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-machine__row:hover td {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.m01-master__grid tr.is-new td {
|
||||
background: color-mix(in srgb, var(--color-success) 12%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.m01-machine__grid td.m01-machine__money {
|
||||
text-align: end;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 목록 열은 화면 폭 안에 — 가로 넘침 없음 · 긴 글은 말줄임(마우스 글 = 전체) */
|
||||
table.m01-master__grid.m01-machine__grid {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.m01-machine__grid th:nth-child(1) {
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
.m01-machine__grid th:nth-child(3),
|
||||
.m01-machine__grid th:nth-child(10) {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.m01-machine__grid th:nth-child(2),
|
||||
.m01-machine__grid th:nth-child(9) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.m01-machine-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
padding-bottom: var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-machine-detail__meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
|
||||
.m01-machine-detail__uses {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-machine-detail__uses ul {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing-16);
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-machine-detail__uses ul {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Machine.ts
|
||||
* M01 기계 목록(PLAN 821-2 · 요구 790-2) — 표는 목록용 열만(이름 · 규격 · 구분 · 가격 · 손료계수 ·
|
||||
* 주연료 · 조종원 · 시간당 경비(서버 값) · 별칭 · 비고) · 줄을 누르면 상세 페이지 · [새 기계] = 양식.
|
||||
* 단위 · 출처 열 없음 · 고친 것 · 새 줄 · 지울 줄은 초안(노랑 · 새 · 지움 표시) — 저장은 페이지 [저장].
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { guessRows, pageKeepFirst, watchFit } from "@ui/ui_template_pager";
|
||||
import { fetchRows, type Row, type RowsPage } from "./M01_MasterData_Api_Fetch";
|
||||
import { onDraftChange, peek, same } from "./M01_MasterData_Draft";
|
||||
import { fetchMachineHourly } from "./M01_MasterData_Machine_Api";
|
||||
import { buildPager } from "./M01_MasterData_UI_Cells";
|
||||
import { saveView, type Pick } from "./M01_MasterData_UI_Side";
|
||||
|
||||
const BAR_H = 52;
|
||||
const num = (v: unknown): string =>
|
||||
typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "";
|
||||
|
||||
/** 목록 열 — 이름 · 줄 → 글 */
|
||||
const COLS: [string, (r: Row) => string][] = [
|
||||
["이름", (r) => String(r["이름"] ?? "")],
|
||||
["규격", (r) => String(r["규격"] ?? "")],
|
||||
["구분", (r) => [r["구분"], r["상세구분"]].filter(Boolean).join(" · ")],
|
||||
["가격(천원)", (r) => num(val(r)["가격"])],
|
||||
["손료계수", (r) => num(val(r)["손료계수"])],
|
||||
[
|
||||
"주연료",
|
||||
(r) =>
|
||||
val(r)["주연료"] ? `${val(r)["주연료종류"] ?? ""} ${num(val(r)["주연료"])}ℓ`.trim() : "",
|
||||
],
|
||||
[
|
||||
"조종원",
|
||||
(r) =>
|
||||
val(r)["조종원"] ? `${num(val(r)["조종원"])}인 ${val(r)["조종원직종"] ?? ""}`.trim() : "",
|
||||
],
|
||||
];
|
||||
const val = (r: Row): Record<string, unknown> => (r["값"] ?? {}) as Record<string, unknown>;
|
||||
|
||||
/** 돌려받은 함수 = 걷기. `open(target)` = 상세(키 · 새 줄 `add:<차례>` · 새 기계 `new`). */
|
||||
export function renderMachines(
|
||||
host: HTMLElement,
|
||||
pick: Pick,
|
||||
q: string,
|
||||
open: (target: string) => void,
|
||||
): () => void {
|
||||
const file = pick.file.file;
|
||||
let size = guessRows(host, BAR_H);
|
||||
let page = pick.view?.page ?? 1;
|
||||
let data: RowsPage | null = null;
|
||||
const hourly = new Map<string, string>();
|
||||
|
||||
const hourText = (key: string): string => hourly.get(key) ?? "…";
|
||||
const line = (row: Row, cur: Row, cls: string, target: string, key = ""): HTMLElement => {
|
||||
const tr = el("tr", {
|
||||
className: `m01-machine__row ${cls}`.trim(),
|
||||
attrs: { "data-key": key || target },
|
||||
children: [
|
||||
...COLS.map(([, get]) => {
|
||||
const text = get(cur);
|
||||
const td = el("td", { text, attrs: { title: text } });
|
||||
td.classList.toggle("is-changed", cls !== "is-new" && text !== get(row));
|
||||
return td;
|
||||
}),
|
||||
el("td", {
|
||||
className: "m01-machine__money",
|
||||
text: key ? hourText(key) : "",
|
||||
attrs: key ? { "data-hourly": key } : {},
|
||||
}),
|
||||
el("td", { text: String(cur["별칭"] ?? ""), attrs: { title: String(cur["별칭"] ?? "") } }),
|
||||
el("td", { text: String(cur["비고"] ?? ""), attrs: { title: String(cur["비고"] ?? "") } }),
|
||||
],
|
||||
});
|
||||
tr.addEventListener("click", () => open(target));
|
||||
return tr;
|
||||
};
|
||||
|
||||
const paint = (): void => {
|
||||
if (!data) return;
|
||||
const d = data;
|
||||
const draft = peek(file);
|
||||
const body = el("tbody");
|
||||
(draft?.adds ?? []).forEach((r, i) => body.append(line(r, r, "is-new", `add:${i}`)));
|
||||
for (const row of d.rows) {
|
||||
const key = String(row["키"]);
|
||||
const cur = draft?.edits[key] ?? row;
|
||||
const cls = draft?.deletes.includes(key) ? "is-deleted" : same(cur, row) ? "" : "is-changed";
|
||||
body.append(line(row, cur, cls, key, key));
|
||||
}
|
||||
const add = createButton({ label: L("M01_MachineNew"), variant: "ghost", iconStart: "plus" });
|
||||
add.addEventListener("click", () => open("new"));
|
||||
const empty =
|
||||
d.rows.length || draft?.adds.length
|
||||
? []
|
||||
: [createEmpty({ text: L("M01_NoRows"), small: true })];
|
||||
host.replaceChildren(
|
||||
el("div", { className: "ui-toolbar m01-master__bar", children: [add] }),
|
||||
el("div", {
|
||||
className: "ui-table-wrap m01-master__grid-wrap m01-machine__wrap",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-table m01-master__grid m01-machine__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
...COLS.map(([h]) => el("th", { text: h })),
|
||||
el("th", { text: `${L("M01_MachineHourly")}(원)` }),
|
||||
el("th", { text: "별칭" }),
|
||||
el("th", { text: "비고" }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
buildPager(d.total, size, d.page, go),
|
||||
...empty,
|
||||
);
|
||||
fit.ask();
|
||||
};
|
||||
|
||||
/** 시간당 경비 — 표를 그린 뒤 서버 값으로 채움(마스터 판마다 서버가 한 번 셈) */
|
||||
const fillHourly = async (keys: string[]): Promise<void> => {
|
||||
const want = keys.filter((k) => !hourly.has(k));
|
||||
if (!want.length) return;
|
||||
try {
|
||||
const got = await fetchMachineHourly(want);
|
||||
for (const [k, h] of Object.entries(got)) hourly.set(k, h.계 === undefined ? "—" : num(h.계));
|
||||
} catch {
|
||||
for (const k of want) hourly.set(k, "—");
|
||||
}
|
||||
host.querySelectorAll<HTMLElement>("td[data-hourly]").forEach((td) => {
|
||||
const text = hourly.get(td.dataset.hourly!);
|
||||
if (text) td.textContent = text;
|
||||
});
|
||||
};
|
||||
|
||||
const go = (next: number): void => {
|
||||
page = next;
|
||||
void load();
|
||||
};
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
data = await fetchRows(file, page, size, q, false, pick.sub, pick.detail, pick.state);
|
||||
saveView(pick.group, { page: data.page });
|
||||
paint();
|
||||
void fillHourly(data.rows.map((r) => String(r["키"])));
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const fit = watchFit(
|
||||
host,
|
||||
() => host.querySelector<HTMLElement>(".m01-master__grid-wrap"),
|
||||
(rows) => {
|
||||
if (rows === size) return;
|
||||
page = pageKeepFirst(data?.page ?? page, size, rows);
|
||||
size = rows;
|
||||
void load();
|
||||
},
|
||||
);
|
||||
const off = onDraftChange(paint);
|
||||
void load();
|
||||
return () => {
|
||||
fit.stop();
|
||||
off();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_MachineDetail.ts
|
||||
* M01 기계 상세 페이지(PLAN 821-2 · 요구 790-2 · 791) — 기계 양식 부품 + 원문번호 · 원문 · 구분 ·
|
||||
* 상세구분 · 별칭 · 비고 · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인).
|
||||
* 고침은 초안(sessionStorage)에 바로 쌓임 → 페이지 [저장] 한 번에 서버로(자동저장 없음) ·
|
||||
* 서버가 계수를 계수 짝으로 다시 셈 · 단위 · 출처 칸을 채움(`M01_MasterData_Machine.fix`).
|
||||
* 대상 = 키(EQ…) · 저장 전 새 줄 `add:<차례>` · 새 기계 `new`(비슷한 기계에서 시작이 펼쳐짐).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { createMachineForm, type MachineSimilar } from "@ui/ui_template_machine_form";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { setLeaveGuard } from "../A00_Common/router";
|
||||
import type { Row } from "./M01_MasterData_Api_Fetch";
|
||||
import { addRow, editRow, peek, removeAdd, setAdd, toggleDelete } from "./M01_MasterData_Draft";
|
||||
import {
|
||||
fetchMachineLists,
|
||||
fetchMachineRow,
|
||||
fetchMachineUses,
|
||||
findMachines,
|
||||
type MachineLists,
|
||||
} from "./M01_MasterData_Machine_Api";
|
||||
import type { Pick } from "./M01_MasterData_UI_Side";
|
||||
|
||||
export interface MachineDetailOptions {
|
||||
target: string;
|
||||
pick: Pick;
|
||||
back: () => void;
|
||||
/** 새 기계가 초안 새 줄이 되면 — 주소를 `add:<차례>` 로 */
|
||||
onAdded?: (target: string) => void;
|
||||
}
|
||||
|
||||
const META = ["원문번호", "원문", "구분", "상세구분", "별칭", "비고"] as const;
|
||||
type Meta = Record<(typeof META)[number], string>;
|
||||
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
|
||||
|
||||
/** 돌려받은 함수 = 걷기 */
|
||||
export function openMachineDetail(host: HTMLElement, opts: MachineDetailOptions): () => void {
|
||||
let alive = true;
|
||||
const stop = (): void => {
|
||||
alive = false;
|
||||
setLeaveGuard(null);
|
||||
};
|
||||
void build(host, opts, () => alive).catch((error) => {
|
||||
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
|
||||
opts.back();
|
||||
});
|
||||
return stop;
|
||||
}
|
||||
|
||||
async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () => boolean) {
|
||||
const file = opts.pick.file.file;
|
||||
const lists = await fetchMachineLists();
|
||||
let version = opts.pick.file.version;
|
||||
let key = "";
|
||||
let addAt: number | null = null;
|
||||
let orig: Row | null = null;
|
||||
let cur: Row;
|
||||
if (opts.target.startsWith("add:")) {
|
||||
addAt = Number(opts.target.slice(4));
|
||||
const row = peek(file)?.adds[addAt];
|
||||
if (!row) throw new Error(L("M01_MachineNotFound"));
|
||||
cur = row;
|
||||
} else if (opts.target === "new") {
|
||||
cur = blankRow(lists);
|
||||
} else {
|
||||
key = opts.target;
|
||||
const got = await fetchMachineRow(key);
|
||||
version = peek(file)?.version ?? got.version;
|
||||
orig = got.row;
|
||||
cur = peek(file)?.edits[key] ?? got.row;
|
||||
}
|
||||
if (!alive()) return;
|
||||
let base: Row = orig ?? cur; // 줄 칸 차례 — 고침은 원래 줄 · 새 줄은 지금 줄
|
||||
const meta: Meta = Object.fromEntries(META.map((m) => [m, text(cur[m])])) as Meta;
|
||||
const metaOrig: Meta = Object.fromEntries(META.map((m) => [m, text((orig ?? {})[m])])) as Meta;
|
||||
const isNew = !key;
|
||||
|
||||
/* --- 비슷한 기계 찾기(양식이 고르면 값 · 이름 · 규격 복사 · 여기서 원문 · 구분 복사) --- */
|
||||
const found = new Map<string, Row>();
|
||||
const findSimilar = async (q: string): Promise<MachineSimilar[]> => {
|
||||
const rows = await findMachines(q);
|
||||
return rows.map((r) => {
|
||||
found.set(String(r["키"]), r);
|
||||
return {
|
||||
key: String(r["키"]),
|
||||
name: text(r["이름"]),
|
||||
spec: text(r["규격"]),
|
||||
tag: text(r["상세구분"] || r["구분"]),
|
||||
values: (r["값"] ?? {}) as Row,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const notice = el("div");
|
||||
const form = createMachineForm({
|
||||
name: text(cur["이름"]),
|
||||
spec: text(cur["규격"]),
|
||||
values: (cur["값"] ?? {}) as Row,
|
||||
coefMode: "pumsem",
|
||||
fuels: lists.fuels,
|
||||
jobs: lists.jobs,
|
||||
power: lists.power,
|
||||
hour: lists.hour,
|
||||
findSimilar,
|
||||
onSimilar: (item) => {
|
||||
const r = found.get(item.key);
|
||||
if (!r || !isNew) return;
|
||||
base = { ...blankRow(lists), ...pickMeta(r), 값: r["값"] ?? {}, 비고: r["비고"] ?? null };
|
||||
for (const m of ["원문", "구분", "상세구분"] as const) meta[m] = text(r[m]);
|
||||
paintMeta();
|
||||
},
|
||||
onChange: () => commit(),
|
||||
});
|
||||
|
||||
/** 양식 · 머리 칸 → 초안(이름 빈 칸 · 숫자 아닌 칸이 있으면 안 넣음) */
|
||||
const commit = (): void => {
|
||||
const s = form.state();
|
||||
const errors = [
|
||||
...s.errors,
|
||||
...(s.name ? [] : [`${L("MF_Name")} · ${L("M01_MachineErrors")}`]),
|
||||
];
|
||||
notice.replaceChildren(...errors.map((e) => createText(e, "error")));
|
||||
if (errors.length) return;
|
||||
const row: Row = { ...base, ...cur, 이름: s.name, 규격: s.spec, 값: s.values };
|
||||
for (const m of META) {
|
||||
if (orig && meta[m] === metaOrig[m]) {
|
||||
if (m in orig) row[m] = orig[m];
|
||||
else delete row[m]; // 안 고친 칸 = 원래 그대로
|
||||
} else if (meta[m]) row[m] = meta[m];
|
||||
else if (m === "별칭")
|
||||
delete row[m]; // 빈 별칭 = 칸 없음
|
||||
else row[m] = m === "비고" ? null : "";
|
||||
}
|
||||
const ordered: Row = Object.fromEntries(
|
||||
[...Object.keys(base), ...Object.keys(row)]
|
||||
.filter((k, i, all) => all.indexOf(k) === i && k in row)
|
||||
.map((k) => [k, row[k]]),
|
||||
);
|
||||
if (key && orig) editRow(file, version, key, orig, ordered);
|
||||
else if (addAt !== null) setAdd(file, addAt, ordered);
|
||||
else {
|
||||
addRow(file, version, { 키: "", ...ordered });
|
||||
addAt = (peek(file)?.adds.length ?? 1) - 1;
|
||||
opts.onAdded?.(`add:${addAt}`);
|
||||
}
|
||||
cur = ordered;
|
||||
checkSame(s.name, s.spec);
|
||||
};
|
||||
|
||||
/* --- 같은 이름 · 규격 알림 --- */
|
||||
let sameTimer: number | undefined;
|
||||
const checkSame = (name: string, spec: string): void => {
|
||||
window.clearTimeout(sameTimer);
|
||||
sameTimer = window.setTimeout(async () => {
|
||||
const rows = name ? await findMachines(name).catch(() => [] as Row[]) : [];
|
||||
const hit = rows.find(
|
||||
(r) => r["키"] !== key && text(r["이름"]) === name && text(r["규격"]) === spec,
|
||||
);
|
||||
form.setNotice(hit ? L("M01_MachineSame").replace("{value}", text(hit["키"])) : "");
|
||||
}, 300);
|
||||
};
|
||||
|
||||
/* --- 머리 칸 --- */
|
||||
const metaBox = el("div", { className: "m01-machine-detail__meta" });
|
||||
const paintMeta = (): void => {
|
||||
const field = (m: (typeof META)[number], label: string): HTMLElement => {
|
||||
const options = m === "원문" ? lists.books : m === "구분" ? Object.keys(lists.groups) : null;
|
||||
const mark = (root: HTMLElement): void => {
|
||||
root.classList.toggle("is-changed", !isNew && meta[m] !== metaOrig[m]);
|
||||
};
|
||||
if (options) {
|
||||
const list = options.includes(meta[m]) || !meta[m] ? options : [meta[m], ...options];
|
||||
const f = createSelectField({
|
||||
label,
|
||||
options: list.map((v) => ({ value: v, text: v })),
|
||||
value: meta[m],
|
||||
size: 28,
|
||||
});
|
||||
f.select.addEventListener("change", () => {
|
||||
meta[m] = f.select.value;
|
||||
if (m === "구분" && lists.groups[meta[m]]) meta["원문"] = lists.groups[meta[m]];
|
||||
mark(f.root);
|
||||
commit();
|
||||
if (m === "구분") paintMeta();
|
||||
});
|
||||
mark(f.root);
|
||||
return f.root;
|
||||
}
|
||||
const f = createInputField({ label, value: meta[m], size: 28 });
|
||||
f.input.addEventListener("input", () => {
|
||||
meta[m] = f.input.value;
|
||||
mark(f.root);
|
||||
commit();
|
||||
});
|
||||
mark(f.root);
|
||||
return f.root;
|
||||
};
|
||||
metaBox.replaceChildren(
|
||||
field("원문번호", L("M01_MachineSourceNo")),
|
||||
field("원문", "원문"),
|
||||
field("구분", "구분"),
|
||||
field("상세구분", L("M01_MachineDetailGroup")),
|
||||
field("별칭", "별칭"),
|
||||
field("비고", "비고"),
|
||||
);
|
||||
};
|
||||
paintMeta();
|
||||
|
||||
/* --- 쓰는 곳 --- */
|
||||
const usesBox = el("section", { className: "m01-machine-detail__uses" });
|
||||
let uses: string[] = [];
|
||||
const paintUses = (): void => {
|
||||
usesBox.replaceChildren(
|
||||
el("strong", { text: `${L("M01_MachineUses")} ${uses.length}` }),
|
||||
uses.length
|
||||
? el("ul", { children: uses.map((u) => el("li", { text: u })) })
|
||||
: createText(L("M01_MachineNoUses")),
|
||||
);
|
||||
};
|
||||
if (key)
|
||||
void fetchMachineUses(key)
|
||||
.then((got) => {
|
||||
uses = got;
|
||||
paintUses();
|
||||
})
|
||||
.catch(() => paintUses());
|
||||
else paintUses();
|
||||
|
||||
/* --- 단추 · 떠남 --- */
|
||||
const dirty = (): boolean => addAt !== null || Boolean(key && peek(file)?.edits[key]);
|
||||
const leave = async (): Promise<boolean> =>
|
||||
!dirty() || (await showConfirmDialog(L("M01_MachineLeave")));
|
||||
setLeaveGuard(leave);
|
||||
const backBtn = createButton({
|
||||
label: L("M01_MachineBack"),
|
||||
variant: "ghost",
|
||||
iconStart: "chevronLeft",
|
||||
});
|
||||
backBtn.addEventListener("click", async () => {
|
||||
if (await leave()) opts.back();
|
||||
});
|
||||
const del = createButton({ label: L("M01_MachineDelete"), variant: "danger-ghost" });
|
||||
del.addEventListener("click", async () => {
|
||||
const ask = L("M01_MachineDeleteAsk").replace("{value}", String(uses.length));
|
||||
if (!(await showConfirmDialog(uses.length ? `${ask}\n${uses.join("\n")}` : ask))) return;
|
||||
if (key) toggleDelete(file, version, key);
|
||||
else if (addAt !== null) removeAdd(file, addAt);
|
||||
setLeaveGuard(null);
|
||||
opts.back();
|
||||
});
|
||||
if (!key && addAt === null) del.hidden = true;
|
||||
const title = isNew ? L("M01_MachineNew") : `${text(cur["이름"])} ${text(cur["규격"])}`.trim();
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-machine-detail",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "ui-toolbar m01-master__bar",
|
||||
children: [
|
||||
backBtn,
|
||||
el("strong", { text: title }),
|
||||
el("span", { className: "ui-text ui-text--muted", text: key }),
|
||||
del,
|
||||
],
|
||||
}),
|
||||
notice,
|
||||
metaBox,
|
||||
form.root,
|
||||
usesBox,
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (opts.target === "new")
|
||||
host.querySelector<HTMLDetailsElement>("details")?.setAttribute("open", "");
|
||||
}
|
||||
|
||||
/** 새 기계 바탕 — 칸 차례는 기계 줄과 같음 · 단위 · 출처는 서버가 채움 */
|
||||
function blankRow(lists: MachineLists): Row {
|
||||
const group = Object.keys(lists.groups)[0] ?? "";
|
||||
return {
|
||||
원문번호: "",
|
||||
원문: lists.groups[group] ?? lists.books[0] ?? "",
|
||||
구분: group,
|
||||
상세구분: "",
|
||||
이름: "",
|
||||
규격: "",
|
||||
단위: {},
|
||||
값: {},
|
||||
출처: {},
|
||||
비고: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 고른 기계의 원문 · 구분 · 상세구분(새 기계 바탕) */
|
||||
const pickMeta = (r: Row): Row => ({ 원문: r["원문"], 구분: r["구분"], 상세구분: r["상세구분"] });
|
||||
@@ -33,6 +33,9 @@ import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch";
|
||||
import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft";
|
||||
import { buildHeadBar, buildHeadFilters } from "./M01_MasterData_UI_Head";
|
||||
import { renderRows } from "./M01_MasterData_UI_Rows";
|
||||
import { renderMachines } from "./M01_MasterData_UI_Machine";
|
||||
import { openMachineDetail } from "./M01_MasterData_UI_MachineDetail";
|
||||
import "./M01_MasterData_UI_Machine.css";
|
||||
import { renderRateRows } from "./M01_MasterData_UI_RateSheet";
|
||||
import {
|
||||
buildSide,
|
||||
@@ -54,6 +57,11 @@ import type { LogicHandle } from "./M01_MasterData_UI_LogicLab";
|
||||
import "./M01_MasterData_UI_Style.css";
|
||||
|
||||
const TABLE_GROUPS = ["소요량", "계수"];
|
||||
// 기계 = 목록 + 상세 페이지(821-2) · 주소 `?machine=키|add:n|new` · 목록 거름은 되돌아올 때 다시 엶
|
||||
const MACHINE_FILE = "기계.json";
|
||||
const MACHINE_BACK = "m01.machine.back";
|
||||
const machineUrl = (target?: string): string =>
|
||||
`#/${ROUTES.M01_MASTER_DATA}${target ? `?machine=${encodeURIComponent(target)}` : ""}`;
|
||||
|
||||
export async function renderM01MasterData(root: HTMLElement): Promise<void> {
|
||||
const user = await fetchSessionUser().catch(() => null);
|
||||
@@ -171,15 +179,39 @@ function buildPage(): HTMLElement {
|
||||
current = { ...pick, view: undefined };
|
||||
const groupTitle = pick.group === pick.label ? [pick.group] : [pick.group, pick.label];
|
||||
title.textContent = groupTitle.join(" · ");
|
||||
const machine = pick.file.file === MACHINE_FILE;
|
||||
if (machine) sessionStorage.setItem(MACHINE_BACK, JSON.stringify(pick));
|
||||
else sessionStorage.removeItem(MACHINE_BACK);
|
||||
if (currentQuery().has("machine")) history.replaceState(null, "", machineUrl());
|
||||
dispose = TABLE_GROUPS.includes(pick.group)
|
||||
? renderTables(body, pick, query, openLogicTab)
|
||||
: pick.group === "요율"
|
||||
? renderRateRows(body, pick, query)
|
||||
: renderRows(body, pick, query);
|
||||
: machine
|
||||
? renderMachines(body, pick, query, (target) => openMachine(target, true))
|
||||
: pick.group === "요율"
|
||||
? renderRateRows(body, pick, query)
|
||||
: renderRows(body, pick, query);
|
||||
renderParts(partsHost, pick, (key) => openLogicTab(key));
|
||||
showNotice(isStale(pick.file.file, pick.file.version) ? [L("M01_FileStale")] : []);
|
||||
};
|
||||
|
||||
/** 기계 상세 — 목록에서 열면 주소를 쌓아 뒤로 가기 = 목록(페이지를 다시 그려 목록 거름을 되엶) */
|
||||
const openMachine = (target: string, push: boolean): void => {
|
||||
if (!current) return;
|
||||
const pick = current;
|
||||
if (push) history.pushState({ m01Machine: true }, "", machineUrl(target));
|
||||
else history.replaceState(history.state, "", machineUrl(target));
|
||||
dispose();
|
||||
dispose = openMachineDetail(body, {
|
||||
target,
|
||||
pick,
|
||||
back: () => {
|
||||
if ((history.state as { m01Machine?: boolean } | null)?.m01Machine) return history.back();
|
||||
openFile({ ...pick, view: loadView(pick.group) });
|
||||
},
|
||||
onAdded: (next) => history.replaceState(history.state, "", machineUrl(next)),
|
||||
});
|
||||
};
|
||||
|
||||
let searchTimer: number | undefined;
|
||||
search.addEventListener("input", () => {
|
||||
window.clearTimeout(searchTimer);
|
||||
@@ -342,5 +374,25 @@ function buildPage(): HTMLElement {
|
||||
// 주소 `?open=` — 대시보드 설계 기본값 줄 · 옛 M02 `open=옵션` 즐겨찾기가 그 칸을 고른 채 엶
|
||||
const want = currentQuery().get("open");
|
||||
if (want === "로직" || want === "옵션" || want === "별칭") side.reveal(want);
|
||||
const machine = currentQuery().get("machine");
|
||||
const backTo = sessionStorage.getItem(MACHINE_BACK);
|
||||
if (machine || (backTo && !want)) {
|
||||
void side.refresh("기계").then((files) => {
|
||||
const saved = (backTo ? JSON.parse(backTo) : {}) as Partial<Pick>;
|
||||
const file = files.find((f) => f.file === MACHINE_FILE);
|
||||
if (!file) return;
|
||||
const label = saved.label ?? "기계";
|
||||
openFile({
|
||||
group: "기계",
|
||||
sub: "",
|
||||
detail: "",
|
||||
label,
|
||||
...saved,
|
||||
file,
|
||||
view: loadView("기계"),
|
||||
});
|
||||
if (machine) openMachine(machine, false);
|
||||
});
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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))}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "따로"})}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
"""M02 내 기계(PLAN 821-1 · 요구 795 · 요구서_796 3 · 7 장) — 개인 층 기계 줄 목록 · 서버 단독.
|
||||
|
||||
저장 = 층 뿌리 `내기계.json` {줄: [줄], 다음: 수} — 줄 = M01 기계 줄 꼴
|
||||
{키 `EQP{사용자}-####`, 원문번호 「개인 {키}」, 원문 · 구분 · 출처 = 개인, 상세구분 · 이름, 규격,
|
||||
단위, 값, 비고, 방식 바로|품셈|직접, 시간당?(직접 {노무비, 재료비, 경비}), 넣은날, 고친날, 본뜬?}
|
||||
값 칸 이름 = M01 기계 줄과 같음 · 바로 = 손료계수 한 칸 · 품셈 = 비율 다섯 → 계수 넷(저장 때 서버가
|
||||
계수 짝 `master_machine_coef` 로 다시 셈) · 직접 = 시간당 값만.
|
||||
셈 색인 `whole()` = 지금 층(`store.root()`) 줄을 M01 위에 얹은 `skc.Copy`(키 앞 두 글자 EQ ·
|
||||
원문번호로도 찾음) — 바로 = 상각비계수 ← 손료계수 · 정비 · 관리 0(식은 M01 기계와 같은 GC 식) ·
|
||||
내 기계 없는 층 = M01 그대로. 단가산출 사본 · [단가 갱신] · 「단가새값」(`M02_Sangeun_Copy`) ·
|
||||
기계 찾기(`M02_Machine_Find`) · 시간당(`M02_Machine_Hourly`) · 사본 모으기(`M02_Item_Prices.collect`)가
|
||||
이 색인을 읽음 — 층을 모르는 길(`/api/m02/logic-lib/machine-hourly` 등)은 M01 만.
|
||||
식 고름(채운 칸) = 연료 · 조종원 둘 다 GC000268 · 연료만 GC000272 · 조종원만 GC000276 · 없음 GC000273.
|
||||
전력 칸은 담기만(M01 기계경비 식에 전력 줄 없음).
|
||||
권한(본인 개인 층 · 맡은 회사 관리자)은 길(`M02_MyMachine_Router`) 몫.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
FILE = "내기계.json"
|
||||
PREFIX, NO = "EQP", "개인 "
|
||||
FIND = re.compile(rf"{NO}({PREFIX}\d+-\d+)") # 문서 안 기계 입력 「개인 EQP…」
|
||||
EASY, BOOK, DIRECT = "바로", "품셈", "직접"
|
||||
COSTS = ("노무비", "재료비", "경비")
|
||||
RATIOS = ("내용시간", "연간표준가동시간", "상각비율", "정비비율", "연간관리비율")
|
||||
COEFS = ("상각비계수", "정비비계수", "관리비계수")
|
||||
NUMS = ("가격", *RATIOS, *COEFS, "손료계수", "주연료", "잡재료율", "조종원", "전력")
|
||||
UNITS = {
|
||||
"가격": "천원", "내용시간": "시간", "연간표준가동시간": "시간", "상각비율": "", "정비비율": "",
|
||||
"연간관리비율": "", **{k: "10^-7/시간" for k in (*COEFS, "손료계수")}, "주연료": "ℓ/hr",
|
||||
"주연료종류": "", "잡재료율": "주연료의 %", "조종원": "인/일", "조종원직종": "", "전력": "kWh/hr",
|
||||
} # fmt: skip
|
||||
LOGIC = {(True, True): "GC000268", (True, False): "GC000272",
|
||||
(False, True): "GC000276", (False, False): "GC000273"} # fmt: skip
|
||||
|
||||
|
||||
def mine(key: Any) -> bool:
|
||||
return str(key or "").startswith(PREFIX)
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _bad(text: str, status: int = 400) -> store.StoreError:
|
||||
return store.StoreError(status, text)
|
||||
|
||||
|
||||
# ── 저장 ──────────────────────────────────────────────────────────────
|
||||
def _path(folder: Path | None = None) -> Path:
|
||||
return Path(folder or store.root()) / FILE
|
||||
|
||||
|
||||
def _read(folder: Path | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
got = json.loads(_path(folder).read_bytes())
|
||||
except (OSError, ValueError):
|
||||
got = {}
|
||||
rows = [r for r in got.get("줄") or [] if isinstance(r, dict) and mine(r.get("키"))]
|
||||
return {"줄": rows, "다음": int(got.get("다음") or 1)}
|
||||
|
||||
|
||||
def rows(folder: Path | None = None) -> list[dict]:
|
||||
"""지금 층 내 기계 줄(저장 꼴)."""
|
||||
return _read(folder)["줄"]
|
||||
|
||||
|
||||
# ── 셈 색인 ──────────────────────────────────────────────────────────
|
||||
def engine(row: dict) -> dict:
|
||||
"""셈 꼴 — 바로 = 상각비계수 ← 손료계수 · 정비 · 관리 0 · 빈 연료 · 조종원 칸 = 0 · 수 = Decimal."""
|
||||
vals = dict(row.get("값") or {})
|
||||
if row.get("방식") == EASY:
|
||||
vals |= {"상각비계수": vals.get("손료계수") or 0, "정비비계수": 0, "관리비계수": 0}
|
||||
if row.get("방식") != DIRECT:
|
||||
vals = {"주연료": 0, "잡재료율": 0, "조종원": 0, "주연료종류": "", "조종원직종": ""} | vals
|
||||
raw = json.dumps({**row, "값": vals}, ensure_ascii=False)
|
||||
return json.loads(raw, parse_float=Decimal, parse_int=Decimal)
|
||||
|
||||
|
||||
_MEMO: dict[str, tuple] = {}
|
||||
|
||||
|
||||
def whole(base: Any = None) -> Any:
|
||||
"""M01 색인 + 지금 층 내 기계 줄 — 파일 · M01 이 그대로면 같은 객체(셈 메모가 붙음)."""
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
|
||||
base = m01.loaded()[1] if base is None else base
|
||||
path = _path()
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return base
|
||||
sig = (st.st_mtime_ns, st.st_size)
|
||||
got = _MEMO.get(str(path))
|
||||
if got and got[0] == sig and got[1] is base:
|
||||
return got[2]
|
||||
own = [engine(r) for r in rows(path.parent)]
|
||||
out = skc.Copy(base, own, ("내기계", sig)) if own else base
|
||||
if len(_MEMO) > 16: # ponytail: 통째 비움 · 층이 많아지면 LRU 로
|
||||
_MEMO.clear()
|
||||
_MEMO[str(path)] = (sig, base, out)
|
||||
return out
|
||||
|
||||
|
||||
def added(index: Any) -> list[dict]:
|
||||
"""`whole()` 이 얹은 내 기계 셈 줄(사본 모으기 `collect` 가 마스터 밖 줄로 담음)."""
|
||||
return [r for k, r in (getattr(index, "index", {}).get("EQ") or {}).items() if mine(k)]
|
||||
|
||||
|
||||
def keys_in(doc: Any) -> list[str]:
|
||||
"""문서 안 기계 입력 「개인 EQP…」 → 내 기계 키(줄 쌓기 `로직(GC…, 기계='개인 …')` · 사본 찍기)."""
|
||||
return sorted(set(FIND.findall(json.dumps(doc, ensure_ascii=False, default=str))))
|
||||
|
||||
|
||||
def logic_key(row: dict) -> str:
|
||||
vals = row.get("값") or {}
|
||||
return LOGIC[(float(vals.get("주연료") or 0) > 0, float(vals.get("조종원") or 0) > 0)]
|
||||
|
||||
|
||||
def logic_for(code: str) -> dict | None:
|
||||
"""내 기계 원문번호 → 시간당 경비 식(채운 칸으로 고름) · 내 기계 아님 = None."""
|
||||
index = whole()
|
||||
row = (index.numbers.get("EQ") or {}).get(code)
|
||||
if row is None or not mine(row.get("키")) or row.get("방식") == DIRECT:
|
||||
return None
|
||||
return index.logic(logic_key(row))
|
||||
|
||||
|
||||
def hourly(row: dict, index: Any) -> dict[str, Any]:
|
||||
"""`M02_Machine_Hourly.hourly` 꼴 — 직접 = 그 값 · 아니면 고른 GC 식(입력 기본값 · 기계 = 원문번호)."""
|
||||
head = {"이름": row.get("이름"), "규격": row.get("규격"), "원문번호": row.get("원문번호")}
|
||||
if row.get("방식") == DIRECT:
|
||||
sums = {k: float((row.get("시간당") or {}).get(k) or 0) for k in COSTS}
|
||||
return {"ok": True, **head, "로직": None, "직접": True, **sums, "계": sum(sums.values())}
|
||||
import master_cut as mcut
|
||||
|
||||
mf = m01.cm.mf
|
||||
logic = index.logic(logic_key(row))
|
||||
given = {i["이름"]: (i.get("고르기") or [""])[0] for i in logic.get("입력") or []}
|
||||
given["기계"] = str(row.get("원문번호"))
|
||||
try:
|
||||
got = mcut.engine(
|
||||
index, logic["키"], mf.run(index, logic["키"], mf.fill_empty(logic, given))
|
||||
)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||||
return {"ok": False, "reason": str(e) or type(e).__name__}
|
||||
sums = {k: float(got[k]) for k in (*COSTS, "계")}
|
||||
return {"ok": True, **head, "로직": logic["키"], **sums}
|
||||
|
||||
|
||||
# ── 검사 ──────────────────────────────────────────────────────────────
|
||||
def _num(value: Any, name: str) -> float | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
got = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise _bad(f"「{name}」 은 수") from None
|
||||
if got < 0 or got != got:
|
||||
raise _bad(f"「{name}」 은 0 이상")
|
||||
return int(got) if got == int(got) else got
|
||||
|
||||
|
||||
def _coef(vals: dict) -> dict:
|
||||
"""품셈 방식 — 비율 다섯 → 계수 넷(계수 짝 `master_machine_coef` · 821-2)."""
|
||||
import master_machine_coef as mmc
|
||||
|
||||
got = mmc.coef_of(vals)
|
||||
if got is None:
|
||||
raise _bad("품셈 방식 — 내용시간 · 연간표준가동시간은 0 보다 큼")
|
||||
return got
|
||||
|
||||
|
||||
def clean(raw: Any) -> dict[str, Any]:
|
||||
"""받은 줄 → 저장 꼴 몸(키 · 날 빼고) — 틀리면 400."""
|
||||
if not isinstance(raw, dict):
|
||||
raise _bad("내 기계 줄이 묶음 아님")
|
||||
name, spec = str(raw.get("이름") or "").strip(), str(raw.get("규격") or "").strip()
|
||||
if not name:
|
||||
raise _bad("이름 없음")
|
||||
how = raw.get("방식") or EASY
|
||||
if how not in (EASY, BOOK, DIRECT):
|
||||
raise _bad("방식은 바로 · 품셈 · 직접")
|
||||
given = raw.get("값") if isinstance(raw.get("값"), dict) else {}
|
||||
out: dict[str, Any] = {"이름": name, "규격": spec, "방식": how}
|
||||
if how == DIRECT:
|
||||
hour = raw.get("시간당") if isinstance(raw.get("시간당"), dict) else {}
|
||||
out["시간당"] = {k: _num(hour.get(k), k) or 0 for k in COSTS}
|
||||
vals: dict[str, Any] = {}
|
||||
else:
|
||||
vals = {k: got for k in NUMS if (got := _num(given.get(k), k)) is not None}
|
||||
need = ("가격", "손료계수") if how == EASY else ("가격", *RATIOS)
|
||||
if lost := [k for k in need if not vals.get(k)]:
|
||||
raise _bad(f"빈 칸 — {' · '.join(lost)}")
|
||||
drop = (*RATIOS, *COEFS) if how == EASY else ()
|
||||
vals = {k: v for k, v in vals.items() if k not in drop}
|
||||
if how == BOOK:
|
||||
vals |= _coef(vals)
|
||||
vals |= _kinds(given, vals)
|
||||
out["값"] = vals
|
||||
out["단위"] = {k: UNITS[k] for k in vals if k in UNITS}
|
||||
out["출처"] = {k: "개인" for k in vals}
|
||||
if note := str(raw.get("비고") or "").strip():
|
||||
out["비고"] = note
|
||||
return out
|
||||
|
||||
|
||||
def _kinds(given: dict, vals: dict) -> dict:
|
||||
"""주연료종류 = M01 유가 이름 · 조종원직종 = M01 인력 원문번호(쓸 때만 · 고르기 목록 밖 = 400)."""
|
||||
index, out = m01.loaded()[1], {}
|
||||
fuel, job = str(given.get("주연료종류") or ""), str(given.get("조종원직종") or "")
|
||||
if vals.get("주연료"):
|
||||
if f"{fuel}:전국평균" not in (index.numbers.get("MO") or {}):
|
||||
raise _bad(f"주연료종류 「{fuel}」 — 유가 목록에서 고름")
|
||||
out["주연료종류"] = fuel
|
||||
if vals.get("조종원"):
|
||||
if job not in (index.numbers.get("LB") or {}):
|
||||
raise _bad(f"조종원직종 「{job}」 — 인력 목록에서 고름")
|
||||
out["조종원직종"] = job
|
||||
return out
|
||||
|
||||
|
||||
# ── 만들기 · 고치기 · 지우기 · 읽기 ─────────────────────────────────────
|
||||
def _seed(key: str) -> dict:
|
||||
"""「비슷한 기계에서 시작」 — M01 · 내 기계 줄 값 복사(품셈 비율 있으면 품셈 · 없으면 바로)."""
|
||||
try:
|
||||
src = whole().get(key)
|
||||
except m01.cm.mf.FormulaError:
|
||||
raise _bad(f"없는 기계 「{key}」", 404) from None
|
||||
if not str(src.get("키")).startswith("EQ"):
|
||||
raise _bad(f"「{key}」 는 기계 아님")
|
||||
if mine(key):
|
||||
got = next(r for r in rows() if r["키"] == key)
|
||||
return {k: got[k] for k in ("이름", "규격", "방식", "값", "시간당", "비고") if k in got}
|
||||
vals = json.loads(json.dumps(src.get("값") or {}, default=float))
|
||||
how = BOOK if all(vals.get(k) for k in RATIOS) else EASY
|
||||
return {"이름": src.get("이름"), "규격": src.get("규격"), "방식": how, "값": vals}
|
||||
|
||||
|
||||
def _user() -> str:
|
||||
"""개인 층(`storage/{회사}/{사용자}/templates`) 사용자 번호 — 그 밖 층 = 404."""
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
here = store.root().resolve()
|
||||
user, company = here.parent.name, here.parent.parent.name
|
||||
if not (user.isdigit() and company.isdigit()) or here != layers.personal_dir(company, user):
|
||||
raise _bad("내 기계는 개인 층만", 404)
|
||||
return user
|
||||
|
||||
|
||||
def create(raw: Any = None, base: str | None = None) -> dict[str, Any]:
|
||||
_user()
|
||||
seed = _seed(base) if base else {}
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
body = clean({**seed, **raw, "값": {**seed.get("값", {}), **(raw.get("값") or {})}})
|
||||
data = _read()
|
||||
key = f"{PREFIX}{_user()}-{data['다음']:04d}"
|
||||
when = _now()
|
||||
row = {"키": key, "원문번호": f"{NO}{key}", "원문": "개인", "구분": "개인", "상세구분": body["이름"],
|
||||
**body, "넣은날": when, "고친날": when, **({"본뜬": base} if base else {})} # fmt: skip
|
||||
atomic_write_json(_path(), {"줄": [*data["줄"], row], "다음": data["다음"] + 1})
|
||||
return answer(row)
|
||||
|
||||
|
||||
def update(key: str, raw: Any) -> dict[str, Any]:
|
||||
_user()
|
||||
data = _read()
|
||||
old = next((r for r in data["줄"] if r["키"] == key), None)
|
||||
if old is None:
|
||||
raise _bad(f"없는 내 기계 「{key}」", 404)
|
||||
body = clean(raw)
|
||||
keep = {k: old[k] for k in ("키", "원문번호", "원문", "구분", "넣은날", "본뜬") if k in old}
|
||||
row = {**keep, "상세구분": body["이름"], **body, "고친날": _now()}
|
||||
atomic_write_json(_path(), {**data, "줄": [row if r["키"] == key else r for r in data["줄"]]})
|
||||
return answer(row)
|
||||
|
||||
|
||||
def delete(key: str) -> dict[str, Any]:
|
||||
"""지움 — 이미 만든 단가산출은 사본의 마지막 값 그대로(「지운키」)."""
|
||||
_user()
|
||||
data = _read()
|
||||
if not any(r["키"] == key for r in data["줄"]):
|
||||
raise _bad(f"없는 내 기계 「{key}」", 404)
|
||||
used = uses().get(key, [])
|
||||
atomic_write_json(_path(), {**data, "줄": [r for r in data["줄"] if r["키"] != key]})
|
||||
return {"지움": key, "쓰는곳": used}
|
||||
|
||||
|
||||
def uses() -> dict[str, list[dict]]:
|
||||
"""{내 기계 키: [{키, 이름}]} — 이 층 단가산출 가운데 사본 단가 줄에 그 키를 가진 것."""
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
out: dict[str, list[dict]] = {}
|
||||
for path in sorted((store.root() / sd.KIND).glob("SK-*.json")):
|
||||
raw = path.read_bytes()
|
||||
if PREFIX.encode() not in raw:
|
||||
continue
|
||||
try:
|
||||
doc = json.loads(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
for row in (doc.get(skc.KEY) or {}).get("단가") or []:
|
||||
if mine(row.get("키")):
|
||||
out.setdefault(str(row["키"]), []).append(
|
||||
{"키": doc.get("키"), "이름": doc.get("이름")}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def same(name: Any, spec: Any, skip: str = "") -> list[dict]:
|
||||
"""같은 이름 · 규격 알림 — M01 기계 · 이 층 내 기계(표기 정규화 `M02_Machine_Find.norm`)."""
|
||||
from M02_MasterTemplete.M02_Machine_Find import norm
|
||||
|
||||
want = (norm(name), norm(spec))
|
||||
return [
|
||||
{
|
||||
"키": r["키"],
|
||||
"이름": r.get("이름"),
|
||||
"규격": r.get("규격"),
|
||||
"출처": "개인" if mine(r["키"]) else "M01",
|
||||
} # fmt: skip
|
||||
for r in (whole().index.get("EQ") or {}).values()
|
||||
if r["키"] != skip and (norm(r.get("이름")), norm(r.get("규격"))) == want
|
||||
]
|
||||
|
||||
|
||||
def answer(row: dict, used: list | None = None) -> dict[str, Any]:
|
||||
out = {
|
||||
"줄": row,
|
||||
"시간당": hourly(engine(row), whole()),
|
||||
"같은이름": same(row["이름"], row["규격"], row["키"]),
|
||||
}
|
||||
return out | ({"쓰는곳": used} if used is not None else {})
|
||||
|
||||
|
||||
def one(key: str) -> dict[str, Any]:
|
||||
got = next((r for r in rows() if r["키"] == key), None)
|
||||
if got is None:
|
||||
raise _bad(f"없는 내 기계 「{key}」", 404)
|
||||
return answer(got, uses().get(key, []))
|
||||
|
||||
|
||||
def listing() -> dict[str, Any]:
|
||||
"""목록 — 줄마다 시간당 · 쓰는곳수(넣은 차례)."""
|
||||
index, used = whole(), uses()
|
||||
return {
|
||||
"줄": [
|
||||
{**r, "시간당": hourly(engine(r), index), "쓰는곳수": len(used.get(r["키"], []))}
|
||||
for r in rows()
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,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.원문번호,
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ STM_SHEETS = (
|
||||
"재료비목록표",
|
||||
"경비목록표",
|
||||
"중기목록표",
|
||||
"중기사용료", # 821-4 기계 구성(못 이은 기계 → 내 기계 값)
|
||||
)
|
||||
EST_SHEETS = (
|
||||
"단가산출총괄표",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. 안 하는 것
|
||||
|
||||
- 재료 개인 표 · 회사 층 화면 · 마스터 승격 제안 흐름 · 화면 속도 · 설명서 고침.
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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},
|
||||
"끝전": "비목버림"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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["로직"])
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -32,6 +32,7 @@ import { ui_locales_b3 } from "./ui_template_locale_b3";
|
||||
import { ui_locales_e04 } from "./ui_template_locale_e04";
|
||||
import { ui_locales_b5 } from "./ui_template_locale_b5";
|
||||
import { ui_locales_m1 } from "./ui_template_locale_m1";
|
||||
import { ui_locales_machine } from "./ui_template_locale_machine";
|
||||
import { ui_locales_m2 } from "./ui_template_locale_m2";
|
||||
import { ui_locales_m2_judge } from "./ui_template_locale_m2_judge";
|
||||
import { ui_locales_de } from "./ui_template_locale_de";
|
||||
@@ -77,6 +78,7 @@ export const ui_locales = {
|
||||
...ui_locales_e04,
|
||||
...ui_locales_b5,
|
||||
...ui_locales_m1,
|
||||
...ui_locales_machine,
|
||||
...ui_locales_m2,
|
||||
...ui_locales_m2_judge,
|
||||
...ui_locales_de,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/* =============================================================================
|
||||
* ui_template_locale_machine.ts
|
||||
* 기계 양식 부품(`ui_template_machine_form.ts`) · M01 기계 목록 · 상세 사전(PLAN 821-2) — 신규 문구는 최하단에.
|
||||
* ========================================================================== */
|
||||
|
||||
export const ui_locales_machine = {
|
||||
MF_Name: ["이름", "Name"],
|
||||
MF_Spec: ["규격", "Spec"],
|
||||
MF_Price: ["가격", "Price"],
|
||||
MF_Coef: ["손료계수", "Ownership coef."],
|
||||
MF_CoefDirect: ["바로 넣기", "Enter directly"],
|
||||
MF_CoefPumsem: ["품셈 방식", "By standard"],
|
||||
MF_Life: ["내용시간", "Service life"],
|
||||
MF_Yearly: ["연간표준가동시간", "Annual hours"],
|
||||
MF_Dep: ["상각비율", "Depreciation ratio"],
|
||||
MF_Rep: ["정비비율", "Maintenance ratio"],
|
||||
MF_Mgmt: ["연간관리비율", "Annual mgmt ratio"],
|
||||
MF_DepCoef: ["상각비계수", "Depreciation coef."],
|
||||
MF_RepCoef: ["정비비계수", "Maintenance coef."],
|
||||
MF_MgmtCoef: ["관리비계수", "Mgmt coef."],
|
||||
MF_Fuel: ["주연료", "Main fuel"],
|
||||
MF_FuelKind: ["연료 종류", "Fuel"],
|
||||
MF_Misc: ["잡품", "Sundries"],
|
||||
MF_Crew: ["조종원", "Operator"],
|
||||
MF_Job: ["직종", "Trade"],
|
||||
MF_Power: ["전력", "Power"],
|
||||
MF_None: ["없음", "None"],
|
||||
MF_Build: ["구성", "Components"],
|
||||
MF_HourlyDirect: ["시간당 값 직접", "Hourly values"],
|
||||
MF_Labor: ["노무비", "Labor"],
|
||||
MF_Material: ["재료비", "Material"],
|
||||
MF_Expense: ["경비", "Expense"],
|
||||
MF_Total: ["계", "Total"],
|
||||
MF_Loss: ["손료", "Ownership"],
|
||||
MF_NumberOnly: ["숫자만", "Numbers only"],
|
||||
MF_Range: ["보통 범위 {value} 밖", "Outside usual range {value}"],
|
||||
MF_Preview: ["시간당 경비 미리보기", "Hourly cost preview"],
|
||||
MF_PreviewNote: [
|
||||
"전국평균 단가 · 넣는 즉시 셈 · 저장 때 서버가 다시 셈",
|
||||
"National average · instant · server recalculates on save",
|
||||
],
|
||||
MF_NoPrice: ["단가 없음", "no price"],
|
||||
MF_Similar: ["비슷한 기계에서 시작", "Start from a similar machine"],
|
||||
MF_SimilarFind: ["기계 이름 · 규격으로 찾기", "Find by name or spec"],
|
||||
MF_SimilarNone: ["찾은 기계 없음", "No machines found"],
|
||||
MF_SimilarCopied: ["{value} 값을 복사함", "Copied values from {value}"],
|
||||
MF_Changed: ["고친 칸", "edited"],
|
||||
M01_MachineNew: ["새 기계", "New machine"],
|
||||
M01_MachineBack: ["목록으로", "Back to list"],
|
||||
M01_MachineHourly: ["시간당 경비", "Hourly cost"],
|
||||
M01_MachineMeta: ["원문 · 구분", "Source · group"],
|
||||
M01_MachineUses: ["쓰는 곳", "Used by"],
|
||||
M01_MachineNoUses: ["쓰는 곳 없음", "Not used"],
|
||||
M01_MachineSame: [
|
||||
"같은 이름 · 규격 기계가 있음 — {value}",
|
||||
"A machine with the same name and spec exists — {value}",
|
||||
],
|
||||
M01_MachineDelete: ["지우기", "Delete"],
|
||||
M01_MachineDeleteAsk: [
|
||||
"이 기계를 지울까? 쓰는 곳 {value} — [저장] 해야 지워짐",
|
||||
"Delete this machine? Used by {value} — applied on [Save]",
|
||||
],
|
||||
M01_MachineLeave: [
|
||||
"저장 안 한 고침이 있음. 나갈까? (고침은 초안에 남음)",
|
||||
"Unsaved edits. Leave? (edits stay in the draft)",
|
||||
],
|
||||
M01_MachineNotFound: ["그 기계가 없음", "Machine not found"],
|
||||
M01_MachineReadOnly: ["읽기만 — 시스템 관리자만 고침", "Read only — system admins edit"],
|
||||
M01_MachineErrors: ["고칠 칸이 있음", "Fix the marked fields"],
|
||||
M01_MachineDetailGroup: ["상세구분", "Sub-group"],
|
||||
M01_MachineSourceNo: ["원문번호", "Source no."],
|
||||
} as const;
|
||||
@@ -0,0 +1,112 @@
|
||||
/* 기계 양식(821-2) — 왼쪽 칸 · 오른쪽 시간당 계산식 · 좁은 폭(720 아래)은 한 줄로 */
|
||||
.ui-machine-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 1fr);
|
||||
gap: var(--spacing-16);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.ui-machine-form__fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-machine-form__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
|
||||
.ui-machine-form__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-machine-form__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.ui-machine-form__coefs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-machine-form__coefs .ui-input {
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ui-machine-form .is-changed .ui-input,
|
||||
.ui-machine-form .is-changed .ui-select {
|
||||
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.ui-machine-form__warn {
|
||||
min-height: 0;
|
||||
color: var(--color-warning);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.ui-machine-form__warn:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-machine-form__notice:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-machine-form__similar {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
|
||||
.ui-machine-form__similar > summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.ui-machine-form__similar .ui-find__list {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ui-machine-form__preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.ui-machine-form__preview .ui-calc-row--formula {
|
||||
grid-template-columns: 64px minmax(0, 1fr) 96px;
|
||||
}
|
||||
|
||||
.ui-machine-form__rule {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: var(--spacing-4) 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ui-machine-form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ui-machine-form__preview {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -54,6 +54,7 @@ import { createRangeBar } from "./ui_template_range_bar";
|
||||
import { createStat, statGrid } from "./ui_template_stat";
|
||||
import { toastNode } from "./ui_template_toast";
|
||||
import { FRAME_DEMOS } from "./ui_template_ui_kit_frames";
|
||||
import { createMachineForm } from "./ui_template_machine_form";
|
||||
|
||||
/** 상태 줄 다섯 칸 */
|
||||
export const KIT_STATES = ["default", "hover", "active", "disabled", "error"] as const;
|
||||
@@ -321,6 +322,22 @@ function tabsDemo(s: KitState): HTMLElement | null {
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 기계 양식 견본 — 굴착기 1.0㎥ 값(821-2) */
|
||||
const kitMachine = (readOnly: boolean): HTMLElement =>
|
||||
createMachineForm({
|
||||
name: "굴착기(무한궤도)",
|
||||
spec: "1.0㎥",
|
||||
values: {
|
||||
...{ 가격: 142069, 내용시간: 10000, 연간표준가동시간: 1250, 상각비율: 0.9, 정비비율: 0.7 },
|
||||
...{ 연간관리비율: 0.1, 상각비계수: 900, 정비비계수: 700, 관리비계수: 485, 손료계수: 2085 },
|
||||
...{ 주연료: 19.5, 주연료종류: "경유", 잡재료율: 22, 조종원: 1, 조종원직종: "1048" },
|
||||
},
|
||||
fuels: [{ value: "경유", text: "경유", price: 1846.39 }],
|
||||
jobs: [{ value: "1048", text: "건설기계운전사", price: 283297 }],
|
||||
readOnly,
|
||||
findSimilar: async () => [],
|
||||
}).root;
|
||||
|
||||
// prettier-ignore
|
||||
export const KIT_PARTS: KitPart[] = [
|
||||
// ── 552-2 기본 ─────────────────────────────────────────────
|
||||
@@ -386,6 +403,8 @@ export const KIT_PARTS: KitPart[] = [
|
||||
demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) },
|
||||
{ key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 시트 줄(이름 · 식 · 값 · 꼬리) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts",
|
||||
demo: (s) => (s === "default" ? kitCalc() : null) },
|
||||
{ key: "machine-form", name: "기계 양식", group: "compound", kinds: "이름 · 규격 · 가격 · 손료계수(바로 넣기 · 품셈 방식) · 주연료 · 잡품 · 조종원 · 전력 · 시간당 값 직접 · 옆 시간당 계산식 · 비슷한 기계에서 시작 · 고친 칸 · 숫자만", file: "ui_template_machine_form.ts",
|
||||
demo: (s) => (s === "default" || s === "disabled" ? kitMachine(s === "disabled") : null) },
|
||||
{ key: "equation", name: "수식", group: "compound", kinds: "분수(가로 줄 안 세로 쌓기) · 위 · 아래첨자 · × ÷ · 괄호 · % · 조건 칩 · 하위 로직 블록(접힘) · 한 줄 = 결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이", file: "ui_template_equation.ts",
|
||||
demo: (s) => (s === "default" ? kitEquation() : null) },
|
||||
{ key: "hopyo-grid", name: "일위대가 격자", group: "compound", kinds: "호표 머리(이름 · 규격 · 단위 · 1 단위 합계) · 구성 줄(품명 · 규격 · 수량 · 단위 · 비목 단가 / 금액 · 비고) · 줄 꼬리표 품셈 · 고정 · 합계 줄 빈 줄 붉게", file: "ui_template_hopyo_grid.ts", demo: (s) => (s === "default" ? createHopyoGrid([{ key: "D", no: 17, name: "돌기슭막이 H=2.0", unit: "m", total: { sum: 121748.6, labor: 94447.5, material: 0, expense: 27301.1 }, lines: [{ name: "돌쌓기 찰쌓기", spec: "뒷길이 35cm", qty: 2.09, unit: "㎡", prices: { sum: 52995, labor: 41250, expense: 11745 }, amounts: { sum: 110759.6, labor: 86212.5, expense: 24547.1 }, note: "산근 1호표", tag: "품셈" }, { name: "야면석 채집 운반", spec: "계곡 내", qty: 0.45, unit: "㎥", prices: { sum: 24420, labor: 18300, expense: 6120 }, amounts: { sum: 10989, labor: 8235, expense: 2754 }, tag: "고정" }, { name: "잡재료", spec: "노무비의", qty: null, unit: "%", prices: {}, amounts: {}, note: "수량 없음", tag: "품셈" }] }]).root : null) },
|
||||
|
||||
Reference in New Issue
Block a user