diff --git a/A00_Common/spreadsheet/spreadsheet_link_set.ts b/A00_Common/spreadsheet/spreadsheet_link_set.ts index 8f740992c..65822f7be 100644 --- a/A00_Common/spreadsheet/spreadsheet_link_set.ts +++ b/A00_Common/spreadsheet/spreadsheet_link_set.ts @@ -53,3 +53,22 @@ export function linkCommand( if (!commands.length) return null; return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }; } + +/** 면적 문서에 알약 키를 잇기 · 풀기(`칸` null) — 활성 시트에 · 새 문서를 돌려줌(받은 문서는 그대로 · + * 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null. 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */ +export function linkAreaCell( + book: T, + key: string, + cell: string | null, + info: LinkInfo = {}, +): T | null { + const sheet = book.시트.find((s) => s.id === book.활성) ?? book.시트[0]; + if (!sheet) return null; + const command = linkCommand(book, sheet.id, key, cell, info); + if (command === false) return null; + const next = structuredClone(book); + const list = !command ? [] : command.종류 === "묶음" ? command.명령 : [command]; + for (const c of list) + if (c.종류 === "시트통째") next.시트 = next.시트.map((s) => (s.id === c.시트.id ? c.시트 : s)); + return next; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 5bc26211c..2b1982f93 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -226,6 +226,19 @@ export const copyPartFrom = async ( }); }; +/** 항목 하나 비우기(40-4 · 카드 [삭제]) — 새 열과 같은 빈 문서 · 번호 · 열 그대로 · 지금 층 */ +export const clearPart = ( + kind: "structure" | "labor" | "area", + column: string, +): Promise => + call( + owned( + activeLayer, + `/layers/${activeLayer}/${kind === "structure" ? "structures" : kind}/${enc(column)}/clear`, + ), + { method: "POST" }, + ); + /** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */ export const copyStructure = async (column: string, from: string): Promise => { await scoped(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts new file mode 100644 index 000000000..8012ac0e6 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts @@ -0,0 +1,117 @@ +/* ============================================================================= + * M02_MasterTemplete_AreaCellPick.ts + * 엑셀 칸 고르기 모달(PLAN 40-3) — 그 열 면적산출 문서를 읽기만 시트로 띄우고 칸을 누르면 그 주소를 돌려줌. + * 일위대가 상세(sub2)가 「수량 → 엑셀에서 고르기」 에 씀 → 돌려받은 칸을 `linkAreaCell` 로 면적 문서에 이어 + * 일위대가 [저장] 때 함께 저장(여기는 저장 안 함). + * · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 이 알약이 이은 칸은 머리 글에 + * · 칸 누름 · Enter = 그 칸 · [연결 풀기] = 풀기 · 닫기 · Esc · 바깥 = 그만둠(null) + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import { readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import "./M02_MasterTemplete_AreaPanel.css"; + +export { linkAreaCell } from "../A00_Common/spreadsheet/spreadsheet_link_set"; + +const TEXT = { + title: "엑셀에서 고르기 — {value}", + close: "닫기", + hint: "칸을 누르면 그 칸으로 이음 · Enter 도 됨", + now: "지금 연결 {value}", + none: "지금 연결 없음", + unlink: "연결 풀기", + loading: "면적산출을 읽는 중…", + failed: "면적산출을 못 읽음 — {value}", +}; + +const fill = (text: string, value: string): string => text.replace("{value}", value); + +export interface AreaCellPickOptions { + /** 그 열 면적산출 문서(저장 안 한 것 포함) — 없으면 `layer` · `column` 저장본을 읽음 */ + doc?: Workbook | null; + layer: Layer; + /** 집계표 열 id */ + column: string; + /** 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — 지금 이은 칸을 보임 */ + pill: string; + /** 모달 제목에 쓸 줄 이름 */ + label: string; +} + +/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸 */ +export type AreaCellPick = { 칸: string | null } | null; + +export function pickAreaCell(opts: AreaCellPickOptions): Promise { + return new Promise((resolve) => { + let handle: SpreadsheetHandle | null = null; + let done = false; + let picked = "A1"; + let moved = false; // 누른 사이 고름이 바뀜 — 머리 · 굴림 막대를 눌렀다 뗀 것은 안 고름 + const finish = (value: AreaCellPick): void => { + if (done) return; + done = true; + handle?.destroy(); + handle = null; + resolve(value); + }; + const { close } = openModal({ + title: fill(TEXT.title, opts.label), + closeLabel: TEXT.close, + dialogClass: "m02-modal m02-cellpick", + beforeClose: () => (finish(null), true), + mount: (body) => { + const head = el("p", { className: "m02-cellpick__now", text: TEXT.loading }); + const unlink = createButton({ + label: TEXT.unlink, + variant: "ghost", + size: "sm", + onClick: () => (finish({ 칸: null }), close()), + }); + unlink.hidden = true; + const host = el("div", { className: "m02-cellpick__sheet" }); + body.append( + el("div", { className: "m02-cellpick__head", children: [head, unlink] }), + el("p", { className: "m02-cellpick__hint", text: TEXT.hint }), + host, + ); + const choose = (): void => (finish({ 칸: picked }), close()); + void (async () => { + try { + const [{ createSpreadsheet }, book] = await Promise.all([ + import("../A00_Common/spreadsheet/spreadsheet"), + opts.doc + ? Promise.resolve(opts.doc) + : readTemplate(opts.layer, "area", opts.column, null).then( + (got) => got.문서 as Workbook, + ), + ]); + if (done) return; + const at = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === opts.pill)?.칸; + head.textContent = at ? fill(TEXT.now, at) : TEXT.none; + unlink.hidden = !at; + handle = createSpreadsheet(host, structuredClone(book), { + readOnly: true, + tabs: false, + onSelect: (s) => { + picked = s.칸; + moved = true; + }, + }); + // 칸을 눌러 뗌 = 그 칸(처음 고름 A1 · 화살표 옮김은 안 닫음) + const grid = host.querySelector(".ss-grid-host"); + grid?.addEventListener("mousedown", () => (moved = false), true); + grid?.addEventListener("mouseup", (e) => { + if ((e as MouseEvent).button === 0 && moved) choose(); + }); + host.addEventListener("keydown", (e) => e.key === "Enter" && choose()); + } catch (error) { + if (done) return; + head.textContent = fill(TEXT.failed, error instanceof Error ? error.message : ""); + } + })(); + }, + }); + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css index 9f7c0e18d..36e932845 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css @@ -62,3 +62,26 @@ flex: 1 1 auto; margin: 0; } + +/* 엑셀 칸 고르기 모달 — 머리(지금 연결 · [연결 풀기]) · 알림 · 읽기만 시트(높이를 정해야 시트가 보임) */ +.m02-cellpick { + width: min(960px, 92vw); +} + +.m02-cellpick__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); +} + +.m02-cellpick__now, +.m02-cellpick__hint { + margin: 0 0 var(--spacing-8); + color: var(--color-text-secondary); +} + +.m02-cellpick__sheet { + height: min(60vh, 520px); + min-height: 240px; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 8d18b5f90..964c7ddcd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -211,3 +211,56 @@ text-align: right; } } + +/* 한 줄 표(카드용 · 40-1) — 금액 = 수량 반영 값 · 반영이 없으면 기준 값 흐리게 */ +.m02-labline { + width: 100%; + font-size: var(--text-caption); +} + +.m02-labline th, +.m02-labline td { + padding: var(--spacing-4) var(--spacing-6, 6px); +} + +.m02-labline th:nth-child(n + 4), +.m02-labline .m02-labrow__num { + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.m02-labline .m02-labrow__name { + display: table-cell; + min-width: 0; + overflow-wrap: anywhere; +} + +.m02-labline .m02-labrow__qty, +.m02-labline .m02-labrow__unit { + color: var(--color-text-secondary); + overflow-wrap: anywhere; +} + +.m02-labline .is-base { + color: var(--color-text-secondary); + font-style: italic; +} + +.m02-labline .m02-labrow__sum td { + font-weight: 600; + border-top: 2px solid var(--color-border); +} + +/* 아주 좁으면(카드 3열 · 400px 이하) 결과 단위 칸을 접음 — 수량산출 글에 단위가 같이 보임 */ +@container m02-labview (max-width: 400px) { + .m02-labline th:nth-child(3), + .m02-labline .m02-labrow__unit { + display: none; + } + + .m02-labline th, + .m02-labline td { + padding: var(--spacing-4) 3px; + } +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index fb987d593..9c2ab01b1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -25,7 +25,7 @@ const TEXT = { "가운데 단추 또는 Space + 끌기로 판 옮김 · Ctrl + 휠로 확대", "Middle button or Space + drag to move · Ctrl + wheel to zoom", ], - None: ["담은 로직이 없음", "No logics yet"], + None: ["단가산출 로직이 없음", "No logics yet"], Open: [ "누르면 이 로직을 단가산출 로직 화면에서 엶", "Open this logic in the unit-price logic screen", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index e41adbce8..efa456a74 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -51,6 +51,28 @@ export function qtyText( return `${tc("Labor_Qty")} ${cell} = ${v}`; } +/** 결과단위 기준 수량 — 분모 앞 수 · 분모(「10 ㎡」 · 「1 ㎥」) · 분모 없으면 빈 글 */ +export function baseQtyText( + line: { 배?: number | null; 분모?: string | null } | undefined, +): string { + if (!line?.분모) return ""; + return `${num(line.배 || 1)} ${line.분모}`; +} + +/** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「B6 = 12.5」 · 「B6 = 빈 칸」 · 없으면 「—」 */ +export function qtyShort( + line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, + unit: string, + cells: ReadonlyMap = new Map(), +): string { + const at = line?.알약 ? cells.get(line.알약) : undefined; + if (!line || line.수량 === null || line.수량 === undefined) + return at ? `${at} = ${tc("In_QtyEmpty")}` : "—"; + if (line.수량출처 === "손") + return `${tc("Labor_Hand")} ${num(line.수량)}${unit ? ` ${unit}` : ""}`; + return `${at ?? tc("In_QtyCell")} = ${num(line.수량)}`; +} + /** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */ function moneyCell( base: number | null | undefined, @@ -307,3 +329,114 @@ export function matTable(data: MatTableData, on?: Omit): HT ? table(heads, trs, []) : el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") }); } + +export interface CompactData { + rows: LaborRow[]; + mats: LaborMat[]; + logics: Map; + calc: LaborCalc | null; + fail?: string | null; + cells?: ReadonlyMap; +} + +/** 한 줄 표(카드용 · 40-1) — 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계 · 금액 = 수량 반영 값 · + * 반영이 없으면 기준 값 흐리게(`is-base`) · 자재 줄은 같은 표 아래(재료비 · 합계만) · 맨 아래 합계 줄 */ +export function compactTable(data: CompactData): HTMLElement { + const wait = !data.calc && !data.fail; + const cell = (v: number | null | undefined, base: boolean, label: string): HTMLElement => + el("td", { + className: `m02-labrow__num${base ? " is-base" : ""}`, + text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v), + attrs: { "data-label": label, ...(base ? { title: tc("Labor_Base") } : {}) }, + }); + const heads = [ + tc("Col_Name"), + tc("Labor_QtyCol"), + tc("Col_Unit"), + ...COSTS.map((c) => tc(HEAD[c])), + ]; + const lines = data.rows.map((row) => { + const line = data.calc?.줄.find((l) => l.로직 === row.로직); + const money = line?.반영 ?? line?.기준; + const base = !line?.반영 && !!line?.기준; + const logic = data.logics.get(row.로직); + return el("tr", { + className: "m02-labrow__row", + attrs: { "data-labor-logic": row.로직 }, + children: [ + el("td", { + className: "m02-labrow__name", + text: logic?.이름 ?? row.로직, + attrs: { title: `${row.로직}${line?.까닭 ? ` · ${line.까닭}` : ""}` }, + }), + el("td", { + className: "m02-labrow__qty", + text: qtyShort(line, line?.분모 ?? "", data.cells), + attrs: { "data-label": tc("Labor_QtyCol") }, + }), + el("td", { className: "m02-labrow__unit", text: logic?.결과단위 ?? "" }), + ...COSTS.map((c) => cell(money?.[c], base, tc(HEAD[c]))), + ], + }); + }); + const mats = data.mats.map((mat) => { + const line = data.calc?.자재.find((m) => m.코드 === mat.코드); + const has = line?.반영 !== null && line?.반영 !== undefined; + const v = has ? line?.반영 : line?.기준단가; + return el("tr", { + className: "m02-labrow__row is-mat", + attrs: { "data-labor-mat": mat.코드 }, + children: [ + el("td", { + className: "m02-labrow__name", + text: mat.이름 ?? mat.코드, + attrs: { title: [mat.코드, mat.규격].filter(Boolean).join(" · ") }, + }), + el("td", { + className: "m02-labrow__qty", + text: qtyShort(line, mat.단위 ?? "", data.cells), + attrs: { "data-label": tc("Labor_QtyCol") }, + }), + el("td", { className: "m02-labrow__unit", text: mat.단위 ?? "" }), + el("td", { className: "m02-labrow__num", attrs: { "data-label": tc(HEAD.노무비) } }), + cell(v, !has && v !== null && v !== undefined, tc(HEAD.재료비)), + el("td", { className: "m02-labrow__num", attrs: { "data-label": tc(HEAD.경비) } }), + cell(v, !has && v !== null && v !== undefined, tc(HEAD.계)), + ], + }); + }); + const applied = data.calc?.반영합; + const anyApplied = !!applied && (applied.계 ?? 0) !== 0; + const total = anyApplied ? applied : data.calc?.기준합; + const foot = + data.rows.length + data.mats.length + ? [ + el("tr", { + className: "m02-labrow__sum", + children: [ + el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }), + el("td", { className: "m02-labrow__qty", text: "" }), + el("td", { className: "m02-labrow__unit", text: "" }), + ...COSTS.map((c) => cell(total?.[c], !anyApplied && !!total, tc(HEAD[c]))), + ], + }), + ] + : []; + return data.rows.length + data.mats.length + ? el("div", { + className: "ui-general-block__table-wrap m02-labcompact", + children: [ + el("table", { + className: "ui-general-block__table m02-labline", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: [...lines, ...mats] }), + ...(foot.length ? [el("tfoot", { children: foot })] : []), + ], + }), + ], + }) + : el("p", { className: "m02-labrow__muted", text: tc("NoLogic") }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 31ecc6c84..5e8742a8c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -24,7 +24,7 @@ const TEXT = { NeedName: ["이름을 적어야 저장됨", "A name is required"], Load_Failed: ["불러오지 못함", "Load failed"], Origin_Self: ["원본", "Original"], - Logics_Title: ["담은 로직", "Logics in it"], + Logics_Title: ["단가산출 로직", "Unit-price logics"], Col_Order: ["차례", "No."], Col_Name: ["이름", "Name"], Col_Unit: ["결과 단위", "Result unit"], @@ -33,7 +33,7 @@ const TEXT = { Down: ["내리기", "Down"], Remove: ["빼기", "Remove"], AddLogic: ["로직 추가", "Add logic"], - NoLogic: ["담은 로직 없음", "No logics yet"], + NoLogic: ["단가산출 로직 없음", "No logics yet"], Open: ["열기", "Open"], Pick_Title: ["로직 고르기", "Pick a logic"], Pick_Search: ["이름·키로 찾기", "Search by name or key"], @@ -56,6 +56,8 @@ const TEXT = { Labor_NoMat: ["담은 자재 없음", "No materials yet"], Labor_AddMat: ["자재 추가", "Add material"], Labor_Qty: ["수량", "Quantity"], + Labor_QtyCol: ["수량산출", "Quantity"], + Labor_Hand: ["손", "Manual"], Labor_Base: ["기준", "Base"], Labor_Applied: ["반영", "Applied"], Labor_BasePrice: ["기준 단가", "Base price"], @@ -68,7 +70,7 @@ const TEXT = { ], Labor_Code: ["코드", "Code"], Labor_Spec: ["규격", "Spec"], - Labor_Already: ["이미 담은 로직 — {value}", "Already added — {value}"], + Labor_Already: ["이미 담은 단가산출 로직 — {value}", "Already added — {value}"], Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"], In_Title: ["넣을 값 — {value}", "Inputs — {value}"], In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts index 9547beba0..517e9efcc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts @@ -6,6 +6,7 @@ * · `setQty({알약 키: 칸 값}, {알약 키: 칸 주소}?)` = 면적 칸 값이 바뀌면 서버 없이 짝 셈(`Labor_Calc.settle`)으로 * 바로 곱함 · 칸 주소를 주면 저장 전 연결(setLink · 알약 끌기)도 「수량 B6 = 20」 으로 보임(안 주면 저장본 연결) * (그 열 면적 알약 값 전부를 줌 — 빠진 키는 「수량 없음」) + * · `compact` = 한 줄 표(구조물 조합 일위대가 카드 · 40-1) * · `onRow` = 줄 누름(알약 키 · 이름 · 단위 — 칸 지정 모달 등) · 없으면 누름 없음 * ========================================================================== */ @@ -20,7 +21,7 @@ import { type LaborDoc, } from "./M02_MasterTemplete_Labor_Api"; import { settle } from "./M02_MasterTemplete_Labor_Calc"; -import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; +import { compactTable, logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import "./M02_MasterTemplete_Labor.css"; @@ -46,7 +47,15 @@ export interface LaborViewHandle { destroy: () => void; } -export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewHandle { +/** `compact` = 한 줄 표(카드용 · 항목 · 수량산출 · 단위 · 금액 넷 · 자재 줄 같은 표 아래 · 줄 누름 없음) */ +export interface LaborViewOptions { + compact?: boolean; +} + +export function createLaborView( + onRow?: (row: LaborViewRow) => void, + opts: LaborViewOptions = {}, +): LaborViewHandle { const root = el("div", { className: "m02-labview" }); const logics = new Map(); let doc: LaborDoc | null = null; @@ -84,6 +93,10 @@ export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewH unit: mat.단위 ?? "", })?.(); }; + if (opts.compact) + return void root.replaceChildren( + compactTable({ rows: d.담은로직, mats: d.자재, logics, calc, fail, cells }), + ); root.replaceChildren( el("h4", { className: "m02-labview__title", text: tc("Logics_Title") }), logicTable( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 89fb986df..413e5409c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -317,6 +317,20 @@ def _copy_practice(column: str, key: str) -> dict[str, Any]: return store.copy_from("labor", column, key, body=labor.from_combo(key)) +@router.post("/layers/{layer}/{kind}/{column}/clear") +def post_clear( + layer: Layer, + kind: Literal["structures", "labor", "area"], + column: str, + owner: str | None = None, + session: Session = Depends(verify_session), +): + """항목 하나 비우기(40-4 · 카드 [삭제]) — 빈 문서 · 번호 · 열 그대로 · 받는 층 쓰기 권한.""" + folder = _root(session, layer, owner, write=True) + part = "structure" if kind == "structures" else kind + return _call(folder, store.clear, part, column) + + @router.post("/layers/{layer}/{kind}/{column}/copy") def post_copy( layer: Layer, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 5bfe9e227..0873d5d5e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -351,6 +351,21 @@ def create_structure(column: str) -> dict[str, Any]: return read("structure", column) +def clear(kind: str, column: str) -> dict[str, Any]: + """항목 하나 비우기(40-4) — 새 열 만들 때와 같은 빈 문서로 다시 씀 · 번호 · 열 그대로 · + 없는 파일은 404 · 면적산출을 비우면 그 열 일위대가 계산값도 다시 · 장부 맞춤.""" + if kind not in _KEY: + raise StoreError(404, f"없는 종류 「{kind}」") + number = read(kind, column)["문서"].get(_KEY[kind]) + with _LOCK: + atomic_write_json(_path(kind, column), {**_blank(kind, column), _KEY[kind]: number}) + info = _info(kind, _path(kind, column)) + if kind == "area": + _refresh_labor(column) + sync_combos() + return info + + def copy_from( kind: str, column: str, diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts index ce15d5b73..032bfb659 100644 --- a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -1,7 +1,7 @@ /* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림. * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */ import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands"; -import { linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; +import { linkAreaCell, linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; let pass = 0; @@ -89,4 +89,15 @@ eq("빈 주소", linkCommand(b, "s1", "깔기|㎡", ""), false); eq("없는 시트", linkCommand(b, "s9", "깔기|㎡", "A1"), false); eq("문서 그대로", links(b), links(book())); +// linkAreaCell — 새 문서(받은 문서 그대로) · 활성 시트 · 번호 칸 그대로 · 틀린 주소 null · 풀기 +b = { ...book(), 면적번: "SA-000001" } as Workbook & { 면적번: string }; +const before = JSON.stringify(b); +const got = linkAreaCell(b, "철근|ton", "c9", { 이름: "철근", 단위: "ton" }); +eq("받은 문서 그대로", JSON.stringify(b), before); +eq("새 문서 연결", got?.시트[0].연결?.[1], { 키: "철근|ton", 칸: "C9", 이름: "철근", 단위: "ton" }); +eq("번호 그대로", (got as { 면적번?: string } | null)?.면적번, "SA-000001"); +eq("틀린 주소", linkAreaCell(b, "철근|ton", "9C"), null); +eq("풀기 · 시트 넘어", links(linkAreaCell(b, "치기|㎥", null)!), [links(book())[0], null]); +eq("바뀔 것 없음 = 사본", JSON.stringify(linkAreaCell(b, "깔기|㎡", "B6")), before); + console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py index fb81c0d7a..d9a79db7c 100644 --- a/resources/tester/test_m02_area_panel.py +++ b/resources/tester/test_m02_area_panel.py @@ -88,3 +88,14 @@ def test_카드_알약_취향은_계정_받은_뒤_올림(): cards = _text("M02_MasterTemplete_ItemCards.ts") load = cards.split("async function load(")[1] assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in load + + +def test_엑셀_칸_고르기_모달_부품(): + """40-3 ① — 읽기만 시트 · 칸 누름 = 주소 · 풀기 = {칸: null} · 그만둠 = null · 저장 안 함.""" + pick = _text("M02_MasterTemplete_AreaCellPick.ts") + assert 'export { linkAreaCell } from "../A00_Common/spreadsheet/spreadsheet_link_set";' in pick + assert "readOnly: true," in pick and "beforeClose: () => (finish(null), true)," in pick + assert "finish({ 칸: null })" in pick and "finish({ 칸: picked })" in pick + assert "if ((e as MouseEvent).button === 0 && moved) choose();" in pick + assert "handle?.destroy();" in pick + assert "saveTemplate" not in pick diff --git a/resources/tester/test_m02_labor_values.py b/resources/tester/test_m02_labor_values.py index 5915821cb..be396caab 100644 --- a/resources/tester/test_m02_labor_values.py +++ b/resources/tester/test_m02_labor_values.py @@ -127,6 +127,13 @@ function load(f) { const m = load(path.join(root, "M02_MasterTemplete", "M02_MasterTemplete_Labor_Rows.ts")); const cells = new Map([["돌쌓기|㎡", "B6"]]); console.log(JSON.stringify([ + m.baseQtyText({ 배: 10, 분모: "㎡" }), + m.baseQtyText({ 배: 1, 분모: "㎥" }), + m.baseQtyText({ 배: 1, 분모: null }), + m.qtyShort({ 수량: 5, 수량출처: "손", 알약: "돌쌓기|㎡" }, "㎥", cells), + m.qtyShort({ 수량: 12.5, 수량출처: "칸", 알약: "돌쌓기|㎡" }, "㎡", cells), + m.qtyShort({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡", cells), + m.qtyShort({ 수량: null, 수량출처: "없음", 알약: "없는|㎡" }, "㎡", cells), m.qtyText({ 수량: 20, 수량출처: "칸", 알약: "돌쌓기|㎡" }, "㎡", cells), m.qtyText({ 수량: 20, 수량출처: "칸", 알약: "돌쌓기|㎡" }, "㎡"), m.qtyText({ 수량: 3, 수량출처: "손", 알약: "돌쌓기|㎡" }, "㎡", cells), @@ -145,7 +152,10 @@ def test_수량_글은_이은_칸_주소를_보임() -> None: [node, "-e", ROWS_SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8" ) assert out.returncode == 0, out.stderr - assert json.loads(out.stdout) == [ + got = json.loads(out.stdout) + # 수량산출 열(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 짧은 글 + assert got[:7] == ["10 ㎡", "1 ㎥", "", "손 5 ㎥", "B6 = 12.5", "B6 = 빈 칸", "—"] + assert got[7:] == [ "수량 B6 = 20 ㎡", "수량 면적산출 칸 = 20 ㎡", "수량 손 입력 3 ㎡", diff --git a/resources/tester/test_m02_part_clear.py b/resources/tester/test_m02_part_clear.py new file mode 100644 index 000000000..79a393f25 --- /dev/null +++ b/resources/tester/test_m02_part_clear.py @@ -0,0 +1,134 @@ +"""항목 하나 비우기(PLAN 40-4 · 카드 [삭제]) — `POST /api/m02/layers/{층}/{종류}/{열}/clear`. + +새 열과 같은 빈 문서 · 번호 · 열 그대로 · 권한 = 받는 층 쓰기(`_root`). +시스템 층 = 견본 표(임시 폴더) · 개인 층 = 임시 storage · 로직은 M01 마스터(읽기만). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +TEN = "GC000421" +URL = "/api/m02/layers/{}/{}/{}/clear" +NUMBER = { + "structures": ("structure", "도번"), + "labor": ("labor", "일위대가번"), + "area": ("area", "면적번"), +} + + +def _fill(folder: Path, cid: str) -> None: + """세 문서를 채움 — 도면 선 · 일위대가 로직 · 면적 칸 + 알약 연결.""" + with store.at(folder): + for kind in ("structure", "labor", "area"): + store._ensure(kind, cid) + got = store.read("structure", cid) + doc = got["문서"] + doc["도면"]["entities"] = [{"type": "line"}] + store.write("structure", cid, got["판"], doc) + got = store.read("labor", cid) + store.write("labor", cid, got["판"], {**got["문서"], "담은로직": [{"로직": TEN}]}) + got = store.read("area", cid) + sheet = got["문서"]["시트"][0] + sheet["칸"] = {"B6": {"값": "5"}} + sheet["연결"] = [{"키": "사석 수상고르기|㎡", "이름": "x", "칸": "B6", "단위": "㎡"}] + store.write("area", cid, got["판"], got["문서"]) + + +@pytest.fixture() +def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + store.create_structure("rv") + _fill(system, "rv") + personal = layers.personal_dir(7, 42) + items_store.ensure(personal) + with store.at(personal): + got = store.read("table", store.STRUCTURE_TABLE) + got["문서"]["열"] = [{"id": "a", "머리": ["돌", "가"], "꼴": "수"}] + store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"]) + _fill(personal, "a") + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app = FastAPI() + app.include_router(items_router.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "session": session, "system": system, "personal": personal} + + +def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient: + world["session"].update(role=role, user_id=user) + return world["client"] + + +def _doc(folder: Path, kind: str, cid: str) -> dict: + return json.loads((folder / kind / f"{cid}.json").read_bytes()) + + +@pytest.mark.parametrize("kind", ["structures", "labor", "area"]) +def test_세_종류_빈_문서_번호_열_그대로(world, kind): + system = world["system"] + part, key = NUMBER[kind] + before = _doc(system, part, "rv") + got = _as(world, "SYSTEM_ADMIN").post(URL.format("system", kind, "rv")) + assert got.status_code == 200, got.text + after = _doc(system, part, "rv") + with store.at(system): + blank = store._blank(part, "rv") + assert after == {**blank, key: before[key]} # 새 열과 같은 빈 문서 + 번호 + assert after["열"] == "rv" and after[key] + with store.at(system): # 장부 번호 그대로 + row = next(r for r in store.read_combos()["줄"] if r["열"] == "rv") + assert row[key] == before[key] + + +def test_면적_비우면_일위대가_계산값_수량_없음으로(world): + system = world["system"] + assert _doc(system, "labor", "rv")["계산값"]["줄"][0]["수량출처"] == "칸" + _as(world, "SYSTEM_ADMIN").post(URL.format("system", "area", "rv")) + assert _doc(system, "labor", "rv")["계산값"]["줄"][0]["수량출처"] == "없음" + assert _doc(system, "labor", "rv")["담은로직"][0]["로직"] == TEN # 일위대가는 그대로 + + +def test_권한_일반_마스터_403_남의_개인_403_내_개인_됨(world): + system, personal = world["system"], world["personal"] + client = _as(world) + assert client.post(URL.format("system", "labor", "rv")).status_code == 403 + assert _doc(system, "labor", "rv")["담은로직"] # 안 지워짐 + other = URL.format("personal", "labor", "a") + "?owner=7/99" + assert client.post(other).status_code == 403 + assert client.post(URL.format("personal", "labor", "a")).status_code == 200 + assert _doc(personal, "labor", "a")["담은로직"] == [] + + +def test_회사_관리자는_동료_개인_층_비움(world): + personal = world["personal"] + client = _as(world, "ADMIN", user=41) + got = client.post(URL.format("personal", "structures", "a") + "?owner=7/42") + assert got.status_code == 200, got.text + assert _doc(personal, "structure", "a")["도면"]["entities"] == [] + + +def test_없는_열_404_모르는_종류_422(world): + client = _as(world, "SYSTEM_ADMIN") + assert client.post(URL.format("system", "labor", "없는열")).status_code == 404 + assert client.post(URL.format("system", "material", "rv")).status_code == 422 diff --git a/resources/tester/test_spreadsheet_set_link.py b/resources/tester/test_spreadsheet_set_link.py index 13e3ba179..000430baf 100644 --- a/resources/tester/test_spreadsheet_set_link.py +++ b/resources/tester/test_spreadsheet_set_link.py @@ -55,7 +55,7 @@ def test_setLink_순수_길(): assert run.stdout.strip(), run.stderr result = json.loads(run.stdout) assert result["fail"] == 0, result["failures"] - assert result["pass"] == 13 + assert result["pass"] == 19 def test_손잡이_setLink_몸():