/* ============================================================================= * ui_template_item_picker_project.ts * 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 입력 목록 · 계산을 한 곳에서 부름. * * 구조물 복사본(sub6 · 26-1 · 26-2b) — 폴더 = 구조물 id(같은 항목을 고른 구조물도 복사본은 따로) * GET /api/projects/{id}/items → {항목: [{폴더, 이름, 원본, 원본판, 가져온날, 마스터바뀜, 원본없음, …}]} * POST /api/projects/{id}/items {structure, layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날} * POST /api/projects/{id}/items/new {structure, 경로, 이름, 단위} 새 항목(개인 층에도) → {…, summary_item} * GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409) * POST …/{폴더}/reapply 재적용 → {폴더, 가져온날, 원본판, 덮임: [부분]} * POST …/{폴더}/save-as {to, 경로, 이름} 개인에 저장 → {key, 열, 이름, summary_item} * PUT …/{사본 id}/link {summary_item | null, facility} B08 바로 연결(154-2) — 그 시설 조합 칸 하나만 정본에 → {폴더, snapshot_failed} * POST …/{폴더}/calc 복사본만으로 계산 — 층 셈 꼴(줄 · 자재 · 기준합 · 반영합) + 알약 · 빠진입력(142-1) * 일위대가(39-1c · 142-1) — 모달 일위대가 카드(M02 카드 · 읽기 전용)가 `calcLabor` 로 셈 · 넣을 값 목록은 `fetchLogicInputs` * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import type { ItemInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import type { LaborDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import type { LaborCalc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Calc"; import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model"; import { layerOf, ownerOf, type PickerRow, type SummaryItem } from "./ui_template_item_picker_data"; /** 복사본 폴더 안 부분 — 25-1b 약속 그대로 */ export type ProjectPart = "도면" | "면적" | "입력값" | "고른자재" | "일위대가"; /** 원본 종류 → 복사본 부분 */ export const PART_OF: Record<"structure" | "area", ProjectPart> = { structure: "도면", area: "면적", }; export interface ProjectItemInfo { 폴더: string; 이름?: string | null; 원본: { layer: string; owner: string | null; key: string; name?: string }; 원본판?: string | null; 가져온날?: string | null; /** 원본(마스터 · 회사 · 개인)이 가져온 뒤 바뀜 */ 마스터바뀜?: boolean; 원본없음?: boolean; 구조물?: string[]; 고침?: boolean; } /** 구조물 id → 복사본 폴더 이름 — 서버 `folder_name` 과 같은 글(id 없는 옛 구조물 `#차례` = `n차례`) */ export const structureFolder = (structureId: string): string => structureId.replace("#", "n"); /** 관 지점의 구조물 id — 서버 `pipe@{round(측점, 3)}`(파이썬 float 글: 정수도 `100.0`) */ export function pipeStructureId(chainageM: number): string { const rounded = Math.round(chainageM * 1000) / 1000; return `pipe@${Number.isInteger(rounded) ? rounded.toFixed(1) : String(rounded)}`; } /** 이 복사본이 그 항목의 것인가 — 같은 구조물이 다른 항목으로 바뀌면 원본 참조가 다름 */ export const sameItem = (info: ProjectItemInfo, row: PickerRow): boolean => info.원본.layer === layerOf(row) && (info.원본.owner ?? null) === ownerOf(row) && info.원본.key === row.key; /** 개인 층에 새로 생긴 항목 — `summary_item` 을 폼에 지정하면 됨 */ export interface CreatedItem { key: string; 이름?: string; summary_item: { layer: string; owner: string | null; key: string; name: string }; } /** 복사본 셈 답(142-1) — 층 셈 꼴 + 사본 알약 · 빈 입력 목록(셈은 자동값으로 돎) */ export type CopyCalc = LaborCalc & { 알약: unknown[]; 빠진입력?: unknown[] }; /** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빈 입력 수 · 못 돈 줄 수 · 일위대가 기준합 · 반영합 계 */ export interface ProjectCalc { total: number; gaps: number; stuck: number; base: number | null; reflected: number | null; } interface Reply { status: number; body: unknown; } const enc = encodeURIComponent; /** 서버가 막은 까닭 그대로 — 문장 · `{message}`(409) · 입력 검사 목록(`[{msg}]`) */ function reasonOf(reply: Reply): string { const detail = (reply.body as { detail?: unknown } | null)?.detail; if (typeof detail === "string" && detail) return detail; const message = (detail as { message?: unknown } | null)?.message; if (typeof message === "string" && message) return message; if (Array.isArray(detail)) { const lines = detail .map((one) => String((one as { msg?: unknown })?.msg ?? "")) .filter(Boolean); if (lines.length) return lines.join(" · "); } return `HTTP ${reply.status}`; } async function call(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", ...init, headers: init.body ? { "Content-Type": "application/json" } : undefined, }); return { status: response.status, body: await response.json().catch(() => null) }; } const post = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) }); export interface ProjectItems { /** 프로젝트에 가져온 항목 목록 — 서버가 항목당 0.5초쯤 걸려 모달이 열 때 한 번만 부르고 들고 있음 */ list: () => Promise; /** 복사본 부분 하나(`내용`) — 없으면 null · 판은 고칠 때를 위해 들고 있음 */ read: (folder: string, part: ProjectPart) => Promise; /** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판이 다름 …) */ write: (folder: string, part: ProjectPart, doc: unknown) => Promise; /** 프로젝트로 미리 가져옴 */ importItem: (row: PickerRow, structureId: string) => Promise; /** 재적용 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) · `covered` = 덮인 부분 */ reapply: (info: ProjectItemInfo) => Promise<{ info: ProjectItemInfo; covered: string[] }>; /** 이 구조물 복사본을 개인 층 새 항목으로 — 못 하면 서버 까닭(회사 없음 409 …) 그대로 던짐 */ saveAs: (folder: string, path: string[], name: string) => Promise; /** 프로젝트에서 새 항목(개인 층에도 만들고 그 구조물 복사본으로) */ newItem: ( structureId: string, path: string[], name: string, unit: string, ) => Promise; /** 복사본만으로 계산 — 계산할 것이 없거나 못 하면 null */ calc: (folder: string) => Promise; /** 복사본 셈 답 통째 — 모달 일위대가 카드(Labor_Detail)가 씀 · 못 하면 서버 까닭 그대로 던짐 */ calcLabor: (folder: string) => Promise; /** 바로 연결(154-2) — 시설 조합 칸 하나만 정본에(null = 풀기) · `facility` = 관 지점 종류 · * 돌려받음 = 못 만든 사본 폴더 · 못 하면 서버 까닭 그대로 던짐 */ link: (copy: string, facility: string | null, item: SummaryItem | null) => Promise; } const clients = new Map(); /** 프로젝트 하나의 창구 — 같은 프로젝트는 한 벌(고칠 때 쓸 판을 들고 있음) */ export function projectItems(projectId: string): ProjectItems { const kept = clients.get(projectId); if (kept) return kept; const base = `/projects/${enc(projectId)}/items`; const versions = new Map(); /** 그 폴더에서 읽어 둔 판을 버림(다시 가져오면 판이 바뀜) */ const forget = (folder: string): void => { for (const key of [...versions.keys()]) if (key.startsWith(`${folder}/`)) versions.delete(key); }; const at = (folder: string, part: string): string => `${base}/${enc(folder)}/${enc(part)}`; /** 복사본 셈(142-1) — 못 하면 서버 까닭 그대로 던짐 */ const calcCopy = async (folder: string): Promise => { const reply = await call(`${base}/${enc(folder)}/calc`, post({})); if (reply.status !== 200) throw new Error(reasonOf(reply)); return reply.body as CopyCalc; }; const client: ProjectItems = { list: async () => { const reply = await call(base); const rows = (reply.body as { 항목?: ProjectItemInfo[] } | null)?.항목; if (reply.status !== 200 || !Array.isArray(rows)) throw new Error(reasonOf(reply)); return rows; }, read: async (folder, part) => { const reply = await call(at(folder, part)); if (reply.status === 404) return null; if (reply.status !== 200) throw new Error(reasonOf(reply)); const got = reply.body as { 판?: string | null; 내용?: unknown }; versions.set(`${folder}/${part}`, got.판 ?? null); return got.내용 ?? null; }, write: async (folder, part, doc) => { const key = `${folder}/${part}`; if (!versions.has(key)) await client.read(folder, part); // 고칠 판을 먼저 앎 const reply = await call(at(folder, part), { method: "PUT", body: JSON.stringify({ 판: versions.get(key) ?? null, 내용: doc }), }); if (reply.status >= 400) throw new Error(reasonOf(reply)); versions.set(key, (reply.body as { 판?: string } | null)?.판 ?? null); }, importItem: async (row, structureId) => { const ref = { layer: layerOf(row), owner: ownerOf(row), key: row.key }; const reply = await call(base, post({ structure: structureId, ...ref })); if (reply.status >= 300) throw new Error(reasonOf(reply)); const got = reply.body as { 폴더: string; 가져온날?: string | null }; forget(got.폴더); return { 폴더: got.폴더, 원본: ref, 가져온날: got.가져온날, 마스터바뀜: false }; }, reapply: async (info) => { const reply = await call(`${base}/${enc(info.폴더)}/reapply`, post({})); if (reply.status >= 300) throw new Error(reasonOf(reply)); forget(info.폴더); const got = reply.body as { 가져온날?: string | null; 원본판?: string | null; 덮임?: string[]; }; return { info: { ...info, 가져온날: got.가져온날, 원본판: got.원본판, 마스터바뀜: false, 고침: false, }, covered: got.덮임 ?? [], }; }, saveAs: async (folder, path, name) => { const reply = await call( `${base}/${enc(folder)}/save-as`, post({ to: "personal", 경로: path, 이름: name }), ); if (reply.status >= 300) throw new Error(reasonOf(reply)); return reply.body as CreatedItem; }, newItem: async (structureId, path, name, unit) => { const reply = await call( `${base}/new`, post({ structure: structureId, 경로: path, 이름: name, 단위: unit }), ); if (reply.status >= 300) throw new Error(reasonOf(reply)); return reply.body as CreatedItem; }, calc: async (folder) => { const got = await calcCopy(folder).catch(() => null); if (!got) return null; return { total: Number(got.반영합?.계 ?? got.기준합?.계 ?? 0), gaps: got.빠진입력?.length ?? 0, stuck: (got.줄 ?? []).filter((line) => line.까닭).length, base: got.기준합?.계 ?? null, reflected: got.반영합?.계 ?? null, }; }, calcLabor: (folder) => calcCopy(folder), link: async (copy, facility, item) => { const reply = await call(`${base}/${enc(copy)}/link`, { method: "PUT", body: JSON.stringify({ summary_item: item, facility }), }); if (reply.status >= 300) throw new Error(reasonOf(reply)); forget(copy); return (reply.body as { snapshot_failed?: string[] } | null)?.snapshot_failed ?? []; }, }; clients.set(projectId, client); return client; } /* ── 일위대가(39-1c) — 넣을 값 자료 ─────────────────────────────── */ /** 넣을 값 카드 자료 — 담은 로직 입력 전부 */ export async function laborValueData( logics: string[], values: ValueDoc, ): Promise<{ list: ItemInputs; values: ValueDoc }> { const got = await fetchLogicInputs(logics); const list: ItemInputs = { 일위대가: null, 로직: got.로직, 빠진로직: got.빠진로직 }; return { list, values }; } /** 복사본 일위대가 문서에 복사본 입력값 · 고른자재를 줄 입력값 · 고름으로 얹음(보기용 · 142-1) — * 빈 값은 안 얹음(= 자동값) · 셈은 서버가 같은 규칙으로(`M02_Item_Prices.with_values`) */ export function withCopyValues( doc: LaborDoc, values: ValueDoc | null, picks: Record | null, ): LaborDoc { const lines = (doc.담은로직 ?? []).map((one) => { const code = (one as { 로직?: string }).로직; if (!code) return one; const given = Object.fromEntries( Object.entries(values?.[code] ?? {}).flatMap(([name, got]) => { const v = (got as { 값?: unknown } | null)?.값; return v === null || v === undefined || v === "" ? [] : [[name, v]]; }), ); const pick = picks?.[code]; return { ...one, 입력값: given, ...(pick && typeof pick === "object" ? { 고름: pick } : {}), } as typeof one; }); return { ...doc, 담은로직: lines }; }