fix(845-2): 단가산출 단위 표시 규칙 한 벌 · 기계 산출근거 겹분수 중간 줄로 나눔
- 단위 규칙 한 벌(common_util_line_stack_unit) — 왼쪽 값 칸 · 식 줄 · 옵션 · 오른쪽 산출근거 · 기계 제어 구역 · 기계 보기가 같이 씀 · 메타 단위(구분 따름 · 수(…) · 원문 단위 없음) 안 그림 · %(설명) = % · 10^-7 = 10⁻⁷ · 기계 칸 연결 = 기계 표 칸 단위 · 코드 값 쉼표 없음 - 비목 · 금액 줄 = 「단가 원/단위 × (수량 식) 단위 = 금액 원」 · 소계 · 합계 · 계 = 원 · 단가 · 수량 한 덩이(줄 바뀜에 단위 안 떨어짐) - 기계경비식 겹분수 → 중간 줄(상여 · 휴지 · 손료계수 · common_util_line_stack_frac) · 한 줄 분수 한 층 - 기계 제어 구역 단위 = 칸 밖 바로 뒤 · 기계 보기 오른쪽 = 박기 전 기계 칸 단위 - 휴대폰 폭(560 px 밑) 산출근거 금액 칸 넷 걷음(가로 넘침 0) · 분수 줄 위아래 여유 - 전수 1,520 표시 탓 43,218 → 0 · 기계 겹분수 2 층 넘는 줄 1,040 → 0 · 금액 · 식 · 문서 자료 불변(그리기만) - 시험 test_845_2_unit_show(차린 줄 글 전수 지킴) · shownUnit 걷음(규칙 한 벌로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { varId, type DocVar, type FormulaDoc } from "../common_util/common_util_formula_doc";
|
||||
import { varUnit } from "../common_util/common_util_line_stack_unit";
|
||||
import { cfMachineNames, cfMachineOff } from "../M02_MasterTemplete/DataFlow/M02_DF_Api";
|
||||
import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text";
|
||||
import { ft } from "./formula_doc_text";
|
||||
@@ -101,7 +102,7 @@ export function buildOptions(
|
||||
value: now === undefined || now === null ? "" : String(now),
|
||||
compact: true,
|
||||
size: 28,
|
||||
unit: v.단위 || undefined,
|
||||
unit: varUnit(v) || undefined, // 845-2 단위 규칙 한 벌
|
||||
});
|
||||
f.input.disabled = lock;
|
||||
f.input.addEventListener("change", () => {
|
||||
|
||||
@@ -378,11 +378,29 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0 var(--spacing-4);
|
||||
gap: var(--spacing-4);
|
||||
padding-inline-start: calc(var(--lstk-depth, 0) * var(--spacing-16));
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* 845-2 휴대폰 폭 — 금액 칸 넷은 줄 글(「= 843.1 원」) · 아래 합계 상자와 같아 걷고 산출근거 칸을 다 씀(가로 넘침 없음) */
|
||||
@container (max-width: 560px) {
|
||||
.lstk-out th:nth-child(n + 2),
|
||||
.lstk-out td:nth-child(n + 2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 845-2 분수 줄 — 한글 분자 · 분모가 KaTeX 높이 셈보다 커서 위아래 줄에 닿음 → 위아래 여유 */
|
||||
.lstk-out .lstk-h:has(.mfrac) {
|
||||
padding-block: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 값 칸 묶음 줄 — 칸 사이 간격(줄이 바뀌어도 줄 앞 빈칸 없음) */
|
||||
.lstk-out tr.packs .lstk-h {
|
||||
column-gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
.lstk-out tr.head,
|
||||
.lstk-out tr.item,
|
||||
.lstk-out tr.total {
|
||||
@@ -408,11 +426,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lstk-gap {
|
||||
display: inline-block;
|
||||
width: var(--spacing-24);
|
||||
}
|
||||
|
||||
.lstk-pack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -487,10 +500,20 @@
|
||||
|
||||
.lstk-mctl > .lstk-frow {
|
||||
display: grid;
|
||||
grid-template-columns: 7em auto 11em auto auto;
|
||||
grid-template-columns: minmax(3em, 6em) auto minmax(0, 15em) auto auto;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
/* 845-2 값 칸 + 단위(칸 밖 바로 뒤) — 좁으면 칸이 줄어듦 */
|
||||
.lstk-mctl > .lstk-frow > .lstk-qty {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lstk-mctl .lstk-unit {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lstk-mctl > .lstk-frow > .lstk-sym {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ import {
|
||||
type Picked,
|
||||
aliasText,
|
||||
repick,
|
||||
shownUnit,
|
||||
tableAlias,
|
||||
unswap,
|
||||
} from "../common_util/common_util_line_stack_pick";
|
||||
import { varUnit } from "../common_util/common_util_line_stack_unit";
|
||||
import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick";
|
||||
import { reach } from "./formula_doc_render";
|
||||
import { attachDrag } from "./line_stack_drag";
|
||||
@@ -326,7 +326,7 @@ function qtyBox(
|
||||
const inBlock = h.items.some((it) => it.줄.some((l) => l.꼴 === "공식" && l.기호.includes(text)));
|
||||
const tag = v && !inBlock ? sourceTag(h, text, v.연결) : null;
|
||||
if (tag && isNum(val))
|
||||
tag.title = `${eqText(val)}${shownUnit(v?.단위) ? ` ${shownUnit(v?.단위)}` : ""} — ${tag.title || tag.textContent}`;
|
||||
tag.title = `${eqText(val)}${varUnit(v) ? ` ${varUnit(v)}` : ""} — ${tag.title || tag.textContent}`;
|
||||
return el("span", { className: "lstk-qty", children: tag ? [f, tag] : [f] });
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "../common_util/common_util_formula_doc_cover";
|
||||
import { subInputs, type StackDoc } from "../common_util/common_util_line_stack";
|
||||
import type { Picked } from "../common_util/common_util_line_stack_pick";
|
||||
import { machineUnit } from "../common_util/common_util_line_stack_unit";
|
||||
import { machineCellName } from "../common_util/common_util_machine_symbol_name";
|
||||
import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
|
||||
import { openStackPick } from "./line_stack_pick";
|
||||
@@ -52,11 +53,6 @@ export interface CtlHooks {
|
||||
lock?: string[];
|
||||
}
|
||||
|
||||
/** 기계 칸 단위(기계 표 `단위` 와 같음) */
|
||||
const UNIT: Record<string, string> = {
|
||||
가격: "천원", 상각비계수: "10⁻⁷/hr", 정비비계수: "10⁻⁷/hr", 관리비계수: "10⁻⁷/hr", 손료계수: "10⁻⁷/hr",
|
||||
주연료: "ℓ/hr", 전력: "kWh/hr", 노무비: "원/hr", 재료비: "원/hr", 경비: "원/hr",
|
||||
}; // prettier-ignore
|
||||
const FUELS = ["경유", "휘발유", "실내등유"];
|
||||
|
||||
/** 그 줄 기계 열쇠 — 부를 입력을 지금 값으로 셈(옵션 · 만약 식 기호도) · 기계 줄 아님 = "" */
|
||||
@@ -83,7 +79,7 @@ const num = (t: string): number | null => {
|
||||
return t.trim() !== "" && Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
/** 수 칸 — 빈 글 = 원래대로(null) · 음수 · 글 = 알림 */
|
||||
/** 수 칸 — 빈 글 = 원래대로(null) · 음수 · 글 = 알림 · 단위 = 칸 바로 뒤(공식 묶음 값 칸과 같은 자리 · 845-2 ㉥) */
|
||||
function numBox(
|
||||
label: string,
|
||||
value: unknown,
|
||||
@@ -97,7 +93,6 @@ function numBox(
|
||||
placeholder: hint,
|
||||
compact: true,
|
||||
size: 24,
|
||||
...(unit ? { unit } : {}),
|
||||
});
|
||||
f.root.classList.add("lstk-f-s");
|
||||
f.input.setAttribute("aria-label", label);
|
||||
@@ -113,7 +108,9 @@ function numBox(
|
||||
}
|
||||
set(n);
|
||||
});
|
||||
return f.root;
|
||||
return unit
|
||||
? el("span", { className: "lstk-qty", children: [f.root, el("span", { className: "lstk-unit", text: unit })] }) // prettier-ignore
|
||||
: f.root;
|
||||
}
|
||||
|
||||
const kind = (text: string): HTMLElement => el("span", { className: "lstk-kind", text });
|
||||
@@ -154,7 +151,7 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen
|
||||
ariaLabel: col,
|
||||
onChange: (x) => setVal(x),
|
||||
}).root
|
||||
: numBox(col, now, UNIT[col] ?? "", shut, setVal);
|
||||
: numBox(col, now, machineUnit(col), shut, setVal); // 845-2 기계 표 칸 단위 한 벌
|
||||
const row = el("div", {
|
||||
className: "lstk-frow",
|
||||
children: [
|
||||
|
||||
@@ -54,6 +54,12 @@ import {
|
||||
type MachineRow,
|
||||
type MachineValues,
|
||||
} from "../common_util/common_util_machine_sheet";
|
||||
import {
|
||||
MACHINE_UNIT,
|
||||
machineUnit,
|
||||
unitText,
|
||||
varUnit,
|
||||
} from "../common_util/common_util_line_stack_unit";
|
||||
import { machineCellName } from "../common_util/common_util_machine_symbol_name";
|
||||
import { docFoot } from "./formula_doc_foot";
|
||||
import { buildMachineCtl } from "./line_stack_machine_ctl";
|
||||
@@ -108,14 +114,20 @@ interface SheetInfo {
|
||||
|
||||
/** 늘 보이는 기계 줄 칸(값이 없어도) — 그 밖 칸은 줄에 있으면 */
|
||||
const BASE_KEYS = ["가격", "손료계수", "주연료", "주연료종류", "전력"];
|
||||
const UNIT: Record<string, string> = {
|
||||
가격: "천원", 손료계수: "10⁻⁷/hr", 내용시간: "시간", 연간표준가동시간: "시간", 주연료: "ℓ/hr", 전력: "kWh",
|
||||
}; // prettier-ignore
|
||||
const PICKS = ["주연료종류"];
|
||||
/** 식이 바뀔 수 있는 칸(서버 식 고름) */
|
||||
const PICKY = ["주연료", "전력", "손료계수", ...MACHINE_COEFS];
|
||||
|
||||
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v ?? null)) as T;
|
||||
/** 값 박은 식(그림용) — 박기 전 기계 칸 단위를 남김(박으면 칸 연결이 수로 바뀌어 단위를 못 읽음 · 845-2) */
|
||||
const boundView = (d: FormulaDoc, raw: MachineValues): FormulaDoc => {
|
||||
const b = bindDoc(d, raw);
|
||||
b.변수?.forEach((v, i) => {
|
||||
const u = varUnit(d.변수?.[i]);
|
||||
if (u && !v.단위) v.단위 = u;
|
||||
});
|
||||
return b;
|
||||
};
|
||||
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
|
||||
const label = (k: string): string => machineCellName(k);
|
||||
|
||||
@@ -278,10 +290,10 @@ export function createMachineSheet(o: MachineSheetOptions) {
|
||||
ctlBox.replaceChildren(...(zone ? [zone] : []));
|
||||
};
|
||||
|
||||
/** 칸 단위 — 줄 `단위` 글(일 단위 손료 등) › 기본 */
|
||||
/** 칸 단위 — 줄 `단위` 글(일 단위 손료 등 · 기계 표 기본과 다를 때) › 기계 표 칸 단위(845-2 한 벌) */
|
||||
const unitOf = (k: string): string => {
|
||||
const u = text((cur.단위 as MachineValues | undefined)?.[k]);
|
||||
return !u || u === "10^-7/시간" ? (UNIT[k] ?? "") : u;
|
||||
return u && u !== MACHINE_UNIT[k] ? unitText(u) : machineUnit(k);
|
||||
};
|
||||
const numField = (k: string, read = false): HTMLElement => {
|
||||
const v = cur.값?.[k];
|
||||
@@ -504,7 +516,7 @@ export function createMachineSheet(o: MachineSheetOptions) {
|
||||
right.replaceChildren(
|
||||
head,
|
||||
...(info.까닭 ? [createText(info.까닭, "warning")] : []),
|
||||
buildOut(bindDoc(shaped, info.값 ?? {}) as StackDoc, a, {
|
||||
buildOut(boundView(shaped, info.값 ?? {}) as StackDoc, a, {
|
||||
cache,
|
||||
open: opened,
|
||||
toggle: (at) => {
|
||||
@@ -520,7 +532,7 @@ export function createMachineSheet(o: MachineSheetOptions) {
|
||||
};
|
||||
const drawWarn = (): void => {
|
||||
const vals: MachineValues = crewView({ ...engineVals(cur, mine), ...(cur.값 ?? {}) });
|
||||
if (unitOf("손료계수") !== UNIT.손료계수) delete vals.손료계수; // 일 단위 손료(임업) = 시간당 범위 아님
|
||||
if (unitOf("손료계수") !== machineUnit("손료계수")) delete vals.손료계수; // 일 단위 손료(임업) = 시간당 범위 아님
|
||||
warn.replaceChildren(
|
||||
...rangeWarnings(vals).map(([k, r]) =>
|
||||
createText(L("MS_Range").replace("{name}", label(k)).replace("{value}", r), "warning"),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* line_stack_out.ts
|
||||
* 줄 쌓기 산출근거 출력(PLAN 804-2 · 요구 670-1 · 677) — 경쟁사 「단가산출」 시트 꼴 표(산출근거 · 합계 · 노무비 · 재료비 · 경비)
|
||||
* 공식 줄 = 「기호 = 기호식 = 값식 = 결과 단위」(값만인 줄은 넷까지 한 줄) · 금액 줄 = 「이름 : 단가 × 수량 단위 = 금액」
|
||||
* 공식 줄 = 「기호 = 기호식 = 값식 = 결과 단위」(값만인 줄은 넷까지 한 줄) · 금액 줄 = 「이름 : 단가 원/단위 × (수량 식) 단위 = 금액 원」
|
||||
* 단위 = 규칙 한 벌(`common_util_line_stack_unit` · 845-2) · 기계경비식 겹분수 = 중간 줄(`common_util_line_stack_frac`)
|
||||
* 기계 · 단가산출 줄 = 비목마다 「노무비 : 59,020 × … = 금액」 — 제목 줄(「굴착기 0.6㎥」) 누름 = 그 문서(기계 시간당 경비 등)를
|
||||
* 같은 계산기로 셈해 같은 꼴로 펼침(손료 식 · 연료 · 운전사 · 노 · 재 · 경 · 계) · 비목 수는 글(링크 꼴 없음 · 요구 687-11) ·
|
||||
* 공식 줄 단가산출 결과 = 수 누름(▸) · 접으면 한 줄 · 출력엔 펼친 것만
|
||||
@@ -12,11 +13,18 @@ import { createButton, createIconButton } from "@ui/ui_template_button";
|
||||
import { eqText } from "@ui/ui_template_equation";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
|
||||
import { COSTS, varId, type FormulaDoc } from "../common_util/common_util_formula_doc";
|
||||
import {
|
||||
COSTS,
|
||||
varId,
|
||||
type FormulaDoc,
|
||||
type Node as ExprNode,
|
||||
} from "../common_util/common_util_formula_doc";
|
||||
import { calcDoc, isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
|
||||
import type { DocCache } from "../common_util/common_util_formula_doc_cache";
|
||||
import { coverDoc, coverOf, type Cover } from "../common_util/common_util_formula_doc_cover";
|
||||
import {
|
||||
decide,
|
||||
hasIf,
|
||||
lineMap,
|
||||
moneyScope,
|
||||
offLine,
|
||||
@@ -33,7 +41,16 @@ import {
|
||||
type StackDoc,
|
||||
} from "../common_util/common_util_line_stack";
|
||||
import { placeNames } from "../common_util/common_util_line_stack_copy";
|
||||
import { shownUnit, stackLevels, tableAlias } from "../common_util/common_util_line_stack_pick";
|
||||
import { splitFrac } from "../common_util/common_util_line_stack_frac";
|
||||
import { stackLevels, tableAlias } from "../common_util/common_util_line_stack_pick";
|
||||
import {
|
||||
AMOUNT_UNIT,
|
||||
machineUnit,
|
||||
moneyUnits,
|
||||
unitText,
|
||||
varUnit,
|
||||
} from "../common_util/common_util_line_stack_unit";
|
||||
import { partName } from "../common_util/common_util_machine_symbol_name";
|
||||
import { katexNode, symTex, tex, valTex, type TexCtx } from "./line_stack_tex";
|
||||
import { lt } from "./line_stack_text";
|
||||
|
||||
@@ -110,8 +127,38 @@ function rows(
|
||||
const shown = new Set(reach(doc, vals)); // 지금 탄 갈래로 닿는 기호만
|
||||
const steps = lineMap(doc);
|
||||
const place = placeNames(doc, stackOf(doc)); // 번지 읽는 이름(804-5)
|
||||
const unit = (id: string): string => shownUnit(vars.get(id)?.단위);
|
||||
const unit = (id: string): string => varUnit(vars.get(id)); // 845-2 단위 규칙 한 벌
|
||||
const alias = tableAlias(doc); // 표 값 기호 화면 이름(요구 683)
|
||||
const machine = doc.부품?.갈래 === "기계경비식"; // 845-2 한 줄 분수 한 층(중간 줄로 나눔)
|
||||
const taken = new Set([...vars.keys(), ...lines.keys(), ...Object.values(alias)]);
|
||||
/** 수 글 — 고르기 · 코드 값(S_ · 직종 1048 등)은 쉼표 없이 */
|
||||
const code = (id: string): boolean => /^S(_|$)/.test(id);
|
||||
const numText = (id: string, v: number): string => (code(id) ? String(v) : eqText(v));
|
||||
/** 식 줄 「기호 = 기호식 = 값식 = 결과 단위」 — 같은 꼴이 이어지면 한 번만(만약이 수 하나를 탄 줄) */
|
||||
const formula = (
|
||||
id: string,
|
||||
n: ExprNode,
|
||||
v: unknown,
|
||||
u: string,
|
||||
vs: Values,
|
||||
more: Partial<TexCtx>,
|
||||
): (string | HTMLElement)[] => {
|
||||
const res = isNum(v)
|
||||
? katexNode(`${code(id) ? String(v) : valTex(v)}\\ ${u ? `\\text{${u}}` : ""}`)
|
||||
: typeof v === "string" // 글 값(기계 원문번호 등 · 838-3) = 그 글 · 기계면 이름
|
||||
? el("span", { text: machineText(v, () => h.redraw?.()) ?? v })
|
||||
: missing(a, id);
|
||||
const sym = tex(n, { side: "sym", vals: vs, alias, ...more });
|
||||
const val = tex(n, { side: "val", vals: vs, alias, ...more });
|
||||
const same = isNum(v) && val === valTex(v);
|
||||
return [
|
||||
katexNode(symTex(alias[id] ?? id)),
|
||||
...(sym === val ? [] : [" = ", katexNode(sym)]),
|
||||
...(same ? [] : [" = ", katexNode(val)]),
|
||||
" = ",
|
||||
res,
|
||||
];
|
||||
};
|
||||
const subOf = (id: string): FormulaDoc | null => {
|
||||
const k = vars.get(id)?.연결;
|
||||
if (k?.갈래 !== "단가산출" || !k.키 || k.키.includes("{")) return null;
|
||||
@@ -171,8 +218,7 @@ function rows(
|
||||
const block = (ids: string[]): void => {
|
||||
let pack: HTMLElement[] = [];
|
||||
const flush = (): void => {
|
||||
if (pack.length)
|
||||
push({ h: pack.flatMap((x, k) => (k ? [el("span", { className: "lstk-gap" }), x] : [x])) });
|
||||
if (pack.length) push({ h: pack, cls: "packs" }); // 칸 사이 = CSS 간격(줄이 바뀌어도 줄 앞 빈칸 없음 · 845-2)
|
||||
pack = [];
|
||||
};
|
||||
for (const id of ids.filter((x) => shown.has(x))) {
|
||||
@@ -181,35 +227,30 @@ function rows(
|
||||
const head = katexNode(symTex(alias[id] ?? id));
|
||||
if (t !== undefined && hasVar(t)) {
|
||||
flush();
|
||||
const res = isNum(v)
|
||||
? katexNode(`${valTex(v)}\\ ${unit(id) ? `\\text{${unit(id)}}` : ""}`)
|
||||
: typeof v === "string" // 글 값(기계 원문번호 등 · 838-3) = 그 글 · 기계면 이름
|
||||
? el("span", { text: machineText(v, () => h.redraw?.()) ?? v })
|
||||
: missing(a, id);
|
||||
// 기호식 = 값식 = 결과 — 같은 꼴이 이어지면 한 번만(만약이 수 하나를 탄 줄)
|
||||
const n = nodeOf(t)!;
|
||||
const sym = tex(n, { side: "sym", vals, alias });
|
||||
const val = tex(n, { side: "val", vals, alias });
|
||||
const same = isNum(v) && val === valTex(v);
|
||||
push({
|
||||
h: [
|
||||
head,
|
||||
...(sym === val ? [] : [" = ", katexNode(sym)]),
|
||||
...(same ? [] : [" = ", katexNode(val)]),
|
||||
" = ",
|
||||
res,
|
||||
],
|
||||
});
|
||||
if (!machine) {
|
||||
push({ h: formula(id, n, v, unit(id), vals, {}) });
|
||||
continue;
|
||||
}
|
||||
// 845-2 기계경비식 — 겹분수 = 중간 줄(상여 · 휴지 · 손료계수) + 남는 안쪽 분수 ÷ 한 줄(그림만)
|
||||
const tree = hasIf(n) ? decide(n, vals) : n;
|
||||
const cut = splitFrac(id, tree, alias, taken, vals, partName(id));
|
||||
const vs = { ...vals, ...cut.vals };
|
||||
const more = { decided: true, frac: 1 };
|
||||
// 중간 줄 단위 = 그 이름이 기계 칸(손료계수)이면 그 칸 단위 · 상여 · 휴지 = 비(무차원)
|
||||
for (const r of cut.rows) push({ h: formula(r.id, r.node, vs[r.id], machineUnit(r.id), vs, more) }); // prettier-ignore
|
||||
push({ h: formula(id, cut.node, v, unit(id), vs, more) });
|
||||
continue;
|
||||
}
|
||||
const sub = subOf(id);
|
||||
if (sub || (v && typeof v === "object")) {
|
||||
flush();
|
||||
const u = unit(id) || unitText(sub?.결과단위);
|
||||
const label =
|
||||
v && typeof v === "object"
|
||||
? COSTS.map((c) => `${COST_SHOW[c]} ${eqText((v as Values)[c])}`).join(" · ")
|
||||
? `${COSTS.map((c) => `${COST_SHOW[c]} ${eqText((v as Values)[c])}`).join(" · ")} ${u}`.trim()
|
||||
: isNum(v)
|
||||
? `${eqText(v)} ${unit(id) || sub?.결과단위 || ""}`
|
||||
? `${eqText(v)} ${u}`.trim()
|
||||
: "—";
|
||||
push({
|
||||
h: [head, " = ", sub ? opener(id, label) : label, sub ? ` ${sub.이름 ?? ""}` : ""],
|
||||
@@ -228,7 +269,7 @@ function rows(
|
||||
...expr,
|
||||
" = ",
|
||||
isNum(v)
|
||||
? `${eqText(v)} ${unit(id)}`
|
||||
? `${numText(id, v)} ${unit(id)}`.trim()
|
||||
: typeof v === "string"
|
||||
? (machineText(v, () => h.redraw?.()) ?? v)
|
||||
: missing(a, id),
|
||||
@@ -269,14 +310,26 @@ function rows(
|
||||
: String(line.이름 ?? ""); // 이름에 규격이 들면 한 번만(814-1)
|
||||
const qty =
|
||||
line.수량 !== undefined && line.수량 !== null && line.수량 !== "" ? line.수량 : null;
|
||||
// 845-2 ㉢ 「단가 원/단위 × (수량 식) 단위 = 금액 원」 — 수량이 식이면 괄호로 식 전체에 단위
|
||||
const ps = priceSym(line);
|
||||
const qn = qty === null ? null : nodeOf(qty);
|
||||
const mu = moneyUnits(line, nodeOf(line.단가), qn, ps ? vars.get(ps) : undefined, ps ? subOf(ps) : null); // prettier-ignore
|
||||
// 값 · 단위 한 덩이(좁은 폭에서 줄이 바뀌어도 단위가 떨어지지 않게)
|
||||
const glue = (...xs: (string | HTMLElement)[]): HTMLElement =>
|
||||
el("span", { className: "lstk-pack", children: xs.filter((x) => x !== "") });
|
||||
const qtyNode = (cost?: string): (string | HTMLElement)[] =>
|
||||
qty === null
|
||||
? []
|
||||
: [
|
||||
" × ",
|
||||
exprNode(qty, "val", scope, true, cost, { alias }),
|
||||
line.단위 ? ` ${line.단위}` : "",
|
||||
glue(
|
||||
" × ",
|
||||
mu.wrap && qn
|
||||
? katexNode(`\\left(${tex(qn, { side: "val", vals: scope, inline: true, cost, alias })}\\right)`) // prettier-ignore
|
||||
: exprNode(qty, "val", scope, true, cost, { alias }),
|
||||
mu.qty ? ` ${mu.qty}` : "",
|
||||
),
|
||||
];
|
||||
const priceUnit = mu.price ? ` ${mu.price}` : "";
|
||||
if (r.까닭 && r.금액 === null) {
|
||||
push({ h: [`${name} : — `, el("span", { className: "lstk-miss", text: r.까닭 })] });
|
||||
return { sub: false, cols: {} };
|
||||
@@ -292,7 +345,7 @@ function rows(
|
||||
if (!isNum(part) || (!part && !bundle[c])) continue;
|
||||
cols[c] = part;
|
||||
push({
|
||||
h: [`${COST_SHOW[c]} : `, eqText(bundle[c]), ...qtyNode(c), ` = ${one(part)}`],
|
||||
h: [`${COST_SHOW[c]} : `, glue(`${eqText(bundle[c])}${priceUnit}`), ...qtyNode(c), ` = ${one(part)} ${AMOUNT_UNIT}`], // prettier-ignore
|
||||
cols: { sum: part, [c]: part }, // 비목 줄 금액 = 합계 · 그 비목 칸(엑셀 단가산출 시트와 같음 · 835-1)
|
||||
});
|
||||
}
|
||||
@@ -307,11 +360,11 @@ function rows(
|
||||
" = ",
|
||||
exprNode(line.단가, "val", scope, true, undefined, { alias }),
|
||||
]
|
||||
: [exprNode(line.단가, "val", scope, true, undefined, { alias })];
|
||||
: [glue(exprNode(line.단가, "val", scope, true, undefined, { alias }), priceUnit)];
|
||||
const c = String(line.비목);
|
||||
const bare = !!line.합밖 && !line.비목; // 합 밖 소계(810-2) = 글에만 금액 · 비목 칸 빈칸
|
||||
push({
|
||||
h: [`${name} : `, ...price, ...qtyNode(), ` = ${one(amt)}`],
|
||||
h: [`${name} : `, ...price, ...qtyNode(), ` = ${one(amt)} ${AMOUNT_UNIT}`],
|
||||
cols: bare ? {} : { sum: amt, [c]: amt },
|
||||
});
|
||||
return { sub: false, cols: { [c]: amt } };
|
||||
@@ -353,7 +406,7 @@ function rows(
|
||||
loc = depth ? undefined : String(k);
|
||||
if (hasSub && COSTS.some((c) => sum[c])) {
|
||||
const s = Math.round((sum.노무비 + sum.재료비 + sum.경비) * 10) / 10;
|
||||
push({ h: [`${lt("Sub_Sum")} : ${one(s)}`], cols: { sum: s, ...sum }, cls: "subsum" });
|
||||
push({ h: [`${lt("Sub_Sum")} : ${one(s)} ${AMOUNT_UNIT}`], cols: { sum: s, ...sum }, cls: "subsum" }); // prettier-ignore
|
||||
}
|
||||
for (const c of COSTS) {
|
||||
terms[c].push(...(hasSub ? (sum[c] ? [sum[c]] : []) : own[c]));
|
||||
@@ -386,10 +439,10 @@ function rows(
|
||||
: t.length
|
||||
? won(v)
|
||||
: "";
|
||||
push({ h: [`${COST_SHOW[c]} : ${body}`] });
|
||||
push({ h: [`${COST_SHOW[c]} : ${body}${isNum(v) && body ? ` ${AMOUNT_UNIT}` : ""}`] });
|
||||
}
|
||||
push({
|
||||
h: [`${lt("Sum")} : ${isNum(a.계) ? won(a.계) : "—"}`],
|
||||
h: [`${lt("Sum")} : ${isNum(a.계) ? `${won(a.계)} ${AMOUNT_UNIT}` : "—"}`],
|
||||
cols: isNum(a.계)
|
||||
? { sum: a.계, 노무비: a.노무비 ?? 0, 재료비: a.재료비 ?? 0, 경비: a.경비 ?? 0 }
|
||||
: {},
|
||||
|
||||
@@ -42,9 +42,9 @@ import {
|
||||
boundConds,
|
||||
currentRow,
|
||||
pickCell,
|
||||
shownUnit,
|
||||
tableAlias,
|
||||
} from "../common_util/common_util_line_stack_pick";
|
||||
import { unitText, varUnit } from "../common_util/common_util_line_stack_unit";
|
||||
import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
|
||||
import type { EditHooks } from "./line_stack_edit";
|
||||
import { openTablePick } from "./line_stack_pick";
|
||||
@@ -161,12 +161,12 @@ export function buildBlock(
|
||||
const v = vals[id];
|
||||
const mach = machineText(v, () => h.change(() => undefined)); // 838-3 기계 원문번호 = 이름
|
||||
if (mach) return mach;
|
||||
if (isNum(v) || typeof v === "string")
|
||||
return `${eqText(v)} ${shownUnit(vars.get(id)?.단위)}`.trim();
|
||||
if (v && typeof v === "object")
|
||||
return ["노무비", "재료비", "경비"]
|
||||
.map((c) => `${c} ${eqText((v as Record<string, unknown>)[c])}`)
|
||||
.join(" · ");
|
||||
if (isNum(v) || typeof v === "string") return `${eqText(v)} ${varUnit(vars.get(id))}`.trim();
|
||||
if (v && typeof v === "object") {
|
||||
const key = vars.get(id)?.연결?.키; // 845-2 묶음 값 = 그 문서 결과 단위(원/시간 등)
|
||||
const u = varUnit(vars.get(id)) || unitText(key ? h.subDoc(key)?.결과단위 : "");
|
||||
return `${["노무비", "재료비", "경비"].map((c) => `${c} ${eqText((v as Record<string, unknown>)[c])}`).join(" · ")} ${u}`.trim(); // prettier-ignore
|
||||
}
|
||||
return `— ${h.answer.빈값?.[id] ?? ""}`.trim();
|
||||
};
|
||||
|
||||
@@ -350,9 +350,7 @@ export function buildBlock(
|
||||
input(shownText(valueOf), valText(id), "lstk-f-m", (t) =>
|
||||
h.change((doc) => setRow(doc, id, realText(t))),
|
||||
),
|
||||
...(shownUnit(v?.단위)
|
||||
? [el("span", { className: "lstk-unit", text: shownUnit(v?.단위) })]
|
||||
: []),
|
||||
...(varUnit(v) ? [el("span", { className: "lstk-unit", text: varUnit(v) })] : []),
|
||||
];
|
||||
const why = iffy
|
||||
? whyText(tree!, vals, {
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface TexCtx {
|
||||
alias?: Record<string, string>;
|
||||
/** 이미 결정된 나무(다시 안 접음) */
|
||||
decided?: boolean;
|
||||
/** 남은 분수 층(845-2 — 0 이면 안쪽 ÷ 는 한 줄 · 없음 = 끝없음) */
|
||||
frac?: number;
|
||||
}
|
||||
|
||||
const HIDE = new Set(["반올림", "올림", "버림"]);
|
||||
@@ -124,10 +126,16 @@ export function tex(n: Node, c: TexCtx): string {
|
||||
}
|
||||
case "bin": {
|
||||
const [, o, a, b] = n;
|
||||
if (o === "/" && !c.inline) return `\\dfrac{${go(a)}}{${go(b)}}`;
|
||||
if (o === "/" && !c.inline && c.frac !== 0) {
|
||||
const d = { ...c, frac: c.frac === undefined ? undefined : c.frac - 1 };
|
||||
return `\\dfrac{${tex(a, d)}}{${tex(b, d)}}`;
|
||||
}
|
||||
const mine = PREC[o];
|
||||
// 845-2 층 상한(기계경비식) — 분수로 그리는 오른쪽 항은 괄호 없이(분수 선이 묶음)
|
||||
const frac = (x: Node): boolean =>
|
||||
c.frac !== undefined && c.frac > 0 && !c.inline && x[0] === "bin" && x[1] === "/";
|
||||
const left = prec(a) < mine || (mine === 3 && prec(a) === 3) ? paren(go(a)) : go(a);
|
||||
const right = prec(b) <= mine ? paren(go(b)) : go(b);
|
||||
const right = prec(b) <= mine && !frac(b) ? paren(go(b)) : go(b);
|
||||
return `${left} ${OPS[o] ?? txt(o)} ${right}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/* =============================================================================
|
||||
* common_util_line_stack_frac.ts
|
||||
* 산출근거 분수 층(PLAN 845-2 · 요구 880 「분모 · 분자가 쌓여 층층이 → 행으로 나눔」)
|
||||
* 층 = 조판(`line_stack_tex`)과 같은 셈 — ÷ 하나 = 분수 한 층 · 감싸개(반올림 · 올림 · 버림)는 안 셈
|
||||
* 기계경비식(`splitFrac`) = 한 줄에 분수 한 층까지:
|
||||
* ① 곱 · 나눗셈 사슬에 ÷ 가 둘 넘으면 「a ÷ b」 를 짝으로 나란히(겹 대신 · 값 같음)
|
||||
* ② 「…분자 ÷ …분모」 짝(그림 이름 머리가 줄 이름과 다름) = 중간 줄 「상여 = 16 ÷ 12」
|
||||
* ③ 분수 안 덧셈 묶음이 또 분수를 품으면 = 중간 줄(그림 사전 `partName` — 「손료계수」)
|
||||
* ④ 그래도 남는 안쪽 분수 = 조판이 ÷ 한 줄로(`TexCtx.frac`)
|
||||
* 그리기만 — 문서 식 · 값 · 금액 불변 · 중간 줄 값 = 같은 식 조각을 같은 셈(`evalNode`) · 화면 없음(시험이 Node 로 돌림)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Node } from "./common_util_formula_doc";
|
||||
import { evalNode, type Values } from "./common_util_formula_doc_view";
|
||||
|
||||
const HIDE = new Set(["반올림", "올림", "버림"]);
|
||||
const hidden = (n: Node): n is ["call", string, Node[]] =>
|
||||
n[0] === "call" && HIDE.has(n[1]) && n[2].length >= 1 && n[2].length <= 2;
|
||||
|
||||
/** 조판 분수 층 수 */
|
||||
export function fracDepth(n: Node): number {
|
||||
switch (n[0]) {
|
||||
case "neg":
|
||||
case "pct":
|
||||
return fracDepth(n[1]);
|
||||
case "call": {
|
||||
const items = n[2];
|
||||
return hidden(n) ? fracDepth(items[0]) : Math.max(0, ...items.map(fracDepth));
|
||||
}
|
||||
case "bin": {
|
||||
const d = Math.max(fracDepth(n[2]), fracDepth(n[3]));
|
||||
return n[1] === "/" ? d + 1 : d;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 왼쪽으로 쌓인 곱 · 나눗셈 사슬 → [연산, 항] */
|
||||
function chain(n: Node): ["*" | "/", Node][] {
|
||||
if (n[0] === "bin" && (n[1] === "*" || n[1] === "/"))
|
||||
return [...chain(n[2]), [n[1] as "*" | "/", n[3]]];
|
||||
return [["*", n]];
|
||||
}
|
||||
const product = (xs: Node[]): Node => xs.reduce((a, b) => ["bin", "*", a, b]);
|
||||
const isFrac = (n: Node): n is ["bin", string, Node, Node] => n[0] === "bin" && n[1] === "/";
|
||||
|
||||
/** ① ÷ 둘 넘는 사슬 = 「a ÷ b」 짝을 나란히 — 감싸개 · 괄호 안까지 */
|
||||
export function regroup(n: Node): Node {
|
||||
switch (n[0]) {
|
||||
case "neg":
|
||||
return ["neg", regroup(n[1])];
|
||||
case "pct":
|
||||
return ["pct", regroup(n[1])];
|
||||
case "call":
|
||||
return ["call", n[1], n[2].map(regroup)];
|
||||
case "bin": {
|
||||
const items = chain(n);
|
||||
if (items.length < 2 || items.filter(([o]) => o === "/").length < 2)
|
||||
return ["bin", n[1], regroup(n[2]), regroup(n[3])];
|
||||
const out: Node[] = [];
|
||||
for (const [o, raw] of items) {
|
||||
const x = regroup(raw);
|
||||
const last = out[out.length - 1];
|
||||
if (o === "*" || !last) out.push(x);
|
||||
else if (isFrac(last))
|
||||
out[out.length - 1] = ["bin", "/", last[2], ["bin", "*", last[3], x]];
|
||||
else out[out.length - 1] = ["bin", "/", last, x];
|
||||
}
|
||||
return product(out);
|
||||
}
|
||||
default:
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
export interface FracRow {
|
||||
/** 중간 줄 이름(그림 전용 — 문서 기호 아님) */
|
||||
id: string;
|
||||
node: Node;
|
||||
}
|
||||
|
||||
const numOk = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
||||
|
||||
/** 기계경비식 한 줄 → 중간 줄 + 줄인 식 · 중간 값(`vals` 에 얹을 것) */
|
||||
export function splitFrac(
|
||||
id: string,
|
||||
n: Node,
|
||||
alias: Record<string, string>,
|
||||
taken: Set<string>,
|
||||
vals: Values,
|
||||
part?: string,
|
||||
): { rows: FracRow[]; node: Node; vals: Values } {
|
||||
const rows: FracRow[] = [];
|
||||
const got: Values = {};
|
||||
const name = (x: string): string => alias[x] ?? x;
|
||||
const own = name(id);
|
||||
const take = (nm: string, sub: Node): Node | null => {
|
||||
if (!nm || taken.has(nm) || nm in got) return null;
|
||||
const v = evalNode(sub, { ...vals, ...got });
|
||||
if (!numOk(v)) return null;
|
||||
got[nm] = v;
|
||||
rows.push({ id: nm, node: sub });
|
||||
return ["var", nm];
|
||||
};
|
||||
// 감싸개(버림 등)는 그대로 두고 안쪽만
|
||||
const inner = (x: Node, fn: (y: Node) => Node): Node =>
|
||||
hidden(x) ? ["call", x[1], [inner(x[2][0], fn), ...x[2].slice(1)]] : fn(x);
|
||||
let node = inner(regroup(n), (x) => {
|
||||
// ② 분자 · 분모 짝(곱 사슬의 분수 항)
|
||||
const factors = (y: Node): Node[] =>
|
||||
y[0] === "bin" && y[1] === "*" ? [...factors(y[2]), y[3]] : [y];
|
||||
const parts = factors(x).map((f) => {
|
||||
if (!isFrac(f) || f[2][0] !== "var" || f[3][0] !== "var") return f;
|
||||
const a = /^(.+)분자$/.exec(name(f[2][1]));
|
||||
const b = /^(.+)분모$/.exec(name(f[3][1]));
|
||||
return a && b && a[1] === b[1] && a[1] !== own ? (take(a[1], f) ?? f) : f;
|
||||
});
|
||||
return product(parts);
|
||||
});
|
||||
// ③ 분수 안 덧셈 묶음이 분수를 품음 = 중간 줄(사전 이름 있을 때만)
|
||||
if (part && fracDepth(node) > 1) {
|
||||
const dig = (x: Node, inFrac: boolean): Node => {
|
||||
if (x[0] === "bin" && (x[1] === "+" || x[1] === "-") && inFrac && fracDepth(x) >= 1)
|
||||
return take(part, x) ?? x;
|
||||
if (x[0] === "bin") return ["bin", x[1], dig(x[2], inFrac || x[1] === "/"), dig(x[3], inFrac || x[1] === "/")]; // prettier-ignore
|
||||
if (x[0] === "call") return ["call", x[1], x[2].map((y) => dig(y, inFrac))];
|
||||
if (x[0] === "neg" || x[0] === "pct") return [x[0], dig(x[1], inFrac)] as Node;
|
||||
return x;
|
||||
};
|
||||
node = dig(node, false);
|
||||
}
|
||||
return { rows, node, vals: got };
|
||||
}
|
||||
@@ -250,6 +250,3 @@ export function aliasText(text: string, alias: Record<string, string>, back = fa
|
||||
t = String(back ? renameText(t, now, id) : renameText(t, id, now));
|
||||
return t;
|
||||
}
|
||||
|
||||
/** 표 칸 단위 「줄 따름」(줄마다 다른 단위) = 화면에 안 보임(요구 683) */
|
||||
export const shownUnit = (u: unknown): string => (u === "줄 따름" ? "" : String(u ?? ""));
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/* =============================================================================
|
||||
* common_util_line_stack_unit.ts
|
||||
* 단가산출 단위 그리는 규칙 한 벌(PLAN 845-2 · 요구 880) — 왼쪽 편집 · 오른쪽 산출근거 · 기계 제어 구역 · 기계 보기가 같이 씀
|
||||
* ㉠ 값 줄 = 값 뒤 단위(`varUnit`) ㉡ 식 줄 = 결과 값 뒤만(식 가운데 숫자 뒤 없음)
|
||||
* ㉢ 비목 · 금액 줄 = 「단가 원/단위 × (수량 식) 단위 = 금액 원」(`moneyUnits` — 수량이 식이면 괄호로 식 전체에 단위) ·
|
||||
* 소계 · 합계 = 원(머리 [원/㎥] = 결과 하나당)
|
||||
* ㉣ 무차원 = 빈 칸 ㉤ 율 = %(「%(인력품 대비)」 등 설명 괄호 뗌) ㉥ 왼쪽 값 칸 = 칸 바로 뒤 한 자리
|
||||
* 메타 단위(「구분 따름」 · 「줄 따름」 · 「수(…)」 · 「원문 단위 없음」) = 단위 아님 → 안 그림 · 「10^-7」 = 10⁻⁷
|
||||
* 기계 칸 연결(요소 `EQ:` 칸) 변수 = 변수 단위가 빈 칸이면 기계 표 칸 단위(`MACHINE_UNIT` — 기계 표 빌더 · 내 기계와 같은 글)
|
||||
* 그리기만 — 자료 · 식 · 금액 불변 · 화면 없음(시험이 Node 로 돌림)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DocVar, MoneyLine, Node } from "./common_util_formula_doc";
|
||||
|
||||
/** 기계 표 칸 단위(`build_기계_건설품셈.py` UNIT · `M02_MyMachine.py` UNITS 와 같은 글) */
|
||||
export const MACHINE_UNIT: Record<string, string> = {
|
||||
가격: "천원",
|
||||
내용시간: "시간",
|
||||
연간표준가동시간: "시간",
|
||||
상각비계수: "10^-7/시간",
|
||||
정비비계수: "10^-7/시간",
|
||||
관리비계수: "10^-7/시간",
|
||||
손료계수: "10^-7/시간",
|
||||
주연료: "ℓ/hr",
|
||||
전력: "kWh/hr",
|
||||
잡재료율: "%",
|
||||
잡품: "%",
|
||||
조종원: "인/일",
|
||||
인력: "인/일",
|
||||
노무비: "원/시간",
|
||||
재료비: "원/시간",
|
||||
경비: "원/시간",
|
||||
};
|
||||
|
||||
/** 금액 · 소계 · 합계 단위 */
|
||||
export const AMOUNT_UNIT = "원";
|
||||
|
||||
/** 자료 단위 글 → 화면 단위(메타 = 빈 글) */
|
||||
export function unitText(u: unknown): string {
|
||||
const s = String(u ?? "").trim();
|
||||
if (!s || /따름|원문 단위 없음/.test(s) || /^수(\(| 또는|$)/.test(s)) return "";
|
||||
return s.replace(/^%\(.*\)$/, "%").replace(/10\^-7/g, "10⁻⁷");
|
||||
}
|
||||
|
||||
/** 기계 칸 이름의 화면 단위 */
|
||||
export const machineUnit = (col: string): string => unitText(MACHINE_UNIT[col]);
|
||||
|
||||
/** 변수 화면 단위 — 변수 단위 › 기계 칸 연결이면 기계 표 칸 단위 */
|
||||
export function varUnit(v: DocVar | undefined): string {
|
||||
const own = unitText(v?.단위);
|
||||
if (own) return own;
|
||||
const k = v?.연결;
|
||||
return k?.갈래 === "요소" && /^EQ:/.test(String(k.키 ?? "")) && k.칸 ? machineUnit(k.칸) : "";
|
||||
}
|
||||
|
||||
const HIDE = new Set(["반올림", "올림", "버림"]);
|
||||
/** 한 덩이(기호 · 수 · 칸 — 감싸개 안 한 덩이 포함) */
|
||||
export function isAtom(n: Node | null | undefined): boolean {
|
||||
if (!n) return false;
|
||||
if (n[0] === "call" && HIDE.has(n[1]) && n[2].length >= 1 && n[2].length <= 2)
|
||||
return isAtom(n[2][0]);
|
||||
return n[0] === "var" || n[0] === "num" || n[0] === "field" || n[0] === "str";
|
||||
}
|
||||
|
||||
export interface MoneyUnits {
|
||||
/** 단가 뒤(원/시간 · 원/인 …) — 단가가 한 덩이이고 수량이 있을 때만 */
|
||||
price: string;
|
||||
/** 수량 식 뒤 */
|
||||
qty: string;
|
||||
/** 수량 식을 괄호로 감쌈(식이고 단위가 있을 때) */
|
||||
wrap: boolean;
|
||||
}
|
||||
|
||||
/** 금액 줄 단위 자리(㉢) — `priceVar` = 단가 기호 변수 · `sub` = 묶음 · 단가산출 줄이 부르는 문서 */
|
||||
export function moneyUnits(
|
||||
line: MoneyLine,
|
||||
price: Node | null,
|
||||
qty: Node | null,
|
||||
priceVar?: DocVar,
|
||||
sub?: { 결과단위?: string } | null,
|
||||
): MoneyUnits {
|
||||
const has = line.수량 !== undefined && line.수량 !== null && line.수량 !== "";
|
||||
const q = has ? unitText(line.단위) : "";
|
||||
const wrap = !!q && !!qty && !isAtom(qty);
|
||||
const own = [sub?.결과단위, priceVar?.단위].map(unitText).find((u) => u.startsWith("원"));
|
||||
const p = has && isAtom(price) ? (own ?? (q ? `${AMOUNT_UNIT}/${q}` : "")) : "";
|
||||
return { price: p, qty: q, wrap };
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
* 기계경비식 밖 문서 · 사용자가 손으로 바꾼 기호(사전에 없는 꼴)는 그대로 · 이름이 겹치면 그 기호만 그대로
|
||||
* 화면 없음(시험이 Node 로 돌림) · 기계 제어 구역(836-1)도 `machineAlias` 로 같은 이름
|
||||
* 인력 · 잡품 줄 여럿(842-3 — 덮음이 더한 `w_조종원N` · `P_잡품N`) = 줄마다 「인력N」 · 「잡품N」
|
||||
* 겹분수 중간 줄 이름(845-2 `partName`) — 산출근거가 손료 식을 「손료계수」 줄 + 「손료」 줄로 나눠 그림
|
||||
* ========================================================================== */
|
||||
|
||||
import type { FormulaDoc } from "./common_util_formula_doc";
|
||||
@@ -133,3 +134,7 @@ export function machineAlias(doc: FormulaDoc): Record<string, string> {
|
||||
|
||||
/** 기계 칸 이름(상각비계수 …) → 짧은 이름 · 없으면 그대로 — 기계 제어 구역(836-1) 값 칸 이름표 */
|
||||
export const machineCellName = (col: string): string => CELL[col] ?? col;
|
||||
|
||||
/** 겹분수 중간 줄 이름(845-2 · `splitFrac` ③) — 손료 식 분자 안 「(상각 + 정비) × (1 + 가산 ÷ 100) + 관리」 */
|
||||
const PART: Record<string, string> = { C_손료: "손료계수" };
|
||||
export const partName = (id: string): string | undefined => PART[id];
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
/* 845-2 단위 표시 지킴 헬퍼 — 산출근거(buildOut) · 왼쪽 편집(buildEdit)을 가짜 DOM 으로 차려 줄 글을 냄(화면 없음 · 진짜 코드) */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
|
||||
|
||||
class FakeNode {
|
||||
constructor(tag) {
|
||||
this.tagName = String(tag).toUpperCase();
|
||||
this.childNodes = [];
|
||||
this.attributes = {};
|
||||
this.dataset = {};
|
||||
this.style = { setProperty: (k, v) => (this.style[k] = v) };
|
||||
this.className = "";
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...c) => c.forEach((x) => !self.cls().includes(x) && (self.className = `${self.className} ${x}`.trim())),
|
||||
remove: (...c) => (self.className = self.cls().filter((x) => !c.includes(x)).join(" ")),
|
||||
toggle: (c, on) => {
|
||||
const has = self.cls().includes(c);
|
||||
const want = on === undefined ? !has : !!on;
|
||||
if (want && !has) self.classList.add(c);
|
||||
if (!want && has) self.classList.remove(c);
|
||||
return want;
|
||||
},
|
||||
contains: (c) => self.cls().includes(c),
|
||||
};
|
||||
}
|
||||
cls() {
|
||||
return String(this.className || "").split(/\s+/).filter(Boolean);
|
||||
}
|
||||
get children() {
|
||||
return this.childNodes.filter((x) => x instanceof FakeNode);
|
||||
}
|
||||
get firstElementChild() {
|
||||
return this.children[0] ?? null;
|
||||
}
|
||||
get firstChild() {
|
||||
return this.childNodes[0] ?? null;
|
||||
}
|
||||
append(...xs) {
|
||||
for (const x of xs) {
|
||||
if (x === null || x === undefined) continue;
|
||||
this.childNodes.push(x instanceof FakeNode ? x : new FakeText(String(x)));
|
||||
}
|
||||
}
|
||||
appendChild(x) {
|
||||
this.append(x);
|
||||
return x;
|
||||
}
|
||||
prepend(...xs) {
|
||||
const keep = this.childNodes;
|
||||
this.childNodes = [];
|
||||
this.append(...xs);
|
||||
this.childNodes.push(...keep);
|
||||
}
|
||||
insertBefore(x) {
|
||||
this.prepend(x);
|
||||
return x;
|
||||
}
|
||||
replaceChildren(...xs) {
|
||||
this.childNodes = [];
|
||||
this.append(...xs);
|
||||
}
|
||||
remove() {}
|
||||
set textContent(t) {
|
||||
this.childNodes = t === "" || t === null || t === undefined ? [] : [new FakeText(String(t))];
|
||||
}
|
||||
get textContent() {
|
||||
return this.childNodes.map((c) => c.textContent).join("");
|
||||
}
|
||||
set innerHTML(t) {
|
||||
this.textContent = String(t).replace(/<[^>]*>/g, "");
|
||||
}
|
||||
get innerHTML() {
|
||||
return this.textContent;
|
||||
}
|
||||
setAttribute(k, v) {
|
||||
this.attributes[k] = String(v);
|
||||
}
|
||||
getAttribute(k) {
|
||||
return this.attributes[k] ?? null;
|
||||
}
|
||||
removeAttribute(k) {
|
||||
delete this.attributes[k];
|
||||
}
|
||||
hasAttribute(k) {
|
||||
return k in this.attributes;
|
||||
}
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
dispatchEvent() {
|
||||
return true;
|
||||
}
|
||||
focus() {}
|
||||
blur() {}
|
||||
click() {}
|
||||
closest() {
|
||||
return null;
|
||||
}
|
||||
contains() {
|
||||
return false;
|
||||
}
|
||||
getBoundingClientRect() {
|
||||
return { x: 0, y: 0, width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0 };
|
||||
}
|
||||
querySelector(s) {
|
||||
return this.querySelectorAll(s)[0] ?? null;
|
||||
}
|
||||
querySelectorAll(s) {
|
||||
const m = /^\.([\w-]+)$/.exec(s);
|
||||
const tag = /^[a-z]+$/i.test(s) ? s.toUpperCase() : null;
|
||||
const out = [];
|
||||
const walk = (n) => {
|
||||
for (const c of n.children) {
|
||||
if ((m && c.cls().includes(m[1])) || (tag && c.tagName === tag)) out.push(c);
|
||||
walk(c);
|
||||
}
|
||||
};
|
||||
walk(this);
|
||||
return out;
|
||||
}
|
||||
cloneNode() {
|
||||
return new FakeNode(this.tagName);
|
||||
}
|
||||
}
|
||||
class FakeText {
|
||||
constructor(t) {
|
||||
this.textContent = t;
|
||||
}
|
||||
}
|
||||
|
||||
function install() {
|
||||
globalThis.Node = FakeNode;
|
||||
globalThis.HTMLElement = FakeNode;
|
||||
globalThis.Element = FakeNode;
|
||||
globalThis.document = {
|
||||
createElement: (t) => new FakeNode(t),
|
||||
createElementNS: (_, t) => new FakeNode(t),
|
||||
createTextNode: (t) => new FakeText(t),
|
||||
createDocumentFragment: () => new FakeNode("fragment"),
|
||||
body: new FakeNode("body"),
|
||||
documentElement: new FakeNode("html"),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
globalThis.window = globalThis;
|
||||
globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} };
|
||||
globalThis.sessionStorage = globalThis.localStorage;
|
||||
globalThis.requestAnimationFrame = (f) => setTimeout(f, 0);
|
||||
globalThis.matchMedia = () => ({ matches: false, addEventListener() {} });
|
||||
globalThis.getComputedStyle = () => ({ getPropertyValue: () => "" });
|
||||
globalThis.fetch = () => Promise.reject(new Error("no fetch"));
|
||||
globalThis.navigator ??= { language: "ko" };
|
||||
}
|
||||
|
||||
/** 모듈 로더 — stubs[이름 끝] 가 있으면 그것 · css = 빈 것 · @ui/@config 길 */
|
||||
function makeLoader(stubs = {}) {
|
||||
const cache = new Map();
|
||||
const resolve = (from, p) => {
|
||||
if (p.startsWith("@ui/")) return path.join(ROOT, "ui_template", `${p.slice(4)}.ts`);
|
||||
if (p.startsWith("@config/")) return path.join(ROOT, "config", `${p.slice(8)}.ts`);
|
||||
if (p.startsWith(".")) {
|
||||
const at = path.resolve(path.dirname(from), p);
|
||||
return at.endsWith(".css") ? at : `${at}.ts`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
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) => {
|
||||
if (p.endsWith(".css")) return {};
|
||||
const name = path.basename(p);
|
||||
if (stubs[name]) return stubs[name];
|
||||
const r = resolve(at, p);
|
||||
if (!r) {
|
||||
if (stubs[p]) return stubs[p];
|
||||
return require(p);
|
||||
}
|
||||
return load(r);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, need);
|
||||
return box.exports;
|
||||
}
|
||||
return (rel) => load(path.join(ROOT, rel));
|
||||
}
|
||||
|
||||
/** 요소 → 읽는 글(조판 = ⟦TeX⟧ · 입력 칸 = [값] · 단위 꼬리 = {단위}) */
|
||||
function textOf(n) {
|
||||
if (n === null || n === undefined) return "";
|
||||
if (n instanceof FakeText) return n.textContent;
|
||||
if (typeof n === "string") return n;
|
||||
if (n.tex !== undefined) return `⟦${n.tex}⟧`;
|
||||
if (n.tagName === "INPUT") return `[${n.value ?? ""}]`;
|
||||
if (n.tagName === "SELECT") return `[${n.value ?? ""}▾]`;
|
||||
return n.childNodes.map(textOf).join("");
|
||||
}
|
||||
|
||||
|
||||
// ── 할일 ── stdin = {할일, 문서들 [{키, 문서, 연결}], 보기: [키]} · stdout = 할일 답
|
||||
install();
|
||||
const noop = () => undefined;
|
||||
const load = makeLoader({
|
||||
katex: { render: (t, box) => (box.tex = t) },
|
||||
formula_doc_options: { machineText: () => null, optionText: (x) => x, liveOptions: (l) => l, nameMachines: noop },
|
||||
M02_DF_UnitPick: {
|
||||
NO_UNIT: "(단위 없음)",
|
||||
createUnitPick: (o) => Object.assign(new FakeNode("select"), { value: o.value }),
|
||||
},
|
||||
M02_DF_Api: { cfMachineNames: async () => ({}), cfMachineOff: async () => [] },
|
||||
M02_MyMachine_Text: { mt: (k) => k },
|
||||
M02_MasterTemplete_Find_Pick: { openFindPicker: noop },
|
||||
line_stack_pick: { openStackPick: noop, openTablePick: noop },
|
||||
line_stack_machine: { openMachineWindow: noop },
|
||||
line_stack_material: { materialMark: () => null, matEntry: () => null, askFileWide: async () => undefined },
|
||||
line_stack_drag: { attachDrag: noop },
|
||||
});
|
||||
const out = load("A00_Common/line_stack_out.ts");
|
||||
const edit = load("A00_Common/line_stack_edit.ts");
|
||||
const opts = load("A00_Common/formula_doc_options.ts");
|
||||
const ls = load("common_util/common_util_line_stack.ts");
|
||||
const unit = load("common_util/common_util_line_stack_unit.ts");
|
||||
const cacheMod = load("common_util/common_util_formula_doc_cache.ts");
|
||||
const calc = load("common_util/common_util_formula_doc_calc.ts");
|
||||
const all = { has: () => true, add: noop, delete: noop };
|
||||
|
||||
/** \dfrac 겹 층 */
|
||||
function depth(s) {
|
||||
const group = (i) => {
|
||||
let d = 0;
|
||||
for (let k = i; k < s.length; k++) {
|
||||
if (s[k] === "{") d++;
|
||||
else if (s[k] === "}" && --d === 0) return k;
|
||||
}
|
||||
return s.length - 1;
|
||||
};
|
||||
let best = 0;
|
||||
let i = 0;
|
||||
while ((i = s.indexOf("\\dfrac{", i)) >= 0) {
|
||||
const a1 = group(i + 6);
|
||||
const b1 = group(a1 + 1);
|
||||
best = Math.max(best, 1 + Math.max(depth(s.slice(i + 7, a1)), depth(s.slice(a1 + 2, b1))));
|
||||
i = b1 + 1;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function right(c) {
|
||||
const cache = cacheMod.createDocCache();
|
||||
cache.merge(c.연결 || {});
|
||||
const a = calc.calcDoc(c.문서, cache.links, {});
|
||||
const root = out.buildOut(c.문서, a, { cache, open: all, toggle: noop, miss: noop, redraw: noop });
|
||||
return root
|
||||
.querySelectorAll("tr")
|
||||
.filter((tr) => tr.cls().includes("lstk-orow"))
|
||||
.map((tr) => ({ cls: tr.cls().join(" "), t: textOf(tr.children[0]) }));
|
||||
}
|
||||
|
||||
function left(c) {
|
||||
const cache = cacheMod.createDocCache();
|
||||
cache.merge(c.연결 || {});
|
||||
const doc = c.문서;
|
||||
const a = calc.calcDoc(doc, cache.links, {});
|
||||
const sub = (k) => {
|
||||
try {
|
||||
return cache.links.doc(k) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const root = edit.buildEdit({
|
||||
doc, answer: a, items: ls.stackOf(doc), change: noop, table: async () => null, subDoc: sub,
|
||||
name: () => "", open: new Set((doc.금액 || []).map((_, i) => `mctl:${i}`)), pair: noop,
|
||||
machine: { scope: "x" }, calc: (d, g) => calc.calcDoc(d, cache.links, g, [], new Map(), {}), lists: () => null,
|
||||
}); // prettier-ignore
|
||||
const rows = [];
|
||||
const walk = (n) => {
|
||||
for (const ch of n.children) {
|
||||
if (ch.cls().includes("lstk-frow")) rows.push({ sym: ch.dataset.sym ?? ch.dataset.mctlField ?? "", t: textOf(ch) });
|
||||
walk(ch);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return [...opts.buildOptions(doc, {}, true, noop).map((x) => ({ sym: `opt:${x.dataset.option}`, t: textOf(x) })), ...rows];
|
||||
}
|
||||
|
||||
/** 전수 지킴 — 줄 글에서 규칙 어긋남만 */
|
||||
const MONEY = /^(.+?) : (?!—)(.*\S)$/;
|
||||
function guard(c, machine) {
|
||||
const before = JSON.stringify(c.문서);
|
||||
const rows = right(c);
|
||||
const bad = [];
|
||||
let sub = false;
|
||||
for (const r of rows) {
|
||||
if (r.cls.includes("subhead")) sub = machine.has(r.t.replace(/ \[[^\]]*\]$/, ""));
|
||||
const texs = [...r.t.matchAll(/⟦([^⟧]*)⟧/g)].map((m) => m[1]);
|
||||
if ((r.cls.includes("is-sub") ? sub : c.기계) && Math.max(0, ...texs.map(depth)) > 1) bad.push(["겹분수", r.t]);
|
||||
// 머리 [결과 단위] = 문서 결과단위 글 그대로(자료 몫 — 「원(원문 단위 없음)」 등 설명 포함)
|
||||
if (!/head/.test(r.cls) && /따름|원문 단위 없음|수\(단위/.test(r.t)) bad.push(["메타 단위", r.t]);
|
||||
const m = MONEY.exec(r.t);
|
||||
if (m && !/^\d+\. /.test(r.t) && !/(원|원\/\S+)$/.test(m[2]) && /\d$/.test(m[2])) bad.push(["금액 원 없음", r.t]);
|
||||
const q = / × ⟦([^⟧]*)⟧ (\S+) = /.exec(r.t);
|
||||
if (q && /\\times|\\div|\+/.test(q[1]) && !q[1].startsWith("\\left(")) bad.push(["수량 식 괄호 없음", r.t]);
|
||||
}
|
||||
return { bad, 같음: JSON.stringify(c.문서) === before };
|
||||
}
|
||||
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const show = new Set(input.보기 || []);
|
||||
const machineNames = new Set(input.기계이름 || []);
|
||||
let res;
|
||||
if (input.할일 === "전수") {
|
||||
res = input.문서들.map((c) => {
|
||||
const g = guard(c, machineNames);
|
||||
return { 키: c.키, ...g, ...(show.has(c.키) ? { 오른쪽: right(c), 왼쪽: left(c) } : {}) };
|
||||
});
|
||||
} else if (input.할일 === "단위") {
|
||||
res = {
|
||||
글: input.글.map((u) => unit.unitText(u)),
|
||||
변수: input.변수.map((v) => unit.varUnit(v)),
|
||||
금액: input.금액.map(([line, price, qty, v, s]) => unit.moneyUnits(line, price, qty, v, s)),
|
||||
};
|
||||
}
|
||||
process.stdout.write(JSON.stringify(res));
|
||||
@@ -0,0 +1,122 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 845-2(요구 879 · 880) — 단가산출 기초 단위 표시 규칙 한 벌 · 기계 산출근거 겹분수 행 나눔(그림만).
|
||||
|
||||
① 전수 1,520 — 산출근거(펼침 모두)를 진짜 코드로 차린 줄 글:
|
||||
금액 · 소계 · 합계 = 「원」 · 수량이 식이면 괄호로 식 전체에 단위(「÷ 560 시간」 꼴 없음) ·
|
||||
메타 단위(「… 따름」 · 「수(…)」) 글 0 · 기계경비식(맨 위 · 펼침) 분수 한 층 · 문서 자료 안 바뀜
|
||||
② GC000088 — 사용자 그림 자리: 「59,020 원/시간 × (1 × 8 ÷ 560) 시간 = 843.1 원」 · 펼친 굴착기 = 상여 · 휴지 · 손료계수 중간 줄
|
||||
③ 왼쪽 — 기계 제어 구역 값 칸 단위 = 오른쪽 펼침과 같은 글(천원 · 10⁻⁷/시간 · ℓ/hr) · 단위는 칸 밖 바로 뒤
|
||||
④ 단위 규칙 낱개 — 메타 · 설명 괄호 · 10⁻⁷ · 기계 칸 연결 · 금액 줄 자리
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01 # noqa: F401 (scripts 길)
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
|
||||
mr = importlib.import_module("master_doc_record")
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
MACHINE = "기계경비식"
|
||||
|
||||
|
||||
def _js(payload: dict):
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_845_2_unit_show.cjs")],
|
||||
input=json.dumps(dc.ml._norm(payload), ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr[-3000:]
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def world():
|
||||
docs = dc.folder()
|
||||
links = dc.ml.Links(dc.skc.whole_(), docs)
|
||||
cases = []
|
||||
for key, doc in sorted(docs.items()):
|
||||
rec = mr.Rec(links)
|
||||
rec.prefetch(doc)
|
||||
dc.mc.calc(doc, rec)
|
||||
cases.append(
|
||||
{
|
||||
"키": key,
|
||||
"문서": doc,
|
||||
"연결": rec.out(),
|
||||
"기계": (doc.get("부품") or {}).get("갈래") == MACHINE,
|
||||
}
|
||||
)
|
||||
names = [c["문서"].get("이름") for c in cases if c["기계"]]
|
||||
got = _js({"할일": "전수", "문서들": cases, "보기": ["GC000088"], "기계이름": names})
|
||||
return {g["키"]: g for g in got}
|
||||
|
||||
|
||||
def test_전수_규칙_어긋남_0(world):
|
||||
assert len(world) >= 1513
|
||||
bad = {k: g["bad"][:2] for k, g in world.items() if g["bad"]}
|
||||
assert bad == {}
|
||||
assert all(g["같음"] for g in world.values()) # 그리기만 — 문서 자료 안 바뀜
|
||||
|
||||
|
||||
def test_GC000088_비목_줄_단위_자리(world):
|
||||
rows = [r["t"] for r in world["GC000088"]["오른쪽"]]
|
||||
labor = next(t for t in rows if t.startswith("노무비 : ") and "×" in t)
|
||||
assert labor.startswith("노무비 : 59,020 원/시간 × ⟦\\left(1 \\times 8 \\div 560\\right)⟧ 시간 = ")
|
||||
assert labor.endswith(" 원")
|
||||
assert any(t.startswith("소 계 : ") and t.endswith(" 원") for t in rows)
|
||||
assert any(t.startswith("계 : ") and t.endswith(" 원") for t in rows)
|
||||
# 펼친 굴착기 시간당 경비 — 겹분수 대신 중간 줄
|
||||
sub = "\n".join(rows)
|
||||
assert "⟦\\text{상여}⟧ = ⟦\\dfrac{\\text{상여분자}}{\\text{상여분모}}⟧ = ⟦\\dfrac{16}{12}⟧" in sub
|
||||
assert "⟦\\text{휴지}⟧ = ⟦\\dfrac{\\text{휴지분자}}{\\text{휴지분모}}⟧ = ⟦\\dfrac{25}{20}⟧" in sub
|
||||
assert "\\dfrac{1}{8} \\times 1.333333 \\times 1.25⟧ = ⟦0.208333" in sub
|
||||
assert "⟦\\text{손료계수}⟧ = " in sub and "= ⟦2{,}085\\ \\text{10⁻⁷/시간}⟧" in sub
|
||||
assert "\\dfrac{142{,}069 \\times 1{,}000 \\times 2{,}085}{10{,}000{,}000}⟧ = ⟦29{,}621" in sub
|
||||
assert "⟦\\text{가격}⟧ = 142,069 천원" in sub
|
||||
assert "⟦\\text{직종}⟧ = 1048" in sub # 코드 값 = 쉼표 없음
|
||||
assert "조종원 : ⟦283{,}297⟧ 원/인 × ⟦\\left(1 \\times 0.208333\\right)⟧ 인 = 59,020.2 원" in sub
|
||||
|
||||
|
||||
def test_왼쪽_제어_구역_단위_오른쪽과_같은_글(world):
|
||||
rows = {r["sym"]: r["t"] for r in world["GC000088"]["왼쪽"]}
|
||||
assert rows["가격"] == "가격=[142069]천원"
|
||||
assert rows["상각비계수"] == "상각=[900]10⁻⁷/시간"
|
||||
assert rows["주연료"] == "주연료=[19.5]ℓ/hr"
|
||||
assert rows["Q_시공^표"].startswith("Q_시공2=[560]㎥")
|
||||
|
||||
|
||||
def test_단위_규칙_낱개():
|
||||
got = _js(
|
||||
{
|
||||
"할일": "단위",
|
||||
"글": ["구분 따름", "줄 따름", "수(단위는 줄의 단위 칸 따름)", "수", "원문 단위 없음",
|
||||
"%(주연료비의)", "10^-7/시간", "㎥/hr", "인", ""], # fmt: skip
|
||||
"변수": [
|
||||
{"단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
|
||||
{"단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "잡재료율"}},
|
||||
{"단위": "%", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
|
||||
{"단위": "", "연결": {"갈래": "요소", "키": "LB:{S_직종}"}},
|
||||
],
|
||||
"금액": [
|
||||
[{"단위": "시간", "수량": "Q × 8 ÷ Q2"}, ["var", "P"], ["bin", "/", ["var", "a"], ["var", "b"]], {"단위": ""}, {"결과단위": "원/시간"}], # fmt: skip
|
||||
[{"단위": "인", "수량": "Q"}, ["var", "w"], ["var", "Q"], {"단위": ""}, None],
|
||||
[{"단위": "식"}, ["bin", "*", ["var", "a"], ["var", "b"]], None, None, None],
|
||||
],
|
||||
}
|
||||
)
|
||||
assert got["글"] == ["", "", "", "", "", "%", "10⁻⁷/시간", "㎥/hr", "인", ""]
|
||||
assert got["변수"] == ["천원", "%", "%", ""]
|
||||
assert got["금액"] == [
|
||||
{"price": "원/시간", "qty": "시간", "wrap": True},
|
||||
{"price": "원/인", "qty": "인", "wrap": False},
|
||||
{"price": "", "qty": "", "wrap": False},
|
||||
]
|
||||
Reference in New Issue
Block a user