Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-11-08 21:14:04 +09:00
16 changed files with 488 additions and 15 deletions
+24
View File
@@ -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);
}
+3 -2
View File
@@ -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({
+2 -1
View File
@@ -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 } };
};
+8 -3
View File
@@ -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)
+80
View File
@@ -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;
}
+14
View File
@@ -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"],
+3 -1
View File
@@ -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["줄"]]
+18 -1
View File
@@ -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)
@@ -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);
@@ -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") }),
@@ -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)} %`,
}
: {}),
};
}
+2 -2
View File
@@ -517,10 +517,10 @@ function settle(doc: FormulaDoc, rules: Record<string, Rule | string>, 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] 와 같음
}
+12 -2
View File
@@ -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<string, unknown>;
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<string> {
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;
}
@@ -539,3 +539,11 @@ M01은 지금 상당히 잘 개발이 되었어. 일위대가 불러오기의
## 요구 707 (11-08 · 사용자 · 일위대가에서 단가산출 = 한 행 · 분석 엔진으로 우리 로직과 연결 찾기)
'일위대가에서 단가산출은 하나의 행으로 표현하면 됨. 그리고 경쟁사의 로직을 인정하기 전에 분석엔진울 잘 개발하여 우리 로직과 연결하는 걸 찾아야함.
## 요구 708 (11-08 · 사용자 · 바꾸기 = 엑셀 쪽 · 우리 기초를 바꿀 이유?)
바꾸기는 엑셀의 정보를 바꾸면 되는데 우리 단가산출 기초의 정보를 변경해야하는 이유가 있음?
## 요구 709 (11-08 · 사용자 · 「나」 — 분석이 확실하면 가져온 단가산출을 우리 기초 사본으로 바꿔도 됨)
그렇다면 '나'가 맞음. 분석해서 제대로 의미를 가져왔다면 변경해도 됨.
+65
View File
@@ -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));
@@ -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 %")