diff --git a/A00_Common/formula_doc_options.ts b/A00_Common/formula_doc_options.ts index 129dfd2c1..6a33a6095 100644 --- a/A00_Common/formula_doc_options.ts +++ b/A00_Common/formula_doc_options.ts @@ -29,6 +29,20 @@ export function nameMachines(list: string[], redraw: () => void): void { }); } +let asked = false; +/** 글 값이 기계 원문번호면 「이름 규격 (원문번호)」(838-3 · 요구 859) — 이름이 아직 없으면 한 번 받아 `redraw` · 아니면 null */ +export function machineText(x: unknown, redraw: () => void): string | null { + if (typeof x !== "string" || !MACHINE.test(x)) return null; + if (!(x in names) && !asked) { + asked = true; + void cfMachineNames().then((got) => { + names = { ...names, ...got }; + redraw(); + }); + } + return optionText(x); +} + export function buildOptions( doc: FormulaDoc | null, given: Record, diff --git a/A00_Common/line_stack.css b/A00_Common/line_stack.css index ae4260662..5af79b1bc 100644 --- a/A00_Common/line_stack.css +++ b/A00_Common/line_stack.css @@ -473,3 +473,37 @@ grid-template-columns: minmax(0, 1fr); } } + +/* 836-1 기계 제어 구역 — 기계 줄 아래 접는 구역(값 칸 · 조종원 인력 줄 · 잡품 재료 줄) · 줄 꼴 = 단가산출 줄과 닮음 */ +.lstk-mctl { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin-block-start: var(--spacing-4); + padding: var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface-sunken); +} + +.lstk-mctl > .lstk-frow { + display: grid; + grid-template-columns: 7em auto 11em auto auto; + justify-content: start; +} + +.lstk-mctl > .lstk-frow > .lstk-sym { + max-width: none; +} + +.lstk-mctl .lstk-f-s { + width: auto; +} + +.lstk-mctl .lstk-line.k-ctl > .lstk-f-s { + flex: 0 1 9em; + min-width: 6em; +} + +.lstk-mctl .lstk-line.k-ctl > :last-child { + margin-inline-start: auto; +} diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts index 92f067e88..78d7a15d4 100644 --- a/A00_Common/line_stack_edit.ts +++ b/A00_Common/line_stack_edit.ts @@ -20,7 +20,7 @@ import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_ut import { isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc"; import { lineMap, offLine, type Values } from "../common_util/common_util_formula_doc_view"; import type { DocTable } from "../common_util/common_util_formula_doc_table"; -import type { DocRecord } from "../common_util/common_util_formula_doc_cache"; +import { coverOf } from "../common_util/common_util_formula_doc_cover"; import { dropSyms, docOf, @@ -38,12 +38,7 @@ import { } from "../common_util/common_util_line_stack"; import { placeNames } from "../common_util/common_util_line_stack_copy"; import { askFileWide, materialMark, matEntry } from "./line_stack_material"; -import { - draftsOf, - machineNow, - pruneDrafts, - putDraft, -} from "../common_util/common_util_line_stack_draft"; +import { draftsOf, machineNow, pruneDrafts } from "../common_util/common_util_line_stack_draft"; import { addPicked, type Picked, @@ -57,6 +52,7 @@ import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_U import { reach } from "./formula_doc_render"; import { attachDrag } from "./line_stack_drag"; import { openMachineWindow, type MachineCtx } from "./line_stack_machine"; +import { buildMachineCtl, machineOfLine } from "./line_stack_machine_ctl"; import { openStackPick, type StackTake } from "./line_stack_pick"; import { buildBlock, sourceTag } from "./line_stack_rows"; import { lt, type LsKey } from "./line_stack_text"; @@ -78,10 +74,10 @@ export interface EditHooks { take?: StackTake; /** 저장본 식 줄 {기호: 식} — 만약 식을 고쳐 굳은 줄 표지(요구 696) */ before?: ReadonlyMap; - /** 827-1 기계 줄 = 기계 창(조회 · 바꾸기 · 고쳐 쓰기) — 층 · 권한 · 사본 줄 */ + /** 827-1 기계 줄 = 기계 창(찾기 · 바꾸기 · 836-1) — 층 · 사본 줄 */ machine?: MachineCtx; - /** 827-1 초안 기계 셈 기록 → 계산기 캐시(브라우저 즉시 셈 · 서버 미리 셈과 같은 값) */ - prime?: (record: DocRecord) => void; + /** 836-1 기계 제어 구역 — 덮음 없이 그 기계 식 셈(원본 = 기본값) */ + calc?: (doc: FormulaDoc, given: Record) => DocAnswer; } const field = ( @@ -192,10 +188,6 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] { now: machineNow(d, link as { 갈래?: string; 입력?: Record }), query, pick: swap, - draft: (item, p, record) => { - h.prime?.(record); - h.change((doc) => putDraft(doc, i, own, item, p)); - }, }) : openStackPick(kind, swap, undefined, query), }); @@ -203,6 +195,24 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] { pick.title = `${lt("Repick")}${res ? ` — ${res}` : ""}`; pick.dataset.repick = String(i); head.push(pick); + if (kind === "기계" && h.calc) { + // 836-1 기계 제어 구역 접기 · 고친 기계 표지 + const on = h.open.has(`mctl:${i}`); + const t = createIconButton({ + icon: on ? "chevronDown" : "chevronRight", + title: lt("Ctl_Open"), + size: 24, + onClick: () => { + if (on) h.open.delete(`mctl:${i}`); + else h.open.add(`mctl:${i}`); + h.change(() => undefined); + }, + }); + t.dataset.mctlToggle = String(i); + head.push(t); + const m = machineOfLine(h.answer, link); + if (m && coverOf(d)[m]) head.push(createBadge(lt("Ctl_Changed"), "info", { size: 18 })); + } const mark = machineMark(found, link); if (mark) head.push(mark); const matMark = kind === "재료" ? materialMark(matEntry(d, own), link) : null; // 827-3 판정 · 단서 @@ -310,7 +320,9 @@ function qtyBox( ); f.dataset.qty = String(i); const val = v ? (h.answer.값 ?? {})[text] : undefined; - const tag = v ? sourceTag(h, text, v.연결) : null; + // 838-3 공식 · 표 값 묶음에 이미 보이는 기호 = 줄 쪽 표 칩 걷음(묶음에서 고름) · 묶음에 없으면 줄에서 고름 + const inBlock = h.items.some((it) => it.줄.some((l) => l.꼴 === "공식" && l.기호.includes(text))); + const tag = v && !inBlock ? sourceTag(h, text, v.연결) : null; if (tag && isNum(val)) tag.title = `${eqText(val)}${shownUnit(v?.단위) ? ` ${shownUnit(v?.단위)}` : ""} — ${tag.title || tag.textContent}`; return el("span", { className: "lstk-qty", children: tag ? [f, tag] : [f] }); @@ -490,6 +502,14 @@ export function buildEdit(h: EditHooks): HTMLElement { : [lineGrip, ...body, del], }); card.dataset.loc = `${i}.${j}`; + if (line.꼴 === "금액" && h.calc && h.open.has(`mctl:${line.i}`)) { + const own = ownedOf(h.doc).get(line.i); + const zone = buildMachineCtl( + { ...h, calc: h.calc, change: (fn) => h.change((d) => fn(d)), readOnly: !h.machine?.scope }, // prettier-ignore + h.doc.변수?.find((x) => varId(x) === own)?.연결, + ); + if (zone) card.append(zone); + } card.addEventListener("pointerdown", () => h.pair(`${i}.${j}`)); attachDrag(lineGrip, { what: "line", diff --git a/A00_Common/line_stack_machine.ts b/A00_Common/line_stack_machine.ts index 031611d93..50b2d858d 100644 --- a/A00_Common/line_stack_machine.ts +++ b/A00_Common/line_stack_machine.ts @@ -1,48 +1,38 @@ /* ============================================================================= * line_stack_machine.ts - * 줄 쌓기 기계 창(PLAN 827-1 · 요구 810 · 811) — 기계 줄 이름 단추 · 옵션 가로 [조회]가 띄움. - * 왼쪽 = 기계 찾기 — 맨 위 「지금 이 줄」 · 이 단가산출 초안 · 개인 층 내 기계 · M01 기계(줄마다 시간당) - * 오른쪽 = 공통 기계 양식(`ui_template_machine_form` · 읽기 전용) — 지금 줄 = 이 단가산출 사본에 박힌 값 - * (원본 지금 값과 다르면 「새 값 있음」) · 고른 줄 = 그 기계 지금 값 - * [이 기계로 바꾸기] = `pick`(부른 쪽 `repick` · 가져온 식 823-1 규칙 그대로) - * [고쳐 쓰기](개인 층 · 쓰기 권한 · `ctx.canEdit`) = 양식 편집 → [확인] = 서버 확인(쓰기 없음) → `draft` - * (부른 쪽이 문서 칸 `내기계초안` 에 · 디스크는 단가산출 [저장] 때 서버) · 그 밖 = 까닭 한 줄 - * M01 기계에서 시작 = 새 내 기계(본뜬 = 그 키) · 내 기계 = 그 기계 고침 · 초안 = 그 초안 고침 · 즉시 저장 없음 + * 줄 쌓기 기계 창(PLAN 827-1 · 836-1 · 요구 810 · 846 · 853) — 기계 줄 이름 단추 · 옵션 가로 [조회]가 띄움 = 찾기 · 바꾸기만 + * 왼쪽 = 기계 찾기 — 맨 위 「지금 이 줄」 · 이 단가산출 옛 초안 · 개인 층 내 기계 · M01 기계(줄마다 시간당) + * 오른쪽 = 고른 기계 이름 · 시간당 · 사본 표지(이 단가산출에 복사된 값 · 원본 새 값) · [이 기계로 바꾸기] = `pick` + * 기계 내부 값 고침 = 단가산출 왼쪽 기계 제어 구역(`line_stack_machine_ctl` · 836-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 { createMachineForm, HOURLY_KEY, type MachineValues } from "@ui/ui_template_machine_form"; +import { createInputField, el, showToast } from "@ui/ui_template_elements"; 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 { DocRecord } from "../common_util/common_util_formula_doc_cache"; +import { isDraftNo, 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 { hourlyText } from "../M02_MasterTemplete/M02_MyMachine_Form"; import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text"; +import { lt } from "./line_stack_text"; -/** 사본 줄(서버 `copy_row`) — 키 · 값 · 방식(내 기계) · 시간당(직접) */ +/** 사본 줄(서버 `copy_row`) — 키 · 이름 · 규격 */ interface CopyRowData { 키: string; 원문번호?: string; 이름?: string; 규격?: string; - 값?: MachineValues; - 방식?: string; - 시간당?: Record; } export interface CopyAnswer { 사본: CopyRowData | null; @@ -53,11 +43,7 @@ export interface CopyAnswer { export interface MachineCtx { /** 층 · 주인 — null = M01 단가산출 기초(M01 기계만) */ scope: { layer: Layer; owner: string | null } | null; - /** [고쳐 쓰기] 됨(개인 층 SK · 쓰기 권한) */ - canEdit: boolean; - /** 못 고칠 까닭(M01 · 회사 · 마스터 층) */ - why: string; - /** 이 SK 키(쓰는 곳 셈에서 뺌) */ + /** 이 SK 키 */ me?: string; /** 이 SK 사본에 박힌 기계 줄(키 있는 M02 SK) */ copyRow?: (no: string) => Promise; @@ -71,8 +57,6 @@ export interface MachineWindowOptions { query: string; /** 없으면 보기만(옵션 가로 조회) */ pick?: (p: Picked) => void; - /** 고쳐 쓰기 [확인] — 초안 · 줄 연결 · 그 식 셈 기록(계산기 캐시 · 즉시 셈) */ - draft?: (d: DraftItem, p: Picked, record: DocRecord) => void; } type Kind = "now" | "draft" | "mine" | "m01"; @@ -90,45 +74,15 @@ interface Cand { 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; @@ -142,8 +96,6 @@ export function openMachineWindow(o: MachineWindowOptions): void { 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; @@ -264,7 +216,7 @@ export function openMachineWindow(o: MachineWindowOptions): void { return got; }; - /** 고른 줄 → 양식 값 · 글 · 바꿀 연결 · 고칠 대상 */ + /** 고른 줄 → 이름 · 글(시간당 · 사본 표지) · 바꿀 식 */ const resolve = async (c: Cand): Promise => { const k = c.kind === "now" ? kindOf(c.no) : c.kind; const lines: HTMLElement[] = []; @@ -274,24 +226,7 @@ export function openMachineWindow(o: MachineWindowOptions): void { 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.키, - }; + return { name: d.줄.이름, spec: d.줄.규격, lines, logic: d.시간당?.로직 }; } // 지금 줄 = 이 단가산출 사본 값(있으면) const copy = c.kind === "now" && ctx.copyRow ? await ctx.copyRow(c.no).catch(() => null) : null; @@ -302,29 +237,22 @@ export function openMachineWindow(o: MachineWindowOptions): void { } 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.시간당)}`)); + if (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 (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?.키 }, }; }; @@ -335,19 +263,17 @@ export function openMachineWindow(o: MachineWindowOptions): void { }; 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 { + async function show(c: Cand): 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); + s = await resolve(c); } catch (error) { if (mine === showSeq) right.replaceChildren( @@ -355,151 +281,41 @@ export function openMachineWindow(o: MachineWindowOptions): void { ); 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); + 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); } right.replaceChildren( el("div", { className: "ui-toolbar", - children: [el("strong", { text: `${s.name} ${s.spec}`.trim() || mt("New") }), ...bar], + children: [el("strong", { text: `${s.name} ${s.spec}`.trim() }), ...bar], }), ...s.lines, - ...extra, - notice, - form.root, + createText(lt("Win_Ctl_Note"), "muted"), ); - right.dataset.mwinMode = edit ? "edit" : "view"; + right.dataset.mwinMode = "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 tag = s.target.표지 ?? newTag(); - const got = await checkDraft(owner, body, { ...s.target, 표지: tag }); - if (!got.시간당.ok || !got.시간당.로직) - throw new Error(hourlyText(got.시간당) || mt("Pick_Direct")); - 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", { diff --git a/A00_Common/line_stack_machine_ctl.ts b/A00_Common/line_stack_machine_ctl.ts new file mode 100644 index 000000000..7ecbcb1d4 --- /dev/null +++ b/A00_Common/line_stack_machine_ctl.ts @@ -0,0 +1,317 @@ +/* ============================================================================= + * line_stack_machine_ctl.ts + * 줄 쌓기 기계 제어 구역(PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 왼쪽 기계 줄 아래 접는 구역 + * ㉠ 값 칸 = 그 기계 식이 읽는 기계 칸(단위 · 기본 = 사본 값 · 고친 칸 = 「고침」 + [원래 값]) + * ㉡ 조종원 = 인력 줄(노임 고르기 · 인/일 · 더하기 · 빼기 · 기본 = 기계 값 조종원 · 직종) + * ㉢ 잡품 = 재료 줄(재료 · 유가 고르기 · 수량 · 율 % · 기본 = 밑 재료 줄 · 품셈 잡재료율) + * 고침 = 문서 칸 `기계덮음`(캐시 · [저장] 때 서버가 정본으로 다시 셈) · 원본과 같으면 지움(`putCover`) + * 기본값 = 덮음 없이 셈한 그 기계 식 답(`defaultsOf`) · 읽기(M01 기초) = 칸 잠김 · 단추 없음 + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createButton, createIconButton } from "@ui/ui_template_button"; +import { eqText } from "@ui/ui_template_equation"; +import { el, showToast } from "@ui/ui_template_elements"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { varId, type FormulaDoc, type Link } from "../common_util/common_util_formula_doc"; +import type { DocAnswer } from "../common_util/common_util_formula_doc_calc"; +import { + coverOf, + defaultsOf, + machineKey, + partsOf, + putCover, + type CrewRow, + type MachineCover, + type MiscRow, +} from "../common_util/common_util_formula_doc_cover"; +import { subInputs, type StackDoc } from "../common_util/common_util_line_stack"; +import type { Picked } from "../common_util/common_util_line_stack_pick"; +import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick"; +import { openStackPick } from "./line_stack_pick"; +import { lt } from "./line_stack_text"; + +export interface CtlHooks { + doc: StackDoc; + answer: DocAnswer; + subDoc: (key: string) => FormulaDoc | null; + /** 덮음 없이 그 기계 식 셈(원본 = 기본값) */ + calc: (doc: FormulaDoc, given: Record) => DocAnswer; + change: (fn: (d: StackDoc) => void) => void; + name: (key: string) => string; + /** 읽기(M01 기초 · 칸 잠김) */ + readOnly: boolean; +} + +/** 기계 칸 단위(기계 표 `단위` 와 같음) */ +const UNIT: Record = { + 가격: "천원", 상각비계수: "10⁻⁷/hr", 정비비계수: "10⁻⁷/hr", 관리비계수: "10⁻⁷/hr", 손료계수: "10⁻⁷/hr", + 주연료: "ℓ/hr", 전력: "kWh/hr", 노무비: "원/hr", 재료비: "원/hr", 경비: "원/hr", +}; // prettier-ignore +const FUELS = ["경유", "휘발유", "실내등유"]; + +/** 그 줄 기계 열쇠 — 부를 입력을 지금 값으로 셈(옵션 · 만약 식 기호도) · 기계 줄 아님 = "" */ +export const machineOfLine = (answer: DocAnswer, link: Link | undefined): string => + link?.갈래 === "단가산출" + ? machineKey(subInputs(link, (answer.값 ?? {}) as Record)) + : ""; + +/** 그 기계 줄의 기계 열쇠 · 식 · 원본 기본값 — 기계 식이 아니면 null */ +export function ctlOf(h: CtlHooks, link: Link | undefined) { + if (link?.갈래 !== "단가산출" || !link.키) return null; + const given = subInputs(link, (h.answer.값 ?? {}) as Record); + const machine = machineKey(given); + const sub = h.subDoc(link.키); + if (!machine || !sub) return null; + const orig = h.calc(sub, given); + const vals = (orig.값 ?? {}) as Record; + return { machine, sub, vals, base: defaultsOf(sub, vals), entry: coverOf(h.doc)[machine] }; +} + +const num = (t: string): number | null => { + const n = Number(t.trim().replace(/,/g, "")); + return t.trim() !== "" && Number.isFinite(n) ? n : null; +}; + +/** 수 칸 — 빈 글 = 원래대로(null) · 음수 · 글 = 알림 */ +function numBox( + label: string, + value: unknown, + unit: string, + ro: boolean, + set: (n: number | null) => void, + hint = "", +): HTMLElement { + const f = createInputField({ + value: value === undefined || value === null ? "" : String(value), + placeholder: hint, + compact: true, + size: 24, + ...(unit ? { unit } : {}), + }); + f.root.classList.add("lstk-f-s"); + f.input.setAttribute("aria-label", label); + f.input.title = label; + f.input.disabled = ro; + f.input.addEventListener("change", () => { + const t = f.input.value; + const n = num(t); + if (t.trim() === "") return set(null); + if (n === null || n < 0) { + f.input.value = value === undefined || value === null ? "" : String(value); + return void showToast(lt("Ctl_Bad"), "warning"); + } + set(n); + }); + return f.root; +} + +const kind = (text: string): HTMLElement => el("span", { className: "lstk-kind", text }); + +/** 기계 제어 구역 — `link` = 그 기계 줄 단가 기호 연결 */ +export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElement | null { + const got = ctlOf(h, link); + if (!got) return null; + const { machine, sub, vals, base } = got; + const entry: MachineCover = got.entry ?? {}; + const ro = h.readOnly; + const put = (next: MachineCover): void => h.change((d) => putCover(d, machine, next, base)); + const parts = partsOf(sub); + const lines = sub.금액 ?? []; + + // ㉠ 값 칸 + const values = parts.fields.map(({ 칸: col }) => { + const was = base.값[col]; + const mine = entry.값?.[col]; + const now = mine ?? was; + const setVal = (v: number | string | null): void => { + const 값 = { ...(entry.값 ?? {}) }; + if (v === null || v === "") delete 값[col]; + else 값[col] = v; + put({ ...entry, 값 }); + }; + const box = + col === "주연료종류" + ? createSelectField({ + options: [...new Set([...FUELS, String(now ?? "")])] + .filter(Boolean) + .map((x) => ({ value: x, text: x })), + value: String(now ?? ""), + compact: true, + size: 24, + disabled: ro, + ariaLabel: col, + onChange: (x) => setVal(x), + }).root + : numBox(col, now, UNIT[col] ?? "", ro, setVal); + const row = el("div", { + className: "lstk-frow", + children: [el("b", { className: "lstk-sym", text: col }), "=", box], + }); + if (mine !== undefined) { + const tag = createBadge(lt("Ctl_Changed"), "info", { size: 18 }); + tag.title = `${lt("Ctl_Was")} ${was === undefined ? "—" : eqText(was)}`; + row.append(tag); + if (!ro) + row.append( + createButton({ + label: lt("Ctl_Back"), + variant: "ghost", + size: 24, + iconStart: "undo", + onClick: () => setVal(null), + }), + ); + } + row.dataset.mctlField = col; + return row; + }); + + // ㉡ 조종원 — 인력 줄 + const crew: CrewRow[] = entry.조종원 ?? base.조종원; + const setCrew = (rows: CrewRow[]): void => put({ ...entry, 조종원: rows }); + const crewName = (r: CrewRow, i: number): string => { + if (r.이름) return r.이름; + const at = parts.crew[i] ?? parts.crew[0]; + const sym = at === undefined ? "" : String(lines[at]?.단가 ?? ""); + const v = (sub.변수 ?? []).find((x) => varId(x) === sym); + return (v?.이름 ?? "").replace(/노임$/, "") || h.name(r.키) || r.키; + }; + const crewRows = crew.map((r, i) => + lineRow( + lt("K_인력"), + crewName(r, i), + ro, + () => openStackPick("인력", (p) => setCrew(swapAt(crew, i, { ...r, 키: p.키, 이름: p.이름 })), undefined, crewName(r, i)), // prettier-ignore + [numBox(lt("Ctl_Crew_Qty"), r.인, lt("Ctl_Per_Day"), ro, (n) => setCrew(swapAt(crew, i, { ...r, 인: n ?? 0 })))], // prettier-ignore + "노무비", + () => setCrew(crew.filter((_, k) => k !== i)), + ), + ); + + // ㉢ 잡품 — 재료 줄 + const misc: MiscRow[] = entry.잡품 ?? base.잡품; + const setMisc = (rows: MiscRow[]): void => put({ ...entry, 잡품: rows }); + const baseQty = (r: MiscRow): number | null => { + const b = lines.find((l) => String(l.이름 ?? "") === r.줄); + const q = b?.수량 ? (vals[String(b.수량)] ?? Number(b.수량)) : null; + return typeof q === "number" && Number.isFinite(q) ? q : null; + }; + const miscName = (r: MiscRow): string => { + if (r.이름) return r.이름; + if (r.키) return h.name(r.키) || r.키; + const fuel = r.줄 === "주연료" ? vals.S_연료 : undefined; + return [r.줄, fuel].filter(Boolean).join(" · "); + }; + const pickMat = (done: (p: Picked) => void, query: string): void => + openFindPicker({ + layer: "system", + owner: null, + title: `${lt("Find_Title")} — ${lt("Ctl_Misc")}`, + kinds: ["material", "oil"], + query: query || undefined, + element: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위, ...(e.규격 ? { 규격: e.규격 } : {}) }), // prettier-ignore + oil: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위 }), + logic: () => undefined, + }); + const asMat = (r: MiscRow, p: Picked): MiscRow => ({ + 키: p.키, + 이름: [p.이름, p.규격].filter(Boolean).join(" "), + ...(p.단위 ? { 단위: p.단위 } : {}), + 수량: r.수량 ?? baseQty(r) ?? 1, + 율: r.율, + }); + const miscRows = misc.map((r, i) => + lineRow( + lt("K_재료"), + miscName(r), + ro, + () => 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 + ], + "재료비", + () => setMisc(misc.filter((_, k) => k !== i)), + ), + ); + + const head = (label: string, covered: boolean, add: () => void, reset: () => void) => + el("div", { + className: "lstk-fhead", + children: [ + el("span", { className: "lstk-kind", text: label }), + ...(covered ? [createBadge(lt("Ctl_Changed"), "info", { size: 18 })] : []), + ...(ro + ? [] + : [ + 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 + : []), + ]), + ], + }); + const box = el("div", { + className: "lstk-mctl lstk-wide", + attrs: { "data-mctl": machine }, + children: [ + ...(values.length + ? [el("div", { className: "lstk-fhead", children: [kind(lt("Ctl_Values"))] }), ...values] + : []), + head( + lt("Ctl_Crew"), + entry.조종원 !== undefined, + () => 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)]), ""), + () => put({ ...entry, 잡품: undefined }), + ), + ...(miscRows.length ? miscRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore + ], + }); + return box; +} + +const swapAt = (rows: T[], i: number, one: T): T[] => rows.map((r, k) => (k === i ? one : r)); + +/** 줄 꼴(단가산출 줄과 닮음) — [종류][이름 단추][칸들][비목][X] */ +function lineRow( + label: string, + name: string, + ro: boolean, + pick: () => void, + boxes: HTMLElement[], + cost: string, + drop: () => void, +): HTMLElement { + const b = createButton({ + label: name || "—", + variant: "ghost", + tone: "link", + size: 24, + iconStart: "search", + disabled: ro, + onClick: pick, + }); + b.classList.add("lstk-res"); + b.title = `${lt("Repick")} — ${name}`; + return el("div", { + className: "lstk-line k-ctl", + children: [ + kind(label), + b, + ...boxes, + el("span", { className: "lstk-hint", text: cost }), + ...(ro ? [] : [createIconButton({ icon: "x", title: lt("Del"), size: 24, onClick: drop })]), + ], + }); +} diff --git a/A00_Common/line_stack_out.ts b/A00_Common/line_stack_out.ts index 0d17543b4..e1b0642b7 100644 --- a/A00_Common/line_stack_out.ts +++ b/A00_Common/line_stack_out.ts @@ -15,12 +15,14 @@ import { tableRow, tableShell, type Column } from "@ui/ui_template_table"; import { COSTS, varId, type FormulaDoc } from "../common_util/common_util_formula_doc"; import { calcDoc, isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc"; import type { DocCache } from "../common_util/common_util_formula_doc_cache"; +import { coverDoc, coverOf, type Cover } from "../common_util/common_util_formula_doc_cover"; import { lineMap, moneyScope, offLine, type Values, } from "../common_util/common_util_formula_doc_view"; +import { machineText } from "./formula_doc_options"; import { reach } from "./formula_doc_render"; import { kindOf, @@ -56,6 +58,10 @@ export interface OutHooks { toggle: (at: string) => void; /** 캐시에 없는 연결 — 뒤에서 서버에 물음 */ miss: () => void; + /** 836-1 기계 덮음(맨 위 문서 칸) — 펼친 기계 식에 얹음(계산기와 같음) */ + cover?: Cover; + /** 838-3 기계 이름을 받은 뒤 다시 그림 */ + redraw?: () => void; } const COST_SHOW: Record = { 노무비: "노무비", 재료비: "재료비", 경비: "경 비" }; @@ -145,9 +151,11 @@ function rows( }; const expand = (id: string): void => { const path = `${at}/${id}`; - const sub = subOf(id); - if (!h.open.has(path) || !sub || depth >= MAX_DEPTH) return; - const got = calcDoc(sub, h.cache.links, subInputs(vars.get(id)?.연결, vals)); + const raw = subOf(id); + if (!h.open.has(path) || !raw || depth >= MAX_DEPTH) return; + const given = subInputs(vars.get(id)?.연결, vals); + const sub = coverDoc(raw, given, h.cover); + const got = calcDoc(sub, h.cache.links, given, [], new Map(), h.cover); if (h.cache.takeMissing()) h.miss(); out.push({ depth: depth + 1, @@ -175,7 +183,9 @@ function rows( flush(); const res = isNum(v) ? katexNode(`${valTex(v)}\\ ${unit(id) ? `\\text{${unit(id)}}` : ""}`) - : missing(a, id); + : typeof v === "string" // 글 값(기계 원문번호 등 · 838-3) = 그 글 · 기계면 이름 + ? el("span", { text: machineText(v, () => h.redraw?.()) ?? v }) + : missing(a, id); // 기호식 = 값식 = 결과 — 같은 꼴이 이어지면 한 번만(만약이 수 하나를 탄 줄) const n = nodeOf(t)!; const sym = tex(n, { side: "sym", vals, alias }); @@ -217,7 +227,11 @@ function rows( head, ...expr, " = ", - isNum(v) ? `${eqText(v)} ${unit(id)}` : typeof v === "string" ? v : missing(a, id), + isNum(v) + ? `${eqText(v)} ${unit(id)}` + : typeof v === "string" + ? (machineText(v, () => h.redraw?.()) ?? v) + : missing(a, id), ], }); pack.push(cell); @@ -408,7 +422,7 @@ export function buildOut(doc: StackDoc, a: DocAnswer, h: OutHooks): HTMLElement depth: 0, lv: 0, }; - for (const r of [head, ...rows(doc, a, h, 0, "")]) { + for (const r of [head, ...rows(doc, a, { ...h, cover: coverOf(doc) }, 0, "")]) { const cell = (k: "sum" | (typeof COSTS)[number]): string => { const v = r.cols?.[k]; if (v === undefined || (r.won && !v && k !== "sum")) return ""; diff --git a/A00_Common/line_stack_rows.ts b/A00_Common/line_stack_rows.ts index 061f8d62b..3011151b8 100644 --- a/A00_Common/line_stack_rows.ts +++ b/A00_Common/line_stack_rows.ts @@ -24,6 +24,7 @@ import { } from "../common_util/common_util_formula_doc"; import { isNum } from "../common_util/common_util_formula_doc_calc"; import { decide, hasIf, tryParse } from "../common_util/common_util_formula_doc_view"; +import { machineText } from "./formula_doc_options"; import { dropSyms, freshId, @@ -157,6 +158,8 @@ export function buildBlock( const realText = (t: string): string => aliasText(t, alias, true); const valText = (id: string): string => { const v = vals[id]; + const mach = machineText(v, () => h.change(() => undefined)); // 838-3 기계 원문번호 = 이름 + if (mach) return mach; if (isNum(v) || typeof v === "string") return `${eqText(v)} ${shownUnit(vars.get(id)?.단위)}`.trim(); if (v && typeof v === "object") diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts index 7b7fcb392..ece4bbea8 100644 --- a/A00_Common/line_stack_screen.ts +++ b/A00_Common/line_stack_screen.ts @@ -11,7 +11,8 @@ * 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음) * 고침 = 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음 * M02 사본(`copy`) — 저장 몸 {문서, 끊음(받은 그대로), 판} · 판 바뀜(409)은 문서가 그대로면 새 판으로 한 번 더 - * 기계 줄 · 옵션 가로 기계 = 기계 창(827-1 · `line_stack_machine`) — 초안은 문서 칸(캐시) · [저장] 때 서버가 내 기계로 + * 기계 줄 · 옵션 가로 기계 = 기계 창(827-1 · 836-1 찾기 · 바꾸기만) · 기계 내부 값 = 왼쪽 기계 제어 구역(836-1 · + * `line_stack_machine_ctl` — 문서 칸 `기계덮음` · 캐시 · [저장] 때 서버가 다시 셈) * [단가 갱신] · [재생성] = 미리 셈 → 알림 → 저장(`formula_doc_copy.ts` · 고친 것 먼저 저장) * 저장 전 문서(`memory` · 836-5 · 불러오기 미리 보기) = 빈 키 + 서버에 문서째(`본`) 읽음 · 왼쪽 줄 쌓기는 보기만(inert) · 디스크 쓰기 0 * ========================================================================== */ @@ -57,7 +58,6 @@ 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"; @@ -283,18 +283,20 @@ export function createStackScreen(opt: StackScreenOptions) { // ── 그림 ── const edit = (): boolean => opt.admin && (!!key || !cp); - /** 827-1 기계 창 — 개인 층 M02 SK 만 [고쳐 쓰기] · M01 기초 = M01 기계만 · 사본 줄 = 수식 길 `/machine` */ + /** 827-1 기계 창(찾기 · 바꾸기) — 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)}`) } : {}), }); - /** 초안 기계 셈 기록(서버 확인 길 · 요소 · 표 · 식 문서) → 계산기 캐시 — [확인] 직후 브라우저가 바로 셈 */ - const prime = (record: DocRecord): void => cache.merge(record); + /** 836-1 기계 제어 구역 기본값 — 덮음 없이 그 기계 식 셈(캐시에 없는 연결 = 뒤에서 서버) */ + const calcSub = (d: FormulaDoc, g: Record): DocAnswer => { + const got = calcDoc(d, cache.links, g, [], new Map(), {}); + if (cache.takeMissing()) ask(); + return got; + }; const draw = (): void => { if (!doc || !answer) { [head, left, right, foot].forEach((x) => x.replaceChildren()); @@ -372,7 +374,7 @@ export function createStackScreen(opt: StackScreenOptions) { pair: (loc) => pair(loc, "left"), take: takeCtx(), machine: machineCtx(), - prime, + calc: calcSub, before: saved ? lineMap(JSON.parse(saved) as StackDoc) : undefined, }), ] @@ -391,6 +393,7 @@ export function createStackScreen(opt: StackScreenOptions) { }, miss: ask, pair: (loc) => pair(loc, "right"), + redraw: () => draw(), }) : el("p", { className: "ui-text ui-text--muted", text: lt("Empty") }), ); diff --git a/A00_Common/line_stack_text.ts b/A00_Common/line_stack_text.ts index 88476c37a..aa76b2d43 100644 --- a/A00_Common/line_stack_text.ts +++ b/A00_Common/line_stack_text.ts @@ -90,6 +90,24 @@ const TEXT = { "이 값의 줄은 위 옵션이 고름(다른 줄은 옵션에서 바꿈 · 여기선 칸만)", "Row follows the option above (change it there; pick a column here)", ], + Ctl_Open: ["기계 값 · 조종원 · 잡품 고치기", "Edit machine values · operator · sundries"], + Ctl_Values: ["기계 값", "Machine values"], + Ctl_Crew: ["조종원", "Operator"], + Ctl_Misc: ["잡품", "Sundries"], + Ctl_Add: ["더함", "add"], + Ctl_Changed: ["고침", "Edited"], + Ctl_Was: ["원래 값", "Original"], + Ctl_Back: ["원래 값", "Original"], + Ctl_Bad: ["0 이상 수만 넣을 수 있음", "Enter a number 0 or more"], + Ctl_Crew_Qty: ["인원", "Persons"], + Ctl_Per_Day: ["인/일", "per day"], + Ctl_Rate: ["율", "Rate"], + Ctl_Follow: ["(따름)", "(follows)"], + Ctl_None: ["없음", "None"], + Win_Ctl_Note: [ + "기계 내부 값(가격 · 연료 · 조종원 · 잡품)은 단가산출 왼쪽 기계 줄의 ▸ 에서 고침", + "Edit machine internals from the ▸ on the left machine line", + ], } as const; export type LsKey = keyof typeof TEXT; diff --git a/M02_MasterTemplete/M02_Hopyo_Router.py b/M02_MasterTemplete/M02_Hopyo_Router.py index 90abb00a5..9ff588aea 100644 --- a/M02_MasterTemplete/M02_Hopyo_Router.py +++ b/M02_MasterTemplete/M02_Hopyo_Router.py @@ -7,7 +7,7 @@ 그대로 쓰는 길(목록 · 올림 · 엑셀 보기 머리 · 격자 · 지우기 · 모두 고정 · 붙여넣기 · 다시 잇기 · 구조물 조합 · 칠 = B09 · M01 이 씀) · 옛 맞춤 길(호표 하나 맞춤 · 미리 셈 · 실무 호표 · 확인 만들기 · 안 이은 줄 · 단가산출 만들기)은 785-7 에서 지움. -층 권한 = `Router_Items._root` · ⚠ 등록은 `main.py`(로그인만). +층 = 늘 개인(요구 855 · 위 `_root` 가 고정) · ⚠ 등록은 `main.py`(로그인만). """ from __future__ import annotations @@ -33,11 +33,17 @@ from M02_MasterTemplete import M02_Hopyo_View as hopyo_view_ from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_San_MatPick as mat_pick from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo -from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import _root as _layer_root router = APIRouter(prefix="/api/m02", tags=["M02 Hopyo"]) +def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path: + """불러오기 자리 = 늘 자기 개인 층(요구 855) — 경로 층 · 주인 글 · 몸 층은 받되 무시(회사 · 마스터 · 남의 층 불가).""" + return _layer_root(session, "personal", None, write=write) + + # ── [수정] 보기(809-1) — 경쟁사 로직 인정 · 요소만 연결 · 읽기만 ───────────────── diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts index c3fe645d7..64ef55d08 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts @@ -1,8 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Import.ts * 일위대가 불러오기 본 칸 보기(PLAN 785-4 · 요구 642-11 — 마법사 `Hopyo_Wizard` 갈음 · 가운데 상세 페이지 없음) — - * 왼쪽 [불러오기] → 층 · 파일 고름 → 올림(`Hopyo_Side`) → 바로 이 보기: - * · 머리 막대 = 엑셀 보기 몸(`Hopyo_Sheet` — 시트 · 호표 · 검산 · 연결)의 머리에 층 · [끝내기]를 덧붙임 + * 왼쪽 [불러오기] → 파일 고름 → 올림(`Hopyo_Side` · 층 가르기 없음 — 누가 하든 자기 개인 층 · 요구 855) → 바로 이 보기: + * · 머리 막대 = 엑셀 보기 몸(`Hopyo_Sheet` — 시트 · 호표 · 검산 · 연결)의 머리에 [끝내기]를 덧붙임 * · 아래 = 엑셀(탭 셋) + 오른쪽 호표 · 단가산출 목록(`Hopyo_Sheet` · `Hopyo_SheetSk`) · 만들기 = 호표 줄 [내 일위대가로](807-1) * · [끝내기] = 물음(안 만든 단가산출 알림) → 서버가 이번 HP · 격자 저장본 지움 · * 만든 일위대가 · 단가산출(엑셀 사본 걷고 출처만) · 별칭 장부 남음 · 옛 「안 이은 줄」 띠는 785-7 에서 지움(판정 「고를 것」) @@ -10,34 +10,19 @@ * 하루 지난 것은 서버가 지움 · 엑셀 자료는 보관하지 않음(필요하면 다시 올림) * ========================================================================== */ -import { createBadge } from "@ui/ui_template_badge"; import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; -import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { deleteHopyo, type HopyoFile, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; import type { HopyoSheetBody } from "./M02_MasterTemplete_Hopyo_Sheet"; -import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer"; import "./M02_MasterTemplete_Hopyo.css"; -export const LAYERS: { layer: ManageLayer; text: string }[] = [ - { layer: "personal", text: "개인" }, - { layer: "company", text: "회사" }, - { layer: "system", text: "마스터" }, -]; -export const layerText = (layer: string): string => - LAYERS.find((x) => x.layer === layer)?.text ?? layer; +/** 불러오기 자리 = 늘 자기 개인 층(요구 855 — 층 가르기 없음) */ +export const MINE: HopyoScope = { layer: "personal", owner: null }; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); -/** 쓸 수 있는 자기 층(개인 · 회사 · 마스터 차례) */ -export async function writableLayers(): Promise { - const user = await fetchSessionUser().catch(() => null); - return LAYERS.map((x) => x.layer).filter((layer) => accessFor(layer, null, user).canWrite); -} - export interface HopyoImportArgs { - layer: ManageLayer; file: HopyoFile; /** [끝내기] 로 올린 파일을 지웠음 */ onEnded: () => void; @@ -52,8 +37,8 @@ export interface HopyoImport { } export function buildHopyoImport(a: HopyoImportArgs): HopyoImport { - const { layer, file } = a; - const scope: HopyoScope = { layer, owner: null }; + const { file } = a; + const scope = MINE; let held = true; let body: HopyoSheetBody | null = null; @@ -74,7 +59,6 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport { // ── 머리 막대에 덧붙일 것 ── const finish = createButton({ label: "끝내기", variant: "filled", size: 28, onClick: () => void end() }); // prettier-ignore finish.dataset.hopyoFinish = ""; - const layerNote = createBadge(`${layerText(layer)} 층`, "neutral"); async function end(): Promise { finish.disabled = true; @@ -111,7 +95,7 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport { 파일: file.파일, target: scope, canWrite: true, - headExtra: [layerNote, finish], + headExtra: [finish], }); body = view; root.replaceChildren(...view.parts); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index 7ede0c750..563c14f1d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -3,8 +3,8 @@ * 좌측 「일위대가 불러오기」 컨테이너(PLAN 324-4 · 718-1 한시 · 785-4) — * · [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) = 바로 파일 하나 고르기 창(엑셀 · 경쟁사 .estx · .STC · 814-1) → 올림 → 본 칸 불러오기 보기 * (`M02_MasterTemplete_Hopyo_Import` · 엑셀 + 오른쪽 호표 목록 · 가운데 상세 페이지 없음 · 누름 하나) - * · 결과 층 고름 = 작은 고름(쓸 수 있는 층 둘 이상일 때만 · 일반 = 개인 하나라 안 보임) - * · 「작업 중」 줄 = 층마다 서버 목록(같은 엑셀 작업 중 이어서) · 누르면 본 칸 불러오기 보기 + * · 층 가르기 없음 — 누가 하든 올린 것 · 만든 것 = 자기 개인 층(요구 855 · 공유는 만든 뒤 본인이) + * · 「작업 중」 줄 = 자기 개인 층 서버 목록(같은 엑셀 작업 중 이어서) · 누르면 본 칸 불러오기 보기 * · 다른 엑셀 [불러오기] · 다른 화면으로 = 지금 보기 떠남(올린 파일 지움 · 743-1) — 고르고 안 만든 확인 캐시가 있으면 물음 한 번 * 옛 [별칭 장부](338-3)는 걷음(815-2) — 별칭은 M01 요소 「별칭」 칸 · M01 별칭 모아 보기. * ========================================================================== */ @@ -12,22 +12,9 @@ import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { createEmpty } from "@ui/ui_template_empty"; import { createListRow } from "@ui/ui_template_list_row"; -import { createSegment } from "@ui/ui_template_segment"; import { t as L } from "@ui/ui_template_locale"; -import { - listHopyo, - uploadHopyo, - type HopyoFile, - type HopyoScope, -} from "./M02_MasterTemplete_Hopyo_Api"; -import { - buildHopyoImport, - LAYERS, - layerText, - writableLayers, - type HopyoImport, -} from "./M02_MasterTemplete_Hopyo_Import"; -import type { ManageLayer } from "./M02_MasterTemplete_Layer"; +import { listHopyo, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api"; +import { buildHopyoImport, MINE, type HopyoImport } from "./M02_MasterTemplete_Hopyo_Import"; export interface HopyoSectionOptions { /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ @@ -46,12 +33,9 @@ export interface HopyoSection { } const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); -const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null }); export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { const title = L("M02_HopyoImport"); - let layers: ManageLayer[] = []; - let pick: ManageLayer = "personal"; const input = el("input", { attrs: { type: "file", accept: ".xls,.xlsx,.estx,.stc", "data-hopyo-file": "" }, }) as HTMLInputElement; @@ -65,50 +49,25 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { start.title = "파일 하나 — 경쟁사 엑셀(.xls · .xlsx) 또는 경쟁사 파일(.estx · .STC) → 우리 정보로 다시 만듦(한시 · 끝내거나 떠나면 올린 파일 지움)"; start.dataset.hopyoWizardOpen = ""; - const seg = el("div", { className: "m02-side__row" }); - seg.hidden = true; const jobs = el("div", { className: "m02-hopyo-side", attrs: { "data-hopyo-jobs": "" } }); const body = el("div", { className: "m02-hopyo-side", - children: [el("div", { className: "m02-side__row", children: [start, input] }), seg, jobs], + children: [el("div", { className: "m02-side__row", children: [start, input] }), jobs], }); let open: { key: string; view: HTMLElement; imp?: HopyoImport } | null = null; - /** 층 고름 · 작업 중 줄 — 쓸 수 있는 층을 처음 한 번 읽음 */ + /** 작업 중 줄 — 자기 개인 층 서버 목록 */ async function refresh(): Promise { - if (!layers.length) { - layers = await writableLayers(); - if (!layers.includes(pick)) pick = layers[0] ?? "personal"; - if (layers.length > 1) { - const s = createSegment({ - options: LAYERS.filter((x) => layers.includes(x.layer)).map((x) => ({ value: x.layer, label: x.text })), // prettier-ignore - value: pick, - size: 24, - onChange: (v) => (pick = v as ManageLayer), - }); - s.root.dataset.hopyoLayer = ""; - seg.replaceChildren(s.root); - seg.hidden = false; - } - } - const rows = ( - await Promise.all( - layers.map((layer) => - listHopyo(scopeOf(layer)) - .then((list) => list.map((f) => ({ layer, f }))) - .catch(() => []), - ), - ) - ).flat(); + const rows = await listHopyo(MINE).catch(() => []); jobs.replaceChildren( ...(rows.length - ? rows.map(({ layer, f }) => { + ? rows.map((f) => { const row = createListRow({ label: f.파일, - sub: `호표 ${f.호표수} · ${layerText(layer)} — 이어서`, + sub: `호표 ${f.호표수} — 이어서`, size: 28, active: open?.imp !== undefined && open.key === f.키, - onClick: () => void show(layer, f), + onClick: () => void show(f), }); row.dataset.hopyoKey = f.키; return row; @@ -119,8 +78,6 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { /** [불러오기] — 지금 보기를 떠날 수 있는지 묻고 바로 파일 고르기 창 */ async function choose(): Promise { - if (!layers.length) await refresh().catch(() => {}); - if (!layers.length) return void showToast("쓸 수 있는 층 없음", "warning"); if (!(await leaveOk())) return; input.click(); } @@ -129,14 +86,14 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { input.value = ""; if (!file) return; start.disabled = true; - uploadHopyo(scopeOf(pick), file) + uploadHopyo(MINE, file) .then((got: HopyoFile & { 이어서?: boolean; 원파일?: boolean }) => { const tool = /\.estx$/i.test(file.name) ? "EST" : "STmate"; // 810-3 원파일 올림 안내 if (got.이어서) showToast(`같은 파일 작업 중 — ${got.키} 이어서`, "info"); else if (got.원파일) showToast(`${tool} 파일 — 단가산출표 · 산출근거`, "success"); else showToast(`올림 — 호표 ${got.호표수}`, got.어긋남 ? "warning" : "success"); if (got.읽기불가) showToast(`가져온 식으로 열 수 없음 — ${got.읽기불가}`, "warning"); - return show(pick, got, true); + return show(got, true); }) .catch((error) => showToast(why(error), "error")) .finally(() => (start.disabled = false)); @@ -153,12 +110,11 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { } /** 본 칸에 불러오기 보기 — 같은 파일이면 그대로 */ - async function show(layer: ManageLayer, file: HopyoFile, checked = false): Promise { + async function show(file: HopyoFile, checked = false): Promise { if (open?.imp && open.key === file.키 && open.view.isConnected) return; if (!checked && !(await leaveOk())) return; open?.imp?.leave(); // 불러오기 떠남 = 올린 파일 지움(743-1) const imp = buildHopyoImport({ - layer, file, onEnded: () => { open = null; diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index 98b4ec47e..056aace19 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -5,7 +5,7 @@ 가져온 식(793-2 · `M02_Sangeun_Imported`) `…/sangeun/imported`(만들기 · 층 쓰기) · `/price` · `/link`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기 · 기초로 바꾸기 없음 · 809-1) · 불러오기 단산 블록 → 가져온 식(793-3b · `M02_Sangeun_FromHopyo`) `…/sangeun/from-hopyo` -(만들 층 쓰기 · 몸 `문서` = 미리 보기에서 고친 문서) · 미리 보기 `…/from-hopyo/preview`(층 읽기 · 안 씀 · 835-1). +(늘 개인 층 쓰기 · 몸 `문서` = 미리 보기에서 고친 문서) · 미리 보기 `…/from-hopyo/preview`(층 읽기 · 안 씀 · 835-1). 로직 사본(795-1 · `M02_Sangeun_Copy`) `…/sangeun/copy/price` [단가 갱신] · `/regen` [재생성] (화면 캐시 문서에 할일 얹음 · 저장 없음 · 층 읽기). 수식 탭(796-2b · `M02_Sangeun_Equation`) `…/sangeun/equation`(층 읽기) · `/preview`(고침 미리 셈 · @@ -176,8 +176,8 @@ def sangeun_from_hopyo( ) -> dict: """불러오기 단산 블록 → 가져온 식 SK(793-3b) — 경로 층 = 만들 층 · 몸 층 = 파일 층 · 답 = {이름, 검산, 단가기준, 다시씀}(같은 내용 = 그 SK).""" - folder = _root(session, layer, owner, write=True) - source = _root(session, body.층, body.주인) + folder = _root(session, "personal", None, write=True) # 불러오기 = 늘 개인 층(요구 855) + source = _root(session, "personal", None) mine = _root(session, "personal", None, write=body.고름 is not None) fp, name = _call(source, mat_pick.fingerprint, source, body.키) # 827-3 그 파일 재료 고름 if body.고름 is not None and fp: @@ -200,8 +200,8 @@ def sangeun_from_hopyo_preview( ) -> dict: """[가져온 식으로 열기] 미리 보기(835-1 · 요구 852-1) — 층에 안 씀(재료 고름도 메모리) · 답 = {문서, 검산, 같은것(같은 내용 SK 키 | None)} · [저장] = `from-hopyo` 몸 `문서`.""" - folder = _root(session, layer, owner) - source = _root(session, body.층, body.주인) + folder = _root(session, "personal", None) # 불러오기 = 늘 개인 층(요구 855) + source = _root(session, "personal", None) mine = _root(session, "personal", None) fp, _ = _call(source, mat_pick.fingerprint, source, body.키) picks = mat_pick.merge(mat_pick.read(mine, fp)["고름"], body.고름) if fp else {} diff --git a/common_util/common_util_formula_doc_cover.ts b/common_util/common_util_formula_doc_cover.ts index b1093e794..6b598089d 100644 --- a/common_util/common_util_formula_doc_cover.ts +++ b/common_util/common_util_formula_doc_cover.ts @@ -41,6 +41,8 @@ const HOUR_LINE = { 기호: HOUR, 식: "Q_분자^표 ÷ Q_분모^표" }; export interface CrewRow { 키: string; 인: number; + /** 화면 이름(직종) — 셈 · 원본 대조 안 봄 */ + 이름?: string; } export interface MiscRow { 줄?: string; @@ -166,12 +168,14 @@ export function defaultsOf(doc: FormulaDoc, vals: Vals): Required return { 값, 조종원, 잡품 }; } +/** 원본 대조 꼴 — 이름 · 단위(화면 글) · 빈 칸 뺌 · 수 12 자리 */ +const LABEL = ["이름", "단위"]; const canon = (v: unknown): string => JSON.stringify(v, (_, x: unknown) => x && typeof x === "object" && !Array.isArray(x) ? Object.fromEntries( Object.entries(x as Record) - .filter(([, y]) => y !== undefined && y !== null && y !== "") + .filter(([k, y]) => y !== undefined && y !== null && y !== "" && !LABEL.includes(k)) .sort(([a], [b]) => (a < b ? -1 : 1)), ) : typeof x === "number" @@ -238,6 +242,7 @@ function crewLines(doc: FormulaDoc, rows: CrewRow[]): MoneyLine[] { } return rows.map((r) => ({ 이름: "조종원", + ...(r.이름 ? { 규격: r.이름 } : {}), 단위: "인", 비목: "노무비", 수량: `${plain(r.인)} × ${HOUR}`, diff --git a/resources/master_data/scripts/master_doc_cover.py b/resources/master_data/scripts/master_doc_cover.py index b7fe9b260..7258111f6 100644 --- a/resources/master_data/scripts/master_doc_cover.py +++ b/resources/master_data/scripts/master_doc_cover.py @@ -145,10 +145,15 @@ def defaults_of(doc: dict, vals: dict) -> dict: return {"값": values, "조종원": crew, "잡품": misc} +_LABEL = ("이름", "단위") # 화면 글 — 원본 대조 안 봄 + + def _canon(v) -> str: def norm(x): if isinstance(x, dict): - return {k: norm(y) for k, y in sorted(x.items()) if y not in (None, "")} + return { + k: norm(y) for k, y in sorted(x.items()) if y not in (None, "") and k not in _LABEL + } if isinstance(x, list): return [norm(y) for y in x] if _num(x) is not None: @@ -209,7 +214,8 @@ def _crew_lines(doc: dict, rows: list) -> list[dict]: ) doc.setdefault("식", []).append(dict(_HOUR_LINE)) return [ - {"이름": "조종원", "단위": "인", "비목": "노무비", "수량": f"{_plain(r['인'])} × {HOUR}", + {"이름": "조종원", **({"규격": r["이름"]} if r.get("이름") else {}), "단위": "인", + "비목": "노무비", "수량": f"{_plain(r['인'])} × {HOUR}", "단가": _add_var(doc, "w", "조종원", "조종원 노임", r["키"])} for r in rows ] # fmt: skip diff --git a/resources/tester/test_705_2_hopyo_paste.py b/resources/tester/test_705_2_hopyo_paste.py index b567d7af9..257ad1276 100644 --- a/resources/tester/test_705_2_hopyo_paste.py +++ b/resources/tester/test_705_2_hopyo_paste.py @@ -352,10 +352,11 @@ def test_구조물_조합으로_규격_묶음_울진_돌기슭막이(client): assert master_hash() == before -def test_구조물_조합으로_남의_층_403(client): +def test_구조물_조합으로_경로_층_무시_늘_개인(client): # 837-1 · 요구 855 — 옛 「남의 층 403」 갈음 key = _upload(client, "대흥") body = {"층": "personal", "키": key, "번": 17} - assert client.post("/api/m02/layers/company/hopyo-item", json=body).status_code == 403 + assert client.post("/api/m02/layers/company/hopyo-item", json=body).status_code == 200 + assert list((layers_dir() / "labor").glob("*.json")) # 쓴 곳 = 개인 층(`layers_dir`) def test_구조물_조합으로_같은_내용_409_씀_새로(client): diff --git a/resources/tester/test_785_4_import_screen.py b/resources/tester/test_785_4_import_screen.py index 24b212d9b..75a2a7188 100644 --- a/resources/tester/test_785_4_import_screen.py +++ b/resources/tester/test_785_4_import_screen.py @@ -27,20 +27,20 @@ def test_누름_하나_바로_엑셀(): choose = side.split("async function choose()")[1].split("input.addEventListener")[0] assert "input.click();" in choose # [불러오기] = 바로 파일 고르기 창 change = side.split('input.addEventListener("change"')[1].split("async function leaveOk")[0] - assert ( - "uploadHopyo(scopeOf(pick), file)" in change and "return show(pick, got, true);" in change - ) + assert "uploadHopyo(MINE, file)" in change and "return show(got, true);" in change show = side.split("async function show(")[1].split("async function showLedger")[0] assert "buildHopyoImport({" in show and "opt.show(" in show # 올림 뒤 본 칸 바로 imp = _src("Import") - assert "m.buildHopyoSheet({" in imp and "headExtra: [layerNote, finish]" in imp + assert "m.buildHopyoSheet({" in imp and "headExtra: [finish]" in imp -def test_결과_층_고름_둘_이상일_때만(): - side = _src("Side") - assert "layers.length > 1" in side and "seg.hidden = false;" in side - assert "seg.hidden = true;" in side # 처음 숨김 — 일반(개인 하나)은 안 보임 - assert 'let pick: ManageLayer = "personal";' in side +def test_층_가르기_없음_늘_개인(): # 837-1 · 요구 855 — 예전 「결과 층 고름」 걷음 + side, imp = _src("Side"), _src("Import") + for gone in ("createSegment", "writableLayers", "LAYERS", "layerText", "seg.hidden", "pick"): + assert gone not in side, gone + for gone in ("LAYERS", "layerText", "writableLayers", "layerNote", "createBadge"): + assert gone not in imp, gone + assert 'export const MINE: HopyoScope = { layer: "personal", owner: null };' in imp def test_끝내기_머리_막대_끝냄_뒤_빈_본_칸(): @@ -71,5 +71,5 @@ def test_부품_상자만_700줄_늘리지_않음(): def test_해시_캐시_열쇠_없음(): # 809-1 — 판정 고름 캐시(`Hopyo_Picks`) 걷음 · 옮기기 = [내 일위대가로] assert not (M02 / "logic" / "M02_MasterTemplete_Hopyo_Picks.ts").exists() - assert "draftKey" not in _src("Import") and "dirty: () => false" in _src("Import") + assert "draftKey" not in _src("Import") and "dirty: () => body?.matDirty()" in _src("Import") assert "location.hash" not in _src("Import") + _src("Side") # 새 해시 열쇠 없음 diff --git a/resources/tester/test_836_1_machine_cover.py b/resources/tester/test_836_1_machine_cover.py index 55941ff82..0cbf1cf35 100644 --- a/resources/tester/test_836_1_machine_cover.py +++ b/resources/tester/test_836_1_machine_cover.py @@ -229,7 +229,7 @@ def _same(a, b, where=""): COVERS = [ None, {"값": {"가격": 150000, "주연료종류": "휘발유"}}, - {"조종원": [{"키": "LB:1048", "인": 2}, {"키": "LB:1002", "인": 0.5}]}, + {"조종원": [{"키": "LB:1048", "인": 2, "이름": "운전사"}, {"키": "LB:1002", "인": 0.5}]}, {"조종원": []}, {"잡품": [{"줄": "주연료", "율": 30}, {"키": "LB:1048", "이름": "시험", "수량": 2, "율": 5}]}, {"잡품": [{"줄": "주연료", "수량": 10, "율": 22}]}, @@ -334,3 +334,8 @@ def test_저장_다시_열기_되돌리면_덮음_없음(world): doc[mcv.COVER] = {NO: {"값": {"가격": -5}}} with pytest.raises(store.StoreError): at(path.save, key, {"문서": doc, "판": back["판"]}) + + +def test_이름_단위는_원본_대조_안_봄(): + base = {"값": {}, "조종원": [{"키": "LB:1048", "인": 1}], "잡품": []} + assert mcv.prune({"조종원": [{"키": "LB:1048", "인": 1, "이름": "운전사"}]}, base) is None diff --git a/resources/tester/test_837_1_import_personal_only.py b/resources/tester/test_837_1_import_personal_only.py new file mode 100644 index 000000000..96d23093d --- /dev/null +++ b/resources/tester/test_837_1_import_personal_only.py @@ -0,0 +1,46 @@ +"""837-1 일위대가 불러오기 = 늘 자기 개인 층(요구 855) — 서버 · 화면 없이 돎. +불러오기 길(`M02_Hopyo_Router` · `Sangeun_Router` from-hopyo)은 경로 층 · 주인 · 몸 층을 무시하고 개인 층만 씀.""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from M02_MasterTemplete import M02_Hopyo_Router as hopyo # noqa: E402 + + +def test_불러오기_길_층_고정(monkeypatch): + seen: list[tuple] = [] + monkeypatch.setattr( + hopyo, + "_layer_root", + lambda s, layer, owner=None, write=False: seen.append((layer, owner, write)), + ) + for layer, owner in (("company", "3"), ("system", None), ("personal", "3/9")): + hopyo._root({}, layer, owner, write=True) + hopyo._root({}, layer, owner) + assert seen == [("personal", None, True), ("personal", None, False)] * 3 + + +def test_라우터_루트_모두_고정_함수(): + src = (ROOT / "M02_MasterTemplete" / "M02_Hopyo_Router.py").read_text(encoding="utf-8") + assert "import _root as _layer_root" in src + assert "_layer_root(" in src.split("def _root(")[1].split("\n\n")[0] + assert src.count("_layer_root(") == 1 # 층 고르는 길은 이 한 곳뿐 + + +def test_단산_블록_만들기_개인_층만(): + src = (ROOT / "M02_MasterTemplete" / "M02_Sangeun_Router.py").read_text(encoding="utf-8") + for fn in ("def sangeun_from_hopyo(", "def sangeun_from_hopyo_preview("): + body = src.split(fn)[1].split("\n@router")[0] + assert "body.층" not in body and "_root(session, layer" not in body, fn + assert '_root(session, "personal", None' in body, fn + + +def test_화면_층_고름_없음(): + m02 = ROOT / "M02_MasterTemplete" + side = (m02 / "M02_MasterTemplete_Hopyo_Side.ts").read_text(encoding="utf-8") + imp = (m02 / "M02_MasterTemplete_Hopyo_Import.ts").read_text(encoding="utf-8") + assert "createSegment" not in side and "writableLayers" not in side + imp + assert '"회사"' not in side + imp and '"마스터"' not in side + imp