From ca72718e26d70503c2b8d7f002c6eb86e0d04803 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 18:09:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(793-3b):=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=EB=8B=A8=EC=82=B0=20=EC=A4=84=20=3D=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A8=20=EC=8B=9D=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=AA=A8=EB=8B=AC=20=E2=80=94=20=EB=B8=94=EB=A1=9D?= =?UTF-8?q?=20=E2=86=92=20=EA=B0=80=EC=A0=B8=EC=98=A8=20=EC=8B=9D=20SK=20?= =?UTF-8?q?=EA=B8=B8=20=C2=B7=20=EA=B2=80=EC=82=B0=20=C2=B7=20=EC=97=91?= =?UTF-8?q?=EC=85=80/=EC=9A=B0=EB=A6=AC=20=EB=8B=A8=EA=B0=80=20=C2=B7=20?= =?UTF-8?q?=EC=9D=B4=EC=9D=8C=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0=20=EC=84=B8=20=EB=8B=A8=20=C2=B7=20=EC=9A=B0?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EC=B4=88=EB=A1=9C=20=EB=B0=94=EA=BE=B8?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=9E=85=EB=A0=A5=20=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=C2=B7=20=EC=A4=84=20=EC=9E=87=EA=B8=B0=20=C2=B7=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EC=B9=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버: 올림 때 산근 시트 둠(실무자료/산근/.json · HP 지울 때 같이) · POST …/sangeun/from-hopyo(블록 → 793-2a 내보내기 → 793-2 만들기 · 같은 내용 = 그 SK 다시 씀 · 옛 HP 409) · STmate 끝전 = 엑셀 계와 맞는 쪽 - 화면: [수정] 모달 단산 줄 · 오른쪽 블록 판 [가져온 식으로 열기](새) → 시트 모달 + 머리 판 · [이 줄에 잇기] = 줄 고른 것 SK(입력 필요 남으면 확인 막힘) · 단산 줄 결과 칩 - 시험: test_793_3b_import_screen 6 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Router.py | 4 + M02_MasterTemplete/M02_Hopyo_Store.py | 1 + .../M02_MasterTemplete_Hopyo_Block.ts | 6 + .../M02_MasterTemplete_Hopyo_Check.ts | 23 +- .../M02_MasterTemplete_Hopyo_CheckRows.ts | 26 +- .../M02_MasterTemplete_Hopyo_Imported.ts | 262 ++++++++++++++++++ .../M02_MasterTemplete_Hopyo_ImportedPanel.ts | 247 +++++++++++++++++ M02_MasterTemplete/M02_Sangeun_FromHopyo.py | 107 +++++++ M02_MasterTemplete/M02_Sangeun_Router.py | 27 +- .../logic/M02_Hopyo_Imported_Model.ts | 134 +++++++++ resources/tester/test_793_3b_import_screen.py | 146 ++++++++++ ui_template/ui_template_locale_m2_judge.ts | 1 + 12 files changed, 981 insertions(+), 3 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts create mode 100644 M02_MasterTemplete/M02_Sangeun_FromHopyo.py create mode 100644 M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts create mode 100644 resources/tester/test_793_3b_import_screen.py diff --git a/M02_MasterTemplete/M02_Hopyo_Router.py b/M02_MasterTemplete/M02_Hopyo_Router.py index cc96feae1..a1bf475ff 100644 --- a/M02_MasterTemplete/M02_Hopyo_Router.py +++ b/M02_MasterTemplete/M02_Hopyo_Router.py @@ -36,6 +36,7 @@ from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root router = APIRouter(prefix="/api/m02", tags=["M02 Hopyo"]) @@ -245,6 +246,8 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: mark = hopyo_store.fingerprint(data) hopyo_forget.sweep(store.root()) # 하루 지난 작업 중 HP 끝냄(743-1) if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None): + if not from_hopyo.has(store.root(), same["키"]): # 793-3b 전에 올린 HP + from_hopyo.keep(store.root(), same["키"], name, data) hopyo_judge.ensure(store.root(), same["키"]) return {**same, "이어서": True} try: @@ -258,6 +261,7 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: sheets, _ = hopyo_excel._open(name, data, hopyo_row.SHEETS) parsed["사전"] = hopyo_row.lexicon(sheets) # 엑셀 사전(785-3 · 1차 줄 판정) row = hopyo_store.save(store.root(), name, parsed, who, grid, mark) + from_hopyo.keep(store.root(), row["키"], name, data) # 산근 시트(793-3b 가져온 식 열기) hopyo_judge.ensure(store.root(), row["키"]) # 블록 풀이 뒤 셈(파생 저장본 · 화면 안 기다림) return row diff --git a/M02_MasterTemplete/M02_Hopyo_Store.py b/M02_MasterTemplete/M02_Hopyo_Store.py index b3ce67a73..261347eb0 100644 --- a/M02_MasterTemplete/M02_Hopyo_Store.py +++ b/M02_MasterTemplete/M02_Hopyo_Store.py @@ -166,6 +166,7 @@ def delete(folder: str | Path, key: str) -> None: raise store.StoreError(404, f"없는 실무자료 「{key}」") path.unlink() _grid_path(folder, key).unlink(missing_ok=True) + (_dir(folder) / "산근" / f"{key}.json").unlink(missing_ok=True) # 793-3b 산근 시트 # ── 맞춤 기억 ───────────────────────────────────────────────────────── diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts index 795659ece..05d33e997 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts @@ -5,6 +5,7 @@ * · 절마다 = 맡은 절 · 기초 후보 칩(여럿 고름) · 고른 후보마다 입력값 칸(출처 꼬리표 B1~B6 · 사용자 · 기본 · * 「입력 필요」 붉은 칸 · 엇갈림 고르기) · 안 씀 · 줄 대조(품 같음 · 품 다름 · 엑셀만 · 우리만 · 입력 필요) · 금액 * · 덧 줄(엑셀만) = 후보 칩 → 재료 · 인력 · 기계 고른 것 + * · 머리 [가져온 식으로 열기](793-3b · 새) = `Hopyo_Imported` 모달(이 블록 → 가져온 식 SK · 시트 · 단가 · 기초로 바꾸기) * · [검산] = 서버 셈(저장 없음 · 옛 요청 끊기) · 고름 · 입력은 캐시(`createJudgeState` = sessionStorage) — 옮김은 [옮기기] 뒤 서버 * 판정 · 금액은 서버 · 여기는 보이기 · 고르기만. * ========================================================================== */ @@ -396,6 +397,11 @@ export function createJudgeBlock(opt: JudgeBlockOptions): { }); ok.root.dataset.judgeOk = "block"; const actions: HTMLElement[] = [ok.root]; + const open = createButton({ label: L("M02_CheckImported"), size: 24, variant: "ghost", disabled: opt.readOnly, onClick: () => void import("./M02_MasterTemplete_Hopyo_Imported").then((m) => m.openImported({ ...{ file: opt.file, key: opt.key, no: blk.번, target: opt.target, canWrite: !opt.readOnly }, bind: (code, given) => checkJudge(opt.file, opt.key, { 블록: blk.번, 고른: [bare({ 갈래: "기초", 키: code, 이름: code, 입력값: given, 출처: {}, 몫: null })] }).then((res) => ({ 입력값: res.고른[0]?.입력값 ?? given, 필요: res.고른[0]?.입력필요 ?? [] })) })) }); // prettier-ignore + open.dataset.judgeImported = String(blk.번); + const fresh = createBadge(L("M02_CheckNew"), "accent", { size: 18 }); // 「새」(793-3b) — CheckRows.newBadge 꼴(돌림 import 피함) + fresh.dataset.checkNew = ""; + actions.push(open, fresh); if (opt.onClose) actions.push(createButton({ label: L("Common_Btn_Close"), size: 24, variant: "ghost", onClick: opt.onClose })); // prettier-ignore const title = `${L("M02_JudgeBlock").replace("{n}", String(blk.번))} ${blk.이름} (${blk.단위})`; const sub = [ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index a78b55e2c..f3d89c72f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -10,6 +10,7 @@ * · 고름 · 확인 = 판정 캐시(`createJudgeState` · 시트 칠 · 오른쪽 [옮기기 (n)] 과 한 벌) · 자동 확인 · 자동 옮김 없음 * · [사전조합 생성] = 옮기기 길(`runMove`) · 이 호표의 확인한 줄만 · 서버가 정본으로 다시 셈 * · [구조물 조합으로] = 이 호표가 사전조합으로 옮겨진 뒤만 · 구조물 조합 만들기 길(`Hopyo_ToItem`) + * · 단산 줄 [가져온 식으로 열기](793-3b) = `Hopyo_Imported` 모달(가져온 식 SK) · [이 줄에 잇기] = 그 줄 고른 것 = 그 SK * · 행 번호 · [엑셀 보기] = 모달 닫고 시트 그 행(고름은 캐시로 남음 · 다시 [수정] 하면 그대로) * · 옛 [맞음·별칭 일괄 체크] · [품셈으로 바꾸기] · [모두 고정으로] = 지운 서버 길 몫 — [호표 제안 모두 확인]이 갈음 * ========================================================================== */ @@ -44,6 +45,7 @@ import { type JudgeState, type SheetRole, } from "./M02_MasterTemplete_Hopyo_Rows"; +import { openImported } from "./M02_MasterTemplete_Hopyo_Imported"; import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem"; import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; @@ -251,6 +253,25 @@ export function openHopyoCheck(opt: CheckOptions): { close: () => void } { }); } + /** 기초 옵션 묶음 — 이 줄 검산(엑셀 값 · 입력 필요) */ + const bindOf = (row: JudgeRow) => (code: string, given: Record) => + checkJudge(opt.file, opt.key, { + 호표: no, + 차례: row.차례, + 고른: [bare({ 갈래: "기초", 키: code, 이름: code, 입력값: given, 출처: {}, 몫: null })], + }).then( + // prettier-ignore + (res) => ({ 입력값: res.고른[0]?.입력값 ?? given, 필요: res.고른[0]?.입력필요 ?? [] }), + ); + + /** [가져온 식으로 열기](793-3b) — 단산 블록 → 가져온 식 SK 모달 · [이 줄에 잇기] = 이 줄 고른 것 */ + const imported = (row: JudgeRow): void => + openImported({ + ...{ file: opt.file, key: opt.key, no: row.번!, target: opt.target, canWrite: opt.canWrite }, + bind: bindOf(row), + onLink: (p) => kit.edit(row, [p]), + }); + /** 추가(빈 값) = 제안으로 되돌림 · 로직없음 = 고정 줄(엑셀 숫자 그대로) */ function out(row: JudgeRow, value: string): void { if (value === NO_LOGIC) { @@ -315,7 +336,7 @@ export function openHopyoCheck(opt: CheckOptions): { close: () => void } { ], }); const legend = el("div", { className: "ui-toolbar m02-judge__wrap", children: [newBadge(), note(L("M02_CheckNewHint"))] }); // prettier-ignore - const ctx = { kit, readOnly, price, pick, source: (r: JudgeRow) => void showSource(r), options, out }; // prettier-ignore + const ctx = { kit, readOnly, price, pick, source: (r: JudgeRow) => void showSource(r), options, out, imported }; // prettier-ignore root.replaceChildren(head, legend, ...(h.줄.length ? checkTables(h.줄, ctx) : [note(L("M02_CheckNoLines"))])); // prettier-ignore } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts index 15038eb23..5e60de77f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts @@ -8,6 +8,8 @@ * · 자재 표 = [확인] · 행 · 코드 · 이름 · 규격 · 수량산출 · 단위 · 세 칸(글만) · 가격 다섯 · 엑셀 / 우리 합계 · 단추 * · 단추 = [고르기] · [원문 보기] · [옵션](기초마다) · 추가 / 로직없음(= 고정 줄) — 옛 「사용안함」(뺌)은 새 옮기기 길에 없어 안 살림 * · 「새」 배지(785 새 기능) = 머리 칸 [확인] · 구분(판정) · 이름(고름 · 블록 풀이) · 수량산출(단위 환산) + * · 단산 줄(793-3b) = 판정 칩 「2차 필요」 대신 결과 칩(가져온 식 있음 · 근거 확정 · 근접 · 사람 고름 · 입력 필요 n · + * SK 안 이었으면 블록 풀이 판정) · 단추 [가져온 식으로 열기](새 · `Hopyo_Imported` 모달) * 줄 표 = `tableOf`(고른 것이 모두 재료 · 유가 · 없으면 엑셀 갈래 · 별도계상 줄 = 자재 표). * ========================================================================== */ @@ -21,6 +23,7 @@ import type { CalcMaterial } from "./M02_MasterTemplete_Labor_Calc"; import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks"; import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { rowChips } from "./logic/M02_Hopyo_Imported_Model"; import type { DraftPick } from "./logic/M02_MasterTemplete_Hopyo_Picks"; type Money4 = Record<"노무비" | "재료비" | "경비" | "계", number | null>; @@ -44,6 +47,8 @@ export interface CheckCtx { options: (row: JudgeRow, p: DraftPick) => void; /** 추가(빈 값 = 제안으로 되돌림) · 로직없음(고정 줄) */ out: (row: JudgeRow, value: string) => void; + /** 단산 줄 [가져온 식으로 열기](793-3b) */ + imported?: (row: JudgeRow) => void; } const COSTS = ["노무비", "재료비", "경비", "계"] as const; @@ -128,6 +133,11 @@ function actsCell(row: JudgeRow, ctx: CheckCtx, picks: DraftPick[]): HTMLElement }; const kids: HTMLElement[] = []; if (!ctx.readOnly && !apartRow(row)) kids.push(ghost(L("M02_CheckPick"), () => ctx.pick(row), "pick")); // prettier-ignore + if (row.번 !== undefined && ctx.imported) { + const b = ghost(L("M02_CheckImported"), () => ctx.imported!(row), "imported"); + (b as HTMLButtonElement).disabled = ctx.readOnly; + kids.push(el("span", { className: "m02-judge__wrap", children: [b, newBadge()] })); + } kids.push(ghost(L("M02_CheckSource"), () => ctx.source(row), "source")); for (const p of picks.filter((x) => x.갈래 === "기초")) { const b = ghost(L("M02_CheckOption"), () => ctx.options(row, p), "option"); @@ -172,11 +182,25 @@ const rowOf = ( ], }); +/** 단산 줄 결과 칩(793-3b) — SK 고른 것 표지 · 없으면 블록 풀이 판정 · 단산 아니면 판정 칩 */ +function verdictOf(row: JudgeRow, ctx: CheckCtx): HTMLElement[] { + if (row.번 === undefined) return [ctx.kit.verdict(row)]; + const got = rowChips(ctx.kit.picksOf(row)); + const list = got ?? (row.풀이?.판정 ? [row.풀이.판정] : []); + if (!list.length) return [ctx.kit.verdict(row)]; + return list.map((text) => { + const tone = text.startsWith("입력 필요") ? "danger" : text.includes("확정") || text.startsWith("가져온") ? "success" : text.includes("근접") ? "warning" : "info"; // prettier-ignore + const b = createBadge(text, tone); + b.dataset.judgeResult = text; + return b; + }); +} + const kindCell = (row: JudgeRow, ctx: CheckCtx): HTMLElement => el("td", { className: "m02-labrow__kind", attrs: { title: row.근거 }, - children: [bar([...row.갈래.map((k) => createBadge(k, "neutral")), ctx.kit.verdict(row)])], + children: [bar([...row.갈래.map((k) => createBadge(k, "neutral")), ...verdictOf(row, ctx)])], }); function logicRow(row: JudgeRow, ctx: CheckCtx): HTMLElement[] { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts new file mode 100644 index 000000000..02e270839 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts @@ -0,0 +1,262 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Imported.ts + * 불러오기 단산 줄 [가져온 식으로 열기] 모달(PLAN 793-3b · 요구 646 · 647 · 652 갈래 C) — + * [수정] 모달 단산 줄(`Hopyo_Check`) · 오른쪽 목록 단가산출 탭 블록(`Hopyo_Block`)이 엶. + * · 열기 = 서버 `…/sangeun/from-hopyo`(그 블록 → 가져온 식 SK · 만들 층 · 엑셀 단가 그대로 · 같은 내용 = 그 SK 다시 씀) + * → 그 SK 를 읽어 우리 단가산출 화면(`Sangeun_Detail` 시트 · 고침 = 시트 그대로) + 머리 판(`Hopyo_ImportedPanel`) + * · [우리 단가로] · [엑셀 단가로] · [이음 고르기] · [우리 기초로 바꾸기] = 서버 `…/sangeun/imported/{price|link|to-base}` + * (화면 캐시 문서 고침 · 저장 없음) → 시트 다시 엶 · 기초로 바꿀 때 옵션 = 불러오기 검산(`bind` · 엑셀 값 묶음 · 입력 필요) + * · [저장] = 그 SK 저장(서버가 다시 셈) · [이 줄에 잇기] = 저장 뒤 단산 줄 고른 것 = 그 SK(갈래 단가산출 · 키) — + * 확인 · [사전조합 생성]은 기존 길(자동 확인 · 옮김 없음) · 입력 필요가 비면 그 줄 확인 막힘 + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_button"; +import { el, showToast } from "@ui/ui_template_elements"; +import { loadingError, showLoading } from "@ui/ui_template_loading"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { askConfirm, openModal } from "@ui/ui_template_modal"; +import { call, readTemplate, saveTemplate } from "./M02_MasterTemplete_Api_Fetch"; +import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; +import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; +import { importedPanel, type PanelState } from "./M02_MasterTemplete_Hopyo_ImportedPanel"; +import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail"; +import { + marksOf, + skPick, + type BasisCand, + type ImportedDoc, + type Money4, + type Tier, +} from "./logic/M02_Hopyo_Imported_Model"; +import type { DraftPick } from "./logic/M02_MasterTemplete_Hopyo_Picks"; + +const TEXT = { + Title: ["가져온 식 단가산출 — 단산{n}", "Imported build-up — block {n}"], + Reused: ["같은 내용 {k} 를 다시 씀", "Reusing same content {k}"], + Made: ["{k} 만듦(엑셀 단가 그대로)", "{k} made (Excel prices)"], + Save: ["저장", "Save"], + Saved: ["{k} 저장함", "{k} saved"], + Link: ["이 줄에 잇기", "Use for this line"], + Linked: ["단산 줄 = {k} — [확인] 뒤 [사전조합 생성]", "Line uses {k} — confirm, then build"], + Discard: ["저장 안 한 고침이 있음 — 버리고 닫을까?", "Discard unsaved edits?"], + Close: ["닫기", "Close"], +} as const satisfies Record; +const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; +const why = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +export interface ImportedOptions { + /** 실무자료 파일 층 */ + file: HopyoScope; + key: string; + /** 단산 블록 번 */ + no: number; + /** SK 를 만들 층 */ + target: HopyoScope; + canWrite: boolean; + /** 기초 옵션 묶음(불러오기 검산) → 엔진 입력값 · 입력 필요 */ + bind: (code: string, given: Record) => Promise<{ 입력값: Record; 필요: string[] }>; // prettier-ignore + /** 단산 줄에서 열었을 때만 — [이 줄에 잇기] */ + onLink?: (pick: DraftPick) => void; +} + +interface Made { + 이름: string; + 다시씀: boolean; +} + +const fromHopyo = (o: ImportedOptions): Promise => + call(at(o.target, "/sangeun/from-hopyo"), { + method: "POST", + body: JSON.stringify({ 층: o.file.layer, ...(o.file.owner ? { 주인: o.file.owner } : {}), 키: o.key, 번: o.no }), // prettier-ignore + }); + +interface EditAnswer { + 문서: ImportedDoc; + 전?: Money4 | null; + 후?: Money4 | null; + 이음없음?: { 자리: string; 이름: string }[]; +} + +export function openImported(o: ImportedOptions): void { + const readOnly = !o.canWrite; + const root = el("div", { + className: "m02-hopyo", + attrs: { "data-imported": `${o.key}|${o.no}` }, + }); + const top = el("div"); + const host = el("div", { attrs: { "data-sk-body": "" } }); + let sk = ""; + let 판 = ""; + let tier: Tier | null = null; + let dirty = false; + let close = (): void => {}; + let handle: ReturnType | null = null; + const st: PanelState = { doc: {}, unlinked: [], price: null, need: [], busy: false, readOnly }; + + const edit = (how: string, body: Record): Promise => + call(at(o.target, `/sangeun/imported/${how}`), { method: "POST", body: JSON.stringify(body) }); + + function mount(doc: ImportedDoc): void { + handle?.destroy(); + st.doc = doc; + handle = mountSangeunDetail(host, doc, { + layer: o.target.layer, + owner: o.target.owner, + readOnly, + onChanged: (d) => (dirty ||= d), + }); + draw(); + } + const now = (): ImportedDoc => (handle?.getDoc() as ImportedDoc | undefined) ?? st.doc; + + function draw(): void { + top.replaceChildren(importedPanel(st, { price, link, toBase, pickBase, fill })); + saveBtn.disabled = readOnly || st.busy; + linkBtn.disabled = readOnly || st.busy; + } + + /** 서버 고침 한 번(눌림 막음) → 시트 다시 엶 */ + async function run(how: string, body: Record): Promise { + st.busy = true; + draw(); + try { + const got = await edit(how, { 문서: now(), ...body }); + dirty = true; + mount(got.문서); + return got; + } catch (e) { + showToast(why(e), "error"); + return null; + } finally { + st.busy = false; + draw(); + } + } + + /** 이음 없는 자원 — 단가 기준 그대로 price 답(전 · 후 = 지금) */ + async function unlinked(): Promise { + if (!st.doc.가져온?.로직) return void (st.unlinked = []); + const got = await edit("price", { 문서: st.doc, 기준: st.doc.가져온.단가기준 ?? "엑셀" }).catch(() => null); // prettier-ignore + st.unlinked = got?.이음없음 ?? []; + draw(); + } + + async function price(basis: "엑셀" | "우리"): Promise { + const got = await run("price", { 기준: basis }); + if (got) st.price = { 전: got.전 ?? null, 후: got.후 ?? null }; + draw(); + } + + function link(spot: string, name: string): void { + const done = (code: string): void => void run("link", { 자리: spot, 이음: code }).then(() => unlinked()); // prettier-ignore + openFindPicker({ + layer: o.target.layer, + owner: o.target.owner, + query: name, + logic: (x) => done(`로직(${x.키})`), + element: (e) => done(e.코드), + machine: (e) => done(e.코드), + oil: (e) => done(e.코드), + }); + } + + async function toBase(code: string, cand: BasisCand | null): Promise { + st.busy = true; + draw(); + let bound: Awaited>; + try { + bound = await o.bind(code, cand?.옵션 ?? {}); + } catch (e) { + st.busy = false; + draw(); + return void showToast(why(e), "error"); + } + st.busy = false; + const got = await run("to-base", { 로직: code, 입력값: bound.입력값 }); + if (!got) return; + tier = cand?.단 ?? null; + st.need = bound.필요; + st.unlinked = []; + draw(); + } + + function pickBase(): void { + openFindPicker({ layer: o.target.layer, owner: o.target.owner, kinds: ["logic"], logic: (x) => void toBase(x.키, null) }); // prettier-ignore + } + + function fill(name: string, value: string): void { + const doc = now(); + const kept = { ...(doc.입력값 ?? {}) }; + if (value.trim()) kept[name] = value.trim(); + else delete kept[name]; + dirty = true; + mount({ ...doc, 입력값: kept }); + } + + async function save(): Promise { + try { + const doc = now(); + const info = await saveTemplate(o.target.layer, "sangeun", sk, null, 판, doc, o.target.owner); + 판 = info.판; + st.doc = doc; + dirty = false; + showToast(t("Saved").replace("{k}", sk), "success"); + return true; + } catch (e) { + showToast(why(e), "error"); + return false; + } + } + + const saveBtn = createButton({ label: t("Save"), variant: "filled", onClick: () => void save() }); + saveBtn.dataset.importedSave = ""; + const linkBtn = createButton({ + label: t("Link"), + variant: "ghost", + onClick: async () => { + if (dirty && !(await save())) return; + o.onLink!(skPick(sk, st.doc, o.target, st.need, marksOf(st.doc, tier))); + showToast(t("Linked").replace("{k}", sk), "info"); + dirty = false; + close(); + }, + }); + linkBtn.dataset.importedLinkRow = ""; + linkBtn.hidden = !o.onLink; + saveBtn.disabled = linkBtn.disabled = true; + + async function load(): Promise { + const stop = showLoading(root); + try { + const made = await fromHopyo(o); + sk = made.이름; + showToast((made.다시씀 ? t("Reused") : t("Made")).replace("{k}", sk), "info"); + const got = await readTemplate(o.target.layer, "sangeun", sk, null, o.target.owner); + 판 = got.판 ?? ""; + root.replaceChildren(top, host); + mount(got.문서 as ImportedDoc); + void unlinked(); + } catch (e) { + root.replaceChildren(loadingError(why(e), () => void load())); + } finally { + stop(); + } + } + + ({ close } = openModal({ + title: t("Title").replace("{n}", String(o.no)), + closeLabel: t("Close"), + width: "1100px", + wide: true, + dialogClass: "m02-modal m02-hopyo__imported-modal", + actions: [saveBtn, linkBtn], + beforeClose: async () => { + if (dirty && !readOnly && !(await askConfirm({ message: t("Discard"), danger: true }))) + return false; + handle?.destroy(); + return true; + }, + mount: (body) => body.append(root), + })); + void load(); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts new file mode 100644 index 000000000..55aabec9e --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts @@ -0,0 +1,247 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_ImportedPanel.ts + * 가져온 식 단가산출 모달 머리 판(PLAN 793-3b · 요구 646 · 647 · 652) — `Hopyo_Imported` 가 엶 · 시트(`Sangeun_Detail`) 위. + * · 줄 1 = 「새」 · 원본(파일 · 시트 · 행) · 검산 표지(같음 / 다름 n · 줄마다 까닭) · 단가 기준 [우리 단가로] / [엑셀 단가로] · + * 바꾸기 전후 금액 · [원본 글 보기] + * · 줄 2 = 이음 없는 자원 = 표지 + [이음 고르기] + * · 줄 3 = 근거 제안 세 단(확정 · 근접(차이 · 까닭) · 사람 고름 · 793-3a) — 후보마다 [우리 기초로 바꾸기] · [기초 고르기] + * · 우리 기초로 바꾼 뒤 = 전후 차이 띠 · 「입력 필요」 붉은 칸(채우기 전엔 그 줄 확인 · 옮김 막힘) + * 부품 = 단추 · 배지 · 글 · 입력 · 띠 · 표 · 모달(날 단추 · 입력 없음) · 서버 길은 부르는 쪽. + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createBanner } from "@ui/ui_template_banner"; +import { createButton } from "@ui/ui_template_button"; +import { el } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; +import { createTable } from "@ui/ui_template_table"; +import { createText } from "@ui/ui_template_text"; +import { createToolbar } from "@ui/ui_template_toolbar"; +import { formatMoney } from "../common_util/common_util_money"; +import { newBadge } from "./M02_MasterTemplete_Hopyo_CheckRows"; +import { + gapText, + isImported, + stillNeed, + tiers, + TIERS, + wrongLines, + type BasisCand, + type ImportedDoc, + type Money4, + type Tier, +} from "./logic/M02_Hopyo_Imported_Model"; + +const TEXT = { + Imported: ["가져온 식", "Imported formula"], + Based: ["우리 기초로 바꿈", "Converted to our basis"], + Same: ["검산 같음", "Check matches"], + Diff: ["검산 다름 {n}", "Check differs {n}"], + NoCheck: ["검산 없음", "No check"], + Basis: ["단가 기준", "Price basis"], + ToOurs: ["우리 단가로", "Use our prices"], + ToExcel: ["엑셀 단가로", "Use Excel prices"], + Source: ["원본 글 보기", "Show source text"], + SourceTitle: ["원본 글", "Source text"], + Row: ["행", "Row"], + Text: ["글", "Text"], + Excel: ["엑셀", "Excel"], + Now: ["지금", "Now"], + Unlinked: ["이음 없는 자원 {n}", "Unlinked resources {n}"], + Link: ["이음 고르기", "Pick link"], + Basis3: ["근거 제안", "Basis suggestions"], + 확정: ["확정", "Confirmed"], + 근접: ["근접", "Near"], + 사람: ["사람 고름", "Human pick"], + None: ["없음", "None"], + NoCand: ["근거 제안 없음 — [기초 고르기]로 고름", "No suggestion — pick a basis"], + ToBase: ["우리 기초로 바꾸기", "Convert to our basis"], + PickBase: ["기초 고르기", "Pick basis"], + Gap: ["바꾸기 전 {a} → 후 {b} ({d})", "Before {a} → after {b} ({d})"], + Need: ["입력 필요", "Input needed"], + NeedHint: ["채워야 이 줄을 확인 · 옮김", "Fill to confirm and move this line"], + Close: ["닫기", "Close"], +} as const satisfies Record; +export const tx = (k: keyof typeof TEXT): string => + TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; + +export interface PanelState { + doc: ImportedDoc; + /** 이음 없는 자원(서버 price 답) */ + unlinked: { 자리: string; 이름: string }[]; + /** 마지막 단가 바꿈 전후 */ + price: { 전: Money4 | null; 후: Money4 | null } | null; + /** 우리 기초로 바꾼 뒤 입력 필요 칸 */ + need: string[]; + busy: boolean; + readOnly: boolean; +} + +export interface PanelActs { + price: (basis: "엑셀" | "우리") => void; + link: (spot: string, name: string) => void; + toBase: (code: string, cand: BasisCand | null) => void; + pickBase: () => void; + fill: (name: string, value: string) => void; +} + +const won = (m: Money4 | null | undefined): string => (m ? formatMoney(m.계) : "—"); +const muted = (s: string): HTMLElement => createText(s, "muted", 14); + +/** [원본 글 보기] — 엑셀 글줄 · 엑셀 값 · 지금 셈 */ +function showSource(doc: ImportedDoc): void { + const src = doc.가져온?.원본; + const lines = new Map((doc.가져온?.검산?.줄 ?? []).map((x) => [x.행, x])); + const rows = (src?.글줄 ?? []).map((g) => { + const hit = g.행 != null ? lines.get(g.행) : undefined; + return [ + String(g.행 ?? ""), + g.글 ?? "", + g.값 == null ? "" : formatMoney(g.값), + hit?.셈 == null ? "" : formatMoney(hit.셈), + hit ? createBadge(hit.같음 ? "=" : (hit.까닭 ?? "≠"), hit.같음 ? "success" : "danger") : "", + ]; + }); + openModal({ + title: `${tx("SourceTitle")} — ${[src?.파일, src?.시트].filter(Boolean).join(" · ")}`, + closeLabel: tx("Close"), + wide: true, + dialogClass: "m02-modal m02-hopyo__src", + mount: (body) => { + const heads = [tx("Row"), tx("Text"), tx("Excel"), tx("Now"), ""]; + const t = createTable(heads.map((h, i) => ({ label: h, ...(i > 1 && i < 4 ? { align: "end" as const } : {}) })), rows, { density: "data" }); // prettier-ignore + t.dataset.importedSource = ""; + body.append(t); + }, + }); +} + +function checkBadge(doc: ImportedDoc): HTMLElement { + const chk = doc.가져온?.검산; + const bad = wrongLines(doc); + const b = !chk + ? createBadge(tx("NoCheck"), "neutral") + : chk.같음 + ? createBadge(tx("Same"), "success") + : createBadge(tx("Diff").replace("{n}", String(bad.length || 1)), "danger"); + b.dataset.importedCheck = chk ? String(chk.같음) : ""; + b.title = [chk?.까닭, ...bad.map((x) => `${tx("Row")} ${x.행 ?? "?"}: ${x.까닭 ?? ""}`)] + .filter(Boolean) + .join("\n"); + return b; +} + +function cand(c: BasisCand, s: PanelState, a: PanelActs): HTMLElement { + const opts = Object.entries(c.옵션 ?? {}) + .map(([k, v]) => `${k} ${String(v)}`) + .join(" · "); + const go = createButton({ label: tx("ToBase"), size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: () => a.toBase(c.기초, c) }); // prettier-ignore + go.dataset.importedToBase = c.기초; + return el("div", { + className: "ui-toolbar m02-judge__wrap", + attrs: { "data-basis-cand": c.기초, "data-basis-tier": c.단 }, + children: [ + createBadge(`${c.기초}${c.이름 ? ` ${c.이름}` : ""}`, "accent"), + ...(opts ? [muted(opts)] : []), + ...(c.차이 !== undefined ? [muted(String(c.차이))] : []), + ...(c.까닭 ? [muted(c.까닭)] : []), + go, + ], + }); +} + +/** 근거 제안 세 단 */ +function basisPart(s: PanelState, a: PanelActs): HTMLElement { + const by = tiers(s.doc.가져온?.근거후보); + const any = TIERS.some((k) => by[k].length); + const pick = createButton({ label: tx("PickBase"), size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: a.pickBase }); // prettier-ignore + pick.dataset.importedPickBase = ""; + const tierRow = (k: Tier): HTMLElement => + el("div", { + attrs: { "data-basis-tiers": k }, + children: [ + createBadge(`${tx(k)} ${by[k].length}`, k === "확정" ? "success" : k === "근접" ? "warning" : "neutral"), // prettier-ignore + ...(by[k].length ? by[k].map((c) => cand(c, s, a)) : [muted(tx("None"))]), + ], + }); + return el("div", { + attrs: { "data-imported-basis": "" }, + children: [ + createToolbar({ items: [newBadge(), createText(tx("Basis3"), "muted", 14), pick] }), + ...(any ? TIERS.map(tierRow) : [muted(tx("NoCand"))]), + ], + }); +} + +/** 머리 판 */ +export function importedPanel(s: PanelState, a: PanelActs): HTMLElement { + const meta = s.doc.가져온 ?? {}; + const src = meta.원본; + const here = isImported(s.doc); + const head: (HTMLElement | "divider")[] = [ + newBadge(), + createBadge(here ? tx("Imported") : tx("Based"), here ? "accent" : "info"), + muted([src?.파일, src?.시트, src?.행?.join("~")].filter(Boolean).join(" · ")), + checkBadge(s.doc), + ]; + if (here) { + const ours = meta.단가기준 === "우리"; + const flip = createButton({ + label: ours ? tx("ToExcel") : tx("ToOurs"), + size: 28, + variant: "ghost", + disabled: s.readOnly || s.busy, + onClick: () => a.price(ours ? "엑셀" : "우리"), + }); + flip.dataset.importedPrice = ours ? "엑셀" : "우리"; + head.push("divider", muted(`${tx("Basis")} ${meta.단가기준 ?? "엑셀"}`), flip); + if (s.price) { + const g = tx("Gap").replace("{a}", won(s.price.전)).replace("{b}", won(s.price.후)); + const t = muted(g.replace("{d}", gapText(s.price.전, s.price.후) || "0")); + t.dataset.importedGap = ""; + head.push(t); + } + } + const srcBtn = createButton({ label: tx("Source"), size: 28, variant: "ghost", disabled: !src?.글줄?.length, onClick: () => showSource(s.doc) }); // prettier-ignore + srcBtn.dataset.importedSourceBtn = ""; + head.push("divider", srcBtn); + const kids: HTMLElement[] = [createToolbar({ items: head })]; + if (here && s.unlinked.length) { + const items = s.unlinked.map((u) => { + const b = createButton({ label: `${u.이름} · ${tx("Link")}`, size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: () => a.link(u.자리, u.이름) }); // prettier-ignore + b.dataset.importedLink = u.자리; + return b; + }); + const tag = createBadge(tx("Unlinked").replace("{n}", String(s.unlinked.length)), "warning"); + kids.push(createToolbar({ items: [tag, ...items] })); + } + const gone = meta.바꿈; + if (!here && gone) { + const g = tx("Gap").replace("{a}", won(gone.전)).replace("{b}", won(gone.후)); + kids.push(createBanner({ tone: "info", text: g.replace("{d}", gapText(gone.전, gone.후) || "0") })); // prettier-ignore + } + const left = stillNeed(s.need, s.doc.입력값); + if (s.need.length) { + const fields = s.need.map((k) => { + const f = createInputField({ label: k, size: 28, value: String(s.doc.입력값?.[k] ?? "") }); + f.input.dataset.importedNeed = k; + f.input.disabled = s.readOnly || s.busy; + if (left.includes(k)) f.setError(tx("Need")); + f.input.addEventListener("change", () => a.fill(k, f.input.value)); + return f.root; + }); + kids.push( + el("div", { + attrs: { "data-imported-needs": String(left.length) }, + children: [ + createToolbar({ items: [createBadge(`${tx("Need")} ${left.length}`, left.length ? "danger" : "success"), muted(tx("NeedHint"))] }), // prettier-ignore + el("div", { className: "ui-toolbar m02-judge__wrap", children: fields }), + ], + }), + ); + } + kids.push(basisPart(s, a)); + return el("div", { className: "m02-std-sheet__body", attrs: { "data-imported-panel": "" }, children: kids }); // prettier-ignore +} diff --git a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py new file mode 100644 index 000000000..20cf6c23e --- /dev/null +++ b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py @@ -0,0 +1,107 @@ +"""M02 불러오기 단산 블록 → 가져온 식 단가산출(PLAN 793-3b · 요구 646 · 647 · 652) — 서버 단독. + +올림 때 경쟁사 산근 시트(`M02_San_Book.sheets` · STmate 숨은 G 열 포함)를 +`실무자료/산근/.json` 에 둠 +(원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 793-2a 내보내기 +(`M02_San_Emit` · 이음 = 1차 줄 판정) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 · +엑셀 단가 그대로) · 같은 내용 SK 가 있으면 그것(다시씀). +블록 번 = 판정 블록 번(STmate = 코드 「D00102」 숫자 · EST = 단가산출 블록 번). +STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 — 참조 평가기로 둘 다 셈해 +엑셀 계와 맞는 것. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +DIRNAME = "산근" +RELOAD = "가져온 식 자료 없음 — 엑셀을 다시 올림" +RULES = ("비목버림", "합버림_큰나머지") + + +def _path(folder: str | Path, key: str) -> Path: + return hopyo_store._dir(folder) / DIRNAME / f"{key}.json" + + +def keep(folder: str | Path, key: str, name: str, data: bytes) -> None: + """올린 엑셀 → 산근 시트 둠(STmate · EST 꼴만 · 못 읽으면 안 둠 — 올리기는 됨).""" + from M02_MasterTemplete import M02_San_Book as book + + try: + sheets = book.sheets(name, data) + if not book.kind_of(sheets): + return + except Exception: # noqa: BLE001 — 산근은 덤(가져온 식 열기만 막힘) + return + path = _path(folder, key) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(path, {"파일": name, "시트": sheets}) + + +def has(folder: str | Path, key: str) -> bool: + return _path(folder, key).is_file() + + +def _rule(blk: dict, nodes: list, prices: dict) -> str: + """STmate 블록 끝전 — 엑셀 계(꼬리 · 없으면 머리)와 맞는 쪽 · 둘 다 아니면 비목버림.""" + from M02_MasterTemplete import M02_San_Eval as ev + + tails = [t for t in blk["꼬리"] if t["A"].startswith("계") and not t["A"].startswith("계약")] + want = (tails or [blk["머리"]])[0].get("C") + for rule in RULES: + try: + got = ev.stmate_block(blk, nodes, prices, rule)["계"]["T"] + except Exception: # noqa: BLE001 — 못 셈 = 다음 갈래 + continue + if want is not None and abs(float(got) - float(want)) < 0.5: + return rule + return RULES[0] + + +def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]: + """HP 단산 블록 하나 → `{로직, 원본}`(793-2a 꼴 · 이음 = M01 키).""" + from M02_MasterTemplete import M02_San_Book as book + from M02_MasterTemplete import M02_San_Emit as em + from M02_MasterTemplete import M02_San_Est as est + from M02_MasterTemplete import M02_San_Stmate as stm + + hopyo_store.read(folder, key) # 없는 HP = 404 + try: + saved = json.loads(_path(folder, key).read_bytes()) + except (OSError, ValueError) as e: + raise store.StoreError(409, RELOAD) from e + sheets, name = saved["시트"], str(saved.get("파일") or "") + if book.kind_of(sheets) == "STmate": + blk = next((b for b in book.stmate_blocks(sheets) if int(b["코드"][1:]) == int(no)), None) + if blk is None: + raise store.StoreError(404, f"없는 단산{no}") + prices = book.stmate_prices(sheets) + nodes = stm.parse_block(blk["몸"]) + return em.stmate(blk, nodes, prices, _rule(blk, nodes, prices), True, name) + prices, total = book.est_prices(sheets) + blk = next((b for b in book.est_blocks(sheets) if int(b["번"]) == int(no)), None) + if blk is None or blk["번"] not in total: + raise store.StoreError(404, f"없는 단산{no}") + return em.est(blk, est.parse_block(blk["글"], prices), total[blk["번"]], True, name) + + +def make(source: Path, key: str, no: int, name: str = "") -> dict[str, Any]: + """지금 층(만들 층)에 가져온 식 SK — 같은 내용이 있으면 그 SK(`다시씀`).""" + from M02_MasterTemplete import M02_Sangeun_Imported as imp + + out = emit(source, key, no) + title = str(name or "").strip() or str(out["로직"].get("이름") or f"단산{no}") + try: + return {**imp.create(out["로직"], out["원본"], title), "다시씀": False} + except store.StoreError as e: + same = (e.detail or {}).get("같은것") if isinstance(e.detail, dict) else None + if e.status != 409 or not same: + raise + got = imp.create(out["로직"], out["원본"], title, use=same[0]["키"]) + return {**got, "다시씀": True} diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index c1af7f3d3..365f50539 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -2,7 +2,9 @@ [단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한) · 식 칸 검사(792-1) `POST /api/m02/layers/{층}/sangeun/formula-check`(셈 없음 · 층 읽기 권한) · 가져온 식(793-2 · `M02_Sangeun_Imported`) `…/sangeun/imported`(만들기 · 층 쓰기) · `/price` · -`/link` · `/to-base`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기). +`/link` · `/to-base`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기) · +불러오기 단산 블록 → 가져온 식(793-3b · `M02_Sangeun_FromHopyo`) `…/sangeun/from-hopyo` +(만들 층 쓰기). 저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만. 몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값) · @@ -23,6 +25,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Sangeun_Calc as calc from M02_MasterTemplete import M02_Sangeun_Doc as sd from M02_MasterTemplete import M02_Sangeun_Formula as fx +from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo from M02_MasterTemplete import M02_Sangeun_Imported as imp from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root @@ -120,6 +123,28 @@ def sangeun_imported( return _call(folder, _sd(imp.create), *args) +class FromHopyoBody(BaseModel): + 층: Layer + 주인: str | None = None + 키: str + 번: int + 이름: str = "" + + +@router.post("/layers/{layer}/sangeun/from-hopyo") +def sangeun_from_hopyo( + layer: Layer, + body: FromHopyoBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """불러오기 단산 블록 → 가져온 식 SK(793-3b) — 경로 층 = 만들 층 · 몸 층 = 파일 층 · + 답 = {이름, 검산, 단가기준, 다시씀}(같은 내용 = 그 SK).""" + folder = _root(session, layer, owner, write=True) + source = _root(session, body.층, body.주인) + return _call(folder, _sd(from_hopyo.make), source, body.키, body.번, body.이름) + + class ImportedEditBody(BaseModel): 문서: dict[str, Any] 기준: str | None = None # price diff --git a/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts b/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts new file mode 100644 index 000000000..ceff4b774 --- /dev/null +++ b/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts @@ -0,0 +1,134 @@ +/* ============================================================================= + * logic/M02_Hopyo_Imported_Model.ts + * 불러오기 단산 줄 = 가져온 식 단가산출(PLAN 793-3b · 요구 646 · 647) — 꼴 · 읽기 · 바꾸기만. + * · SK `가져온` 덧칸 꼴(793-2 `M02_Sangeun_Imported.py` 머리) · 근거 제안 세 단(확정 · 근접 · 사람 고름 · 793-3a 가 채움) + * · 단산 줄 고른 것 = {갈래 단가산출, 키 SK-…, 층, 주인, 필요(입력 필요), 표지(결과 칩)} — 필요가 비어야 확인 · 옮김 + * · 줄 결과 칩 = 가져온 식 있음 · 근거 확정 · 근접 · 사람 고름 · 입력 필요 n(「2차 필요」 대신) + * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). + * ========================================================================== */ + +import type { DraftPick } from "./M02_MasterTemplete_Hopyo_Picks"; + +export type Money4 = Record<"노무비" | "재료비" | "경비" | "계", number>; +export type Tier = "확정" | "근접" | "사람"; +export const TIERS: Tier[] = ["확정", "근접", "사람"]; + +/** 근거 후보 하나(793-3a) */ +export interface BasisCand { + 기초: string; + 이름?: string; + 옵션?: Record; + 품검산?: unknown; + 단: Tier; + 까닭?: string; + 차이?: string | number; +} + +export interface CheckLine { + 자리: string; + 행?: number | null; + 셈?: number | null; + 엑셀?: number | null; + 같음: boolean; + 까닭?: string; +} + +/** SK 문서 덧칸 `가져온` */ +export interface ImportedMeta { + 로직?: { 이름?: string; 호표?: { 이름?: string; 이음?: string | null }[] }; + 단가기준?: "엑셀" | "우리"; + 원본?: { + 파일?: string; + 시트?: string; + 행?: number[]; + 갈래?: string; + 엑셀금액?: Money4; + 글줄?: { 행?: number | null; 글?: string; 자리?: string; 값?: number | null }[]; + }; + 검산?: { + 같음: boolean; + 까닭?: string | null; + 셈?: Money4 | null; + 엑셀?: Money4; + 줄: CheckLine[]; + }; + 근거후보?: BasisCand[]; + 바꿈?: { 전?: Money4 | null; 후?: Money4 | null; 차이?: Money4 | null }; +} + +export interface ImportedDoc { + 이름?: string; + 단위?: string; + 바탕?: { 로직?: string } | null; + 입력값?: Record; + 가져온?: ImportedMeta; + [k: string]: unknown; +} + +/** 가져온 식(아직 우리 기초로 안 바꿈) */ +export const isImported = (d: ImportedDoc | null | undefined): boolean => !!d?.가져온?.로직; + +/** 근거 후보 세 단 */ +export function tiers(list: BasisCand[] | undefined): Record { + const out: Record = { 확정: [], 근접: [], 사람: [] }; + for (const c of list ?? []) if (TIERS.includes(c.단)) out[c.단].push(c); + return out; +} + +/** 검산 다른 줄 */ +export const wrongLines = (d: ImportedDoc): CheckLine[] => + (d.가져온?.검산?.줄 ?? []).filter((x) => !x.같음); + +const blank = (v: unknown): boolean => v === undefined || v === null || String(v).trim() === ""; + +/** 입력 필요 중 아직 빈 칸 */ +export const stillNeed = (need: string[], values: Record | undefined): string[] => + need.filter((k) => blank(values?.[k])); + +/** 단산 줄 고른 것 — 그 SK(필요 = 빈 입력 필요 칸 · `missing` 이 막음) */ +export function skPick( + sk: string, + doc: ImportedDoc, + scope: { layer: string; owner: string | null }, + need: string[], + marks: string[], +): DraftPick & { 표지: string[] } { + const values = doc.입력값 ?? {}; + return { + 갈래: "단가산출", + 키: sk, + 이름: String(doc.이름 ?? sk), + 단위: String(doc.단위 ?? ""), + 입력값: Object.fromEntries(need.map((k) => [k, values[k] ?? ""])), + 출처: {}, + 몫: null, + 층: scope.layer, + 주인: scope.owner, + 필요: need, + 표지: marks, + }; +} + +/** 결과 표지 — 가져온 식 있음 | 우리 기초(+ 근거 단) */ +export function marksOf(doc: ImportedDoc, tier: Tier | null): string[] { + if (isImported(doc)) return ["가져온 식 있음"]; + return tier ? [`근거 ${tier}`] : ["사람 고름"]; +} + +/** 단산 줄 결과 칩 글 — SK 고른 것이 있으면 그 표지 + 입력 필요 n · 없으면 null(옛 판정 칩) */ +export function rowChips(picks: (DraftPick & { 표지?: string[] })[]): string[] | null { + const sk = picks.find((p) => p.갈래 === "단가산출" && /^SK-/.test(String(p.키 ?? ""))); + if (!sk) return null; + const need = stillNeed(sk.필요 ?? [], sk.입력값); + return [...(sk.표지 ?? ["가져온 식 있음"]), ...(need.length ? [`입력 필요 ${need.length}`] : [])]; +} + +/** 금액 차이 글(후 − 전) */ +export function gapText( + before: Money4 | null | undefined, + after: Money4 | null | undefined, +): string { + if (!before || !after) return ""; + const d = after.계 - before.계; + return `${d > 0 ? "+" : ""}${d.toLocaleString("ko-KR")}`; +} diff --git a/resources/tester/test_793_3b_import_screen.py b/resources/tester/test_793_3b_import_screen.py new file mode 100644 index 000000000..865588a00 --- /dev/null +++ b/resources/tester/test_793_3b_import_screen.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +"""793-3b 지킴 — 불러오기 단산 줄 = 가져온 식 단가산출 모달(요구 646 · 647 · 652 갈래 C). + +- 서버 길 `…/sangeun/from-hopyo` — 올림 때 산근 시트 둠(`실무자료/산근/.json`) → + 블록 → 793-2a 내보내기 → 793-2 가져온 식 SK(만들 층 · 엑셀 단가 그대로 · 검산 = 엑셀) · + 같은 내용 = 그 SK 다시 씀 · 산근 없는 옛 HP = 409 · HP 지우면 산근도 지움 · + STmate 블록 끝전 = 엑셀 계와 맞는 쪽 +- 화면 소스 — [수정] 모달 단산 줄 · 오른쪽 블록 판 [가져온 식으로 열기] · 시트 모달 + (`Sangeun_Detail`) · 머리 판(검산 · [우리 단가로] / [엑셀 단가로] · [원본 글 보기] · + [이음 고르기] · 근거 세 단 · [우리 기초로 바꾸기] · 입력 필요 붉은 칸) · + [이 줄에 잇기] = 줄 고른 것 SK · 결과 칩 · 「새」 배지 · 부품 상자만 · 700 줄 +임시 storage(`client` 마당) · M01 원본 그대로. +""" + +import re +from pathlib import Path + +import helper_m02_hopyo_golden as golden +import pytest + +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh +from M02_MasterTemplete import M02_Sangeun_Router as sangeun_router +from M02_MasterTemplete import M02_Template_Layers as layers + +# ruff: noqa: F811 +from resources.tester.test_m02_hopyo_excel import _post, client, master_hash # noqa: F401 + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +API = "/api/m02/layers/personal" +KEY = "HP-000001" +MINE = ("Hopyo_Imported", "Hopyo_ImportedPanel", "Hopyo_Check", "Hopyo_CheckRows", "Hopyo_Block") + + +def _src(name: str) -> str: + return (M02 / f"M02_MasterTemplete_{name}.ts").read_text(encoding="utf-8") + + +def _skcount() -> int: + folder = layers.personal_dir(7, 42) / "sangeun" + return len(list(folder.glob("*.json"))) if folder.exists() else 0 + + +def _open(client, no=18): + body = {"층": "personal", "키": KEY, "번": no} + return client.post(f"{API}/sangeun/from-hopyo", json=body) + + +# ── 서버 길 ─────────────────────────────────────────────────────────── +def test_단산_블록_가져온_식_만들기_다시씀_지움(client): + client.app.include_router(sangeun_router.router) + before = master_hash() + assert _post(client).status_code == 200 + folder = layers.personal_dir(7, 42) + assert fh.has(folder, KEY) + made = _open(client) + assert made.status_code == 200, made.text + got = made.json() + assert got["이름"].startswith("SK-") and got["다시씀"] is False + assert got["단가기준"] == "엑셀" and got["검산"]["같음"] is True + assert round(got["검산"]["셈"]["계"]) == 30369 # 북부청임도 단산18 문양거푸집 = 엑셀 + n = _skcount() + again = _open(client).json() + assert again["이름"] == got["이름"] and again["다시씀"] is True and _skcount() == n + with store.at(folder): + doc = store.read("sangeun", got["이름"])["문서"] + assert doc["바탕"] is None and doc["가져온"]["원본"]["엑셀금액"]["계"] == 30369 + assert _open(client, 99999).status_code == 404 + fh._path(folder, KEY).unlink() # 산근 없는 옛 HP = 다시 올림 + old = _open(client) + assert old.status_code == 409 and old.json()["detail"] == fh.RELOAD + assert _post(client).status_code == 200 and fh.has( + folder, KEY + ) # 같은 엑셀 다시 올림 = 산근 채움 + assert client.delete(f"{API}/hopyo/{KEY}").status_code == 200 + assert not fh.has(folder, KEY) # HP 지우면 산근도 + assert master_hash() == before + + +RULES = [("봉화", "D00102", "비목버림"), ("동부청영월", "D00003", "합버림_큰나머지")] + + +@pytest.mark.parametrize(("tag", "code", "rule"), RULES) +def test_STmate_끝전_엑셀_계와_맞는_쪽(tag, code, rule): + from M02_MasterTemplete import M02_San_Book as book + from M02_MasterTemplete import M02_San_Stmate as stm + + if not golden.FILES[tag].exists(): + pytest.skip(f"실무 엑셀 없음 {tag}") + data = book.load(golden.FILES[tag]) + blk = next(b for b in data["블록"] if b["코드"] == code) + assert fh._rule(blk, stm.parse_block(blk["몸"]), data["단가"]) == rule + + +# ── 화면 소스 ───────────────────────────────────────────────────────── +def test_단산_줄_길_잇기(): + check, rows, block = _src("Hopyo_Check"), _src("Hopyo_CheckRows"), _src("Hopyo_Block") + assert "openImported({" in check and "onLink: (p) => kit.edit(row, [p])" in check + assert "imported }" in check.split("const ctx = ")[1].split(";")[0] + assert 'L("M02_CheckImported")' in rows and "row.번 !== undefined && ctx.imported" in rows + assert 'import("./M02_MasterTemplete_Hopyo_Imported")' in block # 돌림 import 피함 + assert 'L("M02_CheckImported")' in block and "dataset.checkNew" in block + imp = _src("Hopyo_Imported") + assert '"/sangeun/from-hopyo"' in imp and "/sangeun/imported/${how}" in imp + assert "mountSangeunDetail(" in imp and "saveTemplate(" in imp and "openModal({" in imp + assert "skPick(sk" in imp and "o.onLink" in imp # [이 줄에 잇기] = 줄 고른 것 SK + for how in ('run("price"', 'run("link"', 'run("to-base"'): + assert how in imp, how + locale = (ROOT / "ui_template/ui_template_locale_m2_judge.ts").read_text(encoding="utf-8") + assert 'M02_CheckImported: ["가져온 식으로 열기"' in locale + + +def test_머리_판_결과_칩(): + panel = _src("Hopyo_ImportedPanel") + for word in ( + "ToOurs", + "ToExcel", + "Source", + "Link", + "ToBase", + "PickBase", + "Need", + "Same", + "Diff", + ): + assert f'tx("{word}")' in panel, word + assert "newBadge()" in panel and "data-basis-tiers" in panel and "TIERS.map(" in panel + assert 'f.setError(tx("Need"))' in panel # 입력 필요 = 붉은 칸 + model = (M02 / "logic/M02_Hopyo_Imported_Model.ts").read_text(encoding="utf-8") + assert '["확정", "근접", "사람"]' in model and "필요: need" in model + assert "입력 필요 ${need.length}" in model and '"가져온 식 있음"' in model + rows = _src("Hopyo_CheckRows") + assert "rowChips(ctx.kit.picksOf(row))" in rows and "dataset.judgeResult" in rows + assert not re.search(r"from \"@ui|document\.|fetch\(|sessionStorage\.", model) # 순수 + + +def test_부품상자만_700줄_공용_안_고침(): + for name in MINE: + text = _src(name) + assert len(text.splitlines()) <= 700, name + assert not re.search(r"""\bel\(\s*["'](button|input|select)["']""", text), name + assert not re.search(r"""createElement\(\s*["'](button|input|select)["']""", text), name + assert "#" not in "".join(re.findall(r"color:\s*[^;]+", text)), name + for name in ("Labor_Rows", "Labor_SangeunModal", "Sangeun_Detail", "Sangeun_Sheet"): + assert "Hopyo_Imported" not in _src(name), name # 공용 쪽은 새 모달을 모름 diff --git a/ui_template/ui_template_locale_m2_judge.ts b/ui_template/ui_template_locale_m2_judge.ts index b229e6b05..c78205157 100644 --- a/ui_template/ui_template_locale_m2_judge.ts +++ b/ui_template/ui_template_locale_m2_judge.ts @@ -57,6 +57,7 @@ export const ui_locales_m2_judge = { M02_JudgeOutside: ["일위대가 밖", "Not called by items"], M02_CheckTitle: ["확인 — 제{n}호표", "Check — item {n}"], M02_CheckNew: ["새", "New"], + M02_CheckImported: ["가져온 식으로 열기", "Open as imported formula"], M02_CheckNewHint: ["「새」 = 785 에서 더한 기능", "「New」 = added in 785"], M02_CheckApart: ["별도계상 · 품목은 프로젝트에서", "Separate · item chosen in project"], M02_CheckExcelPrice: ["엑셀 단가", "Excel unit price"],