From 98f37c7c256788b49549fb782ba888311d5326b4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 13 Nov 2026 10:15:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(827-1):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EA=B8=B0=EA=B3=84=20=EC=B0=BD=20=E2=80=94=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=C2=B7=20=EB=B0=94=EA=BE=B8=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EA=B3=A0=EC=B3=90=20=EC=93=B0=EA=B8=B0(=EC=B4=88?= =?UTF-8?q?=EC=95=88=20=E2=86=92=20[=EC=A0=80=EC=9E=A5]=20=EB=95=8C=20?= =?UTF-8?q?=EB=82=B4=20=EA=B8=B0=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 줄 쌓기 기계 줄 이름 단추 = 기계 창(왼쪽 찾기 · 오른쪽 공통 기계 양식 읽기 전용 · 지금 줄 = 사본 값 · 새 값 표시) - [이 기계로 바꾸기] = repick 그대로(823-1 · 되돌리기) · M01 목록도 시간당(machine-rows) - [고쳐 쓰기](개인 층 SK) = 서버 확인(쓰기 없음) → 문서 칸 내기계초안 · 줄 입력 개인 EQP초안-N - [저장] 한 번에 서버가 초안 만들기 · 고침 → 키 바꿈 → 사본 박기 · 실패 = 만든 파일 지움 · 고친 줄 되돌림 - 미리 셈 = 초안을 그 요청 안만 셈 색인에 얹음 · 브라우저 = 셈 꼴 값을 캐시에 - 회사 · 마스터 층 · M01 기초 = 조회 · 바꾸기만(까닭 한 줄) · 옵션 가로 곁 [조회] - 걷음: 줄 옆 「복사해 내 기계로」(line_stack_mine.ts) · 찾기 창 [새 기계 만들기] · 개인관리 › 기계 만들기 · 고치기(보기 · 쓰는 곳 · 지우기만) - 내 기계 쓰는 곳 = 수식 길 사본도 셈 - 시험 test_827_1_machine_window(8) Co-Authored-By: Claude Opus 5.5 --- A00_Common/formula_doc_options.ts | 16 + A00_Common/line_stack.css | 21 + A00_Common/line_stack_edit.ts | 46 +- A00_Common/line_stack_machine.ts | 528 ++++++++++++++++++ A00_Common/line_stack_mine.ts | 61 -- A00_Common/line_stack_screen.ts | 24 +- M02_MasterTemplete/M02_Doc_Router.py | 12 + M02_MasterTemplete/M02_Doc_Sk.py | 3 + M02_MasterTemplete/M02_Doc_Sk_Path.py | 23 +- M02_MasterTemplete/M02_Logic_Lib_Read.py | 6 + M02_MasterTemplete/M02_Machine_Hourly.py | 40 ++ .../M02_MasterTemplete_Find_Pick.ts | 21 +- M02_MasterTemplete/M02_MyMachine.py | 30 +- M02_MasterTemplete/M02_MyMachine_Api.ts | 34 ++ M02_MasterTemplete/M02_MyMachine_Draft.py | 184 ++++++ M02_MasterTemplete/M02_MyMachine_Form.ts | 75 +-- M02_MasterTemplete/M02_MyMachine_Router.py | 15 +- M02_MasterTemplete/M02_MyMachine_Text.ts | 26 +- M02_MasterTemplete/M02_MyMachine_View.ts | 20 +- common_util/common_util_line_stack_draft.ts | 76 +++ resources/tester/test_827_1_machine_window.py | 226 ++++++++ 21 files changed, 1319 insertions(+), 168 deletions(-) create mode 100644 A00_Common/line_stack_machine.ts delete mode 100644 A00_Common/line_stack_mine.ts create mode 100644 M02_MasterTemplete/M02_MyMachine_Draft.py create mode 100644 common_util/common_util_line_stack_draft.ts create mode 100644 resources/tester/test_827_1_machine_window.py diff --git a/A00_Common/formula_doc_options.ts b/A00_Common/formula_doc_options.ts index a04ef7037..129dfd2c1 100644 --- a/A00_Common/formula_doc_options.ts +++ b/A00_Common/formula_doc_options.ts @@ -3,11 +3,14 @@ * 수식 문서 옵션 가로(PLAN 802-3 · 802-4b-2 · 요구 655 「옵션 선택은 상단에 수평」) — 선택 · 입력 기호마다 칸 하나 * 값 = 화면 값(`given`) › 문서 값 · 고침은 부른 쪽 `set`(M01 = 화면 값만 · M02 사본 = 문서 값) · `lock` = 읽기만 * 목록이 모두 기계 원문번호(`0101-0007`)면 글 = 「이름 규격 (원문번호)」(요구 692-2 · 값은 그대로 · 이름은 한 번 받아 둠) + * `view` 있음 + 기계 목록 = 곁에 [조회](827-1 · 기계 창 읽기 전용 · 고른 기계) * ========================================================================== */ +import { createButton } from "@ui/ui_template_button"; import { createInputField, createSelectField } from "@ui/ui_template_input"; import { varId, type DocVar, type FormulaDoc } from "../common_util/common_util_formula_doc"; import { cfMachineNames } from "../M02_MasterTemplete/DataFlow/M02_DF_Api"; +import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text"; const MACHINE = /^\d{4}-\d{4}$/; let names: Record = {}; @@ -31,6 +34,7 @@ export function buildOptions( given: Record, lock: boolean, set: (key: string, v: DocVar, value: unknown) => void, + view?: (code: string) => void, ): HTMLElement[] { const out: HTMLElement[] = []; for (const v of doc?.변수 ?? []) { @@ -54,6 +58,18 @@ export function buildOptions( f.root.dataset.option = k; nameMachines(list, () => f.setOptions(items(), f.select.value)); out.push(f.root); + const codes = list.filter(Boolean); + if (view && codes.length && codes.every((x) => MACHINE.test(x))) { + const b = createButton({ + label: mt("Win_View"), + variant: "ghost", + size: 28, + iconStart: "search", + onClick: () => view(f.select.value), + }); + b.dataset.optionView = k; + out.push(b); + } } else { const f = createInputField({ label, diff --git a/A00_Common/line_stack.css b/A00_Common/line_stack.css index ab78bcaa0..ae4260662 100644 --- a/A00_Common/line_stack.css +++ b/A00_Common/line_stack.css @@ -452,3 +452,24 @@ width: auto; min-width: 8em; } + +/* 827-1 기계 창 — 왼쪽 찾기 | 오른쪽 기계 양식 */ +.lstk-mwin { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); + gap: var(--spacing-12); + align-items: start; + min-height: 0; +} +.lstk-mwin__list, +.lstk-mwin__form { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} +@media (max-width: 900px) { + .lstk-mwin { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts index 224846c8a..a7b797214 100644 --- a/A00_Common/line_stack_edit.ts +++ b/A00_Common/line_stack_edit.ts @@ -36,8 +36,15 @@ import { type StackItem, } from "../common_util/common_util_line_stack"; import { placeNames } from "../common_util/common_util_line_stack_copy"; +import { + draftsOf, + machineNow, + pruneDrafts, + putDraft, +} from "../common_util/common_util_line_stack_draft"; import { addPicked, + type Picked, aliasText, repick, shownUnit, @@ -47,7 +54,7 @@ import { import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick"; import { reach } from "./formula_doc_render"; import { attachDrag } from "./line_stack_drag"; -import { toMineButton } from "./line_stack_mine"; +import { openMachineWindow, type MachineCtx } from "./line_stack_machine"; import { openStackPick, type StackTake } from "./line_stack_pick"; import { buildBlock, sourceTag } from "./line_stack_rows"; import { lt, type LsKey } from "./line_stack_text"; @@ -69,6 +76,10 @@ export interface EditHooks { take?: StackTake; /** 저장본 식 줄 {기호: 식} — 만약 식을 고쳐 굳은 줄 표지(요구 696) */ before?: ReadonlyMap; + /** 827-1 기계 줄 = 기계 창(조회 · 바꾸기 · 고쳐 쓰기) — 층 · 권한 · 사본 줄 */ + machine?: MachineCtx; + /** 827-1 초안 기계 셈 꼴 값 → 계산기 캐시(브라우저 즉시 셈 · 서버 미리 셈과 같은 값) */ + prime?: (no: string, values: Record) => void; } const field = ( @@ -157,6 +168,12 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] { : ""; // 가져온 기계 줄(812-9) — 찾기 판정 · 조회어 = 기종 이름만(규격 뺌 · 이은 우리 기계 › 정규화 이름) const found = kind === "기계" ? machineFound(d, own) : undefined; + const query = found?.찾기어 ?? String(mat?.검색어 ?? line.이름 ?? ""); + const swap = (p: Picked): void => + h.change((doc) => { + repick(doc, i, own, kind, p); + pruneDrafts(doc); // 827-1 안 쓰게 된 초안 뺌 + }); const pick = createButton({ label: (size && !name.includes(size) ? `${name} ${size}` : name) || "—", variant: "ghost", @@ -164,12 +181,19 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] { size: 24, iconStart: "search", onClick: () => - openStackPick( - kind, - (p) => h.change((doc) => repick(doc, i, own, kind, p)), - kind === "기계" ? h.take : undefined, // 821-3 개인 층 = 내 기계 묶음도 - found?.찾기어 ?? String(mat?.검색어 ?? line.이름 ?? ""), - ), + kind === "기계" && h.machine // 827-1 기계 창 + ? openMachineWindow({ + ctx: h.machine, + drafts: draftsOf(d), + now: machineNow(d, link as { 갈래?: string; 입력?: Record }), + query, + pick: swap, + draft: (item, p, values) => { + h.prime?.(`개인 ${item.표지}`, values); + h.change((doc) => putDraft(doc, i, own, item, p)); + }, + }) + : openStackPick(kind, swap, undefined, query), }); pick.classList.add("lstk-res"); pick.title = `${lt("Repick")}${res ? ` — ${res}` : ""}`; @@ -195,14 +219,6 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] { back.dataset.unswap = String(i); head.push(tag, back); } - // 821-3 품셈 기계 줄 → 복사해 내 기계로(개인 층 SK) - const mine = - kind === "기계" - ? toMineButton(h.take, link as { 갈래?: string; 입력?: Record }, (p) => - h.change((doc) => repick(doc, i, own, kind, p)), - ) - : null; - if (mine) head.push(mine); } else head.push( field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "m"), diff --git a/A00_Common/line_stack_machine.ts b/A00_Common/line_stack_machine.ts new file mode 100644 index 000000000..4605e1ef7 --- /dev/null +++ b/A00_Common/line_stack_machine.ts @@ -0,0 +1,528 @@ +/* ============================================================================= + * line_stack_machine.ts + * 줄 쌓기 기계 창(PLAN 827-1 · 요구 810 · 811) — 기계 줄 이름 단추 · 옵션 가로 [조회]가 띄움. + * 왼쪽 = 기계 찾기 — 맨 위 「지금 이 줄」 · 이 단가산출 초안 · 개인 층 내 기계 · M01 기계(줄마다 시간당) + * 오른쪽 = 공통 기계 양식(`ui_template_machine_form` · 읽기 전용) — 지금 줄 = 이 단가산출 사본에 박힌 값 + * (원본 지금 값과 다르면 「새 값 있음」) · 고른 줄 = 그 기계 지금 값 + * [이 기계로 바꾸기] = `pick`(부른 쪽 `repick` · 가져온 식 823-1 규칙 그대로) + * [고쳐 쓰기](개인 층 · 쓰기 권한 · `ctx.canEdit`) = 양식 편집 → [확인] = 서버 확인(쓰기 없음) → `draft` + * (부른 쪽이 문서 칸 `내기계초안` 에 · 디스크는 단가산출 [저장] 때 서버) · 그 밖 = 까닭 한 줄 + * M01 기계에서 시작 = 새 내 기계(본뜬 = 그 키) · 내 기계 = 그 기계 고침 · 초안 = 그 초안 고침 · 즉시 저장 없음 + * ========================================================================== */ + +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 { createMachineForm, HOURLY_KEY, type MachineValues } from "@ui/ui_template_machine_form"; +import { openModal } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import { isDraftNo, newTag, type DraftItem } from "../common_util/common_util_line_stack_draft"; +import type { Picked } from "../common_util/common_util_line_stack_pick"; +import { call, type Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { + checkDraft, + fetchMyMachineLists, + listMyMachines, + machineArg, + machineRows, + readMyMachine, + type MachineBrief, + type MyHourly, + type MyMachineRow, +} from "../M02_MasterTemplete/M02_MyMachine_Api"; +import { bodyOfForm, hourlyText, valuesOf } from "../M02_MasterTemplete/M02_MyMachine_Form"; +import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text"; + +/** 사본 줄(서버 `copy_row`) — 키 · 값 · 방식(내 기계) · 시간당(직접) */ +interface CopyRowData { + 키: string; + 원문번호?: string; + 이름?: string; + 규격?: string; + 값?: MachineValues; + 방식?: string; + 시간당?: Record; +} +export interface CopyAnswer { + 사본: CopyRowData | null; + 지금: CopyRowData | null; + 새값: boolean; +} + +export interface MachineCtx { + /** 층 · 주인 — null = M01 단가산출 기초(M01 기계만) */ + scope: { layer: Layer; owner: string | null } | null; + /** [고쳐 쓰기] 됨(개인 층 SK · 쓰기 권한) */ + canEdit: boolean; + /** 못 고칠 까닭(M01 · 회사 · 마스터 층) */ + why: string; + /** 이 SK 키(쓰는 곳 셈에서 뺌) */ + me?: string; + /** 이 SK 사본에 박힌 기계 줄(키 있는 M02 SK) */ + copyRow?: (no: string) => Promise; +} + +export interface MachineWindowOptions { + ctx: MachineCtx; + drafts: DraftItem[]; + /** 지금 줄 기계 입력 글(따옴표 뗌 · 원문번호 · 개인 EQP… · 개인 EQP초안-N) · 없음 = null */ + now: string | null; + query: string; + /** 없으면 보기만(옵션 가로 조회) */ + pick?: (p: Picked) => void; + draft?: (d: DraftItem, p: Picked, values: Record) => void; +} + +type Kind = "now" | "draft" | "mine" | "m01"; +interface Cand { + kind: Kind; + /** 기계 입력 글 */ + no: string; + 이름: string; + 규격: string; + 계: number | null; + 구분: string; + draft?: DraftItem; + mine?: MyMachineRow; + /** M01 키(EQ…) */ + key?: string; +} +interface Shown { + values: MachineValues; + name: string; + spec: string; + lines: HTMLElement[]; + logic?: string | null; + /** [고쳐 쓰기] 대상 */ + target: { 표지?: string; 키?: string; 본뜬?: string }; + /** 내 기계 = 쓰는 곳 셈 키 */ + mineKey?: string; +} + +const kindOf = (no: string): Kind => + isDraftNo(no) ? "draft" : no.startsWith("개인 ") ? "mine" : "m01"; + +/** 사본 · 원본 줄 → 양식 값 — 직접 = 시간당 · 바로(셈 꼴) = 계수 셋 · 빈 연료 · 조종원 0 뺌 */ +function formValues(row: CopyRowData): MachineValues { + if (row.방식 === "직접") { + const v = row.시간당 ?? row.값 ?? {}; + return { [HOURLY_KEY]: { 노무비: v.노무비, 재료비: v.재료비, 경비: v.경비 } }; + } + const v: MachineValues = { ...(row.값 ?? {}) }; + if (row.방식 === "바로") { + for (const k of ["상각비계수", "정비비계수", "관리비계수"]) delete v[k]; + for (const k of ["주연료", "잡재료율", "조종원"]) if (v[k] === 0) delete v[k]; + for (const k of ["주연료종류", "조종원직종"]) if (v[k] === "") delete v[k]; + } + return v; +} +/** 내 기계 목록 줄 → 양식 값(직접 = 서버 시간당 노 · 재 · 경) */ +const mineValues = (m: MyMachineRow): MachineValues => + valuesOf({ + 방식: m.방식, + 값: m.값, + 시간당: { + 노무비: m.시간당.노무비 ?? 0, + 재료비: m.시간당.재료비 ?? 0, + 경비: m.시간당.경비 ?? 0, + }, + }); +const hourOf = (h: MyHourly | undefined): number | null => (h?.ok ? (h.계 ?? null) : null); +const same = (a: Cand | undefined, b: Cand | null): boolean => + !!a && !!b && a.kind === b.kind && a.no === b.no && a.key === b.key; + +export function openMachineWindow(o: MachineWindowOptions): void { + const { ctx } = o; + const owner = ctx.scope?.owner ?? null; + const personal = ctx.scope?.layer === "personal"; + let mines: MyMachineRow[] = []; + let m01: { ref: string; 이름: string; 규격?: string | null; 구분?: string | null }[] = []; + const briefs = new Map(); + let cands: Cand[] = []; + let sel: Cand | null = null; + let editing = false; + let formDirty = (): boolean => false; + let seq = 0; + let showSeq = 0; + let timer = 0; + let close = (): void => {}; + + const search = createInputField({ type: "search", placeholder: mt("Find") }); + search.input.value = o.query; + const hint = createText(""); + const right = el("div", { className: "lstk-mwin__form", attrs: { "data-mwin-form": "" } }); + const table = createDataTable({ + caption: mt("Win_Title"), + columns: [ + { key: "품명", label: mt("Name"), width: 170 }, + { key: "규격", label: mt("Spec"), width: 100 }, + { key: "금액", label: mt("Hourly"), kind: "number", decimals: 0, width: 90 }, + { key: "구분", label: mt("How"), width: 120 }, + ], + rows: [], + rowClass: (i) => (same(cands[i], sel) ? "is-title" : null), + onRowClick: (i) => { + if (cands[i]) void choose(cands[i]); + }, + }); + + /* --- 목록 --- */ + const nowCand = (): Cand | null => { + if (!o.now) return null; + const d = o.drafts.find((x) => `개인 ${x.표지}` === o.now); + const m = mines.find((x) => x.원문번호 === o.now); + const b = [...briefs.values()].find((x) => x.원문번호 === o.now); + const name = d?.줄.이름 ?? m?.이름 ?? b?.이름 ?? o.now; + return { + kind: "now", + no: o.now, + 이름: name, + 규격: d?.줄.규격 ?? m?.규격 ?? b?.규격 ?? "", + 계: d ? hourOf(d.시간당) : m ? hourOf(m.시간당) : hourOf(b?.시간당), + 구분: mt("Win_Now"), + }; + }; + const draw = (): void => { + const words = search.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean); + const hit = (t: string): boolean => words.every((w) => t.toLowerCase().includes(w)); + const now = nowCand(); + cands = [ + ...(now ? [now] : []), + ...o.drafts + .filter((d) => hit(`${d.줄.이름} ${d.줄.규격}`)) + .map((d): Cand => ({ + kind: "draft", + no: `개인 ${d.표지}`, + 이름: d.줄.이름, + 규격: d.줄.규격, + 계: hourOf(d.시간당), + 구분: mt("Win_Draft"), + draft: d, + })), + ...mines + .filter((r) => hit(`${r.이름} ${r.규격} ${r.키}`)) + .map((r): Cand => ({ + kind: "mine", + no: r.원문번호, + 이름: r.이름, + 규격: r.규격, + 계: hourOf(r.시간당), + 구분: `${mt("Pick_Group")} · ${mt(r.방식)}`, + mine: r, + })), + ...m01.map((x): Cand => ({ + kind: "m01", + no: briefs.get(x.ref)?.원문번호 ?? "", + 이름: x.이름, + 규격: x.규격 ?? "", + 계: hourOf(briefs.get(x.ref)?.시간당), + 구분: [mt("Win_M01"), x.구분].filter(Boolean).join(" · "), + key: x.ref, + })), + ]; + hint.textContent = `${cands.length}`; + table.setRows(cands.map((c) => ({ 품명: c.이름, 규격: c.규격, 금액: c.계, 구분: c.구분 }))); + void fill(); + }; + /** M01 줄 시간당 — 보이는 앞 40 줄만 뒤에서(색인 메모 · 없는 것만) */ + const fill = async (): Promise => { + const want = cands + .filter((c) => c.kind === "m01" && c.key && !briefs.has(c.key)) + .slice(0, 40) + .map((c) => c.key!); + if (!want.length) return; + const mine = seq; + const got = await machineRows(want).catch(() => []); + for (const b of got) briefs.set(b.키, b); + if (mine === seq && got.length) draw(); + }; + const find = async (): Promise => { + const mine = ++seq; + const q = search.input.value.trim(); + const got = await call<{ items: typeof m01 }>( + `/logic-lib/elements?group=${encodeURIComponent("기계")}&q=${encodeURIComponent(q)}&limit=200`, + ).catch(() => ({ items: [] })); + if (mine !== seq) return; + m01 = got.items; + draw(); + }; + search.input.addEventListener("input", () => { + draw(); + window.clearTimeout(timer); + timer = window.setTimeout(() => void find(), 250); + }); + + /* --- 오른쪽 --- */ + const briefOf = async (no: string, key?: string): Promise => { + const at = key ?? no; + const have = key ? briefs.get(key) : [...briefs.values()].find((b) => b.원문번호 === no); + if (have) return have; + const [got] = await machineRows([at]).catch(() => []); + if (got) briefs.set(got.키, got); + return got; + }; + + /** 고른 줄 → 양식 값 · 글 · 바꿀 연결 · 고칠 대상 */ + const resolve = async (c: Cand): Promise => { + const k = c.kind === "now" ? kindOf(c.no) : c.kind; + const lines: HTMLElement[] = []; + if (k === "draft") { + const d = c.draft ?? o.drafts.find((x) => `개인 ${x.표지}` === c.no); + if (!d) throw new Error(mt("Hourly_None")); + lines.push(createText(mt("Win_Draft"), "muted")); + if (d.시간당) + lines.push(createText(`${mt("Server_Hourly")} ${hourlyText(d.시간당 as MyHourly)}`)); + const target = { + 표지: d.표지, + ...(d.키 ? { 키: d.키 } : {}), + ...(d.본뜬 ? { 본뜬: d.본뜬 } : {}), + }; + return { + values: valuesOf({ + 방식: d.줄.방식 as never, + 값: (d.줄.값 ?? {}) as MachineValues, + 시간당: d.줄.시간당 as never, + }), + name: d.줄.이름, + spec: d.줄.규격, + lines, + logic: d.시간당?.로직, + target, + mineKey: d.키, + }; + } + // 지금 줄 = 이 단가산출 사본 값(있으면) + const copy = c.kind === "now" && ctx.copyRow ? await ctx.copyRow(c.no).catch(() => null) : null; + const row = copy?.사본 ?? null; + if (row) { + lines.push(createText(mt("Win_Copy"), "muted")); + if (copy?.새값) lines.push(createText(mt("Win_Newer"), "warning")); + } + if (k === "mine") { + const m = c.mine ?? mines.find((r) => r.원문번호 === c.no); + const key = m?.키 ?? c.no.replace(/^개인 /, ""); + const was = o.drafts.find((d) => d.키 === key); // 이미 고친 초안 = 그 초안을 다시 고침 + if (!row && m) lines.push(createText(`${mt("Server_Hourly")} ${hourlyText(m.시간당)}`)); + return { + values: row ? formValues(row) : m ? mineValues(m) : {}, + name: row?.이름 ?? m?.이름 ?? c.이름, + spec: row?.규격 ?? m?.규격 ?? c.규격, + lines, + logic: m?.시간당.로직, + target: { 키: key, ...(was ? { 표지: was.표지 } : {}) }, + mineKey: key, + }; + } + const b = await briefOf(c.no, c.key); + if (!row && b) lines.push(createText(`${mt("Win_Live")} · ${hourlyText(b.시간당)}`)); + if (!row && !b) throw new Error(mt("Hourly_None")); + return { + values: row ? formValues(row) : { ...(b?.값 ?? {}) }, + name: row?.이름 ?? b?.이름 ?? c.이름, + spec: row?.규격 ?? b?.규격 ?? c.규격, + lines, + logic: b?.시간당.ok ? b.시간당.로직 : null, + target: { 본뜬: row?.키 ?? b?.키 }, + }; + }; + + const pickedOf = (c: Cand, s: Shown): Picked | null => { + if (!s.logic) return null; + const no = c.kind === "mine" && c.mine ? machineArg(c.mine.원문번호) : `'${c.no}'`; + return { 키: s.logic, 이름: s.name, 입력: { 기계: no }, ...(s.spec ? { 규격: s.spec } : {}) }; + }; + + const choose = async (c: Cand): Promise => { + if (editing && formDirty() && !(await showConfirmDialog(mt("Leave_Ask")))) return; + editing = false; + sel = c; + table.setRows(cands.map((x) => ({ 품명: x.이름, 규격: x.규격, 금액: x.계, 구분: x.구분 }))); + await show(c); + }; + + async function show(c: Cand, edit = false, blank = false): Promise { + const mine = ++showSeq; + right.replaceChildren(createText(mt("Server_Hourly"), "muted")); + let s: Shown; + try { + s = blank ? { values: {}, name: "", spec: "", lines: [], target: {} } : await resolve(c); + } catch (error) { + if (mine === showSeq) + right.replaceChildren( + createText(error instanceof Error ? error.message : mt("Failed"), "error"), + ); + return; + } + const lists = await fetchMyMachineLists(); + if (mine !== showSeq) return; + editing = edit; + const notice = el("div", { className: "lstk-mwin__notice" }); + const form = createMachineForm({ + name: s.name, + spec: s.spec, + values: s.values, + coefMode: "direct", + allowHourly: true, + fuels: lists.fuels, + jobs: lists.jobs, + power: lists.power, + hour: lists.hour, + readOnly: !edit, + onChange: () => notice.replaceChildren(), + }); + formDirty = () => edit && form.state().changed; + const bar: HTMLElement[] = []; + if (!edit) { + const p = pickedOf(c, s); + if (o.pick && c.kind !== "now") { + const b = createButton({ + label: mt("Win_Swap"), + variant: "filled", + size: 28, + disabled: !p, + onClick: () => { + if (!p) return; + close(); + o.pick!(p); + }, + }); + b.dataset.mwinSwap = ""; + if (!p) b.title = mt("Pick_Direct"); + bar.push(b); + } + if (o.draft && ctx.canEdit) { + const b = createButton({ + label: mt("Win_Edit"), + variant: c.kind === "now" ? "filled" : "ghost", + size: 28, + iconStart: "pencil", + onClick: () => void show(c, true), + }); + b.dataset.mwinEdit = ""; + bar.push(b); + } else if (o.draft && ctx.why) bar.push(createText(ctx.why, "muted")); + } else { + const ok = createButton({ label: mt("Win_Ok"), variant: "filled", size: 28 }); + ok.dataset.mwinOk = ""; + ok.addEventListener("click", () => void confirm(form.state(), s, notice, ok)); + bar.push( + ok, + createButton({ + label: mt("Win_Cancel"), + variant: "ghost", + size: 28, + onClick: () => void (blank ? choose(c) : show(c)), + }), + ); + } + const extra: HTMLElement[] = []; + if (edit) extra.push(createText(mt("Win_Edit_Note"), "muted")); + if (edit && s.target.키) { + const uses = el("div"); + extra.push(uses); + void readMyMachine(owner, s.target.키) + .then((one) => { + const n = (one.쓰는곳 ?? []).filter((u) => u.키 !== ctx.me).length; + uses.replaceChildren(createText(mt("Win_Uses").replace("{value}", String(n)), "warning")); + }) + .catch(() => undefined); + } + right.replaceChildren( + el("div", { + className: "ui-toolbar", + children: [el("strong", { text: `${s.name} ${s.spec}`.trim() || mt("New") }), ...bar], + }), + ...s.lines, + ...extra, + notice, + form.root, + ); + right.dataset.mwinMode = edit ? "edit" : "view"; + } + + /** [확인] — 서버 확인(쓰기 없음) → 초안 · 줄 연결 */ + async function confirm( + state: ReturnType["state"]>, + s: Shown, + notice: HTMLElement, + btn: HTMLButtonElement, + ): Promise { + const { body, errors } = bodyOfForm(state); + notice.replaceChildren(...errors.map((e) => createText(e, "error"))); + if (!body) return; + btn.disabled = true; + try { + const got = await checkDraft(owner, body, s.target); + if (!got.시간당.ok || !got.시간당.로직) + throw new Error(hourlyText(got.시간당) || mt("Pick_Direct")); + const tag = s.target.표지 ?? newTag(); + const item: DraftItem = { + 표지: tag, + ...(s.target.키 ? { 키: s.target.키 } : {}), + ...(!s.target.키 && s.target.본뜬 ? { 본뜬: s.target.본뜬 } : {}), + 줄: body, + 시간당: got.시간당, + }; + const p: Picked = { + 키: got.시간당.로직, + 이름: body.이름, + 입력: { 기계: `'개인 ${tag}'` }, + ...(body.규격 ? { 규격: body.규격 } : {}), + }; + formDirty = () => false; + close(); + o.draft!(item, p, got.셈값); + } catch (error) { + notice.replaceChildren( + createText(error instanceof Error ? error.message : mt("Failed"), "error"), + ); + } finally { + btn.disabled = false; + } + } + + const blankBtn = createButton({ + label: mt("Win_Blank"), + variant: "ghost", + onClick: () => { + const c: Cand = { kind: "draft", no: "", 이름: "", 규격: "", 계: null, 구분: "" }; + sel = null; + void show(c, true, true); + }, + }); + blankBtn.dataset.mwinBlank = ""; + ({ close } = openModal({ + title: mt("Win_Title"), + closeLabel: mt("Close"), + width: "1100px", + wide: true, + ...(o.draft && ctx.canEdit ? { actions: [blankBtn] } : {}), + beforeClose: async () => !formDirty() || showConfirmDialog(mt("Leave_Ask")), + mount: (body) => + body.append( + el("div", { + className: "lstk-mwin", + attrs: { "data-machine-window": "" }, + children: [ + el("div", { + className: "lstk-mwin__list", + children: [ + el("div", { className: "ui-toolbar", children: [search.root, hint] }), + table.root, + ], + }), + right, + ], + }), + ), + })); + right.replaceChildren(createText(mt("Win_Hint"), "muted")); + + void (async () => { + if (personal) mines = await listMyMachines(owner).catch(() => []); + if (o.now && kindOf(o.now) === "m01") await briefOf(o.now); + await find(); + const first = cands[0]; + if (first && first.kind === "now") await choose(first); + })().catch((error) => showToast(error instanceof Error ? error.message : mt("Failed"), "error")); +} diff --git a/A00_Common/line_stack_mine.ts b/A00_Common/line_stack_mine.ts deleted file mode 100644 index 05422fcdf..000000000 --- a/A00_Common/line_stack_mine.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* ============================================================================= - * line_stack_mine.ts - * 줄 쌓기 기계 줄 「복사해 내 기계로」(PLAN 821-3 · 요구서 796 3 장) — M02 개인 층 단가산출만. - * 품셈 기계 줄(기계='원문번호') → 그 기계 값을 복사한 내 기계 양식 창 → 저장하면 줄이 그 내 기계로(기계='개인 EQP…') - * M01 정본은 그대로 · 내 기계 줄(기계='개인 …') · M01 단가산출 기초 = 단추 없음 - * ========================================================================== */ - -import { createButton } from "@ui/ui_template_button"; -import { showToast } from "@ui/ui_template_elements"; -import type { Picked } from "../common_util/common_util_line_stack_pick"; -import { findSimilar, machineArg, mineElement } from "../M02_MasterTemplete/M02_MyMachine_Api"; -import { openMyMachineModal } from "../M02_MasterTemplete/M02_MyMachine_Form"; -import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text"; -import type { StackTake } from "./line_stack_pick"; - -/** 기계 줄 연결 → 품셈 기계 원문번호(내 기계 · 기계 입력 없음 = null) */ -export function m01MachineNo(link: { - 갈래?: string; - 입력?: Record; -}): string | null { - const arg = link.갈래 === "단가산출" ? link.입력?.기계 : undefined; - const no = typeof arg === "string" ? arg.trim().replace(/^'|'$/g, "") : ""; - return no && !no.startsWith("개인 ") ? no : null; -} - -/** 「복사해 내 기계로」 단추 — 개인 층 SK 품셈 기계 줄만 · 고르면 `done(새 줄 고름)` */ -export function toMineButton( - take: StackTake | undefined, - link: { 갈래?: string; 입력?: Record }, - done: (p: Picked) => void, -): HTMLElement | null { - const no = m01MachineNo(link); - if (!no || take?.layer !== "personal") return null; - const b = createButton({ - label: mt("To_Mine"), - variant: "ghost", - size: 24, - iconStart: "copy", - onClick: async () => { - const rows = await findSimilar(take.owner, no).catch(() => []); - const seed = rows.find((r) => r.출처 === "M01" && r.원문번호 === no); - if (!seed) return showToast(`${mt("Hourly_None")} — ${no}`, "warning"); - openMyMachineModal( - take.owner, - (got) => { - const e = mineElement(got.줄, got.시간당); - if (!e.로직) return showToast(mt("Pick_Direct"), "warning"); - done({ - 키: e.로직, - 이름: e.이름, - 입력: { 기계: machineArg(got.줄.원문번호) }, - ...(e.규격 ? { 규격: e.규격 } : {}), - }); - }, - seed, - ); - }, - }); - b.dataset.toMine = no; - return b; -} diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts index e185ae1ea..00403214e 100644 --- a/A00_Common/line_stack_screen.ts +++ b/A00_Common/line_stack_screen.ts @@ -11,6 +11,7 @@ * 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음) * 고침 = 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음 * M02 사본(`copy`) — 저장 몸 {문서, 끊음(받은 그대로), 판} · 판 바뀜(409)은 문서가 그대로면 새 판으로 한 번 더 + * 기계 줄 · 옵션 가로 기계 = 기계 창(827-1 · `line_stack_machine`) — 초안은 문서 칸(캐시) · [저장] 때 서버가 내 기계로 * [단가 갱신] · [재생성] = 미리 셈 → 알림 → 저장(`formula_doc_copy.ts` · 고친 것 먼저 저장) * ========================================================================== */ @@ -53,6 +54,8 @@ import { ft } from "./formula_doc_text"; import { addTaken, type Taken } from "../common_util/common_util_line_stack_copy"; import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { buildEdit } from "./line_stack_edit"; +import { openMachineWindow, type CopyAnswer, type MachineCtx } from "./line_stack_machine"; +import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text"; import { buildOut } from "./line_stack_out"; import { lt } from "./line_stack_text"; import "./line_stack.css"; @@ -269,6 +272,21 @@ export function createStackScreen(opt: StackScreenOptions) { // ── 그림 ── const edit = (): boolean => opt.admin && (!!key || !cp); + /** 827-1 기계 창 — 개인 층 M02 SK 만 [고쳐 쓰기] · M01 기초 = M01 기계만 · 사본 줄 = 수식 길 `/machine` */ + const machineCtx = (): MachineCtx => ({ + scope: cp && opt.scope ? opt.scope : null, + canEdit: !!cp && !!key && opt.scope?.layer === "personal" && edit(), + why: !cp ? mt("Why_M01") : opt.scope?.layer === "personal" ? "" : mt("Why_Company"), + ...(key ? { me: key } : {}), + ...(cp && key + ? { copyRow: (no: string) => call(`/machine?no=${encodeURIComponent(no)}`) } + : {}), + }); + /** 초안 기계 셈 꼴 값 → 계산기 캐시(요소 `EQ:{기계}` 칸마다 · 서버 미리 셈이 같은 값으로 덮음) */ + const prime = (no: string, values: Record): void => + cache.merge({ + 요소: Object.entries(values).map(([칸, 값]) => ({ p: { 키: `EQ:${no}`, 칸 }, 값 })), + }); const draw = (): void => { if (!doc || !answer) { [head, left, right, foot].forEach((x) => x.replaceChildren()); @@ -285,7 +303,9 @@ export function createStackScreen(opt: StackScreenOptions) { // 가져온 식 = 옵션 가로 안 그림(819-1 · 요구 787 — 값은 왼쪽 공식 묶음에 같이 보임) const options = (doc as { 가져온?: unknown }).가져온 ? [] - : buildOptions(doc, given, !!cp && !opt.admin, setOption); + : buildOptions(doc, given, !!cp && !opt.admin, setOption, (no) => + openMachineWindow({ ctx: machineCtx(), drafts: [], now: no, query: "" }), + ); const busy = dirty(); const tools = [ ...(mark?.단가기준 && opt.page ? [basisPick(mark.단가기준)] : []), // 모달 = 머리 판 단추(793-3b) @@ -343,6 +363,8 @@ export function createStackScreen(opt: StackScreenOptions) { open: blocks, pair: (loc) => pair(loc, "left"), take: takeCtx(), + machine: machineCtx(), + prime, before: saved ? lineMap(JSON.parse(saved) as StackDoc) : undefined, }), ] diff --git a/M02_MasterTemplete/M02_Doc_Router.py b/M02_MasterTemplete/M02_Doc_Router.py index f46a4d660..4f3a99c89 100644 --- a/M02_MasterTemplete/M02_Doc_Router.py +++ b/M02_MasterTemplete/M02_Doc_Router.py @@ -4,6 +4,7 @@ POST /api/m02/layers/{층}/sangeun/{키}/doc/preview 미리 셈(층 쓰기) {문서?, 끊음?, 할일?} → {…, 전, 후, 바뀐자원?, 버림?} POST /api/m02/layers/{층}/sangeun/{키}/doc/save 저장(층 쓰기) {문서?, 끊음?, 할일?, 판} → 읽기 꼴 + GET /api/m02/layers/{층}/sangeun/{키}/doc/machine?no= 기계 창(827-1 · 층 읽기) → {사본, 지금, 새값} 옛 별칭 장부 쓰기(812-9) · 연결 찾기 길(810-4)은 걷음(815-2 · 요구 762-1 · 765). ⚠ 등록은 `main.py`(로그인만 · 층 권한은 길 안에서). """ @@ -37,6 +38,17 @@ def read_doc( return _call(_root(session, layer, owner), path.read, key) +@router.get("/layers/{layer}/sangeun/{key}/doc/machine") +def machine_doc( + layer: Layer, + key: str, + no: str, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + return _call(_root(session, layer, owner), path.machine, key, no) + + @router.post("/layers/{layer}/sangeun/{key}/doc/preview") def preview_doc( layer: Layer, diff --git a/M02_MasterTemplete/M02_Doc_Sk.py b/M02_MasterTemplete/M02_Doc_Sk.py index e790a0a1c..beaf9e4df 100644 --- a/M02_MasterTemplete/M02_Doc_Sk.py +++ b/M02_MasterTemplete/M02_Doc_Sk.py @@ -24,6 +24,7 @@ from typing import Any from M02_MasterTemplete import M02_Doc_Copy as dc from M02_MasterTemplete import M02_Doc_Gap as gap from M02_MasterTemplete import M02_Doc_Import as dim +from M02_MasterTemplete import M02_MyMachine_Draft as draft from M02_MasterTemplete import M02_Sangeun_Copy as skc from M02_MasterTemplete import M02_Sangeun_Doc as sd @@ -185,6 +186,8 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict: if mine is not None and mine.get("키") == out.get("키"): # 수식 길 저장 — 서버가 지은 사본 if dim.is_imported(out): # 가져온 식 — 셈이 읽은 줄(바꿔 끼운 줄 등)을 로직 사본에(823-1) out = gap.fill_imported(out, mine["사본"].get("문서")) + if skc.has(out): # 기계 창 초안으로 고친 내 기계 = 새 값(827-1) + out = {**out, skc.KEY: draft.renew(out[skc.KEY])} return {**out, KEY: norm(mine["사본"])} file = stored(out.get("키")) or {} have = file.get(KEY) if dc.has(file) else (raw.get(KEY) if dc.has(raw) else None) diff --git a/M02_MasterTemplete/M02_Doc_Sk_Path.py b/M02_MasterTemplete/M02_Doc_Sk_Path.py index b0dba178b..e740a5101 100644 --- a/M02_MasterTemplete/M02_Doc_Sk_Path.py +++ b/M02_MasterTemplete/M02_Doc_Sk_Path.py @@ -22,6 +22,7 @@ from M02_MasterTemplete import M02_Doc_Import as dim from M02_MasterTemplete import M02_Doc_Screen as screen from M02_MasterTemplete import M02_Doc_Sk as dsk from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MyMachine_Draft as draft from M02_MasterTemplete import M02_Sangeun_Copy as skc from M02_MasterTemplete import M02_Sangeun_Doc as sd @@ -135,12 +136,18 @@ def edited(copy: dict, body: dict, key: str, sk: dict | None = None) -> dict: if not dim.is_imported(sk): out["부름"] = dc.closure(dc.over(out), calls, copy.get("부름")) out = gap.fill(out) # 셈이 읽은 줄 가운데 사본 없는 것 = 지금 값으로 박음(822-1) + out = draft.renew(out) # 기계 창 초안으로 만든 · 고친 내 기계 = 새 값(827-1) out["따로"] = True return out def preview(key: str, body: dict) -> dict[str, Any]: - """미리 셈(저장 없음) — {셈, 전, 후, 바뀐자원?, 버림?}.""" + """미리 셈(저장 없음) — {셈, 전, 후, 바뀐자원?, 버림?} · 기계 창 초안(827-1) = 이 요청 안만 얹음.""" + with draft.overlay(body.get("문서")): + return _preview(key, body) + + +def _preview(key: str, body: dict) -> dict[str, Any]: doc = _file(key)["문서"] mine = _basis(doc, body) before = current(doc) @@ -176,10 +183,22 @@ def _basis(doc: dict, body: dict) -> dict: def save(key: str, body: dict) -> dict[str, Any]: - """저장(층 쓰기) — 서버가 다시 셈한 사본을 그 SK 에 씀 · {문서, 부름, 끊음, 셈, 사본, 판}.""" + """저장(층 쓰기) — 서버가 다시 셈한 사본을 그 SK 에 씀 · {문서, 부름, 끊음, 셈, 사본, 판} · + 기계 창 초안(827-1) = 같은 요청 안에서 내 기계 만들기 · 고침 → 키 바꿈 → 저장(실패 = 되돌림).""" got = _file(key) if str(body.get("판") or "") != got["판"]: raise _bad({"stale": [key], "판": got["판"]}, 409) + return draft.saving(body, lambda b: _save(key, b, got)) + + +def machine(key: str, no: str) -> dict[str, Any]: + """기계 창(827-1) — 이 SK 사본에 박힌 기계 줄 · 지금 줄 · 새 값.""" + doc = _file(key)["문서"] + copy = current(doc) if dsk.own(doc) and not dim.is_imported(doc) else None + return draft.copy_row(doc, copy, no) + + +def _save(key: str, body: dict, got: dict) -> dict[str, Any]: job = body.get("할일") or None raw = {**_basis(got["문서"], body), **({skc.JOB: job} if job else {})} token = None diff --git a/M02_MasterTemplete/M02_Logic_Lib_Read.py b/M02_MasterTemplete/M02_Logic_Lib_Read.py index 40b0e4e8e..d59d19d34 100644 --- a/M02_MasterTemplete/M02_Logic_Lib_Read.py +++ b/M02_MasterTemplete/M02_Logic_Lib_Read.py @@ -75,6 +75,12 @@ def get_machine_hourly(key: str) -> dict: return machine.hourly(key) +@router.get("/machine-rows") +def get_machine_rows(keys: str) -> dict: + """기계 창(827-1) — M01 기계 키 · 원문번호 쉼표 목록(60 까지) → 줄 값 + 시간당.""" + return {"rows": machine.brief([k for k in keys.split(",") if k.strip()])} + + @router.get("/machine-parts") def get_machine_parts(no: str) -> dict: """줄 쌓기 기계 넣기(813-2) — 기계 원문번호 → 그 기종 작업량 틀(문서째).""" diff --git a/M02_MasterTemplete/M02_Machine_Hourly.py b/M02_MasterTemplete/M02_Machine_Hourly.py index f7b76b2cf..ebe68a7d9 100644 --- a/M02_MasterTemplete/M02_Machine_Hourly.py +++ b/M02_MasterTemplete/M02_Machine_Hourly.py @@ -197,6 +197,46 @@ def hourly(key: str) -> dict[str, Any]: } +_BRIEF: dict[tuple, dict] = {} + + +def brief(keys: list[str]) -> list[dict[str, Any]]: + """기계 창 목록 · 양식(827-1) — M01 기계 줄 값 + 시간당(`hourly` 와 같은 식 · 입력 기본값 · + 마스터를 다시 읽지 않고 색인 위에서 셈 · 색인마다 메모) · 키 · 원문번호로 찾음 · 없는 키 = 뺌.""" + import master_cut as mcut + + from M02_MasterTemplete.M02_Item_Prices import to_json + + index, mf = store.loaded()[1], cm.mf + if len(_BRIEF) > 2000: # ponytail: 통째 비움 · 기계 수(655)의 세 배 + _BRIEF.clear() + out = [] + for key in keys[:60]: + if (got := _BRIEF.get((id(index), key))) is None: + try: + row = (index.numbers.get("EQ") or {}).get(key) or index.get(key) + except mf.FormulaError: + continue + if not str(row.get("키")).startswith("EQ"): + continue + logic = pick(row) + head = {k: row.get(k) for k in ("키", "원문번호", "이름", "규격")} + if lack := why(row, logic): + hour = {"ok": False, "reason": lack} + else: + given = {i["이름"]: (i.get("고르기") or [""])[0] for i in logic.get("입력") or []} + if takes_machine(logic): + given["기계"] = str(row.get("원문번호")) + sums = mcut.engine( + index, logic["키"], mf.run(index, logic["키"], mf.fill_empty(logic, given)) + ) + hour = {"ok": True, "로직": logic["키"]} + hour |= {k: float(sums[k]) for k in (*cm.mf.COST_ITEMS, "계")} + got = _BRIEF[(id(index), key)] = {**head, "값": to_json(row.get("값")), "시간당": hour} + out.append(got) + return out + + def combo(main: str, part: str) -> tuple[str, str] | None: """조합 기계(굴착기 + 대형 브레이커 · 부착용 집게 · 814-1) → 우리 조합 식 (키, 부름 글) `로직(GC000271, 기계='…', 브레이커='…', 지역='전국평균', …)` — 두 기계가 그 식 고르기에 있을 때만.""" diff --git a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts index 191987444..c46667bce 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts @@ -18,7 +18,8 @@ * · (785-3) `oil` = M01 유가전력(MO · `/logic-lib/elements?group=재료` 의 MO 줄) 고름 · 고정 줄 꼴 = 이음 유가 MO · * 재료비 = 지금 값(셈은 M01 값) · 「M01 전체」 탭 재료 아래 * · (821-3) `mine` = 개인 층 내 기계 묶음(품셈 기계 위 · 구분 「내 기계」 · 출처 「개인 키」 · 금액 = 서버 시간당) · - * 머리 [새 기계 만들기](양식 창 → 저장하면 그 기계로 고름) · 「시간당 값 직접」 내 기계 = 직접 값 식 GC001199(822-2) · 식 없는 내 기계 = 회색 + * 「시간당 값 직접」 내 기계 = 직접 값 식 GC001199(822-2) · 식 없는 내 기계 = 회색 · + * 머리 [새 기계 만들기](즉시 저장)는 827-1 에 걷음 — 만들기는 단가산출 기계 창 [고쳐 쓰기](초안 → [저장]) * ========================================================================== */ import { createDataTable } from "@ui/ui_template_data_table"; @@ -27,7 +28,6 @@ import { t as L } from "@ui/ui_template_locale"; import { aliasNote, matchLogic } from "@ui/ui_template_logic_find"; import { openNewMaterial } from "./M02_Material_Pick"; import { listMyMachines, mineElement, type MyMachineRow } from "./M02_MyMachine_Api"; -import { openMyMachineModal } from "./M02_MyMachine_Form"; import { mt } from "./M02_MyMachine_Text"; import { openModal } from "@ui/ui_template_modal"; import { createTabs } from "@ui/ui_template_tabs"; @@ -416,22 +416,7 @@ export function openFindPicker(opt: FindPickOptions): void { }); newMat.dataset.materialNew = ""; const canNew = opt.layer === "personal" && !!opt.material && ok("material"); - // 821-3 내 기계 묶음이면 머리 [새 기계 만들기] — 양식 창에서 저장하면 그 기계로 고름 - const newMach = createButton({ - label: mt("Pick_New"), - variant: "ghost", - onClick: () => - openMyMachineModal(opt.mine?.owner ?? null, (got) => { - const e = mineElement(got.줄, got.시간당); - if (!e.로직) return showToast(mt("Pick_Direct"), "warning"); - take(() => opt.machine?.(e))(); - }), - }); - newMach.dataset.machineNew = ""; - const headBtns = [ - ...(canNew ? [newMat] : []), - ...(opt.mine && opt.machine && ok("machine") ? [newMach] : []), - ]; + const headBtns = canNew ? [newMat] : []; ({ close } = openModal({ title: opt.title ?? L("M02_FindTitle"), ...(headBtns.length ? { actions: headBtns } : {}), diff --git a/M02_MasterTemplete/M02_MyMachine.py b/M02_MasterTemplete/M02_MyMachine.py index d1b962409..1992dcd6a 100644 --- a/M02_MasterTemplete/M02_MyMachine.py +++ b/M02_MasterTemplete/M02_MyMachine.py @@ -23,6 +23,7 @@ from __future__ import annotations import json import os import re +from contextvars import ContextVar from datetime import datetime from decimal import Decimal from pathlib import Path @@ -52,6 +53,10 @@ POWER, POWER_CREW, DIRECT_KEY = "GC001196", "GC001197", "GC001199" FUEL_POWER, FUEL_POWER_CREW = "GC001200", "GC001201" # 연료 + 전력(+ 조종원) — 경쟁사 구성(822-2b) +#: 단가산출 기계 창 초안(827-1 · `M02_MyMachine_Draft`) — 셈 꼴 줄 · 미리 셈 · 저장 한 요청 안만 `whole()` 위에 얹음 +DRAFT: ContextVar[tuple] = ContextVar("내기계초안", default=()) + + def mine(key: Any) -> bool: return str(key or "").startswith(PREFIX) @@ -135,7 +140,15 @@ _MEMO: dict[str, tuple] = {} def whole(base: Any = None) -> Any: - """M01 색인 + 지금 층 내 기계 줄 — 파일 · M01 이 그대로면 같은 객체(셈 메모가 붙음).""" + """M01 색인 + 지금 층 내 기계 줄 — 파일 · M01 이 그대로면 같은 객체(셈 메모가 붙음) · 초안(`DRAFT`) = 그 위.""" + from M02_MasterTemplete import M02_Sangeun_Copy as skc + + got = _whole(base) + draft = DRAFT.get() + return skc.Copy(got, list(draft), ("내기계", "초안", id(draft))) if draft else got + + +def _whole(base: Any = None) -> Any: from M02_MasterTemplete import M02_Sangeun_Copy as skc base = m01.loaded()[1] if base is None else base @@ -376,7 +389,7 @@ def delete(key: str) -> dict[str, Any]: def uses() -> dict[str, list[dict]]: - """{내 기계 키: [{키, 이름}]} — 이 층 단가산출 가운데 사본 단가 줄에 그 키를 가진 것.""" + """{내 기계 키: [{키, 이름}]} — 이 층 단가산출 가운데 사본(옛 칸 · 수식) 단가 줄에 그 키를 가진 것.""" from M02_MasterTemplete import M02_Sangeun_Copy as skc from M02_MasterTemplete import M02_Sangeun_Doc as sd @@ -389,11 +402,14 @@ def uses() -> dict[str, list[dict]]: doc = json.loads(raw) except ValueError: continue - for row in (doc.get(skc.KEY) or {}).get("단가") or []: - if mine(row.get("키")): - out.setdefault(str(row["키"]), []).append( - {"키": doc.get("키"), "이름": doc.get("이름")} - ) + keys = { # 옛 칸 사본 · 수식 길 사본(827-1) — 한 SK 한 번 + str(row["키"]) + for part in (skc.KEY, "수식") + for row in (doc.get(part) or {}).get("단가") or [] + if mine(row.get("키")) + } + for key in sorted(keys): + out.setdefault(key, []).append({"키": doc.get("키"), "이름": doc.get("이름")}) return out diff --git a/M02_MasterTemplete/M02_MyMachine_Api.ts b/M02_MasterTemplete/M02_MyMachine_Api.ts index cd12e8301..4f1e5a3db 100644 --- a/M02_MasterTemplete/M02_MyMachine_Api.ts +++ b/M02_MasterTemplete/M02_MyMachine_Api.ts @@ -154,3 +154,37 @@ export const mineElement = ( 로직: h.로직 ?? undefined, 원문번호: row.원문번호, }); + +/** 기계 창 [확인](827-1) — 초안 몸 검사 · 셈 꼴 값 · 시간당(식 키) · 쓰기 없음 */ +export interface DraftCheck { + 줄: MyMachine; + 셈값: Record; + 시간당: MyHourly; +} +export const checkDraft = ( + owner: string | null, + body: MyMachineBody, + target: { 키?: string; 본뜬?: string }, +): Promise => + call(at(owner, "/draft"), { + method: "POST", + body: JSON.stringify({ 줄: body, 키: target.키 ?? null, 본뜬: target.본뜬 ?? null }), + }); + +/** M01 기계 줄 값 + 시간당(기계 창 목록 · 양식 · 60 까지) */ +export interface MachineBrief { + 키: string; + 원문번호: string; + 이름: string; + 규격: string; + 값: MachineValues; + 시간당: MyHourly; +} +export const machineRows = async (keys: string[]): Promise => + keys.length + ? ( + await call<{ rows: MachineBrief[] }>( + `/logic-lib/machine-rows?keys=${encodeURIComponent(keys.join(","))}`, + ) + ).rows + : []; diff --git a/M02_MasterTemplete/M02_MyMachine_Draft.py b/M02_MasterTemplete/M02_MyMachine_Draft.py new file mode 100644 index 000000000..1921d467f --- /dev/null +++ b/M02_MasterTemplete/M02_MyMachine_Draft.py @@ -0,0 +1,184 @@ +"""M02 단가산출 기계 창 초안(PLAN 827-1 · 요구 810 · 811) — 서버 단독. + +초안 = SK 수식 문서 칸 `내기계초안` [{표지 「EQP초안-N」, 키?: 고칠 내 기계 키, 본뜬?: M01 · 내 기계 키, 줄: 몸}] · + 줄 쌓기 기계 줄 입력 기계='개인 EQP초안-N' 이 가리킴 · 화면 캐시에만(디스크에 안 씀). +확인(`check`) = 몸 검사 + 셈 꼴 값 + 시간당(식 키) — 쓰기 없음(기계 창 [확인]). +미리 셈(`overlay`) = 그 요청 안만 `M02_MyMachine.DRAFT` 에 셈 꼴 줄을 얹음(사본 빈틈 = 초안 값으로 셈). +저장(`saving`) = 문서가 쓰는 초안만 만들기 · 고침 → 표지를 진짜 키로 → 같은 요청 안에서 SK 저장 · + `TOUCH` = 사본 단가 줄을 새 값으로(`renew`) · 실패 = 만든 파일 지움 · 고친 줄 되돌림(번호표는 그대로). +사본 줄 보기(`copy_row`) = 그 SK 사본에 박힌 기계 줄 · 지금 줄 · 새 값 여부. +개인 층만(`mym._user` — 그 밖 404). +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Any + +from M02_MasterTemplete import M02_Item_Prices as prices +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MyMachine as mym + +FIELD = "내기계초안" +TAG = re.compile(r"EQP초안-\d+") +BODY = ("이름", "규격", "방식", "값", "시간당", "비고") +#: 저장 한 요청 안 — 만든 · 고친 내 기계 키(사본 단가 줄을 새 값으로) +TOUCH: ContextVar[frozenset] = ContextVar("초안고친키", default=frozenset()) + + +def _bad(text: str, status: int = 400) -> store.StoreError: + return store.StoreError(status, text) + + +def drafts(doc: Any) -> list[dict]: + got = doc.get(FIELD) if isinstance(doc, dict) else None + if got is not None and not isinstance(got, list): + raise _bad(f"「{FIELD}」 은 목록") + return [d for d in got or [] if isinstance(d, dict)] + + +def _have() -> dict[str, dict]: + mym._user() + return {r["키"]: r for r in mym.rows()} + + +def _row(item: dict, have: dict[str, dict]) -> dict: + """초안 하나 → 내 기계 줄(키 · 원문번호 = 표지) — 몸 검사 `mym.clean` · 고칠 키는 이 층에 있어야.""" + tag = str(item.get("표지") or "") + if not TAG.fullmatch(tag): + raise _bad(f"초안 표지 「{tag}」") + body = mym.clean(item.get("줄")) + key = str(item.get("키") or "") + if key: + if key not in have: + raise _bad(f"없는 내 기계 「{key}」", 404) + keep = {k: have[key][k] for k in ("원문", "구분", "넣은날", "본뜬") if k in have[key]} + else: + base = str(item.get("본뜬") or "") + keep = {"원문": "개인", "구분": "개인", **({"본뜬": base} if base else {})} + return {**keep, "키": tag, "원문번호": f"{mym.NO}{tag}", "상세구분": body["이름"], **body} + + +def check(item: Any) -> dict[str, Any]: + """기계 창 [확인] — {줄, 셈값(셈 꼴 값 칸), 시간당(식 키)} · 쓰기 없음 · 틀리면 400.""" + item = item if isinstance(item, dict) else {} + row = _row({**item, "표지": item.get("표지") or "EQP초안-0"}, _have()) + eng = mym.engine(row) + token = mym.DRAFT.set((eng,)) + try: + hour = mym.hourly(eng, mym.whole()) + finally: + mym.DRAFT.reset(token) + return {"줄": row, "셈값": prices.to_json(eng["값"]), "시간당": hour} + + +@contextmanager +def overlay(doc: Any) -> Iterator[None]: + """미리 셈 — 초안 셈 꼴 줄을 이 요청 안 `whole()` 위에 얹음(초안 없음 = 그대로).""" + items = drafts(doc) + if not items: + yield + return + have = _have() + token = mym.DRAFT.set(tuple(mym.engine(_row(i, have)) for i in items)) + try: + yield + finally: + mym.DRAFT.reset(token) + + +def renew(copy: Any) -> Any: + """사본 단가 줄 — 미리 셈 = 문서가 가리키는 초안 줄을 더함 · 저장 = 만든 · 고친 내 기계 줄을 새 값으로 + (이 SK 는 「새 값」 아님).""" + if not isinstance(copy, dict) or not isinstance(copy.get("단가"), list): + return copy + doc = copy.get("문서") if isinstance(copy.get("문서"), dict) else {} + rest = {k: v for k, v in doc.items() if k != FIELD} + used = set(TAG.findall(json.dumps(rest, ensure_ascii=False, default=str))) + keys = TOUCH.get() | used + if not keys: + return copy + from M02_MasterTemplete import M02_Sangeun_Copy as skc + + live = skc.whole_().index.get("EQ") or {} + rows = [ + prices.to_json(live[k]) if (k := str(r.get("키"))) in keys and k in live else r + for r in copy["단가"] + ] + have = {str(r.get("키")) for r in rows} + rows += [prices.to_json(live[k]) for k in sorted(used - have) if k in live] + return {**copy, "단가": rows, "단가판": skc._fp(rows)} + + +def _new(row: dict) -> str: + """새 내 기계 파일(`mym.create` 와 같은 꼴 · 값은 초안 그대로 — 본뜬 값을 다시 섞지 않음).""" + data = mym._read() + key = f"{mym.PREFIX}{mym._user()}-{data['다음']:04d}" + when = mym._now() + head = {"키": key, "원문번호": f"{mym.NO}{key}", "원문": "개인", "구분": "개인"} + body = {k: row[k] for k in ("상세구분", *BODY, "단위", "출처") if k in row} + tail = {"넣은날": when, "고친날": when, **({"본뜬": row["본뜬"]} if row.get("본뜬") else {})} + mym.atomic_write_json(mym._dir() / mym.NEXT, {"다음": data["다음"] + 1}) # 번호 먼저 + mym._put({**head, **body, **tail}) + return key + + +def saving(body: dict, inner: Callable[[dict], Any]) -> Any: + """[저장] 한 번 — 문서가 쓰는 초안 만들기 · 고침 → 표지 = 진짜 키 → `inner(새 몸)` · 실패 = 되돌림.""" + doc = body.get("문서") + if not isinstance(doc, dict) or FIELD not in doc: + return inner(body) + items = drafts(doc) + rest = {k: v for k, v in doc.items() if k != FIELD} + text = json.dumps(rest, ensure_ascii=False) + used = set(TAG.findall(text)) + if not used: + return inner({**body, "문서": rest}) + if lost := sorted(used - {str(i.get("표지")) for i in items}): + raise _bad(f"초안 없는 기계 표지 {lost}") + have = _have() + todo = [(i, _row(i, have)) for i in items if str(i.get("표지")) in used] # 다 검사한 뒤 씀 + made: list[str] = [] + kept: list[dict] = [] + names: dict[str, str] = {} + try: + for item, row in todo: + if key := str(item.get("키") or ""): + kept.append(have[key]) + mym.update(key, {k: row[k] for k in BODY if k in row}) + else: + made.append(key := _new(row)) + names[row["키"]] = key + text = TAG.sub(lambda m: names[m.group(0)], text) + token = TOUCH.set(frozenset(names.values())) + try: + return inner({**body, "문서": json.loads(text)}) + finally: + TOUCH.reset(token) + except BaseException: + for key in made: + (mym._dir() / f"{key}.json").unlink(missing_ok=True) + for old in kept: + mym._put(old) + raise + + +def copy_row(doc: dict, copy: dict | None, no: str) -> dict[str, Any]: + """기계 창 열 때 — {사본: 이 SK 사본에 박힌 줄 | None, 지금: 지금 줄 | None, 새값}(원문번호 · 키로 찾음).""" + from M02_MasterTemplete import M02_Sangeun_Copy as skc + + no = no.strip().strip("'") + pools = [(copy or {}).get("단가"), (doc.get(skc.KEY) or {}).get("단가")] + have = next( + (r for rows in pools for r in rows or [] if no in (r.get("원문번호"), r.get("키"))), + None, + ) + whole = skc.whole_() + live = (whole.numbers.get("EQ") or {}).get(no) or (whole.index.get("EQ") or {}).get(no) + now = prices.to_json(live) if live is not None else None + new = have is not None and now is not None and skc._fp(have) != skc._fp(now) + return {"사본": have, "지금": now, "새값": new} diff --git a/M02_MasterTemplete/M02_MyMachine_Form.ts b/M02_MasterTemplete/M02_MyMachine_Form.ts index c422dc61d..ff48de821 100644 --- a/M02_MasterTemplete/M02_MyMachine_Form.ts +++ b/M02_MasterTemplete/M02_MyMachine_Form.ts @@ -6,7 +6,8 @@ * [저장] 때만 서버(자동저장 없음) — 서버가 정본으로 다시 셈한 시간당을 받아 보임 * 새 기계 = 「비슷한 기계에서 시작」(M01 · 내 기계 · 고른 키 = 서버 `본뜬`) 또는 빈 양식 · `seed` = 품셈 기계 복사 * 지우기 = 쓰는 단가산출 이름 보이고 확인 + 「이미 만든 단가산출은 마지막 값 유지」 - * 부르는 곳 = 내 기계 화면(`M02_MyMachine_View`) · 기계 고르기 창 「새 기계 만들기」 · 줄 쌓기 「복사해 내 기계로」(창 `openMyMachineModal`) + * 부르는 곳 = 내 기계 화면(`M02_MyMachine_View` · 827-1 = 읽기 전용 · 지우기만) — 만들기 · 고치기는 + * 단가산출 기계 창(`A00_Common/line_stack_machine` · 초안 → 단가산출 [저장]) * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; @@ -15,10 +16,10 @@ 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 { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import { createMyMachine, @@ -44,6 +45,8 @@ export interface MyMachineFormOptions { seed?: SimilarRow; /** 새 기계면 「비슷한 기계에서 시작」 을 펼침 */ openSimilar?: boolean; + /** 827-1 보기만(양식 읽기 전용 · [저장] 없음 · [지우기] 그대로) */ + readOnly?: boolean; onSaved: (got: MyMachineOne) => void; onDeleted?: () => void; onBack?: () => void; @@ -63,6 +66,25 @@ export function valuesOf(row: Pick): return row.방식 === "직접" ? { [HOURLY_KEY]: { ...(row.시간당 ?? {}) } } : { ...row.값 }; } +/** 양식 상태 → 보낼 몸(틀림 = 글 목록) — 내 기계 양식 · 기계 창 [확인] 한 벌 */ +export function bodyOfForm(s: MachineFormState): { 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 }; +} + /** 서버 시간당 한 줄 글 */ export function hourlyText(h: MyHourly | undefined): string { if (!h?.ok) return `${mt("Hourly_None")}${h?.reason ? ` — ${h.reason}` : ""}`; @@ -99,6 +121,7 @@ export async function mountMyMachineForm( jobs: lists.jobs, power: lists.power, hour: lists.hour, + readOnly: opt.readOnly, findSimilar: row ? undefined : async (q): Promise => @@ -149,22 +172,13 @@ export async function mountMyMachineForm( /* --- 저장 --- */ const bodyOf = (): MyMachineBody | null => { - const s = form.state(); - const errors = [...s.errors, ...(s.name ? [] : [mt("Name_Needed")])]; + const { body, errors } = bodyOfForm(form.state()); notice.replaceChildren(...errors.map((e) => createText(e, "error"))); - if (errors.length) return null; - if (s.hourly) - return { - 이름: s.name, - 규격: s.spec, - 방식: "직접", - 시간당: (s.values[HOURLY_KEY] ?? {}) as MyMachineBody["시간당"], - }; - const { [HOURLY_KEY]: _drop, ...vals } = s.values; - return { 이름: s.name, 규격: s.spec, 방식: s.mode === "pumsem" ? "품셈" : "바로", 값: vals }; + 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; @@ -247,7 +261,7 @@ export async function mountMyMachineForm( delBtn, ], }), - createText(mt("Own_Note"), "muted"), + createText(mt(opt.readOnly ? "View_Note" : "Own_Note"), "muted"), notice, server, form.root, @@ -263,34 +277,3 @@ export async function mountMyMachineForm( } return { root, dirty }; } - -/** 창으로 띄움 — 저장하면 닫고 `done(답)` · 저장 안 하고 닫으면 물음 */ -export function openMyMachineModal( - owner: string | null, - done: (got: MyMachineOne) => void, - seed?: SimilarRow, -): void { - let handle: MyMachineFormHandle | null = null; - const { close } = openModal({ - title: seed ? mt("Copy_Title") : mt("New"), - closeLabel: mt("Close"), - width: "1100px", - wide: true, - beforeClose: async () => !handle?.dirty() || showConfirmDialog(mt("Leave_Ask")), - mount: (body) => { - void mountMyMachineForm(body, { - owner, - seed, - onSaved: (got) => { - close(); - done(got); - }, - }) - .then((h) => (handle = h)) - .catch((error) => { - showToast(error instanceof Error ? error.message : mt("Failed"), "error"); - close(); - }); - }, - }); -} diff --git a/M02_MasterTemplete/M02_MyMachine_Router.py b/M02_MasterTemplete/M02_MyMachine_Router.py index 33b83ad18..01816163d 100644 --- a/M02_MasterTemplete/M02_MyMachine_Router.py +++ b/M02_MasterTemplete/M02_MyMachine_Router.py @@ -2,7 +2,8 @@ GET 목록(줄마다 시간당 · 쓰는곳수) · GET `/{키}` 하나(+시간당 · 쓰는곳 · 같은이름) · POST 만들기 몸 {줄?, 본뜬?: M01 · 내 기계 키(「비슷한 기계에서 시작」 · 「M01 기계 복사해 만들기」)} · -PUT `/{키}` 몸 {줄} · DELETE `/{키}`(답 = 쓰는곳 — 그 단가산출은 사본의 마지막 값 그대로). +PUT `/{키}` 몸 {줄} · DELETE `/{키}`(답 = 쓰는곳 — 그 단가산출은 사본의 마지막 값 그대로) · +POST `/draft` 몸 {줄, 키?, 본뜬?} = 단가산출 기계 창 [확인](827-1 · 쓰기 없음 · 층 쓰기 권한) → {줄, 셈값, 시간당}. 권한 = 층 권한 한 벌(`Router_Items._root` — 본인 개인 층 · 맡은 회사 관리자) · 개인 층만(그 밖 404). ⚠ 등록은 `main.py`(로그인만). """ @@ -16,6 +17,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_MyMachine as mym +from M02_MasterTemplete import M02_MyMachine_Draft as draft from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Session, _call, _root router = APIRouter(prefix="/api/m02", tags=["M02 My Machine"]) @@ -25,6 +27,7 @@ PATH = "/layers/{layer}/my-machines" class MachineBody(BaseModel): 줄: dict[str, Any] | None = None 본뜬: str | None = None + 키: str | None = None # 초안 확인(827-1) — 고칠 내 기계 def _folder(session: Session, layer: str, owner: str | None, write: bool = False): @@ -38,6 +41,16 @@ def my_machines(layer: str, owner: str | None = None, session: Session = Depends return _call(_folder(session, layer, owner), mym.listing) +@router.post(PATH + "/draft") +def my_machine_draft( + layer: str, + body: MachineBody, + owner: str | None = None, + session: Session = Depends(verify_session), +): + return _call(_folder(session, layer, owner, True), draft.check, body.model_dump()) + + @router.get(PATH + "/{key}") def my_machine( layer: str, key: str, owner: str | None = None, session: Session = Depends(verify_session) diff --git a/M02_MasterTemplete/M02_MyMachine_Text.ts b/M02_MasterTemplete/M02_MyMachine_Text.ts index 03e9805f3..3d51c9d8a 100644 --- a/M02_MasterTemplete/M02_MyMachine_Text.ts +++ b/M02_MasterTemplete/M02_MyMachine_Text.ts @@ -10,7 +10,6 @@ const TEXT = { Crumb_Machine: "개인관리 › 기계", Crumb_Items: "개인관리 › 자재", New: "새 기계", - Copy_Title: "복사해 내 기계로", Find: "이름 · 규격 · 키로 찾기", Name: "이름", Spec: "규격", @@ -19,7 +18,7 @@ const TEXT = { Uses_Count: "쓰는 곳", Made: "넣은 날", Edited: "고친 날", - None: "내 기계 없음 — [새 기계] 로 만듦", + None: "내 기계 없음 — 단가산출의 기계 줄에서 [고쳐 쓰기] 로 만듦", Save: "저장", Saved: "내 기계 저장 — 서버가 시간당을 다시 셈", Delete: "지우기", @@ -50,9 +49,28 @@ const TEXT = { 직접: "시간당 값 직접", // 기계 고르기 창 · 줄 쌓기 Pick_Group: "내 기계", - Pick_New: "새 기계 만들기", Pick_Direct: "시간당 경비 식이 없는 기계 — 줄 쌓기에 못 넣음", - To_Mine: "복사해 내 기계로", + View_Note: "보기 · 쓰는 곳 · 지우기만 — 만들기 · 고치기는 단가산출의 기계 줄에서", + // 단가산출 기계 창(827-1) + Win_Title: "기계", + Win_Now: "지금 이 줄", + Win_Draft: "초안 — 단가산출 [저장] 때 내 기계로", + Win_M01: "M01 기계", + Win_Copy: "이 단가산출에 복사된 값", + Win_Live: "원본 지금 값", + Win_Newer: "원본에 새 값 있음 — [단가 갱신] 으로 반영", + Win_Swap: "이 기계로 바꾸기", + Win_Edit: "고쳐 쓰기", + Win_Blank: "빈 양식에서 새 기계", + Win_Ok: "확인", + Win_Cancel: "취소", + Win_Edit_Note: + "[확인] = 이 단가산출에 초안으로 끼움 · 단가산출 [저장] 때 내 기계로 저장 · 저장 안 하고 나가면 사라짐", + Win_Uses: "이 기계를 쓰는 다른 단가산출 {value} 건 — 저장하면 그쪽에 「새 값」 배지", + Win_Hint: "왼쪽에서 기계를 고르면 값이 보임", + Win_View: "조회", + Why_M01: "기계 값은 M01 기계 상세에서", + Why_Company: "개인용으로 복사해서 씀", // 내 품목 Items_Note: "단가산출 줄에 값을 바로 적은 재료(우리 자재에 안 이어진 것) — 고치기는 그 단가산출에서 · 줄 누르면 쓰는 단가산출", diff --git a/M02_MasterTemplete/M02_MyMachine_View.ts b/M02_MasterTemplete/M02_MyMachine_View.ts index 88b6aea9a..d0dcd6a0f 100644 --- a/M02_MasterTemplete/M02_MyMachine_View.ts +++ b/M02_MasterTemplete/M02_MyMachine_View.ts @@ -1,11 +1,11 @@ /* ============================================================================= * M02_MyMachine_View.ts * M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「개인관리」 › [기계] 단추로 가운데에 뜸(개인 층). - * 목록 = 이름 · 규격 · 방식 · 시간당 경비(서버 셈) · 쓰는 곳 수 · 넣은 날 · 고친 날 · 찾기 · [새 기계] - * 줄 누름 = 양식 한 장(`M02_MyMachine_Form`) · [목록] = 저장 안 한 고침이면 물음 · 저장 · 지우기 뒤 목록 다시 + * 목록 = 이름 · 규격 · 방식 · 시간당 경비(서버 셈) · 쓰는 곳 수 · 넣은 날 · 고친 날 · 찾기 + * 줄 누름 = 양식 한 장(`M02_MyMachine_Form` · 읽기 전용) · 쓰는 곳 · [지우기] → 목록 다시 + * 만들기 · 고치기 없음(827-1) — 단가산출 기계 줄의 기계 창 [고쳐 쓰기] → 단가산출 [저장] 때 내 기계로 * ========================================================================== */ -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"; @@ -68,18 +68,11 @@ export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement { }; search.input.addEventListener("input", draw); - const newBtn = createButton({ - label: mt("New"), - variant: "filled", - size: 28, - onClick: () => void openForm(), - }); - newBtn.dataset.mymachNew = ""; const list = el("div", { className: "m02-mymach-view__list", children: [ - el("div", { className: "ui-toolbar", children: [newBtn, search.root, hint] }), - createText(mt("Own_Note"), "muted"), + el("div", { className: "ui-toolbar", children: [search.root, hint] }), + createText(mt("View_Note"), "muted"), fail, table.root, ], @@ -101,13 +94,14 @@ 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_line_stack_draft.ts b/common_util/common_util_line_stack_draft.ts new file mode 100644 index 000000000..4e774c999 --- /dev/null +++ b/common_util/common_util_line_stack_draft.ts @@ -0,0 +1,76 @@ +/* ============================================================================= + * common_util_line_stack_draft.ts + * 줄 쌓기 기계 창 초안(PLAN 827-1) — 문서 칸 `내기계초안` 다루기(화면 · fetch 없음). + * 초안 하나 = {표지 「EQP초안-N」, 키?(고칠 내 기계), 본뜬?(바탕 키), 줄(내 기계 몸), 시간당?(목록 금액 · 식 키)} + * 줄 입력 기계='개인 EQP초안-N' 이 가리킴 · 단가산출 [저장] 때 서버가 만들기 · 고침 · 키 바꿈 + * (짝 = `M02_MasterTemplete/M02_MyMachine_Draft.py` — 칸 이름 · 표지 꼴) + * ========================================================================== */ + +import { varId, type FormulaDoc } from "./common_util_formula_doc"; +import { repick, type Picked } from "./common_util_line_stack_pick"; + +export const DRAFT_FIELD = "내기계초안"; +const TAG = /EQP초안-\d+/g; + +export interface DraftItem { + 표지: string; + 키?: string; + 본뜬?: string; + 줄: { 이름: string; 규격: string; 방식: string; 값?: Record; 시간당?: object }; + 시간당?: { ok: boolean; 계?: number; 로직?: string | null }; +} + +type Holder = FormulaDoc & { [DRAFT_FIELD]?: DraftItem[] }; + +export const draftsOf = (doc: FormulaDoc | null | undefined): DraftItem[] => + ((doc as Holder | null | undefined)?.[DRAFT_FIELD] ?? []).filter(Boolean); + +/** 기계 입력 글(따옴표 뗌)이 초안인지 */ +export const isDraftNo = (no: string | null | undefined): boolean => + /^개인 EQP초안-\d+$/.test(no ?? ""); + +let last = Date.now() % 1_000_000_000; +/** 새 표지 — 세션 안 겹치지 않게(캐시에 남은 옛 초안 값과 안 섞임) */ +export const newTag = (): string => `EQP초안-${++last}`; + +/** 문서가 안 쓰는 초안은 뺌 · 다 빠지면 칸 지움 */ +export function pruneDrafts(doc: FormulaDoc): void { + const box = doc as Holder; + if (!box[DRAFT_FIELD]) return; + const { [DRAFT_FIELD]: list, ...rest } = box; + const used = new Set(JSON.stringify(rest).match(TAG) ?? []); + const keep = (list ?? []).filter((d) => used.has(d.표지)); + if (keep.length) box[DRAFT_FIELD] = keep; + else delete box[DRAFT_FIELD]; +} + +/** 초안을 줄 `i`(단가 기호 `id`)에 끼움 — 내 기계 고침이면 이 문서에서 그 기계를 가리키던 줄 모두 초안으로 */ +export function putDraft(doc: FormulaDoc, i: number, id: string, item: DraftItem, p: Picked): void { + const arg = p.입력?.기계 ?? ""; + if (item.키) { + const was = `'개인 ${item.키}'`; + for (const v of doc.변수 ?? []) { + const l = v.연결; + if (l?.갈래 === "단가산출" && l.입력?.기계 === was) { + l.입력 = { ...l.입력, 기계: arg }; + l.키 = p.키; + } + } + } + repick(doc, i, id, "기계", p); + const box = doc as Holder; + box[DRAFT_FIELD] = [...draftsOf(doc).filter((d) => d.표지 !== item.표지), item]; + pruneDrafts(doc); +} + +/** 기계 줄 연결 → 지금 기계 입력 글(따옴표 뗌) · 옵션 기호(`S_굴착기`)면 그 옵션 값 · 없음 = null */ +export function machineNow( + doc: FormulaDoc, + link: { 갈래?: string; 입력?: Record }, +): string | null { + const arg = link.갈래 === "단가산출" ? String(link.입력?.기계 ?? "").trim() : ""; + if (!arg) return null; + if (/^'.*'$/.test(arg)) return arg.slice(1, -1); + const val = (doc.변수 ?? []).find((x) => varId(x) === arg)?.연결?.값; + return val === undefined || val === null || String(val) === "" ? null : String(val); +} diff --git a/resources/tester/test_827_1_machine_window.py b/resources/tester/test_827_1_machine_window.py new file mode 100644 index 000000000..cfd8d3747 --- /dev/null +++ b/resources/tester/test_827_1_machine_window.py @@ -0,0 +1,226 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""827-1 단가산출 기계 창 초안 — 임시 storage 만(M01 정본 · 공유 storage 안 씀). + +① 저장 한 번 = 초안 내 기계 만들기 + 줄 연결(표지 → 진짜 키) + 사본 박기 · 미리 셈 = 저장 뒤 셈 · 새 SK(수식 저장 전) +② 같은 초안 두 줄 = 내 기계 하나 ③ 저장 안 함(미리 셈만) = 내 기계 0 +④ 실패(판 다름 · 저장 중 오류) = 내 기계 · SK 그대로(만든 것 지움 · 고친 것 되돌림) +⑤ 내 기계 고침 초안 → 그 SK 는 새 값 · 그 기계 쓰는 다른 SK 「단가새값」 → [단가 갱신] +⑥ 회사 층 · 남의 층 거절 ⑦ 확인 길 = 쓰기 없음 · 틀린 몸 400 ⑧ M01 기계 목록 시간당 = 정본 시간당. +""" + +from __future__ import annotations + +import json + +import pytest +from fastapi import HTTPException + +from M02_MasterTemplete import M02_Doc_Router as doc_route +from M02_MasterTemplete import M02_Doc_Sk_Path as path +from M02_MasterTemplete import M02_Labor_Ref as refs +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_Draft as draft +from M02_MasterTemplete import M02_MyMachine_Router as route +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ — 연료 · 조종원 있음 +NAME = "sub1임시 기계" +USER = {"role": "USER", "company_id": 7, "user_id": 42} + + +def _at(folder, fn, *args): + with store.at(folder): + return fn(*args) + + +def _body(price=1500): + vals = {"가격": price, "손료계수": 2500, "주연료": 2, "주연료종류": "경유", "잡재료율": 10, + "조종원": 1, "조종원직종": "1048"} # fmt: skip + return {"이름": NAME, "규격": "1㎾", "방식": "바로", "값": vals} + + +def _var(n, no, logic): + return {"기호": "P", "아래": f"기계{n}", "위": "", "이름": NAME, "단위": "", + "연결": {"갈래": "단가산출", "키": logic, "입력": {"기계": f"'{no}'"}}} # fmt: skip + + +def _doc(key, nos, logic, drafts=None): + """줄 쌓기 기계 줄(화면이 보내는 꼴) — 줄마다 기계='…' · 초안 칸.""" + lines = [{"이름": NAME, "단위": "hr", "비목": "묶음", "수량": "1", "단가": f"P_기계{n}"} + for n in range(len(nos))] # fmt: skip + out = {"꼴": "수식문서", "판": 1, "키": key, "이름": "sub1임시", "결과단위": "원", + "변수": [_var(n, no, logic) for n, no in enumerate(nos)], "식": [], "금액": lines} # fmt: skip + return out | ({draft.FIELD: drafts} if drafts is not None else {}) + + +def _sk(folder, name="sub1임시"): + body = {"이름": name, "단위": "시간", "더한줄": []} + return _at(folder, lambda: refs.create("sangeun", body)["이름"]) + + +def _save(folder, key, doc): + 판 = _at(folder, path.read, key)["판"] + return _at(folder, path.save, key, {"문서": doc, "판": 판}) + + +def _file(folder, key): + return _at(folder, lambda: store.read("sangeun", key)["문서"]) + + +def _new_draft(folder, tag="EQP초안-7", base=DIG, price=1500): + got = _at(folder, draft.check, {"줄": _body(price), "본뜬": base}) + item = {"표지": tag, "본뜬": base, "줄": _body(price)} + return item, got["시간당"] + + +def test_save_creates_links_and_stamps(world): + """① 새 SK(수식 저장 전) — 미리 셈 = 확인 시간당 = 저장 뒤 셈 · 내 기계 1 · 표지 → 진짜 키 · 사본에 박힘.""" + me = world["personal"] + item, hour = _new_draft(me) + assert hour["ok"] and hour["로직"] + sk = _sk(me) + doc = _doc(sk, ["개인 EQP초안-7"], hour["로직"], [item]) + look = _at(me, path.preview, sk, {"문서": doc}) + assert look["셈"]["계"] == hour["계"] + assert _at(me, mym.rows) == [] # 미리 셈 = 쓰기 없음 + after = _save(me, sk, doc) + rows = _at(me, mym.rows) + assert len(rows) == 1 and rows[0]["본뜬"] == DIG and rows[0]["값"]["가격"] == 1500 + key = rows[0]["키"] + saved = _file(me, sk)["수식"] + text = json.dumps(saved["문서"], ensure_ascii=False) + assert "EQP초안" not in text and draft.FIELD not in saved["문서"] + assert f"'개인 {key}'" in text + assert key in [r["키"] for r in saved["단가"]] # 사본에 박힘 + assert after["셈"]["계"] == look["셈"]["계"] and after["사본"]["단가새값"] is False + assert _at(me, mym.uses)[key] == [{"키": sk, "이름": "sub1임시"}] + + +def test_same_draft_two_lines(world): + """② 같은 초안을 두 줄이 고름 = 내 기계 하나 · 두 줄 모두 그 키.""" + me = world["personal"] + item, hour = _new_draft(me) + sk = _sk(me) + doc = _doc(sk, ["개인 EQP초안-7", "개인 EQP초안-7"], hour["로직"], [item]) + look = _at(me, path.preview, sk, {"문서": doc}) + _save(me, sk, doc) + rows = _at(me, mym.rows) + assert len(rows) == 1 + text = json.dumps(_file(me, sk)["수식"]["문서"], ensure_ascii=False) + assert text.count(f"'개인 {rows[0]['키']}'") == 2 + assert _at(me, path.read, sk)["셈"]["계"] == look["셈"]["계"] == 2 * hour["계"] + + +def test_unused_draft_not_made(world): + """③ 문서가 안 쓰는 초안 = 안 만듦 · 미리 셈만 = 0.""" + me = world["personal"] + item, hour = _new_draft(me) + sk = _sk(me) + _at(me, path.preview, sk, {"문서": _doc(sk, ["개인 EQP초안-7"], hour["로직"], [item])}) + assert _at(me, mym.rows) == [] + _save(me, sk, _doc(sk, ["0101-0010"], "GC000269", [item])) # 줄을 다른 기계로 바꾼 뒤 저장 + assert _at(me, mym.rows) == [] + with pytest.raises(store.StoreError) as e: # 초안 없는 표지 + _save(me, sk, _doc(sk, ["개인 EQP초안-9"], hour["로직"], [item])) + assert e.value.status == 400 and _at(me, mym.rows) == [] + + +def test_fail_leaves_nothing(world, monkeypatch): + """④ 판 다름 409 · 저장 중 오류 = 만든 내 기계 지움 · 고친 내 기계 되돌림 · SK 그대로.""" + me = world["personal"] + item, hour = _new_draft(me) + sk = _sk(me) + doc = _doc(sk, ["개인 EQP초안-7"], hour["로직"], [item]) + with pytest.raises(store.StoreError) as e: + _at(me, path.save, sk, {"문서": doc, "판": "옛판"}) + assert e.value.status == 409 and _at(me, mym.rows) == [] + old = _at(me, mym.create, _body(1000))["줄"] + edit = {"표지": "EQP초안-8", "키": old["키"], "줄": _body(4000)} + both = _doc(sk, ["개인 EQP초안-7", "개인 EQP초안-8"], hour["로직"], [item, edit]) + before = _file(me, sk) + + def boom(*_a, **_k): + raise store.StoreError(500, "시험 실패") + + monkeypatch.setattr(store, "write", boom) + with pytest.raises(store.StoreError): + _save(me, sk, both) + assert _at(me, mym.rows) == [old] # 만든 것 없음 · 고친 것 되돌림 + assert _file(me, sk) == before + + +def test_edit_mine_other_sk_new_value(world): + """⑤ 내 기계 고침 초안 — 저장한 SK = 새 값 그대로 · 그 기계 쓰는 다른 SK = 단가새값 → [단가 갱신].""" + me = world["personal"] + made = _at(me, mym.create, _body(1000)) + key, no, logic, was = (made["줄"]["키"], made["줄"]["원문번호"], made["시간당"]["로직"], + made["시간당"]["계"]) # fmt: skip + first = _sk(me, "sub1임시 첫째") + _save(me, first, _doc(first, [no], logic)) + assert _at(me, path.read, first)["셈"]["계"] == was + second = _sk(me, "sub1임시 둘째") + edit = {"표지": "EQP초안-3", "키": key, "줄": _body(5000)} + doc = _doc(second, ["개인 EQP초안-3"], logic, [edit]) + look = _at(me, path.preview, second, {"문서": doc}) + assert look["셈"]["계"] > was + assert _at(me, mym.rows)[0]["값"]["가격"] == 1000 # 미리 셈 = 원본 그대로 + after = _save(me, second, doc) + assert after["셈"]["계"] == look["셈"]["계"] and after["사본"]["단가새값"] is False + assert _at(me, mym.rows)[0]["값"]["가격"] == 5000 and len(_at(me, mym.rows)) == 1 + now = _at(me, path.read, first) + assert now["셈"]["계"] == was and now["사본"]["단가새값"] is True + job = _at(me, path.preview, first, {"할일": "단가갱신"}) + assert [x["키"] for x in job["바뀐자원"]] == [key] and job["후"]["계"] == look["셈"]["계"] + seen = _at(me, path.machine, first, f"'{no}'") + assert ( + seen["사본"]["값"]["가격"] == 1000 and seen["지금"]["값"]["가격"] == 5000 and seen["새값"] + ) + + +def test_company_and_other_refused(world): + """⑥ 회사 층 = 404(내 기계는 개인 층만) · 남의 개인 층 = 403(일반 사용자).""" + item, hour = _new_draft(world["personal"]) + sk = _sk(world["company"]) + doc = _doc(sk, ["개인 EQP초안-7"], hour["로직"], [item]) + for fn in (path.preview, path.save): + with pytest.raises(store.StoreError) as e: + _at(world["company"], fn, sk, {"문서": doc, "판": ""}) + assert e.value.status in (404, 409) + with pytest.raises(store.StoreError) as e: + _at(world["company"], path.preview, sk, {"문서": doc}) + assert e.value.status == 404 + with pytest.raises(HTTPException) as e: + route.my_machine_draft("personal", route.MachineBody(줄=_body()), "7/43", USER) + assert e.value.status_code == 403 + with pytest.raises(HTTPException) as e: + doc_route.save_doc("personal", sk, doc_route.DocBody(문서=doc, 판=""), "7/43", USER) + assert e.value.status_code == 403 + + +def test_check_no_write(world): + """⑦ 확인 길 = 쓰기 없음 · 직접 = GC001199 · 빈 이름 400 · 없는 내 기계 404.""" + me = world["personal"] + got = _at(me, draft.check, {"줄": {"이름": NAME, "방식": "직접", + "시간당": {"노무비": 10, "재료비": 20, "경비": 30}}}) # fmt: skip + assert got["시간당"]["로직"] == "GC001199" and got["시간당"]["계"] == 60 + assert got["셈값"] == {"노무비": 10, "재료비": 20, "경비": 30} + for raw, code in (({"줄": {"이름": ""}}, 400), ({"줄": _body(), "키": "EQP42-0099"}, 404)): + with pytest.raises(store.StoreError) as e: + _at(me, draft.check, raw) + assert e.value.status == code + assert _at(me, mym.rows) == [] + + +def test_brief_same_as_hourly(): + """⑧ 기계 창 M01 목록 시간당(`brief`) = 정본 시간당(`hourly`) · 원문번호로도 찾음.""" + keys = ["EQ000002", "EQ000010", DIG] + rows = mh.brief(keys) + assert [r["키"] for r in rows] == keys + for r in rows: + h = mh.hourly(r["키"]) + assert r["시간당"]["ok"] == h["ok"] + if h["ok"]: + assert r["시간당"]["계"] == h["계"] and r["시간당"]["로직"] == h["로직"] + assert mh.brief([rows[0]["원문번호"]])[0]["키"] == "EQ000002"