From 39338d9e330b57a32f893f6f9a25b22ee40b4639 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 15:45:07 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor(840-1):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=EC=97=90=EB=A7=8C=20=EC=9E=88=EB=8D=98=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=84=B7=20=ED=8C=90=EC=A0=95=20=C2=B7=20?= =?UTF-8?q?=EC=A3=BD=EC=9D=80=20=EC=BD=94=EB=93=9C=20=EA=B1=B7=EC=9D=8C=20?= =?UTF-8?q?=E2=80=94=20=EC=88=98=EC=8B=9D=20=EB=B3=B4=EA=B8=B0=EA=B0=80=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=ED=95=98=EB=8A=94=20=EA=B2=83=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 판정 = 자체 로직 편집 · 시험 값 고침 · M02 줄 덮음 · 비목 박스 소계 모두 수식 보기로 됨(살릴 것 없음) - M01 상세: 숨은 단가산출 상세 덩이 · 자체 로직 시트 편집기 · 시험 고침 · 셈 구동(createDrive) · 보기 이름표(View.ts) · data-view CSS 걷음 - M02 불러오기 모달 머리 [항목 추가] · [율 줄 추가] 단추와 그 창(Rate.ts · openSangeunLinePicker) 걷음 - 글 정리 = Info_Own 새 뜻 · 죽은 글 키 · No_Key · 항목 추가 글 걷음 - 낡은 시험 고침 = 722(시험 고침 · 덮기 셈) · 799-2 · 804-2 · 804-6 · 804-7 · 806-1 · 717-2b · 717-4 · 721 · 728-2 — 걷은 것이 없음 확인으로 - 남김 = M02 수식 문서 없는 줄의 단가산출 상세 몸(cover · buildHo) Co-Authored-By: Claude Sonnet 5.5 --- A00_Common/formula_doc_text.ts | 4 - M01_MasterData/M01_MasterData_UI_LogicLab.ts | 80 +------------- .../M01_MasterData_UI_LogicLab_Cover.ts | 43 +------- .../M01_MasterData_UI_LogicLab_Detail.ts | 102 ++--------------- .../M01_MasterData_UI_LogicLab_Ho.ts | 9 +- .../M01_MasterData_UI_LogicLab_Style.css | 6 - .../M01_MasterData_UI_LogicLab_Text.ts | 15 +-- .../M01_MasterData_UI_LogicLab_View.ts | 15 --- .../M01_MasterData_UI_Logic_Drive.ts | 66 +---------- .../M02_MasterTemplete_Sangeun_Detail.ts | 78 +------------ .../M02_MasterTemplete_Sangeun_Pick.ts | 33 +----- .../M02_MasterTemplete_Sangeun_Rate.ts | 103 ------------------ .../M02_MasterTemplete_Sangeun_Tabs.ts | 1 - .../tester/test_717_2b_sangeun_detail.py | 6 +- resources/tester/test_717_4_sangeun_pick.py | 7 +- resources/tester/test_721_find_pick.py | 11 +- resources/tester/test_722_ho_detail.py | 37 ++----- resources/tester/test_728_2_sk_unit.py | 2 +- resources/tester/test_799_2_old_tabs_gone.py | 20 ++-- resources/tester/test_804_2_line_stack.py | 4 +- .../tester/test_804_6_formula_tab_gone.py | 5 +- resources/tester/test_804_7_line_stack_fix.py | 10 +- resources/tester/test_806_1_labor_view.py | 6 +- ui_template/ui_template_locale_m2.ts | 2 - 24 files changed, 72 insertions(+), 593 deletions(-) delete mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_View.ts delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun_Rate.ts diff --git a/A00_Common/formula_doc_text.ts b/A00_Common/formula_doc_text.ts index a20e06c60..2a4a8e06c 100644 --- a/A00_Common/formula_doc_text.ts +++ b/A00_Common/formula_doc_text.ts @@ -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"], diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 2e60bc6e8..a406920eb 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -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; reasons: string[]; - lines: CalcLine[] | null; - text: TextAnswer | null; - /** 마지막으로 잘 받은 읽는 식 — 멈춘 뒤에도 수량 칸 조각을 이걸로 */ - lastText?: TextAnswer; ownHost: HTMLElement; - values: Record; } 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 { 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): 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 => { @@ -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"); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts index deb5fec0e..5c0b98b30 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts @@ -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( - row: R, - covers: Record, -): R { - const ho = (row.호표 ?? []).map((raw, i) => { - const c = covers[i]; - if (!c) return raw; - const item = raw as Record; - const out: Record = { ...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>(); -export const trial = { - of(row: object): Record { - 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(row: R): R { - return row && trial.has(row) ? (applyCovers(row, trials.get(row)!) as R) : row; - }, -}; - const TEXT = { Head: ["그 자리 고침 (시험 값)", "Edit here (trial value)"], Note: [ diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index f91ac4350..f711da5bb 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -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 }; + /** 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(`[data-own="${own}"]`) : kept?.node; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts index babe52457..93f28b3c5 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts @@ -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; /** 박스 옵션 · 입력 칩 고침 — 시험 계산 입력 고침 + 다시 셈(없으면 고르기 · 칩 없음) */ 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) ? { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index 8446e0132..5da731c80 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -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; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index a5bc204c8..8498cea68 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -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"], diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts deleted file mode 100644 index 9dabc3291..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts +++ /dev/null @@ -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 = (node: T, view: LabView): T => { - node.dataset.block = view; - return node; -}; diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Drive.ts b/M01_MasterData/M01_MasterData_UI_Logic_Drive.ts index de932e8bb..80d7d5255 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Drive.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Drive.ts @@ -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 { if (ctx.savedKey === null) return false; @@ -88,49 +74,3 @@ export async function autoFill(ctx: DriveContext): Promise { for (const n of names) ctx.values[n] = String(auto[n]); return names.length > 0; } - -/** 로직마다 첫 셈을 한 번만(다시 그려도 안 부름) */ -const first = new WeakSet>(); - -/** 로직 상세용 구동 — `start` = 로직을 열거나 입력 칸이 바뀔 때 · `run` = 지금 값으로 다시 셈 */ -export function createDrive(): { - start: (ctx: DriveContext) => void; - run: () => Promise; - stop: () => void; -} { - let seq = 0; // 로직을 바꾸면 늦게 온 응답을 거름 - let now: DriveContext | null = null; - let ran = false; // 셈 답이 이미 왔으면 뒤늦은 미리보기로 안 덮음 - const run = async (): Promise => { - 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++; - }, - }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index fb7a2c50a..8dd2e7d24 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -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[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 { - 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[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([ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts index 0f925f56b..bf5a8920d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts @@ -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; 수량: 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 }), - }); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Rate.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Rate.ts deleted file mode 100644 index cfcf7e580..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Rate.ts +++ /dev/null @@ -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); - }, - }); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts index 0875e1fb0..75df34578 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts @@ -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 { diff --git a/resources/tester/test_717_2b_sangeun_detail.py b/resources/tester/test_717_2b_sangeun_detail.py index 72df8a4be..d4b2807db 100644 --- a/resources/tester/test_717_2b_sangeun_detail.py +++ b/resources/tester/test_717_2b_sangeun_detail.py @@ -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 diff --git a/resources/tester/test_717_4_sangeun_pick.py b/resources/tester/test_717_4_sangeun_pick.py index 2d0021bd3..445a1933c 100644 --- a/resources/tester/test_717_4_sangeun_pick.py +++ b/resources/tester/test_717_4_sangeun_pick.py @@ -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): diff --git a/resources/tester/test_721_find_pick.py b/resources/tester/test_721_find_pick.py index 43c00c285..bdec67e96 100644 --- a/resources/tester/test_721_find_pick.py +++ b/resources/tester/test_721_find_pick.py @@ -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 ( @@ -171,6 +171,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" - ) diff --git a/resources/tester/test_722_ho_detail.py b/resources/tester/test_722_ho_detail.py index b24a171d7..0b9a26a18 100644 --- a/resources/tester/test_722_ho_detail.py +++ b/resources/tester/test_722_ho_detail.py @@ -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"}] diff --git a/resources/tester/test_728_2_sk_unit.py b/resources/tester/test_728_2_sk_unit.py index bbf694329..826b3f7d7 100644 --- a/resources/tester/test_728_2_sk_unit.py +++ b/resources/tester/test_728_2_sk_unit.py @@ -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): diff --git a/resources/tester/test_799_2_old_tabs_gone.py b/resources/tester/test_799_2_old_tabs_gone.py index 671be4d3c..175583b17 100644 --- a/resources/tester/test_799_2_old_tabs_gone.py +++ b/resources/tester/test_799_2_old_tabs_gone.py @@ -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" ) # 로직 복사 화면이 쓰는 시험 계산 칸 — 구동은 공용 diff --git a/resources/tester/test_804_2_line_stack.py b/resources/tester/test_804_2_line_stack.py index 67385f733..e9b159991 100644 --- a/resources/tester/test_804_2_line_stack.py +++ b/resources/tester/test_804_2_line_stack.py @@ -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") diff --git a/resources/tester/test_804_6_formula_tab_gone.py b/resources/tester/test_804_6_formula_tab_gone.py index 032cbaecd..ae42fde34 100644 --- a/resources/tester/test_804_6_formula_tab_gone.py +++ b/resources/tester/test_804_6_formula_tab_gone.py @@ -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") diff --git a/resources/tester/test_804_7_line_stack_fix.py b/resources/tester/test_804_7_line_stack_fix.py index 573105ddd..ae1dc5be3 100644 --- a/resources/tester/test_804_7_line_stack_fix.py +++ b/resources/tester/test_804_7_line_stack_fix.py @@ -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") diff --git a/resources/tester/test_806_1_labor_view.py b/resources/tester/test_806_1_labor_view.py index 9472964a1..ffe0f1dca 100644 --- a/resources/tester/test_806_1_labor_view.py +++ b/resources/tester/test_806_1_labor_view.py @@ -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 문서 없는 줄 = 상세 몸만 diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 05a6bfbc9..b02ce51ea 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -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"], @@ -368,7 +367,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"], From f822a4c36a0bc35c29a511d920ec3e6e57897d6a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 15:52:13 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat(840-2):=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EA=B3=B5=EC=9A=A9=20=E2=80=94=20M01=20?= =?UTF-8?q?=EA=B8=B0=EA=B3=84=20=EC=83=81=EC=84=B8=20=C2=B7=20=EB=82=B4=20?= =?UTF-8?q?=EA=B8=B0=EA=B3=84=EB=A5=BC=20=EC=99=BC=EC=AA=BD=20=EC=A0=9C?= =?UTF-8?q?=EC=96=B4=20=EA=B5=AC=EC=97=AD=20+=20=EC=98=A4=EB=A5=B8?= =?UTF-8?q?=EC=AA=BD=20=EC=8B=9C=EA=B0=84=EB=8B=B9=20=EA=B2=BD=EB=B9=84=20?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=EA=B7=BC=EA=B1=B0=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=E3=80=8C=EB=B3=B5=EC=82=AC=ED=95=B4=20=EB=82=B4=20=EA=B8=B0?= =?UTF-8?q?=EA=B3=84=EB=A1=9C=E3=80=8D=20=EA=B1=B7=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B0=EA=B3=84=20=EC=A4=84=20=ED=92=80=EA=B8=B0=20=EC=95=88?= =?UTF-8?q?=20=EB=82=B4=EB=86=93=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기계 보기 부품(A00_Common/line_stack_machine_sheet) — 836-1 제어 구역 그대로(기계 표 꼴 훅 sheet · 잠금 lock) + 기본 정보 · 손료 근거 접이 카드 + 비슷한 기계에서 시작 · 값 바꾸면 브라우저 계산기로 즉시 - 서버 POST /api/m02/machine-sheet(M02_MyMachine_Sheet) — 식 고름 + 식 문서 + 처음 줄 값 + 연결 기록 · 쓰기 없음 · 파이썬 bind ↔ TS bindDoc 짝 - 덮음 ↔ 기계 줄(common_util_machine_sheet) — 고침 없음 = 줄 바이트 같음 · 내 기계 바로 · 직접 자리 맞춤 - 내 기계 = M01 에 없는 기계만 — 화면 만들기 · 고치기에서 M01 과 같은 이름 · 규격 409(불러오기 자동 생성은 그대로) · [새 기계] · 고침 열림 - 옛 기계 양식 부품(ui_template_machine_form) 지움 — 쓰는 곳 0(부품 모음 견본 줄도 걷음) - 낡은 시험 test_821_3 test_stack_line_copy — 836-2 자동 따라옴(내 기계 고치면 SK 금액 따라감 · 갱신 할 것 없음)에 맞춤 - 지킴 시험 test_840_2 — M01 기계 655 대 박은 식 셈 = 요소 셈 · 줄 그대로 · 굴착기 덮음 · 내 기계 꼴 · M01 같은 이름 막음 Co-Authored-By: Claude Opus 5.5 --- A00_Common/line_stack_machine_ctl.ts | 50 +- A00_Common/line_stack_machine_sheet.css | 4 + A00_Common/line_stack_machine_sheet.ts | 556 ++++++++++++++++++ A00_Common/line_stack_rows.ts | 6 +- M01_MasterData/M01_MasterData_Machine_Api.ts | 2 +- .../M01_MasterData_UI_MachineDetail.ts | 28 +- M02_MasterTemplete/M02_MyMachine.py | 20 + M02_MasterTemplete/M02_MyMachine_Api.ts | 2 +- M02_MasterTemplete/M02_MyMachine_Form.ts | 163 +++-- M02_MasterTemplete/M02_MyMachine_Look.py | 25 +- M02_MasterTemplete/M02_MyMachine_Router.py | 6 +- M02_MasterTemplete/M02_MyMachine_Sheet.py | 90 +++ M02_MasterTemplete/M02_MyMachine_Text.ts | 5 +- M02_MasterTemplete/M02_MyMachine_View.ts | 23 +- common_util/common_util_machine_sheet.ts | 185 ++++++ .../tester/helper_840_2_machine_sheet.cjs | 35 ++ .../tester/test_821_3_my_machine_screen.py | 8 +- resources/tester/test_840_2_machine_sheet.py | 200 +++++++ ui_template/ui_template_locale_machine.ts | 15 +- ui_template/ui_template_machine_form.css | 112 ---- ui_template/ui_template_machine_form.ts | 520 ---------------- ui_template/ui_template_ui_kit_parts.ts | 19 - 22 files changed, 1283 insertions(+), 791 deletions(-) create mode 100644 A00_Common/line_stack_machine_sheet.css create mode 100644 A00_Common/line_stack_machine_sheet.ts create mode 100644 M02_MasterTemplete/M02_MyMachine_Sheet.py create mode 100644 common_util/common_util_machine_sheet.ts create mode 100644 resources/tester/helper_840_2_machine_sheet.cjs create mode 100644 resources/tester/test_840_2_machine_sheet.py delete mode 100644 ui_template/ui_template_machine_form.css delete mode 100644 ui_template/ui_template_machine_form.ts diff --git a/A00_Common/line_stack_machine_ctl.ts b/A00_Common/line_stack_machine_ctl.ts index 934482289..002bcb429 100644 --- a/A00_Common/line_stack_machine_ctl.ts +++ b/A00_Common/line_stack_machine_ctl.ts @@ -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 diff --git a/A00_Common/line_stack_machine_sheet.css b/A00_Common/line_stack_machine_sheet.css new file mode 100644 index 000000000..16f044378 --- /dev/null +++ b/A00_Common/line_stack_machine_sheet.css @@ -0,0 +1,4 @@ +/* 기계 보기(840-2) — 비슷한 기계 찾기 목록 높이만 · 색 · 크기는 공통 값 */ +.lstk-msheet-similar .ui-find__list { + max-height: 240px; +} diff --git a/A00_Common/line_stack_machine_sheet.ts b/A00_Common/line_stack_machine_sheet.ts new file mode 100644 index 000000000..cb512c184 --- /dev/null +++ b/A00_Common/line_stack_machine_sheet.ts @@ -0,0 +1,556 @@ +/* ============================================================================= + * 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; + 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; +} + +/** 늘 보이는 기계 줄 칸(값이 없어도) — 그 밖 칸은 줄에 있으면 */ +const BASE_KEYS = [ + "가격", + "손료계수", + "주연료", + "주연료종류", + "잡재료율", + "조종원", + "조종원직종", + "전력", +]; +const UNIT: Record = { + 가격: "천원", 손료계수: "10⁻⁷/hr", 내용시간: "시간", 연간표준가동시간: "시간", 주연료: "ℓ/hr", + 잡재료율: "%", 조종원: "인", 전력: "kWh", +}; // prettier-ignore +const PICKS = ["주연료종류", "조종원직종"]; +/** 식이 바뀔 수 있는 칸(서버 식 고름) */ +const PICKY = ["주연료", "조종원", "조종원직종", "전력", "손료계수", ...MACHINE_COEFS]; + +const clone = (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(); + const errors = new Map(); + let info: SheetInfo | null = null; + let jobs: Record = {}; + let parts: CoverParts | null = null; + let base: Required = { 값: {}, 조종원: [], 잡품: [] }; + + 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): DocAnswer => + raw(bindDoc(d, info?.값 ?? {}), g); + const raw = (d: FormulaDoc, g: Record): 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); + 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 => { + 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 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 numField = (k: string, read = false): HTMLElement => { + const v = cur.값?.[k]; + const f = createInputField({ label: label(k), value: text(v), size: 28, ...(UNIT[k] ? { unit: UNIT[k] } : {}) }); // 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 => { + 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.값 ?? {}) }; + 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, + }), + }; +} diff --git a/A00_Common/line_stack_rows.ts b/A00_Common/line_stack_rows.ts index 3011151b8..38086baee 100644 --- a/A00_Common/line_stack_rows.ts +++ b/A00_Common/line_stack_rows.ts @@ -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"), diff --git a/M01_MasterData/M01_MasterData_Machine_Api.ts b/M01_MasterData/M01_MasterData_Machine_Api.ts index 8270f5854..b86155c3a 100644 --- a/M01_MasterData/M01_MasterData_Machine_Api.ts +++ b/M01_MasterData/M01_MasterData_Machine_Api.ts @@ -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 { diff --git a/M01_MasterData/M01_MasterData_UI_MachineDetail.ts b/M01_MasterData/M01_MasterData_UI_MachineDetail.ts index 2ab3d096d..e398dcde7 100644 --- a/M01_MasterData/M01_MasterData_UI_MachineDetail.ts +++ b/M01_MasterData/M01_MasterData_UI_MachineDetail.ts @@ -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("details")?.setAttribute("open", ""); } /** 새 기계 바탕 — 칸 차례는 기계 줄과 같음 · 단위 · 출처는 서버가 채움 */ diff --git a/M02_MasterTemplete/M02_MyMachine.py b/M02_MasterTemplete/M02_MyMachine.py index 1992dcd6a..4d8f2f673 100644 --- a/M02_MasterTemplete/M02_MyMachine.py +++ b/M02_MasterTemplete/M02_MyMachine.py @@ -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 {} diff --git a/M02_MasterTemplete/M02_MyMachine_Api.ts b/M02_MasterTemplete/M02_MyMachine_Api.ts index 7abc91b76..93fdb88f3 100644 --- a/M02_MasterTemplete/M02_MyMachine_Api.ts +++ b/M02_MasterTemplete/M02_MyMachine_Api.ts @@ -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"; diff --git a/M02_MasterTemplete/M02_MyMachine_Form.ts b/M02_MasterTemplete/M02_MyMachine_Form.ts index ff48de821..6eeab3f11 100644 --- a/M02_MasterTemplete/M02_MyMachine_Form.ts +++ b/M02_MasterTemplete/M02_MyMachine_Form.ts @@ -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): 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(); + 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 => - (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("details")?.setAttribute("open", ""); - if (seed) { - form.setNotice(mt("Copied").replace("{value}", `${seed.이름} ${seed.규격}`.trim())); - opt.onDirty?.(true); - } return { root, dirty }; } diff --git a/M02_MasterTemplete/M02_MyMachine_Look.py b/M02_MasterTemplete/M02_MyMachine_Look.py index e288f4955..4b5638572 100644 --- a/M02_MasterTemplete/M02_MyMachine_Look.py +++ b/M02_MasterTemplete/M02_MyMachine_Look.py @@ -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.개인) diff --git a/M02_MasterTemplete/M02_MyMachine_Router.py b/M02_MasterTemplete/M02_MyMachine_Router.py index 45efc3d96..25a9c78fd 100644 --- a/M02_MasterTemplete/M02_MyMachine_Router.py +++ b/M02_MasterTemplete/M02_MyMachine_Router.py @@ -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}") diff --git a/M02_MasterTemplete/M02_MyMachine_Sheet.py b/M02_MasterTemplete/M02_MyMachine_Sheet.py new file mode 100644 index 000000000..fe0b254ea --- /dev/null +++ b/M02_MasterTemplete/M02_MyMachine_Sheet.py @@ -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)) diff --git a/M02_MasterTemplete/M02_MyMachine_Text.ts b/M02_MasterTemplete/M02_MyMachine_Text.ts index b4f313ab9..2461e0a48 100644 --- a/M02_MasterTemplete/M02_MyMachine_Text.ts +++ b/M02_MasterTemplete/M02_MyMachine_Text.ts @@ -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: "구분", diff --git a/M02_MasterTemplete/M02_MyMachine_View.ts b/M02_MasterTemplete/M02_MyMachine_View.ts index d0dcd6a0f..7c777a8a2 100644 --- a/M02_MasterTemplete/M02_MyMachine_View.ts +++ b/M02_MasterTemplete/M02_MyMachine_View.ts @@ -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 { + async function openForm(key?: string): Promise { 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 () => { diff --git a/common_util/common_util_machine_sheet.ts b/common_util/common_util_machine_sheet.ts new file mode 100644 index 000000000..8ca59f4bf --- /dev/null +++ b/common_util/common_util_machine_sheet.ts @@ -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; +/** 고르기 목록 한 칸 — 연료(값 = 종류) · 직종(값 = 인력 원문번호) */ +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 = { + 가격: [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 = (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 = {}; + 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 => + key.startsWith("LB:") ? key.slice(3) : (jobs[key] ?? key); + +/** 덮음 → 지금 줄(제어 구역이 맡은 칸만 · 덮음 없는 칸 = 처음 줄 저장 값으로) — 새 줄 */ +export function applyCover( + parts: CoverParts, + orig: MachineRow, + cur: MachineRow, + entry: MachineCover, + mine: boolean, + jobs: Record, +): 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]] + : []; + }); +} diff --git a/resources/tester/helper_840_2_machine_sheet.cjs b/resources/tester/helper_840_2_machine_sheet.cjs new file mode 100644 index 000000000..e83f17167 --- /dev/null +++ b/resources/tester/helper_840_2_machine_sheet.cjs @@ -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)); diff --git a/resources/tester/test_821_3_my_machine_screen.py b/resources/tester/test_821_3_my_machine_screen.py index 10039bce0..eb6f50add 100644 --- a/resources/tester/test_821_3_my_machine_screen.py +++ b/resources/tester/test_821_3_my_machine_screen.py @@ -111,7 +111,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,10 +131,10 @@ 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] diff --git a/resources/tester/test_840_2_machine_sheet.py b/resources/tester/test_840_2_machine_sheet.py new file mode 100644 index 000000000..f9ae5bf88 --- /dev/null +++ b/resources/tester/test_840_2_machine_sheet.py @@ -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 diff --git a/ui_template/ui_template_locale_machine.ts b/ui_template/ui_template_locale_machine.ts index 3650252ae..3ac2833a4 100644 --- a/ui_template/ui_template_locale_machine.ts +++ b/ui_template/ui_template_locale_machine.ts @@ -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; diff --git a/ui_template/ui_template_machine_form.css b/ui_template/ui_template_machine_form.css deleted file mode 100644 index 7d8681fa8..000000000 --- a/ui_template/ui_template_machine_form.css +++ /dev/null @@ -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; - } -} diff --git a/ui_template/ui_template_machine_form.ts b/ui_template/ui_template_machine_form.ts deleted file mode 100644 index 7060d5757..000000000 --- a/ui_template/ui_template_machine_form.ts +++ /dev/null @@ -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; - -/** 고르기 목록 한 칸 — 연료(값 = 종류 · 값 단가 원/ℓ) · 직종(값 = 코드 · 단가 원/일) */ -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; - 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 = { - 상각비계수: "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(); - - 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 => { - 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 | 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, - }; -} diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index aeca68566..32643e158 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -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) }, From bcc540df300cbb1032c5167c123168c9209b30d3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 15:53:27 +0900 Subject: [PATCH 03/10] =?UTF-8?q?test(840-1):=20=EB=82=A1=EC=9D=80=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20=EC=85=8B=20=E2=80=94=20=EC=A7=80=EA=B8=88?= =?UTF-8?q?=20=EA=BC=B4=EC=97=90=20=EB=A7=9E=EC=B6=A4(=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=ED=9D=A0=20=EC=97=86=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_717_4 = 811-1 부터 일위대가 안 SK 줄은 참조 한 행 그대로(본뜬 · 펼침 없음) — 옛 740-1 펼침 기대 고침 - test_721 736_2 = 머리 단추를 headBtns 로 조건부 전달(actions: headBtns)하는 꼴로 고침 - test_m02_structure_panel = 머리 단추 줄에 actions.undo 가 들어간 꼴(804-8)로 고침 Co-Authored-By: Claude Sonnet 5.5 --- resources/tester/test_717_4_sangeun_pick.py | 8 ++++---- resources/tester/test_721_find_pick.py | 3 ++- resources/tester/test_m02_structure_panel.py | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/resources/tester/test_717_4_sangeun_pick.py b/resources/tester/test_717_4_sangeun_pick.py index 445a1933c..8422912da 100644 --- a/resources/tester/test_717_4_sangeun_pick.py +++ b/resources/tester/test_717_4_sangeun_pick.py @@ -71,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 diff --git a/resources/tester/test_721_find_pick.py b/resources/tester/test_721_find_pick.py index bdec67e96..f0004d1df 100644 --- a/resources/tester/test_721_find_pick.py +++ b/resources/tester/test_721_find_pick.py @@ -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") diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index 271ed7233..cf560b39b 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -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 # 구조물 도면 · 일위대가 · 면적산출만 From 1dff6e90643129c892e960780d3785587903f49d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 15:59:47 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix(840-2):=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=E2=80=94=20=EC=9D=BC=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=EC=86=90=EB=A3=8C=20=EA=B8=B0=EA=B3=84=20=EC=B9=B8=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=3D=20=EC=A4=84=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EA=B8=80=20=C2=B7=20=EC=8B=9C=EA=B0=84=EB=8B=B9=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EA=B2=BD=EA=B3=A0=20=EC=95=88=20=ED=95=A8=20=C2=B7?= =?UTF-8?q?=20=EC=8B=9D=20=EA=B7=B8=EB=8C=80=EB=A1=9C=EC=9D=BC=20=EB=95=8C?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EA=B9=8C=EB=8B=AD=20=EA=B8=80=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=20=EB=B0=9B=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- A00_Common/line_stack_machine_sheet.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/A00_Common/line_stack_machine_sheet.ts b/A00_Common/line_stack_machine_sheet.ts index cb512c184..556c1165b 100644 --- a/A00_Common/line_stack_machine_sheet.ts +++ b/A00_Common/line_stack_machine_sheet.ts @@ -209,7 +209,10 @@ export function createMachineSheet(o: MachineSheetOptions) { info = got; rebuild(); drawAll(); - } else drawLive(); + } else { + info.까닭 = got.까닭; // 식은 그대로 — 까닭만 지금 줄로 + drawLive(); + } } catch (e) { if (mineSeq !== seq || (e as Error).name === "AbortError") return; info = { 키: null, 까닭: e instanceof Error ? e.message : "", 문서: null, 직종: jobs }; @@ -282,9 +285,15 @@ export function createMachineSheet(o: MachineSheetOptions) { 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 f = createInputField({ label: label(k), value: text(v), size: 28, ...(UNIT[k] ? { unit: UNIT[k] } : {}) }); // prettier-ignore + 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; @@ -518,6 +527,7 @@ export function createMachineSheet(o: MachineSheetOptions) { }; 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"), From 3caa3e219333d50605c2b271ac40ac5e376bb5b6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 16:10:24 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix(840-5):=20=EC=A1=B0=EC=A2=85=EC=9B=90?= =?UTF-8?q?=20=EB=85=B8=EC=9E=84=20=EC=8B=9C=EA=B0=84=20=ED=99=98=EC=82=B0?= =?UTF-8?q?=20=3D=201/8=20=C3=97=2016/12=20=C3=97=2025/20(=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20864=20=C2=B7=20=EA=B2=BD=EC=9F=81=EC=82=AC=EC=99=80?= =?UTF-8?q?=20=EA=B0=99=EA=B2=8C)=20=E2=80=94=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=EC=8B=9D=20=EC=97=B4=20=EA=B0=9C=EA=B0=80=20=EA=B3=84=EC=88=98?= =?UTF-8?q?=ED=91=9C=20CC000336=20=EC=8B=9C=EA=B0=84=ED=99=98=EC=82=B0=20?= =?UTF-8?q?=C2=B7=20=EC=83=81=EC=97=AC=20=C2=B7=20=ED=9C=B4=EC=A7=80=20?= =?UTF-8?q?=EC=84=B8=20=EC=A4=84=EC=9D=84=20=EC=9D=BD=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정본 로직(08장 아홉 · 산림 10장 4륜 덤프) 시간환산 중간 식 = 세 줄 분자 ÷ 분모 곱(713-4 전 꼴) · 수식 문서 = 빌더 변환으로 다시 지음(변환 대조 1,520 같음 · 채택 1 원 셋 = 같은 까닭 714-3) - 체인톱 GF000337 은 그대로(시간환산 = 하루 연료 · 손료 → 시간 · 조종원 없음) - 짝: 기계 양식 시간환산(M01 lists = 세 줄 곱) · master_machine_coef ↔ common_util_machine_coef 기본값 5/24 · 기계 덮음 새 조종원 줄 꼴(master_doc_cover ↔ common_util_formula_doc_cover) · 기호 이름 상여분자 · 상여분모 · 휴지분자 · 휴지분모 - 전후: 조종원 기계 295 대 노무비만 ×5/3 · 조종원 없는 기계 불변 · 로직 1,520 중 기계 부르는 431 만 오름 - B09 는 안 고침(계수표 세 줄을 스스로 곱함 · 두 번 곱 0) 낡은 시험 고침: 713-4 지킴을 세 계수 꼴로 · masterdata_check · 796-1 · 821-2 · 821-4 · 822-2 · 836-1 · 839-1 기대값 = 조종원 5/24 · 802-2 채택 차이 셋 · 821-3 줄 쌓기 내 기계 = 836-2 뒤 개인 층 자동 따라옴(옛 단가새값 · 단가갱신 기대 걷음) Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Machine.py | 13 ++-- common_util/common_util_formula_doc_cover.ts | 23 +++++- common_util/common_util_machine_coef.ts | 4 +- .../common_util_machine_symbol_name.ts | 4 ++ .../scripts/build_수식문서_802_2.py | 2 +- .../master_data/scripts/master_doc_cover.py | 13 +++- .../scripts/master_machine_coef.py | 5 +- .../로직_건설품셈_08장_건설기계.json | 68 +++++++++++++----- .../로직_산림품셈_10장_자재․장비_운반.json | 8 ++- .../수식문서/수식_건설품셈_08장_건설기계.json | 72 ++++++++++++++----- .../수식_산림품셈_10장_자재․장비_운반.json | 8 ++- resources/tester/test_713_4_source_match.py | 6 +- resources/tester/test_796_1_formula_model.py | 2 +- resources/tester/test_802_2_convert.py | 10 ++- resources/tester/test_821_2_machine_save.py | 4 +- .../tester/test_821_3_my_machine_screen.py | 12 ++-- .../tester/test_821_4_import_my_machine.py | 5 +- .../tester/test_822_2_machine_hourly_all.py | 11 +-- resources/tester/test_836_1_machine_cover.py | 4 +- .../tester/test_839_1_machine_symbol_name.py | 4 +- resources/tester/test_masterdata_check.py | 12 ++-- 21 files changed, 210 insertions(+), 80 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Machine.py b/M01_MasterData/M01_MasterData_Machine.py index 4016d920b..e04fdd85b 100644 --- a/M01_MasterData/M01_MasterData_Machine.py +++ b/M01_MasterData/M01_MasterData_Machine.py @@ -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, diff --git a/common_util/common_util_formula_doc_cover.ts b/common_util/common_util_formula_doc_cover.ts index 6b598089d..12d0f28ea 100644 --- a/common_util/common_util_formula_doc_cover.ts +++ b/common_util/common_util_formula_doc_cover.ts @@ -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; diff --git a/common_util/common_util_machine_coef.ts b/common_util/common_util_machine_coef.ts index 83316e0ee..4bbba8228 100644 --- a/common_util/common_util_machine_coef.ts +++ b/common_util/common_util_machine_coef.ts @@ -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)); diff --git a/common_util/common_util_machine_symbol_name.ts b/common_util/common_util_machine_symbol_name.ts index 84f99d476..53830025b 100644 --- a/common_util/common_util_machine_symbol_name.ts +++ b/common_util/common_util_machine_symbol_name.ts @@ -34,6 +34,10 @@ const NAME: Record = { Q_시간환산: "환산", "Q_분자^표": "환산분자", "Q_분모^표": "환산분모", + "Q_분자2^표": "상여분자", + "Q_분모2^표": "상여분모", + "Q_분자3^표": "휴지분자", + "Q_분모3^표": "휴지분모", "Q_주연료^표": "휘발유량", "Q_주연료2^표": "오일량", "Q_잡^표": "잡품", diff --git a/resources/master_data/scripts/build_수식문서_802_2.py b/resources/master_data/scripts/build_수식문서_802_2.py index 5d32c7cf5..41fbdeb63 100644 --- a/resources/master_data/scripts/build_수식문서_802_2.py +++ b/resources/master_data/scripts/build_수식문서_802_2.py @@ -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]]: diff --git a/resources/master_data/scripts/master_doc_cover.py b/resources/master_data/scripts/master_doc_cover.py index 7258111f6..86701b53b 100644 --- a/resources/master_data/scripts/master_doc_cover.py +++ b/resources/master_data/scripts/master_doc_cover.py @@ -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 diff --git a/resources/master_data/scripts/master_machine_coef.py b/resources/master_data/scripts/master_machine_coef.py index e9ae5f8c6..bf4085fef 100644 --- a/resources/master_data/scripts/master_machine_coef.py +++ b/resources/master_data/scripts/master_machine_coef.py @@ -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)) diff --git a/resources/master_data/로직_건설품셈_08장_건설기계.json b/resources/master_data/로직_건설품셈_08장_건설기계.json index a699bb932..016258fab 100644 --- a/resources/master_data/로직_건설품셈_08장_건설기계.json +++ b/resources/master_data/로직_건설품셈_08장_건설기계.json @@ -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" }, diff --git a/resources/master_data/로직_산림품셈_10장_자재․장비_운반.json b/resources/master_data/로직_산림품셈_10장_자재․장비_운반.json index cb693f8e3..62ecf86f5 100644 --- a/resources/master_data/로직_산림품셈_10장_자재․장비_운반.json +++ b/resources/master_data/로직_산림품셈_10장_자재․장비_운반.json @@ -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 · 실무 · 경쟁사와 같음)" } ], "덧줄": [ diff --git a/resources/master_data/수식문서/수식_건설품셈_08장_건설기계.json b/resources/master_data/수식문서/수식_건설품셈_08장_건설기계.json index 57886345c..c1d1d7792 100644 --- a/resources/master_data/수식문서/수식_건설품셈_08장_건설기계.json +++ b/resources/master_data/수식문서/수식_건설품셈_08장_건설기계.json @@ -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"}, diff --git a/resources/master_data/수식문서/수식_산림품셈_10장_자재․장비_운반.json b/resources/master_data/수식문서/수식_산림품셈_10장_자재․장비_운반.json index 4b28f9a92..cd2bfd44d 100644 --- a/resources/master_data/수식문서/수식_산림품셈_10장_자재․장비_운반.json +++ b/resources/master_data/수식문서/수식_산림품셈_10장_자재․장비_운반.json @@ -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"}, diff --git a/resources/tester/test_713_4_source_match.py b/resources/tester/test_713_4_source_match.py index 80b9cb0ee..ae78c9f00 100644 --- a/resources/tester/test_713_4_source_match.py +++ b/resources/tester/test_713_4_source_match.py @@ -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: diff --git a/resources/tester/test_796_1_formula_model.py b/resources/tester/test_796_1_formula_model.py index ddbdc6ae7..3b2401716 100644 --- a/resources/tester/test_796_1_formula_model.py +++ b/resources/tester/test_796_1_formula_model.py @@ -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(): diff --git a/resources/tester/test_802_2_convert.py b/resources/tester/test_802_2_convert.py index ce22e5a4e..843ed3db7 100644 --- a/resources/tester/test_802_2_convert.py +++ b/resources/tester/test_802_2_convert.py @@ -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 diff --git a/resources/tester/test_821_2_machine_save.py b/resources/tester/test_821_2_machine_save.py index cf9ebf143..1cc972cdb 100644 --- a/resources/tester/test_821_2_machine_save.py +++ b/resources/tester/test_821_2_machine_save.py @@ -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) diff --git a/resources/tester/test_821_3_my_machine_screen.py b/resources/tester/test_821_3_my_machine_screen.py index 10039bce0..2e6993bde 100644 --- a/resources/tester/test_821_3_my_machine_screen.py +++ b/resources/tester/test_821_3_my_machine_screen.py @@ -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 @@ -132,12 +134,10 @@ def test_stack_line_copy(world): assert key in [r["키"] for r in copy["단가"]] # 값 사본에 박힘 _at(me, mym.update, key, {**made["줄"], "값": {**made["줄"]["값"], "가격": 3000}}) now = _at(me, path.read, sk) - assert now["셈"]["계"] == was and now["사본"]["단가새값"] is True - job = _at(me, path.preview, sk, {"할일": "단가갱신"}) - assert [x["키"] for x in job["바뀐자원"]] == [key] and job["후"]["계"] > was + assert now["셈"]["계"] > was # 836-2 개인 층 = 자동 따라옴([단가 갱신] 없음) _at(me, mym.delete, key) gone = _at(me, path.read, sk) - assert gone["셈"]["계"] == was and gone["사본"]["지운키"] == [key] + assert gone["셈"]["계"] == was # 지운 키 = 저장한 사본 줄 값 def _mine_row(): diff --git a/resources/tester/test_821_4_import_my_machine.py b/resources/tester/test_821_4_import_my_machine.py index 21441b831..947923914 100644 --- a/resources/tester/test_821_4_import_my_machine.py +++ b/resources/tester/test_821_4_import_my_machine.py @@ -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: diff --git a/resources/tester/test_822_2_machine_hourly_all.py b/resources/tester/test_822_2_machine_hourly_all.py index b525b0bdd..8525d0c2c 100644 --- a/resources/tester/test_822_2_machine_hourly_all.py +++ b/resources/tester/test_822_2_machine_hourly_all.py @@ -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 diff --git a/resources/tester/test_836_1_machine_cover.py b/resources/tester/test_836_1_machine_cover.py index 0cbf1cf35..363f6bf5c 100644 --- a/resources/tester/test_836_1_machine_cover.py +++ b/resources/tester/test_836_1_machine_cover.py @@ -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( diff --git a/resources/tester/test_839_1_machine_symbol_name.py b/resources/tester/test_839_1_machine_symbol_name.py index 409922eaa..5e6168b73 100644 --- a/resources/tester/test_839_1_machine_symbol_name.py +++ b/resources/tester/test_839_1_machine_symbol_name.py @@ -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_대표_이름(): diff --git a/resources/tester/test_masterdata_check.py b/resources/tester/test_masterdata_check.py index 275db2c64..b1738edaf 100644 --- a/resources/tester/test_masterdata_check.py +++ b/resources/tester/test_masterdata_check.py @@ -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): From 92fd88e94c89e92935dc1c6103e9be055ab279e1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 16:19:36 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat(840-3):=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20=EC=9E=AC=EB=A3=8C=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EC=85=8B=20=E2=80=94=20=EA=B0=99=EC=9D=80=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=EC=8C=8D=EB=91=A5=EC=9D=B4=20=EB=A8=BC=EC=A0=80=20=C2=B7=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=84=B1=EA=B2=A9=20=ED=99=98=EC=82=B0=20?= =?UTF-8?q?=C2=B7=20=EC=84=B1=EA=B2=A9=20=EB=8B=A4=EB=A5=B4=EB=A9=B4=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=BC=20=EC=9E=AC=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 찾기: 줄 단위를 별칭 · 정확 일치 고르기까지 넘김 · 이은 판정마다 단위 맞춤(MatFind `_by_unit` · 코드 길 포함) · 이름 · 규격 같은 쌍둥이 중 같은 단위 줄 › 같은 성격 = 이음 그대로 › 성격 다름 = 판정 「단위 다름 — 고르기」(까닭 「단위 다름 ㎡ ↔ 매」) - 값: 자재품목 바로 이음 줄 = 셈이 줄 단위로 환산 · 표시 「단위 환산 톤 → kg」 · 성격 다름 = 경고(master_pick 한 벌) - 문서 연결(호표 길): 자재 이음에 줄 단위 · 사람이 고른 줄도 단위 글(설명) - 시험 827-3 고침: 고른 줄 연결에 줄 단위 칸이 붙음(840-3 설계) - 전수(네 원천 · 산근 + 일위): 단위 어긋난 48 줄 = 같은 단위 줄 5 · 고르기 37 · 1:1 그대로 6 · 단위 같은 줄 이음 · 값 전후 같음 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Doc_Import.py | 17 +++++ M02_MasterTemplete/M02_Hopyo_Row.py | 14 ++-- M02_MasterTemplete/M02_San_Link.py | 5 +- M02_MasterTemplete/M02_San_MatFind.py | 37 +++++++++- M02_MasterTemplete/M02_San_MatPick.py | 1 + resources/master_data/scripts/master_pick.py | 18 ++++- resources/tester/test_827_3_material_pick.py | 3 +- resources/tester/test_840_3_material_unit.py | 74 ++++++++++++++++++++ 8 files changed, 157 insertions(+), 12 deletions(-) create mode 100644 resources/tester/test_840_3_material_unit.py diff --git a/M02_MasterTemplete/M02_Doc_Import.py b/M02_MasterTemplete/M02_Doc_Import.py index 921b5a796..0db7ddac7 100644 --- a/M02_MasterTemplete/M02_Doc_Import.py +++ b/M02_MasterTemplete/M02_Doc_Import.py @@ -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` 가 더함) diff --git a/M02_MasterTemplete/M02_Hopyo_Row.py b/M02_MasterTemplete/M02_Hopyo_Row.py index 9f4f24a20..fe40da924 100644 --- a/M02_MasterTemplete/M02_Hopyo_Row.py +++ b/M02_MasterTemplete/M02_Hopyo_Row.py @@ -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] diff --git a/M02_MasterTemplete/M02_San_Link.py b/M02_MasterTemplete/M02_San_Link.py index 31d7fc0cb..3ca5c670f 100644 --- a/M02_MasterTemplete/M02_San_Link.py +++ b/M02_MasterTemplete/M02_San_Link.py @@ -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() diff --git a/M02_MasterTemplete/M02_San_MatFind.py b/M02_MasterTemplete/M02_San_MatFind.py index ce8d57575..f89376c6c 100644 --- a/M02_MasterTemplete/M02_San_MatFind.py +++ b/M02_MasterTemplete/M02_San_MatFind.py @@ -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 ( diff --git a/M02_MasterTemplete/M02_San_MatPick.py b/M02_MasterTemplete/M02_San_MatPick.py index 8de03be7b..2b03c7dbc 100644 --- a/M02_MasterTemplete/M02_San_MatPick.py +++ b/M02_MasterTemplete/M02_San_MatPick.py @@ -31,6 +31,7 @@ DIRNAME = "재료고름" CENSUS = "재료판" KINDS = ( "규격 없음 — 고르기", + "단위 다름 — 고르기", # 840-3 "후보 여럿", "우리 표에 없음", "이어짐", diff --git a/resources/master_data/scripts/master_pick.py b/resources/master_data/scripts/master_pick.py index cb1db3e8a..ffc906650 100644 --- a/resources/master_data/scripts/master_pick.py +++ b/resources/master_data/scripts/master_pick.py @@ -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): diff --git a/resources/tester/test_827_3_material_pick.py b/resources/tester/test_827_3_material_pick.py index 9a1d7a8a2..43e2ce09a 100644 --- a/resources/tester/test_827_3_material_pick.py +++ b/resources/tester/test_827_3_material_pick.py @@ -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 diff --git a/resources/tester/test_840_3_material_unit.py b/resources/tester/test_840_3_material_unit.py new file mode 100644 index 000000000..4d3610ebf --- /dev/null +++ b/resources/tester/test_840_3_material_unit.py @@ -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] == "" From 453dd4fe129a83b38bde8201b5bd7332df202640 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 16:31:24 +0900 Subject: [PATCH 07/10] =?UTF-8?q?test(840-5):=20=EC=A4=84=20=EC=8C=93?= =?UTF-8?q?=EA=B8=B0=20GF000099=20=EA=B8=88=EC=95=A1=20=EA=B8=B0=EB=8C=80?= =?UTF-8?q?=EA=B0=92=20=3D=20=EC=A1=B0=EC=A2=85=EC=9B=90=201/8=20=C3=97=20?= =?UTF-8?q?16/12=20=C3=97=2025/20=20=EB=92=A4=20=EA=B0=92(5286=20=E2=86=92?= =?UTF-8?q?=205651=20=C2=B7=20Cm=20=EA=B3=A0=EC=B9=A8=205434=20=E2=86=92?= =?UTF-8?q?=205840)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 낡은 시험 고침: 840-5 조종원 환산으로 굴착기 시간당이 올라 측구터파기 금액이 바뀜 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_804_2_line_stack.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/tester/test_804_2_line_stack.py b/resources/tester/test_804_2_line_stack.py index 67385f733..f48cc2c10 100644 --- a/resources/tester/test_804_2_line_stack.py +++ b/resources/tester/test_804_2_line_stack.py @@ -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]["같음"] From 7571562a3ccc9156adfc0affee811b082f819819 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 16:36:22 +0900 Subject: [PATCH 08/10] =?UTF-8?q?test(840-3):=20=EC=9B=90=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?=EB=82=A1=EC=9D=80=20=EC=B9=B8=20=EA=B3=A0=EC=B9=A8=20=E2=80=94?= =?UTF-8?q?=20STmate=20=EC=9B=90=ED=8C=8C=EC=9D=BC=20=ED=98=B8=ED=91=9C=20?= =?UTF-8?q?0=20=E2=86=92=2045=20=C2=B7=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=ED=91=9C=20=EC=8B=9C=ED=8A=B8=EB=8A=94=20s1=20?= =?UTF-8?q?=EB=A1=9C=20=EC=B0=BE=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 까닭: 828-1 이 STmate 원파일 구성표 일위대가를 HP 호표(45)와 앞쪽 「일위대가표」 시트로 넣음(뜻한 바꿈 · test_814_1 은 그때 고침) · test_810_2 만 옛 꼴(호표 0 · 첫 시트 = 단가산출표)로 남아 있었음 - 코드 흠 아님 — 엔진 그대로 · test_810_2 20 통과 · test_810_3 16 통과 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_810_2_native_doc.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/resources/tester/test_810_2_native_doc.py b/resources/tester/test_810_2_native_doc.py index a8005b3f8..9c7ce5473 100644 --- a/resources/tester/test_810_2_native_doc.py +++ b/resources/tester/test_810_2_native_doc.py @@ -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["검산"]["같음"] From 207be5bd71d61275f504be8d414548f761905333 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 16:40:08 +0900 Subject: [PATCH 09/10] =?UTF-8?q?feat(840-4):=20=EB=82=B4=20=EC=9E=AC?= =?UTF-8?q?=EB=A3=8C=20=3D=20=EC=9E=AC=EB=A3=8C=20DB(M01)=EC=97=90=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=9E=90=EC=9E=AC=EB=A5=BC=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=ED=95=98=EB=8A=94=20=EC=9E=90=EB=A6=AC=20=E2=80=94=20?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8=EA=B4=80=EB=A6=AC=20=E2=80=BA=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20=EB=93=B1=EB=A1=9D=20=EC=B9=B8(=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EA=B3=A0=EC=B9=98=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EA=B8=B0)=20=C2=B7=20M01=20=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EA=B7=9C?= =?UTF-8?q?=EA=B2=A9=20=C2=B7=20=EB=8B=A8=EC=9C=84=20=EB=A7=89=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=EC=9E=AC?= =?UTF-8?q?=EB=A3=8C=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=E3=80=8C=EB=82=B4=20?= =?UTF-8?q?=EC=9E=AC=EB=A3=8C=E3=80=8D=20=C2=B7=20=EA=B0=92=20=EB=B0=94?= =?UTF-8?q?=EA=BE=B8=EB=A9=B4=20=EC=9E=90=EB=8F=99=20=EB=94=B0=EB=9D=BC?= =?UTF-8?q?=EC=98=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 `M02_Material_Own`: M01 자재품목과 이름 · 규격 · 단위 같음 = 409(빈칸 · 대소문자 · ㎏ = kg 무시) · 고치기 · 지우기(쓰는 곳 있으면 409 · 밑줄 폴더 보관본 뺌) · 목록 줄마다 쓰는 곳 - 셈 색인: 단가산출 사본 색인 · 사본 모으기 · 빈틈 채우기에 내 재료 줄을 내 기계처럼 얹음 → 개인 층 단가산출은 값 바뀌면 836-2 길로 따라옴 · 프로젝트 층 = 사본 값 - 화면: 개인관리 › 자재 위 「내 재료」 표 + 고침 칸(`M02_MyMaterial_View` · `M02_MyMaterial_Api`) · 아래 「값을 바로 적은 재료」 모아 보기는 읽기 그대로 · 찾아 넣기 재료 탭 M01 위 「내 재료」 묶음(줄 쌓기 개인 층) - M01 재료를 본떠 값만 바꾼 내 재료를 만드는 길 = 없었음(걷을 것 없음) 낡은 시험 고침: test_m02_material_own 목록 줄에 「쓰는곳」 칸 Co-Authored-By: Claude Opus 5.5 --- A00_Common/line_stack_pick.ts | 2 + M02_MasterTemplete/M02_Doc_Gap.py | 7 +- M02_MasterTemplete/M02_Item_Prices.py | 8 +- M02_MasterTemplete/M02_Labor_Router.py | 37 +++- .../M02_MasterTemplete_Find_Pick.ts | 22 +++ M02_MasterTemplete/M02_Material_Own.py | 146 +++++++++++++- M02_MasterTemplete/M02_MyItems_View.ts | 3 + M02_MasterTemplete/M02_MyMachine_Text.ts | 2 +- M02_MasterTemplete/M02_MyMaterial_Api.ts | 45 +++++ M02_MasterTemplete/M02_MyMaterial_View.ts | 185 ++++++++++++++++++ M02_MasterTemplete/M02_Sangeun_Copy.py | 5 +- resources/tester/test_840_4_my_material.py | 78 ++++++++ resources/tester/test_m02_material_own.py | 2 +- ui_template/ui_template_locale_m2.ts | 1 + 14 files changed, 527 insertions(+), 16 deletions(-) create mode 100644 M02_MasterTemplete/M02_MyMaterial_Api.ts create mode 100644 M02_MasterTemplete/M02_MyMaterial_View.ts create mode 100644 resources/tester/test_840_4_my_material.py diff --git a/A00_Common/line_stack_pick.ts b/A00_Common/line_stack_pick.ts index 4dbc376b0..c9db700a0 100644 --- a/A00_Common/line_stack_pick.ts +++ b/A00_Common/line_stack_pick.ts @@ -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) => diff --git a/M02_MasterTemplete/M02_Doc_Gap.py b/M02_MasterTemplete/M02_Doc_Gap.py index af21c0afc..1e20667d3 100644 --- a/M02_MasterTemplete/M02_Doc_Gap.py +++ b/M02_MasterTemplete/M02_Doc_Gap.py @@ -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))} diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index 1c3c33176..4a53c9aa9 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -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 diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 5db9ebb66..bcf92e6da 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -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} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts index c6e0e57dd..45ebc0f92 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts @@ -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( diff --git a/M02_MasterTemplete/M02_Material_Own.py b/M02_MasterTemplete/M02_Material_Own.py index 3eb6bab69..b5885a4cb 100644 --- a/M02_MasterTemplete/M02_Material_Own.py +++ b/M02_MasterTemplete/M02_Material_Own.py @@ -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"(? 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` 안 이름 diff --git a/M02_MasterTemplete/M02_MyItems_View.ts b/M02_MasterTemplete/M02_MyItems_View.ts index 3cf894d95..0fe43446f 100644 --- a/M02_MasterTemplete/M02_MyItems_View.ts +++ b/M02_MasterTemplete/M02_MyItems_View.ts @@ -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, diff --git a/M02_MasterTemplete/M02_MyMachine_Text.ts b/M02_MasterTemplete/M02_MyMachine_Text.ts index b4f313ab9..b447bcec5 100644 --- a/M02_MasterTemplete/M02_MyMachine_Text.ts +++ b/M02_MasterTemplete/M02_MyMachine_Text.ts @@ -74,7 +74,7 @@ const TEXT = { Why_Company: "개인용으로 복사해서 씀", // 내 품목 Items_Note: - "단가산출 줄에 값을 바로 적은 재료(우리 자재에 안 이어진 것) — 고치기는 그 단가산출에서 · 줄 누르면 쓰는 단가산출", + "단가산출 줄에 값을 바로 적은 재료(재료 DB · 내 재료에 안 이어진 것 · 읽기만) — 고치기는 그 단가산출에서 · 줄 누르면 쓰는 단가산출", Items_None: "내 품목 없음", Value: "값(원)", Unit: "단위", diff --git a/M02_MasterTemplete/M02_MyMaterial_Api.ts b/M02_MasterTemplete/M02_MyMaterial_Api.ts new file mode 100644 index 000000000..477530f6d --- /dev/null +++ b/M02_MasterTemplete/M02_MyMaterial_Api.ts @@ -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; + +const path = (owner: string | null, key = ""): string => + `/layers/personal/materials${key ? `/${encodeURIComponent(key)}` : ""}${ + owner ? `?owner=${encodeURIComponent(owner)}` : "" + }`; + +export const listMyMaterials = (owner: string | null): Promise => + call<{ 줄: MyMaterial[] }>(path(owner)).then((d) => d.줄); + +export const addMyMaterial = (owner: string | null, body: MyMaterialBody): Promise => + call(path(owner), { method: "POST", body: JSON.stringify(body) }); + +export const editMyMaterial = ( + owner: string | null, + key: string, + body: MyMaterialBody, +): Promise => + call(path(owner, key), { method: "PUT", body: JSON.stringify(body) }); + +export const dropMyMaterial = (owner: string | null, key: string): Promise => + call(path(owner, key), { method: "DELETE" }); diff --git a/M02_MasterTemplete/M02_MyMaterial_View.ts b/M02_MasterTemplete/M02_MyMaterial_View.ts new file mode 100644 index 000000000..165178c89 --- /dev/null +++ b/M02_MasterTemplete/M02_MyMaterial_View.ts @@ -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 { + 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 { + 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 { + 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, + ], + }); +} diff --git a/M02_MasterTemplete/M02_Sangeun_Copy.py b/M02_MasterTemplete/M02_Sangeun_Copy.py index 26e4c9d91..61434f8b6 100644 --- a/M02_MasterTemplete/M02_Sangeun_Copy.py +++ b/M02_MasterTemplete/M02_Sangeun_Copy.py @@ -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: diff --git a/resources/tester/test_840_4_my_material.py b/resources/tester/test_840_4_my_material.py new file mode 100644 index 000000000..f81acc4e6 --- /dev/null +++ b/resources/tester/test_840_4_my_material.py @@ -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 diff --git a/resources/tester/test_m02_material_own.py b/resources/tester/test_m02_material_own.py index 82581f67f..a20d6b2a6 100644 --- a/resources/tester/test_m02_material_own.py +++ b/resources/tester/test_m02_material_own.py @@ -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)["그룹"] == "재료" diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 05a6bfbc9..53d2d2ee4 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -236,6 +236,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 불러오기 — 안 이은 줄 · 일위대가표 띠 From 0b672ac9d8a27036e557d9ccb09a13953b3f44fa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 16:49:31 +0900 Subject: [PATCH 10/10] =?UTF-8?q?chore(840-5):=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EB=8C=80=EC=A1=B0=20=EB=AA=A9=EB=A1=9D=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=94=80=20=E2=80=94=20=EC=A1=B0=EC=A2=85=EC=9B=90=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=ED=99=98=EC=82=B0(1/8=20=C3=97=2016/12=20=C3=97=20?= =?UTF-8?q?25/20)=20=EB=B0=98=EC=98=81=20=EB=92=A4=20=EB=B9=8C=EB=8D=94=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/raw/요구_1104/_목록_802-2_변환대조.xlsx | Bin 14384 -> 15374 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/raw/요구_1104/_목록_802-2_변환대조.xlsx b/docs/raw/요구_1104/_목록_802-2_변환대조.xlsx index 49ffa570f78924a06d5011f67d9396dae4ba221d..660376eff2b672c308cbb85b5e5f9bab29c4fe6e 100644 GIT binary patch delta 13269 zcmZvD1yEhf679j=-Cct_BzSNqxO;FXI2(5-I6;F;aCdhJ5?q2i!6m>yx%bI`_kW72 zU3Gfx?lo(=`wUc1wPUOYin1IOG&TqXf(OxtIwYV}0^oUy;OU-z?`e>!`Kr1owmeVnE;1)$DP-y|FLtE;njdEskqs zvqkENLsZz%Y3N96nI_CWOxK!*yNM^O2h}rs>&rzOVw=&)|mp6e>&|~712B-{WwI~%tOsQ+T*Nv2& z|M>Q^S+~M9*jg%2o5jjx$AYU^GLDw7H=ooWsr ziwL`$t0I>LucpLFV?kNaAX`v17AOe$D8~e( zrjm}#5#`XJni&&djxx&c<@xM%_aPkk;UV$dgrjP{lyeGkp~P@rI-s3W}0 zAR!x?u?4%f$p>pCrmX}aq{Dia<25<~q$mgp}i(~U2q zEapRv(Mv4dJ5fJ@Q8Xx!Mtci_Njxb@?4=fdn~!G{0pKX_9nu71Tq$+3E`E>35Y^H) znxvPZd4Bw=5&}g)$~_75?hAKr=|ydioEO6DfE+7LG|ryqXEw}NURe)XntrJ}!7J|` zCC?|VIeoPwxN-g#*$dp|*0;^N*AvSI*+IUKk`esJ^TPojV)Oj6>30g{t999GEDhhA~Uk{`98_EL$4zJIq`CZ$!c zY!pli?$@x}Hm{EBXr8Kd`Y@&# z?QR|4v9`pphG3fNl@k+`VsaeR$|DY%uE0{r(I~tVJStOzo8{^7nH%vq5XgJ20L;o` z$FPe&4t=Qm7UN>3&L7_4ld`vRQF6+N#JEzF^t@OiG~?VPFWmiv1I^N%bH6^k?O{xJ z#%NP$u=;cCQ#zy+wQdW$qS#`N;iYI}<%e2gVWoVl;X_E))4E4Gz|%Xu0e?5h8sO6X z!1^JZR<{K!Y~fw$_D9cFbOe905CB8Hbe?_YEML9FDRj(kio4^@bluFt#^U>;){Y)z zTvsP=aq@wI{PE}zC-0>_?6$}OvB2)Gea=i?YAFi-NV=h~B0T(i`~^|{{2=3OSaLQQ zEUY%MB)E*BF!c+D5|(EHeMjSQdD@PVDIT~0pg z&G^@$mi6Maw7&FmR205z531&#AEY02)w6o|hC5V@gix@Of}+Pau_21XHQ|@7PLUvbuKgnh3?AgfFL>5P z+6muD2?_2YKMaCQ-tI)2=xfhHu@>>jBDK5=JxDqzHTZmZsWzr^WnK~|M|!9 zIqP}X>G_en(3-GMhhN3()5X~CodbaObR5TVaC}NL!Qu39^SF1ml=ulfI(>KE|1L5Bve&B>CA6DY zmZt)r_J`K4CqEaU$$JrK^}1EsnG#ME;Z|MbK<0RS&I+w*l5 zGFCv_>%}7RcI(SNaCd&&)HIQI^!{ar^hW!&P$Boh-Tk_2_~_W$uk)o7a0i|?c*)3w z4F%uioC=)@>%V$E-8VgLyqIrj^glsw7z!J7c|JS7uAJqSUQZmOO^{8v|Hx@;u@Shw z8wz{8$$X3=-O&3Tfb$je8^H3?9bq(I{v$Qcjbp+)m27#j$&raJeb|6|$Oy-6pi|tW zE--<%(d6dtt;t3~O>mou-}|EqXTP*<^R?nm?Rp1X7}U$D^V;b6$kq(#fjPr*_&4f# z(cCc>pLt$629JxlC(4~4tu61$cY z$nMd`xe*DF*?zWK?7=SFjWG-#2fHf0XO zngM~!#QCPeTTFdKU7JU6Q%!k^ShY;Nu06zQoit<5To*KE7FVnKoLSC-_pbQzHJLDl zrCN}jPu}C>r$`+6=N^Ds5L@9>ZAH6pfQ903dj(|ce2Ig0I@efqfpShK48lk&XPh$c zV1{gtWu73Md{&JjMGkE~(Yah>&W(bM`B8K-CVInJXn_-gw-cs)Gy3~=ojy;1skvzttO+iFgL zT-lcaneNuOt1Ay+f?GITQf}IPda$`Yd#SzqipNW0ke(PS9!*-d2F`F1r@j9`asK{? ztsXkCYa!!wT_5Y0VI;Sv)pu1+H3UIu%t^KHa*h3A*(Hf_du|@Do zC2Ztp%azL6;uXXPNg^`1+XajZJn?8DmwSb(p;@ZA-tqZODv_1RY`M$;(-no!;TH%6~7FrSEe!LNL9VZMNZ_b%ZQhdth;@mo!4AG z%QBx-Cuz}|#$uUrLlDN77h?4tg`6gsA?XJqKVbUEMNtP)g;V`gs?CKpA`@$&#%PLg36!niZ^f&_K@q}i|)reDNb5Cq|K1fIn zHMIw_;3I1i`KPl`lizA%bv0Eqc7ayQ)N9JCdth>am`N3E7#aRXDZusWnD$Hh9? zF@PI0BbK~6Y)veDIJ+^)Y&>(tehbzygutOC+s?yGwO`rplGcw}mL6Aq^yBcacvuTt zT*u0J$Y%MzK8xXJ-^0>My%%JMKPFZKkT$*2CD=-Ny~kQN)1S?fTQ}4@iI##J^$V>v z^RjIDlbU?_u~OKjc8-d4O{Ut1&PYkz;Q`l16SxM-&qlgGAU(<@%mPb?xgA21n$kQL z1>PLTnon>NW~*NYcqMglxTLs28RW_f(XT~~uq14er0{KPKgBap-Dr92p+u$Y%1NU5@uBI9vVLSkLfiC2fRN=RUn+LZVscZ|b&4NC9FI zib#T(dd>Puw4v$j;|g4l7-tkaHh0wgy#!#CH7Ln*4^;TvCYh`G3;$wAUd-0RX06y0 zA-ttl97LXlIITbj8dapm6=}#4*vDPW`d$kzV^gu_M#Rzhv%c+~Vf+ zAqxYE((k^P64O73indAfv)$NzD(}_sw87IfF;Mw@G)R3c6hCWk8Bt))sI9HflFd`j zAVx2$VpH>e=TwvJ?MRR{V((l+ZI!Ve^ZlqSOgT&KQ>=x$OjuaW|e2s(+=c%POo^k_H38Fx#+obt{fdPpP4~wtGHVV&3H|>Q_7lp0( zqAGly((LlMloKbJV+QH^4NciB*XOD$E);{_d4xBX4f3NO4(-usv^8WK&`>yY-$@j?2`=yCNX>0-o^ z?73aFL83MBPVv}d0o~X%jn0|k10(VL4d_-;T7==?>}+0Fy91rybG9eAp>44NA2|0k zV=m^93*Vm3>2>7;EbXl!q0gI%w~-aTCLii5XTune`Bhb1m2K@5D&KWm$+SF@PmGTq zq3wk5_dlOmUwW#dMcrmJ)2X{NSM~8%t-1 zBikqrCV|p|J=dsAFcbGSTSi+}`$lq&w!1cV zkSnFMGx+yat0wR}L;`*yVS@&ZXm7&tgTC^6oE@q$f3G@DJX3e;n3!++amVBNI%z+5 z$S}8h&aV`NRN@hvp>-WuM&HVsaQa+J~v75JdxD#w<5W;cIBOlbFmmiv6> z6LW-A7p4aJ!2&uj>rEtLC@5hY1Zm{n8Yz=U}diSdFs0! z-M-TR`MDw9^{KzxisFa)U*3( zO%HKEz)DCGx#CGa*>0PSInBLp%J2Z6srNM+Cs6D@4f~Fx8Y!Etin!a=H*ZA(aVZ3> z(q+o_S>S!)t@ow4t6}vQO>lyencai+J?wnTAFDFQuxHOBaP^VFMnhrcIfoUZRL8p(|>gc4+N%wMm+)b{HySTl<09ZfJ^Jfo_EdH z)@tWv=_z!Vu-}tAe88@gO-IljN8NB(l%X7$Ww^y7UQe3ybnpvI5INxc?-{<()t%Qe zqV-3^S6T~Gom`fzICb>N;M#S%vuNar}5oR&pT_tSxcG~X-gVU|R=2c5}PH>Dr=RSfe6dIpy<)1sq_}F+KtGVb-5B(R2n639#X19Efo2&{d z$$YvWw~8h(7vf+nIe&m-cl_$LXcQy?{?6gZ9=Hm8`(-BM0WE(Xa7^&qthN^aew!I< z_3z}(>=q=M=Gc~yyP#QWNw#wl&m?J%TY^B2j3x>IOJzQYlsGL&GFfGtNwnPA?R$ds zC!yijW+5s$bv_55A3FN|Nkjvfvu$-y5M_bXK{kMD`DLb;+D9qus(hs=>=wq6%n*c+ zO&}jZ3@bEs$mSC%@lK`R;hK(Q|k~pC9_;5;7}VEY5BrBQ+7^-*}uA36Ixtcn760?yJ{JVCB(xY*Fz+lV<$39*B#mBe{kaDI}Erf>k~`*v3Nwzv>FwBuOcx8hY{|61$okiMdl& z@Uu57Q4{~=XQIC3@k0_afCvH9-Y4d=;~|A9B~>)ob!E)w+zn%_N(VI+RWBraP;dr5 zvW9l?Ai}TGgSyFZ6RE<46YoL4OJ}Wuh#h6JlsnmeD^)TP0WZt&Bm$N9c9e+9C zr`cgt&R<*?`jGU{@LI55#JxP~FT$|yyf1@3nGh@9kH<+Ps`Qa#zZ7evpa7Mec+Hc@ z-GKDRSlky!FyC*h2WaZ59Y(6mC@+5`?8J48f>8Mp^PH!?KVw3sQs8uwYJUA$!h}1sd-+eszG7XenTGqtT&+^?O@!s~CtDt?xmNR|rFRQKjHn46Ya^l8} z=ZggEHllYRsM%H-x4jeCbdR(Of{XfUekL-3wYK3u0?PNem^f!hHmeh{>4d3z!0jD@ ze21@$;`j3Je1DQh)@AbHh-4Ybd_wFmYY2A%Crkf~fJ?yfILHZ|;PN^m$a3EP z=Nul6{T_6A%Lldvvl;-^O|gT&Y>E72vkvQE$|e>W$QOsPNB!}q8ISF#gUx2b}! zhx)uPfcKqAHqe6Zq}zOM01-#Bu#-@auBh{_C7`W~Q>jRhD680d>3GCS-ix_7rDH#Y*Nt%$ED*o4I0k#4xxBxj?k4STc= z8GMwZk)Kdh7baGa8{X=-8248a6Z0vZYk5PhHPSWa<8ga5>YG4vlwFEZz-D9jQER^( z;F+flET8$dm1lS$G;Q3dZcu|RhUY|+h`D? zF>Pi&J+yj0!_MyrnXgb%q8R$gV*A?d3KVu+j7|j@A{65`Nw^`ovRolqncd_he29_v zW!M5!KbaBLmmSr%LaBH|x^s8vp*8XufcII%Vmgl4_zCJVuHY{^rtj+oh_B2B?x=5)shY3|WVC;I1?b z-&j=Y8PT+n;TXeaoxLLcRFe=aa64nsdP}9=Oom`2U_vJod^?3GPfISF{n#r(S>%s zfNGYgIWRmTM{7*lEph1#epBGJ zTMCIr)xLD%*)PC6l?_s`cr=og*=rlv>t}T9S+o5ZDuR`w>DMcPj&8ATUVDe)u2mV; z%{`f-t0aQD2MgybZ|0~bMjR~b=u~Y;679&g`dW{=exj4IySV^h#RrXV=Ee;m#ma^U zDI-i?k?$TInM33C3Cg~ic%IBi+1**l6t#B_$`mSM;$x(+7o%yOihIW@>CCzMp?-nC zM}4+-b=8Y=Si#tFc1RosOhlmsnvT~~beTKB8jYB?geewdYuyXQPDPLJy^U^4xo(`K zOCuZKv1wRf0VeR1Ne!}F`#~-tOPPD|D+A)bZS%K}-0vtGn8%hXr3(0U3A1jTczRS2 zoN%vs4o_LMeym4*0t9&9YC11g6lDrCyiLp04U^=k<48L+ioE?PD$*a!g@!OGqI=R07~yB?>esXcUsxV|m%!JjxcqP=lq{Eew6}lhh;R*P4mjn+Z zc=??~&9m>w4~%l`w%S@a+cOi&DfV59x62C~sfq1CD+t>=ml?YzaT2gb!ClUY29C5e zSTgaW{6PEV{OqrSkbvd-jNfHAv!(*SdCNiYV^n&W00wT>>3D)14TTO2x&(uMp}SsM zk{TRrU2gAVOsW_9VN|--low1M7Y6wW92WaaQWcQFh+oPGXD$y1ryu_y z)Vl;V766mqe?dH^N>cCs1yLMS+L(j2Q89~0S*|g!Brvks$K(t!b}cZF@tFV@n;3rSZgOV@Ik6_0BU z{r2HS_M97whm)k~cb|^u_0lD)KmtZgJ~$ZE;r2DLOiJ7J!Ao^SJA%cSgNVLce(STcj$&7Qp;celx&MZ~jDIn|rs2%NL@XoN?! z4yVCxRKD@IM0k}F50#ln%Il7Ey9TiW;My1sif#QxB>9Kmm)k07n*<-5?W_|XJ?EBG zxxe|zvA7a30u}sLfo|1kZziF2T~c$%kk9pmHov@CyMp@ZSoVIvibO!z%$z2U#*$`({!;HYh>gQDoBYfZlEM6vRFmqqwzt!PxLd9aq}a=& zji|Ty4E_V=)!hXoluo*IU;=rlo|nSB{8?<%(xr-EpBWEp8DE4heUQV z?vv%Lwv%lsB;AmH6byDsLpS*n&U!A@;Fgc>;R8$Q{T~Ha*jHEjPxNi%|I1XdvV8 zWvHLQ6j`-Up#)R&wptKB{sco4&-oLKLYq^q7S=dicEPC^UGd?tfkK^kaiR{@D4lm9 zV7u>cKf@)+$tt4w`5e}NLWLR+GhDyJKJNZye2}#I$i{Uo6nf+fX41h5DB|}r^tq1? z+a46Yt%Uhkh&SwN8j!}}_9N(Y$XlPcbg`jB4HAcR*sf*leenqi;l;Vp(L;|d$?L!j z2tp~GRb#cee#!o^#tywpA zje(K*^3oGQneEUg-~Q>FGIR?Yr|=8PJYI`2eqH3x|Hm&SGYZ}!GvXh)05$}i5F$pz zJ5`lGtocMGO2eeTeBS-F$`-9H@%NhkV?8LyAKnk0_;nSHw#q+LQRYF@B*cWG9^zff zXr$I(-LIBRH)KP$X{N`LqB>;9VS{U<=XT#7U(PjWoo}J8o~ctriC~mF;N|WEqRSg{ z6p!120=%MaA{KP{j`%=lhEoXK7e&N|rVym{MKDo9LX1iAADbzrE+9N%Ma=as$C*m9 z$Ty<3eEr=H?%5Q?Ix#|hs9<-9c;$OaT#X|?@6-uE98b>}AN^S{_YMg=`8kL!Ms7VF zrs%^?&elAVR#+_BEcFhV@vyLo$P0uKVd`&y6G@(qxt2h0?O#qwF+<-`!|qUkikwEo z>$+V7eYi|+pceAB`?wGur138k@Z;k^X@fLm6ObQrBm}B4u#TsH8D|8yQzLuIAP>ud zjOKAuEy@R_cw<>G%H)-Oa`FV1=8e7+)+|QPnNr>XQc0Qb-|) z$6`V`V}oX!cS^L4j_Tmy`45Mf1U$j&%A?H4ctj`~sr9mbsg<_$aD)Au363m2Gtox79D#VAv04q#J zHRBTHkW@q%NgCu?2FAIIBlJI(|DOTl|6@R4oC}|)`CD3W1m!$LRvg4=S^Od)#<}}> zI<28tv=wT#(q834L{{}bhQ>6;hP@-~il!krk2%0=VfSw8>3RF*d?^-@^YpcM;Iu-k=Qu0;z)sd+@=dJ|4EL?hej2E|%s=dPoRKB1~|A zzEUq6_VXJB8IP}-#{SjJ7|}CuSdc0?W1pJ1uT2~AieJ}1b&JXSEI$kEXS=l3bKV{h zf~YrSty5=UYxFtxB4M%Jv1S8rzl>@P%oI0TR5f&uk$$Cai^?bXpl6v;>}zLpHJ7D~ zh@vaED#Kt8ERZXt&ZHS2y;b4GM`Th0bPf7QFDln`a%ut!u@URBB(adxCCPs7w&t9} zt;4vU(bD#e^rV?e8;nLvilR%?v#~k8J_Mpj6NI-%Pa)BfXuxjT`IrMo9p0>PXGW$w zVe~a42Z4#0jDCsDoNJtVzo~<|rDESHRUU?;2wl~~i!Pq-2czCC72LRO;Ta17qze1L zo^82FX@1#yb;%NVvF8m%B7J%m=7~tOtU)9mas351)*f2^t**rJLet(Rk0e3abJCJS ziN59|);_etitu6&IpKU*4B%dE+;4j#CgzhEBJP~=iaVy7`R+3YOrWzs8 z)2=FPmApbkkt(k+B~`_chLwAL(>qpToKir>9OGs=ZW?@X&{L{rEo+o4(PuyFbRNR# ztqe(rzaeV-*|3Vl%+dLGtDc}4C&fGR;ht8><~D%>t0tBF8xhA#*TLc@{n*?)ji701 zKcF@=jMzR}1=J*!H^&)!AmzRP^u2cRcnEf7ym~GZ9wElW6$|J3wYcfvDQB%#u~X;O z+}~P$8bDo$qNCICM0hOhh!YM$jaZe)sX$IO&Ga4m@}Umt6aLrB@6pTJJTYwN)=+oZ zWJaauzxy5}>wT18%pseWm;kI8Vpr}jUO;c{;D@^3I3JstiYJ1#zH|Mg%87lvj=F?Oikj51K)R^HO(oN>)LY5jP-I3_$;UQ>+1VoAoo&Hfi^rKz<>c>~(&~Wh z9btm&M!bspq16Yccu|FbWs>Ne*r2z`d)P3F$)hn|K~`VO=%L-&I||HYT#NhP3X&Z$ zGenljQiT_C(jy}FG$lxuAhW?CGDKdG_*@B&I&75syc9P*{qLN#erjYJaW^>eu)s-( z1b!Mio7=juvH*V`UlLpF!L!jKz`%4i8{9NSIH&n9T<8Y~jeN94OqfWCFJ0f33!UPT z2s{PfVXl%|iJbC%ZHy|#e@0ie(wT3DkZz=jvzkm0tD0jXa$&sx-8u;KfwMnZTAnn?K>-XT@uT*#p-X%-c>p0(2S8#lX4KQ;Rgart{Gyy9 zPh2t0g-@ZkRAxMm+r2Yn7s_%{UVSU4e=ZYkQLQ!_yL1jA?QV%(nI%Q!K!_Lp6JH$3 z;IP~pqxQxNZYzl*E=;MYgf6ZNU4QnPP)TtES?2{Zv<*q~2wgi;Cj__UGaG<f!}Da9)0d0hb|ok-Fjz_O9mku7+x!j^-}(kpdG&%r-7-TR?ayCvkU02t-@Sje1p2 zi-@Lbq7tM|?*kf27?mmy2eu_))w!&poY`|W#(LjEN)fmb9$G&@CodDqX6vp?p6X{y zvZc|+iJJ_Ke!vNw389P~`|_qsr4~xm(HzJLYCz@N4pz&iaZSj92y6X8(qAGcy}u^RhnI-pnV8J<}8(lP63o4%51pP})QUo1x-<<_VS1JLN^Pa$? z|A|)cn3I%T>k(5m`up$OQtvRdy)GYeUD|>#Q*BAl!{QXq;YR(^g|@$u=3KL$m^!_o z@m)2L0nc?V4z=A)7#cp?<7im9?~J|p>ia?}%RxY5gZ^_5B#D6g?O*pB{(BK5$%LDo z?9Y{lbiFJpbXX85i|%jT!P5Wr;}0>JRLf0C{=e5E{yr)F8`Bto2A`yigp-88gYg&e zKXcM>{{(U(gMoxh{~-2{+1G#j1%Vm^@=?K)u(W@9{pUgL->}AjQ`W!2i!i|?ZSo-h z1^(w*;@@C@2p{$&WL_feKMVS2zyB}jp7TH5|4;M(C*OZ|&;P!-#g*jGOY#?m|I{hC o#S;D-0e<>Jr12zm@Y4SU`e(lX8|M!Z&zl6pM+1An_vfSk1Ft^6^#A|> delta 12239 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