feat(810-3): 원파일 가져온 식 화면 — 옵션 칸 · 추정 칸 · 소계 빈칸 · 카드 확정률 · 화면 셈 거울
- 올림 칸 accept .estx · .stc · 올림 안내 「원파일 · EST/STmate — 호표 N · 같은 공사 엑셀 HP 에 붙음」 - 줄 쌓기 위 옵션 가로 — 가져온 식 값으로 드러난 수 하나 변수(q · K · E · Cm · Q …) = 값 칸 + 꼬리표 「원파일」 · 「엑셀」(`sourceOf` · 공식 묶음 밖) · 규격 칸(원파일) · 품셈 조건 글 = 「추정」 칸(읽기만 · 기초에 안 묶음) - 합 밖 · 비목 없는 소계 줄 = 종류 「소계」 · 비목 칸 빈칸(편집 · 산출근거 표 칸 모두) - 원문 카드 — 「원파일 · EST 산근 D… · 행 …」 한 줄 + 원파일 확정(확정 · 표지 · 추정 · 확정률) - 서버 — 원파일 원본에 원코드 · 규격(EST) · 문서 머리 가져온에 원코드 · 규격 · 확정 · 추정 글줄(금액 · 길 그대로) - 화면 계산기 큰나머지 끝전 — 나머지 비교 소수 흔들림 반올림(파이썬 Decimal 과 같은 갈래 · 동부청영월 블록 1810 재료비 1 원 어긋남 고침) - 시험 test_810_3_native_screen(EST 1,849 · STC 371 블록 화면 셈 = 서버 셈 · 옵션 칸 · 추정 칸 수 = 확정 추정 · 소계 · 카드) · 헬퍼 helper_810_3_screen.cjs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 } };
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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["줄"]]
|
||||
|
||||
@@ -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)} %`,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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] 와 같음
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 %")
|
||||
Reference in New Issue
Block a user