diff --git a/A00_Common/line_stack.css b/A00_Common/line_stack.css index ab78bcaa0..4c25f9bc0 100644 --- a/A00_Common/line_stack.css +++ b/A00_Common/line_stack.css @@ -452,3 +452,27 @@ width: auto; min-width: 8em; } + +/* 가져온 식 값 · 추정 칸(810-3) — 칸 + 꼬리표 */ +.lstk-src { + display: flex; + align-items: flex-end; + gap: var(--spacing-4); +} + +.lstk-src > .ui-field { + width: 7em; +} + +.lstk-src > [data-guess] { + width: auto; + min-width: 18em; +} + +.lstk-src > [data-guess] input { + min-width: 18em; +} + +.lstk-src > .ui-badge { + margin-bottom: var(--spacing-4); +} diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts index eab8ae79a..7219591bb 100644 --- a/A00_Common/line_stack_edit.ts +++ b/A00_Common/line_stack_edit.ts @@ -165,13 +165,14 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] { }), ); drawUnit(); + const bare = !!line.합밖 && !line.비목; // 합 밖 소계 · 합계 줄(810-2) = 금액만 · 비목 칸 빈칸 return [ - el("span", { className: "lstk-kind", text: lt(KIND_KEY[kind]) }), + el("span", { className: "lstk-kind", text: lt(bare ? "K_소계" : KIND_KEY[kind]) }), ...head, // 율 · 식 줄(자원 없음)에 수량이 없으면 칸 안 보임(한 줄 유지) ...(own || String(line.수량 ?? "") ? [qtyBox(h, i, shown, real)] : []), unit, - ...(line.비목 === "묶음" + ...(line.비목 === "묶음" || bare ? [] : [ createSelectField({ diff --git a/A00_Common/line_stack_out.ts b/A00_Common/line_stack_out.ts index 06d241799..46d0fceb0 100644 --- a/A00_Common/line_stack_out.ts +++ b/A00_Common/line_stack_out.ts @@ -290,9 +290,10 @@ function rows( ] : [exprNode(line.단가, "val", scope, true, undefined, { alias })]; const c = String(line.비목); + const bare = !!line.합밖 && !line.비목; // 합 밖 소계(810-2) = 글에만 금액 · 비목 칸 빈칸 push({ h: [`${name} : `, ...price, ...qtyNode(), ` = ${one(amt)}`], - cols: { sum: amt, [c]: amt }, + cols: bare ? {} : { sum: amt, [c]: amt }, }); return { sub: false, cols: { [c]: amt } }; }; diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts index f47beced3..a2ffe4b18 100644 --- a/A00_Common/line_stack_screen.ts +++ b/A00_Common/line_stack_screen.ts @@ -24,7 +24,7 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; -import { blankDoc, type FormulaDoc } from "../common_util/common_util_formula_doc"; +import { blankDoc, type DocVar, type FormulaDoc } from "../common_util/common_util_formula_doc"; import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc"; import { importView, type Basis } from "../common_util/common_util_formula_doc_import"; import { createSegment } from "@ui/ui_template_segment"; @@ -48,6 +48,7 @@ import { } from "./formula_doc_copy"; import { docFoot } from "./formula_doc_foot"; import { buildOptions } from "./formula_doc_options"; +import { buildSourced } from "./line_stack_sourced"; import { ft } from "./formula_doc_text"; import { addTaken, type Taken } from "../common_util/common_util_line_stack_copy"; import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; @@ -273,13 +274,17 @@ export function createStackScreen(opt: StackScreenOptions) { opt.tools?.replaceChildren(); return; } - const options = buildOptions(doc, given, !!cp && !opt.admin, (k, v, x) => { + const setOption = (k: string, v: DocVar, x: unknown): void => { const at = doc?.변수?.indexOf(v) ?? -1; if (cp) return change((d) => at >= 0 && (d.변수![at].연결 = { ...v.연결!, 값: x })); given[k] = x; recalc(); draw(); - }); + }; + const options = [ + ...buildOptions(doc, given, !!cp && !opt.admin, setOption), + ...buildSourced(doc, !!cp && !opt.admin, setOption), // 가져온 식 값 · 추정 칸(810-3) + ]; const busy = dirty(); const tools = [ ...(mark?.단가기준 && opt.page ? [basisPick(mark.단가기준)] : []), // 모달 = 머리 판 단추(793-3b) diff --git a/A00_Common/line_stack_sourced.ts b/A00_Common/line_stack_sourced.ts new file mode 100644 index 000000000..8bbbec8f9 --- /dev/null +++ b/A00_Common/line_stack_sourced.ts @@ -0,0 +1,80 @@ +/* ============================================================================= + * line_stack_sourced.ts + * 가져온 식 옵션 가로 칸(PLAN 810-3 · 요구 705 · 703) — 줄 쌓기 위 옵션 가로(`formula_doc_options.ts`) 뒤에 붙음 + * 값 칸 = 경쟁사 파일에 값으로 드러난 수(q · K · E · Cm · Q …) · 꼬리표 「원파일」 · 「엑셀」(`sourceOf`) · + * 고침 = 그 숫자 연결 값(부른 쪽 `set` · 캐시 → [저장]) + * 규격 칸 = 원파일 블록 규격(읽기만) · 추정 칸 = 값 없이 이름만 적힌 품셈 조건 글(문서 `가져온.추정` · 읽기만 · + * M01 기초에 묶지 않음 · 703) + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { el } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; +import { varId, type DocVar, type FormulaDoc } from "../common_util/common_util_formula_doc"; +import { sourceOf } from "../common_util/common_util_line_stack"; +import { lt } from "./line_stack_text"; + +interface Head { + 원파일?: boolean; + 규격?: string; + 추정?: { 행?: number; 글?: string }[]; +} + +const cell = (field: HTMLElement, tag: string, tone: "info" | "warning", hint: string) => + el("div", { + className: "lstk-src", + attrs: { title: hint }, + children: [field, createBadge(tag, tone)], + }); + +export function buildSourced( + doc: FormulaDoc | null, + lock: boolean, + set: (key: string, v: DocVar, value: unknown) => void, +): HTMLElement[] { + const head = (doc as { 가져온?: Head } | null)?.가져온; + if (!doc || !head) return []; + const out: HTMLElement[] = []; + for (const v of doc.변수 ?? []) { + const tag = sourceOf(doc, v); + if (!tag) continue; + const id = varId(v); + const f = createInputField({ + label: v.이름 || id, + value: String(v.연결?.값 ?? ""), + compact: true, + size: 28, + unit: v.단위 || undefined, + }); + f.input.disabled = lock; + f.input.addEventListener("change", () => { + const n = Number(f.input.value.trim().replace(/,/g, "")); + if (f.input.value.trim() === "" || !Number.isFinite(n)) + return void (f.input.value = String(v.연결?.값 ?? "")); // 수만 — 빈 값 · 글 = 그대로 + set(id, v, String(n)); + }); + f.root.dataset.option = id; + const tagText = tag === "원파일" ? lt("Src_Native") : tag === "엑셀" ? lt("Src_Excel") : tag; + out.push(cell(f.root, tagText, "info", lt("Src_Hint"))); + } + const ro = (label: string, value: string, mark: string, guess: boolean) => { + const f = createInputField({ label, value, compact: true, size: 28 }); + f.input.readOnly = true; + f.input.title = value; // 긴 조건 글 = 칸 위에 올리면 다 보임 + if (guess) f.root.dataset.guess = mark; + return guess + ? cell(f.root, lt("Guess"), "warning", lt("Guess_Hint")) + : cell(f.root, lt("Src_Native"), "info", lt("Src_Hint")); + }; + if (head.규격) out.push(ro(lt("Spec"), head.규격, "", false)); + for (const g of head.추정 ?? []) + out.push( + ro( + `${lt("Guess_Label")}${g.행 !== undefined ? ` · ${g.행}행` : ""}`, + String(g.글 ?? "").replace(/^※\s*/, ""), + String(g.행 ?? ""), + true, + ), + ); + return out; +} diff --git a/A00_Common/line_stack_text.ts b/A00_Common/line_stack_text.ts index 202430619..ff8df78b7 100644 --- a/A00_Common/line_stack_text.ts +++ b/A00_Common/line_stack_text.ts @@ -35,6 +35,20 @@ const TEXT = { Self_Take: ["지금 단가산출 자신은 못 가져옴", "Cannot copy itself"], Take_Failed: ["복사 못 함", "Copy failed"], K_율: ["율", "Rate"], + K_소계: ["소계", "Subtotal"], + Src_Native: ["원파일", "Source file"], + Src_Excel: ["엑셀", "Excel"], + Src_Hint: [ + "경쟁사 파일에 값으로 적힌 수 — 고치면 이 단가산출 값", + "Value written in the competitor file — edit to change", + ], + Guess: ["추정", "Guess"], + Guess_Label: ["품셈 조건", "Condition"], + Guess_Hint: [ + "값 없이 이름만 적힌 품셈 조건 — 우리 기초에 묶지 않음", + "Condition named without a value — not bound", + ], + Spec: ["규격", "Spec"], K_공식: ["공식", "Formula"], K_표값: ["표 값", "Table values"], K_글: ["근거 글", "Basis note"], diff --git a/M02_MasterTemplete/M02_Doc_FromNative.py b/M02_MasterTemplete/M02_Doc_FromNative.py index fbe0ce94d..7f03e83f6 100644 --- a/M02_MasterTemplete/M02_Doc_FromNative.py +++ b/M02_MasterTemplete/M02_Doc_FromNative.py @@ -92,7 +92,9 @@ def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]: blk = numbers(saved)[int(no)] if saved["꼴"] == "EST": names = {b["코드"]: i for i, b in enumerate(saved["산근"], 1)} - return ne.block(blk, int(no), keys, saved["파일"], names) + out = ne.block(blk, int(no), keys, saved["파일"], names) + out["원본"] |= {"원코드": blk["코드"]} | ({"규격": blk["규격"]} if blk.get("규격") else {}) + return out from M02_MasterTemplete import M02_San_Stmate as stm nodes = [{"행": ln["행"], "글": ln["글"], **stm.parse_line(ln["글"])} for ln in blk["줄"]] diff --git a/M02_MasterTemplete/M02_Doc_Import.py b/M02_MasterTemplete/M02_Doc_Import.py index a962a362b..f6aec7ef1 100644 --- a/M02_MasterTemplete/M02_Doc_Import.py +++ b/M02_MasterTemplete/M02_Doc_Import.py @@ -26,7 +26,18 @@ from M02_MasterTemplete import M02_Sangeun_Doc as sd from M02_MasterTemplete import M02_Sangeun_Imported as imp XL = "엑셀" # 연결 칸 — 엑셀 값 기억 -HEAD = ("파일", "시트", "행", "갈래", "코드", "원파일") +HEAD = ( + "파일", + "시트", + "행", + "갈래", + "코드", + "원파일", + "원코드", + "규격", + "확정", +) # 뒤 셋 = 원파일(810-3) +GUESS = "추정" # 원파일 품셈 조건 글 표지(810-2 `M02_Doc_NativeEst.GUESS`) SUB = "원파일 소계" # 원파일 소계 덧줄(810-2 `M02_Doc_NativeEst`) — 합 밖 · 비목 칸 비움 PICK = "소요량" # 고른 표 칸 기호 이름 머리 _SCALE = {"eq": (1,), "pct": (100, 0.01), "kgt": (1000, 0.001)} @@ -297,6 +308,12 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict: _native(doc) head = {k: (meta.get("원본") or {}).get(k) for k in HEAD if (meta.get("원본") or {}).get(k)} marks = list((meta.get("원본") or {}).get("표지") or []) + if (meta.get("원본") or {}).get("원파일"): # 810-3 — 품셈 조건 글(값 없음) = 화면 「추정」 칸 + head["추정"] = [ + {"행": x.get("행"), "글": " ".join(str(x.get("글") or "").split())} + for x in meta["원본"].get("글줄") or [] + if GUESS in str(x.get("표지") or "") + ] if not (doc.get("금액") or doc.get("결과") or row["호표"] or row.get("덧줄")): _empty(doc) # 빈 산근 = 엑셀 0 원(줄 없음 합) — 「—」 아님 marks.append(EMPTY) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index 0d20e503c..7c62c78a3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -57,7 +57,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { let layers: ManageLayer[] = []; let pick: ManageLayer = "personal"; const input = el("input", { - attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" }, + attrs: { type: "file", accept: ".xls,.xlsx,.estx,.stc", "data-hopyo-file": "" }, }) as HTMLInputElement; input.hidden = true; const start = createButton({ @@ -145,8 +145,14 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { if (!file) return; start.disabled = true; uploadHopyo(scopeOf(pick), file) - .then((got: HopyoFile & { 이어서?: boolean }) => { - if (got.이어서) showToast(`같은 엑셀 작업 중 — ${got.키} 이어서`, "info"); + .then((got: HopyoFile & { 이어서?: boolean; 원파일?: boolean; 붙음?: string[] | null }) => { + const tool = /\.estx$/i.test(file.name) ? "EST" : "STmate"; // 810-3 원파일 올림 안내 + if (got.원파일) + showToast( + `원파일 · ${tool} — 호표 ${got.호표수}${got.붙음?.length ? ` · 같은 공사 엑셀 ${got.붙음.join(", ")} 에 붙음` : ""}`, + "success", + ); + else if (got.이어서) showToast(`같은 엑셀 작업 중 — ${got.키} 이어서`, "info"); else showToast(`올림 — 호표 ${got.호표수}`, got.어긋남 ? "warning" : "success"); if (got.읽기불가) showToast(`가져온 식으로 열 수 없음 — ${got.읽기불가}`, "warning"); return show(pick, got, true); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts index f2b558871..2e1179b1f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts @@ -55,6 +55,7 @@ const TEXT = { NoClause: ["엑셀 글에 품셈 절 글 없음", "No standard clause in the Excel text"], Hint: ["우리 기초 원문은 위 찾기로", "Find our base above to see its source"], More: ["표지 모두 보기", "Show all flags"], + Sure: ["원파일 확정", "Source-file read"], } as const; const tx = (k: keyof typeof TEXT, n = 0): string => TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n)); @@ -79,6 +80,7 @@ function originCard(o: Origin): HTMLElement { title: tx("Origin"), pad: 12, body: [ + ...(o.요약 ? [el("strong", { text: o.요약, attrs: { "data-origin-native": "" } })] : []), createKv([ { label: tx("Program"), value: o.프로그램 }, { label: tx("File"), value: o.파일 }, @@ -86,6 +88,7 @@ function originCard(o: Origin): HTMLElement { { label: tx("Rows"), value: o.행 }, { label: tx("When"), value: o.때 }, { label: tx("Marks"), value: tx("MarksN", o.표지.length) }, + ...(o.확정 ? [{ label: tx("Sure"), value: o.확정 }] : []), ]), marks, el("strong", { text: tx("Clauses") }), diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Origin.ts b/M02_MasterTemplete/logic/M02_Sangeun_Origin.ts index 408d01593..7e0995116 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Origin.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Origin.ts @@ -17,6 +17,8 @@ export interface OriginMeta { 표지?: string[]; 가져온때?: string; 원파일?: boolean; // 경쟁사 원파일(.estx · .stc)에서 옮김(810-2) — 엑셀 아님 + 원코드?: string; // 원파일 블록 코드(EST `D…` · 810-3) — STmate 는 `코드` 가 그것 + 확정?: { 확정?: number; 표지?: number; 추정?: number }; // 원파일 줄 확정 수(810-2) 글줄?: { 행?: number; 글?: string }[]; }; } @@ -36,6 +38,10 @@ export interface Origin { 때: string; 표지: string[]; 품셈: Clause[]; + /** 원파일 한 줄 「원파일 · EST 산근 D… · 행 …」(810-3) · 엑셀 = 없음 */ + 요약?: string; + /** 원파일 확정 「확정 N · 표지 N · 추정 N · 확정률 N %」 */ + 확정?: string; } const BOOKS: [RegExp, string][] = [ @@ -81,6 +87,9 @@ export function originOf(meta: OriginMeta | null | undefined): Origin | null { const code = src.코드 ?? ""; const block = src.갈래 === "EST" && code !== "" ? `단산${code}` : String(code); const rows = (src.행 ?? []).filter((x) => Number.isFinite(x)); + const native = src.원파일 ? String(src.원코드 ?? (src.갈래 === "EST" ? "" : code)) : ""; + const sure = src.확정; + const rate = sure ? (sure.확정 ?? 0) / Math.max(1, (sure.확정 ?? 0) + (sure.표지 ?? 0)) : 0; return { 프로그램: `${src.갈래 ?? ""}${src.원파일 ? " · 원파일" : ""}`, 파일: src.파일 ?? "", @@ -89,5 +98,20 @@ export function originOf(meta: OriginMeta | null | undefined): Origin | null { 때: localTime(src.가져온때), 표지: [...(src.표지 ?? [])], 품셈: clausesOf(src.글줄), + ...(src.원파일 + ? { + 요약: [ + `원파일 · ${src.갈래 ?? ""} ${src.시트 ?? ""} ${native}`.trim(), + rows.length ? `행 ${rows.join("~")}` : "", + ] + .filter(Boolean) + .join(" · "), + } + : {}), + ...(sure + ? { + 확정: `확정 ${sure.확정 ?? 0} · 표지 ${sure.표지 ?? 0} · 추정 ${sure.추정 ?? 0} · 확정률 ${(Math.floor(rate * 1000) / 10).toFixed(1)} %`, + } + : {}), }; } diff --git a/common_util/common_util_formula_doc_calc.ts b/common_util/common_util_formula_doc_calc.ts index b796b73c4..5aafeccab 100644 --- a/common_util/common_util_formula_doc_calc.ts +++ b/common_util/common_util_formula_doc_calc.ts @@ -517,10 +517,10 @@ function settle(doc: FormulaDoc, rules: Record, total: Bu if (rules.끝전 === "큰나머지") { const whole = COSTS.reduce((s, k) => s + total[k], 0); const rest = Math.round((cut(whole, 비목) as number) - COSTS.reduce((s, k) => s + costs[k], 0)); + const gap = (k: string): number => Math.round((costs[k] - total[k]) * 1e9) / 1e9; // 소수 흔들림 = 같은 나머지(810-3) const order = [...COSTS].sort( (a, b) => - costs[a] - total[a] - (costs[b] - total[b]) || - [...COSTS].reverse().indexOf(a) - [...COSTS].reverse().indexOf(b), + gap(a) - gap(b) || [...COSTS].reverse().indexOf(a) - [...COSTS].reverse().indexOf(b), ); for (const k of order.slice(0, rest)) costs[k] += 1; // 파이썬 order[:rest] 와 같음 } diff --git a/common_util/common_util_line_stack.ts b/common_util/common_util_line_stack.ts index fd6e96163..b92e49ba8 100644 --- a/common_util/common_util_line_stack.ts +++ b/common_util/common_util_line_stack.ts @@ -140,10 +140,20 @@ export function kindOf( return "율"; } -/** 차림에 안 넣는 기호 — 옵션 · 금액 줄이 가진 연결 */ +/** 가져온 식 값 옵션 칸 꼬리표(810-3 · 요구 705) — 경쟁사 파일에 값으로 드러난 수(q · K · E · Cm …)는 옵션 가로 칸 · + * 수 하나 숫자 연결(엑셀 단가 기억 `엑셀` 칸 · 금액 줄 단가로 쓰는 수 빼고) · 원파일 = 「원파일」 · 엑셀 글 = 「엑셀」 · 그 밖 null */ +export function sourceOf(doc: FormulaDoc, v: DocVar): string | null { + const head = (doc as { 가져온?: { 원파일?: boolean } }).가져온; + const link = (v.연결 ?? {}) as Link & Record; + if (!head || link.갈래 !== "숫자" || "엑셀" in link || typeof link.값 === "object") return null; + if ((doc.금액 ?? []).some((l) => priceSym(l) === varId(v))) return null; // 손 단가 = 금액 줄 그대로 + return String((v as { 출처?: string }).출처 ?? (head.원파일 ? "원파일" : "엑셀")); +} + +/** 차림에 안 넣는 기호 — 옵션 · 금액 줄이 가진 연결 · 가져온 식 값 옵션 칸 */ export function hiddenOf(doc: FormulaDoc): Set { const out = new Set(ownedOf(doc).values()); - for (const v of doc.변수 ?? []) if (isOption(v)) out.add(varId(v)); + for (const v of doc.변수 ?? []) if (isOption(v) || sourceOf(doc, v)) out.add(varId(v)); return out; } diff --git a/resources/tester/helper_810_3_screen.cjs b/resources/tester/helper_810_3_screen.cjs new file mode 100644 index 000000000..e39decbd1 --- /dev/null +++ b/resources/tester/helper_810_3_screen.cjs @@ -0,0 +1,65 @@ +/* 810-3 화면 셈 거울 헬퍼 — 가져온 식 문서를 화면과 같은 길(importView → calcDoc)로 셈 + 줄 쌓기 옵션 칸 꼴. + * stdin = {문서들: {키: 문서}, 대상: [키], 기록: {열쇠: {값 | 빈 | 끊김}}, 기준: "엑셀" | "우리"} + * stdout = {답: {키: calcDoc 답}, 꼴: {키: {옵션: [기호], 숨김: [기호], 공식: [기호]}}} + * 짝 = test_810_3_native_screen.py · 기록 열쇠 = helper_802_1_formula_doc.cjs 와 같음. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const cache = new Map(); +function load(at) { + if (cache.has(at)) return cache.get(at); + const js = ts.transpileModule(fs.readFileSync(at, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(at, box.exports); + const need = (p) => + p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} +const root = path.join(__dirname, "..", "..", "common_util"); +const calc = load(path.join(root, "common_util_formula_doc_calc.ts")); +const imp = load(path.join(root, "common_util_formula_doc_import.ts")); +const stack = load(path.join(root, "common_util_line_stack.ts")); +const { varId } = load(path.join(root, "common_util_formula_doc.ts")); + +function canon(v) { + if (typeof v === "number") return calc.plainText(v); + if (Array.isArray(v)) return v.map(canon); + if (v && typeof v === "object") { + const out = {}; + for (const k of Object.keys(v).sort()) out[k] = canon(v[k]); + return out; + } + return v; +} +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const record = input.기록 || {}; +function answer(kind, p) { + const k = `${kind}|${JSON.stringify(canon(p))}`; + if (!(k in record)) throw new calc.Broken(`기록 없음 ${k}`); + const got = record[k]; + if ("빈" in got) throw new calc.Gap(got.빈); + if ("끊김" in got) throw new calc.Broken(got.끊김); + return got.값; +} +const docs = input.문서들 || {}; +const links = { + element: (p) => answer("요소", p), + table: (p) => answer("표", p), + doc: (key) => (docs[key] ? imp.importView(docs[key], input.기준 || "엑셀") : null), +}; +const out = { 답: {}, 꼴: {} }; +for (const key of input.대상 || []) { + const doc = docs[key]; + out.답[key] = calc.calcDoc(imp.importView(doc, input.기준 || "엑셀"), links); + const hidden = stack.hiddenOf(doc); + out.꼴[key] = { + 옵션: (doc.변수 || []).filter((v) => stack.sourceOf(doc, v)).map(varId), + 숨김: [...hidden], + 공식: stack.stackOf(doc).flatMap((it) => it.줄.flatMap((l) => (l.꼴 === "공식" ? l.기호 : []))), + }; +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_810_3_native_screen.py b/resources/tester/test_810_3_native_screen.py new file mode 100644 index 000000000..ed3f26461 --- /dev/null +++ b/resources/tester/test_810_3_native_screen.py @@ -0,0 +1,213 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""810-3 지킴 — 원파일 가져온 식 화면(줄 쌓기 · 옵션 칸 · 추정 칸 · 카드). + +(a) 화면 셈 거울 — EST 여섯 · STC 일곱 산근 블록 모두: 화면 길(TS importView → calcDoc) = 서버 길 + (`M02_Doc_Import.run` 계산기) 답 전부 같음(줄 · 비목 · 합 · 표지) +(b) 옵션 칸 — 원파일 수 하나 변수(출처 「원파일」) = 옵션 가로 칸(`sourceOf`) · + 줄 쌓기 공식 묶음에 안 들어감 · 금액 줄 단가로 쓰는 수 · 엑셀 단가 기억 연결 = 옵션 칸 아님 +(c) 추정 칸 — 문서 머리 `가져온.추정` 줄 수 = 원본 확정 추정 수(EST) · 원코드 · 규격 머리 +(d) 소계 줄 — 합 밖 · 비목 없음(화면 비목 칸 빈칸 · 종류 「소계」 — + `line_stack_edit` · `line_stack_out` 글 대조) +원파일 없으면 그 시험만 skip(다른 PC). 추정 표지 수 표 = tmp/s8103/guess.json(일회 기록). +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from collections import Counter +from functools import lru_cache +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +for p in (ROOT, HERE): + if str(p) not in sys.path: + sys.path.insert(0, str(p)) + +import test_802_1_formula_doc as t1 # noqa: E402 +import test_810_2_native_doc as t2 # noqa: E402 +from test_810_1_estx import FILES as EST # noqa: E402 +from test_810_1_stc import FILES as STC # noqa: E402 + +from M02_MasterTemplete import M02_Doc_Import as dim # noqa: E402 + +HELPER = HERE / "helper_810_3_screen.cjs" + + +class Keep(dim.Links): + """서버 연결 + 기록(요소 · 표 · 문서) — 같은 값을 화면 셈에 넘김.""" + + def __init__(self, master, basis): + super().__init__(master, basis) + self.log, self.docs = {}, {} + self.rec = t1.Rec(self) + + def element(self, p): + return self.rec._keep("요소", p, super().element) + + def table(self, p): + return self.rec._keep("표", p, super().table) + + def doc(self, key): + got = super().doc(key) + if got is not None: + self.docs[key] = got + return got + + +def _plain(v): + return json.loads(json.dumps(t1.ml._norm(v), ensure_ascii=False, default=str)) + + +@lru_cache(maxsize=16) +def _file(kind: str, tag: str) -> list[tuple[int, dict, dict, dict]]: + """블록마다 (번, 원본, 문서, 서버 답 · 기록) — 서버 길 그대로.""" + saved = t2.native(kind, tag) + out = [] + for no in t2.fn.numbers(saved): + emitted = t2.fn.emit(saved, no) + sk = t2.sk_of(emitted) + doc = dim.derive(sk, None, t2.whole()) + links = Keep(t2.whole(), dim.basis_of(sk)) + mine = dim.view(doc, dim.basis_of(sk)) + links.learn(mine) + want = t1.mc.calc(mine, links) + out.append( + (no, emitted["원본"], doc, {"답": want, "기록": links.rec.log, "문서": links.docs}) + ) + return out + + +def _screen(rows) -> dict: + docs, log, keys = {}, {}, [] + for no, _, doc, srv in rows: + key = f"블록{no}" + docs[key] = _plain(doc) + keys.append(key) + log |= srv["기록"] + docs |= {k: _plain(v) for k, v in srv["문서"].items()} + pay = {"문서들": docs, "대상": keys, "기록": _plain(log), "기준": "엑셀"} + proc = subprocess.run( + ["node", str(HELPER)], + input=json.dumps(pay, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(proc.stdout) + + +def _cases(): + return [("EST", k) for k in EST] + [("STC", k) for k in STC] + + +@pytest.mark.parametrize(("kind", "tag"), _cases()) +def test_screen_calc_mirror_and_option_cells(kind: str, tag: str) -> None: + rows = _file(kind, tag) + got = _screen(rows) + for no, src, doc, srv in rows: + key = f"블록{no}" + t1._same(t1.ml._norm(srv["답"]), got["답"][key], f"{tag} {key}") + shape = got["꼴"][key] + nums = [ + v["열쇠"] + for v in doc["변수"] + if v.get("출처") == "원파일" + and not any(str(m.get("단가")) == v["열쇠"] for m in doc.get("금액") or []) + ] + assert set(nums) <= set(shape["옵션"]), (tag, key, nums, shape["옵션"]) + assert not set(shape["옵션"]) & set(shape["공식"]), (tag, key) # 옵션 칸 = 공식 묶음 밖 + for v in doc["변수"]: + if v["열쇠"] in shape["옵션"]: + assert v["연결"]["갈래"] == "숫자" and "엑셀" not in v["연결"] + + +def test_guess_head_counts_and_table() -> None: + table, total = {}, Counter() + for kind, tag in _cases(): + try: + rows = _file(kind, tag) + except pytest.skip.Exception: + continue + n = Counter() + for _, src, doc, _ in rows: + head = doc["가져온"] + assert head["원파일"] is True + guess = head.get("추정") or [] + assert len(guess) == (src.get("확정") or {}).get("추정", 0), (tag, doc["이름"]) + assert all(g["글"] and isinstance(g["행"], int) for g in guess) + if kind == "EST": + assert head.get("원코드") # EST 블록 코드(D… · AAA…) + n["블록"] += 1 + n["추정 칸"] += len(guess) + n["추정 블록"] += bool(guess) + n["값 옵션"] += sum(1 for v in doc["변수"] if v.get("출처") == "원파일") + n["소계 줄"] += sum(1 for m in doc["금액"] if m.get("합밖") and not m.get("비목")) + for k in ("확정", "표지"): + n[k] += (src.get("확정") or {}).get(k, 0) + table[f"{kind} {tag}"] = dict(n) + total.update(n) + if not table: + pytest.skip("원파일 없음") + table["합"] = dict(total) + (ROOT / "tmp" / "s8103").mkdir(parents=True, exist_ok=True) + (ROOT / "tmp" / "s8103" / "guess.json").write_text( + json.dumps(table, ensure_ascii=False, indent=1), encoding="utf-8" + ) + assert total["블록"] and total["추정 칸"] and total["값 옵션"] and total["소계 줄"] + + +def test_subtotal_shape_and_screen_text() -> None: + out, doc, got = t2._block(7) # 지장목 제거 — 인부소계 + sub = next(x for x in doc["금액"] if x["이름"] == "인부소계") + assert sub["합밖"] and "비목" not in sub + edit = (ROOT / "A00_Common" / "line_stack_edit.ts").read_text(encoding="utf-8") + assert "const bare = !!line.합밖 && !line.비목" in edit and '"K_소계"' in edit + outx = (ROOT / "A00_Common" / "line_stack_out.ts").read_text(encoding="utf-8") + assert "cols: bare ? {} :" in outx + side = (ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Hopyo_Side.ts").read_text( + encoding="utf-8" + ) + assert 'accept: ".xls,.xlsx,.estx,.stc"' in side + + +def test_origin_card_native_line() -> None: + js = r""" +const fs = require("fs"), path = require("path"); +const ts = require(path.join(process.cwd(), "config", "node_modules", "typescript")); +const at = path.join(process.cwd(), "M02_MasterTemplete", "logic", "M02_Sangeun_Origin.ts"); +const js = ts.transpileModule(fs.readFileSync(at, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => ({})); +const got = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(JSON.stringify(got.map((m) => box.exports.originOf(m)))); +""" + est = {"로직": {}, "원본": {"갈래": "EST", "시트": "산근", "코드": 7, "원파일": True, + "원코드": "D0000000118", "행": [2, 49], + "확정": {"확정": 13, "표지": 0, "추정": 2}}} # fmt: skip + xl = {"로직": {}, "원본": {"갈래": "EST", "시트": "산근", "코드": 7, "행": [2, 49]}} + stc = {"로직": {}, "원본": {"갈래": "STmate", "시트": "단가산출근거", "코드": "D00014", + "원파일": True, "행": [2, 25], + "확정": {"확정": 199, "표지": 1}}} # fmt: skip + proc = subprocess.run( + ["node", "-e", js], + input=json.dumps([est, xl, stc], ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + cwd=ROOT, + check=True, + ) + a, b, c = json.loads(proc.stdout) + assert a["요약"] == "원파일 · EST 산근 D0000000118 · 행 2~49" + assert a["확정"] == "확정 13 · 표지 0 · 추정 2 · 확정률 100.0 %" + assert "요약" not in b and "확정" not in b # 엑셀 길 = 그대로 + assert c["요약"] == "원파일 · STmate 단가산출근거 D00014 · 행 2~25" + assert c["확정"].endswith("확정률 99.5 %")