Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"""구조물도 식 풀이 — 서버가 부르는 **껍데기**. 계산은 여기 없음.
|
||||
|
||||
풀이는 화면이 쓰는 `B08_Quantity_Formula.ts` 한 벌을 Node 로 돌림(판정 Ⓐ, 2026-09-13 ·
|
||||
명세 13장). 파이썬으로 다시 짜면 반올림이 1원에서 조용히 갈리므로 두 벌을 두지 않음.
|
||||
B06 `B06_Section_Server_Calc_Prebuild.py` 와 같은 본(`common_util_node_bundle`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BUNDLE = ROOT / "config" / "formula_node" / "B08_Quantity_Formula_Node.js"
|
||||
_NPM_SCRIPT = "build:formula"
|
||||
|
||||
|
||||
def evaluate_sheets(sheets: list[dict[str, Any]]) -> list[list[dict[str, Any]]] | None:
|
||||
"""장 여러 벌을 한 번에 풂 — 줄마다 `amount`(반올림 뒤)·`raw`·`error`.
|
||||
|
||||
Node 가 못 돌면 `None` — 값을 지어내지 않고 부르는 쪽이 「못 풂」으로 드러냄.
|
||||
"""
|
||||
output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"sheets": sheets})
|
||||
if not isinstance(output, dict) or not isinstance(output.get("sheets"), list):
|
||||
return None
|
||||
return output["sheets"]
|
||||
@@ -0,0 +1,513 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_Formula.ts
|
||||
* 구조물도 **식 칸 풀이기** — 명세 13장(`docs/raw/verification/2026-09-13_축C_명세.md`) 한 벌.
|
||||
*
|
||||
* 화면(조작 중 즉시)과 서버(`B08_Quantity_Formula_Node.ts` — [저장]·[확정] 재계산)가
|
||||
* **이 파일 하나**를 같이 씀(판정 Ⓐ, 2026-09-13). 두 벌로 짜면 반올림이 1원에서 조용히 갈림.
|
||||
*
|
||||
* ⚠ **수는 BigInt 분수**로 듦 — 부동소수는 1.15×100 = 114.999… 라 버림이 114 로 틀어짐.
|
||||
* 실무 엑셀이 `INT(x*100)/100` 으로 뜻한 값은 십진 값의 버림이라 분수로 풀어야 맞음.
|
||||
* ⚠ **적힌 차례대로 풂** — 식을 미리 접거나 바꿔 쓰지 않음(명세 13장 지킬 것 ①).
|
||||
* `267360*5/24` 는 55700, `267360*0.20833` 은 적힌 상수대로 55699.1088 이 남.
|
||||
* ⚠ 무리수만 끊어 분수로 되돌림 — SQRT 는 소수 30자리 버림, 소수 거듭제곱은 유효 17자리.
|
||||
* ⚠ `refs` 는 **앞 줄만** — 뒷줄·자기 줄을 가리키면 오류(순환이 원리적으로 불가).
|
||||
* ⚠ `eval` 을 쓰지 않음 — 식은 사용자·라이브러리에서 오는 글이라 직접 짠 파서로만 읽음.
|
||||
* ========================================================================== */
|
||||
|
||||
export type RoundingMode = "floor" | "round" | "ceil" | "none" | "round_half_even";
|
||||
|
||||
export interface FormulaRounding {
|
||||
mode: RoundingMode;
|
||||
digits: number;
|
||||
}
|
||||
|
||||
/** 식 칸 한 줄 — 명세 13장 「한 줄이 들고 갈 칸」. */
|
||||
export interface FormulaRow {
|
||||
seq: number;
|
||||
name: string;
|
||||
spec?: string;
|
||||
/** 기계가 푸는 식(정본). 비면 고정형 — `amount` 를 박힌 값으로 씀. */
|
||||
formula?: string | null;
|
||||
formula_text?: string;
|
||||
refs?: Record<string, number>;
|
||||
vars?: Record<string, number | string>;
|
||||
amount?: string | number | null;
|
||||
unit?: string;
|
||||
rounding?: FormulaRounding | null;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/** LOOKUP 표 — `keys` 는 **오름차순**이어야 함(근사 일치의 전제). */
|
||||
export interface LookupTable {
|
||||
keys: (number | string)[];
|
||||
columns: Record<string, (number | string | null)[]>;
|
||||
}
|
||||
|
||||
export interface FormulaSheet {
|
||||
rows: FormulaRow[];
|
||||
/** 장 전체에 걸리는 제원 — 줄의 `vars` 가 같은 이름이면 줄이 이김. */
|
||||
vars?: Record<string, number | string>;
|
||||
tables?: Record<string, LookupTable>;
|
||||
}
|
||||
|
||||
export interface FormulaRowResult {
|
||||
seq: number;
|
||||
name: string;
|
||||
/** 반올림 뒤 값(십진 문자열). 오류면 `null`. */
|
||||
amount: string | null;
|
||||
/** 반올림 전 값 — 어느 자리에서 갈렸는지 되짚는 용. */
|
||||
raw: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 분수 */
|
||||
|
||||
const DIGITS = 30;
|
||||
const TEN = 10n;
|
||||
|
||||
interface Frac {
|
||||
n: bigint;
|
||||
d: bigint;
|
||||
}
|
||||
|
||||
function abs(x: bigint): bigint {
|
||||
return x < 0n ? -x : x;
|
||||
}
|
||||
|
||||
function gcd(a: bigint, b: bigint): bigint {
|
||||
a = abs(a);
|
||||
b = abs(b);
|
||||
while (b) [a, b] = [b, a % b];
|
||||
return a || 1n;
|
||||
}
|
||||
|
||||
function frac(n: bigint, d = 1n): Frac {
|
||||
if (d === 0n) throw new FormulaError("0 으로 나눔");
|
||||
if (d < 0n) [n, d] = [-n, -d];
|
||||
const g = gcd(n, d);
|
||||
return { n: n / g, d: d / g };
|
||||
}
|
||||
|
||||
const add = (a: Frac, b: Frac): Frac => frac(a.n * b.d + b.n * a.d, a.d * b.d);
|
||||
const sub = (a: Frac, b: Frac): Frac => frac(a.n * b.d - b.n * a.d, a.d * b.d);
|
||||
const mul = (a: Frac, b: Frac): Frac => frac(a.n * b.n, a.d * b.d);
|
||||
const div = (a: Frac, b: Frac): Frac => {
|
||||
if (b.n === 0n) throw new FormulaError("0 으로 나눔");
|
||||
return frac(a.n * b.d, a.d * b.n);
|
||||
};
|
||||
const cmp = (a: Frac, b: Frac): number => {
|
||||
const left = a.n * b.d;
|
||||
const right = b.n * a.d;
|
||||
return left === right ? 0 : left < right ? -1 : 1;
|
||||
};
|
||||
|
||||
/** 십진 문자열(`-12.5`, `3`, `1e-3`)을 분수로. */
|
||||
function parseDecimal(text: string): Frac {
|
||||
const match = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(text.trim());
|
||||
if (!match || (match[2] === "" && (match[3] ?? "") === "")) {
|
||||
throw new FormulaError(`수로 읽지 못함: ${text}`);
|
||||
}
|
||||
const [, sign, whole, fraction = "", exponent = "0"] = match;
|
||||
let n = BigInt((whole || "0") + fraction);
|
||||
let d = TEN ** BigInt(fraction.length);
|
||||
const e = Number(exponent);
|
||||
if (e > 0) n *= TEN ** BigInt(e);
|
||||
if (e < 0) d *= TEN ** BigInt(-e);
|
||||
return frac(sign === "-" ? -n : n, d);
|
||||
}
|
||||
|
||||
function toFrac(value: number | string): Frac {
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new FormulaError(`수가 아님: ${value}`);
|
||||
// ⚠ 수로 온 값은 **보이는 십진 표기**로 읽음 — 0.15 를 이진 근사값으로 받지 않음.
|
||||
return parseDecimal(String(value));
|
||||
}
|
||||
return parseDecimal(value);
|
||||
}
|
||||
|
||||
/** 정수로 떨굼 — `mode` 는 반올림 칸과 같은 낱말. */
|
||||
function toInteger(x: Frac, mode: RoundingMode): bigint {
|
||||
const q = x.n / x.d; // 0 쪽으로 자름
|
||||
const r = x.n % x.d;
|
||||
if (r === 0n) return q;
|
||||
const negative = x.n < 0n;
|
||||
if (mode === "floor") return negative ? q - 1n : q;
|
||||
if (mode === "ceil") return negative ? q : q + 1n;
|
||||
const twice = abs(r) * 2n;
|
||||
if (twice === x.d && mode === "round_half_even")
|
||||
return q % 2n === 0n ? q : negative ? q - 1n : q + 1n;
|
||||
// 사사오입 — 엑셀 `ROUND` 와 같이 0 에서 먼 쪽(명세 13장 ⭐).
|
||||
if (twice >= x.d) return negative ? q - 1n : q + 1n;
|
||||
return q;
|
||||
}
|
||||
|
||||
export function applyRounding(x: Frac, rounding?: FormulaRounding | null): Frac {
|
||||
if (!rounding || rounding.mode === "none") return x;
|
||||
const digits = Math.trunc(rounding.digits ?? 0);
|
||||
const scale = digits >= 0 ? frac(TEN ** BigInt(digits)) : frac(1n, TEN ** BigInt(-digits));
|
||||
return div(frac(toInteger(mul(x, scale), rounding.mode)), scale);
|
||||
}
|
||||
|
||||
/** 분수를 십진 문자열로 — 끝나는 소수는 그대로, 안 끝나면 30자리에서 사사오입. */
|
||||
export function fracToString(x: Frac): string {
|
||||
const scaled = toInteger(mul(x, frac(TEN ** BigInt(DIGITS))), "round");
|
||||
const negative = scaled < 0n;
|
||||
const digits = abs(scaled)
|
||||
.toString()
|
||||
.padStart(DIGITS + 1, "0");
|
||||
const whole = digits.slice(0, -DIGITS);
|
||||
const fraction = digits.slice(-DIGITS).replace(/0+$/, "");
|
||||
const body = fraction ? `${whole}.${fraction}` : whole;
|
||||
return negative && body !== "0" ? `-${body}` : body;
|
||||
}
|
||||
|
||||
function sqrt(x: Frac): Frac {
|
||||
if (x.n < 0n) throw new FormulaError("음수의 SQRT");
|
||||
const scale = TEN ** BigInt(DIGITS);
|
||||
// √(n/d) = √(n·d) / d — 소수 30자리까지 정수 제곱근으로.
|
||||
const target = x.n * x.d * scale * scale;
|
||||
if (target === 0n) return frac(0n);
|
||||
// 정수 제곱근(버림) — 참값보다 큰 2 의 거듭제곱에서 뉴턴으로 내려옴(Number 를 안 거침).
|
||||
let root = 1n << BigInt(Math.ceil(target.toString(2).length / 2));
|
||||
for (;;) {
|
||||
const next = (root + target / root) >> 1n;
|
||||
if (next >= root) break;
|
||||
root = next;
|
||||
}
|
||||
return frac(root, x.d * scale);
|
||||
}
|
||||
|
||||
function power(base: Frac, exponent: Frac): Frac {
|
||||
if (exponent.d === 1n) {
|
||||
const e = exponent.n;
|
||||
if (e === 0n) return frac(1n);
|
||||
const magnitude = abs(e);
|
||||
const raised = frac(base.n ** magnitude, base.d ** magnitude);
|
||||
return e > 0n ? raised : div(frac(1n), raised);
|
||||
}
|
||||
// 소수 지수는 무리수 — 유효 17자리 근사(드문 자리: 실무 53탭에 0회).
|
||||
const value = Math.pow(Number(base.n) / Number(base.d), Number(exponent.n) / Number(exponent.d));
|
||||
if (!Number.isFinite(value)) throw new FormulaError("거듭제곱 값이 수가 아님");
|
||||
return parseDecimal(value.toPrecision(17));
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 파서 */
|
||||
|
||||
class FormulaError extends Error {}
|
||||
|
||||
type Token =
|
||||
| { kind: "num"; text: string }
|
||||
| { kind: "str"; text: string }
|
||||
| { kind: "id"; text: string }
|
||||
| { kind: "op"; text: string };
|
||||
|
||||
const OPERATORS = ["<=", ">=", "<>", "+", "-", "*", "/", "^", "(", ")", ",", "=", "<", ">"];
|
||||
|
||||
function tokenize(source: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (/\s/.test(ch)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const number = /^(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/.exec(source.slice(i));
|
||||
if (number) {
|
||||
tokens.push({ kind: "num", text: number[0] });
|
||||
i += number[0].length;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
const end = source.indexOf(ch, i + 1);
|
||||
if (end < 0) throw new FormulaError("따옴표가 닫히지 않음");
|
||||
tokens.push({ kind: "str", text: source.slice(i + 1, end) });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
const identifier = /^[\p{L}_][\p{L}\p{N}_]*/u.exec(source.slice(i));
|
||||
if (identifier) {
|
||||
tokens.push({ kind: "id", text: identifier[0] });
|
||||
i += identifier[0].length;
|
||||
continue;
|
||||
}
|
||||
const op = OPERATORS.find((candidate) => source.startsWith(candidate, i));
|
||||
if (!op) throw new FormulaError(`읽을 수 없는 글자: ${ch}`);
|
||||
tokens.push({ kind: "op", text: op });
|
||||
i += op.length;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
type Node =
|
||||
| { type: "num"; value: Frac }
|
||||
| { type: "str"; value: string }
|
||||
| { type: "name"; name: string }
|
||||
| { type: "unary"; op: string; arg: Node }
|
||||
| { type: "binary"; op: string; left: Node; right: Node }
|
||||
| { type: "call"; name: string; args: Node[] };
|
||||
|
||||
const COMPARISONS = new Set(["=", "<>", "<", "<=", ">", ">="]);
|
||||
|
||||
/** 우선순위는 엑셀과 같음 — 부호 > `^` > `* /` > `+ -` > 비교. 같은 단은 왼쪽부터. */
|
||||
function parse(source: string): Node {
|
||||
const tokens = tokenize(source);
|
||||
let at = 0;
|
||||
const peek = (): Token | undefined => tokens[at];
|
||||
const isOp = (text: string): boolean => peek()?.kind === "op" && peek()?.text === text;
|
||||
const expect = (text: string): void => {
|
||||
if (!isOp(text)) throw new FormulaError(`「${text}」가 있어야 함`);
|
||||
at += 1;
|
||||
};
|
||||
|
||||
const comparison = (): Node => {
|
||||
let left = additive();
|
||||
while (peek()?.kind === "op" && COMPARISONS.has(peek()!.text)) {
|
||||
const op = tokens[at++].text;
|
||||
left = { type: "binary", op, left, right: additive() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const additive = (): Node => {
|
||||
let left = term();
|
||||
while (isOp("+") || isOp("-")) {
|
||||
const op = tokens[at++].text;
|
||||
left = { type: "binary", op, left, right: term() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const term = (): Node => {
|
||||
let left = exponent();
|
||||
while (isOp("*") || isOp("/")) {
|
||||
const op = tokens[at++].text;
|
||||
left = { type: "binary", op, left, right: exponent() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const exponent = (): Node => {
|
||||
let left = unary();
|
||||
while (isOp("^")) {
|
||||
at += 1;
|
||||
left = { type: "binary", op: "^", left, right: unary() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const unary = (): Node => {
|
||||
if (isOp("-") || isOp("+")) {
|
||||
const op = tokens[at++].text;
|
||||
return { type: "unary", op, arg: unary() };
|
||||
}
|
||||
return primary();
|
||||
};
|
||||
const primary = (): Node => {
|
||||
const token = peek();
|
||||
if (!token) throw new FormulaError("식이 중간에 끝남");
|
||||
at += 1;
|
||||
if (token.kind === "num") return { type: "num", value: parseDecimal(token.text) };
|
||||
if (token.kind === "str") return { type: "str", value: token.text };
|
||||
if (token.kind === "id") {
|
||||
if (!isOp("(")) return { type: "name", name: token.text };
|
||||
at += 1;
|
||||
const args: Node[] = [];
|
||||
if (!isOp(")")) {
|
||||
args.push(comparison());
|
||||
while (isOp(",")) {
|
||||
at += 1;
|
||||
args.push(comparison());
|
||||
}
|
||||
}
|
||||
expect(")");
|
||||
return { type: "call", name: token.text, args };
|
||||
}
|
||||
if (token.text === "(") {
|
||||
const inner = comparison();
|
||||
expect(")");
|
||||
return inner;
|
||||
}
|
||||
throw new FormulaError(`여기에 올 수 없음: ${token.text}`);
|
||||
};
|
||||
|
||||
const tree = comparison();
|
||||
if (at < tokens.length) throw new FormulaError(`식 끝에 남은 글: ${tokens[at].text}`);
|
||||
return tree;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 풀이 */
|
||||
|
||||
type Value = Frac | string | boolean;
|
||||
|
||||
interface Scope {
|
||||
names: Map<string, Value>;
|
||||
tables: Record<string, LookupTable>;
|
||||
}
|
||||
|
||||
const isFrac = (v: Value): v is Frac => typeof v === "object";
|
||||
|
||||
function asFrac(value: Value): Frac {
|
||||
if (isFrac(value)) return value;
|
||||
if (typeof value === "boolean") return frac(value ? 1n : 0n);
|
||||
throw new FormulaError(`수 자리에 글이 옴: ${value}`);
|
||||
}
|
||||
|
||||
function truthy(value: Value): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (isFrac(value)) return value.n !== 0n;
|
||||
throw new FormulaError(`조건 자리에 글이 옴: ${value}`);
|
||||
}
|
||||
|
||||
function compareValues(a: Value, b: Value): number {
|
||||
if (typeof a === "string" || typeof b === "string") {
|
||||
if (typeof a !== "string" || typeof b !== "string") {
|
||||
throw new FormulaError("글과 수를 견줄 수 없음");
|
||||
}
|
||||
return a === b ? 0 : a < b ? -1 : 1;
|
||||
}
|
||||
return cmp(asFrac(a), asFrac(b));
|
||||
}
|
||||
|
||||
function lookup(scope: Scope, args: Node[], exact: boolean): Value {
|
||||
if (args.length !== 3) throw new FormulaError("LOOKUP 은 인자가 셋(표, 키, 열)");
|
||||
const [tableNode, keyNode, columnNode] = args;
|
||||
const tableName =
|
||||
tableNode.type === "name" || tableNode.type === "str"
|
||||
? tableNode.type === "name"
|
||||
? tableNode.name
|
||||
: tableNode.value
|
||||
: null;
|
||||
const table = tableName ? scope.tables[tableName] : undefined;
|
||||
if (!table) throw new FormulaError(`없는 표: ${tableName ?? "?"}`);
|
||||
const column = evaluate(columnNode, scope);
|
||||
const cells = typeof column === "string" ? table.columns[column] : undefined;
|
||||
if (!cells) throw new FormulaError(`표 ${tableName} 에 없는 열: ${String(column)}`);
|
||||
const key = evaluate(keyNode, scope);
|
||||
const keys = table.keys.map((item) => (typeof item === "number" ? toFrac(item) : item));
|
||||
let hit = -1;
|
||||
for (let i = 0; i < keys.length; i += 1) {
|
||||
if (i > 0 && compareValues(keys[i - 1], keys[i]) >= 0) {
|
||||
throw new FormulaError(`표 ${tableName} 의 키가 오름차순이 아님`);
|
||||
}
|
||||
const order = compareValues(keys[i], key);
|
||||
if (order === 0) hit = i;
|
||||
// 근사 일치 — 키 이하 중 가장 큰 줄(엑셀 `VLOOKUP(…,1)`).
|
||||
else if (order < 0 && !exact) hit = i;
|
||||
}
|
||||
if (hit < 0) throw new FormulaError(`표 ${tableName} 에서 키 ${String(key)} 를 못 찾음`);
|
||||
const cell = cells[hit];
|
||||
if (cell === null || cell === undefined || cell === "") {
|
||||
throw new FormulaError(`표 ${tableName} 의 칸이 비어 있음(원문 「-」)`);
|
||||
}
|
||||
return typeof cell === "number" ? toFrac(cell) : cell;
|
||||
}
|
||||
|
||||
function evaluate(node: Node, scope: Scope): Value {
|
||||
switch (node.type) {
|
||||
case "num":
|
||||
case "str":
|
||||
return node.value;
|
||||
case "name": {
|
||||
const value = scope.names.get(node.name);
|
||||
if (value === undefined) throw new FormulaError(`모르는 이름: ${node.name}`);
|
||||
return value;
|
||||
}
|
||||
case "unary": {
|
||||
const value = asFrac(evaluate(node.arg, scope));
|
||||
return node.op === "-" ? frac(-value.n, value.d) : value;
|
||||
}
|
||||
case "binary": {
|
||||
const left = evaluate(node.left, scope);
|
||||
const right = evaluate(node.right, scope);
|
||||
if (COMPARISONS.has(node.op)) {
|
||||
const order = compareValues(left, right);
|
||||
return {
|
||||
"=": order === 0,
|
||||
"<>": order !== 0,
|
||||
"<": order < 0,
|
||||
"<=": order <= 0,
|
||||
">": order > 0,
|
||||
">=": order >= 0,
|
||||
}[node.op] as boolean;
|
||||
}
|
||||
const a = asFrac(left);
|
||||
const b = asFrac(right);
|
||||
if (node.op === "+") return add(a, b);
|
||||
if (node.op === "-") return sub(a, b);
|
||||
if (node.op === "*") return mul(a, b);
|
||||
if (node.op === "/") return div(a, b);
|
||||
return power(a, b);
|
||||
}
|
||||
case "call": {
|
||||
const { name, args } = node;
|
||||
if (name === "IF") {
|
||||
if (args.length !== 3) throw new FormulaError("IF 는 인자가 셋(조건, 참, 거짓)");
|
||||
// 고른 갈래만 풂 — 안 고른 갈래의 오류(없는 표 칸 등)가 줄을 막지 않게.
|
||||
return evaluate(truthy(evaluate(args[0], scope)) ? args[1] : args[2], scope);
|
||||
}
|
||||
if (name === "LOOKUP") return lookup(scope, args, false);
|
||||
if (name === "LOOKUP_EXACT") return lookup(scope, args, true);
|
||||
const values = args.map((arg) => asFrac(evaluate(arg, scope)));
|
||||
if (name === "SQRT") {
|
||||
if (values.length !== 1) throw new FormulaError("SQRT 는 인자가 하나");
|
||||
return sqrt(values[0]);
|
||||
}
|
||||
if (name === "SUM") return values.reduce((total, v) => add(total, v), frac(0n));
|
||||
if (name === "MIN" || name === "MAX") {
|
||||
if (!values.length) throw new FormulaError(`${name} 에 인자가 없음`);
|
||||
return values.reduce((best, v) => (cmp(v, best) < 0 === (name === "MIN") ? v : best));
|
||||
}
|
||||
throw new FormulaError(`모르는 함수: ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 장 한 벌을 줄 차례대로 풂. 오류는 **그 줄에만** 적고 다음 줄은 계속 풂 —
|
||||
* 오류 난 줄을 가리키는 줄은 「앞 줄 오류」로 막힘(0 으로 때우지 않음).
|
||||
*/
|
||||
export function evaluateSheet(sheet: FormulaSheet): FormulaRowResult[] {
|
||||
const rows = [...(sheet.rows ?? [])].sort((a, b) => a.seq - b.seq);
|
||||
const done = new Map<number, { value: Frac | null; name: string }>();
|
||||
const results: FormulaRowResult[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const result: FormulaRowResult = {
|
||||
seq: row.seq,
|
||||
name: row.name,
|
||||
amount: null,
|
||||
raw: null,
|
||||
error: null,
|
||||
};
|
||||
try {
|
||||
if (done.has(row.seq)) throw new FormulaError(`같은 차례 번호가 둘: ${row.seq}`);
|
||||
let raw: Frac;
|
||||
if (row.formula && row.formula.trim()) {
|
||||
const names = new Map<string, Value>();
|
||||
for (const [key, value] of Object.entries({ ...(sheet.vars ?? {}), ...(row.vars ?? {}) })) {
|
||||
names.set(key, typeof value === "number" ? toFrac(value) : value);
|
||||
}
|
||||
for (const [key, seq] of Object.entries(row.refs ?? {})) {
|
||||
if (seq >= row.seq) throw new FormulaError(`앞 줄만 가리킬 수 있음: ${key} → ${seq}`);
|
||||
const target = done.get(seq);
|
||||
if (!target) throw new FormulaError(`없는 줄: ${key} → ${seq}`);
|
||||
if (!target.value) throw new FormulaError(`앞 줄 ${seq}(${target.name}) 오류`);
|
||||
names.set(key, target.value);
|
||||
}
|
||||
raw = asFrac(evaluate(parse(row.formula), { names, tables: sheet.tables ?? {} }));
|
||||
} else {
|
||||
// 고정형 — 박힌 값을 그대로(명세 13장 「양식형 ↔ 고정형」).
|
||||
if (row.amount === null || row.amount === undefined || row.amount === "") {
|
||||
throw new FormulaError("식도 값도 없음");
|
||||
}
|
||||
raw = toFrac(row.amount);
|
||||
}
|
||||
const value = applyRounding(raw, row.rounding);
|
||||
result.raw = fracToString(raw);
|
||||
result.amount = fracToString(value);
|
||||
done.set(row.seq, { value, name: row.name });
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
done.set(row.seq, { value: null, name: row.name });
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_Formula_Node.ts
|
||||
* 구조물도 식 풀이를 **서버가** 돌리는 진입점 — [저장]·[확정] 때 정본으로 다시 풂.
|
||||
*
|
||||
* 풀이는 화면이 쓰는 `B08_Quantity_Formula.ts` 그대로(판정 Ⓐ, 2026-09-13) —
|
||||
* B06 `B06_Section_Server_Calc_Node.ts` 와 같은 본. 여기에는 계산이 없음.
|
||||
*
|
||||
* 실행: node <번들> <입력.json> <출력.json>
|
||||
* 입력 { sheets: FormulaSheet[] }
|
||||
* 출력 { sheets: FormulaRowResult[][] } — 입력 장 차례 그대로
|
||||
* 끝 코드: 0 성공 / 2 인자 오류
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { evaluateSheet, type FormulaSheet } from "./B08_Quantity_Formula";
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error("사용법: node <번들> <입력.json> <출력.json>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8")) as { sheets?: FormulaSheet[] };
|
||||
const sheets = (input.sheets ?? []).map((sheet) => evaluateSheet(sheet));
|
||||
writeFileSync(outputPath, JSON.stringify({ sheets }));
|
||||
@@ -19,8 +19,8 @@ from typing import Any
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
# 번들이 낡았는지 재는 대상 — 기하 계통이 걸쳐 있는 폴더.
|
||||
SOURCE_DIRS = ("B05_Profile", "B06_Section", "common_util")
|
||||
# 번들이 낡았는지 재는 대상 — 기하 계통이 걸쳐 있는 폴더 + 구조물도 식 풀이(B08, 2026-09-13).
|
||||
SOURCE_DIRS = ("B05_Profile", "B06_Section", "B08_Quantity", "common_util")
|
||||
# 번들 만들기·실행 상한(초). 실측 번들 실행 0.1초, 빌드 3초 수준이라 넉넉하다.
|
||||
BUILD_TIMEOUT_S = 300
|
||||
RUN_TIMEOUT_S = 600
|
||||
|
||||
@@ -137,9 +137,9 @@ STmate 표준일위대가(암호화) = 조합 + 값(반기마다 바뀜)
|
||||
|
||||
## 5. 이대로 쓰려면 해야 할 것
|
||||
|
||||
1. **수량을 현행 표준품셈으로 갱신** — 조합은 그대로, 수량만. `품셈근거` 문자열이 대조점이다.
|
||||
1. **수량을 현행 표준품셈으로 갱신** — ✅ 32번에서 350개 전수 장부화. 직접 일치 2개, 나머지는 근거·조건별 수동 대조 필요.
|
||||
2. **자원 이름을 우리 코드에 잇기** — `30_원자료/stmate_code_name_list.csv`(894개)와 짝지어 쓴다.
|
||||
3. **못 잡은 21개 보완** — 운반 계산과 기계·인력 비율 조합은 **별도 꼴**로 다뤄야 한다(26번 §1의 `Cm` 식, 23번 §4의 운반 사이클 참조).
|
||||
3. **못 잡은 21개 보완** — ✅ 32번 및 `특수_단가산출_21개.json`에 6유형·원문 산식·인자식으로 전수 구조화.
|
||||
4. **규격별 수량표로 접기** — §3.3처럼 같은 조합이 규격만 다른 것이 많다.
|
||||
|
||||
## 6. 한 줄 정리
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 특수 단가산출 21개 구조화와 현행 품셈 대조
|
||||
|
||||
작성일: 2026-09-13
|
||||
대상: `31_일위대가_레시피_사전.md`의 후속 작업 1·3
|
||||
현행 원문: 산림청고시 제2025-82호 산림사업 표준품셈(2026-01-01 시행), 2026년 건설공사 표준품셈
|
||||
|
||||
## 1. 특수 단가산출 21개
|
||||
|
||||
산출물: `30_원자료/특수_단가산출_21개.json`
|
||||
재현: `40_스크립트/recipe_extract.py`
|
||||
|
||||
| 유형 | 개수 | 구조 |
|
||||
|---|---:|---|
|
||||
| 구역화물 운반 | 9 | 거리구간 운임 ÷ 부가세 ÷ 적재량, 필요 시 적상·적하비 합산 |
|
||||
| 기계작업 사이클 | 4 | `Q=3600×q×K×f×E/Cm`, 기계·인력 비율을 각 성분에 적용 |
|
||||
| 중기운반 사이클 | 3 | `L1·L2·V1~V4·t1~t4 → Cm → N·OH` |
|
||||
| 직접산식 | 2 | 떼 하차비, 제근처럼 자원단가에 작업조건식을 직접 적용 |
|
||||
| 일작업량 환산 | 2 | 일 노임·중기사용료를 일작업량 `Q`로 나눔 |
|
||||
| 인력운반 사이클 | 1 | `N=V×T/(120×L+V×T1)`, `Q=N×운반중량` |
|
||||
| **합계** | **21** | 원문 산식행과 인자를 함께 보존 |
|
||||
|
||||
기존의 「운반 / 기계+인력」 두 갈래만으로는 5개 유형을 잃는다. JSON은 계산값을 임의로 다시 만들지 않고 **원문 산식행 전체와 추출한 인자식**을 보존한다.
|
||||
|
||||
## 2. 레시피 350개 × 현행 품셈 1차 대조
|
||||
|
||||
산출물: `30_원자료/일위대가_현행품셈_대조.json`
|
||||
감사표: `30_원자료/일위대가_현행품셈_대조_감사.txt`
|
||||
재현: `40_스크립트/recipe_current_quantity_audit.py`
|
||||
|
||||
| 상태 | 개수 | 뜻 |
|
||||
|---|---:|---|
|
||||
| 현행 원문 수량문자 전부 확인 | **2** | 현행 절과 조건을 직접 확인한 항목 |
|
||||
| 현행 절 확인·수량 수동대조 필요 | 11 | 근거 절은 찾았으나 규격·조건 해석 필요 |
|
||||
| 현행 절 후보·수동대조 필요 | 161 | 명칭으로 후보 절만 찾음 |
|
||||
| 근거문구·현행 위치 미확인 | 69 | 과거 절 번호가 개편됐거나 복수 절을 참조 |
|
||||
| 근거 미기재 | 107 | 실무 출력에 품셈 근거문구가 없음 |
|
||||
| **합계** | **350** | 전 항목 장부화 완료 |
|
||||
|
||||
### 원문에서 직접 일치한 2개
|
||||
|
||||
| 조합 | 실무 관측 | 2026 현행 원문 | 판정 |
|
||||
|---|---|---|---|
|
||||
| 단끊기(절취없음) | 보통인부 0.34·0.36·1.17인/100m | 산림사업 표준품셈 5-16-1의 수평잡기·잡석/뿌리정리·절성토면 고르기와 각각 일치 | 일치 |
|
||||
| 제근(밀림) | 보통인부 0.05인 | 산림사업 표준품셈 9-21 밀림 0.05인과 일치 | 일치 |
|
||||
|
||||
나머지를 `변경`으로 판정하지 않았다. 같은 숫자가 없다는 사실만으로는 규격·토질·장비·일작업량 조건이 달라진 것인지 품이 개정된 것인지 가를 수 없기 때문이다.
|
||||
|
||||
## 3. 판정 경계
|
||||
|
||||
- 실무 레시피 350개 중 품셈 근거문구 보유는 82개뿐이다. 근거 없는 268개는 현행값으로 임의 확정하지 않는다.
|
||||
- 과거 임도품셈 절 번호가 현행 산림사업 표준품셈에서 크게 바뀌었다. 번호만 같은 다른 절을 자동 연결하지 않는다.
|
||||
- JSON의 `수량문자_확인`은 후보 절 본문에 같은 숫자가 있는지를 세는 1차 검색값이다. `일치` 확정은 위 두 항목처럼 조건과 행을 직접 대조한 경우만 한다.
|
||||
- 후속 수동 대조는 근거 절이 확인된 11개부터 하고, 복수 절 참조 69개는 과거판-현행판 절 대응표가 있어야 안전하다.
|
||||
@@ -99,7 +99,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3 굴삭기 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -191,7 +191,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "3600*q*K*f*E/Cm",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 3-1-3(터파기) <주>⑥ 11-3(굴삭기) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -219,7 +219,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "818.6",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 3-1-3(터파기) <주>⑥ 11-3(굴삭기) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -293,7 +293,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "3600*q*K*f*E/Cm",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 3-1-3(터파기),11-3(굴삭기) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -321,7 +321,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "290m3/일",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈(공통) : 3-2-6 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "○특별인부",
|
||||
@@ -445,7 +445,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "944.5",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 3-1-3(터파기),11-3(굴삭기) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -473,7 +473,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "(3.3+5.9)/2",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 (공통) 8-2-13",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -501,7 +501,7 @@
|
||||
"규격": "",
|
||||
"단위": "자",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 7-5 글자 새김",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "석공",
|
||||
@@ -533,7 +533,7 @@
|
||||
"규격": "",
|
||||
"단위": "M2",
|
||||
"Q식": "3600*q*K*f*E/Cm",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -597,7 +597,7 @@
|
||||
"규격": "",
|
||||
"단위": "M2",
|
||||
"Q식": "3600*q*K*f*E/Cm",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -661,7 +661,7 @@
|
||||
"규격": "",
|
||||
"단위": "M2",
|
||||
"Q식": "3600*q*K*f*E/Cm",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -725,7 +725,7 @@
|
||||
"규격": "",
|
||||
"단위": "M2",
|
||||
"Q식": "3600*q*K*f*E/Cm",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -945,7 +945,7 @@
|
||||
"규격": "",
|
||||
"단위": "M2",
|
||||
"Q식": "3600*q*K*f*E/Cm",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "임도표준품셈 7-3 노면정리 적용(건설품셈의 기계회 시공 적용)",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -973,7 +973,7 @@
|
||||
"규격": "",
|
||||
"단위": "m2",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 8-2-3 굴삭기 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -1001,7 +1001,7 @@
|
||||
"규격": "",
|
||||
"단위": "m2",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3 굴삭기 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -1029,7 +1029,7 @@
|
||||
"규격": "",
|
||||
"단위": "m",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 8-2-3 굴삭기 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -1057,7 +1057,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "736.7",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3 굴삭기 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -1085,7 +1085,7 @@
|
||||
"규격": "",
|
||||
"단위": "m",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "표준품셈 4-16-2(선떼붙이기공) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "보통인부",
|
||||
@@ -1143,7 +1143,7 @@
|
||||
"규격": "",
|
||||
"단위": "m",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "표준품셈 4-16-2 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "보통인부",
|
||||
@@ -1171,7 +1171,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "2,178.3",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기), 11-9(덤프트럭) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -1253,7 +1253,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "2,178.3",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기), 11-9(덤프트럭) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -1335,7 +1335,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "2,178.3",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기), 11-9(덤프트럭) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -1417,7 +1417,7 @@
|
||||
"규격": "",
|
||||
"단위": "M3",
|
||||
"Q식": "3600*q*k*E*f/Cm",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기) , 11-9(덤프트럭) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -1527,7 +1527,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "1,754.3",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기), 11-9(덤프트럭) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -1591,7 +1591,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "1,754.3",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기), 11-9(덤프트럭) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -1655,7 +1655,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "1,754.3",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기), 11-9(덤프트럭) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -1783,7 +1783,7 @@
|
||||
"규격": "",
|
||||
"단위": "M3",
|
||||
"Q식": "3600*q*k*E*f/Cm",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기) , 11-9(덤프트럭) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -1847,7 +1847,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "957.2",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-1 불도저 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -1875,7 +1875,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "916.7",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-1 불도저 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -1903,7 +1903,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "979.9",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-1 불도저 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -1987,7 +1987,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "818.6",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-1 불도저 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -2015,7 +2015,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "906.2",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-1 불도저 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -2043,7 +2043,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "994.1",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-1 불도저 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -2127,7 +2127,7 @@
|
||||
"규격": "",
|
||||
"단위": "ton",
|
||||
"Q식": "806.2",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-9(덤프트럭) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -2173,7 +2173,7 @@
|
||||
"규격": "",
|
||||
"단위": "m2",
|
||||
"Q식": "3,974.7",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 8-2-3(굴삭기) , 8-2-8(덤프트럭) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -2237,7 +2237,7 @@
|
||||
"규격": "",
|
||||
"단위": "m2",
|
||||
"Q식": "4,577.1",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 8-2-3(굴삭기) , 8-2-8(덤프트럭) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -2301,7 +2301,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "110m3/일",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 3-4-4(뒤채움 및 다짐) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "특별인부",
|
||||
@@ -2341,7 +2341,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 3-4-2 기초다짐 및 뒷채움 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "보통인부",
|
||||
@@ -2375,7 +2375,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "63m3/일",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈(공통) : 6-1-1 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "○콘크리트공",
|
||||
@@ -2403,7 +2403,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "63m3/일",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈(공통) : 6-1-1 무근(장비사용타설) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "○콘크리트공",
|
||||
@@ -2449,7 +2449,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "63m3/일",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 6-1-1(레디믹스트콘크리트타설) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "콘크리트공",
|
||||
@@ -2495,7 +2495,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "63m3/일",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 6-1-1(레디믹스트콘크리트타설) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "콘크리트공",
|
||||
@@ -2541,7 +2541,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "63m3/일",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 6-1-1(레디믹스트콘크리트타설) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "콘크리트공",
|
||||
@@ -2587,7 +2587,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "55m3/일",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 6-1-1(레디믹스트콘크리트타설) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "콘크리트공",
|
||||
@@ -2725,7 +2725,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "1,006.8",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-9(덤프트럭) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -2771,7 +2771,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "250m3/일",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 3-4-5 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "○특별인부",
|
||||
@@ -2793,7 +2793,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3 굴삭기 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -2937,7 +2937,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "743.8",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 8-2-3(굴삭기), 8-2-8(덤프트럭) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -3057,7 +3057,7 @@
|
||||
"규격": "",
|
||||
"단위": "개소",
|
||||
"Q식": null,
|
||||
"품셈근거": "비탈규준틀[개소]2-6-1",
|
||||
"품셈근거": null,
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "각재(거푸집용,외송)",
|
||||
@@ -3189,7 +3189,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "1,052.5",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 굴삭기 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -3305,7 +3305,7 @@
|
||||
"규격": "",
|
||||
"단위": "m2",
|
||||
"Q식": "716.8",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "표준품셈 9-3(임도성토면다짐) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -3377,7 +3377,7 @@
|
||||
"규격": "",
|
||||
"단위": "개소",
|
||||
"Q식": null,
|
||||
"품셈근거": "토공의 비탈규준틀[개소]2-6-1",
|
||||
"품셈근거": null,
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "각재(거푸집용,외송)",
|
||||
@@ -3503,7 +3503,7 @@
|
||||
"규격": "",
|
||||
"단위": "m",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "표준품셈 4-16-4(파종공) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "종자(초본류)",
|
||||
@@ -3677,7 +3677,7 @@
|
||||
"규격": "",
|
||||
"단위": "㎥",
|
||||
"Q식": "0.009hr/㎥",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈(1-24) 연암 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "폭 약",
|
||||
@@ -3789,7 +3789,7 @@
|
||||
"규격": "",
|
||||
"단위": "M3",
|
||||
"Q식": "5.0㎥/hr",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "임도표준품셈 3-3-2(굴삭기) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -3963,7 +3963,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "3.5㎥/hr",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -4037,7 +4037,7 @@
|
||||
"규격": "",
|
||||
"단위": "m2",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 6-3-3(유로폼 설치 및 해체) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "패널(유로폼)",
|
||||
@@ -4089,7 +4089,7 @@
|
||||
"규격": "",
|
||||
"단위": "m2",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 6-3-3(유로폼 설치 및 해체) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "패널(유로폼)",
|
||||
@@ -4175,7 +4175,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "1,029.8",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-9(덤프트럭) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -4295,7 +4295,7 @@
|
||||
"규격": "",
|
||||
"단위": "M3",
|
||||
"Q식": "3600*q*k*E*f/Cm",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -4323,7 +4323,7 @@
|
||||
"규격": "",
|
||||
"단위": "M3",
|
||||
"Q식": "3600*q*k*E*f/Cm",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -4351,7 +4351,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "11,140.0",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-3(굴삭기) , 11-18(대형 브레이카) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -4525,7 +4525,7 @@
|
||||
"규격": "",
|
||||
"단위": "ton",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 6-2(철근) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "철근공",
|
||||
@@ -4577,7 +4577,7 @@
|
||||
"규격": "",
|
||||
"단위": "ton",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 6-2(철근) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "철근공",
|
||||
@@ -4629,7 +4629,7 @@
|
||||
"규격": "",
|
||||
"단위": "ton",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 6-2(철근) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "철근공",
|
||||
@@ -4897,7 +4897,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "60*q*E/4",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 6-1-2 콘크리트믹서의 작업량 계산 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -4925,7 +4925,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 제7장 돌공사 채움재 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "콘크리트믹서사용",
|
||||
@@ -4941,7 +4941,7 @@
|
||||
"규격": "",
|
||||
"단위": "m2",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 1-6-2(콘크리트포장 표층인력포설) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "포장공",
|
||||
@@ -4969,7 +4969,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "50m3/일",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈(토목) : 1-6-2 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "공구손료",
|
||||
@@ -5197,7 +5197,7 @@
|
||||
"규격": "",
|
||||
"단위": "M",
|
||||
"Q식": "0.35hr/본(6m)당",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 16-2-7 파형강관 부설 및 접합(D=1200mm)",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "배관공(수도)",
|
||||
@@ -5225,7 +5225,7 @@
|
||||
"규격": "",
|
||||
"단위": "M",
|
||||
"Q식": "0.25hr/본(6m)당",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 16-2-7 파형강관 부설 및 접합(D=800mm)",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "배관공(수도)",
|
||||
@@ -5253,7 +5253,7 @@
|
||||
"규격": "",
|
||||
"단위": "m2",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 4-1-1(잔디붙임)적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "24년 1월",
|
||||
@@ -5281,7 +5281,7 @@
|
||||
"규격": "",
|
||||
"단위": "M",
|
||||
"Q식": "Qd/8hr",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 10-3-2 (콘크리트포장),3. 포장절단및 줄눈설치 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "○.특별인부",
|
||||
@@ -5415,7 +5415,7 @@
|
||||
"규격": "",
|
||||
"단위": "m2",
|
||||
"Q식": null,
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 6-3-1(합판거푸집 설치 및 해체) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "합판(내수)",
|
||||
@@ -5461,7 +5461,7 @@
|
||||
"규격": "",
|
||||
"단위": "M2",
|
||||
"Q식": "3600*q*K*f*E/Cm",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "임도표준품셈 4-39 쇄석 혼합석 부설 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -5507,7 +5507,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "2,352.7",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-8(모터그레이터), 11-10(롤러) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
@@ -5535,7 +5535,7 @@
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"Q식": "1,029.8",
|
||||
"품셈근거": null,
|
||||
"품셈근거": "건설표준품셈 11-9(덤프트럭) 적용",
|
||||
"구성": [
|
||||
{
|
||||
"명칭": "노무비",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
### STmate 일위대가 350개 × 2026년 현행 품셈 대조
|
||||
|
||||
근거문구_현행위치미확인: 69
|
||||
근거미기재: 107
|
||||
현행원문_수량문자_전부확인: 2
|
||||
현행절_확인_수량수동대조필요: 11
|
||||
현행절_후보_수동대조필요: 161
|
||||
|
||||
※ '수량문자 확인'은 현행 절 본문에 같은 숫자가 존재한다는 1차 검사이며 확정 판정이 아니다.
|
||||
※ 근거가 없거나 조건·규격 해석이 필요한 값은 사용자 협의 없이 갱신하지 않았다.
|
||||
@@ -0,0 +1,532 @@
|
||||
[
|
||||
{
|
||||
"원본": "2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km",
|
||||
"호표": 14,
|
||||
"명칭": "측구터파기(토사) 기계100%(굴삭기 0.4m3)",
|
||||
"규격": "",
|
||||
"단위": "M3",
|
||||
"유형": "기계작업_사이클",
|
||||
"인자": {
|
||||
"E": "0.45-0.05= 0.40",
|
||||
"K": "0.7 E=0.45-0.05= 0.40",
|
||||
"f": "1/1.175 = 0.85 Cm=15 sec(90˚)",
|
||||
"Cm": "15 sec(90˚)",
|
||||
"Q": "3600*q*K*f*E/Cm= 22.85 ㎥/hr",
|
||||
"q": "0.4 K=0.7 E=0.45-0.05= 0.40"
|
||||
},
|
||||
"산식행": [
|
||||
"측구터파기(토사) 기계100%(굴삭기 0.4m3) / M3",
|
||||
"▣임도표준품셈 5-1-1 가.토사 적용",
|
||||
"Q : 시간당 작업량(m3/hr)",
|
||||
"q : 버킷용량(m3) E : 작업효율",
|
||||
"f : 토량의 체적 환산계수 K : 버킷계수",
|
||||
"Cm : 1회 사이클 시간(초)",
|
||||
"적용토질 : 자갈섞인흙",
|
||||
"1. 굴삭기(0.4㎥ : 100%)",
|
||||
"q=0.4 , K=0.7 , E=0.45-0.05= 0.40",
|
||||
"f= 1/1.175 = 0.85 , Cm=15 sec(90˚)",
|
||||
"Q=3600*q*K*f*E/Cm= 22.85 ㎥/hr",
|
||||
"경 비 16,378 / Q = 716.7",
|
||||
"노무비 55,700 / Q = 2,437.6",
|
||||
"재료비 15,375 / Q = 672.8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km",
|
||||
"호표": 21,
|
||||
"명칭": "구조물터파기(육상토사0~1m) 기계90%+인력10%(굴삭기 0.7M3)",
|
||||
"규격": "",
|
||||
"단위": "M3",
|
||||
"유형": "기계작업_사이클",
|
||||
"인자": {
|
||||
"E": "0.45-0.05= 0.40",
|
||||
"K": "0.9 E=0.45-0.05= 0.40",
|
||||
"f": "1/1.175 = 0.85 Cm=20 sec(135˚)",
|
||||
"Cm": "20 sec(135˚)",
|
||||
"Q": "3600*q*K*f*E/Cm= 38.56 ㎥/hr",
|
||||
"q": "0.7 K=0.9 E=0.45-0.05= 0.40",
|
||||
"A": "0.2"
|
||||
},
|
||||
"산식행": [
|
||||
"구조물터파기(육상토사0~1m) 기계90%+인력10%(굴삭기 0.7M3) / M3",
|
||||
"▣임도표준품셈 5-1-2 가.육상토사(0~1m) 적용",
|
||||
"집수정, 맨홀 등 배수구조물의 터파기기 적용함.",
|
||||
"1. 굴삭기(0.7㎥ : 90%)",
|
||||
"q=0.7 , K=0.9 , E=0.45-0.05= 0.40",
|
||||
"f= 1/1.175 = 0.85 , Cm=20 sec(135˚)",
|
||||
"Q=3600*q*K*f*E/Cm= 38.56 ㎥/hr",
|
||||
"경 비 23,128 / Q * 0.9 = 539.8",
|
||||
"노무비 55,700 / Q * 0.9 = 1,300.0",
|
||||
"재료비 18,015 / Q * 0.9 = 420.4",
|
||||
"2. 보통인부(인력 10%)",
|
||||
"A=0.2",
|
||||
"165,545 * A * 0.1 = 3,310.9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km",
|
||||
"호표": 23,
|
||||
"명칭": "구조물터파기(육상토사0~1m) 기계100%(굴삭기 0.7M3)",
|
||||
"규격": "",
|
||||
"단위": "M3",
|
||||
"유형": "기계작업_사이클",
|
||||
"인자": {
|
||||
"E": "0.45-0.05= 0.40",
|
||||
"K": "0.7 E=0.45-0.05= 0.40",
|
||||
"f": "1/1.175 = 0.85 Cm=20 sec(135˚)",
|
||||
"Cm": "20 sec(135˚)",
|
||||
"Q": "3600*q*K*f*E/Cm= 29.99 ㎥/hr",
|
||||
"q": "0.7 K=0.7 E=0.45-0.05= 0.40"
|
||||
},
|
||||
"산식행": [
|
||||
"구조물터파기(육상토사0~1m) 기계100%(굴삭기 0.7M3) / M3",
|
||||
"▣임도표준품셈 5-1-2 가.육상토사(0~1m) 적용",
|
||||
"집수정, 맨홀 등 배수구조물의 터파기기 적용함.",
|
||||
"1. 굴삭기(0.7㎥ : 100%)",
|
||||
"q=0.7 , K=0.7 , E=0.45-0.05= 0.40",
|
||||
"f= 1/1.175 = 0.85 , Cm=20 sec(135˚)",
|
||||
"Q=3600*q*K*f*E/Cm= 29.99 ㎥/hr",
|
||||
"경 비 23,128 / Q = 771.1",
|
||||
"노무비 55,700 / Q = 1,857.2",
|
||||
"재료비 18,015 / Q = 600.7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km",
|
||||
"호표": 24,
|
||||
"명칭": "구조물 되메우기 기계100%(굴삭기 0.7M3)",
|
||||
"규격": "",
|
||||
"단위": "M3",
|
||||
"유형": "기계작업_사이클",
|
||||
"인자": {
|
||||
"E": "0.5 f= 0.9 / 1.175 = 0.77",
|
||||
"K": "0.7 E=0.5 f= 0.9 / 1.175 = 0.77",
|
||||
"f": "0.9 / 1.175 = 0.77",
|
||||
"Cm": "18 sec(90˚)",
|
||||
"Q": "3600*q*K*f*E/Cm= 37.73 ㎥/hr",
|
||||
"q": "0.7 K=0.7 E=0.5 f= 0.9 / 1.175 = 0.77"
|
||||
},
|
||||
"산식행": [
|
||||
"구조물 되메우기 기계100%(굴삭기 0.7M3) / M3",
|
||||
"▣임도표준품셈 5-1-3 가.되메우기 및 다짐 적용",
|
||||
"1. 굴삭기(0.7㎥ : 100%)",
|
||||
"q=0.7 , K=0.7 , E=0.5 , f= 0.9 / 1.175 = 0.77",
|
||||
"Cm=18 sec(90˚)",
|
||||
"Q=3600*q*K*f*E/Cm= 37.73 ㎥/hr",
|
||||
"경 비 23,128 / Q = 612.9",
|
||||
"노무비 55,700 / Q = 1,476.2",
|
||||
"재료비 18,015 / Q = 477.4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km",
|
||||
"호표": 59,
|
||||
"명칭": "중기운반(현동) L=36.1km",
|
||||
"규격": "",
|
||||
"단위": "대",
|
||||
"유형": "중기운반_사이클",
|
||||
"인자": {
|
||||
"L1": "36.1 km(포 장)",
|
||||
"L2": "0.0 km(비포장)",
|
||||
"V1": "35 km/hr V2= 35 km/hr",
|
||||
"V2": "35 km/hr",
|
||||
"V3": "10 km/hr V4= 15 km/hr",
|
||||
"V4": "15 km/hr",
|
||||
"t1": "20 E=0.9",
|
||||
"t2": "(L1/V1+L1/V2+L2/V3+L2/V4) * 60 = 123.77",
|
||||
"t3": "20 t4=0.42",
|
||||
"t4": "0.42",
|
||||
"E": "0.9",
|
||||
"Cm": "t1 + t2 + t3 + t4 = 164.19",
|
||||
"N": "60*E/Cm= 0.33 회"
|
||||
},
|
||||
"산식행": [
|
||||
"중기운반(현동) L=36.1km / 대",
|
||||
"중 기 운 반 (20톤 트레일러)",
|
||||
"소재지 ---------------------> 현장",
|
||||
"L1= 36.1 km(포 장)",
|
||||
"L2= 0.0 km(비포장)",
|
||||
"V1= 35 km/hr , V2= 35 km/hr",
|
||||
"V3= 10 km/hr , V4= 15 km/hr",
|
||||
"t1=20 , E=0.9",
|
||||
"t2=(L1/V1+L1/V2+L2/V3+L2/V4) * 60 = 123.77",
|
||||
"t3=20 , t4=0.42",
|
||||
"Cm =t1 + t2 + t3 + t4 = 164.19",
|
||||
"N=60*E/Cm= 0.33 회",
|
||||
"OH=(Cm-t1-t3)/Cm= 0.76",
|
||||
"노무비 : 55,700/N*2 = 337,575.7",
|
||||
"재료비 : 29,196/N*OH*2 = 134,478.5",
|
||||
"경 비 : 16,353/N*2 = 99,109.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km",
|
||||
"호표": 60,
|
||||
"명칭": "기계운반(현동) L=36.1km",
|
||||
"규격": "",
|
||||
"단위": "대",
|
||||
"유형": "구역화물_운반",
|
||||
"인자": {},
|
||||
"산식행": [
|
||||
"기계운반(현동) L=36.1km / 대",
|
||||
"기계 운반( 믹서 , 소형 중기류)",
|
||||
"L=36.1km",
|
||||
"시·군--------------------> 현장중점",
|
||||
"○구역화물(8Ton)적용 L = 40km이내 적용",
|
||||
"128,630 / 1.1 = 116,936.3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km",
|
||||
"호표": 61,
|
||||
"명칭": "시멘트운반(현동) L=62.5km",
|
||||
"규격": "",
|
||||
"단위": "포",
|
||||
"유형": "구역화물_운반",
|
||||
"인자": {},
|
||||
"산식행": [
|
||||
"시멘트운반(현동) L=62.5km / 포",
|
||||
"○L = 37.8km",
|
||||
"1)자동차운반",
|
||||
"하치장 -------> 현장중점",
|
||||
"구역화물(10.5ton적용) L =70km이내적용",
|
||||
"195,340 / 1.1 / 10.5 / 25 = 676.5",
|
||||
"2)적상적하비",
|
||||
"적상 : 0.18 인",
|
||||
"적하 : 0.13 인",
|
||||
"(0.18+0.13) * 165,545 / 25 = 2,052.7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km",
|
||||
"호표": 64,
|
||||
"명칭": "철근운반(현동) L=62.5km",
|
||||
"규격": "",
|
||||
"단위": "TON",
|
||||
"유형": "구역화물_운반",
|
||||
"인자": {},
|
||||
"산식행": [
|
||||
"철근운반(현동) L=62.5km / TON",
|
||||
"철근 운반 ( L = 62.5 km)",
|
||||
"현 장",
|
||||
"하치장 ---------------------->",
|
||||
"중 점",
|
||||
"1).구역화물(10.5ton)적용 L = 70km이내적용",
|
||||
"195,340 / 1.1 / 10.5 = 16,912.5",
|
||||
"2).TON당 하차비",
|
||||
"2 * 11 / 2 / (480-30) * 165,545 = 4,046.6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2024년 간선임도사업(기번3)",
|
||||
"호표": 21,
|
||||
"명칭": "중기운반(울진군 울진읍 대흥리) L=19.00km",
|
||||
"규격": "",
|
||||
"단위": "대",
|
||||
"유형": "중기운반_사이클",
|
||||
"인자": {
|
||||
"L1": "12 km(포 장)",
|
||||
"L2": "7 km(비포장)",
|
||||
"V1": "30 km/hr",
|
||||
"V2": "35 km/hr",
|
||||
"V3": "10 km/hr",
|
||||
"V4": "15 km/hr",
|
||||
"t4": "154.99",
|
||||
"E": "0.9",
|
||||
"Cm": "0.35 회"
|
||||
},
|
||||
"산식행": [
|
||||
"중기운반(울진군 울진읍 대흥리) L=19.00km / 대",
|
||||
"중 기 운 반 (20톤 트레일러)",
|
||||
"소재지 ---------------------> 현장",
|
||||
"건설표준품셈 11-6나(트럭트레일러) 적용",
|
||||
"중 기 운 반 (20톤 트레일러)",
|
||||
"L1= 12 km(포 장)",
|
||||
"L2= 7 km(비포장)",
|
||||
"V1= 30 km/hr",
|
||||
"V2= 35 km/hr",
|
||||
"V3= 10 km/hr",
|
||||
"V4= 15 km/hr",
|
||||
"t1 (상차시간(분)) =20 , E=0.9",
|
||||
"t2 (왕복시간(분)) =(L1/V1+L1/V2+L2/V3+L2/V4) * 60 = 114.57",
|
||||
"t3 (하차시간(분)) =20",
|
||||
"t4 (하차준비시간(분)) =0.42",
|
||||
"Cm (회 사이클 시간(분)) =t1 + t2 + t3 + t4 = 154.99",
|
||||
"N (간단 운반횟수(회)) =60*E/Cm= 0.35 회",
|
||||
"OH (상차 10분 초과 시 운반기계의 유류보정) =(cm-t1-t3)/Cm= 0.74",
|
||||
"노무비 : 55,700/N*2 = 318,285.7",
|
||||
"재료비 : 29,173/N*OH*2 = 123,360.1",
|
||||
"경 비 : 16,353/N*2 = 93,445.7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2024년 간선임도사업(기번3)",
|
||||
"호표": 24,
|
||||
"명칭": "시멘트운반(울진군 울진읍 대흥리) L=19.00km",
|
||||
"규격": "",
|
||||
"단위": "대",
|
||||
"유형": "구역화물_운반",
|
||||
"인자": {},
|
||||
"산식행": [
|
||||
"시멘트운반(울진군 울진읍 대흥리) L=19.00km / 대",
|
||||
"○시멘트운반 L = 19.00 km",
|
||||
"건설표준품셈 1-31-나(품종별 적상.하 기준) 적용",
|
||||
"1)자동차운반",
|
||||
"하치장 -------> 현장중점",
|
||||
"구역화물(5ton적용) L = 20km이내적용",
|
||||
"61,500 / 1.1 / 5 / 25 = 447.2",
|
||||
"2)적상적하비",
|
||||
"적상 : 0.18 인",
|
||||
"적하 : 0.13 인",
|
||||
"(0.18+0.13) * 165,545 / 25 = 2,052.7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2024년 간선임도사업(기번3)",
|
||||
"호표": 31,
|
||||
"명칭": "기계운반(울진군 울진읍 대흥리) L=19.00km",
|
||||
"규격": "",
|
||||
"단위": "대",
|
||||
"유형": "구역화물_운반",
|
||||
"인자": {},
|
||||
"산식행": [
|
||||
"기계운반(울진군 울진읍 대흥리) L=19.00km / 대",
|
||||
"기계 운반( 믹서 , 소형 중기류)",
|
||||
"L=19.00km",
|
||||
"시군--------------------> 현장중점",
|
||||
"○구역화물(5Ton)적용 L = 20km이내적용",
|
||||
"61,500 / 1.1 = 55,909.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2024년 간선임도사업(기번3)",
|
||||
"호표": 35,
|
||||
"명칭": "철근운반(울진군 울진읍 대흥리) L=19.00km",
|
||||
"규격": "",
|
||||
"단위": "톤",
|
||||
"유형": "구역화물_운반",
|
||||
"인자": {},
|
||||
"산식행": [
|
||||
"철근운반(울진군 울진읍 대흥리) L=19.00km / 톤",
|
||||
"철근 운반 ( L = 19 km)",
|
||||
"현 장",
|
||||
"하치장 ------------->",
|
||||
"중 점",
|
||||
"1).구역화물(5ton)적용 L = 20km이내적용",
|
||||
"61,500 / 1.1 / 5 = 11,181.8",
|
||||
"2).TON당 하차비",
|
||||
"2 * 11 / 2 / (480-30) * 165,545 = 4,046.6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2)",
|
||||
"호표": 31,
|
||||
"명칭": "중기운반(울진군 울진읍 대흥리) L=19.00km",
|
||||
"규격": "",
|
||||
"단위": "대",
|
||||
"유형": "중기운반_사이클",
|
||||
"인자": {
|
||||
"L1": "12.0 km(포 장)",
|
||||
"L2": "7.00 km(비포장)",
|
||||
"V1": "30 km/hr",
|
||||
"V2": "35 km/hr",
|
||||
"V3": "10 km/hr",
|
||||
"V4": "15 km/hr",
|
||||
"t4": "154.99",
|
||||
"E": "0.9",
|
||||
"Cm": "0.35 회"
|
||||
},
|
||||
"산식행": [
|
||||
"중기운반(울진군 울진읍 대흥리) L=19.00km / 대",
|
||||
"중 기 운 반 (20톤 트레일러)",
|
||||
"소재지 ---------------------> 현장",
|
||||
"건설표준품셈 11-6나(트럭트레일러) 적용",
|
||||
"중 기 운 반 (20톤 트레일러)",
|
||||
"L1= 12.0 km(포 장)",
|
||||
"L2= 7.00 km(비포장)",
|
||||
"V1= 30 km/hr",
|
||||
"V2= 35 km/hr",
|
||||
"V3= 10 km/hr",
|
||||
"V4= 15 km/hr",
|
||||
"t1 (상차시간(분)) =20 , E=0.9",
|
||||
"t2 (왕복시간(분)) =(L1/V1+L1/V2+L2/V3+L2/V4) * 60 = 114.57",
|
||||
"t3 (하차시간(분)) =20",
|
||||
"t4 (하차준비시간(분)) =0.42",
|
||||
"Cm (회 사이클 시간(분)) =t1 + t2 + t3 + t4 = 154.99",
|
||||
"N (간단 운반횟수(회)) =60*E/Cm= 0.35 회",
|
||||
"OH (상차 10분 초과 시 운반기계의 유류보정) =(cm-t1-t3)/Cm= 0.74",
|
||||
"노무비 : 55,700/N*2 = 318,285.7",
|
||||
"재료비 : 29,173/N*OH*2 = 123,360.1",
|
||||
"경 비 : 16,353/N*2 = 93,445.7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2)",
|
||||
"호표": 32,
|
||||
"명칭": "기계운반(울진군 울진읍 대흥리) L=19.00km",
|
||||
"규격": "",
|
||||
"단위": "대",
|
||||
"유형": "구역화물_운반",
|
||||
"인자": {},
|
||||
"산식행": [
|
||||
"기계운반(울진군 울진읍 대흥리) L=19.00km / 대",
|
||||
"기계 운반( 믹서 , 소형 중기류)",
|
||||
"L=19.00km",
|
||||
"시군--------------------> 현장중점",
|
||||
"○구역화물(5Ton)적용 L = 20km이내적용",
|
||||
"61,500 / 1.1 = 55,909.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2)",
|
||||
"호표": 36,
|
||||
"명칭": "시멘트운반(울진군 울진읍 대흥리) L=19.00km",
|
||||
"규격": "",
|
||||
"단위": "대",
|
||||
"유형": "구역화물_운반",
|
||||
"인자": {},
|
||||
"산식행": [
|
||||
"시멘트운반(울진군 울진읍 대흥리) L=19.00km / 대",
|
||||
"○시멘트운반 L = 19.00 km",
|
||||
"건설표준품셈 1-31-나(품종별 적상.하 기준) 적용",
|
||||
"1)자동차운반",
|
||||
"하치장 -------> 현장중점",
|
||||
"구역화물(5ton적용) L = 20km이내적용",
|
||||
"61,500 / 1.1 / 5.0 / 25 = 447.2",
|
||||
"2)적상적하비",
|
||||
"적상 : 0.18 인",
|
||||
"적하 : 0.13 인",
|
||||
"(0.18+0.13) * 165,545 / 25 = 2,052.7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2)",
|
||||
"호표": 47,
|
||||
"명칭": "철근운반(울진군 울진읍 대흥리) L=19.00km",
|
||||
"규격": "",
|
||||
"단위": "톤",
|
||||
"유형": "구역화물_운반",
|
||||
"인자": {},
|
||||
"산식행": [
|
||||
"철근운반(울진군 울진읍 대흥리) L=19.00km / 톤",
|
||||
"철근 운반 ( L = 19.00 km)",
|
||||
"현 장",
|
||||
"하치장 ------------->",
|
||||
"중 점",
|
||||
"1).구역화물(5ton)적용 L = 20km이내적용",
|
||||
"61,500 / 1.1 / 5 = 11,181.8",
|
||||
"2).TON당 하차비",
|
||||
"2 * 11 / 2 / (480-30) * 165,545 = 4,046.6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경)",
|
||||
"호표": 14,
|
||||
"명칭": "인력운반 기타",
|
||||
"규격": "",
|
||||
"단위": "ton",
|
||||
"유형": "인력운반_사이클",
|
||||
"인자": {
|
||||
"N": "V*T/(120*L+V*T1)= 64.29 회/일",
|
||||
"Q": "N*0.025 ton = 1.61 ton/일",
|
||||
"T": "480-30= 450.00",
|
||||
"T1": "1 T=480-30= 450.00",
|
||||
"V": "2000 T1=1 T=480-30= 450.00"
|
||||
},
|
||||
"산식행": [
|
||||
"인력운반 기타 / ton",
|
||||
"인력운반",
|
||||
"평균 운반거리 L = 100m",
|
||||
"1). 인력운반(L=100M)",
|
||||
"L=100, V=2000, T1=1, T=480-30= 450.00",
|
||||
"N=V*T/(120*L+V*T1)= 64.29 회/일",
|
||||
"Q=N*0.025 ton = 1.61 ton/일",
|
||||
"보통인부 : 169,804 / Q = 105,468.3 원/ton"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외)",
|
||||
"호표": 9,
|
||||
"명칭": "떼 하차비",
|
||||
"규격": "",
|
||||
"단위": "m2",
|
||||
"유형": "직접산식",
|
||||
"인자": {},
|
||||
"산식행": [
|
||||
"떼 하차비 / m2",
|
||||
"◈참고사항",
|
||||
"500매 = 2500kg ⇒ 5 kg/매",
|
||||
"1m2 = 11.11매 ⇒ 0.09 m2/매",
|
||||
"5kg/매 ÷ 0.09m2/매 = 55.55kg/m2 임",
|
||||
"○ 적하비 (2인 1조)",
|
||||
"2 인 * 16 /(480-30) * 169,804 * 55/1000 = 664.1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외)",
|
||||
"호표": 10,
|
||||
"명칭": "평떼붙이기",
|
||||
"규격": "",
|
||||
"단위": "m2",
|
||||
"유형": "일작업량_환산",
|
||||
"인자": {
|
||||
"Q": "150 m2/일"
|
||||
},
|
||||
"산식행": [
|
||||
"평떼붙이기 / m2",
|
||||
"※ 건설표준품셈(공통) : 4-1-1 적용",
|
||||
"□ 일작업량 : Q = 150 m2/일",
|
||||
"1. 인부임",
|
||||
"○ 조경공:",
|
||||
"224,132 * 1.0 인 / Q = 1,494.2 원/m2",
|
||||
"○보통인부 :",
|
||||
"169,804 * 4.0 인 / Q = 4,528.1 원/m2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외)",
|
||||
"호표": 16,
|
||||
"명칭": "구조물터파기(토사) 굴착기0.6m3",
|
||||
"규격": "",
|
||||
"단위": "m3",
|
||||
"유형": "일작업량_환산",
|
||||
"인자": {
|
||||
"Q": "190 m3/일",
|
||||
"q1": "Q / 8 = 23.75 m3/hr"
|
||||
},
|
||||
"산식행": [
|
||||
"구조물터파기(토사) 굴착기0.6m3 / m3",
|
||||
"※ 건설표준품셈(공통) : 3-2-4 적용",
|
||||
"□ 일 시공량 : Q = 190 m3/일",
|
||||
"※ 작업조건 : Type-Ⅲ (협소)",
|
||||
"1. 굴착기 (0.6m3)",
|
||||
"□ 시간당 시공량 : q1 = Q / 8 = 23.75 m3/hr",
|
||||
"노 무 비 : 57,077 / q1 = 2,403.2",
|
||||
"재 료 비 : 19,547 / q1 = 823.0",
|
||||
"경 비 : 26,463 / q1 = 1,114.2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"원본": "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외)",
|
||||
"호표": 19,
|
||||
"명칭": "제근 밀림(90m3/ha이상)",
|
||||
"규격": "",
|
||||
"단위": "m2",
|
||||
"유형": "직접산식",
|
||||
"인자": {
|
||||
"A": "0.05 인/100m2"
|
||||
},
|
||||
"산식행": [
|
||||
"제근 밀림(90m3/ha이상) / m2",
|
||||
"◇임도품셈 2-3 적용",
|
||||
"▷ 보통인부 소요인부 : A = 0.05 인/100m2",
|
||||
"▷ 굴삭기(0.7m3) 소요시간 : B = 0.7 hr/100m2",
|
||||
"1) 작업인부",
|
||||
"보통인부 : 169,804 * A/100 = 84.9",
|
||||
"2) 굴삭기(07",
|
||||
"노 무 비 : 57,077 * B/100 = 399.5",
|
||||
"재 료 비 : 19,232 * B/100 = 134.6",
|
||||
"경 비 : 24,001 * B/100 = 168.0"
|
||||
]
|
||||
}
|
||||
]
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""STmate 레시피 350개를 2026년 현행 품셈 원문과 대조한 근거 장부."""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
from collections import Counter
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
|
||||
def repo_root():
|
||||
here = pathlib.Path(__file__).resolve()
|
||||
return next(parent for parent in here.parents if (parent / "resources" / "knowledge").is_dir())
|
||||
|
||||
|
||||
ROOT = repo_root()
|
||||
BASE = ROOT / "resources" / "knowledge" / "original"
|
||||
ANALYSIS = BASE / "경쟁사 프로그램" / "STmate 분석"
|
||||
RECIPE = ANALYSIS / "30_원자료" / "일위대가_레시피.json"
|
||||
OUTPUT = ANALYSIS / "30_원자료" / "일위대가_현행품셈_대조.json"
|
||||
REPORT = ANALYSIS / "30_원자료" / "일위대가_현행품셈_대조_감사.txt"
|
||||
SOURCES = {
|
||||
"산림": BASE
|
||||
/ "행정규칙"
|
||||
/ "임도 품셈 적용기준 (현 산림사업 표준품셈)"
|
||||
/ "첨부"
|
||||
/ "(산림청고시 제2025-82호) 산림사업 표준품셈.md",
|
||||
"건설": BASE / "원가계산" / "건설공사_표준품셈" / "2026년_건설공사_표준품셈.md",
|
||||
}
|
||||
HEADING = re.compile(r"^#{2,4}\s*(\d+(?:-\d+){1,3})\.?\s*(.*)$", re.MULTILINE)
|
||||
NUMBER = re.compile(r"(?<![\d.])-?\d+(?:\.\d+)?(?![\d.])")
|
||||
|
||||
|
||||
def sections(path):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
hits = list(HEADING.finditer(text))
|
||||
return [
|
||||
{
|
||||
"code": hit.group(1),
|
||||
"title": hit.group(2).strip(),
|
||||
"line": text.count("\n", 0, hit.start()) + 1,
|
||||
"body": text[hit.end() : hits[i + 1].start() if i + 1 < len(hits) else len(text)],
|
||||
}
|
||||
for i, hit in enumerate(hits)
|
||||
]
|
||||
|
||||
|
||||
def compact(value):
|
||||
return re.sub(r"[^0-9A-Za-z가-힣]", "", value or "").lower()
|
||||
|
||||
|
||||
def words(value):
|
||||
return {
|
||||
word
|
||||
for word in re.findall(r"[가-힣A-Za-z]{2,}", value or "")
|
||||
if word not in {"적용", "표준품셈"}
|
||||
}
|
||||
|
||||
|
||||
def source_order(reference):
|
||||
if "건설" in reference:
|
||||
return ("건설",)
|
||||
if "임도" in reference or "산림" in reference:
|
||||
return ("산림",)
|
||||
return ("산림", "건설")
|
||||
|
||||
|
||||
def candidate(recipe, indexes):
|
||||
reference = recipe.get("품셈근거") or ""
|
||||
code_hit = re.search(r"\b\d+(?:-\d+){1,3}\b", reference)
|
||||
target_words = words(reference) | words(recipe["명칭"])
|
||||
ranked = []
|
||||
for source in source_order(reference):
|
||||
for section in indexes[source]:
|
||||
score = 0
|
||||
title_words = words(section["title"])
|
||||
score += len(target_words & title_words) * 3
|
||||
section_title = compact(section["title"])
|
||||
recipe_title = compact(recipe["명칭"])
|
||||
if section_title and (section_title in recipe_title or recipe_title in section_title):
|
||||
score += 7
|
||||
elif (
|
||||
section_title and SequenceMatcher(None, section_title, recipe_title).ratio() >= 0.65
|
||||
):
|
||||
score += 5
|
||||
parenthetical = [compact(value) for value in re.findall(r"\(([^)]+)\)", reference)]
|
||||
if section_title and any(
|
||||
value and (value in section_title or section_title in value)
|
||||
for value in parenthetical
|
||||
):
|
||||
score += 7
|
||||
if code_hit and code_hit.group() == section["code"] and score:
|
||||
score += 2
|
||||
if score:
|
||||
ranked.append((score, source, section))
|
||||
if not ranked:
|
||||
return None
|
||||
ranked.sort(key=lambda item: (-item[0], item[1], item[2]["line"]))
|
||||
return ranked[0]
|
||||
|
||||
|
||||
def observed_quantities(recipe):
|
||||
values = []
|
||||
for item in recipe["구성"]:
|
||||
value = item["수량"]
|
||||
try:
|
||||
number = float(str(value).replace(",", ""))
|
||||
except ValueError:
|
||||
continue
|
||||
values.append({"명칭": item["명칭"], "수량": number, "단위": item["단위"]})
|
||||
return values
|
||||
|
||||
|
||||
def number_present(value, body):
|
||||
target = float(value)
|
||||
return any(abs(float(raw) - target) < 1e-9 for raw in NUMBER.findall(body.replace(",", "")))
|
||||
|
||||
|
||||
recipes = json.loads(RECIPE.read_text(encoding="utf-8"))
|
||||
indexes = {name: sections(path) for name, path in SOURCES.items()}
|
||||
result = []
|
||||
for recipe in recipes:
|
||||
reference = recipe.get("품셈근거") or ""
|
||||
match = candidate(recipe, indexes)
|
||||
quantities = observed_quantities(recipe)
|
||||
row = {
|
||||
"종류": recipe["종류"],
|
||||
"명칭": recipe["명칭"],
|
||||
"규격": recipe["규격"],
|
||||
"단위": recipe["단위"],
|
||||
"관측수량": quantities,
|
||||
"실무_품셈근거": reference or None,
|
||||
"현행근거": None,
|
||||
}
|
||||
if match:
|
||||
score, source, section = match
|
||||
found = sum(number_present(item["수량"], section["body"]) for item in quantities)
|
||||
row["현행근거"] = {
|
||||
"원문": str(SOURCES[source].relative_to(ROOT)).replace("\\", "/"),
|
||||
"절": section["code"],
|
||||
"제목": section["title"],
|
||||
"줄": section["line"],
|
||||
"후보점수": score,
|
||||
"수량문자_확인": found,
|
||||
"수량개수": len(quantities),
|
||||
}
|
||||
if reference and score >= 7 and quantities and found == len(quantities):
|
||||
row["상태"] = "현행원문_수량문자_전부확인"
|
||||
elif reference and score >= 7:
|
||||
row["상태"] = "현행절_확인_수량수동대조필요"
|
||||
else:
|
||||
row["상태"] = "현행절_후보_수동대조필요"
|
||||
elif reference:
|
||||
row["상태"] = "근거문구_현행위치미확인"
|
||||
else:
|
||||
row["상태"] = "근거미기재"
|
||||
result.append(row)
|
||||
|
||||
assert len(result) == 350
|
||||
OUTPUT.write_text(json.dumps(result, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
counts = Counter(row["상태"] for row in result)
|
||||
lines = ["### STmate 일위대가 350개 × 2026년 현행 품셈 대조", ""]
|
||||
lines += [f"{name}: {count}" for name, count in sorted(counts.items())]
|
||||
lines += [
|
||||
"",
|
||||
"※ '수량문자 확인'은 현행 절 본문에 같은 숫자가 존재한다는 1차 검사이며 확정 판정이 아니다.",
|
||||
"※ 근거가 없거나 조건·규격 해석이 필요한 값은 사용자 협의 없이 갱신하지 않았다.",
|
||||
]
|
||||
REPORT.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print("\n".join(lines))
|
||||
@@ -1,67 +1,117 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""일위대가 레시피 사전 — 전수 추출 + 누락 감사.
|
||||
시트 셋(일위대가표·중기사용료·단가산출근거)의 호표를 하나도 빠뜨리지 않는다."""
|
||||
|
||||
import sys, io, re, pathlib, warnings, collections, json
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
warnings.filterwarnings("ignore"); import openpyxl
|
||||
warnings.filterwarnings("ignore")
|
||||
import openpyxl
|
||||
|
||||
|
||||
def _repo():
|
||||
p = pathlib.Path(__file__).resolve()
|
||||
for q in p.parents:
|
||||
if (q / "resources" / "knowledge").is_dir(): return q
|
||||
if (q / "resources" / "knowledge").is_dir():
|
||||
return q
|
||||
return pathlib.Path(r"D:\aislo-wt\sub")
|
||||
REPO = _repo(); PRAC = REPO / "resources" / "knowledge" / "original" / "실무문서"
|
||||
|
||||
HOPYO = re.compile(r"^제?\s*\d+\s*호표$")
|
||||
|
||||
REPO = _repo()
|
||||
PRAC = REPO / "resources" / "knowledge" / "original" / "실무문서"
|
||||
|
||||
HOPYO = re.compile(r"^제?\s*\d+\s*호표$")
|
||||
RE_COMP = re.compile(r"(노\s*무\s*비|재\s*료\s*비|경\s*비)\s*:\s*([\d,]+(?:\.\d+)?)\s*/\s*Q")
|
||||
RE_QF = re.compile(r"\bQ\s*=\s*([^=\n']{3,70})")
|
||||
RE_DIRECT = re.compile(r"^\s*([^:*=]{2,40}?)\s*:\s*([\d,]+(?:\.\d+)?)\s*\*\s*([\d,]+(?:\.\d+)?)\s*([^\s/=]{0,8})")
|
||||
RE_PUM = re.compile(r"[▣▶●]\s*(.{3,60})")
|
||||
RE_QF = re.compile(r"\bQ\s*=\s*([^=\n']{3,70})")
|
||||
RE_DIRECT = re.compile(
|
||||
r"^\s*([^:*=]{2,40}?)\s*:\s*([\d,]+(?:\.\d+)?)\s*\*\s*([\d,]+(?:\.\d+)?)\s*([^\s/=]{0,8})"
|
||||
)
|
||||
RE_PUM = re.compile(r"(?:[▣▶●※]\s*)?((?:임도|건설|산림)?표준품셈.{3,80})")
|
||||
RE_REV = re.compile(r"^\s*([\d,]+(?:\.\d+)?)\s*\*\s*([\d,]+(?:\.\d+)?)\s*=")
|
||||
SKIP = {"합계", "계", "소계", "총계", "총합계"}
|
||||
SKIP = {"합계", "계", "소계", "총계", "총합계"}
|
||||
|
||||
|
||||
def parse_grid(ws):
|
||||
"""일위대가표·중기사용료 — A열 제목, 구성이 자원행."""
|
||||
rows = [list(r) for r in ws.iter_rows(min_row=1, max_row=ws.max_row, max_col=14, values_only=True)]
|
||||
out = []; cur = None
|
||||
rows = [
|
||||
list(r) for r in ws.iter_rows(min_row=1, max_row=ws.max_row, max_col=14, values_only=True)
|
||||
]
|
||||
out = []
|
||||
cur = None
|
||||
for r in rows:
|
||||
a = (str(r[0]) if r[0] is not None else "").strip()
|
||||
k = a.replace(" ", "")
|
||||
if HOPYO.match(k):
|
||||
cur = {"no": len(out)+1, "title": None, "items": [], "q": None, "raw": 0}; out.append(cur); continue
|
||||
if cur is None: continue
|
||||
if k in SKIP: continue
|
||||
cur = {"no": len(out) + 1, "title": None, "items": [], "q": None, "raw": 0}
|
||||
out.append(cur)
|
||||
continue
|
||||
if cur is None:
|
||||
continue
|
||||
if k in SKIP:
|
||||
continue
|
||||
cur["raw"] += 1
|
||||
if cur["title"] is None and a and (r[3] not in (None, "")) and (r[2] in (None, "")):
|
||||
cur["title"] = (a, str(r[1] or "").strip(), str(r[3] or "").strip()); continue
|
||||
cur["title"] = (a, str(r[1] or "").strip(), str(r[3] or "").strip())
|
||||
continue
|
||||
if a and (r[2] not in (None, "")):
|
||||
cur["items"].append((a, str(r[1] or "").strip(), str(r[3] or "").strip(), r[2]))
|
||||
return out
|
||||
|
||||
|
||||
def parse_san(ws):
|
||||
"""단가산출근거 — B열 제목('명칭 규격 / 단위'), 구성은 산식(중기 시간당단가 / Q)."""
|
||||
rows = [list(r) for r in ws.iter_rows(min_row=1, max_row=ws.max_row, max_col=8, values_only=True)]
|
||||
out = []; cur = None
|
||||
rows = [
|
||||
list(r) for r in ws.iter_rows(min_row=1, max_row=ws.max_row, max_col=8, values_only=True)
|
||||
]
|
||||
out = []
|
||||
cur = None
|
||||
for r in rows:
|
||||
b = (str(r[1]) if r[1] is not None else "").strip()
|
||||
k = b.replace(" ", "")
|
||||
if HOPYO.match(k):
|
||||
amt = sum(x for x in r[2:6] if isinstance(x,(int,float)))
|
||||
cur = {"no": len(out)+1, "title": None, "items": [], "q": None, "pum": None, "raw": 0, "amt": amt}; out.append(cur); continue
|
||||
if cur is None: continue
|
||||
if k in SKIP: continue
|
||||
amt = sum(x for x in r[2:6] if isinstance(x, (int, float)))
|
||||
cur = {
|
||||
"no": len(out) + 1,
|
||||
"title": None,
|
||||
"items": [],
|
||||
"q": None,
|
||||
"pum": None,
|
||||
"raw": 0,
|
||||
"lines": [],
|
||||
"amt": amt,
|
||||
}
|
||||
out.append(cur)
|
||||
continue
|
||||
if cur is None:
|
||||
continue
|
||||
if k in SKIP:
|
||||
continue
|
||||
cur["raw"] += 1
|
||||
if b:
|
||||
cur["lines"].append(b)
|
||||
if cur["title"] is None and b and "/" in b:
|
||||
nm, _, un = b.rpartition("/")
|
||||
nm = nm.strip(); un = un.strip(); sz = ""
|
||||
nm = nm.strip()
|
||||
un = un.strip()
|
||||
sz = ""
|
||||
if " " in nm:
|
||||
p1, p2 = nm.split(" ", 1); nm, sz = p1.strip(), p2.strip()
|
||||
cur["title"] = (nm, sz, un); continue
|
||||
p1, p2 = nm.split(" ", 1)
|
||||
nm, sz = p1.strip(), p2.strip()
|
||||
cur["title"] = (nm, sz, un)
|
||||
continue
|
||||
if cur.get("pum") is None:
|
||||
mp = RE_PUM.search(b)
|
||||
if mp:
|
||||
cur["pum"] = mp.group(1).strip(" '\"")
|
||||
m = RE_QF.search(b)
|
||||
if m and cur["q"] is None: cur["q"] = re.sub(r"\s+", "", m.group(1))
|
||||
if m and cur["q"] is None:
|
||||
cur["q"] = re.sub(r"\s+", "", m.group(1))
|
||||
hit = False
|
||||
for mm in RE_COMP.finditer(b):
|
||||
cur["items"].append((mm.group(1).replace(" ", ""), "시간당 성분단가", "원", mm.group(2))); hit = True
|
||||
cur["items"].append(
|
||||
(mm.group(1).replace(" ", ""), "시간당 성분단가", "원", mm.group(2))
|
||||
)
|
||||
hit = True
|
||||
if not hit:
|
||||
md = RE_DIRECT.match(b)
|
||||
if md:
|
||||
@@ -70,56 +120,159 @@ def parse_san(ws):
|
||||
if not hit:
|
||||
mr = RE_REV.match(b)
|
||||
if mr:
|
||||
cur["items"].append(((str(r[0]) if r[0] is not None else "").strip() or "자원", "", "", mr.group(1)))
|
||||
cur["items"].append(
|
||||
((str(r[0]) if r[0] is not None else "").strip() or "자원", "", "", mr.group(1))
|
||||
)
|
||||
hit = True
|
||||
if not hit and cur.get("pum") is None:
|
||||
mp = RE_PUM.search(b)
|
||||
if mp: cur["pum"] = mp.group(1).strip()
|
||||
return out
|
||||
|
||||
|
||||
def special_kind(lines):
|
||||
text = "\n".join(lines)
|
||||
if "L1=" in text and "V1=" in text and "Cm" in text:
|
||||
return "중기운반_사이클"
|
||||
if "N=V*T" in text.replace(" ", ""):
|
||||
return "인력운반_사이클"
|
||||
if "구역화물" in text:
|
||||
return "구역화물_운반"
|
||||
if "Q=3600" in text.replace(" ", ""):
|
||||
return "기계작업_사이클"
|
||||
if "일작업량" in text or "일 시공량" in text:
|
||||
return "일작업량_환산"
|
||||
return "직접산식"
|
||||
|
||||
|
||||
def special_values(lines):
|
||||
text = "\n".join(lines).replace(",", "")
|
||||
keys = "L1 L2 V1 V2 V3 V4 t1 t2 t3 t4 E K f Cm N Q q q1 A T T1 V".split()
|
||||
values = {}
|
||||
for key in keys:
|
||||
m = re.search(rf"(?<![A-Za-z0-9_]){key}\s*=\s*([^\n]+)", text)
|
||||
if m:
|
||||
values[key] = m.group(1).strip()
|
||||
return values
|
||||
|
||||
|
||||
SHEETS = {"일위대가표": "일위대가", "중기사용료": "중기", "단가산출근거": "단가산출"}
|
||||
rec = {}; audit = collections.Counter(); miss = []; files = 0
|
||||
rec = {}
|
||||
audit = collections.Counter()
|
||||
miss = []
|
||||
specials = []
|
||||
files = 0
|
||||
for p in sorted(PRAC.rglob("*.xlsx")):
|
||||
if p.name.startswith("~$"): continue
|
||||
try: wb = openpyxl.load_workbook(p, data_only=True, read_only=True)
|
||||
except Exception: continue
|
||||
if "공사원가계산서" not in wb.sheetnames: continue
|
||||
if p.name.startswith("~$"):
|
||||
continue
|
||||
try:
|
||||
wb = openpyxl.load_workbook(p, data_only=True, read_only=True)
|
||||
except Exception:
|
||||
continue
|
||||
if "공사원가계산서" not in wb.sheetnames:
|
||||
continue
|
||||
files += 1
|
||||
for sh, kind in SHEETS.items():
|
||||
if sh not in wb.sheetnames: continue
|
||||
if sh not in wb.sheetnames:
|
||||
continue
|
||||
bs = parse_san(wb[sh]) if sh == "단가산출근거" else parse_grid(wb[sh])
|
||||
audit[(kind, "호표")] += len(bs)
|
||||
for b in bs:
|
||||
if not b["title"]:
|
||||
audit[(kind, "제목없음")] += 1; miss.append((p.stem[:18], sh, b["no"], "제목 못 잡음", b["raw"])); continue
|
||||
audit[(kind, "제목없음")] += 1
|
||||
miss.append((p.stem[:18], sh, b["no"], "제목 못 잡음", b["raw"]))
|
||||
continue
|
||||
if not b["items"]:
|
||||
if not b.get("amt"): audit[(kind, "빈호표")] += 1
|
||||
else: audit[(kind, "구성없음")] += 1; miss.append((p.stem[:18], sh, b["no"], b["title"][0][:24], b["raw"]))
|
||||
if not b.get("amt"):
|
||||
audit[(kind, "빈호표")] += 1
|
||||
else:
|
||||
audit[(kind, "구성없음")] += 1
|
||||
miss.append((p.stem[:18], sh, b["no"], b["title"][0][:24], b["raw"]))
|
||||
specials.append(
|
||||
{
|
||||
"원본": p.stem,
|
||||
"호표": b["no"],
|
||||
"명칭": b["title"][0],
|
||||
"규격": b["title"][1],
|
||||
"단위": b["title"][2],
|
||||
"유형": special_kind(b["lines"]),
|
||||
"인자": special_values(b["lines"]),
|
||||
"산식행": b["lines"],
|
||||
}
|
||||
)
|
||||
continue
|
||||
audit[(kind, "정상")] += 1
|
||||
key = (kind,) + b["title"]
|
||||
if key in rec: audit[(kind, "중복")] += 1
|
||||
else: rec[key] = (b["items"], b["q"], b.get("pum"))
|
||||
if key in rec:
|
||||
audit[(kind, "중복")] += 1
|
||||
else:
|
||||
rec[key] = (b["items"], b["q"], b.get("pum"))
|
||||
|
||||
print("### 누락 감사 — 출력 엑셀 %d개" % files)
|
||||
print("%-10s %7s %7s %7s %8s %8s %8s" % ("종류","호표","정상","중복","제목없음","빈호표","구성없음"))
|
||||
print(
|
||||
"%-10s %7s %7s %7s %8s %8s %8s"
|
||||
% ("종류", "호표", "정상", "중복", "제목없음", "빈호표", "구성없음")
|
||||
)
|
||||
for kind in ("일위대가", "중기", "단가산출"):
|
||||
print("%-10s %7d %7d %7d %8d %8d %8d" % (kind, audit[(kind,"호표")], audit[(kind,"정상")],
|
||||
audit[(kind,"중복")], audit[(kind,"제목없음")], audit[(kind,"빈호표")], audit[(kind,"구성없음")]))
|
||||
tot = sum(audit[(k,"호표")] for k in SHEETS.values()); ok = sum(audit[(k,"정상")] for k in SHEETS.values())
|
||||
print("\n 호표 %d개 중 %d개 처리 (%.1f%%) · 못 잡은 것 %d개" % (tot, ok, 100*ok/tot, tot-ok))
|
||||
for m in miss[:10]: print(" %-18s %-12s 호표%-4d %-26s 줄%d" % m)
|
||||
print(
|
||||
"%-10s %7d %7d %7d %8d %8d %8d"
|
||||
% (
|
||||
kind,
|
||||
audit[(kind, "호표")],
|
||||
audit[(kind, "정상")],
|
||||
audit[(kind, "중복")],
|
||||
audit[(kind, "제목없음")],
|
||||
audit[(kind, "빈호표")],
|
||||
audit[(kind, "구성없음")],
|
||||
)
|
||||
)
|
||||
tot = sum(audit[(k, "호표")] for k in SHEETS.values())
|
||||
ok = sum(audit[(k, "정상")] for k in SHEETS.values())
|
||||
print(
|
||||
"\n 호표 %d개 중 %d개 처리 (%.1f%%) · 못 잡은 것 %d개" % (tot, ok, 100 * ok / tot, tot - ok)
|
||||
)
|
||||
for m in miss[:10]:
|
||||
print(" %-18s %-12s 호표%-4d %-26s 줄%d" % m)
|
||||
|
||||
print("\n### 서로 다른 조합 %d개" % len(rec))
|
||||
for a, b2 in sorted(collections.Counter(x[0] for x in rec).items()): print(" %-10s %3d개" % (a, b2))
|
||||
for a, b2 in sorted(collections.Counter(x[0] for x in rec).items()):
|
||||
print(" %-10s %3d개" % (a, b2))
|
||||
tot2 = sum(len(v[0]) for v in rec.values())
|
||||
print(" 구성행 합계 %d (조합당 평균 %.1f)" % (tot2, tot2/max(len(rec),1)))
|
||||
print(" 구성행 합계 %d (조합당 평균 %.1f)" % (tot2, tot2 / max(len(rec), 1)))
|
||||
nq = sum(1 for v in rec.values() if v[1])
|
||||
print(" Q식을 가진 조합 %d개" % nq)
|
||||
|
||||
out = REPO/"resources"/"knowledge"/"original"/"경쟁사 프로그램"/"STmate 분석"/"30_원자료"/"일위대가_레시피.json"
|
||||
J = [{"종류":k[0], "명칭":k[1], "규격":k[2], "단위":k[3], "Q식":v[1], "품셈근거":(v[2] if len(v)>2 else None),
|
||||
"구성":[{"명칭":a, "규격":c, "단위":u, "수량":(float(q) if isinstance(q,(int,float)) else str(q))}
|
||||
for a,c,u,q in v[0]]} for k,v in sorted(rec.items())]
|
||||
out = (
|
||||
REPO
|
||||
/ "resources"
|
||||
/ "knowledge"
|
||||
/ "original"
|
||||
/ "경쟁사 프로그램"
|
||||
/ "STmate 분석"
|
||||
/ "30_원자료"
|
||||
/ "일위대가_레시피.json"
|
||||
)
|
||||
J = [
|
||||
{
|
||||
"종류": k[0],
|
||||
"명칭": k[1],
|
||||
"규격": k[2],
|
||||
"단위": k[3],
|
||||
"Q식": v[1],
|
||||
"품셈근거": (v[2] if len(v) > 2 else None),
|
||||
"구성": [
|
||||
{
|
||||
"명칭": a,
|
||||
"규격": c,
|
||||
"단위": u,
|
||||
"수량": (float(q) if isinstance(q, (int, float)) else str(q)),
|
||||
}
|
||||
for a, c, u, q in v[0]
|
||||
],
|
||||
}
|
||||
for k, v in sorted(rec.items())
|
||||
]
|
||||
out.write_text(json.dumps(J, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
print("\n → %s %.1f KB" % (out.name, out.stat().st_size/1024))
|
||||
print("\n → %s %.1f KB" % (out.name, out.stat().st_size / 1024))
|
||||
special_out = out.with_name("특수_단가산출_21개.json")
|
||||
assert len(specials) == 21
|
||||
assert sum(1 for item in specials if item["유형"] == "중기운반_사이클") == 3
|
||||
special_out.write_text(json.dumps(specials, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
print(" → %s %d개" % (special_out.name, len(specials)))
|
||||
|
||||
@@ -102,6 +102,8 @@
|
||||
| `jb_shapes.py` · `jb_block_map.py` · `jb_expansion_diff.py` | JB 43개 형태·블록·확장 차이 |
|
||||
| `rate_grid_defs.py` · `settings_axis_map.py` | 폼에서 그리드·선택축 추출 |
|
||||
|
||||
후속 레시피 분석은 `20_분석/31_일위대가_레시피_사전.md`와 `20_분석/32_특수산식과_현행품셈_대조.md`를 본다.
|
||||
|
||||
## 다음
|
||||
|
||||
`13_통제시험_목록.md` 의 T1~T11. **프로그램을 직접 돌려 값 하나씩 바꿔 보는 일**이라 정적 분석으로는 못 가름. 우선순위 1은 T1(요율 좌표 매핑).
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""구조물도 식 풀이기 — 명세 13장 언어·반올림·참조를 **서버 Node 경로 그대로** 시험 (2026-09-13).
|
||||
|
||||
풀이는 `B08_Quantity_Formula.ts` 한 벌이고(판정 Ⓐ), 서버는 그것을 Node 로 돌림.
|
||||
그래서 이 시험도 파이썬 껍데기(`evaluate_sheets`) → Node 번들 길로 부름 — 화면과 서버가
|
||||
같은 코드를 쓰는지까지 한 번에 잼.
|
||||
|
||||
⭐ 기준값 하나는 **실무 엑셀 캐시값** — 소광리 `07-구조도-소광리.xlsx` 「돌기슭막이(45)찰-고1.0」
|
||||
U20~U31 11줄(2026-09-13 openpyxl 로 읽음). 식을 셀 그대로 옮겨 같은 값이 나오는지 봄.
|
||||
⚠ 부동소수로 풀면 틀리는 자리를 일부러 넣음 — `ROUND(0.95*0.3,2)`(부동소수 0.28499… → 0.28)·
|
||||
`0.29*100` 버림(28.999… → 28). 분수로 풀어야 엑셀·실무 값과 맞음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
|
||||
def _solve(rows: list[dict], vars: dict | None = None, tables: dict | None = None) -> list[dict]:
|
||||
result = evaluate_sheets([{"rows": rows, "vars": vars or {}, "tables": tables or {}}])
|
||||
assert result is not None, "Node 풀이가 안 돌았다"
|
||||
return result[0]
|
||||
|
||||
|
||||
def _one(formula: str, rounding: dict | None = None, **vars) -> dict:
|
||||
return _solve([{"seq": 1, "name": "x", "formula": formula, "rounding": rounding}], vars)[0]
|
||||
|
||||
|
||||
def _amount(formula: str, rounding: dict | None = None, **vars) -> str:
|
||||
row = _one(formula, rounding, **vars)
|
||||
assert row["error"] is None, row
|
||||
return row["amount"]
|
||||
|
||||
|
||||
def test_적힌_차례대로_풂() -> None:
|
||||
"""명세 13장 지킬 것 ① — 접지 않음. 적힌 상수는 적힌 대로."""
|
||||
assert _amount("267360*5/24") == "55700"
|
||||
assert _amount("267360*0.20833") == "55699.1088"
|
||||
|
||||
|
||||
def test_우선순위는_엑셀과_같다() -> None:
|
||||
assert _amount("2+3*4") == "14"
|
||||
assert _amount("10-2-3") == "5"
|
||||
assert _amount("8/2/2") == "2"
|
||||
assert _amount("2^3^2") == "64" # 엑셀은 왼쪽부터
|
||||
assert _amount("-2^2") == "4" # 엑셀은 부호가 ^ 보다 먼저
|
||||
assert _amount("(1+2)*3") == "9"
|
||||
|
||||
|
||||
def test_부동소수_함정을_안_밟는다() -> None:
|
||||
assert _amount("0.29*100", {"mode": "floor", "digits": 0}) == "29"
|
||||
assert _amount("1.15*100", {"mode": "floor", "digits": 0}) == "115"
|
||||
assert _amount("0.95*0.3", {"mode": "round", "digits": 2}) == "0.29"
|
||||
|
||||
|
||||
def test_반올림_갈래() -> None:
|
||||
assert _amount("2.675", {"mode": "round", "digits": 2}) == "2.68" # 사사오입
|
||||
assert _amount("-2.5", {"mode": "round", "digits": 0}) == "-3" # 0 에서 먼 쪽(엑셀 ROUND)
|
||||
assert _amount("2.665", {"mode": "round_half_even", "digits": 2}) == "2.66"
|
||||
assert _amount("2.675", {"mode": "round_half_even", "digits": 2}) == "2.68"
|
||||
assert _amount("0.121", {"mode": "ceil", "digits": 2}) == "0.13"
|
||||
assert _amount("2.61*0.15", {"mode": "floor", "digits": 2}) == "0.39"
|
||||
assert _amount("1234", {"mode": "round", "digits": -2}) == "1200"
|
||||
row = _one("1/3", {"mode": "none", "digits": 0})
|
||||
assert row["amount"] == "0." + "3" * 30
|
||||
|
||||
|
||||
def test_함수() -> None:
|
||||
assert _amount("2*1*SQRT(1+0.3^2)", {"mode": "round", "digits": 3}) == "2.088"
|
||||
assert _amount("SQRT(2.25)") == "1.5"
|
||||
assert _amount("SUM(1,2,3.5)") == "6.5"
|
||||
assert _amount("MIN(3,1,2)") == "1"
|
||||
assert _amount("MAX(3,1,2)") == "3"
|
||||
|
||||
|
||||
def test_IF_와_비교() -> None:
|
||||
식 = "IF(L3=35,0.12,IF(L3=45,0.15,0.18))"
|
||||
assert _amount(식, L3=45) == "0.15"
|
||||
assert _amount(식, L3=55) == "0.18"
|
||||
assert _amount("IF(돌종류='야면석',0.15,0.2)", 돌종류="야면석") == "0.15"
|
||||
assert _amount("IF(L3<>45,1,2)", L3=45) == "2"
|
||||
assert _amount("IF(L3>=45,1,2)", L3=45) == "1"
|
||||
# 안 고른 갈래는 풀지 않음 — 0 으로 나누는 갈래가 줄을 막지 않게.
|
||||
assert _amount("IF(1=1,5,1/0)") == "5"
|
||||
|
||||
|
||||
뒷길이표 = {
|
||||
"뒷길이표": {
|
||||
"keys": [35, 45, 55, 60],
|
||||
"columns": {"고임돌": [0.12, 0.15, 0.18, 0.2], "빈칸": [0.1, None, 0.2, 0.3]},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _lookup(formula: str, **vars) -> dict:
|
||||
return _solve([{"seq": 1, "name": "x", "formula": formula}], vars, 뒷길이표)[0]
|
||||
|
||||
|
||||
def test_LOOKUP_은_근사_일치가_기본() -> None:
|
||||
"""엑셀 `VLOOKUP(…,1)` — 키 이하 중 가장 큰 줄(실무 13건 전부 근사)."""
|
||||
assert _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=45)["amount"] == "0.15"
|
||||
assert _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=50)["amount"] == "0.15"
|
||||
assert _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=100)["amount"] == "0.2"
|
||||
assert "못 찾음" in _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=30)["error"]
|
||||
|
||||
|
||||
def test_LOOKUP_EXACT_와_빈칸() -> None:
|
||||
assert _lookup("LOOKUP_EXACT(뒷길이표, L3, '고임돌')", L3=55)["amount"] == "0.18"
|
||||
assert "못 찾음" in _lookup("LOOKUP_EXACT(뒷길이표, L3, '고임돌')", L3=50)["error"]
|
||||
# 원문 「-」 칸은 값이 아니라 오류 — 0 으로 때우지 않음.
|
||||
assert "비어 있음" in _lookup("LOOKUP(뒷길이표, L3, '빈칸')", L3=45)["error"]
|
||||
assert "없는 열" in _lookup("LOOKUP(뒷길이표, L3, '없는열')", L3=45)["error"]
|
||||
|
||||
|
||||
def test_표_키가_오름차순이_아니면_오류() -> None:
|
||||
tables = {"표": {"keys": [45, 35], "columns": {"a": [1, 2]}}}
|
||||
row = _solve([{"seq": 1, "name": "x", "formula": "LOOKUP(표, 40, 'a')"}], {}, tables)[0]
|
||||
assert "오름차순" in row["error"]
|
||||
|
||||
|
||||
def test_앞_줄만_참조한다() -> None:
|
||||
rows = [
|
||||
{"seq": 1, "name": "면적", "formula": "H*L"},
|
||||
{"seq": 2, "name": "고임돌", "formula": "AREA*0.15", "refs": {"AREA": 1}},
|
||||
{"seq": 3, "name": "뒷줄", "formula": "X*2", "refs": {"X": 4}},
|
||||
{"seq": 4, "name": "자기", "formula": "S*2", "refs": {"S": 4}},
|
||||
]
|
||||
result = _solve(rows, {"H": 2.5, "L": 10})
|
||||
assert [row["amount"] for row in result[:2]] == ["25", "3.75"]
|
||||
assert "앞 줄만" in result[2]["error"]
|
||||
assert "앞 줄만" in result[3]["error"]
|
||||
|
||||
|
||||
def test_오류_난_줄을_가리키면_막힌다() -> None:
|
||||
rows = [
|
||||
{"seq": 1, "name": "나눔", "formula": "1/0"},
|
||||
{"seq": 2, "name": "뒤", "formula": "A+1", "refs": {"A": 1}},
|
||||
]
|
||||
first, second = _solve(rows)
|
||||
assert "0 으로 나눔" in first["error"] and first["amount"] is None
|
||||
assert "앞 줄 1" in second["error"] and second["amount"] is None
|
||||
|
||||
|
||||
def test_참조는_반올림_뒤_값을_받는다() -> None:
|
||||
"""엑셀 셀 참조와 같음 — `INT(…)` 가 든 셀을 가리키면 버린 값이 옴."""
|
||||
rows = [
|
||||
{"seq": 1, "name": "a", "formula": "0.456", "rounding": {"mode": "floor", "digits": 2}},
|
||||
{"seq": 2, "name": "b", "formula": "A*100", "refs": {"A": 1}},
|
||||
]
|
||||
result = _solve(rows)
|
||||
assert result[0]["raw"] == "0.456" and result[0]["amount"] == "0.45"
|
||||
assert result[1]["amount"] == "45"
|
||||
|
||||
|
||||
def test_고정형과_줄_제원() -> None:
|
||||
rows = [
|
||||
{"seq": 1, "name": "박힌값", "amount": "2.088"},
|
||||
{"seq": 2, "name": "빈", "formula": ""},
|
||||
{"seq": 3, "name": "줄이이김", "formula": "K", "vars": {"K": 7}},
|
||||
]
|
||||
result = _solve(rows, {"K": 3})
|
||||
assert result[0]["amount"] == "2.088"
|
||||
assert "식도 값도 없음" in result[1]["error"]
|
||||
assert result[2]["amount"] == "7"
|
||||
|
||||
|
||||
def test_모르는_이름_함수_글자는_오류() -> None:
|
||||
assert "모르는 이름" in _one("constructor")["error"]
|
||||
assert "모르는 함수" in _one("EVAL(1)")["error"]
|
||||
assert "읽을 수 없는 글자" in _one("1;2")["error"]
|
||||
assert "따옴표" in _one("'abc")["error"]
|
||||
assert (
|
||||
"같은 차례"
|
||||
in _solve(
|
||||
[{"seq": 1, "name": "a", "formula": "1"}, {"seq": 1, "name": "b", "formula": "2"}]
|
||||
)[1]["error"]
|
||||
)
|
||||
|
||||
|
||||
def test_실무_찰쌓기_탭을_셀_그대로_재현() -> None:
|
||||
"""소광리 「돌기슭막이(45)찰-고1.0」 — H=1 · 뒷길이 45 · 1:0.3 · 윗폭 300㎜.
|
||||
|
||||
셀 식을 이름만 바꿔 옮김(C5→H, D3→L3, J11→N, L4→TOP_MM). 엑셀 캐시값과 같아야 함.
|
||||
"""
|
||||
vars = {"H": 1, "L3": 45, "N": 0.3, "TOP_MM": 300}
|
||||
rows = [
|
||||
{"seq": 1, "name": "면적", "formula": "H*1*1.044"},
|
||||
{"seq": 2, "name": "중량", "formula": "A*0.91", "refs": {"A": 1}},
|
||||
{
|
||||
"seq": 3,
|
||||
"name": "체적",
|
||||
"formula": "((L3*10+TOP_MM)/1000+(L3*10+H*1000*N)/1000)/2*A",
|
||||
"refs": {"A": 1},
|
||||
},
|
||||
{
|
||||
"seq": 4,
|
||||
"name": "고임돌",
|
||||
"formula": "A*IF(L3=35,0.12,IF(L3=45,0.15,IF(L3=55,0.18,0)))",
|
||||
"refs": {"A": 1},
|
||||
"rounding": {"mode": "floor", "digits": 2},
|
||||
},
|
||||
{"seq": 5, "name": "석적", "formula": "H*L3/100*0.77"},
|
||||
{"seq": 6, "name": "막자갈", "formula": "(TOP_MM/1000+H*1000*N/1000)/2*H"},
|
||||
{
|
||||
"seq": 7,
|
||||
"name": "채움콘크리트",
|
||||
"formula": "A*IF(L3=35,0.16,IF(L3=45,0.2,IF(L3=55,0.25,0)))",
|
||||
"refs": {"A": 1},
|
||||
"rounding": {"mode": "floor", "digits": 2},
|
||||
},
|
||||
{"seq": 8, "name": "모르터", "formula": "A*0.009", "refs": {"A": 1}},
|
||||
{"seq": 9, "name": "물구멍", "formula": "A*1/2.5*0.5", "refs": {"A": 1}},
|
||||
{
|
||||
"seq": 10,
|
||||
"name": "터파기",
|
||||
"formula": "((H*1000*N+L3*10)/1000+0.2)*0.3*1",
|
||||
"rounding": {"mode": "round", "digits": 2},
|
||||
},
|
||||
{
|
||||
"seq": 11,
|
||||
"name": "되메우기",
|
||||
"formula": "(0.2*0.3+(TOP_MM/1000+0.2)*(TOP_MM/1000+0.2)/2)*1",
|
||||
},
|
||||
{"seq": 12, "name": "잔토정리", "formula": "T-B", "refs": {"T": 10, "B": 11}},
|
||||
]
|
||||
엑셀 = {
|
||||
"면적": 1.044,
|
||||
"중량": 0.9500400000000001,
|
||||
"체적": 0.783,
|
||||
"고임돌": 0.15,
|
||||
"석적": 0.34650000000000003,
|
||||
"막자갈": 0.3,
|
||||
"채움콘크리트": 0.2,
|
||||
"모르터": 0.009396,
|
||||
"물구멍": 0.2088,
|
||||
"터파기": 0.29,
|
||||
"되메우기": 0.185,
|
||||
"잔토정리": 0.10499999999999998,
|
||||
}
|
||||
result = {row["name"]: row for row in _solve(rows, vars)}
|
||||
for name, cached in 엑셀.items():
|
||||
assert result[name]["error"] is None, result[name]
|
||||
# 엑셀 캐시는 부동소수 끝자리가 흔들림 — 유효 12자리까지 같으면 같은 값.
|
||||
assert float(result[name]["amount"]) == pytest.approx(cached, rel=1e-12), name
|
||||
# 그러나 우리 값은 **흔들림 없는 십진수**다.
|
||||
assert result["잔토정리"]["amount"] == "0.105"
|
||||
assert result["석적"]["amount"] == "0.3465"
|
||||
Reference in New Issue
Block a user