diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 3e3f493ea..2c411ffe8 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -299,6 +299,8 @@ const PREFS_ENDPOINT = "/api/dashboard/me/ui-prefs"; const PREFS_PUSH_DELAY_MS = 1500; let applyingServerPrefs = false; let prefsPushTimer: ReturnType | null = null; +/** 계정 취향 받기 — 받기 전에 올리면 이 브라우저 옛 값이 계정 값을 덮으므로 올리기는 이것 뒤에 */ +let prefsPull: Promise | null = null; function localPrefs(): Record { const out: Record = {}; @@ -320,17 +322,26 @@ function pushUiPrefs(): void { if (prefsPushTimer) clearTimeout(prefsPushTimer); prefsPushTimer = setTimeout(() => { prefsPushTimer = null; - void fetch(PREFS_ENDPOINT, { - method: "PUT", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ prefs: localPrefs() }), - }).catch(() => undefined); + void (prefsPull ?? Promise.resolve()) + .then(() => + fetch(PREFS_ENDPOINT, { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prefs: localPrefs() }), + }), + ) + .catch(() => undefined); }, PREFS_PUSH_DELAY_MS); } -/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번. */ -export async function syncUiPrefs(): Promise { +/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번(올리기는 받은 뒤로 미룸). */ +export function syncUiPrefs(): Promise { + prefsPull = pullUiPrefs(); + return prefsPull; +} + +async function pullUiPrefs(): Promise { let stored: Record = {}; try { const response = await fetch(PREFS_ENDPOINT, { credentials: "include" }); diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index d452f6b95..54cd49c06 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -76,7 +76,7 @@ export interface SpreadsheetHandle { setDoc(book: Workbook): void; /** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */ recalc(): CalcValues; - /** 알약 키 → 연결 칸 값(빈 칸 0) */ + /** 알약 키 → 연결 칸 값(빈 칸 = 값 · 식 없는 칸은 빠짐 — 「수량 없음」) */ getLinkedValues(): Record; /** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 · * 주소가 틀리거나 읽기만이면 false */ @@ -289,7 +289,9 @@ export function createSpreadsheet( for (const s of ctx.book.시트) for (const l of s.연결 ?? []) { const p = parseA1(l.칸); - if (p) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c)); + const cell = s.칸[l.칸]; + const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === ""); + if (p && !empty) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c)); } return out; }, diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 8f7b2d789..0e95c8319 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -470,7 +470,7 @@ def _lowest(row: dict) -> object: def pick(kind: str, q: str, limit: int) -> dict: """고르기 창 — `price` 자재품목(관급 줄 포함 · ref = 키) · `job` 값 있는 공표 노임 직종 - (미확보·추정 빼고 모든 구분)(ref = 키) · 이름·규격 낱말이 모두 들어간 줄.""" + (미확보·추정 빼고 모든 구분)(ref = 키) · 낱말마다 이름·규격에 들어 있거나 키와 같은 줄.""" if kind not in _PICK: raise StoreError(404, f"없는 고르기 「{kind}」") words = [_squash(w) for w in q.split()] @@ -481,7 +481,7 @@ def pick(kind: str, q: str, limit: int) -> dict: ): continue text = _squash(f"{row.get('이름')} {row.get('규격')} {' '.join(row.get('옛이름') or [])}") - if all(w in text for w in words): + if all(w in text or w == _squash(row.get("키")) for w in words): hits.append( { "ref": str(row.get("키")), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 4c34f3e76..5bc26211c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -54,7 +54,8 @@ function reason(detail: unknown, status: number): string { return lines.filter(Boolean).join(" · ") || `HTTP ${status}`; } -/** 지금 화면의 층 — 시스템 = 옛 길 그대로 · 회사 · 개인 = `/layers/{층}` 아래(구조물집계표 · 조합 길 모두) · +/** 지금 화면의 층 — 시스템 · 회사 · 개인 모두 `/layers/{층}` 아래(구조물집계표 · 조합 길 모두 · 읽기는 + * 로그인 누구나 · 쓰기는 서버 층 권한 · 옛 `/api/m02/templates/*` 는 시스템 관리자 전용이라 안 씀) · * `owner` = 남의 층을 볼 때 주인(회사 `{C}` · 개인 `{C}/{U}` · 자기 층 = null · PLAN 27-1c) — * 지금 층 길에 `?owner=` 로 붙음(M02 화면을 떠나면 페이지가 시스템 · null 로 되돌림) */ let activeLayer: Layer = "system"; @@ -73,24 +74,20 @@ const owned = (layer: Layer, path: string): string => ? `${path}${path.includes("?") ? "&" : "?"}owner=${enc(activeOwner)}` : path; const scoped = (path: string, init: RequestInit = {}): Promise => - call( - owned(activeLayer, `${activeLayer === "system" ? "" : `/layers/${activeLayer}`}${path}`), - init, - ); + call(owned(activeLayer, `/layers/${activeLayer}${path}`), init); const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : ""); -const head = (layer: Layer): string => - layer === "system" ? "/templates" : `/layers/${layer}/templates`; +const head = (layer: Layer): string => `/layers/${layer}/templates`; const item = (layer: Layer, kind: Kind, name: string, projectId: string | null): string => owned(layer, `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`); -/** 시스템 길은 배열 · 층 길은 `{층, 양식: [...]}` — 둘 다 목록으로 */ +/** 층 길은 `{층, 양식: [...]}` · 옛 시스템 길은 배열 — 둘 다 목록으로 */ export const listTemplates = async ( layer: Layer, projectId: string | null, ): Promise => { const got = await call( - layer === "system" ? head(layer) : owned(layer, `${head(layer)}${q(projectId)}`), + owned(layer, `${head(layer)}${layer === "system" ? "" : q(projectId)}`), ); return Array.isArray(got) ? got : got.양식; }; @@ -114,7 +111,7 @@ export const saveTemplate = ( /** 시스템 층만 지움 */ export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> => - call(`/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" }); + call(`${head("system")}/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" }); /** 개인·회사 층에서 지움 — 판이 맞을 때만(다르면 409) */ export const deleteLayerTemplate = ( @@ -204,7 +201,8 @@ export interface OptionsDoc { 종류: OptionKind[]; } -export const fetchStructureOptions = (): Promise => call("/structure-options"); +export const fetchStructureOptions = (): Promise => + call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 옛 길(시스템 관리자) /** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */ export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise => diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts index 3f1df113f..6ba71c3c2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts @@ -15,6 +15,7 @@ const TEXT = { close: "닫기", now: "지금 연결", none: "연결 없음", + empty: "빈 칸", picked: "지금 고른 칸으로 ({value})", address: "칸 주소", hint: "예 B6", @@ -51,6 +52,7 @@ function linkedText(opts: AreaLinkOptions): string | null { .find((l) => l.키 === opts.key); if (!link) return null; const value = opts.sheet.getLinkedValues()[opts.key]; + if (!value) return `${link.칸} = ${TEXT.empty}`; return [link.칸, "=", cellText(value), opts.info.단위 ?? ""].join(" ").trim(); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts index 39c55433d..37c68493a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts @@ -47,13 +47,27 @@ export interface AreaPanel { destroy: () => void; } -/** 시트 알약 값 → 수만(`{키: 수}`) — 수 아닌 칸은 빠짐(「수량 없음」) */ +/** 칸 값 → 수(쉼표 뗌) — 빈 글 · 글 · 참거짓은 null(서버 `M02_Labor_Doc._number` 와 같음) */ +function toNumber(raw: unknown): number | null { + if (raw === null || raw === undefined || typeof raw === "boolean") return null; + const text = String(raw).replace(/,/g, "").trim(); + const n = Number(text); + return text !== "" && Number.isFinite(n) ? n : null; +} + +/** 시트 알약 값 → `{키: 수}` — 서버 `M02_Labor_Doc.area_values` 와 같은 규칙(짝): 식 칸 = 계산 값의 수 · + * 그 밖 = 칸 값 · 빈 칸 · 글은 빠짐(「수량 없음」 · 반영 「—」) */ function quantities(sheet: SpreadsheetHandle | null): Record { const out: Record = {}; - for (const [key, v] of Object.entries(sheet?.getLinkedValues() ?? {})) { - const n = "수" in v ? Number(v.수) : NaN; - if (Number.isFinite(n)) out[key] = n; - } + if (!sheet) return out; + const values = sheet.getLinkedValues(); + for (const s of sheet.getDoc().시트) + for (const l of s.연결 ?? []) { + const cell = s.칸[l.칸]; + const v = values[l.키]; + const n = toNumber(cell?.식 !== undefined ? (v && "수" in v ? v.수 : null) : cell?.값); + if (n !== null) out[l.키] = n; + } return out; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 1a3faea5d..deb7368db 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -478,6 +478,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { return; } root.hidden = false; + paintPills(readStateRaw("m02-card-pills") === "true"); // 계정 취향을 늦게 받았어도 열 때마다 따름 if (col.id !== column) started.clear(); column = col.id; label = col.label; diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts index 4d015ca6b..1df2b007c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -7,7 +7,7 @@ * 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`). * ========================================================================== */ -import { createInputField, el } from "@ui/ui_template_elements"; +import { createInputField, el, showToast } from "@ui/ui_template_elements"; import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells"; import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head"; import "../M01_MasterData/M01_MasterData_UI_Style.css"; @@ -22,12 +22,21 @@ export interface ItemRow extends TreeItem { 도번?: string | null; 면적번?: string | null; 수정일?: Partial>; - labor?: { 번호: string | null; 로직수: number; 합계: number | null } | null; + labor?: { + 번호: string | null; + 로직수: number; + /** 수량 반영 합 · 기준(단가) 합 · 일위대가 문서 단위 */ + 합계: number | null; + 기준?: number | null; + 단위?: string | null; + } | null; } export interface Col { head: string; get: (row: R) => string; + /** 값 뒤 작은 글(예 「기준」) — 없으면 없음 */ + note?: (row: R) => string; } const TEXT = { @@ -51,6 +60,17 @@ const latest = (row: ItemRow): string => .pop(), ); +/** 일위대가 합계(39-8 B2) — 수량 반영 합이 있으면 그것 · 없으면 기준(단가) 합 + 작은 글 「기준」 · + * 담은 로직 0 · 계산값 없음 = 「—」 */ +export function laborSum(row: ItemRow): { text: string; note: string } { + const l = row.labor; + if (!l?.로직수) return { text: DASH, note: "" }; + if (l.합계) return { text: money(l.합계), note: "" }; + return typeof l.기준 === "number" + ? { text: money(l.기준), note: "기준" } + : { text: DASH, note: "" }; +} + const NAME: Col = { head: "이름", get: (r) => leafName(r) }; const FOLDER: Col = { head: "폴더", get: (r) => folderOf(r).join(" / ") }; const UNIT: Col = { head: "단위", get: (r) => r.단위 ?? "" }; @@ -73,9 +93,9 @@ export const ITEM_COLS: Record[]> = { { head: "일위대가번", get: (r) => r.labor?.번호 || DASH }, NAME, FOLDER, - UNIT, + { head: "단위", get: (r) => r.labor?.단위 ?? "" }, // 일위대가 문서 단위(39-8 B3) { head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) }, - { head: "합계", get: (r) => money(r.labor?.합계) }, + { head: "합계", get: (r) => laborSum(r).text, note: (r) => laborSum(r).note }, changed("labor"), ], item: [ @@ -89,6 +109,16 @@ export const ITEM_COLS: Record[]> = { ], }; +const PART_NAME = { structure: "도면", area: "면적산출", labor: "일위대가" } as const; + +/** 그 부분 문서가 없는 줄의 까닭(항목 하나 복사 · 39-8 H3) — 옮기기 전 층의 일위대가처럼 파일이 없으면 + * 가져올 것이 없음 · 항목 통째(복사 생성)는 없는 문서를 빈 문서로 세워 막지 않음 · 있으면 null */ +export function missingPart(kind: ListKind, row: ItemRow): string | null { + if (kind === "item") return null; + const has = kind === "labor" ? !!row.labor : !!row.수정일?.[kind]; + return has ? null : `이 항목에는 ${PART_NAME[kind]} 문서가 없음`; +} + /** 찾기 · 쪽 — 보일 줄 · 맞춘 쪽 · 맞는 줄 수(화면 밖 · 시험이 부름) */ export function pageOf( rows: R[], @@ -131,7 +161,12 @@ export function buildAllItems( export interface ItemListHandle { root: HTMLElement; /** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */ - show: (rows: R[], cols: Col[], onPick: (row: R) => void) => void; + show: ( + rows: R[], + cols: Col[], + onPick: (row: R) => void, + blocked?: (row: R) => string | null, + ) => void; /** 표 자리에 글 한 줄(불러오는 중 · 못 받음) */ message: (text?: string) => void; } @@ -147,7 +182,12 @@ export function buildItemList(): ItemListHandle { grid.replaceChildren(el("p", { className: "m01-master__empty", text })); }; - const show = (rows: R[], cols: Col[], onPick: (row: R) => void): void => { + const show = ( + rows: R[], + cols: Col[], + onPick: (row: R) => void, + blocked?: (row: R) => string | null, + ): void => { page = 1; draw = (): void => { const got = pageOf(rows, cols, search.value, page); @@ -164,10 +204,23 @@ export function buildItemList(): ItemListHandle { className: "m01-master__table-row", children: cols.map((c) => { const text = c.get(row); - return el("td", { text, attrs: { title: text } }); + const note = c.note?.(row) ?? ""; + return el("td", { + text, + attrs: { title: note ? `${text} ${note}` : text }, + children: note + ? [el("small", { className: "m01-master__muted", text: ` ${note}` })] + : [], + }); }), }); - tr.addEventListener("click", () => onPick(row)); + // 고를 수 없는 줄 — 흐리게 · 누르면 까닭 한 줄(고르기 안 함) + const why = blocked?.(row) ?? null; + if (why) { + tr.classList.add("is-blocked"); + tr.title = why; + } + tr.addEventListener("click", () => (why ? showToast(why, "info") : onPick(row))); return tr; }); grid.replaceChildren( diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css index a6713f59b..de02c066e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css @@ -33,3 +33,9 @@ flex-wrap: wrap; gap: var(--spacing-8); } + +/* 그 부분 문서가 없는 줄(39-8 H3) — 흐리게 · 누르면 까닭만 */ +.m02-itemlist .m01-master__table-row.is-blocked { + opacity: 0.45; + cursor: not-allowed; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 4fd1b1215..34deef6f7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -15,6 +15,7 @@ import { buildItemList, ITEM_COLS, loadItems, + missingPart, type Col, type ItemRow, type ListKind, @@ -141,6 +142,7 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } { rows.filter((row) => !opt.skip?.(from, row)), ITEM_COLS[opt.kind], (row) => opt.onPick({ from, row }), + (row) => missingPart(opt.kind, row), // 그 부분 문서 없는 줄 = 흐림 · 까닭(39-8 H3) ); } catch (error) { if (mine === seq) list.message(TEXT.failed.replace("{value}", why(error))); @@ -183,10 +185,21 @@ export function buildTwoWay(opt: TwoWayOptions): HTMLElement { const copy = createButton({ label: TEXT.makeCopy, variant: "ghost" }); const below = el("div", { className: "m02-itempick__below" }); let busy = false; - fresh.addEventListener("click", opt.onNew); + /** 누른 단추 = 지금 화면 — 진한 단추가 고른 쪽 · 복사 생성 목록은 그쪽일 때만 보임 */ + const choose = (which: HTMLElement): void => { + for (const b of [fresh, copy]) { + b.classList.toggle("ui-btn--filled", b === which); + b.classList.toggle("ui-btn--ghost", b !== which); + } + below.hidden = which !== copy; + }; + fresh.addEventListener("click", () => { + choose(fresh); + opt.onNew(); + }); copy.addEventListener("click", () => { + choose(copy); if (below.firstChild) return; - copy.classList.add("is-active"); const picker = buildPicker({ ...opt.picker, onPick: (picked) => diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index b3899ec42..4edfb4f89 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -86,9 +86,19 @@ def items(folder: Path) -> list[dict[str, Any]]: return out +def _number(sums: Any) -> float | None: + """`계산값` 합 칸의 `계` — 수 아닌 값(옛 꼴) · 없음 = null(「—」).""" + total = sums.get("계") if isinstance(sums, dict) else None + try: + return None if total is None or isinstance(total, bool) else float(total) + except (TypeError, ValueError): + return None + + def _list_extras(folder: Path, col: str) -> dict[str, Any]: """목록 칸(39-5) — `수정일` = 도면 · 면적산출 · 일위대가 파일 시각 · `labor` = 일위대가 문서의 - 번호 · 담은 로직 수 · 합계(`계산값.반영합.계` · 없으면 null) — 파일이 없으면 칸 없음.""" + 번호 · 담은 로직 수 · 합계(`계산값.반영합.계`) · 기준(`계산값.기준합.계`) · 단위 — + 파일이 없으면 칸 없음.""" stamps: dict[str, str] = {} for kind in ("structure", "area", "labor"): path = folder / kind / f"{col}.json" @@ -103,15 +113,13 @@ def _list_extras(folder: Path, col: str) -> dict[str, Any]: doc = json.loads(path.read_bytes()) except ValueError: doc = {} - total = ((doc.get("계산값") or {}).get("반영합") or {}).get("계") - try: - total = None if total is None or isinstance(total, bool) else float(total) - except (TypeError, ValueError): - total = None # 수 아닌 값(옛 꼴)은 「—」 + calc = doc.get("계산값") or {} labor = { "번호": doc.get("일위대가번"), "로직수": len(doc.get("담은로직") or []), - "합계": total, + "합계": _number(calc.get("반영합")), + "기준": _number(calc.get("기준합")), # 수량 없는 일위대가 = 단가 합(39-8 B2) + "단위": doc.get("단위"), # 일위대가 문서 단위(구조물 열 단위 아님 · B3) } return {"수정일": stamps, "labor": labor} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index df7c2515d..e41adbce8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -33,13 +33,18 @@ const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { const num = (v: number): string => Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); -/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · 없으면 빈 글 */ +/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · + * 이은 칸이 비었으면 「수량 A1 = 빈 칸」(수량 없음 · 서버와 같음) · 아니면 빈 글 */ export function qtyText( line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, unit: string, cells: ReadonlyMap = new Map(), ): string { - if (!line || line.수량 === null || line.수량 === undefined) return ""; + if (!line) return ""; + if (line.수량 === null || line.수량 === undefined) { + const at = line.알약 ? cells.get(line.알약) : undefined; + return at ? `${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}` : ""; + } const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`; if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`; const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index cba1e88bb..31ecc6c84 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -75,6 +75,7 @@ const TEXT = { In_Memo: ["메모", "Memo"], In_Qty: ["수량", "Quantity"], In_QtyCell: ["면적산출 칸", "Area sheet cell"], + In_QtyEmpty: ["빈 칸", "empty cell"], In_QtyHand: ["손 입력", "Manual"], In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"], In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index ceaf54a23..89fb986df 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -288,6 +288,14 @@ def get_numbers(layer: Layer, owner: str | None = None, session: Session = Depen return _call(_root(session, layer, owner), store.structure_numbers) +@router.get("/layers/system/structure-options") +def get_structure_options(session: Session = Depends(verify_session)) -> dict: + """구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 옛 길 시스템 관리자만).""" + from M02_MasterTemplete import M02_MasterTemplete_Options as options + + return _call(_root(session, "system"), options.listing) + + @router.get("/layers/{layer}/structure-combos") def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): return _call(_root(session, layer, owner), store.read_combos) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 52667b2fe..639f400c2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -276,6 +276,8 @@ async def list_layer( manifest = await asyncio.to_thread(layers.read_manifest, folder) for row in rows: row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}") + if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음 + row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])] return {"층": layer, "양식": rows} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts index 38b03c5f6..ae6ab3e8b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts @@ -4,6 +4,7 @@ * 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움. * ========================================================================== */ +import { t as L } from "@ui/ui_template_locale"; import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch"; import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model"; @@ -22,5 +23,6 @@ export async function askCopyFolder( ): Promise { const view = await fetchTree(to.layer, to.owner).catch(() => null); const folders = view ? listFolders(buildFolders(view)) : []; - return askFolder(to.label.replace("{n}", String(n)), folders, () => false, []); + const title = to.label.replace("{n}", String(n)); + return askFolder(title, folders, () => false, [], L("M02_TreeCopyFolder")); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts index ab090480f..73c701f68 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts @@ -142,12 +142,14 @@ export function askNewItem(opt: { }); } -/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 */ +/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 · + * `fieldLabel` = 칸 글(복사는 「복사할 폴더」) */ export function askFolder( title: string, folders: string[][], blocked: (path: string[]) => boolean, current: string[], + fieldLabel: string = L("M02_TreeMoveTo"), ): Promise { return new Promise((resolve) => { let done = false; @@ -164,7 +166,7 @@ export function askFolder( beforeClose: () => (finish(null), true), mount: (body, close) => { const field = createSelectField({ - label: L("M02_TreeMoveTo"), + label: fieldLabel, options: choices.map((path, i) => ({ value: String(i), text: path.length ? path.join(" / ") : L("M02_TreeTop"), diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 450fc31a7..d70207bdb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -329,6 +329,7 @@ export function createMain( onChange: () => (dirty = !readOnly), ...(struct ? { + laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? "", drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "", areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "", combo: { diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index b41320543..cfa2c9eeb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -97,7 +97,10 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer, item.열), settle: () => main.settle(), - onChanged: (colId) => main.openTable(layer, colId, true), + // 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표 + 좌측 하위 열 줄 · 번호 + onChanged: async (colId) => { + await Promise.all([main.openTable(layer, colId, true), side.refresh()]); + }, }); const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise => { // 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서 diff --git a/resources/tester/helper_m02_item_list.cjs b/resources/tester/helper_m02_item_list.cjs index 875074b62..b1f6baee2 100644 --- a/resources/tester/helper_m02_item_list.cjs +++ b/resources/tester/helper_m02_item_list.cjs @@ -33,7 +33,7 @@ const row = (열, 경로, extra = {}) => ({ }); const rows = [ row("c1", ["기슭막이", "메쌓기", "1.2m"], { - labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4 }, + labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4, 기준: 99, 단위: "개소" }, 수정일: { structure: "2026-10-01T09:00:00", labor: "2026-10-04T11:00:00" }, }), row("c2", ["기슭막이", "찰쌓기"], { labor: { 번호: "SU-000002", 로직수: 0, 합계: null } }), @@ -61,5 +61,16 @@ process.stdout.write( ), pageOver: m.pageOf(many, m.ITEM_COLS.structure, "", 9).page, pageEmpty: m.pageOf([], m.ITEM_COLS.structure, "", 2).page, + // 그 부분 문서 없는 줄(39-8 H3) — c1 = 일위대가 있음 · 면적 수정일 없음 · c3 = 수정일 · 일위대가 없음 + sums: [ + rows[0], // 반영 합 있음 + { labor: { 로직수: 14, 합계: 0, 기준: 167746.6 } }, // 수량 없음 = 기준 + rows[1], // 담은 로직 0 + { labor: { 로직수: 2, 합계: null, 기준: null } }, // 계산값 없음 + rows[2], // 일위대가 없음 + ].map((r) => m.laborSum(r)), + missing: ["structure", "area", "labor", "item"].map((k) => + rows.map((r) => m.missingPart(k, r)), + ), }), ); diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index 094462d2d..de50867eb 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -341,6 +341,13 @@ def test_고르기_자재품목_직종(client: TestClient) -> None: market = _get(client, "/api/m01/pick", kind="price", q="H형강 관급", limit=50) # 조달청 값이 아직 안 이어진 줄도 섞여 있음 — 이어진 줄이 하나라도 있으면 됨 assert any(i["관급"] and i["값"] > 0 for i in market["items"]) + # 키로도 찾음(찾기 칸 글 「이름 · 규격 · 코드」) · 이름 낱말 AND 는 그대로 + cement = _get(client, "/api/m01/pick", kind="price", q="시멘트 전국")["items"] + code = cement[0]["ref"] + by_code = _get(client, "/api/m01/pick", kind="price", q=code.lower())["items"] + assert [i["ref"] for i in by_code] == [code] + assert _get(client, "/api/m01/pick", kind="price", q=f"시멘트 {code}")["total"] == 1 + assert _get(client, "/api/m01/pick", kind="price", q=f"없는낱말 {code}")["total"] == 0 job = _get(client, "/api/m01/pick", kind="job", q="보통인부") assert job["items"][0]["값"] > 0 and job["items"][0]["ref"] == key_of("LB:1002") assert _get(client, "/api/m01/pick", kind="job", q="CAD설계사")["items"][0]["ref"] == key_of( diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py index 2b8e56228..fb81c0d7a 100644 --- a/resources/tester/test_m02_area_panel.py +++ b/resources/tester/test_m02_area_panel.py @@ -47,3 +47,44 @@ def test_칸_지정_모달은_setLink_만(): assert "address.setError(TEXT.bad)" in link # 틀린 · 빈 주소는 모달 그대로 assert "pick.disabled = link.disabled = address.input.disabled = opts.readOnly;" in link assert "saveTemplate" not in link # 저장은 [저장] + + +def test_빈_칸은_수량_없음_서버와_같음(): + """39-8 ② — 빈 칸에 이은 알약 = 수량 없음(반영 「—」 · 「수량 A1 = 빈 칸」) · 0 칸 = 0 · 값 칸 = 값.""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + doc = { + "시트": [ + { + "id": "s1", + "칸": {"A2": {"값": 0}, "A3": {"값": "1,250"}, "A4": {"값": "글"}}, + "연결": [ + {"키": k, "칸": c} + for k, c in (("빈", "A1"), ("영", "A2"), ("값", "A3"), ("글", "A4")) + ], + } + ] + } + assert {k: float(v) for k, v in labor.area_values(doc).items()} == {"영": 0, "값": 1250} + panel = _text("M02_MasterTemplete_AreaPanel.ts") + body = panel.split("function quantities")[1].split("\n}\n")[0] + assert 'toNumber(cell?.식 !== undefined ? (v && "수" in v ? v.수 : null) : cell?.값)' in body + assert 'String(raw).replace(/,/g, "").trim()' in panel and 'typeof raw === "boolean"' in panel + sheet = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8") + assert 'const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === "");' in sheet + rows = _text("M02_MasterTemplete_Labor_Rows.ts") + assert '${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}' in rows + assert "if (!value) return `${link.칸} = ${TEXT.empty}`;" in _text( + "M02_MasterTemplete_AreaLink.ts" + ) + + +def test_카드_알약_취향은_계정_받은_뒤_올림(): + """39-8 ③ — 화면이 뜰 때 브라우저 옛 값이 계정 값을 덮지 않게 · 카드는 열 때마다 취향을 다시 읽음.""" + state = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8") + push = state.split("function pushUiPrefs")[1].split("export function syncUiPrefs")[0] + assert "void (prefsPull ?? Promise.resolve())" in push and "prefs: localPrefs()" in push + assert "prefsPull = pullUiPrefs();" in state + cards = _text("M02_MasterTemplete_ItemCards.ts") + load = cards.split("async function load(")[1] + assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in load diff --git a/resources/tester/test_m02_item_list.py b/resources/tester/test_m02_item_list.py index ccdfa1f68..604f3d7ca 100644 --- a/resources/tester/test_m02_item_list.py +++ b/resources/tester/test_m02_item_list.py @@ -54,11 +54,23 @@ def test_칸_값_빈_번호_합계_날짜(ts): s, labor = ts["cells"]["structure"], ts["cells"]["labor"] assert s[0] == ["SD-c1", "1.2m", "기슭막이 / 메쌓기", "m", "2026-10-01"] assert s[2][0] == "—" and s[2][2] == "" # 빈 도번 · 맨 위 항목(폴더 없음) - assert labor[0][3:] == ["m", "3", "1,234,567", "2026-10-04"] # 합계 원 단위 반올림 + assert labor[0][3:] == ["개소", "3", "1,234,567", "2026-10-04"] # 일위대가 단위 · 원 단위 assert labor[1][5] == "—" and labor[2][0] == labor[2][4] == "—" # 합계 없음 · 일위대가 없음 assert ts["cells"]["item"][0][-1] == "2026-10-04" # 항목 통째 = 가장 늦게 고친 날 +def test_일위대가_합계_반영_기준_빈(ts): + """39-8 B2 — 반영 합이 있으면 그것 · 없으면 기준 합 + 「기준」 · + 담은 로직 0 · 계산값 없음 = 「—」.""" + assert ts["sums"] == [ + {"text": "1,234,567", "note": ""}, + {"text": "167,747", "note": "기준"}, + {"text": "—", "note": ""}, + {"text": "—", "note": ""}, + {"text": "—", "note": ""}, + ] + + def test_찾기_쪽(ts): assert ts["find"] == ["c2"] # 낱말 둘 AND(폴더 · 이름 칸) assert ts["findLabor"] == ["c1"] # 번호 칸도 찾음 @@ -67,6 +79,15 @@ def test_찾기_쪽(ts): assert ts["pageOver"] == 3 and ts["pageEmpty"] == 1 +def test_부분_문서_없는_줄_까닭(ts): + """39-8 H3 — 그 부분 문서가 없으면 고르기 막음(흐림 · 까닭) · 항목 통째는 막지 않음.""" + structure, area, labor, item = ts["missing"] + assert structure == [None, None, "이 항목에는 도면 문서가 없음"] + assert area == ["이 항목에는 면적산출 문서가 없음", None, "이 항목에는 면적산출 문서가 없음"] + assert labor == [None, None, "이 항목에는 일위대가 문서가 없음"] + assert item == [None, None, None] + + def test_서버_목록_칸_수정일_labor(world): t = Tree(_as(world), "personal") t.do(op="add-item", 경로=["배수"], 이름="측구", 단위="m") @@ -82,14 +103,27 @@ def test_서버_목록_칸_수정일_labor(world): doc = { "일위대가번": "SU-000001", "담은로직": [{"로직": "GF000219"}, {"로직": "GF000137"}], - "계산값": {"반영합": {"계": 12345.6}}, + "단위": "개소", + "계산값": {"반영합": {"계": 12345.6}, "기준합": {"계": 2469.12}}, } (root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8") (root / "labor" / f"{wall['열']}.json").write_text("{깨짐", encoding="utf-8") rows = {r["열"]: r for r in t.client.get(t.url).json()["items"]} - assert rows[ditch["열"]]["labor"] == {"번호": "SU-000001", "로직수": 2, "합계": 12345.6} + assert rows[ditch["열"]]["labor"] == { + "번호": "SU-000001", + "로직수": 2, + "합계": 12345.6, + "기준": 2469.12, + "단위": "개소", + } assert "labor" in rows[ditch["열"]]["수정일"] - assert rows[wall["열"]]["labor"] == {"번호": None, "로직수": 0, "합계": None} + assert rows[wall["열"]]["labor"] == { + "번호": None, + "로직수": 0, + "합계": None, + "기준": None, + "단위": None, + } doc["계산값"] = {"반영합": {"계": "글"}} (root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8") again = {r["열"]: r for r in t.client.get(t.url).json()["items"]} diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index e77d15a6d..50f51fcad 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -53,3 +53,13 @@ def test_층_단추는_구조물_조합_한_벌(): page = _text("M02_MasterTemplete_UI_Page.ts") assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나 assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page + + +def test_나무가_바뀌면_좌측_하위_열_줄도_다시_받음(): + """39-8 H1 — 새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기 = 나무 `apply` → + `onChanged` 한 길 · 집계표만 다시 열면 좌측 하위 줄이 새로고침 전까지 안 따라옴.""" + page = _text("M02_MasterTemplete_UI_Page.ts") + changed = page.split("onChanged: async (colId) => {")[1].split("},")[0] + assert "main.openTable(layer, colId, true)" in changed and "side.refresh()" in changed + panel = _text("M02_MasterTemplete_TreePanel.ts") + assert panel.count("await opt.onChanged(") == 1 # 나무 고침은 모두 apply 한 곳을 거침 diff --git a/resources/tester/test_m02_labor_values.py b/resources/tester/test_m02_labor_values.py index 2d96b1d94..5915821cb 100644 --- a/resources/tester/test_m02_labor_values.py +++ b/resources/tester/test_m02_labor_values.py @@ -131,6 +131,7 @@ console.log(JSON.stringify([ m.qtyText({ 수량: 20, 수량출처: "칸", 알약: "돌쌓기|㎡" }, "㎡"), m.qtyText({ 수량: 3, 수량출처: "손", 알약: "돌쌓기|㎡" }, "㎡", cells), m.qtyText({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡", cells), + m.qtyText({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡"), ])); """ @@ -148,5 +149,6 @@ def test_수량_글은_이은_칸_주소를_보임() -> None: "수량 B6 = 20 ㎡", "수량 면적산출 칸 = 20 ㎡", "수량 손 입력 3 ㎡", + "수량 B6 = 빈 칸", # 이은 칸이 비었음 = 수량 없음(39-8 ② · 서버 area_values 와 같음) "", ] diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 80e1bf0f8..9e3fb2227 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -206,7 +206,9 @@ def test_층_단추_셋_남의_회사_사람_나무(): def test_층_길_접두어와_고정_풀림(): api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") - assert 'activeLayer === "system" ? "" : `/layers/${activeLayer}`' in api + # 시스템 층도 층 길 한 벌(39-8 H2 · 옛 /templates/* 는 시스템 관리자 전용) + assert "owned(activeLayer, `/layers/${activeLayer}${path}`)" in api + assert 'activeLayer === "system" ? "" :' not in api for path in ("/structures/numbers", "/structure-combos"): assert re.search(rf'scoped\(\s*[`"]{re.escape(path)}', api), path assert "/layers/personal/items/copy" in api diff --git a/resources/tester/test_m02_system_read.py b/resources/tester/test_m02_system_read.py new file mode 100644 index 000000000..b86aaf039 --- /dev/null +++ b/resources/tester/test_m02_system_read.py @@ -0,0 +1,76 @@ +"""마스터 층 읽기는 층 길 한 벌(39-8 · sub1 검증 흠 H2) — 회사 관리자 · 일반도 `/layers/system/…` 로 +표 · 카드 · 일위대가 · 번호 · 장부 · 옵션을 읽음(200) · 쓰기는 서버가 그대로 막음(403). + +시스템 층 = 견본 표(임시 폴더) · storage = 임시 폴더. +""" + +from __future__ import annotations + +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_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_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 + +SYS = "/api/m02/layers/system" +TABLE = f"{SYS}/templates/table/구조물집계표" + + +@pytest.fixture() +def world(tmp_path, 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") + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app = FastAPI() + app.include_router(items_router.router) + app.include_router(layers_router.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "session": session} + + +@pytest.mark.parametrize("role", ["USER", "ADMIN", "SYSTEM_ADMIN"]) +def test_세_계정_모두_마스터_층_읽기_200(world, role): + world["session"]["role"] = role + client = world["client"] + for url in ( + f"{SYS}/templates", + TABLE, + f"{SYS}/templates/structure/rv", + f"{SYS}/templates/labor/rv", + f"{SYS}/templates/area/rv", + f"{SYS}/structures/numbers", + f"{SYS}/structure-combos", + f"{SYS}/structure-options", + ): + assert client.get(url).status_code == 200, url + rows = client.get(f"{SYS}/templates").json()["양식"] + table = next(r for r in rows if (r["종류"], r["이름"]) == ("table", "구조물집계표")) + assert table["쓰는곳"] # 지우기 확인 창 전에 막는 글 + + +@pytest.mark.parametrize("role", ["USER", "ADMIN"]) +def test_회사_관리자_일반은_마스터_쓰기_403(world, role): + world["session"]["role"] = role + client = world["client"] + got = client.get(TABLE).json() + put = client.put(TABLE, json={"판": got["판"], "문서": got["문서"]}) + assert put.status_code == 403 + assert client.post(f"{SYS}/structures/c1").status_code == 403 + assert client.post(f"{SYS}/labor/rv/copy?from=rv").status_code == 403 + gone = client.delete(f"{SYS}/templates/area/rv?판=x") + assert gone.status_code == 403 diff --git a/resources/tester/test_sheet_drawing_select.py b/resources/tester/test_sheet_drawing_select.py index 2909841c4..133f770b1 100644 --- a/resources/tester/test_sheet_drawing_select.py +++ b/resources/tester/test_sheet_drawing_select.py @@ -8,6 +8,7 @@ master 만 · 단위 줄 바로 아래 차례(키보드) · project 에는 없 from __future__ import annotations import json +import re import shutil import subprocess from pathlib import Path @@ -70,3 +71,18 @@ def test_text_column_pick_rows_blank(): assert _run()["blank"] == [ [True, False], [True, False], [True, False], [False, False], [False, False], ] # fmt: skip + + +def test_labor_row_shows_su_number(): + """마스터 일위대가 줄 = 그 열 일위대가번(SU) — 열의 옛 `일위대가` 칸(UA 키)은 안 읽음.""" + ops = OPS.read_text(encoding="utf-8") + assert "REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA]" in ops + render = (OPS.parent / "ui_template_sheet_render.ts").read_text(encoding="utf-8") + assert re.search(r"key === KEY_PRICE\s*\?\s*state\.laborLabel", render) + assert "col.일위대가" not in render + sheet = (OPS.parent / "ui_template_sheet.ts").read_text(encoding="utf-8") + assert "col.일위대가" not in sheet # 읽기 · 쓰기 · 조회 후 선택 모두 안 건드림 + main = (ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_UI_Main.ts").read_text( + encoding="utf-8" + ) + assert 'laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? ""' in main diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index 35372ff8a..b8f38ebe6 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -25,7 +25,6 @@ import { addLevel, deleteColumn, isBlankPick, - KEY_PRICE, KEY_UNIT, navRows, PICK_ROWS, @@ -54,6 +53,8 @@ export interface SheetOptions { /** master — 열 더하기 · 지우기 · 머리 · 층 이름 고치기 없음(열 · 머리는 나무가 정함) */ lockHead?: boolean; onChange?: (doc: SheetDoc) => void; + /** 일위대가 줄 칸 글(일위대가번 SU · 열 id) — `drawingLabel` 과 같음(열의 옛 `일위대가` 칸은 안 읽음). */ + laborLabel?: (colId: string) => string; /** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */ drawingLabel?: (colId: string) => string; /** 면적산출 줄 칸 글(번호) — `drawingLabel` 과 같음. */ @@ -61,8 +62,7 @@ export interface SheetOptions { /** 칸을 고름 — 누르기 · 방향키 · Tab. */ onSelect?: (sel: SheetSelection) => void; /** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 칸을 두 번 누르기 · Enter · F2 로 부름. - * 글을 돌려주면 일위대가 칸에 넣음 · 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()`. - * 없으면 일위대가는 글로 고침 · 번호 글 줄은 읽기만. */ + * 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()` · 없으면 읽기만. */ onPick?: (sel: SheetSelection) => Promise | void; /** 구조물 조합 표(PLAN 31-1) — 단위 줄 · 안내 글 없음 · 열 폭 자동 · 고른 열은 가장 깊은 이름 칸을 밝힘 · * `onHeadEdit` 를 주면 이름 칸을 두 번 누르기 · F2 · Enter 로 고쳐 부른 쪽에 넘김(표 문서는 안 고침 — @@ -87,6 +87,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio mode: opts.mode, result: { 계산: {}, 합계: {}, 오류: [] }, sel: null, + laborLabel: opts.laborLabel, drawingLabel: opts.drawingLabel, areaLabel: opts.areaLabel, pick: !!opts.onPick, @@ -236,7 +237,6 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio // ── 칸 고치기 ───────────────────────────────────────────────────── const rawText = (key: string, col: SheetColumn): string => { if (key === KEY_UNIT) return col.단위 ?? ""; - if (key === KEY_PRICE) return col.일위대가 ?? ""; if (REF_ROWS.includes(key)) return refText(state, key, col.id); const row = state.doc.줄.find((r) => `d:${r.id}` === key); const formula = row?.식?.[col.id] ?? col.식; @@ -248,15 +248,12 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio if (ro || REF_ROWS.includes(key)) return; // 읽기만 const value = text.trim(); if (key === KEY_UNIT) col.단위 = value || null; - else if (key === KEY_PRICE) col.일위대가 = value || null; else setCell(state.doc, key.slice(2), col, text); changed(); }; - /** 조회 후 선택 — 부른 쪽이 고른 글을 돌려주면 일위대가 칸에 넣음(그 사이 문서가 바뀌었으면 새 열에) */ + /** 조회 후 선택 — 부른 쪽이 가져옴(번호 글 줄 · 문서는 안 고침) */ const pick = async (key: string, colId: string): Promise => { - const got = await opts.onPick?.(selectionOf(state.doc, key, colId)); - const col = colOf(colId); - if (key === KEY_PRICE && typeof got === "string" && col) write(key, col, got); + await opts.onPick?.(selectionOf(state.doc, key, colId)); root.focus({ preventScroll: true }); }; /** 칸 고치기 — 두 번 누르기 = 글 전부 고름 · 바로 치기(initial) = 그 글자로 바꿈 · Enter · F2(atEnd) = 커서를 글 끝에 */ diff --git a/ui_template/sheet/ui_template_sheet_ops.ts b/ui_template/sheet/ui_template_sheet_ops.ts index a3d807da6..0f0a51ea2 100644 --- a/ui_template/sheet/ui_template_sheet_ops.ts +++ b/ui_template/sheet/ui_template_sheet_ops.ts @@ -22,11 +22,11 @@ export const KEY_UNIT = "s:unit"; export const KEY_PRICE = "s:price"; export const KEY_DRAWING = "s:drawing"; export const KEY_AREA = "s:area"; -/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 면적산출 줄은 손으로 못 침(번호 글). */ +/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 일위대가 · 도면 · 면적산출 줄은 손으로 못 침(번호 글). */ export const MASTER_ROWS = [KEY_PRICE, KEY_DRAWING, KEY_AREA] as const; export type MasterRowKey = (typeof MASTER_ROWS)[number]; -/** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임. */ -export const REF_ROWS: readonly string[] = [KEY_DRAWING, KEY_AREA]; +/** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임(일위대가 = SU · 열의 옛 `일위대가` 칸은 안 읽음). */ +export const REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA]; /** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */ export const PICK_ROWS: readonly string[] = MASTER_ROWS; diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 3b5113938..e9992bbf7 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -17,6 +17,7 @@ import { isBlankPick, isCalcColumn, KEY_AREA, + KEY_PRICE, KEY_UNIT, MASTER_ROWS, type MasterRowKey, @@ -38,7 +39,8 @@ export interface RenderState { mode: SheetMode; result: SheetResult; sel: { r: string; c: string } | null; - /** 도면 · 면적산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */ + /** 일위대가 · 도면 · 면적산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */ + laborLabel?: (colId: string) => string; drawingLabel?: (colId: string) => string; areaLabel?: (colId: string) => string; /** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */ @@ -51,9 +53,13 @@ export interface RenderState { headEditable?: boolean; } -/** 도면 · 면적산출 줄 칸 글. */ +/** 일위대가 · 도면 · 면적산출 줄 칸 글(번호). */ export const refText = (state: RenderState, key: string, colId: string): string => - (key === KEY_AREA ? state.areaLabel : state.drawingLabel)?.(colId)?.trim() || st("Drawing_None"); + (key === KEY_AREA + ? state.areaLabel + : key === KEY_PRICE + ? state.laborLabel + : state.drawingLabel)?.(colId)?.trim() || st("Drawing_None"); /** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */ const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map): number => @@ -68,7 +74,7 @@ function fitMeta(state: RenderState, cols: SheetColumn[], mins: Map refText(state, key, col.id))]; + const texts = REF_ROWS.map((key) => refText(state, key, col.id)); const need = Math.ceil(Math.max(...texts.map((t) => textWidth(t, font)))) + 14; mins.set(col.id, Math.max(mins.get(col.id) ?? 0, need)); } @@ -191,11 +197,7 @@ export function renderSheet(state: RenderState): HTMLElement { const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] }); cols.forEach((col, j) => { const blank = isBlankPick(key, col); - const text = blank - ? "" - : REF_ROWS.includes(key) - ? refText(state, key, col.id) - : (col.일위대가 ?? ""); + const text = blank ? "" : refText(state, key, col.id); // 마스터 줄 = 모두 번호 글 const td = bodyCell(key, col, j, text); td.classList.remove("is-num"); if (state.pick && PICK_ROWS.includes(key) && !blank) td.classList.add("is-pick"); diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 670d97500..78aa7c1c7 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -143,6 +143,7 @@ export const ui_locales_m2 = { M02_TreeItemName: ["항목 이름", "Item name"], M02_TreeUnit: ["단위", "Unit"], M02_TreeMoveTo: ["옮길 폴더", "Move to"], + M02_TreeCopyFolder: ["복사할 폴더", "Copy to folder"], M02_TreeTop: ["맨 위 (폴더 밖)", "Top (no folder)"], M02_TreeOk: ["확인", "OK"], M02_TreeCancel: ["취소", "Cancel"],