Merge remote-tracking branch 'origin/dev' into sub_laptop_5

This commit is contained in:
2026-11-15 17:01:14 +09:00
89 changed files with 2270 additions and 1499 deletions
-4
View File
@@ -94,10 +94,6 @@ const TEXT = {
"저장 안 한 고침이 있음 — [저장] 또는 [되돌림] 먼저",
"Save or discard edits first",
],
No_Key: [
"저장한 단가산출만 수식 문서를 엶 — [단가산출 상세] 탭",
"Saved items only — use the detail tab",
],
Read_Only: ["읽기만", "Read only"],
Tools: ["수식 도구", "Equation tools"],
Tool_Frac: ["분수", "Fraction"],
+37 -13
View File
@@ -6,6 +6,8 @@
* ㉢ 잡품 = 재료 줄(재료 · 유가 고르기 · 수량 · 율 % · 기본 = 밑 재료 줄 · 품셈 잡재료율)
* 고침 = 문서 칸 `기계덮음`(캐시 · [저장] 때 서버가 정본으로 다시 셈) · 원본과 같으면 지움(`putCover`)
* 기본값 = 덮음 없이 셈한 그 기계 식 답(`defaultsOf`) · 읽기(M01 기초) = 칸 잠김 · 단추 없음
* 기계 표 꼴(`sheet` · 840-2 기계 보기 `line_stack_machine_sheet`) = 조종원 한 줄까지 · 잡품 = 밑 줄 고정(율 하나) ·
* `lock` = 잠긴 값 칸(품셈 방식 계수 등)
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
@@ -42,6 +44,10 @@ export interface CtlHooks {
name: (key: string) => string;
/** 읽기(M01 기초 · 칸 잠김) */
readOnly: boolean;
/** 기계 표 꼴(840-2) — 기계 줄 칸에 담기는 만큼만(조종원 한 줄 · 잡품 율 하나 · 재료 고르기 없음) */
sheet?: boolean;
/** 잠긴 값 칸 */
lock?: string[];
}
/** 기계 칸 단위(기계 표 `단위` 와 같음) */
@@ -116,6 +122,7 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen
const { machine, sub, vals, base } = got;
const entry: MachineCover = got.entry ?? {};
const ro = h.readOnly;
const table = !!h.sheet;
const put = (next: MachineCover): void => h.change((d) => putCover(d, machine, next, base));
const parts = partsOf(sub);
const lines = sub.금액 ?? [];
@@ -125,6 +132,7 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen
const was = base.값[col];
const mine = entry.값?.[col];
const now = mine ?? was;
const shut = ro || !!h.lock?.includes(col);
const setVal = (v: number | string | null): void => {
const 값 = { ...(entry.값 ?? {}) };
if (v === null || v === "") delete 값[col];
@@ -140,11 +148,11 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen
value: String(now ?? ""),
compact: true,
size: 24,
disabled: ro,
disabled: shut,
ariaLabel: col,
onChange: (x) => setVal(x),
}).root
: numBox(col, now, UNIT[col] ?? "", ro, setVal);
: numBox(col, now, UNIT[col] ?? "", shut, setVal);
const row = el("div", {
className: "lstk-frow",
children: [
@@ -157,7 +165,7 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen
const tag = createBadge(lt("Ctl_Changed"), "info", { size: 18 });
tag.title = `${lt("Ctl_Was")} ${was === undefined ? "—" : eqText(was)}`;
row.append(tag);
if (!ro)
if (!shut)
row.append(
createButton({
label: lt("Ctl_Back"),
@@ -230,21 +238,30 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen
lineRow(
lt("K_재료"),
miscName(r),
ro,
ro || table,
() => pickMat((p) => setMisc(swapAt(misc, i, asMat(r, p))), r.키 ? miscName(r) : ""),
[
numBox(lt("Qty"), r.수량, r.단위 ?? "", ro, (n) => {
const { 수량: _, ...rest } = r;
setMisc(swapAt(misc, i, n === null ? rest : { ...r, 수량: n }));
}, r.줄 && r.수량 === undefined ? `${baseQty(r) ?? ""} ${lt("Ctl_Follow")}` : ""), // prettier-ignore
numBox(lt("Ctl_Rate"), r.율, "%", ro, (n) => setMisc(swapAt(misc, i, { ...r, 율: n ?? 0 }))), // prettier-ignore
...(table
? []
: [numBox(lt("Qty"), r.수량, r.단위 ?? "", ro, (n) => {
const { 수량: _, ...rest } = r;
setMisc(swapAt(misc, i, n === null ? rest : { ...r, 수량: n }));
}, r.줄 && r.수량 === undefined ? `${baseQty(r) ?? ""} ${lt("Ctl_Follow")}` : "")]), // prettier-ignore
numBox(
lt("Ctl_Rate"),
r.율,
"%",
ro,
(n) =>
setMisc(table ? misc.map((x) => ({ ...x, 율: n ?? 0 })) : swapAt(misc, i, { ...r, 율: n ?? 0 })), // prettier-ignore
),
],
"재료비",
() => setMisc(misc.filter((_, k) => k !== i)),
),
);
const head = (label: string, covered: boolean, add: () => void, reset: () => void) =>
const head = (label: string, covered: boolean, add: (() => void) | null, reset: () => void) =>
el("div", {
className: "lstk-fhead",
children: [
@@ -253,7 +270,7 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen
...(ro
? []
: [
createIconButton({ icon: "plus", title: `${label} ${lt("Ctl_Add")}`, size: 24, onClick: add }), // prettier-ignore
...(add ? [createIconButton({ icon: "plus", title: `${label} ${lt("Ctl_Add")}`, size: 24, onClick: add })] : []), // prettier-ignore
...(covered
? [createButton({ label: lt("Ctl_Back"), variant: "ghost", size: 24, iconStart: "undo", onClick: reset })] // prettier-ignore
: []),
@@ -270,14 +287,21 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen
head(
lt("Ctl_Crew"),
entry.조종원 !== undefined,
() => openStackPick("인력", (p) => setCrew([...crew, { 키: p.키, 이름: p.이름, 인: 1 }])),
table && crew.length
? null
: () =>
openStackPick("인력", (p) => setCrew([...crew, { 키: p.키, 이름: p.이름, 인: 1 }])),
() => put({ ...entry, 조종원: undefined }),
),
...(crewRows.length ? crewRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore
head(
lt("Ctl_Misc"),
entry.잡품 !== undefined,
() => pickMat((p) => setMisc([...misc, asMat({ 율: 100 }, p)]), ""),
!table
? () => pickMat((p) => setMisc([...misc, asMat({ 율: 100 }, p)]), "")
: parts.base.length && !misc.length
? () => setMisc(parts.base.map((k) => ({ 줄: String(lines[k].이름 ?? ""), 율: 0 })))
: null,
() => put({ ...entry, 잡품: undefined }),
),
...(miscRows.length ? miscRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore
+4
View File
@@ -0,0 +1,4 @@
/* 기계 보기(840-2) — 비슷한 기계 찾기 목록 높이만 · 색 · 크기는 공통 값 */
.lstk-msheet-similar .ui-find__list {
max-height: 240px;
}
+566
View File
@@ -0,0 +1,566 @@
/* =============================================================================
* line_stack_machine_sheet.ts
* 기계 보기(PLAN 840-2 · 요구 853 · 862 「기계 보기 공용」) — 기계 한 대를 단가산출 수식 보기와 닮은 꼴로:
* 왼쪽 = 기계 제어 구역(836-1 `line_stack_machine_ctl` 그대로 · 기계 표 꼴 `sheet` — 값 칸 · 조종원 인력 줄 · 잡품 줄) +
* 「기본 정보 · 손료 근거」 접이 카드(이름 · 규격 · 손료 방식 · 품셈 비율 · 식이 안 읽는 값 칸) + 비슷한 기계에서 시작(새 기계)
* 오른쪽 = 그 기계 시간당 경비 산출근거(`line_stack_out` · 노 · 재 · 경 · 계 합계 상자) — 값을 바꾸면 브라우저 계산기로 즉시
* 서버(`POST /api/m02/machine-sheet` · `M02_MyMachine_Sheet`) = 식 고름 + 처음 줄 값을 박은 식 문서 + 연결 기록 ·
* 고친 값은 덮음(`기계덮음` 꼴)으로 얹어 셈 · 캐시에 없는 연결 · 식이 바뀔 칸만 뒤에서 다시 물음(옛 요청 끊음)
* 줄 = 저장 꼴(M01 기계 줄 · 내 기계 줄) — 덮음 ↔ 줄은 `common_util_machine_sheet` · 저장은 부르는 쪽(M01 상세 · 내 기계)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { createFindList } from "@ui/ui_template_find_list";
import { t as L } from "@ui/ui_template_locale";
import { attachPickSearch } from "@ui/ui_template_pick_search";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import type { FormulaDoc, Link } from "../common_util/common_util_formula_doc";
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache";
import {
COVER,
coverDoc,
coverOf,
defaultsOf,
partsOf,
putCover,
type CoverParts,
type MachineCover,
} from "../common_util/common_util_formula_doc_cover";
import type { StackDoc } from "../common_util/common_util_line_stack";
import {
MACHINE_COEFS,
MACHINE_INPUTS,
machineCoefOf,
} from "../common_util/common_util_machine_coef";
import {
applyCover,
bindDoc,
coverFrom,
engineVals,
jobCode,
modeOf,
ownedCols,
rangeWarnings,
sameVal,
slotOf,
type MachineMode,
type MachinePick,
type MachineRow,
type MachineValues,
} from "../common_util/common_util_machine_sheet";
import { machineCellName } from "../common_util/common_util_machine_symbol_name";
import { docFoot } from "./formula_doc_foot";
import { buildMachineCtl } from "./line_stack_machine_ctl";
import { buildOut } from "./line_stack_out";
import "./line_stack.css";
import "./line_stack_machine_sheet.css";
export interface MachineSimilar {
key: string;
name: string;
spec: string;
/** 흐린 꼬리표(구분 · 내 기계 등) */
tag?: string;
row: MachineRow;
}
export interface MachineSheetOptions {
/** 처음 줄(저장본 · 새 기계 = 빈 줄) — 고친 칸 표지 · 되돌림 기준 */
orig: MachineRow;
/** 지금 줄(초안) · 없으면 처음 줄 */
cur?: MachineRow;
/** 내 기계(방식 바로 · 품셈 · 직접 · 그 사람 개인 층 기계 표) */
mine: boolean;
readOnly: boolean;
lists: { fuels: MachinePick[]; jobs: MachinePick[] };
findSimilar?: (q: string) => Promise<MachineSimilar[]>;
onSimilar?: (item: MachineSimilar) => void;
onChange?: () => void;
}
export interface MachineSheetState {
name: string;
spec: string;
/** 지금 줄(이름 · 규격 넣음) */
row: MachineRow;
mode: MachineMode;
/** 숫자 아닌 칸 — 있으면 저장 막음 */
errors: string[];
changed: boolean;
}
interface SheetInfo {
키: string | null;
이름?: string;
까닭?: string;
문서: FormulaDoc | null;
/** 처음 줄 셈 꼴 값(식에 박을 값) */
값?: MachineValues;
연결?: DocRecord;
직종: Record<string, string>;
}
/** 늘 보이는 기계 줄 칸(값이 없어도) — 그 밖 칸은 줄에 있으면 */
const BASE_KEYS = [
"가격",
"손료계수",
"주연료",
"주연료종류",
"잡재료율",
"조종원",
"조종원직종",
"전력",
];
const UNIT: Record<string, string> = {
가격: "천원", 손료계수: "10⁻⁷/hr", 내용시간: "시간", 연간표준가동시간: "시간", 주연료: "ℓ/hr",
잡재료율: "%", 조종원: "인", 전력: "kWh",
}; // prettier-ignore
const PICKS = ["주연료종류", "조종원직종"];
/** 식이 바뀔 수 있는 칸(서버 식 고름) */
const PICKY = ["주연료", "조종원", "조종원직종", "전력", "손료계수", ...MACHINE_COEFS];
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v ?? null)) as T;
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
const label = (k: string): string => machineCellName(k);
export function createMachineSheet(o: MachineSheetOptions) {
const mine = o.mine;
const ro = o.readOnly;
let orig: MachineRow = clone(o.orig);
let cur: MachineRow = clone(o.cur ?? o.orig);
let name = text(cur.이름);
let spec = text(cur.규격);
const start = JSON.stringify([name, spec, cur]);
const no = text(cur.원문번호) || text(cur.키) || "새 기계";
const given = { 기계: no };
const link: Link = { 갈래: "단가산출", 입력: { 기계: `'${no.replace(/'/g, "")}'` } };
const holder = { 꼴: "수식문서", 변수: [], 식: [], 금액: [] } as unknown as StackDoc;
const cache = createDocCache();
const opened = new Set<string>();
const errors = new Map<string, string>();
let info: SheetInfo | null = null;
let jobs: Record<string, string> = {};
let parts: CoverParts | null = null;
let base: Required<MachineCover> = { 값: {}, 조종원: [], 잡품: [] };
const left = el("div", { className: "lstk__left" });
const right = el("div", { className: "lstk__right" });
const foot = el("div", { className: "lstk__foot" });
const notice = el("div");
const warn = el("div");
const ctlBox = el("div");
const baseBox = el("div");
const root = el("div", {
className: "lstk",
attrs: { "data-edit": "1", "data-machine-sheet": no },
children: [el("div", { className: "lstk__areas", children: [left, right] }), foot],
});
attachCollapsible(baseBox);
const sub = (): FormulaDoc | null => info?.문서 ?? null;
const entry = (): MachineCover => coverOf(holder)[no] ?? {};
/** 값 박은 셈(덮음 얹은 뒤 · 제어 구역 기본값) */
const plain = (d: FormulaDoc, g: Record<string, unknown>): DocAnswer =>
raw(bindDoc(d, info?.값 ?? {}), g);
const raw = (d: FormulaDoc, g: Record<string, unknown>): DocAnswer =>
calcDoc(d, cache.links, g, [], new Map(), {});
/** 식이 바뀌거나 처음 줄 · 방식이 바뀐 뒤 — 덮음을 처음 줄 대 지금 줄로 다시 지음 */
const rebuild = (): void => {
delete (holder as { [COVER]?: unknown })[COVER];
const d = sub();
parts = d ? partsOf(d) : null;
if (!d || !parts) return;
base = defaultsOf(d, (plain(d, given).값 ?? {}) as Record<string, unknown>);
putCover(holder, no, coverFrom(parts, d.금액 ?? [], orig, cur, mine), base);
};
// ── 서버(뒤에서) ──
let seq = 0;
let timer: number | undefined;
let ctl: AbortController | null = null;
const ask = (now = false): void => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void run(), now ? 0 : 300);
};
const run = async (): Promise<void> => {
ctl?.abort();
ctl = new AbortController();
const mineSeq = ++seq;
const body = { 줄: rowOut(), 본: orig, 덮음: coverOf(holder)[no] ?? null, 개인: mine };
try {
const r = await fetch(`${API_BASE_URL}/m02/machine-sheet`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: ctl.signal,
});
const got = (await r.json().catch(() => ({}))) as SheetInfo & { detail?: unknown };
if (mineSeq !== seq) return;
if (!r.ok) throw new Error(text(got.detail) || r.statusText);
cache.merge(got.연결);
jobs = got.직종 ?? jobs;
if (!info || got.키 !== info.키) {
info = got;
rebuild();
drawAll();
} else {
info.까닭 = got.까닭; // 식은 그대로 — 까닭만 지금 줄로
drawLive();
}
} catch (e) {
if (mineSeq !== seq || (e as Error).name === "AbortError") return;
info = { 키: null, 까닭: e instanceof Error ? e.message : "", 문서: null, 직종: jobs };
parts = null;
drawAll();
}
};
const rowOut = (): MachineRow => ({ ...cur, 이름: name.trim(), 규격: spec.trim() });
const changed = (refit = false): void => {
o.onChange?.();
drawLive();
ask(refit);
};
// ── 품셈 비율 → 계수(같으면 처음 값 · 제어 구역 칸은 덮음으로) ──
const recoef = (): void => {
if (modeOf(cur, mine) !== "품셈") return;
const v = (cur.값 ??= {});
const same = MACHINE_INPUTS.every((k) => sameVal(v[k], orig.값?.[k]));
const got = same ? null : machineCoefOf(v);
if (!same && !got) return;
const owned = parts ? ownedCols(parts) : [];
const e = clone(entry());
for (const k of MACHINE_COEFS) {
const want = same ? orig.값?.[k] : got![k];
if (owned.includes(k)) {
e.값 = { ...(e.값 ?? {}) };
if (want === undefined || same) delete e.값[k];
else e.값[k] = want as number;
} else if (want === undefined) delete v[k];
else if (!sameVal(v[k], want)) v[k] = want;
}
if (parts) {
putCover(holder, no, e, base);
cur = applyCover(parts, orig, cur, entry(), mine, jobs);
}
};
// ── 왼쪽 ──
const drawCtl = (): void => {
const d = sub();
if (!d || !info?.키) return void ctlBox.replaceChildren();
const mode = modeOf(cur, mine);
const lock = mode === "품셈" ? [...MACHINE_COEFS] : mine && mode === "바로" ? ["정비비계수", "관리비계수"] : []; // prettier-ignore
const zone = buildMachineCtl(
{
doc: holder,
answer: { 값: {} } as unknown as DocAnswer,
subDoc: () => d,
calc: plain,
change: (fn) => {
fn(holder);
const e = entry();
if (e.조종원) {
e.조종원 = e.조종원.map((r) => ({ ...r, 키: `LB:${jobCode(r.키, jobs)}` }));
putCover(holder, no, e, base);
}
if (parts) cur = applyCover(parts, orig, cur, entry(), mine, jobs);
changed();
},
name: (k) => cache.names.get(k) ?? "",
readOnly: ro,
sheet: true,
lock,
},
{ ...link, 키: info.키 },
);
if (zone && lock.length && mode === "품셈") zone.title = L("MS_Locked");
ctlBox.replaceChildren(...(zone ? [zone] : []));
};
/** 칸 단위 — 줄 `단위` 글(일 단위 손료 등) › 기본 */
const unitOf = (k: string): string => {
const u = text((cur.단위 as MachineValues | undefined)?.[k]);
return !u || u === "10^-7/시간" ? (UNIT[k] ?? "") : u;
};
const numField = (k: string, read = false): HTMLElement => {
const v = cur.값?.[k];
const unit = unitOf(k);
const f = createInputField({ label: label(k), value: text(v), size: 28, ...(unit ? { unit } : {}) }); // prettier-ignore
f.input.inputMode = "decimal";
f.input.dataset.key = k;
f.input.readOnly = ro || read;
f.root.classList.toggle("is-changed", !sameVal(v, orig.값?.[k]));
f.input.addEventListener("change", () => {
const t = f.input.value.replace(/,/g, "").trim();
const box = (cur.값 ??= {});
const n = Number(t);
if (t && !Number.isFinite(n)) {
errors.set(k, `${label(k)} · ${L("MF_NumberOnly")}`);
f.setError(L("MF_NumberOnly"));
return o.onChange?.();
}
errors.delete(k);
f.setError();
if (!t) {
if (k in (orig.값 ?? {})) box[k] = null;
else delete box[k];
} else box[k] = n;
if ((MACHINE_INPUTS as readonly string[]).includes(k)) recoef();
f.root.classList.toggle("is-changed", !sameVal(box[k], orig.값?.[k]));
changed(PICKY.includes(k));
if ((MACHINE_INPUTS as readonly string[]).includes(k)) drawBase();
});
return f.root;
};
const pickField = (k: string): HTMLElement => {
const list = k === "주연료종류" ? o.lists.fuels : o.lists.jobs;
const now = text(cur.값?.[k]);
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")})` }); // prettier-ignore
const f = createSelectField({ label: label(k), options, value: now, size: 28 });
f.select.dataset.key = k;
f.select.disabled = ro;
attachPickSearch(f.select, { placeholder: L("MF_PickFind") });
f.root.classList.toggle("is-changed", !sameVal(now, orig.값?.[k]));
f.select.addEventListener("change", () => {
const box = (cur.값 ??= {});
if (f.select.value) box[k] = f.select.value;
else if (k in (orig.값 ?? {})) box[k] = orig.값![k] === "" ? "" : null;
else delete box[k];
f.root.classList.toggle("is-changed", !sameVal(box[k], orig.값?.[k]));
changed(true);
});
return f.root;
};
const setMode = (m: MachineMode): void => {
const v = (cur.값 ??= {});
const was = modeOf(cur, mine);
if (m === was) return;
if (m === "품셈") for (const k of MACHINE_INPUTS) if (orig.값?.[k] !== undefined) v[k] = clone(orig.값[k]); // prettier-ignore
if (m === "바로" && !mine) for (const k of MACHINE_INPUTS) delete v[k];
if (mine) {
if (m === "바로" && blankNum(v.손료계수)) {
const e = engineVals(cur, true);
v.손료계수 = ["상각비계수", "정비비계수", "관리비계수"].reduce(
(s, k) => s + (Number(e[k]) || 0),
0,
);
}
if (m === "직접" && !cur.시간당) cur.시간당 = Object.fromEntries((["노무비", "재료비", "경비"] as const).map((k) => [k, Math.round(lastAnswer?.[k] ?? 0)])); // prettier-ignore
cur.방식 = m;
}
recoef();
rebuild();
drawAll();
changed(true);
};
const blankNum = (x: unknown): boolean => typeof x !== "number" || !x;
const drawBase = (): void => {
const keep = baseBox.firstElementChild?.classList.contains("is-collapsed") ?? false;
const mode = modeOf(cur, mine);
const owned = new Set(
(parts && info?.키 ? ownedCols(parts) : [])
.map((c) => slotOf(c, mode, mine))
.flatMap((s) => (s && s[0] === "값" ? [s[1]] : [])),
);
const pumsem = mode === "품셈";
const keys =
mode === "직접"
? []
: [...new Set([...BASE_KEYS, ...Object.keys(cur.값 ?? {})])].filter(
(k) => !owned.has(k) && !(MACHINE_INPUTS as readonly string[]).includes(k) && !(pumsem && (MACHINE_COEFS as readonly string[]).includes(k)), // prettier-ignore
);
const textBox = (
lab: string,
get: string,
was: string,
set: (v: string) => void,
): HTMLElement => {
const f = createInputField({ label: lab, value: get, size: 28 });
f.input.readOnly = ro;
f.root.classList.toggle("is-changed", get !== was);
f.input.addEventListener("input", () => {
set(f.input.value);
f.root.classList.toggle("is-changed", f.input.value !== was);
o.onChange?.();
});
return f.root;
};
const seg = createSegment({
options: [
{ value: "바로", label: L("MF_CoefDirect") },
{ value: "품셈", label: L("MF_CoefPumsem") },
...(mine ? [{ value: "직접", label: L("MF_HourlyDirect") }] : []),
],
value: mode,
size: 24,
ariaLabel: L("MS_Mode"),
onChange: (v) => setMode(v as MachineMode),
});
if (ro) seg.root.querySelectorAll("button").forEach((b) => (b.disabled = true));
const row = (children: HTMLElement[]): HTMLElement => el("div", { className: "lstk-frow", children }); // prettier-ignore
const card = createPanelCard(L("MS_Base"), keep);
card.append(
row([
textBox(L("MF_Name"), name, text(orig.이름), (v) => (name = v)),
textBox(L("MF_Spec"), spec, text(orig.규격), (v) => (spec = v)),
]),
row([el("span", { className: "lstk-kind", text: L("MS_Mode") }), seg.root]),
...(pumsem
? [
row(MACHINE_INPUTS.map((k) => numField(k))),
row([
el("span", { className: "lstk-kind", text: L("MS_Coefs") }),
...MACHINE_COEFS.filter((k) => !owned.has(k)).map((k) => numField(k, true)),
]),
]
: []),
...(keys.length
? [row(keys.map((k) => (PICKS.includes(k) ? pickField(k) : numField(k))))]
: []),
);
baseBox.replaceChildren(card);
};
const similarBox = (): HTMLElement[] => {
if (!o.findSimilar || ro) return [];
const find = createFindList({ placeholder: L("MF_SimilarFind") });
const card = createPanelCard(L("MF_Similar"), !!(orig.이름 || orig.값 && Object.keys(orig.값).length)); // prettier-ignore
card.classList.add("lstk-msheet-similar");
card.append(find.root);
attachCollapsible(card);
let n = 0;
let wait: number | undefined;
const look = async (): Promise<void> => {
const me = ++n;
const got = await o.findSimilar!(find.search.value.trim()).catch(() => []);
if (me !== n) 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: () => {
orig = clone(m.row);
cur = clone(m.row);
name = m.name;
spec = m.spec;
errors.clear();
info = null;
parts = null;
delete (holder as { [COVER]?: unknown })[COVER];
card.classList.add("is-collapsed");
setNotice(L("MF_SimilarCopied").replace("{value}", `${m.name} ${m.spec}`.trim()));
o.onSimilar?.(m);
drawAll();
changed(true);
},
})),
);
};
find.search.addEventListener("input", () => {
window.clearTimeout(wait);
wait = window.setTimeout(() => void look(), 250);
});
void look();
return [card];
};
// ── 오른쪽 · 경고 ──
let lastAnswer: DocAnswer | null = null;
const drawRight = (): void => {
const d = sub();
const head = el("div", {
className: "lstk__bar",
children: [
el("strong", { text: L("MS_Out") }),
...(info?.키 ? [el("span", { className: "ui-text ui-text--muted", text: `${info.키} ${info.이름 ?? ""}`.trim() })] : []), // prettier-ignore
],
});
if (!info) {
lastAnswer = null;
foot.replaceChildren();
return void right.replaceChildren(head, createText(L("MS_Loading"), "muted"));
}
if (!d) {
lastAnswer = null;
foot.replaceChildren();
const why = info.까닭 ? ` — ${info.까닭}` : "";
return void right.replaceChildren(head, createText(`${L("MS_NoFormula")}${why}`, "warning"));
}
const shaped = coverDoc(d, given, coverOf(holder));
const a = plain(shaped, given);
if (cache.takeMissing()) ask();
lastAnswer = a;
right.replaceChildren(
head,
...(info.까닭 ? [createText(info.까닭, "warning")] : []),
buildOut(bindDoc(shaped, info.값 ?? {}) as StackDoc, a, {
cache,
open: opened,
toggle: (at) => {
if (opened.has(at)) opened.delete(at);
else opened.add(at);
drawRight();
},
miss: () => ask(),
redraw: () => drawRight(),
}),
);
foot.replaceChildren(docFoot(a));
};
const drawWarn = (): void => {
const vals: MachineValues = { ...engineVals(cur, mine), ...(cur.값 ?? {}) };
if (unitOf("손료계수") !== UNIT.손료계수) delete vals.손료계수; // 일 단위 손료(임업) = 시간당 범위 아님
warn.replaceChildren(
...rangeWarnings(vals).map(([k, r]) =>
createText(L("MS_Range").replace("{name}", label(k)).replace("{value}", r), "warning"),
),
);
};
const drawLive = (): void => {
drawCtl();
drawRight();
drawWarn();
};
const drawAll = (): void => {
drawBase();
drawLive();
};
const setNotice = (t: string): void => {
notice.replaceChildren(...(t ? [createText(t, "warning")] : []));
};
left.append(...similarBox(), notice, warn, ctlBox, baseBox);
drawAll();
ask(true);
return {
root,
setNotice,
state: (): MachineSheetState => ({
name: name.trim(),
spec: spec.trim(),
row: rowOut(),
mode: modeOf(cur, mine),
errors: [...errors.values()],
changed: JSON.stringify([name, spec, cur]) !== start,
}),
};
}
+2
View File
@@ -4,6 +4,7 @@
* 찾아 넣기 = M02 [항목 추가] 창 그대로(`openFindPicker` · 탭 M01 전체 · 찾기 칸 · 표) — 줄 갈래마다 그 종류만
* 인력(노임 LB) · 재료(자재 MT) · 기계(EQ → 시간당 경비 로직 + 기계='원문번호') · 다른 단가산출(M01 단가산출 로직)
* 기계 + M02 개인 층(821-3) = 품셈 기계 위 「내 기계」 묶음 · 머리 [새 기계 만들기] — 고름 = 로직 + 기계='개인 EQP…'
* 재료 + M02 개인 층(840-4) = M01 재료 위 「내 재료」 묶음 — 고름 = 요소 PM 키(M01 재료와 같은 꼴)
* M01 단가산출 기초 줄 쌓기 = 「M01 전체」 탭만 — M01 은 원천 기준값이라 M02 단가산출 · 일위대가를 부르지 않음(요구 680-1)
* M02 단가산출 줄 쌓기(`take` 있음) · 다른 단가산출 = 탭 [M01 전체 | M02 단가산출 | 일위대가](위 층 · 자기 SK 뺌) —
* 고름 = 복사해 펼침(`take(src)` · src = M01 키 | `참조|층|주인|키`) · 참조 줄 없음(요구 681)
@@ -58,6 +59,7 @@ export function openStackPick(
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,
mineMat: kind === "재료" && m02?.layer === "personal" ? { owner: m02.owner } : undefined, // 840-4 내 재료
query: query.trim() || undefined,
ref: copy
? (r) =>
+4 -2
View File
@@ -5,7 +5,7 @@
* 고침(804-7 · 요구 687-14) = [식 고치기] 걷음 — 이름 · 식 글을 누르면 그 줄만 그 자리에서 이름 · 식 · 단위 칸 + 지움 ·
* 바꾸면 닫힘 · 머리 [+] = 식 줄 더함 — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결
* 만약 안 탄 갈래 기호(`hide` · 요구 687-10) = 안 보임(식 글은 그대로)
* 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) — 꼴이 바뀌면 「내 틀 (틀 이름 에서 변경)」 ·
* 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold` · 입력에 기계 있는 줄은 안 함 840-2) — 꼴이 바뀌면 「내 틀 (틀 이름 에서 변경)」 ·
* 빈 틀 = [찾아 넣기](M01 단가산출 결과 — 작업량 등) · [틀로 저장] · 내 틀 목록 걷음(요구 685) · 표 값만 = 머리 「표 값」
* 표 값(804-3) = [표 · 칸] 단추 → 표 고르기 창(`line_stack_pick.ts`) · 값 칸 누름 = 그 줄 · 칸(`pickCell`)
* ========================================================================== */
@@ -23,6 +23,7 @@ import {
type FormulaDoc,
} from "../common_util/common_util_formula_doc";
import { isNum } from "../common_util/common_util_formula_doc_calc";
import { machineKey } from "../common_util/common_util_formula_doc_cover";
import { decide, hasIf, tryParse } from "../common_util/common_util_formula_doc_view";
import { machineText } from "./formula_doc_options";
import {
@@ -262,7 +263,8 @@ export function buildBlock(
const srcTag = src === "원파일" ? lt("Src_Native") : src === "엑셀" ? lt("Src_Excel") : src;
const tag = expr ? null : srcTag ? clipTag(srcTag) : sourceTag(h, id, link);
const tools: HTMLElement[] = [];
if (sub?.결과 && link?.키)
// 기계 줄(입력에 기계) = 풀기 안 내놓음(840-2 · 요구 864 — 기계 값 고침은 제어 구역 한 길 · 풀린 옛 줄은 그대로 열림)
if (sub?.결과 && link?.키 && !machineKey(link.입력))
tools.push(
createButton({
label: lt("Unfold"),
+8 -5
View File
@@ -23,6 +23,7 @@ from M01_MasterData import M01_MasterData_Store as store
FILE = "기계.json"
OWN_SOURCE = "자체"
HOUR_PARTS = ("시간환산", "상여계수", "휴지계수") # 조종원 노임 1/8 × 16/12 × 25/20(CC000336)
_EQ = re.compile(r"EQ\d{6}")
_cache: dict[str, Any] = {"ver": None, "hourly": {}, "uses": None}
@@ -71,12 +72,14 @@ def lists() -> dict:
if isinstance(r.get("값"), (int, float, Decimal))
]
head = store.read(FILE)[0]
hour = Decimal(1) / Decimal(8)
hour = Decimal(5) / Decimal(24) # 1/8 × 16/12 × 25/20(계수표 없을 때)
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["분모"])
if t.get("키") == "CC000336": # 기계 식 Q_시간환산 과 같은 세 줄(840-5)
got = {x.get("구분"): x for x in t.get("줄") or []}
if all(p in got for p in HOUR_PARTS):
hour = Decimal(1)
for p in HOUR_PARTS:
hour *= Decimal(got[p]["분자"]) / Decimal(got[p]["분모"])
return {
"fuels": fuels,
"jobs": jobs,
+1 -1
View File
@@ -5,7 +5,7 @@
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { MachinePick } from "@ui/ui_template_machine_form";
import type { MachinePick } from "../common_util/common_util_machine_sheet";
import type { Row } from "./M01_MasterData_Api_Fetch";
export interface MachineLists {
+2 -78
View File
@@ -1,7 +1,6 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab.ts
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 수식(줄 쌓기 `A00_Common/line_stack_screen.ts` · 804-6) · 단가산출 상세 / 오른쪽 원문
* 셈 = 화면 칸 없이 구동(`_Logic_Drive.ts` · 799-2) — 열 때 첫 셈 · 입력 · 시험 고침 · 재료 고름이 `drive.run`
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 수식(줄 쌓기 `A00_Common/line_stack_screen.ts` · 804-6) / 오른쪽 원문
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안
*
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
@@ -29,18 +28,14 @@ import {
fetchLogicUsers,
removeLogic,
saveFiles,
type CalcLine,
type TextAnswer,
type ElementBrief,
type LogicRow,
type LogicSummary,
type SaveFile,
} from "./M01_MasterData_UI_Logic_Api";
import { createDrive } from "./M01_MasterData_UI_Logic_Drive";
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
import type { SideHandle } from "./M01_MasterData_UI_Side";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { buildLabDetail, isOwnLogic, trial } from "./M01_MasterData_UI_LogicLab_Detail";
import { buildLabDetail } from "./M01_MasterData_UI_LogicLab_Detail";
import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal";
import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
@@ -67,19 +62,11 @@ interface Opened extends Draft {
id: string;
row: LogicRow | null;
original: string | null;
prices: Record<string, ElementBrief | null>;
reasons: string[];
lines: CalcLine[] | null;
text: TextAnswer | null;
/** 마지막으로 잘 받은 읽는 식 — 멈춘 뒤에도 수량 칸 조각을 이걸로 */
lastText?: TextAnswer;
ownHost: HTMLElement;
values: Record<string, string>;
}
const OWN_FILE = "로직_자체.json";
/** 시험 계산에 넘길 줄 — 시험 고침(722-2) 덮은 사본 */
const calcRow = (row: LogicRow | null): LogicRow | null => trial.apply(row);
const cacheKey = "m01_logic_drafts_lab";
const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" });
@@ -105,12 +92,9 @@ export async function mountM01LogicLab(
): Promise<LogicHandle> {
let drafts = loadDrafts();
let opened: Opened | null = null;
let calcInputs = "";
const admin = await fetchSessionUser()
.then((u) => u?.role === "SYSTEM_ADMIN")
.catch(() => false);
let recalc: number | undefined;
const drive = createDrive();
// 수식 = 줄 쌓기(804-2 · 804-6 옛 수식 문서 탭 걷음) — 수식 문서 · 캐시 m01_doc_drafts
const stack = createStackScreen({
base: "/m01/logic/doc",
@@ -122,7 +106,6 @@ export async function mountM01LogicLab(
const editor = el("div", { className: "m01-logic__editor" });
// 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」
const panes = buildLogicSide(editor);
editor.dataset.view = "stack"; // 보기 = 수식 하나(836-3) — 「단가산출 상세」 덩이는 CSS 로 숨김
const errors = el("div", {
className: "ui-banner ui-banner--danger m01-logic__reasons",
attrs: { hidden: "" },
@@ -181,13 +164,6 @@ export async function mountM01LogicLab(
if (opened.origKey !== null && now === opened.original) delete drafts[opened.id];
else drafts[opened.id] = pick(opened, clone(opened.row));
persist();
const inputs = JSON.stringify(calcRow(opened.row)?.입력 ?? []);
if (inputs !== calcInputs) drawCalc();
if (isOwnLogic(opened.row)) {
// 자체 로직 — 고친 뒤 잠시 쉬면 다시 계산해 호표 소계와 텍스트 수식을 새로 그림
window.clearTimeout(recalc);
recalc = window.setTimeout(() => void drive.run(), 700);
}
};
const pick = (o: Opened, row: LogicRow | null): Draft => ({
@@ -198,32 +174,6 @@ export async function mountM01LogicLab(
row,
});
/** 구동을 지금 로직으로 (다시) 시작 — 읽는 식 미리보기 · 첫 셈 */
const drawCalc = (): void => {
if (!opened?.row) {
drive.stop();
return;
}
const current = opened;
calcInputs = JSON.stringify(calcRow(current.row)?.입력 ?? []);
drive.start({
savedKey: current.origKey,
file: current.file,
row: calcRow(current.row) as LogicRow,
dirty: () => current.id in drafts || trial.has(current.row),
values: current.values,
onText: (text) => {
current.text = text;
if (text.ok) current.lastText = text;
drawEditor(); // [계산] 전 미리보기 응답도 왼쪽 이름 식 칸에 바로 비치게 — onLines 와 같은 자리
},
onLines: (lines) => {
current.lines = lines;
drawEditor();
},
});
};
const drawEditor = (): void => {
const listing = !opened; // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산
list.root.hidden = !listing || !loaded;
@@ -242,37 +192,19 @@ export async function mountM01LogicLab(
row: current.row as LogicRow,
file: current.file,
reasons: current.reasons,
prices: current.prices,
lines: current.lines,
text: current.text,
lastText: current.lastText,
values: current.values,
ownHost: current.ownHost,
onChange: touch,
onInput: (name, v) => syncLeft(current, { [name]: v }),
onTrial: drawCalc,
rerun: () => void drive.run(),
admin,
stack: stack.root,
});
stack.draw(current.origKey ?? "");
};
/** 단가산출 상세 옵션 입력 → 시험 입력(이름이 있는 칸만) · 바뀌면 다시 셈 */
const syncLeft = (o: Opened, ex: Record<string, string>): void => {
const names = new Set((calcRow(o.row)?.입력 ?? []).map((x) => x.이름));
const changed = Object.entries(ex).filter(([k, v]) => names.has(k) && o.values[k] !== v);
if (!changed.length) return;
for (const [k, v] of changed) o.values[k] = v;
void drive.run();
};
const show = (next: Opened | null): void => {
opened = next;
panes.showSource(next?.row?.출처 ?? "");
errors.hidden = true;
drawEditor();
drawCalc();
};
const open = async (id: string, sub: string, key: string): Promise<void> => {
@@ -282,12 +214,8 @@ export async function mountM01LogicLab(
...draft,
id,
original: null,
prices: {},
reasons: [],
lines: null,
text: null,
ownHost: ownHost(),
values: {},
});
return;
}
@@ -301,12 +229,8 @@ export async function mountM01LogicLab(
origKey: key,
row: draft ? (draft.row === null ? null : clone(draft.row)) : clone(one.logic),
original: JSON.stringify(one.logic),
prices: one.prices,
reasons: one.reasons,
lines: null,
text: null,
ownHost: ownHost(),
values: {},
});
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
@@ -2,9 +2,7 @@
* M01_MasterData_UI_LogicLab_Cover.ts
* 단가산출 상세 줄 「그 자리 고침」(PLAN 722-2 · 내역 16 · 17-2) — 줄 모달 안 수량 · 단가 칸.
* 고침 꼴 = M02 단가산출 덮개 칸과 같음(`qty` · `labor` · `material` · `expense` — `M02_Sangeun_Doc._covered` 규칙 중 품 · 단가)
* M01 = 시험 값만 — 고친 값은 화면 메모리(로직 줄 객체 열쇠)에만 · 셈 = 줄 사본에 덮어 서버로(`/calc` · `/text` row)
* · 정본 · 요소(노임 · 자재) · 캐시(초안) 안 바뀜 · 로직을 다시 열면 사라짐
* M02(724) = 같은 고침 칸 · 같은 꼴을 고침 고리(`HoEdit.set`)로 받아 덮개로 보관
* M02(724) = 고침 칸의 값을 고침 고리(`HoEdit.set`)로 받아 덮개로 보관 — M01 시험 고침은 840-1 에 걷음
* 서버 셈 = 줄 `단가`(손 단가) · 로직 줄 `값묶음`(노 · 재 · 경) · `수량` 글 — `master_pick._priced` · `bundled` 가 읽음
* ========================================================================== */
@@ -23,47 +21,8 @@ export interface LineCover {
export const COVER_PARTS = { labor: "노무비", material: "재료비", expense: "경비" } as const;
export type CoverPart = keyof typeof COVER_PARTS;
/** 지수 꼴 없는 수 글(엔진 식 칸) — 1e-7 → 0.0000001 */
const plainNum = (v: number): string =>
v.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 20 });
/** 호표 줄(차례 열쇠)에 고침을 덮은 줄 사본 — 원본은 안 바뀜 · 덧줄은 고침 대상 밖 */
export function applyCovers<R extends { 호표?: object[] }>(
row: R,
covers: Record<number, LineCover>,
): R {
const ho = (row.호표 ?? []).map((raw, i) => {
const c = covers[i];
if (!c) return raw;
const item = raw as Record<string, unknown>;
const out: Record<string, unknown> = { ...item };
if (c.qty !== undefined) out.수량 = plainNum(c.qty);
const parts = (Object.keys(COVER_PARTS) as CoverPart[]).filter((k) => c[k] !== undefined);
if (parts.length && item.종류 === "로직") {
const given = parts.map((k) => [COVER_PARTS[k], c[k]]);
out.값묶음 = { ...((item.값묶음 as object) ?? {}), ...Object.fromEntries(given) };
} else if (parts.length) out.단가 = c[parts[0]];
return out;
});
return { ...row, 호표: ho };
}
/* ── 순수 셈 끝 ── */
/** 시험 고침 — 로직 줄 객체마다(다시 열면 새 객체 = 사라짐) */
const trials = new WeakMap<object, Record<number, LineCover>>();
export const trial = {
of(row: object): Record<number, LineCover> {
let got = trials.get(row);
if (!got) trials.set(row, (got = {}));
return got;
},
has: (row: object | null): boolean => !!row && Object.keys(trials.get(row) ?? {}).length > 0,
/** 시험 계산에 넘길 줄 — 고침 없으면 원본 그대로(같은 객체) */
apply<R extends object | null>(row: R): R {
return row && trial.has(row) ? (applyCovers(row, trials.get(row)!) as R) : row;
},
};
const TEXT = {
Head: ["그 자리 고침 (시험 값)", "Edit here (trial value)"],
Note: [
@@ -1,41 +1,33 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Detail.ts
* 「단가산출 로직」 상세 화면 — 컨테이너를 위에서 아래로
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 수식(줄 쌓기 804-2 · 804-6) ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침)
* 시트 · 인력/재료/경비 · 시험 계산 · 텍스트 수식 보기는 799-2 에 걷음 — 셈은 화면 칸 없이 `_Logic_Drive.ts` 구동
* 그 자리 고침(722-2) = 시험 값만(`_LogicLab_Cover.ts` trial — 줄 사본으로 셈 · 정본 · 요소 · 캐시 안 바뀜)
* ① 기본정보(정본은 읽기 전용 · 비고만 고침 · 자체 로직은 이름 · 단위 · 출처도) ② 수식(줄 쌓기 804-2 · 804-6)
* ③ M02 수식 문서 없는 줄(일위대가 줄 모달 등)만 단가산출 상세 몸(`cover` — 공용 부품 `_LogicLab_Ho.ts`)
* 단가산출 상세 보기 · 자체 로직 시트 편집기 · 시험 고침은 840-1 에 걷음 — 자체 로직도 수식(줄 쌓기)에서 만들고 고침
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import type { LogicRow } from "./M01_MasterData_UI_Logic_Api";
import { buildEditor } from "./M01_MasterData_UI_Logic_Edit";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
import { buildHo, lineOpener, type HoContext, type HoEdit } from "./M01_MasterData_UI_LogicLab_Ho";
import { trial } from "./M01_MasterData_UI_LogicLab_Cover";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import "./M01_MasterData_UI_LogicLab_Style.css";
import "./M01_MasterData_UI_LogicLab_Pill.css";
import { block } from "./M01_MasterData_UI_LogicLab_View";
/** 원문 절 머리 개정 연도 글 — [2008, 2020, 2025] → 「'08 · '20 · '25」(없으면 빈 글) */
const revisionText = (row: LogicRow): string =>
(row.개정 ?? []).map((y) => `'${String(y).slice(-2)}`).join(" · ");
export { trial };
export interface DetailContext extends HoContext {
export interface DetailContext {
row: LogicRow;
file: string;
reasons: string[];
/** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */
/** 기본정보 접힘 기억 자리 — 다시 그려도 같은 칸을 씀 */
ownHost: HTMLElement;
onChange: () => void;
/** 시스템 관리자 — 기본 로직 안내 글이 다름(시트에서 고침 / 보기만) */
admin?: boolean;
/** 722-2 시험 고침 뒤 — 구동을 고친 줄로 다시 시작(있으면 그 자리 고침 켜짐 · 셈은 `rerun`) */
onTrial?: () => void;
/** 717-2b M02 단가산출 — 단가산출 상세 덩이를 이 칸으로(SK 셈 답 · 고침 표) */
cover?: { ho: HTMLElement; open?: ReturnType<typeof lineOpener> };
/** 717-2b M02 단가산출 — 수식 문서 없는 줄의 단가산출 상세 덩이(SK 셈 답 · 고침 표) */
cover?: { ho: HTMLElement };
/** 수식 탭 = 줄 쌓기 그릇(`A00_Common/line_stack_screen.ts` · 804-2 · 804-6) */
stack?: HTMLElement;
}
@@ -148,88 +140,18 @@ function infoBox(ctx: DetailContext): HTMLElement[] {
/** 자체 로직(키 GX…) — 정본은 아님 */
export const isOwnLogic = (row: LogicRow): boolean => row.키.startsWith("GX");
/** 자체 로직은 정본 화면과 같은 편집기를 호표 아래에 폄(머리 칸은 기본정보에서 고치므로 숨김) */
function ownEditor(ctx: DetailContext): HTMLElement {
if (!ctx.ownHost.childElementCount)
buildEditor(ctx.ownHost, {
row: ctx.row,
file: ctx.file,
isNew: false,
files: [],
reasons: [],
prices: ctx.prices,
lines: ctx.lines,
onChange: ctx.onChange,
onFile: () => undefined,
onPick: openPicker,
});
return el("details", {
className: "m01lab__edit",
attrs: { open: "" },
children: [
el("summary", { text: tl("Edit_Title") }),
el("p", { className: "ui-text ui-text--muted", text: tl("Edit_SheetHint") }),
ctx.ownHost,
],
});
}
/** M01 그 자리 고침 고리 — 시험 값(줄 객체 열쇠) · 고치면 시험 계산을 다시 그리고 셈 */
function trialEdit(ctx: DetailContext): HoEdit | undefined {
const redraw = ctx.onTrial;
if (!redraw) return undefined;
const covers = trial.of(ctx.row);
return {
covers,
scope: "m01",
set: (idx, cover) => {
if (cover) covers[idx] = cover;
else delete covers[idx];
redraw();
ctx.rerun?.();
},
};
}
function hoBox(ctx: DetailContext): HTMLElement {
const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원");
if (!money) {
return block(
section(tl("Ho_Title"), [
el("p", { className: "ui-text ui-text--muted", text: tl("Not_Money") }),
el("pre", { className: "m01lab__expr", text: ctx.row.결과?.식 ?? "" }),
]),
"ho",
);
}
const body = buildHo({ ...ctx, edit: trialEdit(ctx) });
const totalLine = body.pop()!; // 계 줄 — 자체 로직은 접힌 표 밖에 둠
// 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 박스는 접어 둠(펼치면 소계가 보임)
const fold = el("details", {
className: "m01lab__fold",
attrs: ctx.ownHost.dataset.fold ? { open: "" } : {},
children: [el("summary", { text: tl("Ho_Fold") }), ...body],
});
fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : ""));
const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...body, totalLine];
const calculated = ctx.lines !== null;
const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`;
return block(section(tl("Ho_Title"), blocks, hint), "ho");
}
/** 상세 화면(기본정보 · 수식 · 단가산출 상세)을 `host` 에 쌓음 */
/** 상세 화면(기본정보 · 수식 · M02 단가산출 상세 몸)을 `host` 에 쌓음 */
export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
// 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림
// 다시 그리면 포커스가 빠짐 — 고치던 기본정보 칸을 기억해 되돌림
const active = document.activeElement;
const kept =
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
? { node: active, from: active.selectionStart, to: active.selectionEnd }
: null;
// 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임
host.replaceChildren(
...infoBox(ctx),
...(ctx.stack ? [block(ctx.stack, "stack")] : []),
ctx.cover ? block(section(tl("Ho_Title"), [ctx.cover.ho]), "ho") : hoBox(ctx),
...(ctx.stack ? [ctx.stack] : []),
...(ctx.cover ? [section(tl("Ho_Title"), [ctx.cover.ho])] : []),
);
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
const back = own ? host.querySelector<HTMLInputElement>(`[data-own="${own}"]`) : kept?.node;
@@ -31,7 +31,6 @@ import { pctView } from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { formatLineMoney, formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick";
import { coverForm, type CoverPart, type LineCover } from "./M01_MasterData_UI_LogicLab_Cover";
import {
condText,
@@ -144,8 +143,6 @@ export interface HoContext {
values: Record<string, string>;
/** 박스 옵션 · 입력 칩 고침 — 시험 계산 입력 고침 + 다시 셈(없으면 고르기 · 칩 없음) */
onInput?: (name: string, value: string) => void;
/** 지금 값으로 다시 셈(구동) — 재료 후보 고르기가 씀(없으면 후보만 보임) */
rerun?: () => void;
/** 그 자리 고침 고리(없으면 모달은 보기만) */
edit?: HoEdit;
/** (M02 750-2) Ho 줄 차례 → SK 줄 cid — 읽는 식 줄을 `cid` 로 이음(이름 차례 추측 안 함) */
@@ -279,11 +276,7 @@ function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLi
// 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜)
kind: item?.종류,
usedSlot: line?.품목?.값칸,
...(item?.종류 === "재료" && ctx.pickMat
? { onPick: ctx.pickMat(e.idx) }
: item?.종류 === "재료" && ctx.rerun
? { onPick: (it) => pickMaterial(ctx.values, e.idx, it, ctx.rerun!) }
: {}),
...(item?.종류 === "재료" && ctx.pickMat ? { onPick: ctx.pickMat(e.idx) } : {}),
// 덧줄(%) = M02 덮개만 율 고침(M01 시험 값은 호표 줄만)
...(edit && (e.extra ? edit.scope === "m02" : item)
? {
@@ -177,12 +177,6 @@
font-weight: 600;
}
/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김 */
.m01-logic__editor[data-view="stack"] [data-block]:not([data-block="stack"]),
.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]) {
display: none;
}
/* 기본정보 — 늘 맨 위 접힌 한 줄 */
.m01lab__infofold > summary {
display: flex;
@@ -17,19 +17,11 @@ const TEXT = {
"Base logic — view only · only the note can be edited",
],
Info_Own: [
"자체 로직 — 칸을 고치면 잠시 뒤 호표 소계가 다시 계산됨",
"Own logic — edits recalculate the subtotals shortly",
"자체 로직 — 이름 · 단위 · 출처 · 비고는 여기서 · 식은 수식에서 고침",
"Own logic — edit the info here · formulas in the Equation view",
],
Edit_Title: [
"고치기 (호표 · 중간 값 · 덧줄 · 끝수)",
"Edit (lines · middles · extras · rounding)",
],
Ho_Fold: ["묶음 소계 표 (읽기용 — 고치기는 위 편집기)", "Group subtotals (read-only)"],
Ho_Title: ["단가산출 상세", "Unit-price detail"],
Calc_Title: ["시험 계산", "Trial calculation"],
View_Equation: ["수식", "Equation"],
Edit_SheetHint: ["식은 「수식」 보기에서 고침", "Edit formulas in the Equation view"],
View_Label: ["보기", "View"],
G_labor: ["인력", "Labor"],
G_material: ["재료", "Material"],
G_cost: ["경비", "Expense"],
@@ -46,9 +38,6 @@ const TEXT = {
Col_Price: ["단가", "Price"],
Col_Amount: ["금액", "Amount"],
Empty_Group: ["줄 없음", "No lines"],
Not_Money: ["호표가 없는 로직 — 결과 식", "No unit-cost lines — result formula"],
Calc_First: ["금액은 셈이 끝나면 채워짐", "Amounts fill when the calculation finishes"],
Click_Hint: ["줄을 누르면 그 줄이 쓰는 자료가 뜸", "Click a line to see the data it uses"],
Modal_Close: ["닫기", "Close"],
Pill_Tone: ["요소 연결", "Linked element"],
Modal_Sum: ["한 줄 풀이", "In one line"],
@@ -1,15 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_View.ts
* 「단가산출 로직」 상세 보기 이름표 — 보기 = 수식(줄 쌓기 `A00_Common/line_stack_screen.ts`) 하나 · 고르기 단추 없음(836-3)
* 그릇 `data-view="stack"` 고정 — `data-block="ho"` 덩이(단가산출 상세 표)는 CSS 로 숨김
* 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지
* 셈 구동은 화면 칸 없이 `_Logic_Drive.ts` · 기본정보는 이름표 없음(늘 맨 위)
* ========================================================================== */
export type LabView = "stack" | "ho";
/** 덩이에 보기 이름표를 붙여 돌려줌 */
export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
node.dataset.block = view;
return node;
};
@@ -1,12 +1,10 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Drive.ts
* 셈 구동(PLAN 799-2) — 화면 없이 「입력값 → 서버 셈 → 답」을 돌림. 시험 계산 칸(`Logic_Calc`)과 로직 상세(`LogicLab`)가 같이 씀.
* `driveRun` = 한 번 셈(`/calc` + `/text`) · `previewText` = 열 때 읽는 식 미리보기 · `autoFill` = 자동값 채움
* `createDrive` = 로직 상세용 묶음 — 열 때 미리보기 + 첫 셈(입력 없으면 바로 · 있으면 자동값 채운 뒤) · `run` 은 칸 없이 다시 셈
* 셈 구동(PLAN 799-2) — 화면 없이 「입력값 → 서버 셈 → 답」을 돌림. 시험 계산 칸(`Logic_Calc`)이 씀
* `driveRun` = 한 번 셈(`/calc` + `/text`) · `autoFill` = 자동값 채움 — 로직 상세 구동은 840-1 에 걷음
* 셈 자리 = 서버(CLAUDE.md 5 장 ③) · 화면 · sessionStorage 없음 · 고른 재료(`CalcPick`) 는 값 객체에 딸림
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import {
fetchAuto,
runCalc,
@@ -16,8 +14,7 @@ import {
type LogicRow,
type TextAnswer,
} from "./M01_MasterData_UI_Logic_Api";
import { isMaterialKeyInput, pickBody } from "./M01_MasterData_UI_Logic_CalcPick";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { pickBody } from "./M01_MasterData_UI_Logic_CalcPick";
export interface DriveContext {
/** 저장된 키 — 새 로직은 null */
@@ -67,17 +64,6 @@ export async function driveRun(
return { answer, draft };
}
/** 열 때 읽는 식 미리보기 — [계산] 전에도 이름 식 칸이 비치게 · 늦게 온 응답은 `live()` 로 거름 */
export function previewText(ctx: DriveContext, live: () => boolean): void {
if (!ctx.onText) return;
const body = { key: ctx.savedKey ?? ctx.row.키, inputs: {}, ...draftBody(ctx) };
void runText(body).then((answer) => {
if (!live()) return;
(answer as TextAnswer & { 계산됨?: boolean }).계산됨 = false;
ctx.onText?.(answer);
});
}
/** 자동값으로 빈 칸 채움 — 채웠으면 true */
export async function autoFill(ctx: DriveContext): Promise<boolean> {
if (ctx.savedKey === null) return false;
@@ -88,49 +74,3 @@ export async function autoFill(ctx: DriveContext): Promise<boolean> {
for (const n of names) ctx.values[n] = String(auto[n]);
return names.length > 0;
}
/** 로직마다 첫 셈을 한 번만(다시 그려도 안 부름) */
const first = new WeakSet<Record<string, string>>();
/** 로직 상세용 구동 — `start` = 로직을 열거나 입력 칸이 바뀔 때 · `run` = 지금 값으로 다시 셈 */
export function createDrive(): {
start: (ctx: DriveContext) => void;
run: () => Promise<void>;
stop: () => void;
} {
let seq = 0; // 로직을 바꾸면 늦게 온 응답을 거름
let now: DriveContext | null = null;
let ran = false; // 셈 답이 이미 왔으면 뒤늦은 미리보기로 안 덮음
const run = async (): Promise<void> => {
const ctx = now;
if (!ctx) return;
ran = true;
try {
await driveRun(ctx);
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
const start = (ctx: DriveContext): void => {
now = ctx;
ran = false;
const mine = ++seq;
previewText(ctx, () => !ran && mine === seq);
if (first.has(ctx.values)) return;
if (!(ctx.row.입력 ?? []).some((spec) => !isMaterialKeyInput(spec))) {
first.add(ctx.values); // 입력이 없으면 채울 것이 없음 — 열 때 한 번 바로 셈
queueMicrotask(() => mine === seq && void run());
} else if (ctx.savedKey !== null) {
first.add(ctx.values);
void autoFill(ctx).then((filled) => filled && mine === seq && void run());
}
};
return {
start,
run,
stop: () => {
now = null;
seq++;
},
};
}
@@ -1,6 +1,7 @@
/* =============================================================================
* M01_MasterData_UI_MachineDetail.ts
* M01 기계 상세 페이지(PLAN 821-2 · 요구 790-2 · 791) — 기계 양식 부품 + 원문번호 · 원문 · 구분 ·
* M01 기계 상세 페이지(PLAN 821-2 · 840-2 · 요구 790-2 · 791 · 862) — 기계 보기(`line_stack_machine_sheet` ·
* 왼쪽 제어 구역 + 오른쪽 시간당 경비 산출근거 · 고침 = M01 기계 줄 정본 값) + 원문번호 · 원문 · 구분 ·
* 상세구분 · 별칭 · 비고 · 쓰는 계산식(822-2 · 서버 식 고름) · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인).
* 고침은 초안(sessionStorage)에 바로 쌓임 → 페이지 [저장] 한 번에 서버로(자동저장 없음) ·
* 서버가 계수를 계수 짝으로 다시 셈 · 단위 · 출처 칸을 채움(`M01_MasterData_Machine.fix`).
@@ -16,8 +17,8 @@ import {
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 { createMachineSheet, type MachineSimilar } from "../A00_Common/line_stack_machine_sheet";
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";
@@ -96,22 +97,19 @@ async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () =>
name: text(r["이름"]),
spec: text(r["규격"]),
tag: text(r["상세구분"] || r["구분"]),
values: (r["값"] ?? {}) as Row,
row: { 이름: r["이름"], 규격: r["규격"], 값: (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,
const form = createMachineSheet({
orig: (orig ?? cur) as Row,
cur,
mine: false,
readOnly: false,
lists,
findSimilar: isNew ? findSimilar : undefined,
onSimilar: (item) => {
const r = found.get(item.key);
if (!r || !isNew) return;
@@ -131,7 +129,7 @@ async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () =>
];
notice.replaceChildren(...errors.map((e) => createText(e, "error")));
if (errors.length) return;
const row: Row = { ...base, ...cur, 이름: s.name, 규격: s.spec, 값: s.values };
const row: Row = { ...base, ...cur, 이름: s.name, 규격: s.spec, 값: s.row.값 ?? {} };
for (const m of META) {
if (orig && meta[m] === metaOrig[m]) {
if (m in orig) row[m] = orig[m];
@@ -305,8 +303,6 @@ async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () =>
}),
);
paintFormula();
if (opts.target === "new")
host.querySelector<HTMLDetailsElement>("details")?.setAttribute("open", "");
}
/** 새 기계 바탕 — 칸 차례는 기계 줄과 같음 · 단위 · 출처는 서버가 채움 */
+4 -3
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
from typing import Any
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Material_Own as own
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete import M02_Sangeun_Copy as skc
@@ -46,7 +47,7 @@ def _have(copy: dict) -> set[str]:
def gaps(copy: dict) -> list[str]:
"""값 사본에 없는데 문서 셈이 읽은 M01 줄 키 + 문서의 내 기계 키(셈 틀림 = 읽은 데까지)."""
"""값 사본에 없는데 문서 셈이 읽은 M01 줄 키 + 문서의 내 기계 · 내 재료(840-4) 키(셈 틀림 = 읽은 데까지)."""
if "로직" not in copy or not isinstance(copy.get("문서"), dict):
return []
seen = _Seen(dc.over(copy))
@@ -55,7 +56,7 @@ def gaps(copy: dict) -> list[str]:
except dc._ERR:
pass
have = _have(copy)
return sorted({*seen.keys, *mym.keys_in(copy["문서"])} - have)
return sorted({*seen.keys, *mym.keys_in(copy["문서"]), *own.keys_in(copy["문서"])} - have)
def fill(copy: dict) -> dict:
@@ -76,7 +77,7 @@ def fill_imported(sk: dict, doc: Any) -> dict:
return sk
seen = _Seen(dim._index(sk, None, doc))
dim.run(doc, sk, index=seen) # 셈 틀림 = 읽은 데까지(run 이 받음)
keys = sorted({*seen.keys, *mym.keys_in(doc)} - _have(sk[skc.KEY]))
keys = sorted({*seen.keys, *mym.keys_in(doc), *own.keys_in(doc)} - _have(sk[skc.KEY]))
if not keys:
return sk
return {**sk, skc.KEY: skc.grow(sk[skc.KEY], skc.snap([], keys))}
+17
View File
@@ -257,9 +257,26 @@ def _link_of(item: dict) -> tuple[dict | None, str]:
link = {"갈래": "요소", "키": key}
if key[:2] == dc.cv.mm.LINKED: # 품셈재료 = 줄 단위로 값이 갈림(M01 변환과 같음)
link["단위"] = str(item.get("단위") or "")
if key[:2] == dc.cv.mm.MARKET and (unit := str(item.get("단위") or "")):
link["단위"] = unit # 840-3 자재품목 = 셈이 줄 단위로 환산(`master_pick.priced`)
return link, _unit_note(key, unit)
return link, ""
def _unit_note(key: str, unit: str) -> str:
"""840-3 자재 단위 ↔ 줄 단위 글 — 같음 · 1 : 1 = 빈 글 · 같은 성격 = 환산 · 성격 다름 = 경고(사람이 고른 줄도)."""
mu = dc.cv.mm.mu
try:
have = str(imp._whole().get(key).get("단위") or "")
except Exception: # noqa: BLE001 — 없는 키 = 셈이 끊김 표지
return ""
if not have or mu.factor(have, unit) == 1:
return ""
if mu.same_kind(have, unit):
return f"단위 환산 {have} → {unit}"
return f"단위 다름 {unit} ↔ {have} — 값은 자재 단위 그대로"
OPTION = "_옵션" # 연결이 부르는 옵션 변수(문서에 없으면 `_attach` 가 더함)
+9 -5
View File
@@ -77,7 +77,7 @@ def m01_picks(kind: str, line: dict[str, Any]) -> list[dict[str, Any]]:
@lru_cache(maxsize=4096)
def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[dict[str, Any]]:
if kind in ("인력", "재료") and (named := _alias(kind, name, spec)):
if kind in ("인력", "재료") and (named := _alias(kind, name, spec, unit)):
return named # 815-1 K2 — M01 줄 「별칭」 칸 먼저
if kind == "인력":
trade = est.trade(name)
@@ -130,17 +130,21 @@ def _found(line: dict[str, Any]) -> list[dict[str, Any]]:
return out
def _alias(kind: str, name: str, spec: str) -> list[dict[str, Any]]:
def _alias(kind: str, name: str, spec: str, unit: str = "") -> list[dict[str, Any]]:
"""M01 별칭 칸 이음(`M02_Elem_Alias`) — 인력 = 이름 · 재료 = 이름 + 규격 › 표기 정규화 정확 일치 ·
같은 품목 지역 줄은 하나."""
같은 품목 지역 줄은 하나(840-3 줄 단위 넘김 = 같은 단위 줄 먼저)."""
from M02_MasterTemplete import M02_Elem_Alias as ea
table = "LB" if kind == "인력" else ea.MARKET
keys = ea.keys(table, name, "" if kind == "인력" else spec)
if kind == "재료" and len(keys) > 1:
keys = [k] if (k := ea.one({"이름": name, "규격": spec}, keys)) else keys
keys = [k] if (k := ea.one({"이름": name, "규격": spec, "단위": unit}, keys)) else keys
if kind == "재료" and not keys: # 815-1 K3 — 표기 정규화 정확 일치(바탕 이름 길보다 먼저)
keys = [k] if (k := ea.one({"이름": name, "규격": spec}, ea.exact(name, spec))) else []
keys = (
[k]
if (k := ea.one({"이름": name, "규격": spec, "단위": unit}, ea.exact(name, spec)))
else []
)
rows = [(k, ea.row(table, k)) for k in keys]
if kind == "인력":
return [pick("인력", k, r.get("이름"), "", "인", 단가=_plain(r.get("값"))) for k, r in rows]
+6 -2
View File
@@ -241,8 +241,12 @@ 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)}
from M02_MasterTemplete import M02_Material_Own as mat
whole = mat.whole(
mym.whole(whole)
) # 지금 층 내 기계(821-1) · 내 재료(840-4) — 없으면 M01 그대로
own = {str(r.get("키")): r for r in (*mym.added(whole), *mat.added(whole))}
mine = {str(r.get("키")): r for r in over or []}
if mine:
from M02_MasterTemplete import M02_Sangeun_Copy as skc
+35 -2
View File
@@ -196,10 +196,13 @@ def own_materials(
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""개인 자재 목록(148-2) — 개인 층만 · 회사 · 마스터 = 빈 목록."""
"""개인 자재 목록(148-2) — 개인 층만 · 회사 · 마스터 = 빈 목록 · 줄마다 쓰는 곳(840-4)."""
if layer != "personal":
return {"줄": []}
return {"줄": own.rows(_root(session, layer, owner))}
folder = _root(session, layer, owner)
rows = own.rows(folder)
used = own.usage(folder, [str(r.get("키")) for r in rows])
return {"줄": [{**r, "쓰는곳": used[str(r.get("키"))]} for r in rows]}
@router.post("/layers/{layer}/materials")
@@ -215,3 +218,33 @@ def new_material(
raise HTTPException(status_code=400, detail="신규 자재는 개인 층에서만")
folder = _root(session, layer, owner, write=True)
return _call(folder, own.add, folder, body)
@router.put("/layers/{layer}/materials/{key}")
def edit_material(
layer: Layer,
key: str,
body: dict[str, Any],
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""내 재료 고침(840-4) — `{이름, 규격, 단위, 값}` · 키 그대로 · 개인 층만."""
if layer != "personal":
raise HTTPException(status_code=400, detail="내 재료는 개인 층에서만")
folder = _root(session, layer, owner, write=True)
return _call(folder, own.update, folder, key, body)
@router.delete("/layers/{layer}/materials/{key}")
def drop_material(
layer: Layer,
key: str,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""내 재료 지움(840-4) — 쓰는 곳 있으면 409 · 개인 층만."""
if layer != "personal":
raise HTTPException(status_code=400, detail="내 재료는 개인 층에서만")
folder = _root(session, layer, owner, write=True)
_call(folder, own.remove, folder, key)
return {"ok": True}
@@ -28,6 +28,7 @@ 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 { listMyMaterials, type MyMaterial } from "./M02_MyMaterial_Api";
import { mt } from "./M02_MyMachine_Text";
import { openModal } from "@ui/ui_template_modal";
import { createTabs } from "@ui/ui_template_tabs";
@@ -91,6 +92,8 @@ export interface FindPickOptions {
oil?: (e: M01Element) => void;
/** 821-3 개인 층 내 기계 묶음(`machine` 과 함께) — 주인(null = 자기 층) */
mine?: { owner: string | null };
/** 840-4 개인 층 내 재료(PM) 묶음 — 재료 고름 위 · 주인(null = 자기 층) */
mineMat?: { owner: string | null };
}
/** 727-1 기초마다 한 줄(옛 창 `Labor_Pick` 도 씀) — 규격 칸 = 「옵션 N」 */
@@ -194,6 +197,7 @@ export function openFindPicker(opt: FindPickOptions): void {
let machines: PickItem[] = [];
let oils: PickItem[] = []; // 785-3 유가전력 MO
let mines: MyMachineRow[] = []; // 821-3 내 기계
let mats: MyMaterial[] = []; // 840-4 내 재료
const lists: Record<"sk" | "pre", SangeunRow[] | null> = { sk: null, pre: null };
let shown: Line[] = [];
let seq = 0;
@@ -312,6 +316,16 @@ export function openFindPicker(opt: FindPickOptions): void {
};
});
};
/** 840-4 내 재료 줄 — M01 재료처럼 고름(코드 = PM 키 · 값 = 내 목록 값) */
const matLines = (q: string): Line[] => {
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
return elementLines(
"재료",
mats
.filter((r) => words.every((w) => `${r.이름} ${r.규격} ${r.키}`.toLowerCase().includes(w)))
.map((r) => ({ ref: r.키, 구분: L("M02_FindMyMaterial"), ...r })),
).map((x) => ({ ...x, 출처: x.출처.replace(/^M01 /, "") }));
};
const docLines = (rows: SangeunRow[], kind: string): Line[] =>
rows.map((x) => ({
품명: x.제목 || x.이름,
@@ -329,6 +343,7 @@ export function openFindPicker(opt: FindPickOptions): void {
shown = [
...(ok("logic") ? logicLines(q) : []),
...(ok("labor") ? elementLines("인력", people) : []),
...(opt.mineMat && ok("material") ? matLines(q) : []),
...(ok("material") ? elementLines("재료", goods) : []),
...(opt.oil && ok("oil") ? elementLines("유가", oils) : []),
...(opt.machine && opt.mine && ok("machine") ? mineLines(q) : []),
@@ -456,6 +471,13 @@ export function openFindPicker(opt: FindPickOptions): void {
draw();
})
.catch(fail);
if (opt.mineMat && ok("material"))
void listMyMaterials(opt.mineMat.owner)
.then((rows) => {
mats = rows;
draw();
})
.catch(fail);
void upper(opt.layer, opt.owner)
.then((scopes) =>
Promise.all(
@@ -20,7 +20,6 @@ import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { t as L } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { swapUnit } from "../A00_Common/master_unit_swap";
import {
fetchLogic,
setLogicApiPaths,
@@ -31,12 +30,10 @@ import {
type TextAnswer,
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail";
import { buildHo, lineOpener, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho";
import { buildHo, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho";
import { readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead";
import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
import { openRateLine } from "./M02_MasterTemplete_Sangeun_Rate";
import { createSangeunTabs } from "./M02_MasterTemplete_Sangeun_Tabs";
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
import { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side";
@@ -57,7 +54,7 @@ import {
type SkAnswer,
type SkDoc,
} from "./logic/M02_Sangeun_Cover_Model";
import { resetQty, setQty, withFirst } from "./logic/M02_Sangeun_Undo";
import { resetQty, setQty } from "./logic/M02_Sangeun_Undo";
export interface SangeunDetailOptions {
layer: Layer;
@@ -138,74 +135,13 @@ export function mountSangeunDetail(
}
}
const ownHost = el("div");
const addButton = createButton({
label: L("M02_SangeunAddLine"),
variant: "ghost",
size: 28,
onClick: () =>
openSangeunLinePicker(scope, (line) => {
if ("종류" in line && line.종류 === "로직" && !doc.바탕) return setBase(line.로직);
if ("종류" in line && line.종류 === "로직") return void addBase(line);
doc.더한줄 = [
...(doc.더한줄 ?? []),
withFirst(line as unknown as NonNullable<SkDoc["더한줄"]>[number]),
];
touch();
}),
});
addButton.hidden = !edit || !!opt.source;
// 778-4 율 줄 — 기준 줄 후보 = 지금 셈 답 줄(세부 줄 뺌) · 새 줄은 맨 끝(앞 줄만 가리킴)
const rateButton = createButton({
label: L("M02_SkRateAdd"),
variant: "ghost",
size: 28,
onClick: () =>
openRateLine(
(ans?.줄 ?? []).map((l) => ({ cid: l.cid, 이름: l.이름 })),
(line) => ((doc.더한줄 = [...(doc.더한줄 ?? []), withFirst(line)]), touch()),
),
});
rateButton.hidden = !edit || !!opt.source;
/** 둘째 기초(728-2) — 수량 처음값 = 바탕 기준단위 1 개 = 그 기초 기준단위 몇 개(같은 성격 · 다르면 1) */
async function addBase(line: { 로직: string; 수량: number }): Promise<void> {
let qty = 1;
const have = base ? baseUnitOf(base.logic.결과단위) : "";
try {
const swap = have
? swapUnit(have, baseUnitOf((await fetchLogic(line.로직)).logic.결과단위))
: null;
if (swap?.배 != null && swap.계수 === null) qty = swap.배;
} catch {
/* 못 읽음 = 1 그대로 */
}
if (!alive) return;
doc.더한줄 = [
...(doc.더한줄 ?? []),
withFirst({ ...line, 수량: qty } as unknown as NonNullable<SkDoc["더한줄"]>[number]),
];
touch();
}
/** 첫 기초 = 바탕(옛 [기본 로직 선택] 동작 · 단위는 `loadBase` 가 기초 결과단위로) */
function setBase(key: string): void {
doc.바탕 = { 로직: key };
delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음
touch(false);
void loadBase(true);
}
const main = el("div", {
className: "m02-sk",
attrs: { "data-sangeun-detail": doc.키 ?? "" },
children: [
head,
notes,
...(opt.slot
? []
: [
el("div", {
className: "m02-sk__head",
children: [tabs.tools, addButton, rateButton],
}),
]),
...(opt.slot ? [] : [el("div", { className: "m02-sk__head", children: [tabs.tools] })]),
tabs.host,
editor,
],
@@ -420,15 +356,9 @@ export function mountSangeunDetail(
row: base?.logic ?? row,
file: base?.file ?? "",
reasons: base?.reasons ?? [],
prices: base?.prices ?? {},
lines: null,
text,
lastText,
values,
ownHost,
onChange: () => undefined,
onInput,
cover: { ho, open: lineOpener(hoCtx) },
cover: { ho },
});
// prettier-ignore
side?.set([
@@ -4,13 +4,10 @@
* · `listLayerDocs` = 층 목록 길(`/layers/{층}/templates` · 종류 하나)을 마스터 › 회사 › 개인 차례로 이음 ·
* 금액 = 서버가 붙인 1 단위 `기준금액` · `listSangeun` = 종류 `sangeun` · 찾아 넣기 창(`Find_Pick`) 탭 · B09 재료 창고 출처가 씀
* · 고른 SK = 참조 `{층, 주인, 키: SK-…}` — 일위대가 줄은 옛 참조 줄 꼴(서버 `M02_Labor_Expand` 가 펼침)
* · `openSangeunLinePicker` = 단가산출 [항목 추가] — 찾아 넣기 창(721-2 · M01 로직 · 인력 · 재료 · 단가산출 · 일위대가)
* → SK 문서 `더한줄` 한 줄(PLAN 715 이음 꼴 · 인력 · 재료 = 요소 키 LB · MT · 수량 1)
* ========================================================================== */
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import { fixedOf, openFindPicker } from "./M02_MasterTemplete_Find_Pick";
import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import { call } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
export const SANGEUN_KIND = "sangeun";
/** 층 차례 — 위에서 아래 */
@@ -68,29 +65,3 @@ export async function listLayerDocs(
);
return lists.flat();
}
/** 단가산출 더한줄 한 줄(PLAN 715 이음 꼴) */
export type SangeunLine =
| { 종류: "로직"; 로직: string; 입력값: Record<string, never>; 수량: number }
| { 참조: LaborRef; 수량: number }
| { 종류: "인력" | "재료"; 요소: string; 이름: string | null; 단위: string | null; 수량: number }
| { 고정: LaborFixed; 이름: string; 단위: string | null; 수량: number };
/** 단가산출 [항목 추가] — `scope` = 그 SK 문서 층(고를 수 있는 서고 = 위 층만) */
export function openSangeunLinePicker(
scope: { layer: Layer; owner: string | null },
done: (line: SangeunLine) => void,
): void {
openFindPicker({
...scope,
logic: (x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 }),
ref: (ref) => done({ 참조: ref, 수량: 1 }),
element: (e) =>
(e.종류 === "인력" || e.종류 === "재료") &&
done({ 종류: e.종류, 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 }),
// 785-3 유가전력 = 더한줄 재료 요소 MO
oil: (e) => done({ 종류: "재료", 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 }),
// 736-3 기계 = 고정 줄(이음 기계 EQ · 단가 = 한 시간 노 · 재 · 경) · 수량 1
machine: (e) => done({ 고정: fixedOf(e), 이름: e.이름, 단위: e.단위, 수량: 1 }),
});
}
@@ -1,103 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Rate.ts
* M02 단가산출(SK) 율 줄 넣기 창(PLAN 778-4 · 요구 623) — [율 줄 추가] → 이름 · 율 % · 기준 · 금액이 드는 칸.
* 기준 = 묶음(인력 · 재료 · 장비 줄 합) · 비목 전체(노무비 · 재료비 · 경비) · 줄 고르기(이 SK 줄 cid 목록 ·
* 앞 율 줄 고르면 율 위 율) → 더한줄 `{고정: {이름, 단위 %, 단가 0, 이음 없음, 비율 {기준, 비목}}, 수량 = 율}`
* (꼴 = 서버 `M02_Sangeun_Doc` · 셈 = 서버 `M02_Sangeun_Calc._rated`) · 부품 상자만.
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { el } from "@ui/ui_template_elements";
import { createFormGrid } from "@ui/ui_template_general_layout";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import type { SkAdded } from "./logic/M02_Sangeun_Cover_Model";
const LINES = "줄";
/** 율 줄 넣기 — `rows` = 지금 셈 답 줄(기준 줄 고르기 후보) · `done` = 새 더한줄 한 줄 */
export function openRateLine(
rows: { cid: string; 이름: string }[],
done: (line: SkAdded) => void,
): void {
const name = createInputField({ label: L("M02_SkRateName"), value: L("M02_SkRateDefault") });
const rate = createInputField({
label: L("M02_SkRateRate"),
type: "number",
value: "2",
unit: "%",
min: 0,
});
const picks = rows.map((r) => ({
cid: r.cid,
box: createCheckbox(r.이름 || r.cid, { chip: true, size: 28 }),
}));
const list = el("div", {
className: "m02-sk__tags",
attrs: { "data-sk-rate-lines": "" },
children: picks.length
? picks.map((p) => p.box.root)
: [el("span", { text: L("M02_SkRateNoLines") })],
});
list.hidden = true;
const basis = createSelectField({
label: L("M02_SkRateBasis"),
value: "인력",
options: [
{ value: "인력", text: L("M02_SkRateGroupLabor") },
{ value: "재료", text: L("M02_SkRateGroupMat") },
{ value: "장비", text: L("M02_SkRateGroupMach") },
{ value: "노무비", text: L("M02_SkRateCostLabor") },
{ value: "재료비", text: L("M02_SkRateCostMat") },
{ value: "경비", text: L("M02_SkRateCostExp") },
{ value: LINES, text: L("M02_SkRateBasisLines") },
],
onChange: (v) => (list.hidden = v !== LINES),
});
const part = createSelectField({
label: L("M02_SkRatePart"),
value: "경비",
options: [
{ value: "경비", text: L("M02_SkRatePartExp") },
{ value: "재료비", text: L("M02_SkRatePartMat") },
{ value: "노무비", text: L("M02_SkRatePartLabor") },
],
});
openModal({
title: L("M02_SkRateTitle"),
closeLabel: L("M02_SkRateClose"),
width: "40rem",
mount: (body, close, actions) => {
const ok = createButton({
label: L("M02_SkRateOk"),
variant: "filled",
onClick: () => {
const label = name.input.value.trim();
const q = Number(rate.input.value);
const keys = picks.filter((p) => p.box.input.checked).map((p) => p.cid);
const lines = basis.select.value === LINES;
name.setError(label ? undefined : L("M02_SkRateNeedName"));
rate.setError(rate.input.value.trim() && q >= 0 ? undefined : L("M02_SkRateBadRate"));
if (lines && !keys.length) return void rate.setError(L("M02_SkRateNeedLine"));
if (!label || !rate.input.value.trim() || !(q >= 0)) return;
const fx = {
이름: label,
단위: "%",
단가: { 노무비: 0, 재료비: 0, 경비: 0 },
이음: { 노임: null, 자재: null, 기계: null },
비율: { 기준: lines ? keys : basis.select.value, 비목: part.select.value },
};
done({ 고정: fx, 이름: label, 단위: "%", 수량: q });
close();
},
});
ok.dataset.skRateOk = "";
actions.append(ok);
const grid = createFormGrid([name.root, rate.root, basis.root, part.root]);
grid.dataset.skRate = "";
body.append(grid, list);
},
});
}
@@ -63,7 +63,6 @@ export function createSangeunTabs(
const show = (doc: boolean): void => {
stack.root.hidden = tools.hidden = !doc;
editor.hidden = doc;
if (!doc) editor.dataset.view = "ho";
};
show(true);
return {
+141 -5
View File
@@ -6,12 +6,16 @@
`master_material.element`). 품번 = `PM` + 6자리 — 모든 사람 통틀어 한 줄 번호(모든 개인 층 파일
가장 큰 번호 + 1 · `store._LOCK` 안 · 화면이 보낸 키는 안 받음). 신규 자재는 개인 층에서만
(회사 · 마스터 층 = 마스터 자재만).
840-4 내 재료 = M01 재료 DB 에 없는 자재를 등록하는 자리 — M01 과 이름 · 규격 · 단위가 같으면 막음(409) ·
고치기 · 지우기(쓰는 곳 있으면 막음) · 단가산출 셈 색인에 M01 재료처럼 얹음(`whole` · `M02_Sangeun_Copy.whole_`)
→ 프로젝트 층 밖 SK 는 값이 바뀌면 자동 따라옴(836-2 `M02_Sk_AutoRenew`) · 프로젝트 층 = 사본 값.
"""
from __future__ import annotations
import json
import re
import unicodedata
from pathlib import Path
from typing import Any
@@ -22,6 +26,7 @@ from M02_MasterTemplete import M02_Template_Layers as layers
FILE = "개인자재.json"
PREFIX = "PM"
KEY = re.compile(rf"^{PREFIX}(\d{{6}})$")
FIND = re.compile(rf"(?<![A-Za-z0-9]){PREFIX}\d{{6}}(?!\d)")
COLS = ("이름", "규격", "단위")
@@ -70,23 +75,154 @@ def _value(v: Any) -> float | int | None:
return int(got) if got.is_integer() else got
def add(folder: str | Path, body: dict[str, Any]) -> dict[str, Any]:
"""새 개인 자재 한 줄 → 그 줄(키는 서버) · 품명 · 단위 빈 = 400 ·
같은 품명 + 규격 + 단위 = 409."""
def _norm(v: Any) -> str:
"""빈칸 · 대소문자 · 겹낱자(㎏ = kg · ㎥ = m3) 무시."""
return unicodedata.normalize("NFKC", " ".join(_text(v).split())).lower()
def m01_same(row: dict[str, Any]) -> dict[str, Any] | None:
"""M01 재료 DB(자재품목 MT)에서 이름 · 규격 · 단위가 같은 줄(빈칸 · 대소문자 무시)."""
from M01_MasterData import M01_MasterData_Store as m01
want = tuple(_norm(row.get(k)) for k in COLS)
for r in (m01.loaded()[1].index.get("MT") or {}).values():
if tuple(_norm(r.get(k)) for k in COLS) == want:
return r
return None
def _checked(body: dict[str, Any], others: list[dict[str, Any]]) -> dict[str, Any]:
"""품명 · 단위 빈 = 400 · 내 목록 같은 줄 · M01 재료 DB 같은 줄 = 409."""
row = {k: _text(body.get(k)) for k in COLS}
if not row["이름"] or not row["단위"]:
raise store.StoreError(400, "품명 · 단위는 꼭 적음")
row["값"] = _value(body.get("값"))
if any(all(_norm(x.get(k)) == _norm(row[k]) for k in COLS) for x in others):
raise store.StoreError(409, f"이미 있는 자재 「{row['이름']} {row['규격']}」")
if m01_same(row):
raise store.StoreError(
409,
f"재료 DB 에 있는 자재 「{row['이름']} {row['규격']}」 — 재료 DB 자재를 고르고 "
"값은 단가산출 안 재료 줄에서 바꿈",
)
return row
def add(folder: str | Path, body: dict[str, Any]) -> dict[str, Any]:
"""새 개인 자재 한 줄 → 그 줄(키는 서버) · 품명 · 단위 빈 = 400 ·
같은 품명 + 규격 + 단위(내 목록 · M01 재료 DB) = 409."""
with store._LOCK:
doc = read(folder)
if any(all(_text(x.get(k)) == row[k] for k in COLS) for x in doc["줄"]):
raise store.StoreError(409, f"이미 있는 자재 「{row['이름']} {row['규격']}」")
row = _checked(body, doc["줄"])
got = {"키": f"{PREFIX}{_last() + 1:06d}", **row}
doc["줄"].append(got)
atomic_write_json(Path(folder) / FILE, doc)
return got
def update(folder: str | Path, key: str, body: dict[str, Any]) -> dict[str, Any]:
"""내 재료 한 줄 고침(키 그대로) — 없는 키 = 404 · 검사는 새로 만들기와 같음."""
with store._LOCK:
doc = read(folder)
at = next((i for i, r in enumerate(doc["줄"]) if r.get("키") == key), None)
if at is None:
raise store.StoreError(404, f"없는 내 재료 「{key}」")
row = _checked(body, [r for i, r in enumerate(doc["줄"]) if i != at])
doc["줄"][at] = got = {"키": key, **row}
atomic_write_json(Path(folder) / FILE, doc)
return got
def _aside(path: Path, folder: str | Path) -> bool:
"""밑줄 · 점 폴더(「_합친것」 등 파생 · 보관) — 쓰는 곳 아님."""
return any(x.startswith(("_", ".")) for x in path.relative_to(folder).parts[:-1])
def usage(folder: str | Path, keys: list[str]) -> dict[str, list[dict[str, Any]]]:
"""그 층에서 키마다 쓰는 문서(단가산출 · 일위대가 …) {키: [{종류, 키, 이름}]} — 파일은 한 번씩 읽음."""
out: dict[str, list[dict[str, Any]]] = {k: [] for k in keys}
for path in sorted(Path(folder).rglob("*.json")): # 종류 폴더 · 그 밑 폴더 — 내 목록 파일 뺌
if path.name == FILE or _aside(path, folder):
continue
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
hit = [k for k in keys if k in text]
if not hit:
continue
try:
doc = json.loads(text)
except ValueError:
continue
name = (doc.get("이름") if isinstance(doc, dict) else None) or path.stem
for k in hit:
out[k].append({"종류": path.parent.name, "키": path.stem, "이름": name})
return out
def uses(folder: str | Path, key: str) -> list[dict[str, Any]]:
return usage(folder, [key])[key]
def remove(folder: str | Path, key: str) -> None:
"""내 재료 지움 — 쓰는 문서가 있으면 409(그 줄을 먼저 바꿈)."""
with store._LOCK:
doc = read(folder)
if not any(r.get("키") == key for r in doc["줄"]):
raise store.StoreError(404, f"없는 내 재료 「{key}」")
if used := uses(folder, key):
names = " · ".join(str(u["이름"]) for u in used[:3])
raise store.StoreError(409, f"쓰는 곳 {len(used)} — {names} · 그 줄을 먼저 바꿈")
doc["줄"] = [r for r in doc["줄"] if r.get("키") != key]
atomic_write_json(Path(folder) / FILE, doc)
# ── 840-4 셈 색인(단가산출이 M01 재료처럼 읽음) ─────────────────────────
_MEMO: dict[str, tuple] = {}
def whole(base: Any) -> Any:
"""셈 색인 + 지금 층(개인) 내 재료 줄 — 파일 · 바탕이 그대로면 같은 객체 · 개인 층 아님 = 바탕 그대로."""
from decimal import Decimal
from M02_MasterTemplete import M02_Sangeun_Copy as skc
folder = store.root()
path = folder / FILE
try:
st = path.stat()
except OSError:
return base
if not _personal(folder):
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]
raw = json.dumps(rows(folder), ensure_ascii=False)
mine = [
r
for r in json.loads(raw, parse_float=Decimal, parse_int=Decimal)
if is_own(str(r.get("키")))
]
out = skc.Copy(base, mine, ("내재료", sig)) if mine else base
if len(_MEMO) > 16: # ponytail: 통째 비움 · 층이 많아지면 LRU 로
_MEMO.clear()
_MEMO[str(path)] = (sig, base, out)
return out
def keys_in(doc: Any) -> list[str]:
"""문서 안 내 재료 키(요소 이음 「PM…」 · 사본 빈틈 찍기)."""
return sorted(set(FIND.findall(json.dumps(doc, ensure_ascii=False, default=str))))
def added(index: Any) -> list[dict]:
"""`whole` 이 얹은 내 재료 줄(사본 모으기 `collect` 가 마스터 밖 줄로 담음)."""
return list((getattr(index, "index", {}).get(PREFIX) or {}).values())
# ── 148-3 — 저장 검사 · 셈 · 사본 · 층 사이 ─────────────────────────────
BUNDLE = "재료_개인자재.json" # 사본 `단가.json` 안 이름
+3
View File
@@ -4,6 +4,7 @@
* 줄 = 그 층 단가산출 줄에 값을 바로 적은 재료(우리 자재에 안 이어진 것 · 서버 `M02_MyMachine_Look.items`)
* 칸 = 이름 · 규격 · 단위 · 값 · 쓰는 단가산출 수 · 같은 이름 수(같은 이름 묶음 = 이름 차례로 붙어 보임)
* 줄 누름 = 아래 쓰는 단가산출 목록 → 누르면 그 단가산출로(고치기는 거기서 · 여기선 저장 없음)
* 위 = 「내 재료」 등록 칸(840-4 · `M02_MyMaterial_View` — 재료 DB 에 없는 자재 등록) · 이 모아 보기는 그 아래 읽기
* ========================================================================== */
import { createDataTable } from "@ui/ui_template_data_table";
@@ -11,6 +12,7 @@ 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 { buildMyMaterialBlock } from "./M02_MyMaterial_View";
import { mt } from "./M02_MyMachine_Text";
export interface MyItemsViewOptions {
@@ -85,6 +87,7 @@ export function buildMyItemsView(opt: MyItemsViewOptions): HTMLElement {
className: "m02-mymach-view",
attrs: { "data-my-items": "" },
children: [
buildMyMaterialBlock({ owner: opt.owner }),
el("div", { className: "ui-toolbar", children: [search.root, hint] }),
createText(mt("Items_Note"), "muted"),
fail,
+20
View File
@@ -349,6 +349,26 @@ def _user() -> str:
return user
def not_m01(raw: Any, skip: str = "") -> None:
"""화면 만들기 · 고치기(840-2) — 이름 · 규격이 M01 기계와 같으면 409(내 기계 = M01 에 없는 기계만 ·
불러오기 자동 생성은 이 검사 없이 `create`)."""
raw = raw if isinstance(raw, dict) else {}
if any(h["출처"] == "M01" for h in same(raw.get("이름"), raw.get("규격"), skip)):
raise _bad(
"M01 에 있는 기계 — 값은 단가산출 안에서 고침 · 내 기계는 M01 에 없는 기계만", 409
)
def create_own(raw: Any = None, base: str | None = None) -> dict[str, Any]:
not_m01(raw)
return create(raw, base)
def update_own(key: str, raw: Any) -> dict[str, Any]:
not_m01(raw, key)
return update(key, raw)
def create(raw: Any = None, base: str | None = None) -> dict[str, Any]:
_user()
seed = _seed(base) if base else {}
+1 -1
View File
@@ -5,7 +5,7 @@
* 양식 목록 · 비슷한 기계 찾기 · 내 품목 = `M02_MyMachine_Look`(읽기만)
* ========================================================================== */
import type { MachinePick, MachineValues } from "@ui/ui_template_machine_form";
import type { MachinePick, MachineValues } from "../common_util/common_util_machine_sheet";
import type { DocRecord } from "../common_util/common_util_formula_doc_cache";
import { call } from "./M02_MasterTemplete_Api_Fetch";
import type { M01Element } from "./M02_MasterTemplete_Find_Pick";
+73 -90
View File
@@ -1,26 +1,26 @@
/* =============================================================================
* M02_MyMachine_Form.ts
* M02 내 기계 양식 한 장(PLAN 821-3 · 요구서 796 1 · 3 장) — 기계 양식 부품(`ui_template_machine_form` · 821-2) +
* [저장] · [지우기] · 「개인 값」 표지 · 같은 이름 · 규격 알림 · 서버 정본 시간당 · 쓰는 곳.
* 기본 = 손료계수 「바로 넣기」 · 「시간당 값 직접」 꼴 · 직종 · 연료 = M01 목록 고르기 · 옆 계산식 즉시(계수 짝 TS)
* M02 내 기계 한 대(PLAN 821-3 · 840-2 · 요구 862) — 기계 보기(`A00_Common/line_stack_machine_sheet` · M01 기계 상세와
* 같은 꼴: 왼쪽 제어 구역 + 기본 정보 · 손료 근거 · 오른쪽 시간당 경비 산출근거) + [저장] · [지우기] · 「개인 값」 표지 ·
* 같은 이름 · 규격 알림 · 서버 정본 시간당 · 쓰는 곳.
* 내 기계 = M01 에 없는 기계를 등록하는 자리 — 이름 · 규격이 M01 기계와 같으면 알림 + 서버가 저장 막음 ·
* M01 기계 값 고침은 단가산출 안 기계 제어 구역(836-1) · 「복사해 내 기계로」 걷음(840-2)
* [저장] 때만 서버(자동저장 없음) — 서버가 정본으로 다시 셈한 시간당을 받아 보임
* 새 기계 = 「비슷한 기계에서 시작」(M01 · 내 기계 · 고른 키 = 서버 `본뜬`) 또는 빈 양식 · `seed` = 품셈 기계 복사
* 새 기계 = 「비슷한 기계에서 시작」(M01 · 내 기계 · 고른 키 = 서버 `본뜬`) 또는 빈 줄
* 지우기 = 쓰는 단가산출 이름 보이고 확인 + 「이미 만든 단가산출은 마지막 값 유지」
* 부르는 곳 = 내 기계 화면(`M02_MyMachine_View` · 827-1 = 읽기 전용 · 지우기만) — 만들기 · 고치기는
* 단가산출 기계 창(`A00_Common/line_stack_machine` · 초안 → 단가산출 [저장])
* ========================================================================== */
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 MachineFormState,
type MachineSimilar,
type MachineValues,
} from "@ui/ui_template_machine_form";
import { t as L } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import {
createMachineSheet,
type MachineSheetState,
type MachineSimilar,
} from "../A00_Common/line_stack_machine_sheet";
import type { MachineRow } from "../common_util/common_util_machine_sheet";
import {
createMyMachine,
deleteMyMachine,
@@ -29,7 +29,6 @@ import {
readMyMachine,
updateMyMachine,
type MyHourly,
type MyMachine,
type MyMachineBody,
type MyMachineOne,
type SimilarRow,
@@ -41,12 +40,6 @@ export interface MyMachineFormOptions {
owner: string | null;
/** 고칠 내 기계 키 — 없으면 새 기계 */
key?: string;
/** 새 기계 바탕(복사해 내 기계로) — 값 · 이름 · 규격 · 본뜬 키 */
seed?: SimilarRow;
/** 새 기계면 「비슷한 기계에서 시작」 을 펼침 */
openSimilar?: boolean;
/** 827-1 보기만(양식 읽기 전용 · [저장] 없음 · [지우기] 그대로) */
readOnly?: boolean;
onSaved: (got: MyMachineOne) => void;
onDeleted?: () => void;
onBack?: () => void;
@@ -60,29 +53,32 @@ export interface MyMachineFormHandle {
const won = (v: number | undefined): string =>
`${(v ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 2 })}${mt("Won")}`;
const RATIOS = ["내용시간", "연간표준가동시간", "상각비율", "정비비율", "연간관리비율"];
/** 줄(저장 꼴) → 양식 값 묶음 — 직접 = 시간당 칸 하나 */
export function valuesOf(row: Pick<MyMachine, "방식" | "값" | "시간당">): MachineValues {
return row.방식 === "직접" ? { [HOURLY_KEY]: { ...(row.시간당 ?? {}) } } : { ...row.값 };
/** 찾은 줄 → 내 기계 줄 꼴(M01 줄 = 비율 다섯 있으면 품셈 · 없으면 바로 — 손료계수 = 계수 셋 합) */
export function rowOfSimilar(r: SimilarRow): MachineRow {
if (r.출처 === "개인") return { 이름: r.이름, 규격: r.규격, 방식: r.방식 ?? "바로", 값: { ...(r.값 ?? {}) }, 시간당: r.시간당 }; // prettier-ignore
const v = { ...(r.값 ?? {}) };
const how = RATIOS.every((k) => typeof v[k] === "number" && v[k]) ? "품셈" : "바로";
if (how === "바로" && typeof v.손료계수 !== "number")
v.손료계수 = ["상각비계수", "정비비계수", "관리비계수"].reduce(
(s, k) => s + (Number(v[k]) || 0),
0,
);
return { 이름: r.이름, 규격: r.규격, 방식: how, 값: v };
}
/** 양식 상태 → 보낼 몸(틀림 = 글 목록) — 내 기계 양식 · 기계 창 [확인] 한 벌 */
export function bodyOfForm(s: MachineFormState): { body: MyMachineBody | null; errors: string[] } {
/** 보기 상태 → 보낼 몸(틀림 = 글 목록) */
export function bodyOfSheet(s: MachineSheetState): {
body: MyMachineBody | null;
errors: string[];
} {
const errors = [...s.errors, ...(s.name ? [] : [mt("Name_Needed")])];
if (errors.length) return { body: null, errors };
if (s.hourly)
return {
body: {
이름: s.name,
규격: s.spec,
방식: "직접",
시간당: (s.values[HOURLY_KEY] ?? {}) as MyMachineBody["시간당"],
},
errors,
};
const { [HOURLY_KEY]: _drop, ...vals } = s.values;
const how = s.mode === "pumsem" ? "품셈" : "바로";
return { body: { 이름: s.name, 규격: s.spec, 방식: how, 값: vals }, errors };
const head = { 이름: s.name, 규격: s.spec };
if (s.mode === "직접")
return { body: { ...head, 방식: "직접", 시간당: (s.row.시간당 ?? {}) as MyMachineBody["시간당"] }, errors }; // prettier-ignore
return { body: { ...head, 방식: s.mode, 값: s.row.값 ?? {} }, errors };
}
/** 서버 시간당 한 줄 글 */
@@ -100,41 +96,30 @@ export async function mountMyMachineForm(
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>();
let base: string | undefined; // 서버 `본뜬`
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 start: MachineRow = row
? { 키: row.키, 원문번호: row.원문번호, 이름: row.이름, 규격: row.규격, 방식: row.방식, 값: row.값, ...(row.시간당 ? { 시간당: row.시간당 } : {}) } // prettier-ignore
: { 이름: "", 규격: "", 방식: "바로", 값: {} };
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,
readOnly: opt.readOnly,
let m01Same = false;
const sheet = createMachineSheet({
orig: start,
mine: true,
readOnly: false,
lists,
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.시간당 }),
};
}),
(await findSimilar(opt.owner, q)).map((r) => ({
key: r.키,
name: r.이름,
spec: r.규격,
tag: r.출처 === "개인" ? mt("Mine") : r.구분,
row: rowOfSimilar(r),
})),
onSimilar: (item) => {
base = item.key;
checkSame();
@@ -146,42 +131,42 @@ export async function mountMyMachineForm(
},
});
let saved = !opt.key && seed ? false : true;
const dirty = (): boolean => form.state().changed || !saved;
let saved = true;
const dirty = (): boolean => sheet.state().changed || !saved;
/* --- 같은 이름 · 규격 알림(M01 · 내 기계) --- */
/* --- 같은 이름 · 규격 알림(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(" · "))
? [
...(hits.some((h) => h.출처 === "M01") ? [L("MS_M01Same")] : []),
mt("Same").replace("{value}", hits.map((h) => `${h.출처} ${h.키}`).join(" · ")),
].join(" — ")
: "";
const checkSame = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(async () => {
const mine = ++seq;
const s = form.state();
const s = sheet.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));
m01Same = hits.some((h) => h.출처 === "M01");
saveBtn.disabled = m01Same;
sheet.setNotice(sameText(hits));
}, 300);
};
/* --- 저장 --- */
const bodyOf = (): MyMachineBody | null => {
const { body, errors } = bodyOfForm(form.state());
notice.replaceChildren(...errors.map((e) => createText(e, "error")));
return body;
};
const saveBtn = createButton({ label: mt("Save"), variant: "filled", size: 28 });
saveBtn.dataset.mymachSave = "";
saveBtn.hidden = !!opt.readOnly;
saveBtn.addEventListener("click", async () => {
const body = bodyOf();
if (!body) return;
const { body, errors } = bodyOfSheet(sheet.state());
notice.replaceChildren(...errors.map((e) => createText(e, "error")));
if (!body || m01Same) return;
saveBtn.disabled = true;
try {
const got = opt.key
@@ -196,7 +181,7 @@ export async function mountMyMachineForm(
createText(error instanceof Error ? error.message : mt("Failed"), "error"),
);
} finally {
saveBtn.disabled = false;
saveBtn.disabled = m01Same;
}
});
@@ -238,7 +223,11 @@ export async function mountMyMachineForm(
server.replaceChildren(
...(one ? [createText(`${mt("Server_Hourly")} ${hourlyText(one.시간당)}`)] : []),
);
if (one?.같은이름.length) form.setNotice(sameText(one.같은이름));
if (one?.같은이름.length) {
m01Same = one.같은이름.some((h) => h.출처 === "M01");
saveBtn.disabled = m01Same;
sheet.setNotice(sameText(one.같은이름));
}
const uses = one?.쓰는곳 ?? [];
usesBox.replaceChildren(
el("strong", { text: `${mt("Uses")} ${uses.length}` }),
@@ -261,19 +250,13 @@ export async function mountMyMachineForm(
delBtn,
],
}),
createText(mt(opt.readOnly ? "View_Note" : "Own_Note"), "muted"),
createText(mt("Own_Note"), "muted"),
notice,
server,
form.root,
sheet.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 };
}
+23 -2
View File
@@ -2,12 +2,14 @@
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`) ·
GET `/api/m02/layers/{층}/my-machine-find?q=&owner=` = 「비슷한 기계에서 시작」 · 같은 이름 알림 —
그 층 내 기계(앞) + M01 기계(`M01_MasterData_Machine.find`) ·
q 가 M01 원문번호와 같으면 그 기계 먼저.
GET `/api/m02/layers/{층}/my-items?owner=` = 내 품목 — 그 층 단가산출 수식 문서 재료비 줄 가운데
단가가 우리 자재에 안 이어진 것(단가 변수 연결 = 숫자 · 입력) ·
(이름 · 규격 · 단위 · 값) 묶음마다 쓰는 단가산출.
POST `/api/m02/machine-sheet?owner=` 몸 {줄, 본?, 덮음?, 개인?} = 기계 보기(840-2 · `M02_MyMachine_Sheet` · 쓰기 없음 ·
개인 = 그 개인 층 · 아니면 M01 만).
권한 = 내 기계 길과 같음(`M02_MyMachine_Router._folder` — 개인 층 · 본인 · 맡은 회사 관리자 읽기).
⚠ 등록은 `main.py`(로그인만).
"""
@@ -18,6 +20,7 @@ import json
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from M01_MasterData import M01_MasterData_Store as m01
@@ -125,3 +128,21 @@ def get_items(
layer: str, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
return {"rows": _call(_folder(session, layer, owner), items)}
class SheetBody(BaseModel):
줄: dict[str, Any]
본: dict[str, Any] | None = None
덮음: dict[str, Any] | None = None
개인: bool = False
@router.post("/machine-sheet")
def post_sheet(
body: SheetBody, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""기계 보기(840-2 · `M02_MyMachine_Sheet`) — 개인 = 내 기계(그 층) · 아니면 M01 기계만 · 쓰기 없음."""
from M02_MasterTemplete import M02_MyMachine_Sheet as ms
folder = _folder(session, "personal", owner) if body.개인 else store.FOLDER
return _call(folder, ms.sheet, body.줄, body.본, body.덮음, body.개인)
+3 -3
View File
@@ -1,7 +1,7 @@
"""M02 내 기계 길(PLAN 821-1 · `M02_MyMachine`) — `/api/m02/layers/personal/my-machines`(?owner=).
GET 목록(줄마다 시간당 · 쓰는곳수) · GET `/{키}` 하나(+시간당 · 쓰는곳 · 같은이름) ·
POST 만들기 몸 {줄?, 본뜬?: M01 · 내 기계 키(「비슷한 기계에서 시작」 · 「M01 기계 복사해 만들기」)} ·
POST 만들기 몸 {줄?, 본뜬?: M01 · 내 기계 키(「비슷한 기계에서 시작」)} · 만들기 · 고치기 = M01 과 같은 이름 · 규격 409(840-2) ·
PUT `/{키}` 몸 {줄} · DELETE `/{키}`(답 = 쓰는곳 — 그 단가산출은 사본의 마지막 값 그대로) ·
POST `/draft` 몸 {줄, 키?, 본뜬?} = 단가산출 기계 창 [확인](827-1 · 쓰기 없음 · 층 쓰기 권한) → {줄, 셈값, 시간당}.
권한 = 층 권한 한 벌(`Router_Items._root` — 본인 개인 층 · 맡은 회사 관리자) · 개인 층만(그 밖 404).
@@ -66,7 +66,7 @@ def my_machine_create(
owner: str | None = None,
session: Session = Depends(verify_session),
):
return _call(_folder(session, layer, owner, True), mym.create, body.줄, body.본뜬)
return _call(_folder(session, layer, owner, True), mym.create_own, body.줄, body.본뜬)
@router.put(PATH + "/{key}")
@@ -77,7 +77,7 @@ def my_machine_update(
owner: str | None = None,
session: Session = Depends(verify_session),
):
return _call(_folder(session, layer, owner, True), mym.update, key, body.줄)
return _call(_folder(session, layer, owner, True), mym.update_own, key, body.줄)
@router.delete(PATH + "/{key}")
+90
View File
@@ -0,0 +1,90 @@
"""기계 보기 서버 몫(PLAN 840-2 · 요구 853 · 862) — M01 기계 상세 · 내 기계가 같이 씀 · 쓰기 없음.
`sheet(줄, 본, 덮음, 개인)` → {키, 이름, 까닭, 문서, 값, 연결, 직종}
- 식 고름 = `M02_Machine_Hourly.pick`(지금 줄 · 내 기계 = 셈 꼴 `M02_MyMachine.engine`) · 못 고름 = 키 없음 + 까닭
- 문서 = 그 식 수식 문서(그대로) · 값 = `본`(처음 줄 · 없으면 줄)의 셈 꼴 값 칸 — 셈 차례 = 덮음(`기계덮음` 꼴 ·
836-1) 얹음 → 값 박음(`bind` — 남은 요소 `EQ:{S_기계}` 칸 → 숫자 · 저장 전 새 기계 · 내 기계도 같은 계산기로 셈)
- 파이썬 · TS 짝 — `bind` ↔ `common_util/common_util_machine_sheet.ts` `bindDoc` · 거울 시험 test_840_2_machine_sheet.py
- 연결 = 덮음 얹은 문서 셈이 쓴 요소 · 표 · 문서(`master_doc_record`) — 화면이 같은 계산기로 즉시 다시 셈
- 직종 = 노임 요소 코드 → 인력 원문번호(조종원 줄 → 기계 줄 `조종원직종`)
"""
from __future__ import annotations
import copy
import json
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
def _plain(v: Any) -> Any:
if isinstance(v, Decimal):
return int(v) if v == v.to_integral_value() else float(v)
return v
def bind(doc: dict, vals: dict) -> dict:
"""식 문서 → 기계 값 칸을 숫자로 박은 새 문서(그 칸이 없는 줄은 요소 그대로)."""
out = copy.deepcopy(doc)
for v in out.get("변수") or []:
link = v.get("연결") or {}
key = str(link.get("키") or "")
col = link.get("칸")
if (
link.get("갈래") == "요소"
and key.startswith("EQ:")
and "{S_기계}" in key
and col in vals
):
v["연결"] = {"갈래": "숫자", "값": vals[col]}
return out
def _shape(row: dict, mine: bool) -> dict:
if mine:
return mym.engine(row)
return {**row, "값": m01._dec(row.get("값") or {})}
def _jobs() -> dict[str, str]:
rows = (m01.loaded()[0].get("인력.json") or {}).get("줄") or []
return {str(r["키"]): str(r.get("원문번호")) for r in rows if r.get("키")}
def sheet(row: Any, base: Any = None, cover: Any = None, mine: bool = False) -> dict:
import master_doc_calc as mc
import master_doc_cover as mcv
import master_doc_link as ml
import master_doc_record as mr
from M01_MasterData import M01_MasterData_Doc as m01doc
from M02_MasterTemplete import M02_Machine_Hourly as mh
if not isinstance(row, dict):
raise m01.StoreError(400, "기계 줄이 묶음 아님")
cur = _shape(row, mine)
logic = mh.pick(cur)
out: dict[str, Any] = {"직종": _jobs()}
doc = m01doc.docs().get(str(logic["키"])) if logic else None
if doc is None:
why = mh.why(cur, logic) or ("식 문서 없음" if logic else "식 없음")
return out | {"키": None, "까닭": why, "문서": None, "값": {}, "연결": {}}
vals = _shape(base if isinstance(base, dict) else row, mine).get("값") or {}
no = str(row.get("원문번호") or row.get("키") or "새 기계")
given = {"기계": no}
entry = cover if isinstance(cover, dict) and cover else None
try:
covered = mcv.cover_doc(doc, given, mcv.clean({no: m01._dec(entry)})) if entry else doc
except ValueError as e:
raise m01.StoreError(400, str(e)) from None
shaped = bind(covered, vals)
links = ml.Links(mym.whole(), m01doc.docs())
rec = mr.Rec(links)
rec.prefetch(shaped)
mc.calc(shaped, rec, given)
got = {"키": logic["키"], "이름": logic.get("이름"), "까닭": mh.why(cur, logic), "문서": doc,
"값": vals, "연결": rec.out()} # fmt: skip
return json.loads(json.dumps(out | got, ensure_ascii=False, default=_plain))
+4 -3
View File
@@ -18,7 +18,7 @@ const TEXT = {
Uses_Count: "쓰는 곳",
Made: "넣은 날",
Edited: "고친 날",
None: "내 기계 없음 — 단가산출의 기계 줄에서 [고쳐 쓰기] 로 만듦",
None: "내 기계 없음 — [새 기계] 로 M01 에 없는 기계를 등록",
Save: "저장",
Saved: "내 기계 저장 — 서버가 시간당을 다시 셈",
Delete: "지우기",
@@ -50,7 +50,8 @@ const TEXT = {
// 기계 고르기 창 · 줄 쌓기
Pick_Group: "내 기계",
Pick_Direct: "시간당 경비 식이 없는 기계 — 줄 쌓기에 못 넣음",
View_Note: "보기 · 쓰는 곳 · 지우기만 — 만들기 · 고치기는 단가산출의 기계 줄에서",
View_Note:
"M01 에 없는 기계를 등록하는 자리 · 줄 누름 = 열고 고침 · M01 기계 값은 단가산출 안 기계 제어 구역에서 고침",
// 단가산출 기계 창(827-1)
Win_Title: "기계",
Win_Kind: "구분",
@@ -74,7 +75,7 @@ const TEXT = {
Why_Company: "개인용으로 복사해서 씀",
// 내 품목
Items_Note:
"단가산출 줄에 값을 바로 적은 재료(우리 자재에 안 이어진 것) — 고치기는 그 단가산출에서 · 줄 누르면 쓰는 단가산출",
"단가산출 줄에 값을 바로 적은 재료(재료 DB · 내 재료에 안 이어진 것 · 읽기만) — 고치기는 그 단가산출에서 · 줄 누르면 쓰는 단가산출",
Items_None: "내 품목 없음",
Value: "값(원)",
Unit: "단위",
+18 -5
View File
@@ -2,11 +2,12 @@
* M02_MyMachine_View.ts
* M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「개인관리」 › [기계] 단추로 가운데에 뜸(개인 층).
* 목록 = 이름 · 규격 · 방식 · 시간당 경비(서버 셈) · 쓰는 곳 수 · 넣은 날 · 고친 날 · 찾기
* 줄 누름 = 양식 한 장(`M02_MyMachine_Form` · 읽기 전용) · 쓰는 곳 · [지우기] → 목록 다시
* 만들기 · 고치기 없음(827-1) — 단가산출 기계 줄의 기계 창 [고쳐 쓰기] → 단가산출 [저장] 때 내 기계로
* 줄 누름 = 기계 보기 한 장(`M02_MyMachine_Form` · 840-2 M01 기계 상세와 같은 꼴) · 고침 · [저장] · 쓰는 곳 · [지우기]
* [새 기계] = M01 에 없는 기계 등록(비슷한 기계에서 시작 · M01 과 같은 이름 · 규격 = 막음) · 불러오기 자동 생성은 그대로
* ========================================================================== */
import { createDataTable } from "@ui/ui_template_data_table";
import { createButton } from "@ui/ui_template_button";
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";
@@ -71,7 +72,20 @@ export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement {
const list = el("div", {
className: "m02-mymach-view__list",
children: [
el("div", { className: "ui-toolbar", children: [search.root, hint] }),
el("div", {
className: "ui-toolbar",
children: [
search.root,
hint,
createButton({
label: mt("New"),
variant: "filled",
size: 28,
iconStart: "plus",
onClick: () => void openForm(),
}),
],
}),
createText(mt("View_Note"), "muted"),
fail,
table.root,
@@ -94,14 +108,13 @@ export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement {
draw();
}
async function openForm(key: string): Promise<void> {
async function openForm(key?: string): Promise<void> {
const slot = el("div");
host.replaceChildren(slot);
try {
const h = await mountMyMachineForm(slot, {
owner: opt.owner,
key,
readOnly: true,
onSaved: (got) => void openForm(got.줄.키),
onDeleted: () => void showList(),
onBack: async () => {
+45
View File
@@ -0,0 +1,45 @@
/* =============================================================================
* M02_MyMaterial_Api.ts
* M02 내 재료(개인 자재 PM · PLAN 840-4) 서버 호출 — 개인 층만 · 서버 `M02_Labor_Router` 의 /layers/personal/materials
* 목록(줄마다 쓰는 곳) · 만들기 · 고치기 · 지우기(쓰는 곳 있으면 409) · M01 재료 DB 와 같은 이름 · 규격 · 단위 = 409
* ========================================================================== */
import { call } from "./M02_MasterTemplete_Api_Fetch";
export interface MyMaterialUse {
종류: string;
키: string;
이름: string;
}
export interface MyMaterial {
키: string;
이름: string;
규격: string;
단위: string;
값: number | null;
쓰는곳?: MyMaterialUse[];
}
export type MyMaterialBody = Pick<MyMaterial, "이름" | "규격" | "단위" | "값">;
const path = (owner: string | null, key = ""): string =>
`/layers/personal/materials${key ? `/${encodeURIComponent(key)}` : ""}${
owner ? `?owner=${encodeURIComponent(owner)}` : ""
}`;
export const listMyMaterials = (owner: string | null): Promise<MyMaterial[]> =>
call<{ 줄: MyMaterial[] }>(path(owner)).then((d) => d.줄);
export const addMyMaterial = (owner: string | null, body: MyMaterialBody): Promise<MyMaterial> =>
call<MyMaterial>(path(owner), { method: "POST", body: JSON.stringify(body) });
export const editMyMaterial = (
owner: string | null,
key: string,
body: MyMaterialBody,
): Promise<MyMaterial> =>
call<MyMaterial>(path(owner, key), { method: "PUT", body: JSON.stringify(body) });
export const dropMyMaterial = (owner: string | null, key: string): Promise<unknown> =>
call(path(owner, key), { method: "DELETE" });
+185
View File
@@ -0,0 +1,185 @@
/* =============================================================================
* M02_MyMaterial_View.ts
* M02 개인관리 › 자재 위 「내 재료」 등록 칸(PLAN 840-4 · 요구 854 · 862) — 개인 층
* 내 재료 = 재료 DB(M01)에 없는 자재를 등록하는 곳 · M01 재료 값 바꿈은 단가산출 안 재료 줄에서
* 표 = 이름 · 규격 · 단위 · 값 · 쓰는 곳 · 키 · 줄 누름 = 아래 칸에서 고치기 · [새 자재] = 빈 칸
* [저장] = 서버(M01 과 이름 · 규격 · 단위 같으면 서버가 막음 — 까닭 그대로 알림) · [지우기] = 쓰는 곳 있으면 서버가 막음
* 값을 고치면 그 재료를 쓰는 개인 층 단가산출이 따라옴(836-2 자동 따라옴 · 프로젝트 층 = 사본 값)
* 남의 층(owner 있음) = 읽기만
* ========================================================================== */
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 { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
import {
addMyMaterial,
dropMyMaterial,
editMyMaterial,
listMyMaterials,
type MyMaterial,
} from "./M02_MyMaterial_Api";
const TEXT = {
title: "내 재료",
note: "재료 DB(M01)에 없는 자재를 등록하는 곳 — 단가산출 재료 고르기 「내 재료」 로 씀 · M01 재료 값 바꿈은 단가산출 안 재료 줄에서",
none: "내 재료 없음",
add: "새 자재",
name: "품명",
spec: "규격",
unit: "단위",
value: "값(원)",
uses: "쓰는 곳",
key: "키",
save: "저장",
drop: "지우기",
cancel: "취소",
need: "품명 · 단위는 꼭 적음",
saved: "저장함 — 쓰는 단가산출이 새 값으로 셈",
dropped: "지움",
dropAsk: "「{name}」 을 지움",
readOnly: "남의 층 — 읽기만",
} as const;
export interface MyMaterialBlockOptions {
owner: string | null;
}
export function buildMyMaterialBlock(opt: MyMaterialBlockOptions): HTMLElement {
const canWrite = opt.owner === null;
let rows: MyMaterial[] = [];
let editing: MyMaterial | null = null;
let unit = "";
const table = createDataTable({
caption: TEXT.title,
columns: [
{ key: "이름", label: TEXT.name, width: 200 },
{ key: "규격", label: TEXT.spec, width: 140 },
{ key: "단위", label: TEXT.unit, width: 60 },
{ key: "값", label: TEXT.value, kind: "number", decimals: 2, trim: true, width: 100 },
{ key: "쓰는곳", label: TEXT.uses, kind: "number", decimals: 0, width: 70 },
{ key: "키", label: TEXT.key, width: 90 },
],
rows: [],
empty: TEXT.none,
onRowClick: (i) => canWrite && open(rows[i]),
});
const name = createInputField({ label: TEXT.name, required: true });
const spec = createInputField({ label: TEXT.spec });
const price = createInputField({ label: TEXT.value, type: "number", min: 0 });
const unitHost = el("span");
const drawUnit = (): void =>
unitHost.replaceChildren(
createUnitPick({
value: unit || NO_UNIT,
ariaLabel: TEXT.unit,
onPick: (u) => {
unit = u === NO_UNIT ? "" : u;
drawUnit();
},
}),
);
const head = createText("");
const saveBtn = createButton({ label: TEXT.save, variant: "filled", onClick: () => void save() });
const dropBtn = createButton({
label: TEXT.drop,
variant: "danger-ghost",
onClick: () => void drop(),
});
const form = el("div", {
className: "m02-pick__new",
attrs: { "data-my-material-form": "" },
children: [
head,
name.root,
spec.root,
el("div", {
className: "ui-field",
children: [el("label", { className: "ui-field__label", text: TEXT.unit }), unitHost],
}),
price.root,
el("div", {
className: "ui-btn-group",
children: [
createButton({ label: TEXT.cancel, variant: "ghost", onClick: () => close() }),
dropBtn,
saveBtn,
],
}),
],
});
form.hidden = true;
function open(row: MyMaterial | null): void {
editing = row;
name.input.value = row?.이름 ?? "";
spec.input.value = row?.규격 ?? "";
price.input.value = row?.값 === null || row?.값 === undefined ? "" : String(row.값);
unit = row?.단위 ?? "";
drawUnit();
head.textContent = row ? `${row.키} ${row.이름}` : TEXT.add;
dropBtn.hidden = !row;
form.hidden = false;
name.input.focus();
}
function close(): void {
editing = null;
form.hidden = true;
}
async function load(): Promise<void> {
rows = await listMyMaterials(opt.owner).catch((e) => {
showToast(e instanceof Error ? e.message : String(e), "error");
return [];
});
table.setRows(rows.map((r) => ({ ...r, 쓰는곳: r.쓰는곳?.length ?? 0 })));
}
async function save(): Promise<void> {
const 이름 = name.input.value.trim();
if (!이름 || !unit) return showToast(TEXT.need, "error");
const raw = price.input.value.trim();
const body = {
이름,
규격: spec.input.value.trim(),
단위: unit,
값: raw === "" ? null : Number(raw),
};
saveBtn.disabled = true;
try {
if (editing) await editMyMaterial(opt.owner, editing.키, body);
else await addMyMaterial(opt.owner, body);
showToast(TEXT.saved, "success");
close();
await load();
} catch (e) {
showToast(e instanceof Error ? e.message : String(e), "error");
} finally {
saveBtn.disabled = false;
}
}
async function drop(): Promise<void> {
if (!editing) return;
if (!(await showConfirmDialog(TEXT.dropAsk.replace("{name}", editing.이름), TEXT.drop))) return;
try {
await dropMyMaterial(opt.owner, editing.키);
showToast(TEXT.dropped, "success");
close();
await load();
} catch (e) {
showToast(e instanceof Error ? e.message : String(e), "error");
}
}
const addBtn = createButton({ label: TEXT.add, variant: "filled", onClick: () => open(null) });
addBtn.hidden = !canWrite;
void load();
return el("div", {
attrs: { "data-my-materials": "" },
children: [
el("div", { className: "ui-toolbar", children: [addBtn] }),
createText(canWrite ? TEXT.note : TEXT.readOnly, "muted"),
table.root,
form,
],
});
}
+4 -1
View File
@@ -44,7 +44,7 @@ def m01(kind: str, src: dict, label: str = "") -> tuple[str | None, str | None]:
return got["키"], mfind.mark(got, line["이름"], line["규격"])
if kind == "재료" and src.get("종류") == "자재" and (hit := ea.est_code(src.get("코드"))):
key = ea.one(line, hit) or hit[0] # 815-1 K1 — EST 원파일 자재 = 우리 자재 출처 코드
return mf.done(key, "코드", src)["키"], None
return mf.done(key, "코드", src, line["단위"])["키"], None # 840-3 단위 맞춤
oil = ea.keys("MO", line["이름"], line["규격"]) if kind == "재료" else []
if kind == "재료":
if oil or elem.oil_rows(line["이름"]):
@@ -183,6 +183,9 @@ def why(item: dict) -> str:
return mp.apart_note(res.get("재료찾기") or {})
if elem.oil_rows(name):
return "유가 후보 여럿 — 고름"
found = res.get("재료찾기") or {}
if found.get("판정") == "단위 다름 — 고르기": # 840-3 `M02_San_MatFind.UNIT`
return f"{found.get('방법')} — 고름"
line = {"이름": name, "규격": res.get("규격") or "", "단위": res.get("단위") or ""}
if picks := hrow.m01_picks("재료", line): # 817-1 ㉯ 1 순위를 글에(고르기 창은 이름 찾기)
return f"자재 후보 여럿 — 고름 · 1 순위 {picks[0]['이름']} {picks[0]['규격']}".strip()
+34 -3
View File
@@ -9,6 +9,9 @@
- 「규격 없음 — 고르기」 — 가져온 규격 빔 · 별도계상 · 바탕 이름 같은 후보 있음
- 「후보 여럿」 — 규격은 있는데 하나로 못 정함
- 「우리 표에 없음」 — 바탕 이름 같은 후보 없음 · 규격 수가 하나도 안 닮음(후보 = 닮은 줄 · 참고)
- 「단위 다름 — 고르기」 — 이은 우리 줄 단위가 줄 단위와 성격 다름(840-3 · 방법 = 「단위 다름 ㎡ ↔ 매」 · 후보 첫째 = 그 줄)
단위 맞춤(840-3 `_by_unit` · 이은 판정 모두) = ㉠ 이름 · 규격 같은 쌍둥이 중 줄과 같은 단위 줄 › ㉡ 같은 성격(kg ↔ 톤 등)
= 그대로 이음 · 값은 셈이 줄 단위로 환산(`master_pick.priced`) · ㉢ 성격 다름 = 이음 풂.
후보 = [{키, 이름, 규격, 단위, 단서?}] 차례 = 단서로 좁힌 것 › 이름 같은 묶음 › 옛 후보.
별도계상 줄(요구 811-2 — 품목은 정해짐 · 셈만 프로젝트에서) = 같은 찾기 · 키는 판정에만(줄 이음 · 단가 0 그대로).
@@ -35,6 +38,7 @@ from M02_MasterTemplete import M02_Hopyo_Material as hmat
from M02_MasterTemplete import M02_Hopyo_MatSame as ms
LINKED, NO_SPEC, MANY, NONE = "이어짐", "규격 없음 — 고르기", "후보 여럿", "우리 표에 없음"
UNIT = "단위 다름 — 고르기"
TOP = 8
_CTX: ContextVar[dict] = ContextVar("재료단서", default={})
_LEAD = re.compile(r"^[\s\-·\d.)]+")
@@ -126,16 +130,16 @@ def find(
code = str((src or {}).get("코드") or "")
me = ms._item(text[0], text[1])
keys = tuple(sorted(k for k in keys if k))
got = dict(_find(*text, keys, _texts(me, code, label), apart, id(hm.targets())))
got = _by_unit(line, dict(_find(*text, keys, _texts(me, code, label), apart, id(hm.targets()))))
got |= {"후보": [dict(x) for x in got["후보"]], "단서": list(got["단서"])}
if src is not None:
src["재료찾기"] = got
return got
def done(key: str, how: str, src: dict | None = None) -> dict:
def done(key: str, how: str, src: dict | None = None, unit: str = "") -> dict:
"""이미 정한 이음(코드 길) 판정."""
got = {"판정": LINKED, "방법": how, "키": key, "후보": [], "단서": []}
got = _by_unit({"단위": unit}, {"판정": LINKED, "방법": how, "키": key, "후보": [], "단서": []})
if src is not None:
src["재료찾기"] = got
return got
@@ -169,6 +173,33 @@ def _find(
return _out(verdict, "", None, cands, used)
def _by_unit(line: dict, got: dict) -> dict:
"""이은 판정 단위 맞춤(840-3) — ㉠ 같은 단위 쌍둥이 › ㉡ 같은 성격 = 그대로 › ㉢ 성격 다름 = 고르기."""
from M02_MasterTemplete import M02_Elem_Alias as ea
key, want = got.get("키"), str(line.get("단위") or "")
if got["판정"] != LINKED or not key or not want:
return got
row = ea.row(ea.MARKET, key)
have, plain = str(row.get("단위") or ""), hm.mm.plain_unit
if not have or plain(have) == plain(want):
return got
twins = [k for k in ea.exact(row.get("이름"), row.get("규격")) if plain(ea.row(ea.MARKET, k).get("단위")) == plain(want)] # fmt: skip
if twin := ea.one({"이름": line.get("이름") or row.get("이름"), "규격": line.get("규격") or row.get("규격"), "단위": want}, twins): # fmt: skip
return {**got, "키": twin, "방법": f"{got['방법']} · 같은 단위 줄"}
if hm.mp.same_kind(have, want):
return got # 값 = 셈이 줄 단위로 환산 · 표시도 셈(`단위경고`)
first = dict(_pub({**row, "자재": key}, ""))
rest = [dict(c) for c in got["후보"] if dict(c).get("키") != key]
return {
**got,
"판정": UNIT,
"방법": f"단위 다름 {want} ↔ {have}",
"키": None,
"후보": [first, *rest],
}
def _alike(me: dict, x: dict) -> bool:
"""이름 같은 우리 줄이 이 물건일 수 있음 — 우리 규격 빔 · 가져온 수 없음 · 수 하나라도 같음(BUTTON BIT Ø38 ↔ 105mm = 아님)."""
return (
+1
View File
@@ -31,6 +31,7 @@ DIRNAME = "재료고름"
CENSUS = "재료판"
KINDS = (
"규격 없음 — 고르기",
"단위 다름 — 고르기", # 840-3
"후보 여럿",
"우리 표에 없음",
"이어짐",
+3 -2
View File
@@ -45,10 +45,11 @@ READS: ContextVar[dict | None] = ContextVar("읽은줄", default=None)
def whole_() -> Any:
"""M01 색인 + 지금 층 내 기계 줄(821-1 · `M02_MyMachine.whole`)."""
"""M01 색인 + 지금 층 내 기계 줄(821-1 · `M02_MyMachine.whole`) + 내 재료 줄(840-4 · `M02_Material_Own.whole`)."""
from M02_MasterTemplete import M02_Material_Own as own
from M02_MasterTemplete import M02_MyMachine as mym
return mym.whole()
return own.whole(mym.whole())
def _fp(rows: Any) -> str:
+21 -2
View File
@@ -24,7 +24,7 @@ export const COVER = "기계덮음";
/** 값 칸에서 빼는 기계 칸 — 줄(조종원 · 잡품)로 다룸 */
export const NOT_VALUE = ["조종원", "조종원직종", "잡재료율"];
const HOUR = "Q_시간환산";
/** 조종원 줄을 새로 넣을 때(조종원 없는 식) 시간환산 — GC000267 과 같은 꼴(건설품셈 공통 1-1-3 2) */
/** 조종원 줄을 새로 넣을 때(조종원 없는 식) 시간환산 — GC000267 과 같은 꼴(1/8 × 16/12 × 25/20 · 건협기술-2230) */
const HOUR_VARS: DocVar[] = [
{
열쇠: "Q_분자^표", 기호: "Q", 아래: "분자", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분자", 단위: "",
@@ -34,9 +34,28 @@ const HOUR_VARS: DocVar[] = [
열쇠: "Q_분모^표", 기호: "Q", 아래: "분모", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분모", 단위: "",
연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'시간환산'" }, 칸: "분모" },
},
{
열쇠: "Q_분자2^표", 기호: "Q", 아래: "분자2", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분자", 단위: "",
연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'상여계수'" }, 칸: "분자" },
},
{
열쇠: "Q_분모2^표", 기호: "Q", 아래: "분모2", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분모", 단위: "",
연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'상여계수'" }, 칸: "분모" },
},
{
열쇠: "Q_분자3^표", 기호: "Q", 아래: "분자3", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분자", 단위: "",
연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'휴지계수'" }, 칸: "분자" },
},
{
열쇠: "Q_분모3^표", 기호: "Q", 아래: "분모3", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분모", 단위: "",
연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'휴지계수'" }, 칸: "분모" },
},
{ 열쇠: "시간환산", 기호: "Q", 아래: "시간환산", 위: "", 이름: "시간환산", 단위: "", 연결: { 갈래: "식" } },
]; // prettier-ignore
const HOUR_LINE = { 기호: HOUR, 식: "Q_분자^표 ÷ Q_분모^표" };
const HOUR_LINE = {
기호: HOUR,
식: "Q_분자^표 ÷ Q_분모^표 × Q_분자2^표 ÷ Q_분모2^표 × Q_분자3^표 ÷ Q_분모3^표",
};
export interface CrewRow {
키: string;
+3 -1
View File
@@ -20,6 +20,8 @@ export const MACHINE_COEFS = ["상각비계수", "정비비계수", "관리비
export type MachineCoefs = Record<(typeof MACHINE_COEFS)[number], number>;
const E = 1e-6; // 끊는 경계 부동소수 흔들림 막음
/** 조종원 하루 → 시간(CC000336 시간환산 · 상여 · 휴지 = 1/8 × 16/12 × 25/20 · 840-5) */
export const MACHINE_HOUR = ((((1 / 8) * 16) / 12) * 25) / 20; // 파이썬 짝과 같은 차례
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
const halfUp = (x: number): number => Math.floor(x + 0.5 + E);
@@ -69,7 +71,7 @@ export function machineHourly(
fuel: number,
wage: number,
power = 0,
hour = 1 / 8,
hour = MACHINE_HOUR,
): MachineHourly {
const loss = Math.floor((num(values["가격"]) * 1000 * num(values["손료계수"])) / 1e7 + E);
const oil = tenth(num(values["주연료"]) * num(fuel));
+185
View File
@@ -0,0 +1,185 @@
/* =============================================================================
* common_util_machine_sheet.ts
* 기계 보기 셈 꼴(PLAN 840-2 · 요구 853 · 862) — 기계 줄(저장 꼴) ↔ 식이 읽는 값 · 덮음(836-1 `기계덮음` 꼴) ↔ 줄
* 셈 꼴(`engineVals`) = M01 = 값 그대로 · 내 기계 = 바로 → 상각비계수 ← 손료계수 · 정비 · 관리 0 ·
* 직접 → 노무비 · 재료비 · 경비 · 빈 연료 · 조종원 칸 = 0(서버 `M02_MyMachine.engine` 과 같은 꼴)
* 덮음 ← 줄 차이(`coverFrom` — 처음 줄 대 지금 줄) · 지금 줄 ← 덮음(`applyCover` — 제어 구역이 맡은 칸만 ·
* 덮음이 없는 칸 = 처음 줄 값 그대로 = 안 고치고 닫으면 줄 바이트 같음)
* 제어 구역이 맡은 칸(`ownedCols`) = 식이 읽는 값 칸 + 조종원 · 조종원직종 + (잡품 줄 있는 식) 잡재료율
* 값 박음(`bindDoc`) = 식 문서 요소 `EQ:{S_기계}` 칸 → 숫자(처음 줄 값) — 셈 차례 = 덮음 얹음(`coverDoc`) → 값 박음
* 파이썬 · TS 짝(`bindDoc`) — 짝 = M02_MasterTemplete/M02_MyMachine_Sheet.py `bind` · 거울 시험 test_840_2_machine_sheet.py
* · 화면 · DOM · fetch 없음
* ========================================================================== */
import type { FormulaDoc, MoneyLine } from "./common_util_formula_doc";
import type { CoverParts, MachineCover } from "./common_util_formula_doc_cover";
import { MACHINE_INPUTS } from "./common_util_machine_coef";
export type MachineValues = Record<string, unknown>;
/** 고르기 목록 한 칸 — 연료(값 = 종류) · 직종(값 = 인력 원문번호) */
export interface MachinePick {
value: string;
text: string;
price: number | null;
}
export type MachineMode = "바로" | "품셈" | "직접";
/** 기계 줄(M01 기계 줄 · 내 기계 줄 꼴) — 이름 · 규격 · 값 · (내 기계) 방식 · 시간당 */
export interface MachineRow {
이름?: unknown;
규격?: unknown;
값?: MachineValues;
방식?: MachineMode;
시간당?: MachineValues;
[k: string]: unknown;
}
export const HOURLY = ["노무비", "재료비", "경비"] as const;
const CREW = ["조종원", "조종원직종"];
const MISC = "잡재료율";
/** 보통 범위(옛 기계 양식과 같음) — 밖이면 경고만 */
export const RANGE: Record<string, [number, number]> = {
가격: [0, 5_000_000], 손료계수: [1, 100_000], 내용시간: [100, 100_000], 연간표준가동시간: [100, 8_760],
상각비율: [0, 1], 정비비율: [0, 3], 연간관리비율: [0, 1], 주연료: [0, 2_000], 잡재료율: [0, 100],
조종원: [0, 20], 전력: [0, 10_000],
}; // prettier-ignore
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v ?? null)) as T;
const blank = (v: unknown): boolean => v === undefined || v === null || v === "";
/** 식 문서 → 기계 값 칸을 숫자로 박은 새 문서(그 칸이 없는 줄은 요소 그대로) */
export function bindDoc(doc: FormulaDoc, vals: MachineValues): FormulaDoc {
const out = clone(doc);
for (const v of out.변수 ?? []) {
const l = v.연결 ?? {};
const key = String(l.키 ?? "");
const col = l.칸;
if (
l.갈래 === "요소" &&
key.startsWith("EQ:") &&
key.includes("{S_기계}") &&
col &&
col in vals
)
v.연결 = { 갈래: "숫자", 값: vals[col] as number | string };
}
return out;
}
/** 같은 값 — 수는 작은 차이 무시 · 글은 글로 */
export function sameVal(a: unknown, b: unknown): boolean {
if (blank(a) && blank(b)) return true;
if (typeof a === "number" && typeof b === "number")
return Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
return String(a ?? "") === String(b ?? "");
}
export function modeOf(row: MachineRow, mine: boolean): MachineMode {
if (mine) return row.방식 ?? "바로";
return MACHINE_INPUTS.some((k) => typeof row.값?.[k] === "number") ? "품셈" : "바로";
}
/** 식이 읽는 값 묶음(셈 꼴) */
export function engineVals(row: MachineRow, mine: boolean): MachineValues {
const v: MachineValues = { ...(row.값 ?? {}) };
if (!mine) return v;
const how = row.방식 ?? "바로";
if (how === "직접") return Object.fromEntries(HOURLY.map((k) => [k, row.시간당?.[k] ?? 0]));
if (how === "바로")
Object.assign(v, { 상각비계수: v.손료계수 || 0, 정비비계수: 0, 관리비계수: 0 });
return { 주연료: 0, 잡재료율: 0, 조종원: 0, 주연료종류: "", 조종원직종: "", ...v };
}
/** 셈 꼴 칸 → 저장 꼴 자리 · null = 저장 안 함(내 기계 바로의 정비 · 관리 = 늘 0) */
export function slotOf(
col: string,
mode: MachineMode,
mine: boolean,
): ["값" | "시간당", string] | null {
if (!mine) return ["값", col];
if (mode === "직접") return (HOURLY as readonly string[]).includes(col) ? ["시간당", col] : null;
if (mode === "바로" && col === "상각비계수") return ["값", "손료계수"];
if (mode === "바로" && (col === "정비비계수" || col === "관리비계수")) return null;
return ["값", col];
}
/** 제어 구역이 맡은 칸(셈 꼴 이름) */
export const ownedCols = (parts: CoverParts): string[] => [
...parts.fields.map((f) => f.칸),
...CREW,
...(parts.misc !== null ? [MISC] : []),
];
/** 처음 줄 대 지금 줄 → 덮음(조종원 = 「LB:직종」 한 줄 · 잡품 = 밑 줄마다 같은 율) — 정리는 `putCover` 몫 */
export function coverFrom(
parts: CoverParts,
lines: MoneyLine[],
orig: MachineRow,
cur: MachineRow,
mine: boolean,
): MachineCover {
const o = engineVals(orig, mine);
const c = engineVals(cur, mine);
const out: MachineCover = {};
const 값: Record<string, number | string> = {};
for (const { 칸 } of parts.fields)
if (!blank(c[칸]) && !sameVal(c[칸], o[칸])) 값[칸] = c[칸] as number | string;
if (Object.keys(값).length) out.값 = 값;
if (CREW.some((k) => !sameVal(c[k], o[k]))) {
const n = Number(c.조종원) || 0;
out.조종원 = n > 0 && !blank(c.조종원직종) ? [{ 키: `LB:${String(c.조종원직종)}`, 인: n }] : [];
}
if (parts.misc !== null && !sameVal(c[MISC], o[MISC])) {
const r = Number(c[MISC]) || 0;
out.잡품 = r ? parts.base.map((i) => ({ 줄: String(lines[i]?.이름 ?? ""), 율: r })) : [];
}
return out;
}
/** 노임 요소 키 → 인력 원문번호(`LB:1048` → 1048 · `LB000048` → 직종 표) */
export const jobCode = (key: string, jobs: Record<string, string>): string =>
key.startsWith("LB:") ? key.slice(3) : (jobs[key] ?? key);
/** 덮음 → 지금 줄(제어 구역이 맡은 칸만 · 덮음 없는 칸 = 처음 줄 저장 값으로) — 새 줄 */
export function applyCover(
parts: CoverParts,
orig: MachineRow,
cur: MachineRow,
entry: MachineCover,
mine: boolean,
jobs: Record<string, string>,
): MachineRow {
const mode = modeOf(cur, mine);
const next = clone(cur);
const put = (col: string, v: unknown): void => {
const at = slotOf(col, mode, mine);
if (!at) return;
const [part, key] = at;
const box = (next[part] ??= {}) as MachineValues;
if (v === undefined) {
const was = (orig[part] ?? {}) as MachineValues;
if (key in was) box[key] = clone(was[key]);
else delete box[key];
} else if (!(key in box) || !sameVal(box[key], v)) box[key] = v;
};
for (const { 칸 } of parts.fields) put(칸, entry.값?.[칸]);
if (entry.조종원 === undefined) CREW.forEach((k) => put(k, undefined));
else {
const r = entry.조종원[0];
put("조종원", r ? r.인 : 0);
put("조종원직종", r ? jobCode(r.키, jobs) : undefined);
}
if (parts.misc !== null)
put(MISC, entry.잡품 === undefined ? undefined : (entry.잡품[0]?.율 ?? 0));
return next;
}
/** 범위 밖 칸 — [칸, 범위 글] */
export function rangeWarnings(values: MachineValues): [string, string][] {
return Object.entries(RANGE).flatMap(([k, [lo, hi]]) => {
const v = values[k];
return typeof v === "number" && (v < lo || v > hi)
? [[k, `${lo}~${hi}`] as [string, string]]
: [];
});
}
@@ -34,6 +34,10 @@ const NAME: Record<string, string> = {
Q_시간환산: "환산",
"Q_분자^표": "환산분자",
"Q_분모^표": "환산분모",
"Q_분자2^표": "상여분자",
"Q_분모2^표": "상여분모",
"Q_분자3^표": "휴지분자",
"Q_분모3^표": "휴지분모",
"Q_주연료^표": "휘발유량",
"Q_주연료2^표": "오일량",
"Q_잡^표": "잡품",
Binary file not shown.
@@ -47,7 +47,7 @@ RAW_SUB = (
"(옛 엔진 = 끝수 전 날 값 · `master_formula.run` 결과를 식에 씀)"
)
# 채택된 차이 — (로직, 옵션) · 요구 714-3
ADOPTED = {("GC000100", "지역=경북")}
ADOPTED = {("GC000091", "지역=울산"), ("GC000093", "지역=경북"), ("GC000100", "지역=전북")}
def rows_of(files: dict) -> list[tuple[str, dict]]:
@@ -30,9 +30,20 @@ _HOUR_VARS = [
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}},
{"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모",
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}},
{"열쇠": "Q_분자2^표", "기호": "Q", "아래": "분자2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자",
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모2^표", "기호": "Q", "아래": "분모2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모",
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분모"}},
{"열쇠": "Q_분자3^표", "기호": "Q", "아래": "분자3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자",
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모3^표", "기호": "Q", "아래": "분모3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모",
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분모"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "연결": {"갈래": "식"}},
] # fmt: skip
_HOUR_LINE = {"기호": HOUR, "식": "Q_분자^표 ÷ Q_분모^표"}
_HOUR_LINE = {
"기호": HOUR,
"식": "Q_분자^표 ÷ Q_분모^표 × Q_분자2^표 ÷ Q_분모2^표 × Q_분자3^표 ÷ Q_분모3^표",
}
MAX_ROWS = 30
@@ -22,6 +22,7 @@ import math
INPUTS = ("내용시간", "연간표준가동시간", "상각비율", "정비비율", "연간관리비율")
COEFS = ("상각비계수", "정비비계수", "관리비계수", "손료계수")
_E = 1e-6 # 끊는 경계 부동소수 흔들림 막음
HOUR = 1 / 8 * 16 / 12 * 25 / 20 # 조종원 하루 → 시간(CC000336 시간환산 · 상여 · 휴지 · 840-5)
def _num(v) -> float:
@@ -56,9 +57,7 @@ def coef_of(values: dict) -> dict | 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:
def preview(values: dict, fuel: float, wage: float, power: float = 0.0, hour: float = HOUR) -> dict:
"""값 묶음 + 유가(원/ℓ) · 노임(원/일) · 전력(원/kWh) · 시간환산 → 시간당 줄 · 비목 · 계."""
loss = math.floor(_num(values.get("가격")) * 1000 * _num(values.get("손료계수")) / 1e7 + _E)
oil = _tenth(_num(values.get("주연료")) * _num(fuel))
+16 -2
View File
@@ -12,7 +12,7 @@ from contextvars import ContextVar
from decimal import Decimal
import master_material as mm
from master_unit import UNITS, convert, kind_of, same_kind # noqa: F401 — 단위 한 벌(712-2)
from master_unit import UNITS, convert, factor, kind_of, same_kind # noqa: F401 — 단위 한 벌(712-2)
REGIONS = (
"전국평균 전국 서울 경기 강원 충북 충남 전북 전남 경북 경남 부산 대구 인천 대전 울산 세종 제주 광주"
@@ -163,7 +163,9 @@ def _priced(master, item: dict, ref, env: dict, index: int | None):
price = convert(price, have, unit) if have and unit else price
else:
price, source = mm.element(master, ref, env, unit)
warn = None
warn = _direct(master, ref, unit)
if warn and warn.startswith(SWAP): # 840-3 자재품목 바로 이음 = 줄 단위로 환산
price = convert(price, master.get(ref).get("단위"), unit)
except mm.mf.FormulaError as e:
return None, None, str(e), None
why = None if price is not None else f"「{master.label(ref)}」 값 없음 — 관리자가 채울 값"
@@ -171,6 +173,18 @@ def _priced(master, item: dict, ref, env: dict, index: int | None):
HAND = "손 단가"
SWAP = "단위 환산"
def _direct(master, ref, unit) -> str | None:
"""자재품목 바로 이음 줄(불러오기 · 840-3) 단위 글 — 같음 · 1 : 1 = None · 같은 성격 = 「단위 환산 톤 → kg」 ·
성격 다름 = 검색어 길과 같은 경고(값 그대로)."""
if not unit or not isinstance(ref, str) or ref[:2] != mm.MARKET:
return None
have = master.get(ref).get("단위")
if not have or factor(have, unit) == 1:
return None
return f"{SWAP} {have} → {unit}" if same_kind(have, unit) else f"줄 {unit} · 자재 {have}"
def bundled(item: dict, base):
@@ -33,7 +33,11 @@
},
{ "이름": "연료", "식": "EQ:{기계}.주연료종류", "출처": "건설품셈 공통 8-4 주①㉰" },
{ "이름": "직종", "식": "EQ:{기계}.조종원직종", "출처": "건설품셈 공통 8-1-3 5" },
{ "이름": "시간환산", "식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)" }
{
"이름": "시간환산",
"식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모 * 찾기(CC000336, 구분='상여계수').분자 / 찾기(CC000336, 구분='상여계수').분모 * 찾기(CC000336, 구분='휴지계수').분자 / 찾기(CC000336, 구분='휴지계수').분모",
"출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)"
}
],
"호표": [
{ "종류": "재료", "요소": "MO:{연료}:{지역}", "이름": "주연료", "단위": "ℓ", "수량": "EQ:{기계}.주연료", "비목": "재료비", "출처": "건설품셈 공통 8-4-1" },
@@ -45,7 +49,7 @@
"수량": "EQ:{기계}.조종원 * 시간환산",
"비목": "노무비",
"출처": "건설품셈 공통 8-1-3 6",
"비고": "하루 노임 × 1/8(1일 8시간 · 건설품셈 공통 1-1-3 2) — 질의회신 건협기술-2230 의 상여 16/12 · 휴지 25/20 뺌(내역 15 · 품셈 본문대로)"
"비고": "하루 노임 × 1/8 × 16/12 × 25/20(1일 8시간 · 상여 300 % + 퇴직 100 % · 월 25 일 중 20 일 가동 — 질의회신 건협기술-2230 · 실무 · 경쟁사와 같음)"
}
],
"덧줄": [{ "이름": "잡재료비", "식": "재료비 * EQ:{기계}.잡재료율 / 100", "비목": "재료비", "출처": "건설품셈 공통 8-4 주②" }, { "이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2" }],
@@ -303,7 +307,11 @@
{ "이름": "손료", "식": "버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) + EQ:{기계}.관리비계수) / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4" },
{ "이름": "연료", "식": "EQ:{기계}.주연료종류", "출처": "건설품셈 공통 8-4 주①㉰" },
{ "이름": "직종", "식": "EQ:{기계}.조종원직종", "출처": "건설품셈 공통 8-1-3 5" },
{ "이름": "시간환산", "식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)" }
{
"이름": "시간환산",
"식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모 * 찾기(CC000336, 구분='상여계수').분자 / 찾기(CC000336, 구분='상여계수').분모 * 찾기(CC000336, 구분='휴지계수').분자 / 찾기(CC000336, 구분='휴지계수').분모",
"출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)"
}
],
"호표": [
{ "종류": "재료", "요소": "MO:{연료}:{지역}", "이름": "주연료", "단위": "ℓ", "수량": "EQ:{기계}.주연료", "비목": "재료비", "출처": "건설품셈 공통 8-4-1" },
@@ -315,7 +323,7 @@
"수량": "EQ:{기계}.조종원 * 시간환산",
"비목": "노무비",
"출처": "건설품셈 공통 8-1-3 6",
"비고": "하루 노임 × 1/8(1일 8시간 · 건설품셈 공통 1-1-3 2) — 질의회신 건협기술-2230 의 상여 16/12 · 휴지 25/20 뺌(내역 15 · 품셈 본문대로)"
"비고": "하루 노임 × 1/8 × 16/12 × 25/20(1일 8시간 · 상여 300 % + 퇴직 100 % · 월 25 일 중 20 일 가동 — 질의회신 건협기술-2230 · 실무 · 경쟁사와 같음)"
}
],
"덧줄": [{ "이름": "잡재료비", "식": "재료비 * EQ:{기계}.잡재료율 / 100", "비목": "재료비", "출처": "건설품셈 공통 8-4 주②" }, { "이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2" }],
@@ -496,7 +504,11 @@
},
{ "이름": "연료", "식": "EQ:{기계}.주연료종류", "출처": "건설품셈 공통 8-4 주①㉰" },
{ "이름": "직종", "식": "EQ:{기계}.조종원직종", "출처": "건설품셈 공통 8-1-3 5" },
{ "이름": "시간환산", "식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)" }
{
"이름": "시간환산",
"식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모 * 찾기(CC000336, 구분='상여계수').분자 / 찾기(CC000336, 구분='상여계수').분모 * 찾기(CC000336, 구분='휴지계수').분자 / 찾기(CC000336, 구분='휴지계수').분모",
"출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)"
}
],
"호표": [
{ "종류": "재료", "요소": "MO:{연료}:{지역}", "이름": "주연료", "단위": "ℓ", "수량": "EQ:{기계}.주연료", "비목": "재료비", "출처": "건설품셈 공통 8-4-1" },
@@ -508,7 +520,7 @@
"수량": "EQ:{기계}.조종원 * 시간환산",
"비목": "노무비",
"출처": "건설품셈 공통 8-1-3 6",
"비고": "하루 노임 × 1/8(1일 8시간 · 건설품셈 공통 1-1-3 2) — 질의회신 건협기술-2230 의 상여 16/12 · 휴지 25/20 뺌(내역 15 · 품셈 본문대로)"
"비고": "하루 노임 × 1/8 × 16/12 × 25/20(1일 8시간 · 상여 300 % + 퇴직 100 % · 월 25 일 중 20 일 가동 — 질의회신 건협기술-2230 · 실무 · 경쟁사와 같음)"
}
],
"덧줄": [{ "이름": "잡재료비", "식": "재료비 * EQ:{기계}.잡재료율 / 100", "비목": "재료비", "출처": "건설품셈 공통 8-4 주②" }, { "이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2" }],
@@ -557,7 +569,11 @@
{ "이름": "조합잡재료율", "식": "찾기(CC000074, 구분='불도저 및 굴착기 잡재료 비율').적용", "출처": "건설품셈 공통 8-4-8 주⑤" },
{ "이름": "연료", "식": "EQ:{기계}.주연료종류", "출처": "건설품셈 공통 8-4 주①㉰" },
{ "이름": "직종", "식": "EQ:{기계}.조종원직종", "출처": "건설품셈 공통 8-1-3 5" },
{ "이름": "시간환산", "식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)" }
{
"이름": "시간환산",
"식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모 * 찾기(CC000336, 구분='상여계수').분자 / 찾기(CC000336, 구분='상여계수').분모 * 찾기(CC000336, 구분='휴지계수').분자 / 찾기(CC000336, 구분='휴지계수').분모",
"출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)"
}
],
"호표": [
{ "종류": "재료", "요소": "MO:{연료}:{지역}", "이름": "주연료", "단위": "ℓ", "수량": "EQ:{기계}.주연료", "비목": "재료비", "출처": "건설품셈 공통 8-4-1" },
@@ -569,7 +585,7 @@
"수량": "EQ:{기계}.조종원 * 시간환산",
"비목": "노무비",
"출처": "건설품셈 공통 8-1-3 6",
"비고": "하루 노임 × 1/8(1일 8시간 · 건설품셈 공통 1-1-3 2) — 질의회신 건협기술-2230 의 상여 16/12 · 휴지 25/20 뺌(내역 15 · 품셈 본문대로)"
"비고": "하루 노임 × 1/8 × 16/12 × 25/20(1일 8시간 · 상여 300 % + 퇴직 100 % · 월 25 일 중 20 일 가동 — 질의회신 건협기술-2230 · 실무 · 경쟁사와 같음)"
}
],
"덧줄": [
@@ -628,7 +644,11 @@
{ "이름": "조합잡재료율", "식": "찾기(CC000074, 구분='불도저 및 굴착기 잡재료 비율').적용", "출처": "건설품셈 공통 8-4-8 주⑤" },
{ "이름": "연료", "식": "EQ:{기계}.주연료종류", "출처": "건설품셈 공통 8-4 주①㉰" },
{ "이름": "직종", "식": "EQ:{기계}.조종원직종", "출처": "건설품셈 공통 8-1-3 5" },
{ "이름": "시간환산", "식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)" }
{
"이름": "시간환산",
"식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모 * 찾기(CC000336, 구분='상여계수').분자 / 찾기(CC000336, 구분='상여계수').분모 * 찾기(CC000336, 구분='휴지계수').분자 / 찾기(CC000336, 구분='휴지계수').분모",
"출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)"
}
],
"호표": [
{ "종류": "재료", "요소": "MO:{연료}:{지역}", "이름": "주연료", "단위": "ℓ", "수량": "EQ:{기계}.주연료", "비목": "재료비", "출처": "건설품셈 공통 8-4-1" },
@@ -640,7 +660,7 @@
"수량": "EQ:{기계}.조종원 * 시간환산",
"비목": "노무비",
"출처": "건설품셈 공통 8-1-3 6",
"비고": "하루 노임 × 1/8(1일 8시간 · 건설품셈 공통 1-1-3 2) — 질의회신 건협기술-2230 의 상여 16/12 · 휴지 25/20 뺌(내역 15 · 품셈 본문대로)"
"비고": "하루 노임 × 1/8 × 16/12 × 25/20(1일 8시간 · 상여 300 % + 퇴직 100 % · 월 25 일 중 20 일 가동 — 질의회신 건협기술-2230 · 실무 · 경쟁사와 같음)"
}
],
"덧줄": [
@@ -1318,7 +1338,11 @@
"중간": [
{ "이름": "손료", "식": "버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) + EQ:{기계}.관리비계수) / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4" },
{ "이름": "연료", "식": "EQ:{기계}.주연료종류", "출처": "건설품셈 공통 8-4 주①㉰" },
{ "이름": "시간환산", "식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)" }
{
"이름": "시간환산",
"식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모 * 찾기(CC000336, 구분='상여계수').분자 / 찾기(CC000336, 구분='상여계수').분모 * 찾기(CC000336, 구분='휴지계수').분자 / 찾기(CC000336, 구분='휴지계수').분모",
"출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)"
}
],
"호표": [
{ "종류": "재료", "요소": "MO:{연료}:{지역}", "이름": "주연료", "단위": "ℓ", "수량": "EQ:{기계}.주연료", "비목": "재료비", "출처": "건설품셈 공통 8-4-1" },
@@ -1330,7 +1354,7 @@
"수량": "EQ:{기계}.조종원 * 시간환산",
"비목": "노무비",
"출처": "건설품셈 공통 8-1-3 6",
"비고": "하루 노임 × 1/8(1일 8시간 · 건설품셈 공통 1-1-3 2) — 질의회신 건협기술-2230 의 상여 16/12 · 휴지 25/20 뺌(내역 15 · 품셈 본문대로)"
"비고": "하루 노임 × 1/8 × 16/12 × 25/20(1일 8시간 · 상여 300 % + 퇴직 100 % · 월 25 일 중 20 일 가동 — 질의회신 건협기술-2230 · 실무 · 경쟁사와 같음)"
}
],
"덧줄": [{ "이름": "잡재료비", "식": "재료비 * EQ:{기계}.잡재료율 / 100", "비목": "재료비", "출처": "건설품셈 공통 8-4 주②" }, { "이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2" }],
@@ -1389,7 +1413,11 @@
"중간": [
{ "이름": "손료", "식": "버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) + EQ:{기계}.관리비계수) / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4" },
{ "이름": "직종", "식": "EQ:{기계}.조종원직종", "출처": "건설품셈 공통 8-1-3 5" },
{ "이름": "시간환산", "식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)" }
{
"이름": "시간환산",
"식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모 * 찾기(CC000336, 구분='상여계수').분자 / 찾기(CC000336, 구분='상여계수').분모 * 찾기(CC000336, 구분='휴지계수').분자 / 찾기(CC000336, 구분='휴지계수').분모",
"출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)"
}
],
"호표": [
{
@@ -1400,7 +1428,7 @@
"수량": "EQ:{기계}.조종원 * 시간환산",
"비목": "노무비",
"출처": "건설품셈 공통 8-1-3 6",
"비고": "하루 노임 × 1/8(1일 8시간 · 건설품셈 공통 1-1-3 2) — 질의회신 건협기술-2230 의 상여 16/12 · 휴지 25/20 뺌(내역 15 · 품셈 본문대로)"
"비고": "하루 노임 × 1/8 × 16/12 × 25/20(1일 8시간 · 상여 300 % + 퇴직 100 % · 월 25 일 중 20 일 가동 — 질의회신 건협기술-2230 · 실무 · 경쟁사와 같음)"
}
],
"덧줄": [{ "이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2" }],
@@ -3516,7 +3544,11 @@
"중간": [
{ "이름": "손료", "식": "버림(EQ:{기계}.가격 * 1000 * EQ:{기계}.손료계수 / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4" },
{ "이름": "직종", "식": "EQ:{기계}.조종원직종", "출처": "건설품셈 공통 8-1-3 5" },
{ "이름": "시간환산", "식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)" }
{
"이름": "시간환산",
"식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모 * 찾기(CC000336, 구분='상여계수').분자 / 찾기(CC000336, 구분='상여계수').분모 * 찾기(CC000336, 구분='휴지계수').분자 / 찾기(CC000336, 구분='휴지계수').분모",
"출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)"
}
],
"호표": [
{
@@ -3671,7 +3703,11 @@
{ "이름": "손료", "식": "버림(EQ:{기계}.가격 * 1000 * EQ:{기계}.손료계수 / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4" },
{ "이름": "연료", "식": "EQ:{기계}.주연료종류", "출처": "건설품셈 공통 8-4 주①㉰" },
{ "이름": "직종", "식": "EQ:{기계}.조종원직종", "출처": "건설품셈 공통 8-1-3 5" },
{ "이름": "시간환산", "식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)" }
{
"이름": "시간환산",
"식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모 * 찾기(CC000336, 구분='상여계수').분자 / 찾기(CC000336, 구분='상여계수').분모 * 찾기(CC000336, 구분='휴지계수').분자 / 찾기(CC000336, 구분='휴지계수').분모",
"출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)"
}
],
"호표": [
{ "종류": "재료", "요소": "MO:{연료}:{지역}", "이름": "주연료", "단위": "ℓ", "수량": "EQ:{기계}.주연료", "비목": "재료비", "출처": "건설품셈 공통 8-4-8" },
@@ -534,7 +534,11 @@
{ "이름": "손료", "식": "버림(EQ000655.가격 * 1000 * (EQ000069.상각비계수 + EQ000069.정비비계수 + EQ000069.관리비계수) / 10000000, 0)", "출처": "산림품셈 10-9 · 건설품셈 공통 8-1-5 12" },
{ "이름": "비율", "식": "찾기(QF000330, 구분='2.5톤 덤프 주 연료 대비').비율", "출처": "산림품셈 10-9" },
{ "이름": "직종", "식": "EQ000069.조종원직종" },
{ "이름": "시간환산", "식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)" }
{
"이름": "시간환산",
"식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모 * 찾기(CC000336, 구분='상여계수').분자 / 찾기(CC000336, 구분='상여계수').분모 * 찾기(CC000336, 구분='휴지계수').분자 / 찾기(CC000336, 구분='휴지계수').분모",
"출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)"
}
],
"호표": [
{ "종류": "재료", "요소": "MO:경유:{지역}", "이름": "주연료", "단위": "ℓ", "수량": "EQ000069.주연료 * 비율 / 100", "비목": "재료비", "출처": "산림품셈 10-9" },
@@ -546,7 +550,7 @@
"수량": "EQ000069.조종원 * 시간환산",
"비목": "노무비",
"출처": "건설품셈 공통 8-1-3 6",
"비고": "하루 노임 × 1/8(1일 8시간 · 건설품셈 공통 1-1-3 2) — 질의회신 건협기술-2230 의 상여 16/12 · 휴지 25/20 뺌(내역 15 · 품셈 본문대로)"
"비고": "하루 노임 × 1/8 × 16/12 × 25/20(1일 8시간 · 상여 300 % + 퇴직 100 % · 월 25 일 중 20 일 가동 — 질의회신 건협기술-2230 · 실무 · 경쟁사와 같음)"
}
],
"덧줄": [
@@ -20,7 +20,7 @@
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4 · 8-1-7 1", "연결": {"갈래": "식"}},
{"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류"}},
{"열쇠": "직종", "기호": "S", "아래": "직종", "위": "", "이름": "직종", "단위": "", "출처": "건설품셈 공통 8-1-3 5", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원직종"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)", "연결": {"갈래": "식"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)", "연결": {"갈래": "식"}},
{"열쇠": "r_가산^표", "기호": "r", "아래": "가산", "위": "표", "이름": "기계손료의 보정.가산비율", "단위": "%", "연결": {"갈래": "표", "표": "CC000034", "조건": {"기종": "'굴착기(무한궤도) 및 로더(무한궤도)'", "작업": "S_보정작업"}, "칸": "가산비율"}},
{"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
{"열쇠": "k_기계상각비", "기호": "k", "아래": "기계상각비", "위": "", "이름": "EQ:{기계}.상각비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "상각비계수"}},
@@ -28,6 +28,10 @@
{"열쇠": "k_기계관리비", "기호": "k", "아래": "기계관리비", "위": "", "이름": "EQ:{기계}.관리비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "관리비계수"}},
{"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}},
{"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}},
{"열쇠": "Q_분자2^표", "기호": "Q", "아래": "분자2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모2^표", "기호": "Q", "아래": "분모2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분모"}},
{"열쇠": "Q_분자3^표", "기호": "Q", "아래": "분자3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모3^표", "기호": "Q", "아래": "분모3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분모"}},
{"열쇠": "P_기계주연료", "기호": "P", "아래": "기계주연료", "위": "", "이름": "EQ:{기계}.주연료", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료"}},
{"열쇠": "P_자동차용경유", "기호": "P", "아래": "자동차용경유", "위": "", "이름": "자동차용경유단가", "단위": "", "연결": {"갈래": "요소", "키": "MO:{S_연료}:{S_지역}"}},
{"열쇠": "Q_기계조종원", "기호": "Q", "아래": "기계조종원", "위": "", "이름": "EQ:{기계}.조종원", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원"}},
@@ -37,7 +41,7 @@
"식": [
{"기호": "r_가산", "식": "만약(S_보정작업 == '해당 없음', 0, r_가산^표)"},
{"기호": "C_손료", "식": "버림(P_기계 × 1000 × ((k_기계상각비 + k_기계정비비) × (1 + r_가산 ÷ 100) + k_기계관리비) ÷ 10000000, 0)"},
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표"}
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표 × Q_분자2^표 ÷ Q_분모2^표 × Q_분자3^표 ÷ Q_분모3^표"}
],
"금액": [
{"이름": "주연료", "단위": "ℓ", "비목": "재료비", "수량": "P_기계주연료", "단가": "P_자동차용경유", "출처": "건설품셈 공통 8-4-1"},
@@ -67,13 +71,17 @@
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류"}},
{"열쇠": "직종", "기호": "S", "아래": "직종", "위": "", "이름": "직종", "단위": "", "출처": "건설품셈 공통 8-1-3 5", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원직종"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)", "연결": {"갈래": "식"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)", "연결": {"갈래": "식"}},
{"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
{"열쇠": "k_기계상각비", "기호": "k", "아래": "기계상각비", "위": "", "이름": "EQ:{기계}.상각비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "상각비계수"}},
{"열쇠": "k_기계정비비", "기호": "k", "아래": "기계정비비", "위": "", "이름": "EQ:{기계}.정비비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "정비비계수"}},
{"열쇠": "k_기계관리비", "기호": "k", "아래": "기계관리비", "위": "", "이름": "EQ:{기계}.관리비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "관리비계수"}},
{"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}},
{"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}},
{"열쇠": "Q_분자2^표", "기호": "Q", "아래": "분자2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모2^표", "기호": "Q", "아래": "분모2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분모"}},
{"열쇠": "Q_분자3^표", "기호": "Q", "아래": "분자3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모3^표", "기호": "Q", "아래": "분모3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분모"}},
{"열쇠": "P_기계주연료", "기호": "P", "아래": "기계주연료", "위": "", "이름": "EQ:{기계}.주연료", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료"}},
{"열쇠": "P_휘발유", "기호": "P", "아래": "휘발유", "위": "", "이름": "휘발유단가", "단위": "", "연결": {"갈래": "요소", "키": "MO:{S_연료}:{S_지역}"}},
{"열쇠": "Q_기계조종원", "기호": "Q", "아래": "기계조종원", "위": "", "이름": "EQ:{기계}.조종원", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원"}},
@@ -82,7 +90,7 @@
],
"식": [
{"기호": "C_손료", "식": "버림(P_기계 × 1000 × (k_기계상각비 + k_기계정비비 + k_기계관리비) ÷ 10000000, 0)"},
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표"}
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표 × Q_분자2^표 ÷ Q_분모2^표 × Q_분자3^표 ÷ Q_분모3^표"}
],
"금액": [
{"이름": "주연료", "단위": "ℓ", "비목": "재료비", "수량": "P_기계주연료", "단가": "P_휘발유", "출처": "건설품셈 공통 8-4-1"},
@@ -115,7 +123,7 @@
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4 · 8-1-7 1", "연결": {"갈래": "식"}},
{"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류"}},
{"열쇠": "직종", "기호": "S", "아래": "직종", "위": "", "이름": "직종", "단위": "", "출처": "건설품셈 공통 8-1-3 5", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원직종"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)", "연결": {"갈래": "식"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)", "연결": {"갈래": "식"}},
{"열쇠": "r_가산^표", "기호": "r", "아래": "가산", "위": "표", "이름": "기계손료의 보정.가산비율", "단위": "%", "연결": {"갈래": "표", "표": "CC000034", "조건": {"기종": "S_보정기종", "작업": "S_보정작업"}, "칸": "가산비율"}},
{"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
{"열쇠": "k_기계상각비", "기호": "k", "아래": "기계상각비", "위": "", "이름": "EQ:{기계}.상각비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "상각비계수"}},
@@ -123,6 +131,10 @@
{"열쇠": "k_기계관리비", "기호": "k", "아래": "기계관리비", "위": "", "이름": "EQ:{기계}.관리비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "관리비계수"}},
{"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}},
{"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}},
{"열쇠": "Q_분자2^표", "기호": "Q", "아래": "분자2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모2^표", "기호": "Q", "아래": "분모2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분모"}},
{"열쇠": "Q_분자3^표", "기호": "Q", "아래": "분자3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모3^표", "기호": "Q", "아래": "분모3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분모"}},
{"열쇠": "P_기계주연료", "기호": "P", "아래": "기계주연료", "위": "", "이름": "EQ:{기계}.주연료", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료"}},
{"열쇠": "P_휘발유", "기호": "P", "아래": "휘발유", "위": "", "이름": "휘발유단가", "단위": "", "연결": {"갈래": "요소", "키": "MO:{S_연료}:{S_지역}"}},
{"열쇠": "Q_기계조종원", "기호": "Q", "아래": "기계조종원", "위": "", "이름": "EQ:{기계}.조종원", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원"}},
@@ -133,7 +145,7 @@
{"기호": "S_보정기종", "식": "만약(S_기계 == '0101-0007' 또는 S_기계 == '0101-0010' 또는 S_기계 == '0101-0012' 또는 S_기계 == '0102-0015' 또는 S_기계 == '0102-0028' 또는 S_기계 == '0102-0033' 또는 S_기계 == '0121-0004' 또는 S_기계 == '0121-0013', '불도저(19톤이상 제외)', 만약(S_기계 == '0602-0025' 또는 S_기계 == '0602-0045' 또는 S_기계 == '0602-0060' 또는 S_기계 == '0602-0080' 또는 S_기계 == '0602-0105' 또는 S_기계 == '0602-0150', '덤프트럭', 만약(S_기계 == '0301-0057' 또는 S_기계 == '0301-0076' 또는 S_기계 == '0301-0095' 또는 S_기계 == '0301-0115' 또는 S_기계 == '0301-0134' 또는 S_기계 == '0301-0153' 또는 S_기계 == '0301-0172' 또는 S_기계 == '0301-0287', '굴착기(무한궤도) 및 로더(무한궤도)', '없음')))"},
{"기호": "r_가산", "식": "만약(S_보정작업 == '해당 없음' 또는 S_보정기종 == '없음', 0, r_가산^표)"},
{"기호": "C_손료", "식": "버림(P_기계 × 1000 × ((k_기계상각비 + k_기계정비비) × (1 + r_가산 ÷ 100) + k_기계관리비) ÷ 10000000, 0)"},
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표"}
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표 × Q_분자2^표 ÷ Q_분모2^표 × Q_분자3^표 ÷ Q_분모3^표"}
],
"금액": [
{"이름": "주연료", "단위": "ℓ", "비목": "재료비", "수량": "P_기계주연료", "단가": "P_휘발유", "출처": "건설품셈 공통 8-4-1"},
@@ -168,7 +180,7 @@
{"열쇠": "조합잡재료율", "기호": "r", "아래": "조합잡재료", "위": "", "이름": "조합잡재료율", "단위": "", "출처": "건설품셈 공통 8-4-8 주⑤", "연결": {"갈래": "표", "표": "CC000074", "조건": {"구분": "'불도저 및 굴착기 잡재료 비율'"}, "칸": "적용"}},
{"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류"}},
{"열쇠": "직종", "기호": "S", "아래": "직종", "위": "", "이름": "직종", "단위": "", "출처": "건설품셈 공통 8-1-3 5", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원직종"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)", "연결": {"갈래": "식"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)", "연결": {"갈래": "식"}},
{"열쇠": "r_가산^표", "기호": "r", "아래": "가산", "위": "표", "이름": "기계손료의 보정.가산비율", "단위": "%", "연결": {"갈래": "표", "표": "CC000034", "조건": {"기종": "'굴착기(무한궤도) 및 로더(무한궤도)'", "작업": "S_보정작업"}, "칸": "가산비율"}},
{"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
{"열쇠": "k_기계상각비", "기호": "k", "아래": "기계상각비", "위": "", "이름": "EQ:{기계}.상각비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "상각비계수"}},
@@ -180,6 +192,10 @@
{"열쇠": "k_집게관리비", "기호": "k", "아래": "집게관리비", "위": "", "이름": "EQ:{집게}.관리비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_집게}", "칸": "관리비계수"}},
{"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}},
{"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}},
{"열쇠": "Q_분자2^표", "기호": "Q", "아래": "분자2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모2^표", "기호": "Q", "아래": "분모2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분모"}},
{"열쇠": "Q_분자3^표", "기호": "Q", "아래": "분자3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모3^표", "기호": "Q", "아래": "분모3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분모"}},
{"열쇠": "P_기계주연료", "기호": "P", "아래": "기계주연료", "위": "", "이름": "EQ:{기계}.주연료", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료"}},
{"열쇠": "P_자동차용경유", "기호": "P", "아래": "자동차용경유", "위": "", "이름": "자동차용경유단가", "단위": "", "연결": {"갈래": "요소", "키": "MO:{S_연료}:{S_지역}"}},
{"열쇠": "Q_기계조종원", "기호": "Q", "아래": "기계조종원", "위": "", "이름": "EQ:{기계}.조종원", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원"}},
@@ -189,7 +205,7 @@
{"기호": "r_가산", "식": "만약(S_보정작업 == '해당 없음', 0, r_가산^표)"},
{"기호": "C_손료", "식": "버림(P_기계 × 1000 × ((k_기계상각비 + k_기계정비비) × (1 + r_가산 ÷ 100) + k_기계관리비) ÷ 10000000, 0)"},
{"기호": "C_집게", "식": "버림(P_집게 × 1000 × (k_집게상각비 + k_집게정비비 + k_집게관리비) ÷ 10000000, 0)"},
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표"}
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표 × Q_분자2^표 ÷ Q_분모2^표 × Q_분자3^표 ÷ Q_분모3^표"}
],
"금액": [
{"이름": "주연료", "단위": "ℓ", "비목": "재료비", "수량": "P_기계주연료", "단가": "P_자동차용경유", "출처": "건설품셈 공통 8-4-1"},
@@ -225,7 +241,7 @@
{"열쇠": "조합잡재료율", "기호": "r", "아래": "조합잡재료", "위": "", "이름": "조합잡재료율", "단위": "", "출처": "건설품셈 공통 8-4-8 주⑤", "연결": {"갈래": "표", "표": "CC000074", "조건": {"구분": "'불도저 및 굴착기 잡재료 비율'"}, "칸": "적용"}},
{"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류"}},
{"열쇠": "직종", "기호": "S", "아래": "직종", "위": "", "이름": "직종", "단위": "", "출처": "건설품셈 공통 8-1-3 5", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원직종"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)", "연결": {"갈래": "식"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)", "연결": {"갈래": "식"}},
{"열쇠": "r_가산^표", "기호": "r", "아래": "가산", "위": "표", "이름": "기계손료의 보정.가산비율", "단위": "%", "연결": {"갈래": "표", "표": "CC000034", "조건": {"기종": "'굴착기(무한궤도) 및 로더(무한궤도)'", "작업": "S_보정작업"}, "칸": "가산비율"}},
{"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
{"열쇠": "k_기계상각비", "기호": "k", "아래": "기계상각비", "위": "", "이름": "EQ:{기계}.상각비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "상각비계수"}},
@@ -237,6 +253,10 @@
{"열쇠": "k_브레이커관리", "기호": "k", "아래": "브레이커관리", "위": "", "이름": "EQ:{브레이커}.관리비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_브레이커}", "칸": "관리비계수"}},
{"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}},
{"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}},
{"열쇠": "Q_분자2^표", "기호": "Q", "아래": "분자2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모2^표", "기호": "Q", "아래": "분모2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분모"}},
{"열쇠": "Q_분자3^표", "기호": "Q", "아래": "분자3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모3^표", "기호": "Q", "아래": "분모3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분모"}},
{"열쇠": "P_기계주연료", "기호": "P", "아래": "기계주연료", "위": "", "이름": "EQ:{기계}.주연료", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료"}},
{"열쇠": "P_자동차용경유", "기호": "P", "아래": "자동차용경유", "위": "", "이름": "자동차용경유단가", "단위": "", "연결": {"갈래": "요소", "키": "MO:{S_연료}:{S_지역}"}},
{"열쇠": "Q_기계조종원", "기호": "Q", "아래": "기계조종원", "위": "", "이름": "EQ:{기계}.조종원", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원"}},
@@ -246,7 +266,7 @@
{"기호": "r_가산", "식": "만약(S_보정작업 == '해당 없음', 0, r_가산^표)"},
{"기호": "C_손료", "식": "버림(P_기계 × 1000 × ((k_기계상각비 + k_기계정비비) × (1 + r_가산 ÷ 100) + k_기계관리비) ÷ 10000000, 0)"},
{"기호": "C_브레이커", "식": "버림(P_브레이커 × 1000 × (k_브레이커상각 + k_브레이커정비 + k_브레이커관리) ÷ 10000000, 0)"},
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표"}
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표 × Q_분자2^표 ÷ Q_분모2^표 × Q_분자3^표 ÷ Q_분모3^표"}
],
"금액": [
{"이름": "주연료", "단위": "ℓ", "비목": "재료비", "수량": "P_기계주연료", "단가": "P_자동차용경유", "출처": "건설품셈 공통 8-4-1"},
@@ -372,13 +392,17 @@
{"열쇠": "조종원직종", "기호": "S", "아래": "조종원직종", "위": "", "이름": "조종원직종", "단위": "", "출처": "건설품셈 공통 8-1-3 5", "연결": {"갈래": "선택", "목록": ["1048", "1049", "1050"], "값": "1048"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)", "연결": {"갈래": "식"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)", "연결": {"갈래": "식"}},
{"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
{"열쇠": "k_기계상각비", "기호": "k", "아래": "기계상각비", "위": "", "이름": "EQ:{기계}.상각비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "상각비계수"}},
{"열쇠": "k_기계정비비", "기호": "k", "아래": "기계정비비", "위": "", "이름": "EQ:{기계}.정비비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "정비비계수"}},
{"열쇠": "k_기계관리비", "기호": "k", "아래": "기계관리비", "위": "", "이름": "EQ:{기계}.관리비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "관리비계수"}},
{"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}},
{"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}},
{"열쇠": "Q_분자2^표", "기호": "Q", "아래": "분자2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모2^표", "기호": "Q", "아래": "분모2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분모"}},
{"열쇠": "Q_분자3^표", "기호": "Q", "아래": "분자3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모3^표", "기호": "Q", "아래": "분모3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분모"}},
{"열쇠": "P_기계주연료", "기호": "P", "아래": "기계주연료", "위": "", "이름": "EQ:{기계}.주연료", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료"}},
{"열쇠": "P_자동차용경유", "기호": "P", "아래": "자동차용경유", "위": "", "이름": "자동차용경유단가", "단위": "", "연결": {"갈래": "요소", "키": "MO:{S_연료}:{S_지역}"}},
{"열쇠": "Q_기계조종원", "기호": "Q", "아래": "기계조종원", "위": "", "이름": "EQ:{기계}.조종원", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원"}},
@@ -387,7 +411,7 @@
],
"식": [
{"기호": "C_손료", "식": "버림(P_기계 × 1000 × (k_기계상각비 + k_기계정비비 + k_기계관리비) ÷ 10000000, 0)"},
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표"}
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표 × Q_분자2^표 ÷ Q_분모2^표 × Q_분자3^표 ÷ Q_분모3^표"}
],
"금액": [
{"이름": "주연료", "단위": "ℓ", "비목": "재료비", "수량": "P_기계주연료", "단가": "P_자동차용경유", "출처": "건설품셈 공통 8-4-1"},
@@ -416,19 +440,23 @@
{"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "직종", "기호": "S", "아래": "직종", "위": "", "이름": "직종", "단위": "", "출처": "건설품셈 공통 8-1-3 5", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원직종"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)", "연결": {"갈래": "식"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)", "연결": {"갈래": "식"}},
{"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
{"열쇠": "k_기계상각비", "기호": "k", "아래": "기계상각비", "위": "", "이름": "EQ:{기계}.상각비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "상각비계수"}},
{"열쇠": "k_기계정비비", "기호": "k", "아래": "기계정비비", "위": "", "이름": "EQ:{기계}.정비비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "정비비계수"}},
{"열쇠": "k_기계관리비", "기호": "k", "아래": "기계관리비", "위": "", "이름": "EQ:{기계}.관리비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "관리비계수"}},
{"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}},
{"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}},
{"열쇠": "Q_분자2^표", "기호": "Q", "아래": "분자2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모2^표", "기호": "Q", "아래": "분모2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분모"}},
{"열쇠": "Q_분자3^표", "기호": "Q", "아래": "분자3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모3^표", "기호": "Q", "아래": "분모3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분모"}},
{"열쇠": "Q_기계조종원", "기호": "Q", "아래": "기계조종원", "위": "", "이름": "EQ:{기계}.조종원", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원"}},
{"열쇠": "w_기계운전사", "기호": "w", "아래": "기계운전사", "위": "", "이름": "건설기계운전사노임", "단위": "", "연결": {"갈래": "요소", "키": "LB:{S_직종}"}}
],
"식": [
{"기호": "C_손료", "식": "버림(P_기계 × 1000 × (k_기계상각비 + k_기계정비비 + k_기계관리비) ÷ 10000000, 0)"},
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표"}
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표 × Q_분자2^표 ÷ Q_분모2^표 × Q_분자3^표 ÷ Q_분모3^표"}
],
"금액": [
{"이름": "조종원", "단위": "인", "비목": "노무비", "수량": "Q_기계조종원 × Q_시간환산", "단가": "w_기계운전사", "출처": "건설품셈 공통 8-1-3 6"},
@@ -2263,11 +2291,15 @@
{"열쇠": "전력 계약종별", "기호": "S", "아래": "전력계약종별", "위": "", "이름": "전력 계약종별", "단위": "", "출처": "유가전력 한전 전기요금표 2025-01-01", "연결": {"갈래": "선택", "목록": ["일반용(갑)Ⅰ 저압", "주택용 저압"], "값": "일반용(갑)Ⅰ 저압"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "직종", "기호": "S", "아래": "직종", "위": "", "이름": "직종", "단위": "", "출처": "건설품셈 공통 8-1-3 5", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원직종", "계약종별": "S_전력계약종별"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)", "연결": {"갈래": "식"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)", "연결": {"갈래": "식"}},
{"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격", "계약종별": "S_전력계약종별"}},
{"열쇠": "k_기계손료", "기호": "k", "아래": "기계손료", "위": "", "이름": "EQ:{기계}.손료계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "손료계수", "계약종별": "S_전력계약종별"}},
{"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}},
{"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}},
{"열쇠": "Q_분자2^표", "기호": "Q", "아래": "분자2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모2^표", "기호": "Q", "아래": "분모2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분모"}},
{"열쇠": "Q_분자3^표", "기호": "Q", "아래": "분자3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모3^표", "기호": "Q", "아래": "분모3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분모"}},
{"열쇠": "P_기계전력", "기호": "P", "아래": "기계전력", "위": "", "이름": "EQ:{기계}.전력", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "전력", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_전력", "기호": "P", "아래": "전력", "위": "", "이름": "전력단가", "단위": "", "연결": {"갈래": "요소", "재료": {"이름": "전력량요금", "계약종별": "입력"}, "단위": "㎾h", "계약종별": "S_전력계약종별"}},
{"열쇠": "Q_기계조종원", "기호": "Q", "아래": "기계조종원", "위": "", "이름": "EQ:{기계}.조종원", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원", "계약종별": "S_전력계약종별"}},
@@ -2276,7 +2308,7 @@
],
"식": [
{"기호": "C_손료", "식": "버림(P_기계 × 1000 × k_기계손료 ÷ 10000000, 0)"},
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표"}
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표 × Q_분자2^표 ÷ Q_분모2^표 × Q_분자3^표 ÷ Q_분모3^표"}
],
"금액": [
{"이름": "전력", "단위": "㎾h", "비목": "재료비", "수량": "P_기계전력", "단가": "P_전력", "출처": "건설품셈 공통 8-1-6 3 가"},
@@ -2409,11 +2441,15 @@
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류", "계약종별": "S_전력계약종별"}},
{"열쇠": "직종", "기호": "S", "아래": "직종", "위": "", "이름": "직종", "단위": "", "출처": "건설품셈 공통 8-1-3 5", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원직종", "계약종별": "S_전력계약종별"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)", "연결": {"갈래": "식"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)", "연결": {"갈래": "식"}},
{"열쇠": "P_EQ기계", "기호": "P", "아래": "EQ기계", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_EQ기계2", "기호": "P", "아래": "EQ기계2", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "손료계수", "계약종별": "S_전력계약종별"}},
{"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}},
{"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}},
{"열쇠": "Q_분자2^표", "기호": "Q", "아래": "분자2", "위": "표", "이름": "분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모2^표", "기호": "Q", "아래": "분모2", "위": "표", "이름": "분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분모"}},
{"열쇠": "Q_분자3^표", "기호": "Q", "아래": "분자3", "위": "표", "이름": "분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모3^표", "기호": "Q", "아래": "분모3", "위": "표", "이름": "분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분모"}},
{"열쇠": "P_EQ기계3", "기호": "P", "아래": "EQ기계3", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_MO연료지역", "기호": "P", "아래": "MO연료지역", "위": "", "이름": "MO:{연료}:{지역}", "단위": "", "연결": {"갈래": "요소", "키": "MO:{S_연료}:{S_지역}", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_EQ기계4", "기호": "P", "아래": "EQ기계4", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "전력", "계약종별": "S_전력계약종별"}},
@@ -2424,7 +2460,7 @@
],
"식": [
{"기호": "C_손료", "식": "버림(P_EQ기계 × 1000 × P_EQ기계2 ÷ 10000000, 0)"},
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표"}
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표 × Q_분자2^표 ÷ Q_분모2^표 × Q_분자3^표 ÷ Q_분모3^표"}
],
"금액": [
{"이름": "주연료", "단위": "ℓ", "비목": "재료비", "수량": "P_EQ기계3", "단가": "P_MO연료지역", "출처": "건설품셈 공통 8-4-8"},
@@ -481,13 +481,17 @@
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "산림품셈 10-9 · 건설품셈 공통 8-1-5 12", "연결": {"갈래": "식"}},
{"열쇠": "비율", "기호": "r", "아래": "비율", "위": "", "이름": "비율", "단위": "", "출처": "산림품셈 10-9", "연결": {"갈래": "표", "표": "QF000330", "조건": {"구분": "'2.5톤 덤프 주 연료 대비'"}, "칸": "비율"}},
{"열쇠": "직종", "기호": "S", "아래": "직종", "위": "", "이름": "직종", "단위": "", "연결": {"갈래": "요소", "키": "EQ000069", "칸": "조종원직종"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)", "연결": {"갈래": "식"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "질의회신 건협기술-2230(1일 8시간 · 상여 · 휴지)", "연결": {"갈래": "식"}},
{"열쇠": "P_덤프트럭", "기호": "P", "아래": "덤프트럭", "위": "", "이름": "EQ000655.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ000655", "칸": "가격"}},
{"열쇠": "k_덤프트럭", "기호": "k", "아래": "덤프트럭", "위": "", "이름": "EQ000069.상각비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ000069", "칸": "상각비계수"}},
{"열쇠": "k_덤프트럭2", "기호": "k", "아래": "덤프트럭2", "위": "", "이름": "EQ000069.정비비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ000069", "칸": "정비비계수"}},
{"열쇠": "k_덤프트럭3", "기호": "k", "아래": "덤프트럭3", "위": "", "이름": "EQ000069.관리비계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ000069", "칸": "관리비계수"}},
{"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}},
{"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}},
{"열쇠": "Q_분자2^표", "기호": "Q", "아래": "분자2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모2^표", "기호": "Q", "아래": "분모2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분모"}},
{"열쇠": "Q_분자3^표", "기호": "Q", "아래": "분자3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모3^표", "기호": "Q", "아래": "분모3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분모"}},
{"열쇠": "P_덤프트럭2", "기호": "P", "아래": "덤프트럭2", "위": "", "이름": "EQ000069.주연료", "단위": "", "연결": {"갈래": "요소", "키": "EQ000069", "칸": "주연료"}},
{"열쇠": "P_자동차용경유", "기호": "P", "아래": "자동차용경유", "위": "", "이름": "자동차용경유단가", "단위": "", "연결": {"갈래": "요소", "키": "MO:경유:{S_지역}"}},
{"열쇠": "Q_덤프트럭", "기호": "Q", "아래": "덤프트럭", "위": "", "이름": "EQ000069.조종원", "단위": "", "연결": {"갈래": "요소", "키": "EQ000069", "칸": "조종원"}},
@@ -496,7 +500,7 @@
],
"식": [
{"기호": "C_손료", "식": "버림(P_덤프트럭 × 1000 × (k_덤프트럭 + k_덤프트럭2 + k_덤프트럭3) ÷ 10000000, 0)"},
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표"}
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표 × Q_분자2^표 ÷ Q_분모2^표 × Q_분자3^표 ÷ Q_분모3^표"}
],
"금액": [
{"이름": "주연료", "단위": "ℓ", "비목": "재료비", "수량": "P_덤프트럭2 × r_비율 ÷ 100", "단가": "P_자동차용경유", "출처": "산림품셈 10-9"},
@@ -0,0 +1,35 @@
/* 840-2 기계 보기 셈 꼴 헬퍼 — common_util_machine_sheet.ts · common_util_formula_doc_cover.ts 를 그 자리에서 트랜스파일해 Node 로 돌림.
* stdin = {문서들: {식 키: 식 문서}, 대상: [{식, 처음, 지금?, 덮음?, 개인, 직종?, 박음?: 값}]}
* stdout = [{덮음: coverFrom(처음, 지금 | 처음), 줄: applyCover(처음, 지금 | 처음, 덮음 | coverFrom), 박음?: bindDoc(식, 박음)}] */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const cache = new Map();
function load(at) {
if (cache.has(at)) return cache.get(at);
const js = ts.transpileModule(fs.readFileSync(at, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(at, box.exports);
const need = (p) =>
p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p);
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const root = path.join(__dirname, "..", "..", "common_util");
const cover = load(path.join(root, "common_util_formula_doc_cover.ts"));
const sheet = load(path.join(root, "common_util_machine_sheet.ts"));
const ask = JSON.parse(fs.readFileSync(0, "utf8"));
const out = ask.대상.map((t) => {
const doc = ask.문서들[t.식];
const parts = cover.partsOf(doc);
const cur = t.지금 ?? t.처음;
const made = sheet.coverFrom(parts, doc.금액 ?? [], t.처음, cur, t.개인);
const use = t.덮음 ?? made;
const row = sheet.applyCover(parts, t.처음, cur, use, t.개인, t.직종 ?? {});
return { 덮음: made, 줄: row, ...(t.박음 ? { 박음: sheet.bindDoc(doc, t.박음) } : {}) };
});
process.stdout.write(JSON.stringify(out));
+3 -3
View File
@@ -1,6 +1,6 @@
"""713-4 지킴 — 정본 로직 원문 대조 정리(내역 15) 뒤 꼴.
조종원 = 하루 노임 × 1/8(질의회신 상여 · 휴지계수 없음) ·
조종원 = 하루 노임 × 1/8 × 16/12 × 25/20(840-5 · 요구 864 경쟁사와 같게 — 713-4 의 1/8 만 꼴을 되돌림) ·
지운 키(쪼갠 로직 · 쪽 표 · 터파기 작업량)를
쓰는 곳 0 · 방제 8-3 열 여덟 모두 로직 · 플랜트 13-1-1 표 하나.
"""
@@ -57,11 +57,11 @@ def test_지운_키는_어디에도_없음() -> None:
assert not pattern.search(path.read_text(encoding="utf-8", errors="ignore")), path.name
def test_조종원_시간환산_본문대로() -> None:
def test_조종원_시간환산_세_계수() -> None:
rows = _rows(cm.load())
for key in MACHINE:
text = str(rows[key]["중간"])
assert "상여계수" not in text and "휴지계수" not in text, key
assert "'시간환산'" in text and "상여계수" in text and "휴지계수" in text, key
def test_방제_열_여덟_플랜트_한_표() -> None:
@@ -165,9 +165,11 @@ def test_상세_부품_길_부품상자():
cover = _text(M02 / "M02_MasterTemplete_Sangeun_Cover.ts")
assert "mountSangeunDetail(host, doc," in view and "createEmpty" not in view
assert 'read: "/m02/logic-lib"' in detail and "setLogicApiPaths(LIB)" in detail
assert "buildLabDetail(editor" in detail and "cover: { ho, open: lineOpener(hoCtx) }" in detail
assert "buildLabDetail(editor" in detail and "cover: { ho }," in detail
assert "buildCalc" not in detail # 799-2 시험 계산 보기 걷음
assert "openSangeunLinePicker(scope" in detail
assert (
"openSangeunLinePicker" not in detail and "openRateLine" not in detail
) # 840-1 [항목 추가] · [율 줄 추가] 걷음
assert "if (mine !== seq || !alive) return" in detail # 최신 응답만
# 724-1 — 단가산출 상세 = 공용 상세(Ho) · 고침 고리 = SK 덮개 · 옛 고침 표 걷음
assert "...buildHo(hoCtx)" in detail and 'scope: "m02",' in detail
+7 -8
View File
@@ -38,10 +38,9 @@ def test_sk_list_layer_order_and_ref_shape():
) # 일위대가 [로직 추가] → 찾아 넣기 창(785-7 `withSangeun`)
store_ts = _text(ROOT / "B09_Estimation" / "B09_Estimation_UI_Store.ts")
assert '["sangeun", "단가산출"],' in store_ts and "sangeun: sangeunItems," in store_ts
# 더한줄 고르기 — 715 이음 꼴 셋(인력 · 재료 = 요소 키)
assert 'logic: (x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 })' in pick
assert "ref: (ref) => done({ 참조: ref, 수량: 1 })" in pick
assert "done({ 종류: e.종류, 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 })" in pick
assert (
"openSangeunLinePicker" not in pick
) # 840-1 단가산출 [항목 추가] 걷음 — 줄 더하기 = 수식 [+]
def _sk(folder, name):
@@ -72,14 +71,14 @@ def test_list_amount_and_labor_line_holds_upper_sk(world): # noqa: F811
},
)["이름"]
line = store.read("precombo", sc)["문서"]["담은로직"][0]
saved, inner = line["본뜬"], [line["수량"]]
saved, inner = line["참조"], [line["수량"]]
outer = {"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}
got = labor.calc(outer, {})
fed = labor.calc(outer, {f"{refs.ref_id({**MINE, '키': sc})}#0": 3})
direct = labor.calc({"담은로직": [{"참조": {**COMPANY, "키": sk}, "수량": {"손": 3}}]}, {})
# 740-1 — 회사 SK = 로직 줄 + 패치로 펼쳐 담음 · 출처 = 본뜬
assert {k: saved[k] for k in ("층", "주인", "키")} == {**COMPANY, "키": sk}
assert line["로직"] and "참조" not in line
# 811-1 — 일위대가 안 SK 줄 = 참조 한 행 그대로(펼치지 않음 · 본뜬 없음)
assert saved == {**COMPANY, "키": sk}
assert "로직" not in line and "본뜬" not in line
# 742-1 — 일위대가 안 줄 수량 = 저장(사용자 입력) · 쓰는 자리 알약이 이어지면 그 값
assert inner == [{"손": 3}]
assert got["반영합"]["계"] == pytest.approx(one * 3, abs=1) # 저장 수량 3
+6 -8
View File
@@ -27,12 +27,12 @@ def _text(path: Path) -> str:
def test_721_1_단추_이름과_자리():
detail = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts")
assert (
"children: [tabs.picker, tabs.tools, addButton, rateButton]" in detail
) # 750-2 안내 걷음 · 778-4 율 줄 추가 · 799-2 보기 · 808-1 [단가 갱신]
"addButton" not in detail and "rateButton" not in detail
) # 840-1 [항목 추가] · [율 줄 추가] 걷음
assert "M02_SangeunPickBase" not in detail # 727-2 단추 걷음
locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts")
assert "M02_SangeunPickBase" not in locale
assert 'M02_SangeunAddLine: ["항목 추가"' in locale
assert "M02_SangeunAddLine" not in locale and "M02_SkRateAdd" not in locale
def test_721_2_부르는_곳_셋만_새_창():
@@ -43,7 +43,7 @@ def test_721_2_부르는_곳_셋만_새_창():
assert "findSangeun" not in labor_pick # 옛 창의 단가산출 탭 걷음
assert "logicOnly" not in _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts") # 727-2
assert "logicOnly" not in find
assert "openFindPicker({" in _text(M02 / "M02_MasterTemplete_Sangeun_Pick.ts")
assert "openFindPicker" not in _text(M02 / "M02_MasterTemplete_Sangeun_Pick.ts") # 840-1
assert "sangeun: true }" in labor_pick # 일위대가(785-7 `withSangeun`)
# 옛 창 그대로 — B09 단가 연결은 sangeun 없이 부름(736-1 불러오기 · 별칭 장부는 찾아 넣기 창으로)
for f in (
@@ -128,7 +128,8 @@ def test_736_2_빠진_길_둘_신규_자재_기계_탭():
"""736-2 — 개인 층 재료 고름 머리 [신규 자재] · 엑셀 연결 = 줄 갈래대로 탭(노임 인력 · 기계 기계 · 자재 재료)."""
find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts")
assert 'opt.layer === "personal" && !!opt.material && ok("material")' in find
assert "openNewMaterial(opt.owner" in find and "actions: [newMat]" in find
assert "openNewMaterial(opt.owner" in find and "headBtns = canNew ? [newMat] : []" in find
assert "{ actions: headBtns }" in find # 머리 단추 있을 때만 모달 머리에
assert "machine?: (e: M01Element) => void;" in find
assert 'opt.machine && ok("machine")' in find
pick = _text(M02 / "M02_Material_Pick.ts")
@@ -171,6 +172,3 @@ def test_736_3_기계_고정_줄_금액():
and "이음: { 노임: null, 자재: null, 기계: e.코드 }" in find
)
assert "machine: fixed &&" in _text(M02 / "M02_MasterTemplete_Labor_Pick.ts")
assert "machine: (e) => done({ 고정: fixedOf(e)" in _text(
M02 / "M02_MasterTemplete_Sangeun_Pick.ts"
)
+11 -26
View File
@@ -38,7 +38,12 @@ def _node(code: str):
def test_상세가_공용_부품을_씀():
detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts")
assert "buildHo({ ...ctx, edit: trialEdit(ctx) })" in detail
assert (
"buildHo" not in detail and "trialEdit" not in detail
) # 840-1 M01 상세 보기 걷음 — M02 줄 모달만 씀
assert "...buildHo(hoCtx)" in _text(
ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Sangeun_Detail.ts"
)
ho = _text(HO)
# 부품 상자만 — 표 · 카드 · 계산 줄 · 변수 칸(calc_sheet) · 입력 칩(텍스트 수식)
for part in (
@@ -60,13 +65,13 @@ def test_상세가_공용_부품을_씀():
)
def test_시험_고침은_캐시_밖():
def test_시험_고침은_M01_에서_걷음():
lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts")
# 시험 계산에만 덮은 사본 · 고침이 있으면 줄을 보냄 · drafts(캐시)에는 원본 줄
assert "=> trial.apply(row);" in lab
assert "dirty: () => current.id in drafts || trial.has(current.row)," in lab
assert "onTrial: drawCalc," in lab and "rerun: () => void drive.run()," in lab # 799-2 셈 구동
assert (
"trial" not in lab and "drive" not in lab
) # 840-1 — 시험 값 · 구동 걷음(수식에서 값을 바꾸고 [되돌림])
cover = _text(COVER)
assert "export const trial" not in cover and "applyCovers" not in cover
assert "drafts" not in cover and "touch" not in cover and "/save" not in cover
modal = _text(M01 / "M01_MasterData_UI_LogicLab_Modal.ts")
assert "...(target.edit ? [target.edit(close, head)] : [])" in modal
@@ -91,23 +96,3 @@ console.log(JSON.stringify(got));
assert got[3]["줄"] == [[2, 5, 2]]
empty = _node(_pure(HO) + '\nconsole.log(JSON.stringify(boxesOf([], ["인력"])))')
assert empty == [{"묶음": "인력", "줄": [], "소계": 0}]
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_고침_덮기():
body = """
const row = { 키: "GF1", 호표: [
{ 종류: "인력", 요소: "LB1", 수량: "찾기(Q).수량" },
{ 종류: "로직", 요소: "로직(GC1)", 수량: "1 / 작업량", 값묶음: { 경비: 3 } },
{ 종류: "재료", 요소: "MT1", 수량: "2" } ], 덧줄: [{ 이름: "공구", 식: "노무비 * 3 / 100" }] };
const out = applyCovers(row, { 0: { qty: 0.0000001, labor: 100000 }, 1: { labor: 50000 }, 9: { qty: 1 } });
console.log(JSON.stringify({ out, same: row.호표[0].수량, keep: out.호표[2] === row.호표[2] }));
"""
got = _node(_pure(COVER) + "\n" + body)
out = got["out"]
assert (
out["호표"][0]["수량"] == "0.0000001" and out["호표"][0]["단가"] == 100000
) # 지수 꼴 없음 · 손 단가
assert out["호표"][1]["값묶음"] == {"경비": 3, "노무비": 50000} and "단가" not in out["호표"][1]
assert got["keep"] and got["same"] == "찾기(Q).수량" # 원본 안 바뀜 · 안 고친 줄 그대로
assert out["덧줄"] == [{"이름": "공구", "식": "노무비 * 3 / 100"}]
+1 -1
View File
@@ -90,7 +90,7 @@ def test_둘째_기초_수량_처음값_배(whole):
src = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts").read_text(
encoding="utf-8"
)
assert "swapUnit(" in src and "addBase(" in src and "baseUnitOf(" in src
assert "addBase(" not in src and "baseUnitOf(" in src # 840-1 [항목 추가] 걷음
def test_돈_아닌_기초_고르기_창에서_뺌(whole):
+1 -1
View File
@@ -93,7 +93,7 @@ def test_굴착기_블록_Q_노재경():
assert item["이름식"]["우"]["꼴"] == "분수" # 1 / 작업량
cut = mcut.engine(whole(), EXCAVATOR, raw(EXCAVATOR))
assert eq["합계"] == {k: cut[k] for k in mf.BUNDLE}
assert eq["합계"]["계"] == D("2190.0")
assert eq["합계"]["계"] == D("2791.0") # 조종원 1/8 × 16/12 × 25/20(840-5 · 옛 2190)
def test_지게_N_M_42917():
+8 -12
View File
@@ -57,27 +57,23 @@ def test_걷은_파일_없고_부르는_곳_없음():
def test_보기는_수식_하나_고르기_없음():
m01 = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts")
m02 = _text(M02 / "M02_MasterTemplete_Sangeun_Tabs.ts")
for src in (m01, m02):
assert not re.findall(r'\{ value: "(\w+)", label: ', src) and "createSegment" not in src
assert "createViewPicker" not in _text(M01 / "M01_MasterData_UI_LogicLab.ts") # 836-3
assert 'editor.dataset.view = "stack";' in _text(M01 / "M01_MasterData_UI_LogicLab.ts")
assert 'export type LabView = "stack" | "ho";' in m01
assert not re.findall(r'\{ value: "(\w+)", label: ', m02) and "createSegment" not in m02
assert not (M01 / "M01_MasterData_UI_LogicLab_View.ts").exists() # 840-1 보기 이름표 걷음
lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts")
assert "createViewPicker" not in lab and "dataset.view" not in lab # 836-3 · 840-1
css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css")
assert '[data-view="ho"] [data-block]:not([data-block="ho"])' in css
assert 'data-view="sheet"' not in css and 'data-view="calc"' not in css
assert "data-view" not in css and "data-block" not in css # 840-1 보기 고르기 규칙 걷음
def test_셈_구동은_화면_없이_상세는_구동만():
drive = _text(M01 / "M01_MasterData_UI_Logic_Drive.ts")
assert "document." not in drive and "sessionStorage." not in drive
assert "export async function driveRun(" in drive and "export function createDrive(" in drive
assert "export async function driveRun(" in drive and "createDrive" not in drive # 840-1
lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts")
assert "createDrive()" in lab and "buildCalc" not in lab and "data-calc-run" not in lab
assert "rerun: () => void drive.run()," in lab
assert "createDrive" not in lab and "buildCalc" not in lab and "data-calc-run" not in lab
detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts")
assert "calcHost" not in detail and "data-calc-run" not in detail and "ctx.rerun?.()" in detail
assert "calcHost" not in detail and "data-calc-run" not in detail and "rerun" not in detail
calc = _text(
M01 / "M01_MasterData_UI_Logic_Calc.ts"
) # 로직 복사 화면이 쓰는 시험 계산 칸 — 구동은 공용
+7 -3
View File
@@ -57,8 +57,8 @@ def test_옵션_표본_모든_갈래_한_번_이상(result):
info = result["요약"]
assert info["옵션 표본"] == want == info["옵션 같음"] + info["옵션 다름"]
assert (
info["옵션 같음"] >= 18400 and info["옵션 다름"] == info["채택된 차이"] == 1
) # 813-2: 지운 6 문서 표본 빠져 18,438
info["옵션 같음"] >= 18400 and info["옵션 다름"] == info["채택된 차이"] == 3
) # 813-2: 지운 6 문서 표본 빠져 18,438 · 840-5 조종원 환산 뒤 채택 셋
def _whole():
@@ -72,7 +72,11 @@ def test_다름_목록_까닭(result):
diff = [x for x in result["대조"] if not x["같음"]]
assert all(x["까닭"] != "까닭 모름" for x in diff)
# 채택된 차이(요구 714-3) — 식 속 단가산출 부름 값 = 끝수 뒤 · 재료비 1 원 · 저장은 함
assert [(x["로직"], x["옵션"], x["채택"]) for x in diff] == [("GC000100", "지역=경북", True)]
assert [(x["로직"], x["옵션"], x["채택"]) for x in diff] == [
("GC000091", "지역=울산", True),
("GC000093", "지역=경북", True),
("GC000100", "지역=전북", True),
] # 840-5 조종원 1/8 × 16/12 × 25/20 뒤 — 같은 까닭 1 원 셋(옛 GC000100 경북 = 같아짐)
assert diff[0]["까닭"].startswith(build.RAW_SUB)
assert diff[0]["옛"]["재료비"] != diff[0]["새"]["재료비"]
assert abs(diff[0]["새"]["재료비"] - diff[0]["옛"]["재료비"]) == 1
+5 -5
View File
@@ -191,9 +191,9 @@ def test_틀풀기_전수_금액같음_Cm고침(world):
"펼침",
[{**world["cases"]["GF000099"], "기호": "Q_작업", "고침": [{"기호": "Cm", "글": "20"}]}],
)[0][0]
assert one["전"]["계"] == 5286 and one["후"]["계"] == 5286
assert one["전"]["계"] == 5651 and one["후"]["계"] == 5651 # 840-5 조종원 환산(옛 5286)
assert one["옮김"][-1] == "Q_작업" and {"q", "K", "f", "E", "Cm"} <= set(one["옮김"])
assert one["고친"]["계"] == 5434 # Cm 18 → 20 = Q 58.212 → 52.391
assert one["고친"]["계"] == 5840 # Cm 18 → 20 = Q 58.212 → 52.391(840-5 · 옛 5434)
gone = _ts("펼침", [{**world["cases"]["GF000099"], "기호": "Q_작업", "지움": ["Cm"]}])[0][0]
assert gone["고친"]["계"] is None and "정의 필요" in gone["표지"] # 지워도 오류 없음
@@ -217,7 +217,7 @@ def test_저장_쌓기_다시읽어같음(client, tmp_path):
laid = got["차림문서"]
res = client.post("/api/m01/logic/doc/save", json={"문서": laid})
assert res.status_code == 200, res.text
assert res.json()["셈"]["계"] == read["셈"]["계"] == 5286
assert res.json()["셈"]["계"] == read["셈"]["계"] == 5651 # 840-5(옛 5286)
again = client.get("/api/m01/logic/doc", params={"key": "GF000099"}).json()
assert (
again["문서"]["쌓기"] == laid["쌓기"] and _ts("왕복", [{"문서": again["문서"]}])[0]["같음"]
@@ -240,8 +240,8 @@ def test_화면_이음():
'base: "/m01/logic/doc",' in lab and 'cache: "m01_doc_drafts",' in lab
) # 804-7 page = 페이지 [저장]
assert "stack: stack.root" in lab and "=> stack.canLeave();" in lab # 804-6 수식 탭 = 줄 쌓기
assert 'editor.dataset.view = "stack";' in lab # 836-3 보기 고르기 없음 · 수식 하나
assert '[data-view="stack"]' in _src("M01_MasterData/M01_MasterData_UI_LogicLab_Style.css")
assert "dataset.view" not in lab # 836-3 보기 고르기 없음 · 840-1 이름표 · CSS 걷음
assert "data-view" not in _src("M01_MasterData/M01_MasterData_UI_LogicLab_Style.css")
tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts")
assert 'cache: "m02_sk_doc_drafts"' in tabs and tabs.count("skDocPath(opt.scope, k, p)") == 1
vite = _src("vite.config.ts")
@@ -51,10 +51,7 @@ def test_걷은_파일_없고_부르는_곳_없음():
def test_보기_수식은_줄쌓기_첫탭():
for rel in (
"M01_MasterData/M01_MasterData_UI_LogicLab_View.ts",
"M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts",
):
for rel in ("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts",):
src = _text(rel)
assert "createSegment" not in src and '"equation"' not in src, rel # 836-3 고르기 없음
lab = _text("M01_MasterData/M01_MasterData_UI_LogicLab.ts")
@@ -209,7 +209,10 @@ def test_시험_페이지_걷음_화면_차림() -> None:
def test_M02_수식탭_M01과_같은_단추_저장_한_번() -> None:
"""804-8 — M02 단가산출 수식 탭 = M01 과 같은 공용 화면 · 페이지 [되돌림] · [저장] 한 번 · 머리 줄 한 줄."""
screen = _src("A00_Common/line_stack_screen.ts")
assert "...(cp && edit() && key && !mark?.자동 ? copyTools(true, (job) => void runJob(job)) : [])," in screen # 836-2 자동 층 = 단추 없음 · 프로젝트 층 = 있음
assert (
"...(cp && edit() && key && !mark?.자동 ? copyTools(true, (job) => void runJob(job)) : []),"
in screen
) # 836-2 자동 층 = 단추 없음 · 프로젝트 층 = 있음
assert "notesOf" not in screen and "lstk__notes" not in screen # 못 고치는 칸 안내 줄 걷음
assert "Notes:" not in _src("A00_Common/line_stack_text.ts")
tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts")
@@ -217,8 +220,9 @@ def test_M02_수식탭_M01과_같은_단추_저장_한_번() -> None:
detail = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts")
assert "page: opt.source ? () => report() : undefined," in detail
assert "JSON.stringify(doc) !== saved || tabs.dirty()" in detail # 고침 알림 = 기본 + 수식
assert "addButton.hidden = !edit || !!opt.source;" in detail # [항목 추가] · [율 줄 추가] 걷음
assert "rateButton.hidden = !edit || !!opt.source;" in detail
assert (
"addButton" not in detail and "rateButton" not in detail
) # 840-1 [항목 추가] · [율 줄 추가] 걷음
assert "if (!(await tabs.save())) throw" in detail and "await refreshing;" in detail
assert "if (tabs.dirty()) void tabs.discard();" in detail # 버리고 떠남 = 수식 캐시도
view = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts")
+4 -2
View File
@@ -133,8 +133,10 @@ def test_808_1_모달_머리_일원화():
assert "actions: row.참조 ? [slot, ...tools] : tools," in row and 't("FromSk")' in row
assert "slot: o.slot," in _src("M02_MasterTemplete_Labor_RowSk.ts")
detail = _src("M02_MasterTemplete_Sangeun_Detail.ts")
assert "...(opt.slot\n ? []" in detail # 머리 칸 있으면 옛 단추 줄 없음
assert "...(opt.slot ? [] : [" in detail # 머리 칸 있으면 머리 줄 없음
assert "children: [tabs.tools]" in detail and "tabs.picker" not in detail
assert r"/^SK-(?!0{6})\d{6}$/" in detail
tabs = _src("M02_MasterTemplete_Sangeun_Tabs.ts")
assert "const doc = !!k || !!opt.page || !!opt.memory?.();" in tabs # 836-3 · 836-5 문서 없는 줄 = 상세 몸만
assert (
"const doc = !!k || !!opt.page || !!opt.memory?.();" in tabs
) # 836-3 · 836-5 문서 없는 줄 = 상세 몸만
+3 -2
View File
@@ -220,8 +220,9 @@ def test_upload_native_one_file_list_only(world) -> None:
ans = dim.run(dim.of(sk), sk)
assert (ans["노무비"], ans["재료비"], ans["경비"]) == (74880, 7029, 12306)
st = fh.upload_native(folder, stc.name, stc.read_bytes(), WHO)
assert st["원파일"] and st["호표수"] == 0
head = hs.grid(folder, st["키"])["책"]["시트"][0]["칸"]
assert st["원파일"] and st["호표수"] == 45 # 828-1 STmate 구성표 일위대가
sheets = hs.grid(folder, st["키"])["책"]["시트"] # 828-1 「일위대가표」 시트가 앞
head = next(s for s in sheets if s["id"] == "s1")["칸"]
assert head["A1"]["값"] == "코드" and str(head["A2"]["값"]).startswith("D")
made = fh.make(folder, st["키"], next(iter(fn.numbers(native("STC", "봉화")))))
assert made["검산"]["같음"]
+3 -1
View File
@@ -82,7 +82,9 @@ def test_새_기계_단위_출처_자동_계수_서버_재계산(copy: Path) ->
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
assert (
any(j["value"] == "1048" for j in lists["jobs"]) and abs(lists["hour"] - 5 / 24) < 1e-12
) # 840-5
found = client.get("/api/m01/machine/find", params={"q": "굴착기 1.0㎥"}).json()["rows"]
seed = next(r for r in found if r["키"] == "EQ000022")
before = _files(copy)
@@ -42,7 +42,9 @@ def test_lists_same_as_m01():
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
assert (
got["fuels"] and got["jobs"] and abs(got["hour"] - 5 / 24) < 1e-12
) # 1/8 × 16/12 × 25/20(840-5)
def test_find_mine_first_and_number(world):
@@ -111,7 +113,7 @@ def _stack_doc(key, no):
def test_stack_line_copy(world):
"""⑤ 줄 쌓기로 고른 내 기계 — 미리 셈 됨 · 저장 때 값 사본에 박힘 →
고쳐도 금액 그대로 + 단가새값 → [단가 갱신] 바뀐 자원 1 → 지워도 마지막 값 + 지운키."""
고치면 자동 따라옴(836-2 · 갱신 할 것 없음) → 지워도 저장한 사본 값 + 지운키."""
from M02_MasterTemplete import M02_Doc_Sk_Path as path
from M02_MasterTemplete import M02_Labor_Ref as refs
@@ -131,13 +133,13 @@ def test_stack_line_copy(world):
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
now = _at(me, path.read, sk) # 836-2 프로젝트 밖 SK = 자동 따라옴(새 값 배지 · 갱신 할 것 없음)
assert now["셈"]["계"] > was and now["사본"]["자동"] and not now["사본"]["단가새값"]
job = _at(me, path.preview, sk, {"할일": "단가갱신"})
assert [x["키"] for x in job["바뀐자원"]] == [key] and job["후"]["계"] > was
assert job["바뀐자원"] == [] and job["후"]["계"] == now["셈"]["계"]
_at(me, mym.delete, key)
gone = _at(me, path.read, sk)
assert gone["셈"]["계"] == was and gone["사본"]["지운키"] == [key]
assert gone["셈"]["계"] == was # 지운 키 = 저장한 사본 줄 값
def _mine_row():
@@ -124,7 +124,8 @@ WANT = ("파워트로웰", "3.73kW")
def test_generator_ours_formula(world) -> None:
"""발전기 5.5kW 내 기계 시간당 노무비 = 우리 노임 ÷ 8 × 1 인(EST 의 ×16/12 ×25/20 안 씀)."""
"""발전기 5.5kW 내 기계 시간당 노무비 = 우리 노임 × 1/8 × 16/12 × 25/20 × 1 인(우리 식이 한 번 · 840-5 —
EST 구성의 환산을 가져와 또 곱하지 않음 = 0.347 아님)."""
me = world["personal"]
me.mkdir(parents=True, exist_ok=True)
body = mm.body(_main()["기계구성"][GEN], 0, _line("발전기", "5.5kW"), "main.estx")
@@ -133,7 +134,7 @@ def test_generator_ours_formula(world) -> None:
hour = mh.hourly(key)
lb = m01.loaded()[1].numbers["LB"][_labor("일반기계운전사")]
assert hour["ok"] and hour["로직"] == "GC000268"
assert abs(hour["노무비"] - float(lb["값"]) / 8) < 1
assert abs(hour["노무비"] - float(lb["값"]) * 5 / 24) < 1
def test_make_flow(world) -> None:
@@ -39,7 +39,10 @@ POWER, POWER_CREW, ONE_FUEL, DIRECT = "GC001196", "GC001197", "GC001198", "GC001
FUEL_POWER, FUEL_POWER_CREW = "GC001200", "GC001201"
NAME = "sub1임시기계822"
# 안 옮긴 기계 — 822-2 전 금액(전국평균 · 기본 옵션)
KEEP = {"EQ000001": 68550, "EQ000022": 108958}
KEEP = {
"EQ000001": 92158,
"EQ000022": 132566,
} # 840-5 조종원 1/8 × 16/12 × 25/20(옛 68550 · 108958)
def _rows() -> dict[str, dict]:
@@ -196,7 +199,7 @@ def test_power_line_jumbo_drill():
elec = Decimal("135") * Decimal("114.4")
misc = int(elec * 6) / Decimal(100)
assert h["경비"] == loss and h["재료비"] == int(elec + misc)
assert h["노무비"] == int(_wage("1048") / 8)
assert h["노무비"] == int(_wage("1048") * 5 / 24) # 1/8 × 16/12 × 25/20(840-5)
p = _preview("EQ000347")
assert (p["노무비"], p["재료비"], p["경비"], p["계"]) == (
h["노무비"],
@@ -237,10 +240,10 @@ def test_power_my_machine(world):
# ⑥ · ⑦ 옮긴 기계 견본 ─────────────────────────────────────────────────
def test_moved_samples():
plant = mh.hourly("EQ000188") # 아스팔트 믹싱플랜트 60t — 연료 + 조종원 2 인
assert plant["로직"] == "GC000268" and plant["노무비"] == int(_wage("1048") * 2 / 8)
assert plant["로직"] == "GC000268" and plant["노무비"] == int(_wage("1048") * 2 * 5 / 24)
assert plant["재료비"] > 0
tower = mh.hourly("EQ000165") # 타워크레인 — 건설기계운전사 조종원 줄(요구 799)
assert tower["로직"] == "GC000276" and tower["노무비"] == int(_wage("1048") / 8)
assert tower["로직"] == "GC000276" and tower["노무비"] == int(_wage("1048") * 5 / 24)
spray = m01.loaded()[1].get("EQ000198") # 아스팔트 스프레어 — 직종 빈 칸 = 직종 입력 식
assert mh.pick(spray)["키"] == "GC000275" and "조종원직종" in json.dumps(
mh.pick(spray)["입력"], ensure_ascii=False
+2 -1
View File
@@ -83,7 +83,8 @@ def test_pick_whole_file(world) -> None:
conv = dim.derive(doc, None, imp._whole())
e = _entry(conv, WIRE)
assert e["고름"]["키"] == pick["키"] and e["처음"]["갈래"] == "숫자"
assert _var(conv, e["기호"])["연결"] == {"갈래": "요소", "키": pick["키"]} # 우리 값
got = _var(conv, e["기호"])["연결"] # 우리 값(엑셀 기억 없음) · 840-3 줄 단위 실음
assert got == {"갈래": "요소", "키": pick["키"], "단위": got["단위"]}
assert sorted(mp.made(me, fp)) == sorted(made)
assert mp.uses(fp, WIRE, (made[0],)) == [made[1]]
# 다른 파일 = 영향 0
+2 -2
View File
@@ -89,7 +89,7 @@ def test_굴착기_덮음_손셈(links):
return {"노무비": int(labor), "재료비": int(mats), "경비": int(exp)}
fuel_amt = _cut1(fuel * oil)
crew1 = _cut1(D(1) / 8 * wage)
crew1 = _cut1(D(1) / 8 * 16 / 12 * 25 / 20 * wage) # 840-5
misc = _cut1(fuel_amt * rate / 100)
want = total(crew1, fuel_amt + misc, loss(price))
got = mc.calc(_one("GC000267", NO), links)
@@ -104,7 +104,7 @@ def test_굴착기_덮음_손셈(links):
f20 = _cut1(20 * oil)
assert run({"값": {"주연료": 20}}) == total(crew1, f20 + _cut1(f20 * rate / 100), loss(price))
assert run({"조종원": [{"키": "LB:1048", "인": 2}]}) == total(
_cut1(D(2) / 8 * wage), fuel_amt + misc, loss(price)
_cut1(D(2) / 8 * 16 / 12 * 25 / 20 * wage), fuel_amt + misc, loss(price)
)
assert run({"조종원": []}) == total(0, fuel_amt + misc, loss(price))
assert run({"잡품": [{"줄": "주연료", "율": 30}]}) == total(
@@ -53,7 +53,9 @@ def test_16_문서_이름_전수_겹침_없음():
assert all("_" not in v and "^" not in v for v in alias.values()), (k, alias)
forms |= {i for i in ids if not i.startswith("P_EQ기계")}
assert not g["틀림"], (k, g["틀림"][:3]) # 식 글 왕복 = 같은 식
assert len(forms) == 58 # 64 꼴 = 사전 58 + 「P_EQ기계~6」 읽는 칸 이름 6
assert (
len(forms) == 62
) # 68 꼴 = 사전 62(840-5 상여 · 휴지 분자 · 분모 넷 더함) + 「P_EQ기계~6」 6
def test_대표_이름():
@@ -0,0 +1,200 @@
"""840-2 기계 보기 지킴 시험 — 값 박음 · 덮음 ↔ 기계 줄 · M01 같은 이름 막음 · 쓰기 없음.
짝 = `M02_MasterTemplete/M02_MyMachine_Sheet.py` `bind` ↔ `common_util/common_util_machine_sheet.ts` `bindDoc`
(+ 덮음 ↔ 줄 `coverFrom` · `applyCover` — 화면 몫 · Node 로 돌림).
① M01 기계 655 대 전수 — 식 고름 → 값 박은 식 셈 = 기계 표 요소로 읽는 셈(노 · 재 · 경 · 계 같음) · 식 없음 = 까닭.
② 655 대 전수(TS) — 고침 없음 = 덮음 없음 · 줄 JSON 같음(열고 닫아도 M01 기계 파일 그대로) · 박음 거울.
③ 굴착기 1.0㎥ — 가격 · 조종원(2 인 · 직종 · 뺌) · 잡품 율 덮음 → 줄 칸 · 덮음 얹고 박은 셈 = 줄 고친 셈.
④ 내 기계 꼴 — 바로(상각비계수 → 손료계수) · 직접(시간당) · 처음 값으로 되돌리면 줄 같음.
⑤ M01 과 같은 이름 · 규격 = 409 · 다른 이름 = 통과(검사만 · 쓰기 없음).
"""
import copy
import importlib
import json
import os
import subprocess
from decimal import Decimal as D
import pytest
from M01_MasterData import M01_MasterData_Store as m01 # scripts 길을 둠
from M01_MasterData import M01_MasterData_Doc as m01doc
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_Sheet as ms
mc = importlib.import_module("master_doc_calc")
ml = importlib.import_module("master_doc_link")
mcv = importlib.import_module("master_doc_cover")
HERE = os.path.dirname(os.path.abspath(__file__))
COSTS = ("노무비", "재료비", "경비", "계")
DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ 0201-0100 · GC000267
def _plain(v):
return json.loads(json.dumps(v, ensure_ascii=False, default=ms._plain))
@pytest.fixture(scope="module")
def world():
"""M01 기계 줄 · 식 고름 · 식 문서 — 한 번만(마스터 층 · 쓰기 없음)."""
with store.at(store.FOLDER):
rows = m01.loaded()[0]["기계.json"]["줄"]
docs = m01doc.docs()
links = ml.Links(m01.loaded()[1], docs)
picks = {}
for r in rows:
got = mh.pick({**r, "값": m01._dec(r.get("값") or {})})
picks[r["키"]] = str(got["키"]) if got and str(got["키"]) in docs else None
yield {"rows": rows, "docs": docs, "links": links, "picks": picks}
def _node(payload):
proc = subprocess.run(
["node", os.path.join(HERE, "helper_840_2_machine_sheet.cjs")],
input=json.dumps(_plain(payload), ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(proc.stdout)
def _money(a):
return {k: a.get(k) for k in COSTS}
# ── ① 655 대 금액 ──────────────────────────────────────────────────────
def test_655대_박은_식_셈_같음(world):
links, docs = world["links"], world["docs"]
same = none = 0
for r in world["rows"]:
key = world["picks"][r["키"]]
if key is None:
none += 1
continue
given = {"기계": str(r.get("원문번호"))}
before = mc.calc(docs[key], links, given)
after = mc.calc(ms.bind(docs[key], m01._dec(r.get("값") or {})), links, given)
assert _money(after) == _money(before), (r["키"], key)
same += before["셈됨"]
assert len(world["rows"]) == 655 and same >= 600 and none <= 44
def test_식_없는_기계_까닭(world):
with store.at(store.FOLDER):
for r in world["rows"]:
if world["picks"][r["키"]] is None:
got = ms.sheet(_plain(r))
assert got["키"] is None and got["문서"] is None and got["까닭"]
return
pytest.skip("식 없는 기계 없음")
# ── ② 655 대 덮음 ↔ 줄(TS) ──────────────────────────────────────────────
def test_655대_고침_없음_줄_같음_박음_거울(world):
docs, targets, want = {}, [], []
for r in world["rows"]:
key = world["picks"][r["키"]]
if key is None:
continue
docs[key] = docs.get(key) or _plain(world["docs"][key])
bind = r["키"] in (DIG, "EQ000001", world["rows"][-1]["키"])
targets.append({"식": key, "처음": _plain(r), "개인": False,
**({"박음": _plain(r["값"])} if bind else {})}) # fmt: skip
want.append(_plain(ms.bind(world["docs"][key], r["값"])) if bind else None)
got = _node({"문서들": docs, "대상": targets})
for t, g, w in zip(targets, got, want):
assert g["덮음"] == {}, t["처음"]["키"]
assert json.dumps(g["줄"], ensure_ascii=False) == json.dumps(t["처음"], ensure_ascii=False)
if w is not None:
assert g["박음"] == w
# ── ③ 굴착기 덮음 → 줄 · 셈 ────────────────────────────────────────────
def test_굴착기_덮음_줄_칸_셈(world):
row = _plain(next(r for r in world["rows"] if r["키"] == DIG))
doc = world["docs"]["GC000267"]
given = {"기계": row["원문번호"]}
jobs = ms._jobs()
lb = next(k for k, v in jobs.items() if v == "1002")
cases = [
({"값": {"가격": 150000}}, {"가격": 150000}),
({"조종원": [{"키": "LB:1048", "인": 2}]}, {"조종원": 2}),
({"조종원": [{"키": lb, "인": 1}]}, {"조종원직종": "1002"}),
({"조종원": []}, {"조종원": 0}),
({"잡품": [{"줄": "주연료", "율": 30}]}, {"잡재료율": 30}),
]
targets = [
{"식": "GC000267", "처음": row, "덮음": c, "개인": False, "직종": jobs} for c, _ in cases
]
got = _node({"문서들": {"GC000267": _plain(doc)}, "대상": targets})
for (cover, change), g in zip(cases, got):
want = copy.deepcopy(row)
want["값"].update(change)
assert g["줄"] == want, cover
shaped = ms.bind(
mcv.cover_doc(doc, given, {row["원문번호"]: m01._dec(cover)}), m01._dec(row["값"])
)
edited = ms.bind(doc, m01._dec(g["줄"]["값"]))
a, b = mc.calc(shaped, world["links"], given), mc.calc(edited, world["links"], given)
assert a["셈됨"] and _money(a) == _money(b), cover
# 지금 줄(초안) → 덮음 — 처음 줄 대비 고친 칸만
cur = copy.deepcopy(row)
cur["값"].update({"가격": 150000, "조종원": 2, "잡재료율": 30})
made = _node({"문서들": {"GC000267": _plain(doc)},
"대상": [{"식": "GC000267", "처음": row, "지금": cur, "개인": False}]})[0] # fmt: skip
assert made["덮음"] == {
"값": {"가격": 150000},
"조종원": [{"키": "LB:1048", "인": 2}],
"잡품": [{"줄": "주연료", "율": 30}],
}
assert made["줄"] == cur
# ── ④ 내 기계 꼴 ───────────────────────────────────────────────────────
def test_내기계_바로_직접(world):
easy = {"이름": "t", "규격": "", "방식": "바로",
"값": {"가격": 1000, "손료계수": 2000, "주연료": 5, "주연료종류": "경유"}} # fmt: skip
hour = {"이름": "t", "규격": "", "방식": "직접", "값": {},
"시간당": {"노무비": 1, "재료비": 2, "경비": 3}} # fmt: skip
with store.at(store.FOLDER):
a, b = ms.sheet(easy, mine=True), ms.sheet(hour, mine=True)
assert a["키"] == "GC000272" and b["키"] == "GC001199"
assert a["값"]["상각비계수"] == 2000 and a["값"]["정비비계수"] == 0
docs = {a["키"]: a["문서"], b["키"]: b["문서"]}
targets = [
{"식": a["키"], "처음": easy, "개인": True},
{"식": a["키"], "처음": easy, "개인": True, "덮음": {"값": {"상각비계수": 2500}}},
{"식": b["키"], "처음": hour, "개인": True},
{"식": b["키"], "처음": hour, "개인": True, "덮음": {"값": {"경비": 10}}},
]
got = _node({"문서들": docs, "대상": targets})
assert got[0]["덮음"] == {} and got[0]["줄"] == easy
assert got[1]["줄"]["값"] == {**easy["값"], "손료계수": 2500}
assert got[2]["덮음"] == {} and got[2]["줄"] == hour
assert got[3]["줄"]["시간당"] == {"노무비": 1, "재료비": 2, "경비": 10}
# ── ⑤ M01 같은 이름 막음 ───────────────────────────────────────────────
def test_M01_같은_이름_막음(world):
dig = next(r for r in world["rows"] if r["키"] == DIG)
with store.at(store.FOLDER):
with pytest.raises(store.StoreError) as e:
mym.not_m01({"이름": dig["이름"], "규격": dig["규격"]})
assert e.value.status == 409
mym.not_m01({"이름": "sub3임시 없는 기계", "규격": "1"})
def test_서버_덮음_몸_틀림_400():
with store.at(store.FOLDER), pytest.raises(m01.StoreError) as e:
ms.sheet({"원문번호": "0201-0100", "값": {}}, None, {"값": {"가격": -1}})
assert e.value.status == 400
def test_수_꼴():
assert ms._plain(D("1.50")) == 1.5 and ms._plain(D("3")) == 3
@@ -0,0 +1,74 @@
"""840-3 불러오기 재료 단위 — ㉠ 같은 이름 · 규격 쌍둥이 중 경쟁사 줄과 같은 단위 줄 먼저 · ㉡ 같은 성격
단위는 이은 뒤 셈이 줄 단위로 환산(표시 = `단위경고` 「단위 환산 톤 → kg」) · ㉢ 성격 다르면 잇지 않고 고르기(요구 860 · 862).
판정 = `M02_San_MatFind`(`M02_San_Link.m01`) · 값 = `master_pick.priced`(자재품목 바로 이음) · 문서 연결 = `M02_Doc_Import._link_of`.
"""
import sys
from decimal import Decimal
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT), str(ROOT / "resources" / "master_data" / "scripts")]
import master_material as mm # noqa: E402
import master_pick as mp # noqa: E402
from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402
from M02_MasterTemplete import M02_Doc_Import as dim # noqa: E402
from M02_MasterTemplete import M02_San_Link as sl # noqa: E402
from M02_MasterTemplete import M02_San_MatFind as mf # noqa: E402
def _go(name, spec, unit, code="", kind=""):
src = {"이름": name, "규격": spec, "단위": unit, "갈래": "재료", "코드": code, "종류": kind}
key, _mark = sl.m01("재료", src)
return key, src["재료찾기"]
def test_twin_same_unit_first():
"""잔디 ㎡ 줄 — 매 줄(MT006796)이 아니라 ㎡ 쌍둥이(MT006799)."""
key, found = _go("천연잔디(한국형)", "0.18×0.18×0.01", "㎡")
assert key == "MT006799" and found["판정"] == mf.LINKED
key, _ = _go("천연잔디(한국형)", "0.18×0.18×0.01", "매")
assert key == "MT006796"
def test_unit_kind_differs_goes_to_pick():
"""골재시험 회 ↔ 자재 kg · 각재 ㎥ ↔ 재 = 이음 없음 · 판정 고르기 · 까닭 글 · 후보에 그 줄."""
for name, spec, unit, was in (
("골재시험", "0.08mm체 통과량시험", "회", "MT009890"),
("각재(외송)", "3.6×3.0×3.0", "㎥", "MT000393"),
):
key, found = _go(name, spec, unit)
assert key is None and found["판정"] == mf.UNIT
assert found["방법"].startswith(f"단위 다름 {unit} ↔ ")
assert was in {c["키"] for c in found["후보"]}
def test_same_meaning_units_stay_linked():
"""본 ↔ EA · 개소 ↔ 개(개수 1 : 1) = 같은 단위로 봄 — 이음 그대로 · 표시 없음."""
key, found = _go("시스템동바리(수직재)", "D60.5×2.6T P1725", "본")
assert key == "MT000512" and found["판정"] == mf.LINKED and "단위" not in found["방법"]
def test_ton_to_kg_price():
"""자재 Ton 줄을 kg 줄에 이음 → 단가 ÷ 1,000 · 표시 「단위 환산 Ton → kg」 · 성격 다름 = 값 그대로 + 경고."""
whole = m01.loaded()[1]
base, _ = mm._lowest(whole, "MT000811")
price, _src, _why, extra = mp.priced(whole, {"단위": "kg"}, "MT000811", {}, None)
assert price == base * Decimal(1000) / Decimal(1000000)
assert extra["단위경고"] == "단위 환산 Ton → kg"
price, *_rest, extra = mp.priced(whole, {"단위": "㎡"}, "MT000811", {}, None)
assert price == base and extra["단위경고"] == "줄 ㎡ · 자재 Ton"
price, *_rest, extra = mp.priced(whole, {"단위": "ton"}, "MT000811", {}, None)
assert price == base and "단위경고" not in extra
def test_doc_link_carries_unit():
"""문서 연결(호표 길) — 자재 이음에 줄 단위 · 사람이 고른 줄도 성격 다르면 설명에 경고."""
link, note = dim._link_of({"이음": "MT000811", "단위": "kg"})
assert link == {"갈래": "요소", "키": "MT000811", "단위": "kg"} and note == "단위 환산 Ton → kg"
link, note = dim._link_of({"이음": "MT000811", "단위": "㎡"})
assert link["단위"] == "㎡" and note == "단위 다름 ㎡ ↔ Ton — 값은 자재 단위 그대로"
assert dim._link_of({"이음": "MT000811", "단위": "Ton"})[1] == ""
@@ -0,0 +1,78 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""840-4 내 재료(개인 자재 PM) = M01 재료 DB 에 없는 자재 등록 자리 — 임시 storage.
① M01 재료 DB 와 이름 · 규격 · 단위가 같으면 만들기 · 고치기 막음(409) · 품명 · 단위 빈 = 400
② 단가산출 재료 줄이 내 재료를 M01 재료처럼 읽음(미리 셈 · 저장)
③ 내 재료 값을 고치면 개인 층 단가산출 금액이 따라옴(836-2 자동 따라옴 길)
④ 쓰는 곳 있으면 지움 막음(409) · 쓰는 곳 없으면 지움 · 없는 키 = 404.
"""
from __future__ import annotations
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Material_Own as own
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
NAME = "sub4임시자재"
def _at(folder, fn, *args):
with store.at(folder):
return fn(*args)
def _code(fn, *args) -> int:
with pytest.raises(store.StoreError) as e:
fn(*args)
return e.value.status
def _doc(key: str, code: str) -> dict:
"""줄 쌓기 재료 줄 하나(화면이 저장하는 꼴) — 단가 = 요소 이음(내 재료)."""
var = {"기호": "P", "아래": "재", "위": "", "이름": NAME, "단위": "",
"연결": {"갈래": "요소", "키": code, "단위": "kg"}} # fmt: skip
line = {"이름": NAME, "단위": "kg", "비목": "재료비", "수량": "2", "단가": "P_재"}
return {"꼴": "수식문서", "판": 1, "키": key, "이름": NAME, "결과단위": "원",
"변수": [var], "식": [], "금액": [line]} # fmt: skip
def test_m01_same_blocked(world):
me = world["personal"]
mt = next(r for r in m01.loaded()[1].index["MT"].values() if r.get("이름") and r.get("단위"))
same = {"이름": f" {mt['이름']} ", "규격": mt.get("규격") or "", "단위": mt["단위"], "값": 1}
assert _at(me, _code, own.add, me, same) == 409
assert _at(me, _code, own.add, me, {"이름": NAME, "단위": ""}) == 400
row = _at(me, own.add, me, {"이름": NAME, "규격": "x", "단위": "kg", "값": 1000})
assert own.is_own(row["키"]) and own.m01_same(row) is None
assert _at(me, _code, own.update, me, row["키"], same) == 409
assert _at(me, _code, own.update, me, "PM999999", {"이름": NAME, "단위": "kg"}) == 404
assert (
_at(me, _code, own.add, me, {"이름": NAME, "규격": "X", "단위": "㎏"}) == 409
) # 내 목록 같음
def test_sk_reads_and_follows(world):
from M02_MasterTemplete import M02_Doc_Sk_Path as path
from M02_MasterTemplete import M02_Labor_Ref as refs
me = world["personal"]
code = _at(me, own.add, me, {"이름": NAME, "규격": "y", "단위": "kg", "값": 1000})["키"]
sk = _at(me, lambda: refs.create("sangeun", {"이름": NAME, "단위": "식", "더한줄": []})["이름"])
doc = _doc(sk, code)
assert _at(me, path.preview, sk, {"문서": doc})["셈"]["계"] == 2000 # 저장 전 미리 셈
_at(me, path.save, sk, {"문서": doc, "판": _at(me, path.read, sk)["판"]})
assert _at(me, path.read, sk)["셈"]["계"] == 2000
_at(me, own.update, me, code, {"이름": NAME, "규격": "y", "단위": "kg", "값": 1500})
assert _at(me, path.read, sk)["셈"]["계"] == 3000 # 값 바꿈 → 따라옴
assert [u["키"] for u in _at(me, own.uses, me, code)] == [sk]
assert _at(me, _code, own.remove, me, code) == 409 # 쓰는 곳 있음
_at(me, store.delete, "sangeun", sk)
aside = me / "_합친것" / f"{sk}.json" # 파생 · 보관 폴더 = 쓰는 곳 아님
aside.parent.mkdir(parents=True, exist_ok=True)
aside.write_text(f'{{"이름": "x", "키": "{code}"}}', encoding="utf-8")
_at(me, own.remove, me, code)
assert all(r["키"] != code for r in own.rows(me))
assert _at(me, _code, own.remove, me, code) == 404
+1 -1
View File
@@ -55,7 +55,7 @@ def test_추가하면_PM_6자리_키_목록에_보임_화면_키_무시(world):
assert got.status_code == 200, got.text
row = got.json()
assert row == {"키": "PM000001", "이름": "앵커볼트", "규격": "M16", "단위": "EA", "값": 1200}
assert c.get(URL.format("personal")).json()["줄"] == [row]
assert c.get(URL.format("personal")).json()["줄"] == [{**row, "쓰는곳": []}] # 840-4 쓰는 곳
path = layers.personal_dir(7, 42) / own.FILE
assert path.is_file()
assert own.read(path.parent)["그룹"] == "재료"
+1 -1
View File
@@ -220,7 +220,7 @@ def test_상세_화면_돌아가기는_집계표로_그_열_고른_채():
main = _text(MAIN)
# M01 꼴 — 제목 앞(39-5) · 105-4 상세 이동 묶음(nav) · 49-7 [?] 칸(helpSlot)이 그 뒤
assert re.search(
r"children: \[\s*back,\s*title,\s*nav\.root,\s*helpSlot,\s*slot,\s*reload,\s*actions\.reset,\s*actions\.del,\s*save,",
r"children: \[\s*back,\s*title,\s*nav\.root,\s*helpSlot,\s*slot,\s*reload,\s*actions\.reset,\s*actions\.del,\s*actions\.undo,\s*save,",
main,
)
assert "!(sel.kind in NUMBER_FIELD)" in main # 구조물 도면 · 일위대가 · 면적산출만
+6 -6
View File
@@ -89,10 +89,10 @@ def test_굴착기(whole):
whole, EXCAVATOR, {"기계": "0201-0070", "지역": "전국평균", "보정작업": "해당 없음"}
)
assert [cents(got[k]) for k in ("노무비", "재료비", "경비", "계")] == [
"35412.13", # 조종원 = 하루 노임 × 1/8(713-4 · 질의회신 상여 · 휴지계수 뺌)
"59020.21", # 조종원 = 하루 노임 × 1/8 × 16/12 × 25/20(840-5 · 옛 1/8 = 35412.13)
"26130.11",
"24554.00",
"86096.24",
"109704.32",
]
@@ -103,7 +103,7 @@ def test_굴착기_암석_보정(whole):
{"기계": "0201-0070", "지역": "전국평균", "보정작업": "암석작업(연암・보통암・경암)"},
)
assert got["중간"]["손료"] == 28322
assert cents(got["계"]) == "89864.24"
assert cents(got["계"]) == "113472.32" # 840-5 조종원 환산
def test_불도저_덤프_로더_손료_보정(whole):
@@ -148,10 +148,10 @@ def test_노체포설(whole):
assert mf.run(whole, "GF000118", {})["결과"] == Decimal("24.255")
got = mf.run(whole, "GF000119", {"지역": "전국평균", "보정작업": "해당 없음"})
assert [cents(got[k]) for k in ("노무비", "재료비", "경비", "계")] == [
"1459.99", # 조종원 = 하루 노임 × 1/8(713-4)
"2433.32", # 조종원 = 하루 노임 × 1/8 × 16/12 × 25/20(840-5)
"1077.31",
"1012.33",
"3549.63",
"4522.96",
]
@@ -191,7 +191,7 @@ def test_굴착기_부착용_집게(whole):
{"기계": "0201-0060", "집게": "7206-0070", "지역": "전국평균", "보정작업": "해당 없음"},
)
assert got["중간"]["집게손료"] == 5786
assert cents(got["계"]) == "86467.61"
assert cents(got["계"]) == "110075.69" # 840-5 조종원 환산
def test_불도저_운반_작업량(whole):
+1 -2
View File
@@ -187,7 +187,6 @@ export const ui_locales_m2 = {
M02_SangeunSwapCoef: ["환산 계수", "Conversion factor"],
M02_SangeunSwapWeight: ["단위중량", "Unit weight"],
M02_SangeunSwapPick: ["고르세요", "Choose"],
M02_SangeunAddLine: ["항목 추가", "Add item"],
M02_SangeunDrop: ["삭제", "Delete"],
M02_SangeunDropBase: ["기초 빼기", "Remove base"],
M02_SangeunBases: ["더한 기초", "Added bases"],
@@ -236,6 +235,7 @@ export const ui_locales_m2 = {
M02_FindClass: ["구분", "Class"],
M02_FindLogic: ["단가산출 기초", "Unit-cost logic"],
M02_FindNewMaterial: ["신규 자재", "New material"],
M02_FindMyMaterial: ["내 재료", "My material"],
M02_FindElementLater: ["일위대가 표 개편 뒤", "After the unit-cost table rework"],
M02_FindOptions: ["옵션 {n}", "{n} options"],
// 729-3 불러오기 — 안 이은 줄 · 일위대가표 띠
@@ -368,7 +368,6 @@ export const ui_locales_m2 = {
M02_ToneDanger: ["못 이음", "Not linked"],
M02_ToneMuted: ["밖", "Outside"],
M02_ToneMine: ["내가 고름", "My pick"],
M02_SkRateAdd: ["율 줄 추가", "Add % line"],
M02_SkRateTitle: ["율 줄 — 기준 × 율 %", "% line — base × rate %"],
M02_SkRateName: ["이름", "Name"],
M02_SkRateDefault: ["공구손료", "Tool cost"],
+14 -1
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* ui_template_locale_machine.ts
* 기계 양식 부품(`ui_template_machine_form.ts`) · M01 기계 목록 · 상세 사전(PLAN 821-2) — 신규 문구는 최하단에.
* 기계 보기(`A00_Common/line_stack_machine_sheet.ts` · 840-2 · 옛 기계 양식 걷음) · M01 기계 목록 · 상세 사전(PLAN 821-2) — 신규 문구는 최하단에.
* ========================================================================== */
export const ui_locales_machine = {
@@ -78,4 +78,17 @@ export const ui_locales_machine = {
M01_MachineErrors: ["고칠 칸이 있음", "Fix the marked fields"],
M01_MachineDetailGroup: ["상세구분", "Sub-group"],
M01_MachineSourceNo: ["원문번호", "Source no."],
// 기계 보기(840-2 · `A00_Common/line_stack_machine_sheet`)
MS_Base: ["기본 정보 · 손료 근거", "Basics · ownership basis"],
MS_Mode: ["손료 방식", "Ownership method"],
MS_Out: ["시간당 경비 산출근거", "Hourly cost breakdown"],
MS_NoFormula: ["시간당 경비 식 없음", "No hourly formula"],
MS_Loading: ["식 불러오는 중", "Loading formula"],
MS_Locked: ["품셈 방식 — 손료 근거 비율로 셈", "By standard — computed from the ratios"],
MS_Coefs: ["계수(비율로 셈)", "Coefficients (from ratios)"],
MS_M01Same: [
"M01 에 있는 기계 — 값은 단가산출 안에서 고침 · 내 기계는 M01 에 없는 기계만",
"Already in M01 — change its values inside a unit-cost sheet · my machines are for machines not in M01",
],
MS_Range: ["{name} 보통 범위 {value} 밖", "{name} outside usual range {value}"],
} as const;
-112
View File
@@ -1,112 +0,0 @@
/* 기계 양식(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;
}
}
-520
View File
@@ -1,520 +0,0 @@
/* =============================================================================
* 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 { attachPickSearch } from "./ui_template_pick_search";
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);
attachPickSearch(field.select, { placeholder: L("MF_PickFind") }); // 항목 많은 고르기(직종) = 찾기 판(801-2)
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.주연료),
...(values["전력"]
? [line(L("MF_Power"), `${num("전력")}kWh × ${fmt(opts.power ?? 0)}원`, p.전력)]
: []),
line(L("MF_Misc"), `${fmt(p.주연료 + p.전력)} × ${num("잡재료율")}%`, p.잡품),
line(L("MF_Crew"), `${num("조종원")}인 × ${fmt(wage)}원 × ${fmt(hour)}`, 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,
};
}
-19
View File
@@ -54,7 +54,6 @@ 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;
@@ -322,22 +321,6 @@ 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 기본 ─────────────────────────────────────────────
@@ -403,8 +386,6 @@ 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) },