Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad9a50c584 | ||
|
|
c1872fe41b | ||
|
|
1a0eaf1dee | ||
|
|
cbbe03e81d | ||
|
|
3439e1ec6f | ||
|
|
741805ffde | ||
|
|
da8867b0e7 | ||
|
|
8e3072b95e | ||
|
|
7b8a5850df | ||
|
|
806d5ecc7d | ||
|
|
7317dbe4e5 | ||
|
|
5b4d6e9daf | ||
|
|
a07ad33306 | ||
|
|
09f4de0607 | ||
|
|
5c551c3105 | ||
|
|
221ac33e81 | ||
|
|
aef35a456b | ||
|
|
1724d98ae9 | ||
|
|
8a3ac5ad78 | ||
|
|
cdf0e46914 | ||
|
|
b0c0c923fc | ||
|
|
462e79abe2 | ||
|
|
dbc1263232 | ||
|
|
c2e84eaa3d | ||
|
|
7dd5bd7608 | ||
|
|
85408c100d | ||
|
|
937c5f9a56 | ||
|
|
e6cbe3ddac | ||
|
|
f072aab248 | ||
|
|
852aedf849 | ||
|
|
d25894a473 | ||
|
|
f60c593686 | ||
|
|
252fbf7fee | ||
|
|
af156a3c3a | ||
|
|
fd30e71dba | ||
|
|
b8cd50fd63 | ||
|
|
74faf9cf40 | ||
|
|
eaab09896b | ||
|
|
0c27c0793a | ||
|
|
a83b923363 | ||
|
|
9e59cffbbe | ||
|
|
4e7ebf7f87 | ||
|
|
043bc1ee78 | ||
|
|
2094436577 | ||
|
|
79769e6a47 | ||
|
|
22588ea7c4 | ||
|
|
fbaf2f1d28 | ||
|
|
f1abfc2fb1 | ||
|
|
61bb15fbde | ||
|
|
1e4ce036c6 | ||
|
|
5779943540 | ||
|
|
74446990f8 | ||
|
|
284ea58419 | ||
|
|
624fd3a555 | ||
|
|
82e598b11b |
@@ -30,6 +30,7 @@ import {
|
||||
newVar,
|
||||
setRow,
|
||||
shapeOf,
|
||||
sourceOf,
|
||||
unfold,
|
||||
type StackItem,
|
||||
type StackLine,
|
||||
@@ -254,7 +255,9 @@ export function buildBlock(
|
||||
const was = h.before?.get(id);
|
||||
const freed = !iffy && raw !== undefined && !!was && hasIf(tryParse(was) ?? ["num", 0, "0"]);
|
||||
const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
|
||||
const tag = expr ? null : sourceTag(h, id, link);
|
||||
const src = v ? sourceOf(h.doc, v) : null; // 가져온 값(812-3) = 옵션 가로 칸과 같은 값 · 꼬리표만
|
||||
const srcTag = src === "원파일" ? lt("Src_Native") : src === "엑셀" ? lt("Src_Excel") : src;
|
||||
const tag = expr ? null : srcTag ? clipTag(srcTag) : sourceTag(h, id, link);
|
||||
const tools: HTMLElement[] = [];
|
||||
if (sub?.결과 && link?.키)
|
||||
tools.push(
|
||||
|
||||
@@ -51,7 +51,7 @@ export function buildSourced(
|
||||
const n = Number(f.input.value.trim().replace(/,/g, ""));
|
||||
if (f.input.value.trim() === "" || !Number.isFinite(n))
|
||||
return void (f.input.value = String(v.연결?.값 ?? "")); // 수만 — 빈 값 · 글 = 그대로
|
||||
set(id, v, String(n));
|
||||
set(id, v, n); // 수 그대로(글 「0.7」 = 셈 「—」 · 공식 묶음 값 칸 `setRow` 와 같은 꼴 · 812-3)
|
||||
});
|
||||
f.root.dataset.option = id;
|
||||
const tagText = tag === "원파일" ? lt("Src_Native") : tag === "엑셀" ? lt("Src_Excel") : tag;
|
||||
@@ -64,7 +64,7 @@ export function buildSourced(
|
||||
if (guess) f.root.dataset.guess = mark;
|
||||
return guess
|
||||
? cell(f.root, lt("Guess"), "warning", lt("Guess_Hint"))
|
||||
: cell(f.root, lt("Src_Native"), "info", lt("Src_Hint"));
|
||||
: cell(f.root, lt(head.원파일 ? "Src_Native" : "Src_Excel"), "info", lt("Src_Hint")); // 812-1 엑셀 읽기기 = 「엑셀」
|
||||
};
|
||||
if (head.규격) out.push(ro(lt("Spec"), head.규격, "", false));
|
||||
for (const g of head.추정 ?? [])
|
||||
|
||||
@@ -18,9 +18,7 @@
|
||||
.m01lab-src__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere; /* 제목 한 줄이 길면 접음 */
|
||||
}
|
||||
|
||||
.m01lab-src__body {
|
||||
@@ -40,6 +38,11 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* [주] 글 — 본문(--text-body)보다 한 단 작게 */
|
||||
.m01lab-src__note {
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01lab-src__file {
|
||||
margin: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
@@ -56,6 +59,11 @@
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* 표 머리 · 칸 = 본문과 같은 16 — 좁으면 둘레 칸(m01-logic__scroll)이 가로로 굴림 */
|
||||
.m01lab-src .m01lab-src__table :is(td, th) {
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* 출처가 가리킨 항 · 주 — 옅은 바탕 + 왼쪽 막대 */
|
||||
.m01lab-src__hit {
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
|
||||
@@ -38,6 +38,9 @@ interface Found {
|
||||
item?: string;
|
||||
}
|
||||
|
||||
/** 절 제목 블록의 글만 「번호 이름」 꼴로 */
|
||||
const headFixed = (b: Block): Block => (b.h ? { ...b, text: headText(b.text ?? "") } : b);
|
||||
|
||||
function blockNode(b: Block): HTMLElement {
|
||||
const node = rawNode(b);
|
||||
if (b.hit) node.classList.add("m01lab-src__hit");
|
||||
@@ -65,6 +68,27 @@ function rawNode(b: Block): HTMLElement {
|
||||
return el("p", { className: "m01lab-src__p", text: b.p ?? "" });
|
||||
}
|
||||
|
||||
/** 출처 글에서 첫 번호 앞까지 = 책 · 부문(예 「건설품셈 공통」) — 제목 한 줄의 앞 */
|
||||
const prefixOf = (src: string): string => {
|
||||
const at = src.search(/\d+(?:-\d+)+/);
|
||||
return (at < 0 ? src : src.slice(0, at)).trim();
|
||||
};
|
||||
|
||||
/** 절 제목 줄 「9-7-1. 이름」 → 「9-7-1 이름」 */
|
||||
const headText = (t: string): string => t.replace(/^(\d+(?:-\d+)*)\.\s*/, "$1 ");
|
||||
|
||||
/** 「[주]」 줄부터 다음 제목 앞까지 글 줄 = 한 단 작은 글자(`m01lab-src__note`) */
|
||||
function noteMarked(blocks: Block[]): HTMLElement[] {
|
||||
let inNote = false;
|
||||
return blocks.map((b) => {
|
||||
if (b.h) inNote = false;
|
||||
else if (b.p?.trim().startsWith("[주]")) inNote = true;
|
||||
const node = blockNode(b);
|
||||
if (inNote && b.p !== undefined) node.classList.add("m01lab-src__note");
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
export interface SourceView {
|
||||
root: HTMLElement;
|
||||
/** 그 출처 글의 원문을 그림 — 지금 보이는 것과 같으면 안 부름 */
|
||||
@@ -121,20 +145,28 @@ export function createSourceView(opts: SourceViewOptions = {}): SourceView {
|
||||
.then(({ found }) => {
|
||||
if (shown !== src) return;
|
||||
if (!found.length) return none();
|
||||
// 제목 한 줄 = 「책 부문 + 절 번호 · 이름(개정 꼬리)」 — 절 하나면 원문 제목 줄을 숨김 · 여럿이면 절마다 번호 · 이름 줄 둠
|
||||
const lone = found.length === 1 && found[0].blocks[0]?.h ? found[0] : null;
|
||||
if (found.length > 1 || lone) {
|
||||
const pre = prefixOf(src);
|
||||
title.textContent = lone ? `${pre} ${headText(lone.blocks[0].text ?? "")}`.trim() : pre;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...found.flatMap((f) => [
|
||||
el("p", {
|
||||
className: "ui-text ui-text--muted m01lab-src__file",
|
||||
text: [
|
||||
f.file,
|
||||
f.matched === f.number ? "" : ts("Near", f.number),
|
||||
f.item ? (/^\d+$/.test(f.item) ? `${f.item}항` : f.item) : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
}),
|
||||
...f.blocks.map(blockNode),
|
||||
]),
|
||||
...found.flatMap((f) => {
|
||||
// 파일 경로는 안 보임 — 가까운 절 · 항 안내만
|
||||
const note = [
|
||||
f.matched === f.number ? "" : ts("Near", f.number),
|
||||
f.item ? (/^\d+$/.test(f.item) ? `${f.item}항` : f.item) : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return [
|
||||
...(note
|
||||
? [el("p", { className: "ui-text ui-text--muted m01lab-src__file", text: note })]
|
||||
: []),
|
||||
...noteMarked(f === lone ? f.blocks.slice(1) : f.blocks.map(headFixed)),
|
||||
];
|
||||
}),
|
||||
);
|
||||
// 강조 항을 패널 몸 가운데로 — 몸만 굴림(85-5 · scrollIntoView 는 메인 창까지 굴림)
|
||||
requestAnimationFrame(() => {
|
||||
|
||||
@@ -77,7 +77,7 @@ export function createSourceFind(opts: {
|
||||
unit: x.결과단위 ?? "",
|
||||
onPick: () => {
|
||||
find.setRows([]);
|
||||
find.hint.textContent = `${x.원문번호 || x.키} ${x.이름}`;
|
||||
find.hint.textContent = ""; // 고른 기초 이름은 원문 제목 한 줄이 보임
|
||||
opts.onPick(x);
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -52,7 +52,10 @@ def pack(got: dict, name: str) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"꼴": got["꼴"], "파일": name, "표지": got.get("표지") or []}
|
||||
out |= {"엑셀": True} if got.get("엑셀") else {} # 812-1 엑셀 읽기기(`M02_San_Xlsx`) — 같은 꼴
|
||||
head = ("코드", "번", "이름", "규격", "단위", "저장")
|
||||
if got.get("엑셀"): # 엑셀 읽기기 — 줄 행 = 시트 행(원문 카드 · 옛 엑셀 길과 같은 자리)
|
||||
if got.get(
|
||||
"엑셀"
|
||||
): # 엑셀 읽기기 — 줄 행 = 시트 행(원문 카드 · 옛 엑셀 길과 같은 자리) · 블록 표지
|
||||
head += ("표지", "몸", "꼬리")
|
||||
got = {**got, "산근": [_rows(b) for b in got["산근"]]}
|
||||
if got["꼴"] == "EST":
|
||||
out["산근"] = [
|
||||
@@ -106,27 +109,29 @@ def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]:
|
||||
names = {b["코드"]: n for n, b in numbers(saved).items()}
|
||||
out = ne.block(blk, int(no), keys, saved["파일"], names)
|
||||
out["원본"] |= {"원코드": blk["코드"]} | ({"규격": blk["규격"]} if blk.get("규격") else {})
|
||||
return _excel(saved, out)
|
||||
return _excel(saved, out, blk)
|
||||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||||
|
||||
nodes = [{"행": ln["행"], "글": ln["글"], **stm.parse_line(ln["글"])} for ln in blk["줄"]]
|
||||
saved_amt = blk.get("저장") or {}
|
||||
head = {"C": saved_amt.get("T"), "D": saved_amt.get("L"), "E": saved_amt.get("M")}
|
||||
head["F"] = saved_amt.get("S")
|
||||
block = {"코드": blk["코드"], "이름": blk.get("이름"), "몸": [], "꼬리": [], "머리": head}
|
||||
block = {"코드": blk["코드"], "이름": blk.get("이름"), "머리": head}
|
||||
block |= {"몸": blk.get("몸") or [], "꼬리": blk.get("꼬리") or []} # 엑셀 = 줄 금액 · 계 꼬리
|
||||
out = em.stmate(block, nodes, saved["단가"], saved["끝전"], keys, saved["파일"])
|
||||
marks = sum(1 for one in out["원본"]["글줄"] if one.get("표지"))
|
||||
sure = len(out["로직"]["호표"]) + len(out["로직"].get("덧줄") or []) + len(out["로직"]["중간"])
|
||||
out["원본"] |= {"원파일": True, "갈래": "STmate", "확정": {"확정": sure, "표지": marks}}
|
||||
return _excel(saved, out)
|
||||
return _excel(saved, out, blk)
|
||||
|
||||
|
||||
def _excel(saved: dict, out: dict) -> dict[str, Any]:
|
||||
def _excel(saved: dict, out: dict, blk: dict) -> dict[str, Any]:
|
||||
"""엑셀 읽기기 꼴(812-1) — 원본 `원파일` 거짓 · `엑셀` 참(화면 꼬리표 「엑셀」 · 원코드 = 단산 번)."""
|
||||
if saved.get("엑셀"):
|
||||
out["원본"] |= {"원파일": False, "엑셀": True}
|
||||
out["원본"] |= {"시트": "단가산출"} if saved["꼴"] == "EST" else {}
|
||||
out["원본"].pop("원코드", None)
|
||||
out["원본"]["표지"] += [m for m in blk.get("표지") or [] if m.startswith("참고 · ")]
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -161,7 +161,10 @@ def machines(doc: dict, title: dict[str, str]) -> None:
|
||||
|
||||
def shape(doc: dict, meta: dict) -> dict:
|
||||
"""derive 문서 다듬기 — 원본 글줄 · 엔진 줄 자원 이름."""
|
||||
from M02_MasterTemplete import M02_Doc_NativeLayout as lay
|
||||
|
||||
src = meta.get("원본") or {}
|
||||
lay.tag(doc, meta) # 812-3 원파일 차림 — 금액 줄 ↔ 로직 줄(끝에 `apply`)
|
||||
names(doc, list(src.get("글줄") or []))
|
||||
title = {}
|
||||
for item in (meta.get("로직") or {}).get("호표") or []:
|
||||
@@ -169,4 +172,5 @@ def shape(doc: dict, meta: dict) -> dict:
|
||||
if res.get("갈래") == "기계" and res.get("이름"):
|
||||
title[str(item.get("이름"))] = " ".join(x for x in (res["이름"], res.get("규격")) if x)
|
||||
machines(doc, title)
|
||||
lay.apply(doc, meta)
|
||||
return doc
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
② 옵션 풀이 = 그 문서의 선택 · 입력 변수 값을 줄마다 좁힘(줄 수량 · 단가가 의존하는 변수만 돌림 ·
|
||||
값이 가져온 줄 수량 · 단가 묶음에 있거나 0(꺼진 줄)인 값만 남김) → 남은 조합(상한 `CAP_ALL`)을
|
||||
문서 셈 · 입력 변수 후보 = 기본값 · 0 · 가져온 식에 나온 수 · 선택은 가져온 글에 든 값부터.
|
||||
③ 판정 = 줄 대조(수량 · 단가 · 비목 금액 · 율 줄) + 노 · 재 · 경 · 계 —
|
||||
③ 판정 = 줄 대조 = 로직(812-2 · 요구 712 — `M02_Doc_Match_Note`): 다른 항 = 자원 · 기계 규격 · 수량 ·
|
||||
비목 · 율 구조(대상 · 비목) · 줄 없음 · 이름 · 참고 = 단가 · 끝수 · 율 값 %(값 — 확정을 안 막음) —
|
||||
다른 항 0 = 「확정」 · 같은 줄이 하나 넘고 핵심 줄 반 넘게 짝 = 「근접」(다른 항 목록) · 그 밖 「없음」.
|
||||
결과 `{판정, 키, 이름, 옵션, 다른, 금액 {가져온, 우리}, 후보수}` = 가져온 식 원본 `연결후보`.
|
||||
결과 `{판정, 키, 이름, 옵션, 다른, 참고, 금액 {가져온, 우리}, 후보수}` = 가져온 식 원본 `연결후보`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +23,7 @@ from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
from M02_MasterTemplete import M02_Doc_Match_Note as note
|
||||
|
||||
md, mc, ml = dc.md, dc.mc, dc.ml
|
||||
if not hasattr(
|
||||
@@ -133,6 +135,7 @@ def facts(doc: dict, got: dict) -> list[dict]:
|
||||
"합밖": bool(row.get("합밖")),
|
||||
}
|
||||
)
|
||||
note.rates(doc, got, out)
|
||||
return out
|
||||
|
||||
|
||||
@@ -405,7 +408,7 @@ def _ratio(a: Any, b: Any) -> str | None:
|
||||
|
||||
|
||||
def compare(mine: list[dict], ours: list[dict]) -> dict[str, Any]:
|
||||
"""가져온 줄(mine) ↔ M01 줄(ours) → {같음, 짝, 핵심, 다른 [{항, 글}]}."""
|
||||
"""가져온 줄(mine) ↔ M01 줄(ours) → {같음(로직 같은 줄), 짝, 핵심, 다른 [{항, 글}], 참고 [{항, 글}]}."""
|
||||
left, right = list(mine), list(ours)
|
||||
same = paired = 0
|
||||
diffs: list[dict] = []
|
||||
@@ -426,22 +429,29 @@ def compare(mine: list[dict], ours: list[dict]) -> dict[str, Any]:
|
||||
def cls(a: dict, b: dict) -> bool:
|
||||
return a["갈래"] == b["갈래"] or {a["갈래"], b["갈래"]} <= {"기계", "산출"}
|
||||
|
||||
notes: list[dict] = []
|
||||
exact = take(
|
||||
lambda a, b: (
|
||||
a["수량"] == b["수량"] and a["단가"] == b["단가"] and a["비목"] == b["비목"]
|
||||
if core(a, b)
|
||||
else a["갈래"] == b["갈래"] == "율" and a["비목"] == b["비목"]
|
||||
core(a, b)
|
||||
and a["수량"] == b["수량"]
|
||||
and a["단가"] == b["단가"]
|
||||
and a["비목"] == b["비목"]
|
||||
and (a["코드"] == b["코드"] or not (a["코드"] and b["코드"])) # 코드 다름 = 짝 대조
|
||||
)
|
||||
)
|
||||
same += sum(1 for a, _ in exact if a["갈래"] != "율")
|
||||
for a, b in take(lambda a, b: core(a, b) and cls(a, b) and a["수량"] == b["수량"]):
|
||||
paired += 1
|
||||
if a["갈래"] == "기계" and b["갈래"] == "기계" and a["코드"] != b["코드"]:
|
||||
diffs.append({"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']}"})
|
||||
elif a["단가"] != b["단가"]:
|
||||
diffs.append({"항": "단가 차이", "글": f"{a['이름']} 단가 {a['단가']} ↔ {b['단가']}"})
|
||||
else:
|
||||
diffs.append({"항": "비목", "글": f"{a['이름']} 비목 다름"})
|
||||
same += len(exact)
|
||||
|
||||
def twin(a: dict, b: dict) -> bool:
|
||||
return core(a, b) and cls(a, b) and a["수량"] == b["수량"]
|
||||
|
||||
for a, b in take(lambda a, b: twin(a, b) and not note.pair(a, b)[0]): # 로직 같은 짝 먼저
|
||||
same += 1
|
||||
notes += note.pair(a, b)[1]
|
||||
for a, b in take(twin):
|
||||
bad, ref = note.pair(a, b)
|
||||
same, paired = (same + 1, paired) if not bad else (same, paired + 1)
|
||||
diffs += bad
|
||||
notes += ref
|
||||
for a, b in take(lambda a, b: core(a, b) and cls(a, b) and a["단가"] == b["단가"]):
|
||||
paired += 1
|
||||
how = _ratio(a["수량"], b["수량"])
|
||||
@@ -452,10 +462,9 @@ def compare(mine: list[dict], ours: list[dict]) -> dict[str, Any]:
|
||||
diffs.append(
|
||||
{"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']} · 수량 다름"}
|
||||
)
|
||||
for a, b in take(lambda a, b: a["갈래"] == b["갈래"] == "율" and a["금액"] == b["금액"]):
|
||||
diffs.append({"항": "비목", "글": f"율 줄 {a['이름']} 비목 다름"})
|
||||
for a, b in take(lambda a, b: a["갈래"] == b["갈래"] == "율"):
|
||||
diffs.append({"항": "율 값", "글": f"{a['이름']} {a['금액']} ↔ {b['이름']} {b['금액']}"})
|
||||
rate_diffs, rate_notes = note.rate_pairs(left, right, take)
|
||||
diffs += rate_diffs
|
||||
notes += rate_notes
|
||||
for side, rows in (("가져온 쪽만", left), ("우리 쪽만", right)):
|
||||
for x in rows:
|
||||
if x["갈래"] == "율":
|
||||
@@ -464,7 +473,7 @@ def compare(mine: list[dict], ours: list[dict]) -> dict[str, Any]:
|
||||
what = "줄 없음"
|
||||
diffs.append({"항": what, "글": f"{side} — {x['이름']}"})
|
||||
n_core = sum(1 for a in mine if a["갈래"] != "율")
|
||||
return {"같음": same, "짝": same + paired, "핵심": n_core, "다른": diffs}
|
||||
return {"같음": same, "짝": same + paired, "핵심": n_core, "다른": diffs, "참고": notes}
|
||||
|
||||
|
||||
def _totals(got: dict) -> dict:
|
||||
@@ -473,27 +482,30 @@ def _totals(got: dict) -> dict:
|
||||
|
||||
EXPLAINED = (
|
||||
"이름 다름",
|
||||
"단가 차이",
|
||||
"자원",
|
||||
"산출 단가",
|
||||
"율 대상",
|
||||
"단위 환산",
|
||||
"기계 규격",
|
||||
"비목",
|
||||
"율 값",
|
||||
"율 줄",
|
||||
"공구손료 유무",
|
||||
"끝수",
|
||||
)
|
||||
|
||||
|
||||
def verdict(
|
||||
mine: list[dict], ours: list[dict], got_a: dict, got_b: dict, off: list[dict] = ()
|
||||
) -> dict:
|
||||
"""확정 = 다른 항 0(옵션 글 근거까지 맞음) · 근접 = 핵심 줄 반 넘게 짝 + (같은 줄 있음 또는
|
||||
다른 항이 모두 까닭 있는 갈래 — 단가 · 단위 환산 · 기계 규격 · 비목 · 율) · 그 밖 없음."""
|
||||
"""확정 = 다른 항 0(옵션 글 근거까지 맞음 · 참고는 있어도 됨) · 근접 = 핵심 줄 반 넘게 짝 + (같은 줄
|
||||
있음 또는 다른 항이 모두 까닭 있는 갈래 — 자원 · 단위 환산 · 기계 규격 · 비목 · 율) · 그 밖 없음 ·
|
||||
끝수 = 줄 로직 · 단가 모두 같은데 합만 다름(참고)."""
|
||||
res = compare(mine, ours)
|
||||
res["다른"] = [*off, *res["다른"]]
|
||||
ta, tb = _totals(got_a), _totals(got_b)
|
||||
if not res["다른"] and ta != tb:
|
||||
res["다른"].append({"항": "끝수", "글": f"줄은 같고 합이 다름 {ta} ↔ {tb}"})
|
||||
if not res["다른"] and not res["참고"] and ta != tb:
|
||||
res["참고"].append(
|
||||
{"항": "끝수", "글": f"줄은 같고 합이 다름 {_sum_txt(ta)} ↔ {_sum_txt(tb)}"}
|
||||
)
|
||||
if not res["다른"] and res["같음"]:
|
||||
res["판정"] = SURE
|
||||
elif res["짝"] * 2 >= res["핵심"] + 1 and (
|
||||
@@ -505,6 +517,10 @@ def verdict(
|
||||
return res
|
||||
|
||||
|
||||
def _sum_txt(t: dict) -> str:
|
||||
return " · ".join(f"{k} {note.txt(v)}" for k, v in t.items() if v is not None)
|
||||
|
||||
|
||||
# ── 한 문서 ───────────────────────────────────────────────────────────
|
||||
def _calc(doc: dict, links: Any, given: dict) -> dict | None:
|
||||
try:
|
||||
@@ -533,12 +549,26 @@ def try_doc(
|
||||
off = [_off(base, k, pick[k], ev[k]) for k in ev if k in pick and pick[k] not in ev[k]]
|
||||
off += named
|
||||
res = verdict(mine, facts(base, got), got_imp, got, off)
|
||||
rank = (res["판정"] == SURE, -len(off), res["같음"], res["짝"], -len(res["다른"]))
|
||||
rank = (
|
||||
res["판정"] == SURE,
|
||||
-len(off),
|
||||
res["같음"],
|
||||
res["짝"],
|
||||
-len(res["다른"]),
|
||||
-len(res["참고"]),
|
||||
)
|
||||
if best is None or rank > best["순위"]:
|
||||
best = {**res, "순위": rank, "옵션": pick, "금액": _totals(got)}
|
||||
if res["판정"] == SURE:
|
||||
if res["판정"] == SURE and not res["참고"]:
|
||||
break
|
||||
return best or {"판정": NONE, "같음": 0, "짝": 0, "다른": [], "순위": (False, -9, 0, 0, 0)}
|
||||
return best or {
|
||||
"판정": NONE,
|
||||
"같음": 0,
|
||||
"짝": 0,
|
||||
"다른": [],
|
||||
"참고": [],
|
||||
"순위": (False, -9, 0, 0, 0, 0),
|
||||
}
|
||||
|
||||
|
||||
def _off(doc: dict, vid: str, value: Any, seen: set) -> dict:
|
||||
@@ -549,7 +579,7 @@ def _off(doc: dict, vid: str, value: Any, seen: set) -> dict:
|
||||
|
||||
|
||||
def match(imported: dict, links_imp: Any, index: Index, links: Any) -> dict[str, Any]:
|
||||
"""가져온 식 문서 → 연결후보 `{판정, 키, 이름, 옵션, 다른, 금액, 후보수}`."""
|
||||
"""가져온 식 문서 → 연결후보 `{판정, 키, 이름, 옵션, 다른, 참고, 금액, 후보수}`."""
|
||||
links = links if isinstance(links, Memo) else Memo(links)
|
||||
got_imp = _calc(imported, links_imp, {})
|
||||
if got_imp is None:
|
||||
@@ -561,7 +591,7 @@ def match(imported: dict, links_imp: Any, index: Index, links: Any) -> dict[str,
|
||||
res = try_doc(index.docs[key], links, imported, got_imp, mine, index.regions)
|
||||
if best is None or res["순위"] > best["순위"]:
|
||||
best, best_key = res, key
|
||||
if res["판정"] == SURE:
|
||||
if res["판정"] == SURE and not res["참고"]:
|
||||
break
|
||||
if best is None:
|
||||
return {"판정": NONE, "후보수": 0}
|
||||
@@ -575,6 +605,7 @@ def match(imported: dict, links_imp: Any, index: Index, links: Any) -> dict[str,
|
||||
"이름": base.get("이름"),
|
||||
"옵션": _shown(base, best["옵션"]),
|
||||
"다른": best["다른"],
|
||||
"참고": best["참고"],
|
||||
"금액": {"가져온": _plain(_totals(got_imp)), "우리": _plain(best["금액"])},
|
||||
"후보수": len(keys),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""연결 찾기 — 로직 차이(다른 항) 대 값 차이(참고) 가르기(PLAN 812-2 · 요구 712) — `M02_Doc_Match` 가 부름.
|
||||
|
||||
확정을 막는 「다른 항」 = 로직 차이만 — 자원 갈래 · 기계 규격 · 노임 종류 · 자재 이름 종류 · 비목 · 산출 단가 ·
|
||||
율 구조(있고 없음 · 대상 비목 — 율 값 % 는 참고) · 수량(옵션 풀이 뒤 · 원 단위 같아야 함).
|
||||
「참고」 = 값 차이 — 인력 · 재료 · 기계 시간당 단가 · 끝수(합만 다름) · 율 값(%) · 그로 인한 비목 금액 · 계.
|
||||
율 = 율 줄 셈값 ÷ 대상 금액 합 — 대상 = 율 식이 (거쳐서) 부르는 금액 줄 · 비목 이름이면 그 비목 몫 · 번지 합 = 핵심 줄 전부 ·
|
||||
대상 금액 끝수 자리 차이만 지움(유효 6 자리 · 두 바탕 중 짧은 꼴) — 지어 맞추기 아님.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
|
||||
md = dc.md
|
||||
PRICED = ("노임", "재료", "기계") # 시간당 · 단위 단가 = 값(참고)
|
||||
|
||||
|
||||
# ── 율 구조 ───────────────────────────────────────────────────────────
|
||||
def _names(text: Any) -> set[str]:
|
||||
if text in (None, ""):
|
||||
return set()
|
||||
try:
|
||||
return set(md.names(md.parse(text)))
|
||||
except md.DocError:
|
||||
return set()
|
||||
|
||||
|
||||
def _reach(doc: dict) -> dict[str, set[str]]:
|
||||
"""기호 → 바로 부르는 이름(변수 연결 · 식 줄 · 금액 줄 수량 · 단가)."""
|
||||
direct: dict[str, set[str]] = {}
|
||||
for v in doc.get("변수") or []:
|
||||
direct[md.var_id(v)] = set(md.link_names(v.get("연결") or {}))
|
||||
for line in doc.get("식") or []:
|
||||
direct[str(line.get("기호"))] = _names(line.get("식"))
|
||||
for line in doc.get("금액") or []:
|
||||
if line.get("기호"):
|
||||
direct[str(line["기호"])] = _names(line.get("수량")) | _names(line.get("단가"))
|
||||
return direct
|
||||
|
||||
|
||||
def _close(start: set[str], direct: dict[str, set[str]]) -> set[str]:
|
||||
seen, todo = set(start), list(start)
|
||||
while todo:
|
||||
for n in direct.get(todo.pop(), ()):
|
||||
if n not in seen:
|
||||
seen.add(n)
|
||||
todo.append(n)
|
||||
return seen
|
||||
|
||||
|
||||
def _short(r: Decimal) -> Decimal:
|
||||
return Decimal(f"{r:.6g}").normalize()
|
||||
|
||||
|
||||
def rates(doc: dict, got: dict, facts: list[dict]) -> None:
|
||||
"""율 사실에 `율`(비율 · 못 셈 None) · `대상`({(자원 갈래, 비목)})을 채움(제자리)."""
|
||||
money, rows = doc.get("금액") or [], got.get("줄") or []
|
||||
direct = _reach(doc)
|
||||
core = [f for f in facts if f["갈래"] != "율" and not f["합밖"]]
|
||||
for f in facts:
|
||||
if f["갈래"] != "율":
|
||||
continue
|
||||
line, row = money[f["번"]], rows[f["번"]]
|
||||
reach = _close(_names(line.get("수량")) | _names(line.get("단가")), direct)
|
||||
costs = reach & set(md.COSTS)
|
||||
hits = [c for c in core if _hit(money[c["번"]], reach)]
|
||||
if hits: # 줄 기호 · 줄 수량 · 단가 이름 = 그 줄 금액 전부
|
||||
parts = [(c, tuple(c["비목"])) for c in hits]
|
||||
elif costs: # 비목 이름(「노무비 × 4 %」) = 그 비목 몫만
|
||||
parts = [(c, tuple(k for k in c["비목"] if k in costs)) for c in core]
|
||||
else: # 번지 합 · 못 찾음 = 블록 핵심 줄 전부
|
||||
parts = [(c, tuple(c["비목"])) for c in core]
|
||||
parts = [(c, ks) for c, ks in parts if ks]
|
||||
f["대상"] = frozenset((_cls(c["갈래"]), k) for c, ks in parts for k in ks)
|
||||
f["율"] = None
|
||||
amount = _mul(row.get("수량"), row.get("단가"))
|
||||
if amount is None:
|
||||
continue
|
||||
bases = [sum((_num(c["비목"][k]) for c, ks in parts for k in ks), Decimal(0))]
|
||||
if hits:
|
||||
each = (_mul(rows[c["번"]].get("수량"), rows[c["번"]].get("단가")) for c in hits)
|
||||
bases.append(sum((x or 0 for x in each), Decimal(0)))
|
||||
found = [_short(amount / b) for b in bases if b]
|
||||
if found:
|
||||
f["율"] = min(found, key=lambda r: len(r.as_tuple().digits))
|
||||
|
||||
|
||||
def _hit(line: dict, reach: set[str]) -> bool:
|
||||
parts = _names(line.get("수량")) | _names(line.get("단가"))
|
||||
return bool(line.get("기호") and str(line["기호"]) in reach) or bool(parts and parts <= reach)
|
||||
|
||||
|
||||
def _cls(kind: str) -> str:
|
||||
return "기계" if kind == "산출" else kind # 기계경비 묶음(코드 없음) = 기계
|
||||
|
||||
|
||||
def _num(v: Any) -> Decimal:
|
||||
if isinstance(v, dict):
|
||||
return sum((_num(v.get(k)) for k in md.COSTS), Decimal(0))
|
||||
try:
|
||||
return Decimal(str(v)) if v is not None else Decimal(0)
|
||||
except InvalidOperation:
|
||||
return Decimal(0)
|
||||
|
||||
|
||||
def _mul(q: Any, p: Any) -> Decimal | None:
|
||||
if q is None or p is None:
|
||||
return None
|
||||
return _num(q) * _num(p)
|
||||
|
||||
|
||||
def pct(r: Any) -> str:
|
||||
"""비율 → 「9 %」."""
|
||||
if not isinstance(r, Decimal):
|
||||
return "?"
|
||||
return f"{(r * 100).normalize():f} %"
|
||||
|
||||
|
||||
# ── 짝 줄 ─────────────────────────────────────────────────────────────
|
||||
def txt(v: Any) -> str:
|
||||
"""단가 글 — 수 = 원 단위 쉼표 · 기계 묶음 = 노 · 재 · 경."""
|
||||
if isinstance(v, tuple):
|
||||
return " · ".join(f"{k[0]} {txt(x)}" for k, x in zip(md.COSTS, v) if x)
|
||||
if isinstance(v, Decimal):
|
||||
return f"{v.normalize():,f}"
|
||||
return str(v)
|
||||
|
||||
|
||||
_ALIAS = (("로울러", "롤러"), ("굴삭", "굴착"), ("브레이카", "브레이커"), ("로우더", "로더"))
|
||||
_ALIAS += (("물탱크", "살수차"), ("아스팔트페이버", "아스팔트피니셔")) # 같은 기계 다른 이름
|
||||
|
||||
|
||||
def _plain(text: str) -> str:
|
||||
t = re.sub(r"\s", "", text)
|
||||
for old, new in _ALIAS:
|
||||
t = t.replace(old, new)
|
||||
return t
|
||||
|
||||
|
||||
def _head(text: str) -> str:
|
||||
"""기계 이름 머리(괄호 · 규격 앞 한글) — 「머캐덤로울러(자주식)」 → 「머캐덤롤러」."""
|
||||
m = re.match(r"[가-힣]+", _plain(text))
|
||||
return m.group(0) if m else ""
|
||||
|
||||
|
||||
def _same_machine(a: str, b: str) -> bool:
|
||||
x, y = _head(a), _head(b)
|
||||
return bool(x and y) and (x in y or y in x)
|
||||
|
||||
|
||||
def _grams(text: str) -> set[str]:
|
||||
t = _plain(text)
|
||||
return {run[i : i + 2] for run in re.findall(r"[가-힣]+", t) for i in range(len(run) - 1)}
|
||||
|
||||
|
||||
def pair(a: dict, b: dict) -> tuple[list[dict], list[dict]]:
|
||||
"""수량 같은 짝 줄 → (다른 항, 참고) — 단가만 다르면 참고 · 자원 · 비목이 다르면 다른 항."""
|
||||
diffs: list[dict] = []
|
||||
kinds = {a["갈래"], b["갈래"]}
|
||||
# 기계경비 숫자 묶음(코드 없음 · 원파일 값) ↔ 우리 기계 = 이름 종류로 같은 기계인지
|
||||
loose = kinds == {"기계", "산출"} and not (a if a["갈래"] == "산출" else b)["코드"]
|
||||
if a["갈래"] == b["갈래"] == "기계" and a["코드"] != b["코드"]:
|
||||
diffs.append({"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']}"})
|
||||
elif loose and not _same_machine(a["이름"], b["이름"]):
|
||||
diffs.append({"항": "기계 규격", "글": f"{a['이름']} ↔ {b['이름']} {b['코드']}"})
|
||||
elif a["갈래"] == b["갈래"] == "노임" and a["코드"] != b["코드"]:
|
||||
diffs.append({"항": "자원", "글": f"{a['이름']} {a['코드']} ↔ {b['이름']} {b['코드']}"})
|
||||
elif a["갈래"] == b["갈래"] == "재료" and not _same_stuff(a, b):
|
||||
diffs.append({"항": "자원", "글": f"{a['이름']} ↔ {b['이름']}"})
|
||||
machine = a["갈래"] == b["갈래"] == "기계" or loose # 시간당 단가 구성(운전 노무비 등) = 값
|
||||
priced = a["비목"] and b["비목"] # 한쪽 단가 없음(0) = 값
|
||||
if priced and not machine and set(a["비목"]) != set(b["비목"]):
|
||||
diffs.append(
|
||||
{"항": "비목", "글": f"{a['이름']} 비목 {'·'.join(a['비목'])} ↔ {'·'.join(b['비목'])}"}
|
||||
)
|
||||
if a["단가"] == b["단가"]:
|
||||
return diffs, []
|
||||
say = f"{a['이름']} 단가 {txt(a['단가'])} ↔ {txt(b['단가'])}"
|
||||
if (a["갈래"] == b["갈래"] and a["갈래"] in PRICED) or loose:
|
||||
return diffs, [{"항": "단가 차이", "글": say}]
|
||||
return [*diffs, {"항": "산출 단가", "글": say}], []
|
||||
|
||||
|
||||
def _same_stuff(a: dict, b: dict) -> bool:
|
||||
"""자재 종류 같음 — 두 코드가 있으면 코드 · 없으면 이름 낱말(한글 두 글자) 겹침."""
|
||||
if a["코드"] and b["코드"] and a["코드"] == b["코드"]:
|
||||
return True
|
||||
return bool(_grams(a["이름"]) & _grams(b["이름"]))
|
||||
|
||||
|
||||
def rate_pairs(left: list[dict], right: list[dict], take: Any) -> tuple[list[dict], list[dict]]:
|
||||
"""율 줄 짝 → (다른 항, 참고) — 구조 = 대상 · 비목 · 같으면 로직 같음(율 값 % 다름 = 참고 · 값은 업데이트됨)."""
|
||||
diffs: list[dict] = []
|
||||
notes: list[dict] = []
|
||||
|
||||
def is_rate(a: dict, b: dict) -> bool:
|
||||
return a["갈래"] == b["갈래"] == "율"
|
||||
|
||||
def same(a: dict, b: dict, *keys: str) -> bool:
|
||||
return is_rate(a, b) and all(
|
||||
(set(a[k]) == set(b[k])) if k == "비목" else a.get(k) == b.get(k) for k in keys
|
||||
)
|
||||
|
||||
def name(a: dict, b: dict) -> str:
|
||||
return f"{a['이름']} {pct(a.get('율'))} ↔ {b['이름']} {pct(b.get('율'))}"
|
||||
|
||||
def target(x: dict) -> str:
|
||||
return "·".join(f"{k} {c}" for k, c in sorted(x.get("대상") or ())) or "없음"
|
||||
|
||||
def value(a: dict, b: dict) -> None:
|
||||
if a.get("율") != b.get("율"):
|
||||
notes.append({"항": "율 값", "글": f"{name(a, b)} — 바꾸면 우리 값"})
|
||||
|
||||
take(lambda a, b: same(a, b, "율", "대상", "비목")) # 로직 · 값 같음
|
||||
for a, b in take(lambda a, b: same(a, b, "대상", "비목")): # 로직 같음 · 율 값만 다름
|
||||
value(a, b)
|
||||
for a, b in take(lambda a, b: same(a, b, "대상")):
|
||||
diffs.append({"항": "비목", "글": f"율 줄 {name(a, b)} 비목 다름"})
|
||||
value(a, b)
|
||||
for a, b in take(lambda a, b: same(a, b, "율")):
|
||||
diffs.append({"항": "율 대상", "글": f"{name(a, b)} 대상 {target(a)} ↔ {target(b)}"})
|
||||
for a, b in take(is_rate):
|
||||
diffs.append({"항": "율 대상", "글": f"{name(a, b)} 대상 {target(a)} ↔ {target(b)}"})
|
||||
return diffs, notes
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_NativeLayout as lay
|
||||
from M02_MasterTemplete import M02_San_Emit as em
|
||||
from M02_MasterTemplete import M02_San_Est as est
|
||||
from M02_MasterTemplete import M02_San_Expr as ex
|
||||
@@ -76,6 +77,8 @@ class Block:
|
||||
self.groups: dict[str, list[tuple[str, float]]] = {} # 구간 글자 → (부름 글, 결과)
|
||||
self.extra: dict[int, dict] = {} # 행 → 소계가 정한 비목(아래 줄)
|
||||
self.got = {"확정": 0, "표지": 0, "추정": 0}
|
||||
self.memo: list[int] = [] # 글 줄 행(812-3 차림 · 분수 그림 위아래 줄 뺌)
|
||||
self.frac: set[int] = set()
|
||||
|
||||
# ── 작은 도구 ──────────────────────────────────────────────────
|
||||
def mark(self, ln: dict, why: str) -> None:
|
||||
@@ -182,6 +185,7 @@ class Block:
|
||||
return re.sub(r"\s*([×÷·+*/−-])\s*", r"\1", t).strip().split()
|
||||
|
||||
tops, bottoms, at = cols(i - 1), cols(i + 1), [0]
|
||||
self.frac |= {self.lines[k]["행"] for k in (i - 1, i + 1) if 0 <= k < len(self.lines)}
|
||||
|
||||
def one(_: re.Match) -> str:
|
||||
k = at[0]
|
||||
@@ -298,7 +302,7 @@ class Block:
|
||||
"""기계 · 산근 · 견적 세 줄 → 로직 값묶음 한 줄(수량 식 · 자리 같을 때) · 아니면 줄마다."""
|
||||
got = [self.money(ln) for ln in rows]
|
||||
parts = [ln["비목"] for ln in rows]
|
||||
same = all(got) and len({g for g in got}) == 1 and sorted(parts) == ["L", "M", "S"]
|
||||
same = all(got) and len({repr(g) for g in got}) == 1 and sorted(parts) == ["L", "M", "S"]
|
||||
if not same:
|
||||
for ln, g in zip(rows, got):
|
||||
self.machine_line(ln, g)
|
||||
@@ -485,6 +489,7 @@ class Block:
|
||||
|
||||
def text_line(self, ln: dict) -> None:
|
||||
"""글 줄 — 품셈 · 조건 이름만 있는 글(값 없음) = 「추정」 표지(연결 안 함 · 810-3 화면)."""
|
||||
self.memo.append(ln["행"])
|
||||
if _COND.search(ln["보임"]):
|
||||
self.o.glyph(
|
||||
ln["행"], ln["보임"], mark=f"{NOTE}{GUESS} — 품셈 조건 글(값 없음 · 연결 안 함)"
|
||||
@@ -493,6 +498,22 @@ class Block:
|
||||
return
|
||||
self.o.glyph(ln["행"], ln["보임"])
|
||||
|
||||
def tags(self) -> None:
|
||||
"""812-3 차림 표지(`M02_Doc_NativeLayout`) — 글줄 `차림` = 머리(단 > 0 ·
|
||||
소계 구간 첫 글 · 번호 글) · 합계 · 글(근거 · 메모)."""
|
||||
self.o.head[lay.TAG] = True
|
||||
for ln in self.lines:
|
||||
one = self.o.at.get(ln["행"])
|
||||
if one is None or ln["행"] in self.frac or "자리" in one:
|
||||
continue
|
||||
head = (ln.get("단") or 0) > 0 or ln.get("색인") == -20
|
||||
if ln.get("합계"):
|
||||
one[lay.TAG] = "합계"
|
||||
elif head or (ln["행"] in self.memo and lay.NUMBERED.match(one["글"])):
|
||||
one[lay.TAG] = "머리"
|
||||
elif ln["행"] in self.memo:
|
||||
one[lay.TAG] = "글"
|
||||
|
||||
# ── 한 벌 ─────────────────────────────────────────────────────
|
||||
def run(self) -> dict:
|
||||
for k, ln in enumerate(self.lines): # 「노무비 : ‹단가›」(식 · 결과 없음) = 단가 × 1
|
||||
@@ -544,6 +565,7 @@ class Block:
|
||||
elif (ln.get("보임") or "").strip():
|
||||
self.text_line(ln)
|
||||
i += 1
|
||||
self.tags()
|
||||
saved = self.blk.get("저장") or {}
|
||||
check = {COMP[c]: saved.get(c) or 0 for c in COMP} | {"계": saved.get("T") or 0}
|
||||
out = self.o.done("비목버림", None, check, "산근", self.file)
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
"""가져온 단가산출 줄 쌓기 차림(PLAN 812-3 · 요구 713 · 669~677) — 경쟁사 산출근거 꼴.
|
||||
|
||||
금액 · 셈 그대로. `M02_Doc_Import_Shape.shape` 가 부름(`tag` = 다듬기 앞 · `apply` = 끝) ·
|
||||
원본 `차림` 표지 있는 블록만(EST = `M02_Doc_NativeEst` · STmate = `M02_San_Emit.stmate`):
|
||||
항목 = 머리 줄(글줄 `차림` 「머리」 · 「1. …」 「가. …」 글) · 번호 글은 떼고 화면이 매김 ·
|
||||
가 · 나 = `가나다` · STmate 는 계 줄(`단계:`)마다 끊고 그 뒤 첫 글 줄 = 제목 ·
|
||||
머리 앞 줄 = 제목 없는 항목 · 합계 줄(「합계」 · 「소계」 절)만 있는 머리 = 뺌
|
||||
(화면 소계 · 합계가 냄)
|
||||
금액 줄 = 그 자리(원본 행 차례) — EST 는 셈이 같을 때만 금액 차례를 원본 차례로
|
||||
(단계 · 누계 이름 없고 기호 부름이 앞 줄일 때)
|
||||
공식 묶음 = 변수 대입 줄(`mid:` · 쓰는 차례) · 글 줄 = 근거 · 메모(글줄 `차림` 「글」)
|
||||
저장 = 금액 줄 `묶음` = 항목 제목 + 문서 `쌓기`(기본 차림과 다를 때만).
|
||||
파이썬 · TS 짝 — `common_util/common_util_line_stack.ts` `stackOf` · `saveOf` ·
|
||||
`hiddenOf`(안 놓인 기호 자리 · 기본 차림)의 거울 · 거울 시험
|
||||
`resources/tester/test_812_3_native_layout.py`(TS 왕복). 고칠 때 둘을 같이 고침.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_SCRIPTS = Path(__file__).resolve().parents[1] / "resources" / "master_data" / "scripts"
|
||||
if str(_SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS))
|
||||
|
||||
import master_doc as md # noqa: E402
|
||||
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd # noqa: E402
|
||||
|
||||
TAG = "차림" # 글줄 칸 — 머리 | 합계 | 글 · 원본 칸 = 차림 낼 블록
|
||||
KEEP = "_로직줄" # 문서 금액 줄 ↔ 로직 줄 차례(다듬기 사이 임시)
|
||||
GANADA = "가나다라마바사아자차카타파하"
|
||||
NUMBERED = re.compile(rf"^\s*'?\s*(\d{{1,2}}|[{GANADA}])\s*[.)]\s*(?!\d)(\S.*)$")
|
||||
_TOTAL = {"합계", "소계", "계"}
|
||||
|
||||
|
||||
def tag(doc: dict, meta: dict) -> None:
|
||||
"""다듬기(이름 · 기계 합침) 앞 — 금액 줄마다 로직 줄 차례(호표 → 덧줄)를 적어 둠."""
|
||||
row = meta.get("로직") or {}
|
||||
n = len(row.get("호표") or []) + len(row.get("덧줄") or [])
|
||||
money = doc.get("금액") or []
|
||||
if (meta.get("원본") or {}).get(TAG) and money and len(money) == n:
|
||||
for k, line in enumerate(money):
|
||||
line[KEEP] = k
|
||||
|
||||
|
||||
def title_of(text: str) -> tuple[str, bool]:
|
||||
"""「 1. 기 계(100%)」 → (「기 계(100%)」, 거짓) · 「'가. 굴삭기'」 → (「굴삭기」, 가나다)."""
|
||||
flat = " ".join(str(text or "").replace("'", " ").split())
|
||||
m = NUMBERED.match(flat)
|
||||
if not m:
|
||||
return flat, False
|
||||
return m.group(2).strip(), m.group(1) in GANADA
|
||||
|
||||
|
||||
# ── 차림 ──────────────────────────────────────────────────────────────
|
||||
def apply(doc: dict, meta: dict) -> None:
|
||||
money = doc.get("금액") or []
|
||||
keys = [line.pop(KEEP, None) for line in money]
|
||||
src = meta.get("원본") or {}
|
||||
if not src.get(TAG) or not money or None in keys:
|
||||
return
|
||||
from M02_MasterTemplete import M02_Sangeun_Imported as imp
|
||||
|
||||
row = meta.get("로직") or {}
|
||||
ids = sd.line_ids(
|
||||
imp.row_of({imp.KEY: {"로직": row}}) or {}
|
||||
) # 글줄 자리 = 줄 정체(`imp._named`)
|
||||
lines = [*(row.get("호표") or []), *(row.get("덧줄") or [])]
|
||||
by: dict[str, int] = {}
|
||||
for k, (cid, one) in enumerate(zip(ids, lines)):
|
||||
by.setdefault(cid, k)
|
||||
by.setdefault(str(one.get("이름")), k)
|
||||
inv = {k: j for j, k in enumerate(keys)}
|
||||
texts = sorted(
|
||||
(t for t in src.get("글줄") or [] if isinstance(t.get("행"), int)), key=lambda t: t["행"]
|
||||
)
|
||||
at: dict[int, int] = {} # 문서 금액 줄 → 원본 행
|
||||
for t in texts:
|
||||
for spot in t.get("자리들") or ([t["자리"]] if t.get("자리") else []):
|
||||
j = inv.get(by.get(str(spot), -1))
|
||||
if j is not None:
|
||||
at[j] = min(at.get(j, t["행"]), t["행"])
|
||||
rows = _fill([at.get(j) for j in range(len(money))])
|
||||
order = sorted(range(len(money)), key=lambda j: (rows[j], j))
|
||||
if order != list(range(len(money))) and _safe(doc, order):
|
||||
doc["금액"] = money = [money[j] for j in order]
|
||||
rows = [rows[j] for j in order]
|
||||
else:
|
||||
for j in range(1, len(rows)): # 차례 그대로 — 앞 줄보다 위면 앞 줄 자리로
|
||||
rows[j] = max(rows[j], rows[j - 1])
|
||||
items = _items(doc, texts, rows, src.get("갈래") == "STmate")
|
||||
for it in items:
|
||||
for kind, j in it["줄"]:
|
||||
if kind == "금액":
|
||||
money[j].pop("묶음", None)
|
||||
money[j] |= {"묶음": it["제목"]} if it["제목"] else {}
|
||||
raw = [_save_item(it, money) for it in items]
|
||||
doc.pop("쌓기", None)
|
||||
plain = save_of(stack_of(doc), money)
|
||||
got = save_of(stack_of({**doc, "쌓기": raw}), money)
|
||||
if got != plain:
|
||||
doc["쌓기"] = got
|
||||
|
||||
|
||||
def _fill(rows: list[int | None]) -> list[int]:
|
||||
"""원본 행 없는 줄(글줄에 안 붙은 소계 덧줄 등) = 다음 줄 행 · 없으면 앞 줄 행."""
|
||||
out = list(rows)
|
||||
for j in range(len(out) - 1, -1, -1):
|
||||
if out[j] is None and j + 1 < len(out):
|
||||
out[j] = out[j + 1]
|
||||
for j in range(len(out)):
|
||||
if out[j] is None:
|
||||
out[j] = out[j - 1] if j else 0
|
||||
return [int(x or 0) for x in out]
|
||||
|
||||
|
||||
def _safe(doc: dict, order: list[int]) -> bool:
|
||||
"""금액 차례를 바꿔도 셈이 같음 — 단계 없음 · 누계 · 번지 · 단계 이름 안 씀 ·
|
||||
줄 기호 부름 = 앞 줄."""
|
||||
if doc.get("단계"):
|
||||
return False
|
||||
money = doc.get("금액") or []
|
||||
pos = {j: p for p, j in enumerate(order)}
|
||||
sym = {str(x["기호"]): pos[j] for j, x in enumerate(money) if x.get("기호")}
|
||||
for j, line in enumerate(money):
|
||||
for text in (line.get("수량"), line.get("단가")):
|
||||
if text is None or str(text).strip() == "":
|
||||
continue
|
||||
try:
|
||||
got = md.names(md.parse(text))
|
||||
except md.DocError:
|
||||
return False
|
||||
if any(n in md.MONEY_NAMES or n == "계" for n in got):
|
||||
return False
|
||||
if any(n in sym and sym[n] >= pos[j] for n in got):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _items(doc: dict, texts: list[dict], rows: list[int], stm: bool) -> list[dict]:
|
||||
"""원본 행 차례로 항목 · 줄을 쌓음 — 줄 = (금액, 문서 줄) | (공식, [기호]) | (글, 글)."""
|
||||
have = set(symbols(doc))
|
||||
hidden = hidden_of(doc)
|
||||
ev: list[tuple[int, int, int, str, Any]] = []
|
||||
for t in texts:
|
||||
mark, text = t.get(TAG), str(t.get("글") or "")
|
||||
spots = t.get("자리들") or ([t["자리"]] if t.get("자리") else [])
|
||||
mids = [s[4:] for s in spots if str(s).startswith("mid:")]
|
||||
if any(str(s).startswith("단계:") for s in spots):
|
||||
ev.append((t["행"], 0, 0, "끝", None))
|
||||
elif mark == "합계":
|
||||
ev.append((t["행"], 0, 0, "합계", None))
|
||||
elif not spots and (mark == "머리" or (mark == "글" and NUMBERED.match(text))):
|
||||
ev.append((t["행"], 0, 0, "머리", title_of(text)))
|
||||
elif not spots and mark == "글" and text.strip():
|
||||
ev.append((t["행"], 1, 0, "글", " ".join(text.replace("'", " ").split())))
|
||||
ids = [m for m in mids if m in have and m not in hidden]
|
||||
if ids:
|
||||
ev.append((t["행"], 1, 0, "공식", ids))
|
||||
ev += [(r, 2, j, "금액", j) for j, r in enumerate(rows)]
|
||||
ev.sort(key=lambda e: e[:3])
|
||||
items: list[dict] = []
|
||||
cur: dict | None = None
|
||||
cut = False # STmate 계 줄 뒤 — 다음 첫 글 줄 = 제목
|
||||
|
||||
def new(title: str = "", sub: bool = False) -> dict:
|
||||
it = {"제목": title, "가나다": sub, "줄": [], "합계": False}
|
||||
items.append(it)
|
||||
return it
|
||||
|
||||
for _, _, _, kind, val in ev:
|
||||
if kind == "끝":
|
||||
cur, cut = None, stm
|
||||
elif kind == "합계":
|
||||
if cur is not None:
|
||||
cur["합계"] = True
|
||||
elif kind == "머리" or (kind == "글" and cut and cur is None):
|
||||
title, sub = val if kind == "머리" else title_of(val)
|
||||
if cur is not None and not cur["줄"] and not cur["제목"]:
|
||||
cur["제목"], cur["가나다"] = title, sub
|
||||
else:
|
||||
cur = new(title, sub)
|
||||
cut = False
|
||||
else:
|
||||
cur = cur if cur is not None else new()
|
||||
if kind == "공식" and cur["줄"] and cur["줄"][-1][0] == "공식":
|
||||
cur["줄"][-1][1].extend(x for x in val if x not in cur["줄"][-1][1])
|
||||
else:
|
||||
cur["줄"].append((kind, list(val) if kind == "공식" else val))
|
||||
keep = []
|
||||
for it in items:
|
||||
flat = re.sub(r"\s+", "", it["제목"])
|
||||
if not it["줄"] and (it["합계"] or not flat or flat in _TOTAL):
|
||||
continue
|
||||
keep.append(it)
|
||||
return keep
|
||||
|
||||
|
||||
def _save_item(it: dict, money: list[dict]) -> dict:
|
||||
out: dict[str, Any] = {}
|
||||
if it["제목"]:
|
||||
out["제목"] = it["제목"]
|
||||
if it["가나다"]:
|
||||
out["가나다"] = True
|
||||
lines = []
|
||||
for kind, val in it["줄"]:
|
||||
if kind == "금액":
|
||||
lines.append({"금액": val, "이름": money[val].get("이름") or ""})
|
||||
elif kind == "공식":
|
||||
lines.append({"공식": val})
|
||||
else:
|
||||
lines.append({"글": val})
|
||||
return out | {"줄": lines}
|
||||
|
||||
|
||||
# ── 줄 쌓기 모델 거울(`common_util_line_stack.ts`) ──────────────────────
|
||||
def _names_in(text: Any) -> list[str]:
|
||||
if text is None or str(text).strip() == "":
|
||||
return []
|
||||
try:
|
||||
return md.names(md.parse(text))
|
||||
except md.DocError:
|
||||
return []
|
||||
|
||||
|
||||
def symbols(doc: dict) -> list[str]:
|
||||
out = [md.var_id(v) for v in doc.get("변수") or []]
|
||||
for line in doc.get("식") or []:
|
||||
sid = str(line.get("기호") or "")
|
||||
if sid and sid not in out:
|
||||
out.append(sid)
|
||||
return out
|
||||
|
||||
|
||||
def _is_option(v: dict | None) -> bool:
|
||||
return bool(v and v.get("열쇠") and (v.get("연결") or {}).get("갈래") in ("선택", "입력"))
|
||||
|
||||
|
||||
def uses_of(doc: dict) -> dict[str, list[str]]:
|
||||
out: dict[str, list[str]] = {}
|
||||
|
||||
def add(sid: str, more: list[str]) -> None:
|
||||
now = out.setdefault(sid, [])
|
||||
now += [n for n in more if n not in now]
|
||||
|
||||
for v in doc.get("변수") or []:
|
||||
add(md.var_id(v), md.link_names(v["연결"]) if v.get("연결") else [])
|
||||
for line in doc.get("식") or []:
|
||||
add(str(line.get("기호") or ""), _names_in(line.get("식")))
|
||||
return out
|
||||
|
||||
|
||||
def price_sym(line: dict) -> str | None:
|
||||
text = line.get("단가")
|
||||
if text is None or str(text).strip() == "":
|
||||
return None
|
||||
try:
|
||||
n = md.parse(text)
|
||||
except md.DocError:
|
||||
return None
|
||||
if n[0] == "var":
|
||||
return n[1]
|
||||
if n[0] == "field" and n[1][0] == "var":
|
||||
return n[1][1]
|
||||
return None
|
||||
|
||||
|
||||
def owned_of(doc: dict) -> dict[int, str]:
|
||||
count: dict[str, int] = {}
|
||||
for ns in (
|
||||
*(_names_in(x.get("수량")) + _names_in(x.get("단가")) for x in doc.get("금액") or []),
|
||||
*uses_of(doc).values(),
|
||||
*([[doc["결과"]]] if doc.get("결과") else []),
|
||||
):
|
||||
for n in ns:
|
||||
count[n] = count.get(n, 0) + 1
|
||||
vars_ = {md.var_id(v): v for v in doc.get("변수") or []}
|
||||
lines = {str(x.get("기호") or "") for x in doc.get("식") or []}
|
||||
out = {}
|
||||
for i, line in enumerate(doc.get("금액") or []):
|
||||
s = price_sym(line)
|
||||
kind = ((vars_.get(s) or {}).get("연결") or {}).get("갈래") if s else None
|
||||
if s and kind in ("요소", "단가산출") and count.get(s) == 1 and s not in lines:
|
||||
out[i] = s
|
||||
return out
|
||||
|
||||
|
||||
def hidden_of(doc: dict) -> set[str]:
|
||||
"""차림에 안 넣는 기호 — 옵션 · 금액 줄이 가진 연결(가져온 값 옵션 칸은 공식 묶음에도 보임)."""
|
||||
out = set(owned_of(doc).values())
|
||||
out |= {md.var_id(v) for v in doc.get("변수") or [] if _is_option(v)}
|
||||
return out
|
||||
|
||||
|
||||
def _post_order(doc: dict, roots: list[str], uses: dict[str, list[str]]) -> list[str]:
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
stack: list[tuple[str, int]] = []
|
||||
for start in [*roots, *symbols(doc)]: # 되부름 없이(깊은 식 사슬)
|
||||
if start in seen:
|
||||
continue
|
||||
seen.add(start)
|
||||
stack.append((start, 0))
|
||||
while stack:
|
||||
sid, k = stack.pop()
|
||||
kids = uses.get(sid) or []
|
||||
if k < len(kids):
|
||||
stack.append((sid, k + 1))
|
||||
if kids[k] not in seen:
|
||||
seen.add(kids[k])
|
||||
stack.append((kids[k], 0))
|
||||
else:
|
||||
out.append(sid)
|
||||
return out
|
||||
|
||||
|
||||
def _reach(line: dict, uses: dict[str, list[str]]) -> set[str]:
|
||||
out: set[str] = set()
|
||||
todo = _names_in(line.get("수량")) + _names_in(line.get("단가"))
|
||||
while todo:
|
||||
sid = todo.pop()
|
||||
if sid in out:
|
||||
continue
|
||||
out.add(sid)
|
||||
todo += uses.get(sid) or []
|
||||
return out
|
||||
|
||||
|
||||
def stack_of(doc: dict) -> list[dict]:
|
||||
"""`stackOf` — 저장 차림 + 안 놓인 것은 기본 자리 ·
|
||||
줄 = {꼴 금액 i | 꼴 공식 기호 틀? | 꼴 글 글}."""
|
||||
money = doc.get("금액") or []
|
||||
all_ = set(symbols(doc))
|
||||
hidden = hidden_of(doc)
|
||||
items: list[dict] = []
|
||||
where: dict[int, dict] = {}
|
||||
placed: set[str] = set()
|
||||
saved = doc.get("쌓기") if isinstance(doc.get("쌓기"), list) else []
|
||||
taken: set[int] = set()
|
||||
for s in saved:
|
||||
s = s if isinstance(s, dict) else {}
|
||||
it: dict[str, Any] = {
|
||||
"제목": str(s.get("제목") if s.get("제목") is not None else ""),
|
||||
"줄": [],
|
||||
}
|
||||
if s.get("가나다"):
|
||||
it["가나다"] = True
|
||||
for line in s.get("줄") if isinstance(s.get("줄"), list) else []:
|
||||
if isinstance(line, dict) and "금액" in line:
|
||||
i = int(line["금액"])
|
||||
name = line.get("이름")
|
||||
if not (
|
||||
0 <= i < len(money) and (money[i].get("이름") or "") == name and i not in taken
|
||||
):
|
||||
free = (k for k, m in enumerate(money) if k not in taken)
|
||||
i = next((k for k in free if (money[k].get("이름") or "") == name), -1)
|
||||
if i < 0 or i in taken:
|
||||
continue
|
||||
taken.add(i)
|
||||
where[i] = it
|
||||
it["줄"].append({"꼴": "금액", "i": i})
|
||||
elif isinstance(line, dict) and "공식" in line:
|
||||
got = [
|
||||
x
|
||||
for x in line["공식"] or []
|
||||
if x in all_ and x not in hidden and x not in placed
|
||||
]
|
||||
placed |= set(got)
|
||||
one: dict[str, Any] = {"꼴": "공식", "기호": got}
|
||||
if line.get("틀"):
|
||||
one["틀"] = str(line["틀"])
|
||||
if line.get("틀꼴"):
|
||||
one["틀꼴"] = str(line["틀꼴"])
|
||||
it["줄"].append(one)
|
||||
elif isinstance(line, dict) and "글" in line:
|
||||
it["줄"].append(
|
||||
{"꼴": "글", "글": str(line["글"] if line["글"] is not None else "")}
|
||||
)
|
||||
items.append(it)
|
||||
for i, m in enumerate(money):
|
||||
if i in taken:
|
||||
continue
|
||||
title = str(m.get("묶음") if m.get("묶음") is not None else "")
|
||||
prev = where.get(i - 1)
|
||||
if prev is not None and prev["제목"] == title:
|
||||
it = prev
|
||||
at = next(k for k, x in enumerate(prev["줄"]) if x["꼴"] == "금액" and x["i"] == i - 1)
|
||||
prev["줄"].insert(at + 1, {"꼴": "금액", "i": i})
|
||||
else:
|
||||
it = next((x for x in items if x["제목"] == title), None) if saved else None
|
||||
if it is None:
|
||||
it = {"제목": title, "줄": []}
|
||||
items.append(it)
|
||||
it["줄"].append({"꼴": "금액", "i": i})
|
||||
taken.add(i)
|
||||
where[i] = it
|
||||
uses = uses_of(doc)
|
||||
reach = [_reach(m, uses) for m in money]
|
||||
for sid in _post_order(doc, [doc["결과"]] if doc.get("결과") else [], uses):
|
||||
if sid not in all_ or sid in hidden or sid in placed:
|
||||
continue
|
||||
i = next((k for k, r in enumerate(reach) if sid in r), -1)
|
||||
it = where.get(i) if i >= 0 else (items[0] if items else None)
|
||||
if it is None:
|
||||
it = {"제목": "", "줄": []}
|
||||
items.append(it)
|
||||
block = next((x for x in it["줄"] if x["꼴"] == "공식"), None)
|
||||
if block is None:
|
||||
block = {"꼴": "공식", "기호": []}
|
||||
it["줄"].insert(0, block)
|
||||
block["기호"].append(sid)
|
||||
placed.add(sid)
|
||||
return items
|
||||
|
||||
|
||||
def save_of(items: list[dict], money: list[dict]) -> list[dict]:
|
||||
"""`saveOf`(차례 그대로)."""
|
||||
out = []
|
||||
for it in items:
|
||||
one: dict[str, Any] = {}
|
||||
if it["제목"]:
|
||||
one["제목"] = it["제목"]
|
||||
if it.get("가나다"):
|
||||
one["가나다"] = True
|
||||
lines = []
|
||||
for x in it["줄"]:
|
||||
if x["꼴"] == "금액":
|
||||
lines.append({"금액": x["i"], "이름": money[x["i"]].get("이름") or ""})
|
||||
elif x["꼴"] == "글":
|
||||
lines.append({"글": x["글"]})
|
||||
else:
|
||||
lines.append(
|
||||
{"공식": list(x["기호"])}
|
||||
| ({"틀": x["틀"]} if x.get("틀") else {})
|
||||
| ({"틀꼴": x["틀꼴"]} if x.get("틀꼴") else {})
|
||||
)
|
||||
out.append(one | {"줄": lines})
|
||||
return out
|
||||
@@ -12,11 +12,8 @@ from __future__ import annotations
|
||||
import io
|
||||
import re
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_ReadEst as read_est
|
||||
|
||||
STM_SHEETS = (
|
||||
"단가산출근거",
|
||||
"단가산출근거목록표",
|
||||
@@ -233,23 +230,3 @@ def est_prices(book: dict[str, list]) -> tuple[list[dict[str, Any]], dict[int, d
|
||||
one = {"종류": "산근", "이름": f"단산{n}", "규격": ""}
|
||||
out.append({**one, "비목": k, "값": san[n][k]})
|
||||
return out, san
|
||||
|
||||
|
||||
def est_blocks(book: dict[str, list]) -> list[dict[str, Any]]:
|
||||
"""→ [{번, 이름, 단위, 글 [줄], 글행 [행]}]."""
|
||||
blocks = read_est.read(book.get(read_est.SHEET))
|
||||
keep = ("번", "이름", "단위", "글", "글행")
|
||||
return [{k: b[k] for k in keep} for b in blocks.values()]
|
||||
|
||||
|
||||
def load(path: str | Path) -> dict[str, Any]:
|
||||
"""파일 → {꼴, 블록, 단가(STmate dict | EST 목록), 총괄(EST)}."""
|
||||
path = Path(path)
|
||||
book = sheets(path.name, path.read_bytes())
|
||||
kind = kind_of(book)
|
||||
if kind == "STmate":
|
||||
return {"꼴": kind, "블록": stmate_blocks(book), "단가": stmate_prices(book)}
|
||||
if kind == "EST":
|
||||
prices, san = est_prices(book)
|
||||
return {"꼴": kind, "블록": est_blocks(book), "단가": prices, "총괄": san}
|
||||
raise ValueError("STmate · EST 산근 시트 없음")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""경쟁사 산근 → 우리 엔진 로직 줄 꼴 내보내기(PLAN 793-2a · 요구서_652 · eval 없음).
|
||||
|
||||
793-1 줄 나무(`M02_San_Stmate` · `M02_San_Est`) → `{로직, 원본}` — 꼴 = 793-2 가져온 식 SK
|
||||
793-1 줄 나무(`M02_San_Stmate`) → `{로직, 원본}`(EST = 원파일 · 엑셀 둘 다 `M02_Doc_NativeEst` · 812-1) — 꼴 = 793-2 가져온 식 SK
|
||||
만들기 몸(`M02_Sangeun_Imported.py` 머리) · 로직 = `_틀.md` 7 장 꼴(셈 = `master_formula.run` →
|
||||
`master_cut.engine` · `요소` 는 SK 서버가 채움).
|
||||
- 로직 = {이름, 결과단위, 입력 [], 중간 [{이름, 식, 표시?}], 호표,
|
||||
@@ -33,7 +33,6 @@ COMP = {"L": "노무비", "M": "재료비", "S": "경비"}
|
||||
_CODE = {v: k for k, v in COMP.items()}
|
||||
_SUM = "@합{}@" # 누계 % 밑 자리(블록 끝에 `finish` 가 채움)
|
||||
KIND = {"L": "인력", "M": "재료", "X": "기계", "D": "단가산출", "B": "단가산출"}
|
||||
KIND_EST = {"노임": "인력", "자재": "재료", "기계": "기계", "견적": "고정", "산근": "단가산출"}
|
||||
RESERVED = {"노무비", "재료비", "경비", "계", "번지", "단계", "줄", "찾기", "로직", "그리고"}
|
||||
RESERVED |= {"또는", "만약", "올림", "버림", "반올림", "최소", "최대"}
|
||||
_KEY = re.compile(r"[A-Z]{2}\d{6}")
|
||||
@@ -486,11 +485,15 @@ def stmate(
|
||||
me = prices.get(block["코드"]) or {}
|
||||
meta = me.get("meta") or {}
|
||||
o = Out(block.get("이름"), me.get("단위"), "STmate", block["코드"])
|
||||
o.head["차림"] = True # 812-3 — 계 단으로 항목 · 글 줄(`M02_Doc_NativeLayout`)
|
||||
st = _Stm(o, block, prices, keys)
|
||||
for node in nodes:
|
||||
kind, src = node["종류"], {"행": node.get("행"), "글": node.get("글") or ""}
|
||||
if kind in ("빈", "글"):
|
||||
if src["글"].strip():
|
||||
if src["글"].strip().strip("'").strip():
|
||||
o.glyph(src["행"], src["글"])
|
||||
o.at[src["행"]]["차림"] = "글" # 812-3 줄 쌓기 글 줄 · 제목(`M02_Doc_NativeLayout`)
|
||||
elif src["글"].strip():
|
||||
o.glyph(src["행"], src["글"])
|
||||
elif kind == "대입":
|
||||
st.assign(node, src)
|
||||
@@ -527,90 +530,3 @@ def stmate(
|
||||
more["계약"] = {"낙찰률": float(meta["NAG"]), "금액": _cols(block["머리"])}
|
||||
end = "큰나머지" if rule.startswith("합버림") else "비목버림"
|
||||
return o.done(end, adjust, _cols(kye), "단가산출근거", file, **more)
|
||||
|
||||
|
||||
# ── EST ────────────────────────────────────────────────────────────────
|
||||
_VAR = re.compile(r"([A-Za-z][A-Za-z0-9_]*)\s*(?:\([^)]*\))?\s*=")
|
||||
|
||||
|
||||
def _est_res(role: dict, value: float, comp: str | None) -> dict:
|
||||
"""EST 단가 토큰 정체 → 자원 이음 열쇠(옮김 = 성분 비목이 단가 비목과 다를 때)."""
|
||||
p = role["정체"]
|
||||
pc = p["비목"] if p["비목"] in COMP else (comp or "S")
|
||||
res = {"꼴": "EST", "갈래": KIND_EST.get(p["종류"], "고정"), "종류": p["종류"]}
|
||||
res |= {"이름": p["이름"], "규격": p["규격"], "단위": None}
|
||||
res |= {"단가": {pc: value}}
|
||||
res |= {"비목": pc, "옮김": comp if comp and comp != pc else None, "열쇠비목": p["비목"]}
|
||||
return res | ({"모호": True} if role.get("모호") else {})
|
||||
|
||||
|
||||
def est(block: dict, lines: list[dict], want: dict, keys: bool = False, file: str = "") -> dict:
|
||||
"""EST 블록(`M02_San_Book.est_blocks` 한 개) + 복원 줄(`M02_San_Est.parse_block`) +
|
||||
총괄표 성분 want {T, L, M, S} → {로직, 원본} · 성분을 낸 줄만 합(나머지 = 합밖)."""
|
||||
des = ev.designate(lines, want)
|
||||
mine = {k: c for c, k in des.items() if isinstance(k, int)}
|
||||
o = Out(block.get("이름"), block.get("단위"), "EST", block.get("번"))
|
||||
rows = block.get("글행") or []
|
||||
if want.get("칸"): # 당초 칸 사용 · 적용 ≠ 당초(797-1a)
|
||||
o.marks.append(f"{NOTE}{want['칸']}")
|
||||
for c, k in des.items():
|
||||
if k is None:
|
||||
o.marks.append(f"성분 {COMP[c]} {want[c]} 을 낸 줄 없음")
|
||||
val = ev.run_est(lines)
|
||||
ref: dict[int, str] = {} # 차례 → 엔진 부름 글(앞줄 · 숨은 합)
|
||||
for L in lines:
|
||||
k, form, kind = L["차례"], L["꼴"], L.get("종류")
|
||||
at, glyph = rows[k] if k < len(rows) else k, L["원글"]
|
||||
comp = mine.get(k)
|
||||
if form == "빈":
|
||||
continue
|
||||
if form == "변수" and L.get("값") is not None:
|
||||
m = _VAR.search(L["원글"])
|
||||
eng = o.names.line(m.group(1) if m else "변수")
|
||||
o.middle(eng, num(L["값"]), None, at, glyph) # 얼린 값(금액엔 보인 값이 박힘)
|
||||
continue
|
||||
if form not in ("돈", "값"):
|
||||
shown = L.get("보인") if form == "글끝" else L.get("값") if form == "식못읽음" else None
|
||||
if shown is None:
|
||||
o.glyph(at, glyph)
|
||||
else: # 금액 줄 못 읽음 — 보인 값 얼림(글줄 값으로 보존)
|
||||
o.glyph(at, glyph, value=shown, mark=f"{NOTE}금액 줄 못 읽음 · 보인 값 얼림")
|
||||
continue
|
||||
head = {} if comp else {"합밖": True}
|
||||
try:
|
||||
place = _place(L.get("규칙"))
|
||||
if k not in val:
|
||||
raise EmitError("보인 값이 되는 식 · 자리 못 찾음 — 보인 값으로 얼림")
|
||||
if kind == "단가줄":
|
||||
i, role = next((i, r) for i, r in L["역할"].items() if r["갈래"] == "단가")
|
||||
price = next(n[1] for n in ex.numbers(L["식"]) if n[2] == i)
|
||||
res = _est_res(role, price, comp)
|
||||
link, many = _m01(res["갈래"], res) if keys else (None, None)
|
||||
qty = text(L["식"], {}, {i: "1"})
|
||||
one = resource(o.names.item(L.get("라벨") or ""), None, qty, res, link)
|
||||
one |= {**head, **({"자리": place} if place else {})}
|
||||
o.add_line(one, True, at, glyph, L["값"])
|
||||
if many:
|
||||
o.glyph(at, glyph, mark=many)
|
||||
if role.get("모호"): # 임의 선택 아님 — 후보를 사람이 고름
|
||||
o.glyph(at, glyph, mark=f"{NOTE}단가 후보 여럿({role['남은']}) · 사람 확인")
|
||||
elif role.get("갈림") in ("문맥", "기계 세 줄"):
|
||||
o.glyph(at, glyph, mark=f"{NOTE}추정 — 단가 정체 근거 {role['갈림']}")
|
||||
ref[k] = f"줄.'{one['이름']}'"
|
||||
continue
|
||||
if kind == "값줄":
|
||||
parts = [ref.get(j) or num(lines[j]["값"]) for j in L["숨은"]["줄"]]
|
||||
formula = " + ".join(parts)
|
||||
else:
|
||||
swap = {i: ref[r["줄"]] for i, r in L["역할"].items() if r.get("줄") in ref}
|
||||
formula = text(L["식"], {}, swap)
|
||||
except EmitError as error:
|
||||
o.glyph(at, glyph, mark=str(error))
|
||||
formula, place = num(L["값"]), None
|
||||
name = o.names.line(L.get("라벨") or "줄")
|
||||
one = {"이름": name, "식": formula, "비목": COMP[comp or L.get("비목") or "S"]}
|
||||
o.add_line(one | head | ({"자리": place} if place else {}), False, at, glyph, L["값"])
|
||||
if k in val:
|
||||
ref[k] = name
|
||||
check = _money(want) | {"계": want["T"]}
|
||||
return o.done("비목버림", None, check, "단가산출", file)
|
||||
|
||||
@@ -1,42 +1,19 @@
|
||||
"""EST 산근 보이는 글 → 식 복원(PLAN 793-1 · 요구서_652 · 문법 = `_분석_648-A` 3 장 · eval 없음).
|
||||
"""EST 식 글 토큰(PLAN 793-1 · 812-1 · eval 없음) — 원파일 산근 변환(`M02_Doc_NativeEst`)이 쓰는 수 · 토큰 읽기만.
|
||||
|
||||
엑셀엔 식이 없고 인쇄 글뿐 — 「라벨 : 식 = 결과」 로 나눠 식을 되살림. 셈은 `M02_San_Eval.run_est`.
|
||||
|
||||
줄 꼴(`꼴`) — 빈 · 그림(분수 ─) · 글 · 글끝(끝이 결과 아님) · 식못읽음 · 변수 · 값 · 돈.
|
||||
- 돈(`종류` 단가줄 · 참조줄 · 상수줄) — 라벨 · 비목(라벨 끝 노무비 · 재료비 · 경비) ·
|
||||
값(보인 결과) · 식(`M02_San_Expr` 나무) ·
|
||||
역할 {수 토큰 차례: 단가(후보 · 정체 · 모호) | 앞줄(같은 블록 앞 결과 줄 차례) | 상수} ·
|
||||
규칙(보인 결과가 되는 줄 자리 속성 · 기본 버림1)
|
||||
- 값(`종류` 값줄 「소 계 : 1,355.9」) — 숨은 = {갈래 복사 | 합, 줄 [차례], 규칙}(앞 결과 하나 또는
|
||||
바로 앞 돈 · 값 줄 이음 합)
|
||||
- 변수(`종류` 변수식 · 변수값 · 변수기호 · 변수글) — 값 · 규칙 = 얼림(금액엔 반올림된 값이 박힘)
|
||||
옛 엑셀 글 추정 사슬(줄 꼴 · 역할 · 값 역색인 · `parse_block`)은 812-1 엑셀 읽기기(`M02_San_Xlsx`)로 바뀌어 걷음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Expr as ex
|
||||
from M02_MasterTemplete.M02_San_Book import flat
|
||||
|
||||
COMPW = {"노무비": "L", "재료비": "M", "경비": "S"}
|
||||
NUMRE = r"\d{1,3}(?:,\d{3})+(?:\.\d*)?|\d+(?:\.\d*)?|\.\d+"
|
||||
EPS = ex.EPS
|
||||
OPS = {"×": "*", "x": "*", "X": "*", "*": "*", "÷": "/", "/": "/", "+": "+", "-": "-"}
|
||||
OPS |= {"-": "-", "−": "-"}
|
||||
_UNIT = re.compile(r"\s*(?:[A-Za-z가-힣㎡㎥㎏ℓ㎝㎜°˚’'\"Øø]+(?:\s*/\s*[A-Za-z가-힣㎡㎥㎏ℓ]+)?)")
|
||||
VARLINE = re.compile(r"^\s*(?:[○●◦·\-]\s*)?[A-Za-z][A-Za-z0-9_]*\s*(?:\([^)]*\))?\s*=")
|
||||
RES = re.compile(
|
||||
r"^\s*(?:[A-Z]\s+)?("
|
||||
+ NUMRE
|
||||
+ r")\s*\.?\s*(?:원)?\s*(?:[₩\\]?\s*/\s*\S+)?\s*(?:\([^()]*\))?\s*$"
|
||||
)
|
||||
_PLAIN = re.compile(r"[\s\d.,×÷+\-()A-Za-z가-힣㎡㎥]*")
|
||||
_LABEL = re.compile(r"^(?P<l>[^::=]{0,40}?)\s*[::]\s*(?P<r>.*)$")
|
||||
TAIL = re.compile(r"^\s*(" + NUMRE + r")\s*\.?\s*[::][^=]*$") # 결과 뒤 꼬리 글 「3,094.2 : 채집」
|
||||
_HEADING = re.compile(r"^\s*(\d+|[가-하])\s*[.)]")
|
||||
|
||||
|
||||
def num(s: str) -> float:
|
||||
@@ -112,214 +89,3 @@ def tokens(seg: str) -> list[tuple] | None:
|
||||
if depth != 0 or want or not any(t[0] in ("연산", "%") for t in out):
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
def calc(node: tuple, sub: dict | None = None) -> float | None:
|
||||
try:
|
||||
return ex.value(node, {"V": {}, "sub": sub})
|
||||
except ex.ExprError:
|
||||
return None
|
||||
|
||||
|
||||
def label_comp(label: str) -> str | None:
|
||||
w = flat(label)
|
||||
return next((v for k, v in COMPW.items() if w.endswith(k)), None)
|
||||
|
||||
|
||||
def parse_line(text: str) -> dict[str, Any]:
|
||||
"""보이는 글 한 줄 → 꼴 · 라벨 · 결과 · 식 나무(역할 전)."""
|
||||
t = text.strip()
|
||||
if not t:
|
||||
return {"꼴": "빈"}
|
||||
if "─" in t or _PLAIN.fullmatch(t) and "=" not in t and ":" not in t:
|
||||
return {"꼴": "그림" if "─" in t else "글"}
|
||||
label, rhs = "", t
|
||||
mc = _LABEL.match(t)
|
||||
if mc and not re.search(r"\d\s*$", mc.group("l")):
|
||||
label, rhs = mc.group("l"), mc.group("r")
|
||||
if VARLINE.match(rhs) or VARLINE.match(t):
|
||||
return {"꼴": "변수", "조각": rhs.split("=")}
|
||||
if "=" not in rhs:
|
||||
m = re.match(r"^\s*(" + NUMRE + r")\s*\.?\s*$", rhs)
|
||||
if label and m:
|
||||
return {"꼴": "값", "라벨": label, "비목": label_comp(label), "값": num(m.group(1))}
|
||||
return {"꼴": "글"}
|
||||
segs = rhs.split("=")
|
||||
mres = RES.match(segs[-1]) or TAIL.match(segs[-1]) # 꼬리 글은 떼고 읽음(797-1a)
|
||||
if not mres:
|
||||
lead = re.match(r"\s*(" + NUMRE + r")", segs[-1])
|
||||
if (
|
||||
label and lead and re.search(r"\d", "=".join(segs[:-1]))
|
||||
): # 금액 줄 못 읽음 — 보인 값 보존
|
||||
return {"꼴": "글끝", "라벨": label, "보인": num(lead.group(1))}
|
||||
return {"꼴": "글끝"}
|
||||
shown = num(mres.group(1))
|
||||
toks = None
|
||||
for seg in reversed(segs[:-1]):
|
||||
toks = tokens(seg)
|
||||
if toks is None: # 라벨 없는 줄 — 왼쪽 글을 깎아 가며
|
||||
starts = {m.start() for m in re.finditer(NUMRE, seg)}
|
||||
starts |= {m.start() for m in re.finditer(r"\(\s*\d", seg)}
|
||||
for p in sorted(starts):
|
||||
if p and (seg[p - 1].isdigit() or seg[p - 1] in ".,"):
|
||||
continue
|
||||
toks = tokens(seg[p:])
|
||||
if toks:
|
||||
label = label or seg[:p]
|
||||
break
|
||||
if toks:
|
||||
break
|
||||
if not toks:
|
||||
return {"꼴": "식못읽음", "라벨": label, "값": shown}
|
||||
return {"꼴": "돈", "라벨": label, "비목": label_comp(label), "값": shown, "식": ex.parse(toks)}
|
||||
|
||||
|
||||
# ── 블록 풀이 ──────────────────────────────────────────────────────────
|
||||
def _ident(p: dict[str, Any]) -> tuple:
|
||||
return (p["종류"], p["이름"], p["규격"])
|
||||
|
||||
|
||||
def _money(L: dict, prices_by_value: dict, results: list) -> None:
|
||||
"""돈 줄 — 수 토큰 역할(단가 · 앞줄 · 상수) · 다시 셈 · 자리 규칙."""
|
||||
comp = L["비목"]
|
||||
nums = [(n[2], n[1]) for n in ex.numbers(L["식"])]
|
||||
roles: dict[int, dict[str, Any]] = {}
|
||||
price_at = None
|
||||
for i, v in nums:
|
||||
if v >= 1 and any(abs(r[1] - v) < EPS for r in results):
|
||||
continue # 앞 결과와 같은 수 = 참조(단가표 우연 일치보다 먼저)
|
||||
cands = [p for p in prices_by_value.get(v, []) if v >= 1 or (v == 0 and comp)]
|
||||
if comp:
|
||||
cands = [p for p in cands if p["비목"] == comp] or cands
|
||||
if v == 0 and comp and not cands:
|
||||
cands = [{"종류": "0", "이름": "-", "규격": "", "비목": comp, "값": 0.0}]
|
||||
first = i == nums[0][0]
|
||||
if cands and (v >= 100 or (first and v > 0)) or (v == 0 and cands):
|
||||
price_at = i
|
||||
roles[i] = {"갈래": "단가", "후보": cands}
|
||||
break
|
||||
for i, v in nums:
|
||||
if i in roles:
|
||||
continue
|
||||
hit = [r for r in reversed(results) if abs(r[1] - v) < EPS]
|
||||
roles[i] = {"갈래": "앞줄", "줄": hit[0][0]} if hit and v >= 1 else {"갈래": "상수"}
|
||||
L["역할"] = roles
|
||||
L["셈"] = calc(L["식"])
|
||||
L["규칙"] = ex.fit(L["셈"], L["값"])
|
||||
if price_at is not None:
|
||||
L["종류"] = "단가줄"
|
||||
pc = roles[price_at]["후보"]
|
||||
L["비목"] = L["비목"] or (pc[0]["비목"] if len({p["비목"] for p in pc}) == 1 else None)
|
||||
L["후보수"] = len({_ident(p) for p in pc})
|
||||
elif any(r["갈래"] == "앞줄" for r in roles.values()):
|
||||
L["종류"] = "참조줄"
|
||||
else:
|
||||
L["종류"] = "상수줄"
|
||||
|
||||
|
||||
def _value(L: dict, lines: list, results: list) -> None:
|
||||
"""값 줄 — 앞 결과 하나(자리 처리) 또는 바로 앞 돈 · 값 줄 이음 합."""
|
||||
v, how = L["값"], None
|
||||
for r in reversed(results):
|
||||
rule = ex.fit(r[1], v)
|
||||
if rule:
|
||||
how = {"갈래": "복사", "줄": [r[0]], "규칙": rule}
|
||||
break
|
||||
if not how and flat(L["라벨"]) in ("계", "합계"):
|
||||
got: dict[str, dict] = {}
|
||||
for x in reversed(lines):
|
||||
c = x.get("비목")
|
||||
if c and x.get("꼴") in ("돈", "값") and c not in got:
|
||||
got[c] = x
|
||||
if len(got) == 3:
|
||||
break
|
||||
if got:
|
||||
rule = ex.fit(sum(x["값"] for x in got.values()), v)
|
||||
if rule:
|
||||
how = {"갈래": "합", "줄": sorted(x["차례"] for x in got.values()), "규칙": rule}
|
||||
if not how:
|
||||
acc, used = 0.0, []
|
||||
for x in reversed([x for x in lines if x.get("꼴") in ("돈", "값")]):
|
||||
if len(used) >= 25:
|
||||
break
|
||||
acc += x["값"]
|
||||
used.append(x["차례"])
|
||||
rule = ex.fit(acc, v)
|
||||
if rule and len(used) > 1:
|
||||
how = {"갈래": "합", "줄": used[::-1], "규칙": rule}
|
||||
break
|
||||
L["종류"] = "값줄"
|
||||
L["숨은"] = how
|
||||
L["규칙"] = how["규칙"] if how else None
|
||||
|
||||
|
||||
def _var(L: dict) -> None:
|
||||
segs = L.pop("조각")
|
||||
m = re.match(r"^\s*(" + NUMRE + r")", segs[-1]) if len(segs) > 1 else None
|
||||
if not m:
|
||||
L["종류"] = "변수글"
|
||||
return
|
||||
shown, toks = num(m.group(1)), None
|
||||
for seg in reversed(segs[1:-1]):
|
||||
toks = tokens(seg)
|
||||
if toks:
|
||||
break
|
||||
L["값"] = shown
|
||||
L["규칙"] = ex.fit(calc(ex.parse(toks)), shown) if toks else None
|
||||
L["종류"] = "변수식" if toks else ("변수값" if len(segs) == 2 else "변수기호")
|
||||
|
||||
|
||||
def parse_block(text: list[str], prices: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""블록 글 줄 → 복원 줄 목록(역할 · 숨은 합 · 자리 규칙 · 단가 정체)."""
|
||||
by_value = collections.defaultdict(list)
|
||||
for p in prices:
|
||||
by_value[p["값"]].append(p)
|
||||
lines: list[dict[str, Any]] = []
|
||||
results: list[tuple] = [] # (차례, 값, 비목)
|
||||
for k, raw in enumerate(text):
|
||||
L = {"차례": k, "원글": raw, **parse_line(raw)}
|
||||
if L["꼴"] == "돈":
|
||||
_money(L, by_value, results)
|
||||
results.append((k, L["값"], L["비목"]))
|
||||
elif L["꼴"] == "값":
|
||||
_value(L, lines, results)
|
||||
results.append((k, L["값"], L["비목"]))
|
||||
elif L["꼴"] == "변수":
|
||||
_var(L)
|
||||
lines.append(L)
|
||||
identify(lines)
|
||||
return lines
|
||||
|
||||
|
||||
def identify(lines: list[dict[str, Any]]) -> None:
|
||||
"""단가 토큰 정체 — 값 하나 · 기계 세 줄 겹침 · 문맥(라벨 · 머리 글 이름) · 남으면 모호 표지."""
|
||||
head = ""
|
||||
for L in lines:
|
||||
if L["꼴"] in ("글", "글끝") or _HEADING.match(L["원글"]):
|
||||
head = L["원글"]
|
||||
if L.get("종류") != "단가줄":
|
||||
continue
|
||||
role = next(r for r in L["역할"].values() if r["갈래"] == "단가")
|
||||
cands = role["후보"]
|
||||
ids = {_ident(p) for p in cands}
|
||||
how = "값"
|
||||
if len(ids) > 1 and cands[0]["종류"] in ("기계", "산근"):
|
||||
k0, inter = L["차례"], set(ids)
|
||||
for M in lines[max(0, k0 - 3) : k0 + 4]:
|
||||
if M is L or M.get("종류") != "단가줄":
|
||||
continue
|
||||
r2 = next(r for r in M["역할"].values() if r["갈래"] == "단가")
|
||||
ids2 = {_ident(p) for p in r2["후보"]}
|
||||
if ids2 & inter and len(ids2 & inter) < len(inter):
|
||||
inter &= ids2
|
||||
ids, how = inter, "기계 세 줄"
|
||||
if len(ids) > 1:
|
||||
ctx = flat(L["원글"]) + "|" + flat(head)
|
||||
hit = {x for x in ids if x[1] and (x[1] in ctx or x[1][:3] in ctx)}
|
||||
ids, how = ({x for x in hit if not x[2] or x[2] in ctx} or hit or ids), "문맥"
|
||||
role["모호"] = len(ids) != 1
|
||||
role["갈림"] = how
|
||||
role["남은"] = len(ids)
|
||||
pick = sorted(ids)[0]
|
||||
p = next(p for p in cands if _ident(p) == pick)
|
||||
role["정체"] = {"종류": p["종류"], "이름": p["이름"], "규격": p["규격"], "비목": p["비목"]}
|
||||
|
||||
@@ -7,9 +7,8 @@ STmate 셈 규칙(`_분석_649-G2` 1 장) — 줄 금액 = 단가 × (계수 + 1
|
||||
T% = 바로 앞 계표시 값 × 수량 · 계표시 = 아래 단 누계 · `(-=)` = 찍되 위 합에서 뺌 ·
|
||||
`->` = 번지엔 쌓되 합에서 뺌 · 블록 끝전 두 갈래(비목 버림 / 계 버림 + 큰 나머지) ·
|
||||
결과금액 조정(SBM = 계를 한 비목으로) · 계약단가 = 비목 × 낙찰률 원 버림.
|
||||
EST 셈 규칙(같은 문서 2 장) — 줄 = 복원 식(단가 · 앞줄 결과 · 상수) → 줄 자리 속성 ·
|
||||
값 줄 = 숨은 복사 · 합 → 자리 속성 · 블록 성분 = 그 값을 낸 마지막 줄.
|
||||
단가 바뀜 전파 — STmate = 단가 dict 를 바꿔 다시 셈 · EST = `run_est(lines, {정체 열쇠: 새 값})`.
|
||||
단가 바뀜 전파 — 단가 dict 를 바꿔 다시 셈. 옛 EST 엑셀 셈 · 엑셀 검산(`run_est` · `est_check` ·
|
||||
`stmate_check`)은 812-1 엑셀 읽기기로 걷음(EST = `M02_Doc_NativeEst`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,7 +21,6 @@ from M02_MasterTemplete import M02_San_Expr as ex
|
||||
|
||||
COMPS = ("L", "M", "S")
|
||||
SPECIAL = ("L%", "M%", "S%", "T%")
|
||||
TOL_LINE = 0.05
|
||||
|
||||
|
||||
def _zero() -> dict[str, float]:
|
||||
@@ -179,115 +177,3 @@ def stmate_block(block: dict, nodes: list[dict], prices: dict, rule: str) -> dic
|
||||
fin = {c: math.floor(round(kye[c] * nag / 100, 6)) for c in COMPS}
|
||||
fin["T"] = fin["L"] + fin["M"] + fin["S"]
|
||||
return {"줄": lines, "합": tot, "계": kye, "금액": fin, "낙찰": nag}
|
||||
|
||||
|
||||
def stmate_check(block: dict, result: dict) -> dict[str, Any]:
|
||||
"""줄마다 · 블록 검산 — 금액 줄(참조 · 율 · 특수) C~F 대조 · 계표시 줄 · 블록 머리 · 꼬리 계."""
|
||||
bad, n_amt, n_tot, ok_tot = [], 0, 0, 0
|
||||
for r, ln in zip(result["줄"], block["몸"], strict=True):
|
||||
want = {"L": ln["D"] or 0, "M": ln["E"] or 0, "S": ln["F"] or 0}
|
||||
if r["종류"] in ("참조", "율", "특수"):
|
||||
n_amt += 1
|
||||
if not r["값"] or any(abs(r["값"][c] - want[c]) >= TOL_LINE for c in COMPS):
|
||||
bad.append({"행": ln["행"], "엑셀": want, "셈": r["값"], "오류": r["오류"]})
|
||||
elif r["종류"] == "계" and ln["C"] is not None:
|
||||
n_tot += 1
|
||||
ok_tot += all(abs(r["값"][c] - want[c]) < 0.15 for c in COMPS)
|
||||
head = block["머리"]
|
||||
want = {"T": head["C"] or 0, "L": head["D"] or 0, "M": head["E"] or 0, "S": head["F"] or 0}
|
||||
kye = next((t for t in block["꼬리"] if t["A"] == "계"), None)
|
||||
want_k = {"T": kye["C"], "L": kye["D"], "M": kye["E"], "S": kye["F"]} if kye else None
|
||||
same = lambda x, y: all(abs((x[k] or 0) - (y[k] or 0)) < 0.5 for k in "TLMS") # noqa: E731
|
||||
ok = same(result["금액"], want) and (want_k is None or same(result["계"], want_k))
|
||||
return {
|
||||
"맞음": ok,
|
||||
"엑셀": want,
|
||||
"엑셀계": want_k,
|
||||
"금액줄": n_amt,
|
||||
"틀린줄": bad,
|
||||
"계줄": n_tot,
|
||||
"계줄맞음": ok_tot,
|
||||
}
|
||||
|
||||
|
||||
# ── EST ────────────────────────────────────────────────────────────────
|
||||
def key_of(ident: dict[str, Any]) -> tuple:
|
||||
"""EST 단가 정체 → 전파 열쇠 (종류, 이름, 규격, 비목)."""
|
||||
return (ident["종류"], ident["이름"], ident["규격"], ident["비목"])
|
||||
|
||||
|
||||
def run_est(lines: list[dict], newp: dict[tuple, float] | None = None) -> dict[int, float | None]:
|
||||
"""복원 줄 → {차례: 다시 셈 값}(줄 자리 속성 그대로) · newp = {정체 열쇠: 새 단가}."""
|
||||
newp = newp or {}
|
||||
val: dict[int, float | None] = {}
|
||||
for L in lines:
|
||||
k = L["차례"]
|
||||
if L.get("꼴") == "돈" and L.get("규칙"):
|
||||
sub = {}
|
||||
for i, r in L["역할"].items():
|
||||
if r["갈래"] == "단가" and key_of(r["정체"]) in newp:
|
||||
sub[i] = newp[key_of(r["정체"])]
|
||||
elif r["갈래"] == "앞줄" and val.get(r["줄"]) is not None:
|
||||
sub[i] = val[r["줄"]]
|
||||
v = _est_calc(L["식"], sub)
|
||||
val[k] = ex.RULE[L["규칙"]](v) if v is not None else None
|
||||
elif L.get("종류") == "값줄" and L.get("숨은"):
|
||||
how = L["숨은"]
|
||||
s = sum(
|
||||
(val.get(j) if val.get(j) is not None else lines[j]["값"]) or 0 for j in how["줄"]
|
||||
)
|
||||
val[k] = ex.RULE[how["규칙"]](s)
|
||||
return val
|
||||
|
||||
|
||||
def _est_calc(node: tuple, sub: dict) -> float | None:
|
||||
try:
|
||||
return ex.value(node, {"V": {}, "sub": sub})
|
||||
except ex.ExprError:
|
||||
return None
|
||||
|
||||
|
||||
def designate(lines: list[dict], want: dict[str, float]) -> dict[str, Any]:
|
||||
"""블록 성분(총괄표) → 그 값을 낸 줄(마지막 · 라벨 비목 우선) · 0 = 「영」 · 없음 = None."""
|
||||
out: dict[str, Any] = {}
|
||||
kinds = ("단가줄", "참조줄", "상수줄", "값줄")
|
||||
for c in COMPS:
|
||||
w = want[c]
|
||||
if not w:
|
||||
out[c] = "영"
|
||||
continue
|
||||
cand = [L for L in lines if L.get("종류") in kinds and abs(L["값"] - w) < ex.EPS]
|
||||
pick = [L for L in cand if L.get("비목") == c] or cand
|
||||
out[c] = pick[-1]["차례"] if pick else None
|
||||
return out
|
||||
|
||||
|
||||
def est_block(lines: list[dict], des: dict[str, Any], newp: dict | None = None) -> dict[str, Any]:
|
||||
"""성분 줄 → 블록 금액(성분 원 버림 · 계 = 합) · 줄 값."""
|
||||
val = run_est(lines, newp)
|
||||
raw = {
|
||||
c: (0.0 if k == "영" else (val.get(k) if k is not None else None)) for c, k in des.items()
|
||||
}
|
||||
if any(v is None for v in raw.values()):
|
||||
return {"줄": val, "성분": raw, "금액": None}
|
||||
fin = {c: math.floor(round(raw[c], 6)) for c in COMPS}
|
||||
fin["T"] = fin["L"] + fin["M"] + fin["S"]
|
||||
return {"줄": val, "성분": raw, "금액": fin}
|
||||
|
||||
|
||||
def est_check(lines: list[dict], want: dict[str, float]) -> dict[str, Any]:
|
||||
"""블록 검산 — 다시 셈 성분 = 총괄표 · 줄마다 보인 값 대조(돈 · 값 줄)."""
|
||||
des = designate(lines, want)
|
||||
got = est_block(lines, des)
|
||||
val = got["줄"]
|
||||
n, bad = 0, []
|
||||
for L in lines:
|
||||
if L.get("꼴") == "돈" or L.get("종류") == "값줄":
|
||||
n += 1
|
||||
v = val.get(L["차례"])
|
||||
if v is None or abs(v - L["값"]) >= ex.EPS:
|
||||
bad.append({"차례": L["차례"], "글": L["원글"].strip(), "보인": L["값"], "셈": v})
|
||||
fin = got["금액"]
|
||||
ok = fin is not None and all(abs(fin[c] - want[c]) < 0.5 for c in COMPS)
|
||||
ok = ok and abs(want["L"] + want["M"] + want["S"] - want["T"]) < 0.5
|
||||
return {"맞음": ok, "성분줄": des, "금액": fin, "줄수": n, "틀린줄": bad}
|
||||
|
||||
@@ -141,8 +141,9 @@ def resources(sheets: dict[str, list]) -> dict[str, list[dict[str, Any]]]:
|
||||
got = []
|
||||
for one in _table(sheets.get(sheet) or [], ()):
|
||||
r = list(one["줄"]) + [None] * 30
|
||||
vals = {k: float(v or 0) for k, v in book.applied(r).items()}
|
||||
got.append({**_drop(one), "표": sheet, "값": vals})
|
||||
vals, note = book.applied_how(r)
|
||||
vals = {k: float(v or 0) for k, v in vals.items()}
|
||||
got.append({**_drop(one), "표": sheet, "값": vals} | ({"칸": note} if note else {}))
|
||||
out[kind] = got
|
||||
return out
|
||||
|
||||
|
||||
@@ -300,6 +300,45 @@ class Lines:
|
||||
self.plain(ln)
|
||||
return self.done(ln)
|
||||
|
||||
def sections(self, saved: dict) -> None:
|
||||
"""절 소계(가 · 나 … 머리 아래 합계 줄 「노무비 : a + b = X」)가 뺀 줄 하나 = 합에 안 든 줄(비목 없음) —
|
||||
절(앞 숫자 머리부터) 안 그 비목 줄 결과 합 − X 가 꼭 한 줄 값이고 나머지 줄 값은 모두 덧셈 글에 있을
|
||||
때만 · 블록 금액도 그 줄을 빼야 맞을 때만(엑셀 글 덧셈 · 원파일 SCheck 0)."""
|
||||
lines, start = self.lines, 0
|
||||
for i, ln in enumerate(lines):
|
||||
text = ln["보임"]
|
||||
if ln["단"] == 1 and re.match(r"^\s*\d", text):
|
||||
start = i
|
||||
if not (ln["합계"] and ln["비목"] in ("L", "M", "S") and ln["결과"] and "+" in text):
|
||||
continue
|
||||
head = next((k for k in range(i - 1, start - 1, -1) if lines[k]["단"] == 1), None)
|
||||
if head is None or not re.match(r"^\s*[가-하]", lines[head]["보임"]):
|
||||
continue
|
||||
part, want = ln["비목"], ln["결과"][-1]
|
||||
kids = [
|
||||
x for x in lines[start:head] if x["비목"] == part and x["결과"] and not x["합계"]
|
||||
]
|
||||
kids += [
|
||||
x for x in lines[start:head] if x["비목"] == part and x["줄참조"] and not x["결과"]
|
||||
]
|
||||
value = {id(x): (x["결과"] or x["줄참조"])[-1] for x in kids}
|
||||
extra = sum(value.values()) - want
|
||||
odd = [x for x in kids if abs(value[id(x)] - extra) < 0.5]
|
||||
colon = _COLON.search(text)
|
||||
adds = [v for _, _, v in _nums(text, colon.end() if colon else 0, text.rfind("="))]
|
||||
rest = [value[id(x)] for x in kids if odd and x is not odd[0]]
|
||||
listed = all(any(_close(v, a) for a in adds) for v in rest)
|
||||
if len(odd) != 1 or abs(extra) < 0.5 or not listed:
|
||||
continue
|
||||
every = sum(
|
||||
(x["결과"] or x["줄참조"])[-1]
|
||||
for x in lines
|
||||
if x["비목"] == part and not x["합계"] and (x["결과"] or x["줄참조"])
|
||||
)
|
||||
block = float(saved.get(part) or 0) # 블록 금액이 그 줄을 빼야 맞을 때만
|
||||
if abs(every - block) >= 1 and abs(every - extra - block) < 1:
|
||||
odd[0]["비목"] = None
|
||||
|
||||
def unused(self) -> None:
|
||||
"""머리 아닌 줄의 기계 이름 — 아래 세 줄이 안 쓰면 글 줄(설명 속 장비 이름)."""
|
||||
for h in self.heads:
|
||||
@@ -597,10 +636,13 @@ def read(sheets: dict[str, list]) -> dict[str, Any]:
|
||||
}
|
||||
if not one:
|
||||
tags.append(f"{sx.NOTE}총괄표에 단산{b['번']} 없음")
|
||||
elif one.get("칸"): # 총괄표 당초 / 적용 칸(797-1a)
|
||||
tags.append(f"참고 · {one['칸']}")
|
||||
work = Lines(look, tags)
|
||||
for src in b["줄"]:
|
||||
work.run(src)
|
||||
work.unused()
|
||||
work.sections(head["저장"] or {})
|
||||
lines = []
|
||||
for ln in work.lines:
|
||||
marks = ln.pop("_marks")
|
||||
|
||||
@@ -59,7 +59,14 @@ def read(sheets: dict[str, list]) -> dict[str, Any]:
|
||||
head = {"코드": blk["코드"], "이름": blk.get("이름") or me.get("이름")}
|
||||
head |= {"규격": me.get("규격"), "단위": me.get("단위"), "저장": _amt(blk["머리"])}
|
||||
_, tot = ev.run_stmate([ln["풀이"] for ln in lines], prices)
|
||||
out["산근"].append({**head, "줄": lines, "줄합": tot, "표지": tags, "엑셀행": blk["행"]})
|
||||
body = [{k: ln.get(k) for k in ("행", "B", "C", "D", "E", "F")} for ln in blk["몸"]]
|
||||
out["산근"].append(
|
||||
{**head, "줄": lines, "줄합": tot, "표지": tags, "엑셀행": blk["행"]}
|
||||
| {
|
||||
"몸": body,
|
||||
"꼬리": blk["꼬리"],
|
||||
} # 엑셀 줄 금액 · 계 꼬리(검산 · `M02_San_Emit.stmate`)
|
||||
)
|
||||
rule, got = vote(out["산근"], prices)
|
||||
out["옵션"] = {"SAN_TYP": RULE[rule]}
|
||||
out["내역"] = None
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 가져온 식 ↔ 우리 M01 기초 연결 판정 띠 자료(PLAN 810-4 · 요구 707 · 709 · 703) — 원문 패널 카드 위.
|
||||
* SK `가져온.원본.연결후보`(서버 `M02_Doc_Match` 가 적음) → 띠 글 · 다른 항 · 단추 하나:
|
||||
* 확정 = [우리 기초 사본으로 바꾸기] · 근접 · 없음 = 단추 없음(703) · 안 찾음 = [연결 찾기] ·
|
||||
* 다른 항 = 로직 차이(확정을 막음) · 참고 = 단가 · 끝수 · 율 값 %(값 차이 — 「우리 단가로 셈 ○○ 원」 · 812-2 · 812-4) ·
|
||||
* 바꾼 SK(`가져온보관` 있음) = 「우리 기초 사본 ○○」 + [가져온 식으로 되돌리기]
|
||||
* 화면 · DOM · fetch 없음
|
||||
* ========================================================================== */
|
||||
@@ -15,6 +16,8 @@ export interface MatchFound {
|
||||
이름?: string;
|
||||
옵션?: Record<string, string>;
|
||||
다른?: { 항: string; 글: string }[];
|
||||
/** 값 차이(단가 · 끝수 · 율 값) — 확정을 안 막음 */
|
||||
참고?: { 항: string; 글: string }[];
|
||||
금액?: { 가져온?: Record<string, number | null>; 우리?: Record<string, number | null> };
|
||||
후보수?: number;
|
||||
가까움?: { 키?: string; 이름?: string; 다른?: { 항: string; 글: string }[] };
|
||||
@@ -44,6 +47,23 @@ const opts = (o?: Record<string, unknown>): string => {
|
||||
const lines = (d?: { 항: string; 글: string }[]): string[] =>
|
||||
(d ?? []).map((x) => `${x.항} — ${x.글}`);
|
||||
|
||||
const won = (n?: number | null): string =>
|
||||
n == null ? "" : `${Math.round(n).toLocaleString("ko-KR")} 원`;
|
||||
/** 참고 요약 — 「참고: 단가 다름 3 줄 · 율 값 다름 1 줄 · 우리 단가로 셈 53,218 원」 · 참고 없음 = "" */
|
||||
const noteText = (f: MatchFound): string => {
|
||||
const n = f.참고 ?? [];
|
||||
if (!n.length) return "";
|
||||
const price = n.filter((x) => x.항 === "단가 차이").length;
|
||||
const rate = n.filter((x) => x.항 === "율 값").length;
|
||||
const bits = [
|
||||
price ? `단가 다름 ${price} 줄` : "",
|
||||
rate ? `율 값 다름 ${rate} 줄` : "",
|
||||
n.some((x) => x.항 === "끝수") ? "끝수 다름" : "",
|
||||
].filter(Boolean);
|
||||
const ours = won(f.금액?.우리?.계);
|
||||
return ` · 참고: ${bits.join(" · ")}${ours ? ` · 우리 단가로 셈 ${ours}` : ""}`;
|
||||
};
|
||||
|
||||
/** SK → 띠 자료 · 가져온 식도 · 바꾼 SK 도 아니면 null */
|
||||
export function matchBand(sk: SkLike | null | undefined): MatchBand | null {
|
||||
const kept = sk?.가져온보관;
|
||||
@@ -64,8 +84,8 @@ export function matchBand(sk: SkLike | null | undefined): MatchBand | null {
|
||||
if (f.판정 === "확정") {
|
||||
return {
|
||||
tone: "success",
|
||||
text: `우리 기초 ${base} 와 확정 일치`,
|
||||
details: [],
|
||||
text: `확정 — 로직 같음 · 우리 기초 ${base}${noteText(f)}`,
|
||||
details: lines(f.참고),
|
||||
action: "바꾸기",
|
||||
};
|
||||
}
|
||||
@@ -73,8 +93,8 @@ export function matchBand(sk: SkLike | null | undefined): MatchBand | null {
|
||||
const kinds = [...new Set((f.다른 ?? []).map((x) => x.항))].join(" · ");
|
||||
return {
|
||||
tone: "warning",
|
||||
text: `근접 — ${base} · 다른 항: ${kinds}`,
|
||||
details: lines(f.다른),
|
||||
text: `근접 — ${base} · 다른 항: ${kinds}${noteText(f)}`,
|
||||
details: [...lines(f.다른), ...lines(f.참고).map((x) => `참고 ${x}`)],
|
||||
action: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,10 +150,11 @@ export function sourceOf(doc: FormulaDoc, v: DocVar): string | null {
|
||||
return String((v as { 출처?: string }).출처 ?? (head.원파일 ? "원파일" : "엑셀"));
|
||||
}
|
||||
|
||||
/** 차림에 안 넣는 기호 — 옵션 · 금액 줄이 가진 연결 · 가져온 식 값 옵션 칸 */
|
||||
/** 차림에 안 넣는 기호 — 옵션 · 금액 줄이 가진 연결(가져온 식 값 옵션 칸은 공식 묶음에도 보임 · 같은 값 · 812-3)
|
||||
* 파이썬 거울 = `M02_Doc_NativeLayout.py`(stackOf · saveOf · hiddenOf · 가져온 단가산출 차림) — 고칠 때 같이 고침 */
|
||||
export function hiddenOf(doc: FormulaDoc): Set<string> {
|
||||
const out = new Set(ownedOf(doc).values());
|
||||
for (const v of doc.변수 ?? []) if (isOption(v) || sourceOf(doc, v)) out.add(varId(v));
|
||||
for (const v of doc.변수 ?? []) if (isOption(v)) out.add(varId(v));
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -561,3 +561,171 @@ M01은 지금 상당히 잘 개발이 되었어. 일위대가 불러오기의
|
||||
## 요구 712 (11-09 · 사용자 · 매칭은 로직으로(돈만 보지 말 것) · 권장 순서 뒤 정합성 추정 · 엑셀 엔진 새로 짜는 게 나은지)
|
||||
|
||||
결국 전부 단가산출을 제대로 연결할 수 있어야한다인데. 결과적으로 단가산출은 로직과 값으로 되어있음. 경쟁사도 값은 주기적으로 업데이트 됨. 그렇다면 매칭해야하는 자료는 로직임. 단순히 총합계나 계산된 돈만 보면 판단이 어려워지니 조심할 것. 다음으로 권장 순서대로 했을때 정합성이 어느정도 될것같아? 그리고 현 엑셀엔진을 처음부터 코드를 만드는게 더 효율적이고 체계적일 것 같다는 생각이드는데 어떻게 생각함?
|
||||
|
||||
## 요구 713 (11-09 · 사용자 · 제안대로 진행(엑셀 읽기기 새로 + 연결 기준 = 로직) · 가져온 단가산출이 좌측에서 읽히게(나열만 X))
|
||||
|
||||
다음으로 중요한 점은 경쟁사의 단가산출을 가져왔다 가정하고 현재의 경쟁사 단가산출은 좌측영역에서 제대로 읽기가 힘든 그냥 노무비, 경비, 재료 1,2, 3 형태로 죽 나열만 되어있음. 분석도 중요하지만 보여지는것도 중요함. 너의 제안대로 잘 끝까지 진행해줘
|
||||
|
||||
## 요구 714 (11-09 · 사용자 · 판단 셋 답 — 1 보류 · 2-1 율 값 = 값(로직 아님) · 2-2 기계 이름 = 별칭 조사 · 3 1 원은 로직만 맞으면 됨)
|
||||
|
||||
1번은 생각할 시간이 필요.
|
||||
2-1번 율값 관련 내가 로직과 값을 구분하라고 했잖아. 로직은 어떤 계산을 할때 뭐를 곱하는지 뭐를 더할지 같은 것들이고 여기에 사용된 값은 결국 언제든 업데이트 될수 있다고 불러오기 기능들에서에서 초기에 검산을 위해서는 겂을 사용하겠지만 결국은 우리의 m01의 값을 사용할 수 있어야해. 그게 현재 m02의 단가산출에 있는 값 업데이트 기능이고 이해해?
|
||||
2-2번 기계 이름이 다를경우 별칭을 사용하는게 어떨까해. 우리는 품셈원문의 이름을 사용하고 있기도 하고 m01 단가산출에도 별칭으로 경쟁사와 정보가 다를경우 승인하고 별칭으로 등록하는게 있잖아. 조사해봐
|
||||
3. 1원 차이는 로직만 맞다면 문제 없음.
|
||||
|
||||
## 요구 715 (11-09 · 사용자 · 수량 추가 설명 요청 · 1번 계속 대기 · 기계 230 블록 뜻 물음)
|
||||
|
||||
수량 관련은 추가 설명 요청
|
||||
|
||||
1번 계속 대기
|
||||
2번 기계 230 블럭은 경쟁사 프로그램에서 만들어둔 기계의리스트라는 말임?
|
||||
|
||||
## 요구 716 (11-09 · 사용자 · 하나씩 — 기계 230 블록: 굴삭기 0.7 = 바스켓 크기 · 기계 옵션값 변경 또는 미리 만들어 두기 · 확인부터)
|
||||
|
||||
일단 하나씩 하자. 기계230블럭에서 굴삭기 0.7에서 0.7은 바스켓의 사이즈로 보이는데 기계의 옵션값 변경을 하면 되는거 아닌가? 아니면 미리 만들어 두던가 확인부터 해봐.
|
||||
|
||||
## 요구 717 (11-09 · 사용자 · 우리 단가산출에서 기계 · 옵션 변경이 안 되는 게 문제 — 기계가 들어오면 상단 옵션에 기계 선택 · 하위 옵션 · 그 뒤 연결 찾기 방향 · 확인부터)
|
||||
|
||||
1. 그것보다 중요한 건 우리 단가산출에서 기계를 변경하거나 옵션을 변경하는게 안되는게 문제아니야? 그렇다면 기계가 단가산출에 들어오면 상단 옵션에 기계선택이나 하위 옵션선택이 되어야하는거 아닌가?
|
||||
2. 그리고 나서 연결찾기의 방향이 결정될것같은데?
|
||||
3. 확인부터 해봐.
|
||||
|
||||
## 요구 718 (11-09 · 사용자 · 기계 줄 누르면 목록이 나옴 → 옵션 없어도 됨 · 문제는 연결을 못 찾는 것 · 조회어 「굴착기(무한궤도) 0.7㎥」 가 문제 아닌지 · 기계 못 연결하는 이유 다시 확인)
|
||||
|
||||
지금 확인했는데 좌측영역에서 기계 줄에서 지정된 기계를 눌렀더니 옵션 별로 항목 리스트가 나오는데 이러면 옵션이 없어도 되는거잖아. 그럼 문제는 옵션 여부가 아니라 연결을 제대로 찾지 못하는게 아닌가? 그리고 추가로 좌측영역 기계를 눌렀을때 굴착기(무한궤도) 0.7㎥ 이렇게 조회를 하는데 이게 문제 아니야? 기계를 못 연결하는 이유를 다시 확인해봐.
|
||||
|
||||
## 요구 719 (11-09 · 사용자 · 기계 예들 직접 찾아 보니 대형 브레이커 + 굴착기 빼고 다 찾을 수 있음 · 옛 이름은 별칭 사용 가능 · 조합 기계 = 설비 둘을 각각 가져오기)
|
||||
|
||||
너의 알려준 낱말을 모르는 예와 후보가 여럿인 예를 내가 직접찾아 봤는데 대형 브레이커 + 굴착기 만 제외하면 다 려었이 아니라 다찾을 수 있겠는데? 그리고 경쟁사 프로그램이 예전프로그램이라 찾기 힘들면 별칭을 사용해도됨. 다음으로 2개의 기계의 조합을 대현 브레이커, 굴착기로 표현한게 아닐까? 우리는 설비 2개를 각각 가져오면 맞을 것같다는 생각이 드네.
|
||||
|
||||
## 요구 720 (11-09 · 사용자 · 수정을 어떻게 요청했는지 물음)
|
||||
|
||||
뭐지 어떻게 수정을 요청한거야?
|
||||
|
||||
## 요구 721 (11-09 · 사용자 · 작업 이어서 · 기계 · 옵션 때문에 품셈 원문 제목과 다르게 표현된 단가산출 기초 조사 — 200 개쯤 예상 · 1,300 → 1,500 늘어난 까닭)
|
||||
|
||||
같은 기종인데 규격만 다른 경우은 지금의 수정으로 가능한거 아니야?
|
||||
|
||||
1. 작업은 이어서 진행, 문제는 없어보임.
|
||||
2. 우리의 단가산출은 기계나 특정 옵션에 의해 품셈 원문 제목과는 다르게 표현된 항목들이 거기에 해당 될것 같은데. 그것때문에 제대로 연결이 안되는게 아닐까? 어떤게 있는지 먼저 조사가 필요해봐. 그렇다 하더라도 품셈원문 제목에 품셈원문 제목이 있다면 모를까 하지만 제목에 기계가 들어가는 경우는 드물것같아. . 내가 예상하기로는 200개 정도 될것같은데 확인해봐. 이전에는 1300개정도에서 갑자기 1500개로 단가산출 기초가 늘었거든.
|
||||
|
||||
## 요구 722 (11-09 · 사용자 · 기초 이름 정리 방향 다섯 — 수정 말고 대답만 · 방향성부터)
|
||||
|
||||
1. 제목 + 옵션, 조건 덧말은 조심해야 원문제목과 대조할 필요가 있어보임
|
||||
2. 제목과 다르지만 분기가 필요해 셋으로 나눈건 괜찮음.
|
||||
3. 보조문서는 기계와 연계된 정보이니 단가산출이 아닌걸로 봐야하는게 아닌지. 자동으로 연결될 것같은데?
|
||||
4. 기계 이름이 품셈 제목에 없는데붙는 경우는 수정해야 될것같음.
|
||||
5. 위에처럼 정리하고 나면 이름으로 구분안되는 정보들이 나올텐데 이때는 삭제하는게 맞겠지?
|
||||
수정하지말고 대답해줘. 방향성 부터 봐야지.
|
||||
|
||||
## 요구 723 (11-09 · 사용자 · 방향 다섯 동의(3 은 둘 자리 협의) · 원문 패널 md 경로 삭제 · [주] 글자 크기 반대로 · 반영 · 다시 조사 · 작업 이어서 — sub3 과 충돌 없으면 다른 워크트리에)
|
||||
|
||||
1. 동의
|
||||
2. 동의
|
||||
3. 동의는 하나, 단가산출 기초 리스트에서 빼서 어디에 둘건지는 협의 필요
|
||||
4. 동의
|
||||
5. 동의
|
||||
추가로
|
||||
6. 지금 단가산출의 우측 공통 원문 패널보기 내용 중에 md파일의 경로를 알려주고 있음. 이부분은 다른 워크트리에게 경로를 볼필요없으니 삭제해달라고 해줘.
|
||||
7. 원문 패널의 내용 중 [주] 내용의 글자가 더크고 본문의 내용이 작음. 반대가 되는게 맞음.
|
||||
위 내용을 반영하고 다시 조사하고 작업을 이어서 하는데 너가 판단해보고 현재 작업중인 서브3과 충돌이 없다면 다른 워크트리에게 작업을 시키자.
|
||||
|
||||
## 요구 724 (11-09 · 사용자 · 보조 문서를 단가산출 목록에서 빼면 어디에 둘지 의견 물음)
|
||||
|
||||
단가산출 목록에서 빼면 어디다 두는게 좋을것같아?
|
||||
|
||||
## 요구 725 (11-09 · 사용자 · 보조 문서 자리 = 권장으로 해도 될 듯 · 조사해 맞는지 먼저 보고 판단 · 「정할 것」 설명 필요)
|
||||
|
||||
1. 권장으로 해도 될것같음. 하지만 조사해보고 맞는 먼저 보고 판단하자.
|
||||
2. 정할것에 대한 설명이 필요.
|
||||
|
||||
## 요구 726 (11-09 · 사용자 · 전용 작업량도 기계 항목으로 · 작업량당 기계경비 예시 요청 · 기계 무관 = 제안대로 · 토사절취(백호 0.7㎥) 대화창 출력 · 1~4 한 번에 진행 예정 — 일단 대기)
|
||||
|
||||
1. 전용 일지라도 결과적으로는 다른곳에서 사용할수도 있으니 기계라면 당연히 기계항목으로 들어가는게 맞아보임. 현재는 하나지만 나중에는 늘어날수도 있잖아.
|
||||
2. 작업량당 기계경비는 어떤걸말하는것임? 예를 들어 보여줘.
|
||||
3. 기계무관은 너의 제안대로
|
||||
4. 토사절취(백호 0.7㎥) 단가산출을 대화창에 출력해줘.
|
||||
5. 1~4번은 한번에 진행할 생각이야. 그러니 일단대기하고 4번을 대화창에 출력
|
||||
|
||||
## 요구 727 (11-09 · 사용자 · 토사절취 예시의 문제가 무엇인지 물음 — 시간당이 아니라 단위당으로 나오는 것이 문제냐)
|
||||
|
||||
이게 뭐가 문제야? 원래는 기계의 돈은 시간당으로 나와야하는데 단위면적당으로 나온다는 거야?
|
||||
|
||||
## 요구 728 (11-09 · 사용자 · 신규 단가산출에 기계 하나만 지정하면 작업량당 기계경비와 같은 것)
|
||||
|
||||
아니 그러니까 내가 만약에 신규 단가산출을 만들고 거기에 기계만 하나 지정하면 같은게 되는거잖아.
|
||||
|
||||
## 요구 729 (11-09 · 사용자 · 결국 표현 단위 차이 — 단위 환산 로직을 단가산출 기초에 두는 게 맞지 않나)
|
||||
|
||||
결국은 표현의 단위차이라는 말 아니야? 그렇다면 표현하는 단위 환산로직을 단가산출 기초에 넣어두는게 더 맍지 않아?
|
||||
|
||||
## 요구 730 (11-09 · 사용자 · 표로 다시 · 짧게)
|
||||
|
||||
테이블로 다시 표현해봐 . 이해하기 힘들어. 짧게 설명해줘.
|
||||
|
||||
## 요구 731 (11-09 · 사용자 · 나눔 맞는 것 같음 · 단가산출 기초 화면의 공식 틀 추가는 필요 없어진 것인지)
|
||||
|
||||
맞는것같네. 그럼 이제 단가산출 기초화면에서 공식 틀 추가는 필요없어진 것임?
|
||||
|
||||
## 요구 732 (11-09 · 사용자 · 「식」으로 변경 · 작업 진행 · 진행 뒤 엑셀 불러오기에서 더 결정할 것 알려 달라)
|
||||
|
||||
식으로 변경하자. 이제 작업 진행하자.
|
||||
진행 후 엑셀불러오기와 관련하여 뭐를 더 결정해야하는지 알랴줘.
|
||||
|
||||
## 요구 733 (11-09 · 사용자 · 결정 목록 여섯에 대한 답 · 물음 + 드래그 그룹 이동 확인 — 수정 아님 · 조사부터)
|
||||
|
||||
1. 이유 짧게 설명
|
||||
2. 규격이 다른 같으 기종의 경우 가져온 걸로 우리의 기계값을 바꾸면 되는거 아닌지.
|
||||
3. 반올림 자리만 다른 경우 계산에는 정밀도 높게 계산하는지 확인하고 계산에 문제 없다면 확정. 품숫자가 다른 4개라는건 뭔지 짧게 설명
|
||||
4. 작업량 당 기계경비의 경우에는 단가산출이 어떤식으로 제목이 나오는지 확인. 기계만 있는 단가산출도 필요는 해보이는데 문제는 우리의 단가산출은 자유롭게 기계를 변경할 수 있음. 그렇다면 만약 단가산출 기초 제목으로 M02에서 찾을텐데 이때 기계명이 있는 이름의 단가산출이 있다면 사용자는 해당 단가산출 정보만 있을것으로 오해할것이기 때문에 6개의 단가산출이 기계명으로만 구분되어 있는 단가산출이라면 그냥 통합도 가능할 것으로 예상되어 물어봄(수정하라는 말은 아님)
|
||||
5. 별칭 승인의 승인요청 시점이 사용자들이 저장하여 단가산출을 저장한 시점으로 보이는데 맞음? 그리고 별칭은 있어야 되는건 맞는데 혹시 경쟁사 프로그램의 출력물로 찾을 경우 이제 빈도가 줄었을 것으로 보임. 어느정도 수준인 확인
|
||||
6. 굴삭기 + 브레이카 는 사용자가 그룹 구분 없이 사용한다면 한 덩이로 그룹으로 구분한다면 자동으로 구분되겠지. 일반적으로는 구분되게 사용하는게 맞는것같음.
|
||||
7. 드래그로 그룹이동 되는지 확인
|
||||
바로 수정작업 아님. 조사부터 진행.
|
||||
|
||||
## 요구 734 (11-09 · 사용자 · 결정 답 — 소계는 우리 양식 · 수량 끝자리 뜻 물음 · 품 숫자 = 우리 값이 최신(가져오면 우리 값) · 작업량당 기계경비 단가산출 불필요 의견 물음 · 별칭 8 목록 요청(초기 별칭 직접 · 승인까지) · 6 · 7 문제 없음 · 1~5 한 번에 진행 예정 — 먼저 대답)
|
||||
|
||||
1. 소계는 결국 항목들의 합계형식일 뿐 우리양식 맞춰 표기해도 문제 없을것같다는 생각이 듬.
|
||||
2. 수량 끝자리라는게 뭐임?
|
||||
3. 품 숫자가 다르다는건 결국 품셈이나 요소들에 지정된 값이 다르다라는 의미지? 그렇다면 우리가 더 최신이 맞음. 그래서 가져오기 전 비교할때는 해당정보로 계산하겠지만 가져오면 우리값으로 업데이트 되는게 맞다고 대답한 적이 있음. 그리고 만약 그래도 사용자가 이전값으로 사용하겠다면 우리 단가산출 양식에는 값변경을 할수 있으니 OK
|
||||
4. 작업량당 기계경비는 결국 이걸 활용하여 다른 단가산출들에서 사용하고 있는거잖아. 기계로 넣어서 사용하고 있는거잖아. 그렇다면 작업량당 기계경비라는 단가산출을 만들어둘 필요가 없어보이네 너의 생각은? 어차피 경쟁사 데이터 불러오기 기능에도 우리 단가산출 표현에 문제도 없을거고 있으면 되려 혼란을 줄것같아서 말이야.
|
||||
5. 별칭 필요한 8개의 리스트를 보여주면 초기 별칭을 내가 넣어줄게. 승인처리까지 했으면 함.
|
||||
6. 문제 없음.
|
||||
7. 문제 없음.
|
||||
1 ~ 5번은 한번에 진행할거야. 가능한 경우에는 다른 워크트리를 사용해도됨. 그러니 먼저 대답부터
|
||||
|
||||
## 요구 735 (11-09 · 사용자 · 1~3 동의 · 작업량당 기계경비 = 필요하면 기계 하나 넣어 만들어 씀 · 별칭 아홉 답)
|
||||
|
||||
1. 동의
|
||||
2. 동의
|
||||
3. 동의
|
||||
4. 작업량당 기계경비는 본인이 필요한 경우 기계하나만 넣고 만들어 사용하면 됨.
|
||||
5. 별칭 확인
|
||||
1) 후보맞음.
|
||||
2) 나중에 내가 승인(어디 단가산출에 있는지 대화창에 출력)
|
||||
3) 후보맞음.
|
||||
4) 엔진(디젤) 이렇게 되어 있음. 가솔린은 엔진(가솔린)으로 되어 있음.
|
||||
5) 나중에 내가 승인(어디 단가산출에 있는지 대화창에 출력)
|
||||
6) 나중에 내가 승인(어디 단가산출에 있는지 대화창에 출력)
|
||||
7) 워터젯트
|
||||
8) 나중에 내가 승인(어디 단가산출에 있는지 대화창에 출력)
|
||||
9) 후보맞음
|
||||
|
||||
## 요구 736 (11-09 · 사용자 · 바이브레이터 규격이 하나면 엔진식)
|
||||
|
||||
바이브 레이터에 규격은 하나임? 하나라면 엔진식이 맞음.
|
||||
|
||||
## 요구 737 (11-09 · 사용자 · 남은 결정 사항 물음)
|
||||
|
||||
남은 결정사항은 뭐가 있어?
|
||||
|
||||
## 요구 738 (11-09 · 사용자 · 별칭 넷 = 단가산출 만들다 나오면 승인(할 일 없음) · 원문 패널 표 글자 16 · 제목 통합(그림 `요구738_원문패널_제목중복.png`) · 조합 기계 = 개별 그룹으로 · 공식 연결 분리 가능한지 조사 · 반영 뒤 단가산출 불러오기 정합성 확인)
|
||||
|
||||
지금 정할 수 있는 것.
|
||||
1. 지금 하겠다는게 아니라 단가산출을 만들면서 해당내용이 나오면 내가 승인하겠다는거야. 그러니 이제 너가 할 일은 없음.
|
||||
2 .표의 글자도 16으로 변경
|
||||
2-1. [Image #1]중복표기가 많음 제목을 통합해줘. 건설품셈 공통 7-1-1 메쌓기('12, '19, '26년 보완) 형태면 될것같음.
|
||||
위 내용도 같이 반영, 반영완료
|
||||
3. 조합기계는 우리 단가산출로 가져올때 개별 그룹으로 하나씩 가져오는게 좋을것같음.
|
||||
4. 공식을 연결해야 단가가 나올것같은데 분리할 수 없을것같은데 조사
|
||||
5. 위내용까지 반영 후 일단 단가산출에 대한 불러오기 정합성 확인
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -2791,16 +2791,16 @@
|
||||
"GF000249": {"원문번호": "9-14-2 다짐", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000250": {"원문번호": "9-15-2 표토제거 답외구간", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000251": {"원문번호": "9-17-2 비다짐", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000252": {"원문번호": "9-4-1 암파쇄(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000253": {"원문번호": "9-5-2 발파암 깎기 90%(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000252": {"원문번호": "9-4-1 암파쇄", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000253": {"원문번호": "9-5-2 발파암 깎기(90%)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000254": {"원문번호": "9-5-1 육상 암석 절취(발파 10%)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000255": {"원문번호": "9-6-1 발파암 기계소할(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000256": {"원문번호": "9-7-1 무근콘크리트 깨기(T=30㎝ 미만) 깨기(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000257": {"원문번호": "9-7-2 무근콘크리트 깨기(T=30㎝ 이상) 깨기(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000258": {"원문번호": "9-8-1 철근콘크리트 깨기(T=30㎝ 미만) 깨기(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000259": {"원문번호": "9-8-2 철근콘크리트 깨기(T=30㎝ 이상) 깨기(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000260": {"원문번호": "9-10-1 기존포장 깨기 콘크리트 깨기(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000261": {"원문번호": "9-10-2 기존포장 깨기 아스팔트 깨기(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000255": {"원문번호": "9-6-1 발파암 기계소할(15%)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000256": {"원문번호": "9-7-1 무근콘크리트 깨기(T=30㎝ 미만)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000257": {"원문번호": "9-7-2 무근콘크리트 깨기(T=30㎝ 이상)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000258": {"원문번호": "9-8-1 철근콘크리트 깨기(T=30㎝ 미만)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000259": {"원문번호": "9-8-2 철근콘크리트 깨기(T=30㎝ 이상)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000260": {"원문번호": "9-10-1 기존포장 깨기 콘크리트", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000261": {"원문번호": "9-10-2 기존포장 깨기 아스팔트", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000262": {"원문번호": "9-11-1 포장절단 콘크리트(기계)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000263": {"원문번호": "9-12-2 측구터파기 암절취", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000264": {"원문번호": "9-12-3 측구터파기 발파암", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
@@ -2822,13 +2822,13 @@
|
||||
"GF000280": {"원문번호": "10-6-2 인력운반 돌(지게)", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000281": {"원문번호": "10-6-3 인력운반 기타 임업자재", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000282": {"원문번호": "10-8-1 케이블 크레인 짐내리기", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000283": {"원문번호": "10-9-1 덤프트럭 자재 운반(1회 표준 운반량)", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000284": {"원문번호": "10-12-1 덤프운반(굴착기 0.7㎥ 적재 · 덤프트럭 15ton)", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000283": {"원문번호": "10-9-1 덤프트럭 운반(자재의 1회당 표준 운반량)", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000284": {"원문번호": "10-12-1 덤프운반 토사", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000285": {"원문번호": "10-13-2 묘목 인력운반", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000286": {"원문번호": "4-1-1 수확베기 임업용 동력기계톱 벌목", "파일": "로직_산림품셈_04장_나무베기.json"},
|
||||
"GF000287": {"원문번호": "4-2-1 단목베기(100본당)", "파일": "로직_산림품셈_04장_나무베기.json"},
|
||||
"GF000288": {"원문번호": "4-2-2 단목베기(1,000㎡당 · 굴착기 0.2㎥ + 부착용 집게)", "파일": "로직_산림품셈_04장_나무베기.json"},
|
||||
"GF000289": {"원문번호": "4-3 위험목 베기(굴착기 우드그랩 0.2㎥ · 크레인 5톤)", "파일": "로직_산림품셈_04장_나무베기.json"},
|
||||
"GF000288": {"원문번호": "4-2-2 단목베기(1,000㎡당)", "파일": "로직_산림품셈_04장_나무베기.json"},
|
||||
"GF000289": {"원문번호": "4-3 위험목 베기", "파일": "로직_산림품셈_04장_나무베기.json"},
|
||||
"GF000290": {"원문번호": "5-1-1 관목굴취", "파일": "로직_산림품셈_05장_식재_및_녹화.json"},
|
||||
"GF000291": {"원문번호": "5-3-2 관목식재(단식)", "파일": "로직_산림품셈_05장_식재_및_녹화.json"},
|
||||
"GF000292": {"원문번호": "5-3-3 관목식재(군식)", "파일": "로직_산림품셈_05장_식재_및_녹화.json"},
|
||||
@@ -2860,7 +2860,7 @@
|
||||
"GF000318": {"원문번호": "8-1-2 나무주사 약제주입병", "파일": "로직_산림품셈_08장_방제.json"},
|
||||
"GF000319": {"원문번호": "8-4 끈끈이 롤 트랩 설치·제거", "파일": "로직_산림품셈_08장_방제.json"},
|
||||
"GF000320": {"원문번호": "8-6-2 드론방제(무인헬기 · 멀티콥터)", "파일": "로직_산림품셈_08장_방제.json"},
|
||||
"GF000321": {"원문번호": "8-6-3 지상방제 차량살포(동력분무기)", "파일": "로직_산림품셈_08장_방제.json"},
|
||||
"GF000321": {"원문번호": "8-6-3 지상방제 차량살포", "파일": "로직_산림품셈_08장_방제.json"},
|
||||
"GF000322": {"원문번호": "8-10 그물망 피복", "파일": "로직_산림품셈_08장_방제.json"},
|
||||
"GF000323": {"원문번호": "8-11 이동식 임목 파쇄(93.25KW)", "파일": "로직_산림품셈_08장_방제.json"},
|
||||
"GF000324": {"원문번호": "13-4-6 밑돌 및 천단 돌쌓기", "파일": "로직_산림품셈_13장_구조물.json"},
|
||||
|
||||
@@ -30,8 +30,8 @@ REGIONS = [
|
||||
] # fmt: skip
|
||||
ROCK = ["암석작업(연암・보통암・경암)", "해당 없음", "전석섞인토사"]
|
||||
NAMES = (
|
||||
"시멘트운반(구역화물 운임 + 하차비)",
|
||||
"철근운반(구역화물 운임 + 하차비)",
|
||||
"시멘트운반",
|
||||
"철근운반",
|
||||
"깬잡석 · 야면석 · 고임돌 · 파쇄석 채집(기계)",
|
||||
)
|
||||
|
||||
@@ -73,7 +73,7 @@ def haul(no: str, what: str, unit: str, per: str, old: str) -> dict:
|
||||
div = "1.1 * 적재량 * 포수" if sack else "1.1 * 적재량"
|
||||
return {
|
||||
"원문번호": no,
|
||||
"이름": f"{what}(구역화물 운임 + 하차비)",
|
||||
"이름": what,
|
||||
"결과단위": unit,
|
||||
"출처": f"산림품셈 {no}",
|
||||
"소유": "공용",
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
· 계(돈 아닌 로직 = 결과)
|
||||
원 단위 — 기본 옵션 전부 + 옵션 표본(로직마다 고르기 입력의 다른 갈래 하나씩 · 나머지 기본).
|
||||
둘 다 못 셈 = 같음(따로 셈). 보정 없음 — 다르면 목록에 까닭.
|
||||
- 저장 = 같은 것만(요구서_664 1 장 3) — 모든 대조가 같고 부르는 단가산출도 저장되는 문서 ·
|
||||
- 채택된 차이(`ADOPTED`) = 사용자 요구 714-3 「1원 차이는 로직만 맞다면 문제 없음」 — 식 속 단가산출
|
||||
부름 값 = 끝수 뒤(새 계산기 그대로) · 목록에 남기고 저장은 막지 않음.
|
||||
- 저장 = 같은 것만(요구서_664 1 장 3) — 모든 대조가 같고(채택된 차이 빼고) 부르는 단가산출도 저장되는 문서 ·
|
||||
`resources/master_data/수식문서/수식_<옛 로직 파일 꼬리>.json`(로직 파일마다
|
||||
· 옛 로직 JSON 은 안 씀).
|
||||
- 목록 = `docs/raw/요구_1104/_목록_802-2_변환대조.xlsx`(로직 · 옵션 · 옛 · 새 · 차이 · 까닭 + 요약).
|
||||
@@ -44,6 +46,8 @@ RAW_SUB = (
|
||||
"식 속 단가산출 부름 = 끝수 뒤 값"
|
||||
"(옛 엔진 = 끝수 전 날 값 · `master_formula.run` 결과를 식에 씀)"
|
||||
)
|
||||
# 채택된 차이 — (로직, 옵션) · 요구 714-3
|
||||
ADOPTED = {("GC000100", "지역=경북")}
|
||||
|
||||
|
||||
def rows_of(files: dict) -> list[tuple[str, dict]]:
|
||||
@@ -108,7 +112,7 @@ def why(doc: dict, old, new) -> str:
|
||||
if isinstance(new, str) and not isinstance(old, str):
|
||||
return f"새 계산기 못 셈 — {new}"
|
||||
if _uses_raw_sub(doc):
|
||||
return RAW_SUB
|
||||
return RAW_SUB + " · 채택(요구 714-3)"
|
||||
return "까닭 모름"
|
||||
|
||||
|
||||
@@ -151,6 +155,7 @@ def build(log=print) -> dict:
|
||||
)
|
||||
for one in runs:
|
||||
if not one["같음"]:
|
||||
one["채택"] = (one["로직"], one["옵션"]) in ADOPTED
|
||||
one["까닭"] = why(docs[one["로직"]], one["옛"], one["새"])
|
||||
keep = _keep(docs, runs)
|
||||
for one in runs: # 같아도 못 넣는 것 — 부르는 단가산출이 빠짐
|
||||
@@ -165,6 +170,7 @@ def build(log=print) -> dict:
|
||||
"옵션 표본": sum(1 for x in runs if x["옵션"]),
|
||||
"옵션 같음": sum(1 for x in runs if x["옵션"] and x["같음"]),
|
||||
"옵션 다름": sum(1 for x in runs if x["옵션"] and not x["같음"]),
|
||||
"채택된 차이": sum(1 for x in runs if x.get("채택")),
|
||||
"둘 다 못 셈": both_out,
|
||||
"저장": len(keep),
|
||||
"변환 알림": sum(1 for v in notes.values() if v),
|
||||
@@ -192,7 +198,7 @@ def _calls(doc: dict) -> set[str]:
|
||||
def _keep(docs: dict, runs: list[dict]) -> set[str]:
|
||||
"""저장할 문서 — 모든 대조 같음 + 부르는 단가산출도 저장(닫힘).
|
||||
키 구멍 부름은 그 로직 대조로 판정."""
|
||||
keep = set(docs) - {x["로직"] for x in runs if not x["같음"]}
|
||||
keep = set(docs) - {x["로직"] for x in runs if not x["같음"] and not x.get("채택")}
|
||||
while True:
|
||||
drop = {k for k in keep if not _calls(docs[k]) <= keep}
|
||||
if not drop:
|
||||
|
||||
@@ -824,7 +824,7 @@
|
||||
"원문번호": "1-2-16",
|
||||
"구분": "건설품셈 유지관리",
|
||||
"상세구분": "01장 공통",
|
||||
"이름": "약제살포(동력분무기)",
|
||||
"이름": "약제살포(기계)",
|
||||
"결과단위": "원/ℓ",
|
||||
"출처": "건설품셈 유지관리 1-2-16",
|
||||
"개정": [2019, 2022],
|
||||
|
||||
@@ -860,7 +860,7 @@
|
||||
"원문번호": "1-6-5",
|
||||
"구분": "건설품셈 토목",
|
||||
"상세구분": "01장 도로포장공사",
|
||||
"이름": "콘크리트 페이버 조립 및 해체",
|
||||
"이름": "기계포설 장비조립 및 해체",
|
||||
"결과단위": "원/회",
|
||||
"출처": "건설품셈 토목 1-6-5",
|
||||
"개정": [2021],
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"원문번호": "1-1-1",
|
||||
"구분": "건설품셈 건축",
|
||||
"상세구분": "01장 철골공사",
|
||||
"이름": "철골 공장가공조립(기본철골공수)",
|
||||
"이름": "철골 가공 조립 기본철골공수",
|
||||
"결과단위": "원/ton",
|
||||
"출처": "건설품셈 건축 1-1-1",
|
||||
"개정": [2008, 2013],
|
||||
@@ -80,7 +80,7 @@
|
||||
"원문번호": "1-1-3",
|
||||
"구분": "건설품셈 건축",
|
||||
"상세구분": "01장 철골공사",
|
||||
"이름": "철골 공장용접(기본용접공수)",
|
||||
"이름": "철골 가공 조립 기본용접공수",
|
||||
"결과단위": "원/ton",
|
||||
"출처": "건설품셈 건축 1-1-3",
|
||||
"소유": "공용",
|
||||
@@ -443,7 +443,7 @@
|
||||
"원문번호": "1-2-7",
|
||||
"구분": "건설품셈 건축",
|
||||
"상세구분": "01장 철골공사",
|
||||
"이름": "타워크레인 가설 · 해체 · 수직이동",
|
||||
"이름": "철골세우기용 장비의 가설 및 해체이동",
|
||||
"결과단위": "원/대",
|
||||
"출처": "건설품셈 건축 1-2-7",
|
||||
"소유": "공용",
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
"원문번호": "5-1-7",
|
||||
"구분": "건설품셈 공통",
|
||||
"상세구분": "05장 기초공사",
|
||||
"이름": "앵커 천공(크롤러형 보링장비)",
|
||||
"이름": "앵커 천공",
|
||||
"결과단위": "원/공",
|
||||
"출처": "건설품셈 공통 5-1-7",
|
||||
"개정": [2020, 2026],
|
||||
@@ -1501,7 +1501,7 @@
|
||||
"원문번호": "5-2-3",
|
||||
"구분": "건설품셈 공통",
|
||||
"상세구분": "05장 기초공사",
|
||||
"이름": "PBD천공기 조립 및 해체",
|
||||
"이름": "PBD 장비조립 및 해체",
|
||||
"결과단위": "원/회",
|
||||
"출처": "건설품셈 공통 5-2-3",
|
||||
"개정": [2013, 2021, 2026],
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"원문번호": "6-1-2",
|
||||
"구분": "건설품셈 건축",
|
||||
"상세구분": "06장 방수공사",
|
||||
"이름": "방수프라이머 바름(롤러 1회)",
|
||||
"이름": "방수프라이머 바름",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 건축 6-1-2",
|
||||
"개정": [2018],
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"원문번호": "7-1-1",
|
||||
"구분": "건설품셈 공통",
|
||||
"상세구분": "07장 돌공사",
|
||||
"이름": "메쌓기(굴착기+부착용 집게)",
|
||||
"이름": "메쌓기",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 공통 7-1-1",
|
||||
"개정": [2012, 2019, 2026],
|
||||
@@ -49,7 +49,7 @@
|
||||
"원문번호": "7-1-2",
|
||||
"구분": "건설품셈 공통",
|
||||
"상세구분": "07장 돌공사",
|
||||
"이름": "찰쌓기(굴착기+부착용 집게)",
|
||||
"이름": "찰쌓기",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 공통 7-1-2",
|
||||
"개정": [2012, 2018, 2019, 2026],
|
||||
@@ -88,7 +88,7 @@
|
||||
"원문번호": "7-2-1",
|
||||
"구분": "건설품셈 공통",
|
||||
"상세구분": "07장 돌공사",
|
||||
"이름": "메붙임(굴착기+부착용 집게)",
|
||||
"이름": "메붙임",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 공통 7-2-1",
|
||||
"개정": [2012, 2019, 2026],
|
||||
@@ -127,7 +127,7 @@
|
||||
"원문번호": "7-2-2",
|
||||
"구분": "건설품셈 공통",
|
||||
"상세구분": "07장 돌공사",
|
||||
"이름": "찰붙임(굴착기+부착용 집게)",
|
||||
"이름": "찰붙임",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 공통 7-2-2",
|
||||
"개정": [2012, 2019, 2026],
|
||||
@@ -166,7 +166,7 @@
|
||||
"원문번호": "7-3-1",
|
||||
"구분": "건설품셈 공통",
|
||||
"상세구분": "07장 돌공사",
|
||||
"이름": "전석쌓기(굴착기+부착용 집게)",
|
||||
"이름": "전석쌓기",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 공통 7-3-1",
|
||||
"개정": [1992, 2012, 2018, 2026],
|
||||
@@ -221,7 +221,7 @@
|
||||
"원문번호": "7-3-2",
|
||||
"구분": "건설품셈 공통",
|
||||
"상세구분": "07장 돌공사",
|
||||
"이름": "전석깔기(굴착기+부착용 집게)",
|
||||
"이름": "전석깔기",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 공통 7-3-2",
|
||||
"개정": [2018, 2026],
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
"원문번호": "9-2-1",
|
||||
"구분": "건설품셈 기계설비",
|
||||
"상세구분": "09장 기타공사",
|
||||
"이름": "바탕만들기(블라스트 · 공구 · 와이어브러시)",
|
||||
"이름": "바탕만들기",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 기계설비 9-2-1",
|
||||
"소유": "공용",
|
||||
@@ -270,7 +270,7 @@
|
||||
"원문번호": "9-4-1",
|
||||
"구분": "건설품셈 기계설비",
|
||||
"상세구분": "09장 기타공사",
|
||||
"이름": "가스배관 기밀시험",
|
||||
"이름": "기밀시험",
|
||||
"결과단위": "원/회",
|
||||
"출처": "건설품셈 기계설비 9-4-1",
|
||||
"개정": [2015, 2019],
|
||||
|
||||
@@ -1317,7 +1317,7 @@
|
||||
"원문번호": "13-2-6",
|
||||
"구분": "건설품셈 기계설비",
|
||||
"상세구분": "13장 플랜트설비공사",
|
||||
"이름": "응력제거(Ring Burner · 전기저항)",
|
||||
"이름": "응력제거(Ring Burner, Electric, Resistence Heating)",
|
||||
"결과단위": "원/개소",
|
||||
"출처": "건설품셈 기계설비 13-2-6",
|
||||
"소유": "공용",
|
||||
@@ -1884,7 +1884,7 @@
|
||||
"원문번호": "13-5-1",
|
||||
"구분": "건설품셈 기계설비",
|
||||
"상세구분": "13장 플랜트설비공사",
|
||||
"이름": "화력 보일러 설치(공정별)",
|
||||
"이름": "화력 보일러 설치",
|
||||
"결과단위": "원/기",
|
||||
"출처": "건설품셈 기계설비 13-5-1",
|
||||
"소유": "공용",
|
||||
@@ -2059,7 +2059,7 @@
|
||||
"원문번호": "13-5-2",
|
||||
"구분": "건설품셈 기계설비",
|
||||
"상세구분": "13장 플랜트설비공사",
|
||||
"이름": "보일러 드럼 설치(공정별)",
|
||||
"이름": "보일러 드럼 설치",
|
||||
"결과단위": "원/대",
|
||||
"출처": "건설품셈 기계설비 13-5-2",
|
||||
"소유": "공용",
|
||||
@@ -2275,7 +2275,7 @@
|
||||
"원문번호": "13-5-4",
|
||||
"구분": "건설품셈 기계설비",
|
||||
"상세구분": "13장 플랜트설비공사",
|
||||
"이름": "덕트 설치(공정별)",
|
||||
"이름": "덕트 설치",
|
||||
"결과단위": "원/식",
|
||||
"출처": "건설품셈 기계설비 13-5-4",
|
||||
"소유": "공용",
|
||||
@@ -2360,7 +2360,7 @@
|
||||
"원문번호": "13-5-5",
|
||||
"구분": "건설품셈 기계설비",
|
||||
"상세구분": "13장 플랜트설비공사",
|
||||
"이름": "공기예열기(Preheater) 설치(공정별)",
|
||||
"이름": "공기예열기(Preheater) 설치",
|
||||
"결과단위": "원/식",
|
||||
"출처": "건설품셈 기계설비 13-5-5",
|
||||
"소유": "공용",
|
||||
@@ -2555,7 +2555,7 @@
|
||||
"원문번호": "13-5-8",
|
||||
"구분": "건설품셈 기계설비",
|
||||
"상세구분": "13장 플랜트설비공사",
|
||||
"이름": "터빈 설치(공정별)",
|
||||
"이름": "터빈 설치",
|
||||
"결과단위": "원/기",
|
||||
"출처": "건설품셈 기계설비 13-5-8",
|
||||
"소유": "공용",
|
||||
@@ -2716,7 +2716,7 @@
|
||||
"원문번호": "13-5-9",
|
||||
"구분": "건설품셈 기계설비",
|
||||
"상세구분": "13장 플랜트설비공사",
|
||||
"이름": "발전기 설치(공정별)",
|
||||
"이름": "발전기 설치",
|
||||
"결과단위": "원/기",
|
||||
"출처": "건설품셈 기계설비 13-5-9",
|
||||
"소유": "공용",
|
||||
@@ -2842,7 +2842,7 @@
|
||||
"원문번호": "13-5-10",
|
||||
"구분": "건설품셈 기계설비",
|
||||
"상세구분": "13장 플랜트설비공사",
|
||||
"이름": "복수기 설치(공정별)",
|
||||
"이름": "복수기 설치",
|
||||
"결과단위": "원/기",
|
||||
"출처": "건설품셈 기계설비 13-5-10",
|
||||
"소유": "공용",
|
||||
@@ -3350,7 +3350,7 @@
|
||||
"원문번호": "13-6-1",
|
||||
"구분": "건설품셈 기계설비",
|
||||
"상세구분": "13장 플랜트설비공사",
|
||||
"이름": "수차 설치(Kaplan · Francis · Propeller)",
|
||||
"이름": "수차 설치",
|
||||
"결과단위": "원/ton",
|
||||
"출처": "건설품셈 기계설비 13-6-1",
|
||||
"소유": "공용",
|
||||
@@ -6728,7 +6728,7 @@
|
||||
"원문번호": "13-7-19",
|
||||
"구분": "건설품셈 기계설비",
|
||||
"상세구분": "13장 플랜트설비공사",
|
||||
"이름": "Mill Line 기기류 조립설치(공정별)",
|
||||
"이름": "Mill Line 기기류 조립설치",
|
||||
"결과단위": "원/식",
|
||||
"출처": "건설품셈 기계설비 13-7-19",
|
||||
"소유": "공용",
|
||||
@@ -6772,7 +6772,7 @@
|
||||
"원문번호": "13-7-20",
|
||||
"구분": "건설품셈 기계설비",
|
||||
"상세구분": "13장 플랜트설비공사",
|
||||
"이름": "Roller Table 조립설치(공정별)",
|
||||
"이름": "Roller Table 조립설치",
|
||||
"결과단위": "원/식",
|
||||
"출처": "건설품셈 기계설비 13-7-20",
|
||||
"소유": "공용",
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
"원문번호": "3-4-2",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "03장 작업장 관리",
|
||||
"이름": "임산물 운반로 및 작업로 신설(굴착기 0.2㎥)",
|
||||
"이름": "임산물 운반로 및 작업로 신설비 산정",
|
||||
"결과단위": "원/m",
|
||||
"출처": "산림품셈 3-4-2",
|
||||
"개정": [2026],
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
"원문번호": "4-2-2",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "04장 나무베기",
|
||||
"이름": "단목베기(1,000㎡당 · 굴착기 0.2㎥ + 부착용 집게)",
|
||||
"이름": "단목베기(1,000㎡당)",
|
||||
"결과단위": "원/1,000㎡",
|
||||
"출처": "산림품셈 4-2-2",
|
||||
"개정": [2026],
|
||||
@@ -199,7 +199,7 @@
|
||||
"원문번호": "4-3",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "04장 나무베기",
|
||||
"이름": "위험목 베기(굴착기 우드그랩 0.2㎥ · 크레인 5톤)",
|
||||
"이름": "위험목 베기",
|
||||
"결과단위": "원/본",
|
||||
"출처": "산림품셈 4-3",
|
||||
"개정": [2026],
|
||||
|
||||
@@ -1219,7 +1219,7 @@
|
||||
"원문번호": "5-29",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "05장 식재 및 녹화",
|
||||
"이름": "복사이식(굴착기 1.0㎥)",
|
||||
"이름": "복사이식",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 5-29",
|
||||
"개정": [2026],
|
||||
|
||||
@@ -362,7 +362,7 @@
|
||||
"원문번호": "8-6-3",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "08장 방제",
|
||||
"이름": "지상방제 차량살포(동력분무기)",
|
||||
"이름": "지상방제 차량살포",
|
||||
"결과단위": "원/일",
|
||||
"출처": "산림품셈 8-6-3",
|
||||
"개정": [2026],
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"원문번호": "9-3-2",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "토사깍기(기계 · 굴착기 0.7㎥)",
|
||||
"이름": "토사깍기(기계)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-3-2",
|
||||
"개정": [2026],
|
||||
@@ -94,7 +94,7 @@
|
||||
"원문번호": "9-4-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "암파쇄(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "암파쇄",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-4-1",
|
||||
"개정": [2026],
|
||||
@@ -178,7 +178,7 @@
|
||||
"원문번호": "9-4-2",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "암절취 집토(굴착기 0.7㎥)",
|
||||
"이름": "암절취 집토",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-4-2",
|
||||
"개정": [2026],
|
||||
@@ -235,7 +235,7 @@
|
||||
"원문번호": "9-5-2",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "발파암 깎기 90%(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "발파암 깎기(90%)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-5-2",
|
||||
"개정": [2026],
|
||||
@@ -319,7 +319,7 @@
|
||||
"원문번호": "9-5-3",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "발파암 집토(굴착기 0.7㎥)",
|
||||
"이름": "발파암 집토",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-5-3",
|
||||
"개정": [2026],
|
||||
@@ -340,7 +340,7 @@
|
||||
"원문번호": "9-6-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "발파암 기계소할(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "발파암 기계소할(15%)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-6-1",
|
||||
"개정": [2026],
|
||||
@@ -407,7 +407,7 @@
|
||||
"원문번호": "9-7-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "콘크리트 깨기 집토(굴착기 0.7㎥)",
|
||||
"이름": "무근콘크리트 깨기 집토",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-7-1 주⑥",
|
||||
"개정": [2026],
|
||||
@@ -428,7 +428,7 @@
|
||||
"원문번호": "9-7-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "무근콘크리트 깨기(T=30㎝ 미만) 깨기(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "무근콘크리트 깨기(T=30㎝ 미만)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-7-1",
|
||||
"개정": [2026],
|
||||
@@ -475,7 +475,7 @@
|
||||
"원문번호": "9-7-2",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "무근콘크리트 깨기(T=30㎝ 이상) 깨기(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "무근콘크리트 깨기(T=30㎝ 이상)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-7-2",
|
||||
"개정": [2026],
|
||||
@@ -579,7 +579,7 @@
|
||||
"원문번호": "9-8-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "철근콘크리트 깨기(T=30㎝ 미만) 깨기(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "철근콘크리트 깨기(T=30㎝ 미만)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-8-1",
|
||||
"개정": [2026],
|
||||
@@ -626,7 +626,7 @@
|
||||
"원문번호": "9-8-2",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "철근콘크리트 깨기(T=30㎝ 이상) 깨기(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "철근콘크리트 깨기(T=30㎝ 이상)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-8-2",
|
||||
"개정": [2026],
|
||||
@@ -691,7 +691,7 @@
|
||||
"원문번호": "9-10-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "기존포장 깨기 콘크리트 깨기(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "기존포장 깨기 콘크리트",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-10-1",
|
||||
"개정": [2026],
|
||||
@@ -738,7 +738,7 @@
|
||||
"원문번호": "9-10-2",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "기존포장 깨기 아스팔트 깨기(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "기존포장 깨기 아스팔트",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-10-2",
|
||||
"개정": [2026],
|
||||
@@ -2101,7 +2101,7 @@
|
||||
"원문번호": "9-14-2",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "다짐(플레이트콤펙터 1.5ton)",
|
||||
"이름": "다짐(플레이트콤펙터)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-14-2",
|
||||
"개정": [2026],
|
||||
@@ -2141,7 +2141,7 @@
|
||||
"원문번호": "9-15-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "표토제거 답(畓)구간(굴착기 0.7㎥)",
|
||||
"이름": "표토제거 답(畓)구간",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "산림품셈 9-15-1",
|
||||
"개정": [2026],
|
||||
@@ -2201,7 +2201,7 @@
|
||||
"원문번호": "9-15-2",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "표토제거 답(畓)외구간(불도저 19ton)",
|
||||
"이름": "표토제거 답(畓)외구간",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "산림품셈 9-15-2",
|
||||
"개정": [2026],
|
||||
@@ -2243,7 +2243,7 @@
|
||||
"원문번호": "9-16-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "노체포설(굴착기 0.7㎥ 무한궤도)",
|
||||
"이름": "노체포설",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-16-1",
|
||||
"개정": [2026],
|
||||
@@ -2372,7 +2372,7 @@
|
||||
"원문번호": "9-17-2",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "비다짐(정지 · 불도저 19ton)",
|
||||
"이름": "비다짐(정지)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-17-2",
|
||||
"개정": [2026],
|
||||
@@ -2415,7 +2415,7 @@
|
||||
"원문번호": "9-18",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "층따기(굴착기 0.7㎥)",
|
||||
"이름": "층따기",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-18",
|
||||
"개정": [2026],
|
||||
@@ -2478,7 +2478,7 @@
|
||||
"원문번호": "9-19-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "토사면 성토면 고르기(기계시공 · 굴착기 0.6㎥)",
|
||||
"이름": "토사면 성토면 고르기(기계시공)",
|
||||
"결과단위": "원/10㎡",
|
||||
"출처": "산림품셈 9-19-1",
|
||||
"개정": [2026],
|
||||
@@ -2661,7 +2661,7 @@
|
||||
"원문번호": "9-22",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "섞기(굴착기 0.7㎥)",
|
||||
"이름": "섞기",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-22",
|
||||
"개정": [2026],
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"원문번호": "10-3-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "골재운반 모래(덤프 15ton)",
|
||||
"이름": "골재운반 모래",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 10-3-1",
|
||||
"개정": [2026],
|
||||
@@ -61,7 +61,7 @@
|
||||
"원문번호": "10-3-2",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "골재운반 잡석(덤프 15ton)",
|
||||
"이름": "골재운반 잡석",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 10-3-2",
|
||||
"개정": [2026],
|
||||
@@ -96,7 +96,7 @@
|
||||
"원문번호": "10-3-3",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "골재운반 혼합골재(덤프 15ton)",
|
||||
"이름": "골재운반 혼합골재",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 10-3-3",
|
||||
"개정": [2026],
|
||||
@@ -131,7 +131,7 @@
|
||||
"원문번호": "10-4",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "중기운반 트레일러(20ton)",
|
||||
"이름": "중기운반",
|
||||
"결과단위": "원/회",
|
||||
"출처": "산림품셈 10-4",
|
||||
"개정": [2026],
|
||||
@@ -565,7 +565,7 @@
|
||||
"원문번호": "10-9-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "덤프트럭 자재 운반(1회 표준 운반량)",
|
||||
"이름": "덤프트럭 운반(자재의 1회당 표준 운반량)",
|
||||
"결과단위": "원/ton",
|
||||
"출처": "산림품셈 10-9-1",
|
||||
"개정": [2026],
|
||||
@@ -729,7 +729,7 @@
|
||||
"원문번호": "10-12-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "덤프운반(굴착기 0.7㎥ 적재 · 덤프트럭 15ton)",
|
||||
"이름": "덤프운반 토사",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 10-12-1",
|
||||
"개정": [2026],
|
||||
@@ -861,7 +861,7 @@
|
||||
"원문번호": "10-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "시멘트운반(구역화물 운임 + 하차비)",
|
||||
"이름": "시멘트운반",
|
||||
"결과단위": "원/포",
|
||||
"출처": "산림품셈 10-1",
|
||||
"개정": [2026],
|
||||
@@ -964,7 +964,7 @@
|
||||
"원문번호": "10-2",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "철근운반(구역화물 운임 + 하차비)",
|
||||
"이름": "철근운반",
|
||||
"결과단위": "원/ton",
|
||||
"출처": "산림품셈 10-2",
|
||||
"개정": [2026],
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
"원문번호": "11-4",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "11장 가설",
|
||||
"이름": "쇄석·혼합석 부설(굴착기 0.7㎥)",
|
||||
"이름": "쇄석·혼합석 부설",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 11-4",
|
||||
"개정": [2026],
|
||||
|
||||
@@ -704,7 +704,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC001013",
|
||||
"이름": "약제살포(동력분무기)",
|
||||
"이름": "약제살포(기계)",
|
||||
"결과단위": "원/ℓ",
|
||||
"출처": "건설품셈 유지관리 1-2-16",
|
||||
"변수": [
|
||||
|
||||
@@ -889,7 +889,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000300",
|
||||
"이름": "콘크리트 페이버 조립 및 해체",
|
||||
"이름": "기계포설 장비조립 및 해체",
|
||||
"결과단위": "원/회",
|
||||
"출처": "건설품셈 토목 1-6-5",
|
||||
"변수": [
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000559",
|
||||
"이름": "철골 공장가공조립(기본철골공수)",
|
||||
"이름": "철골 가공 조립 기본철골공수",
|
||||
"결과단위": "원/ton",
|
||||
"출처": "건설품셈 건축 1-1-1",
|
||||
"변수": [
|
||||
@@ -51,7 +51,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000560",
|
||||
"이름": "철골 공장용접(기본용접공수)",
|
||||
"이름": "철골 가공 조립 기본용접공수",
|
||||
"결과단위": "원/ton",
|
||||
"출처": "건설품셈 건축 1-1-3",
|
||||
"변수": [
|
||||
@@ -302,7 +302,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000567",
|
||||
"이름": "타워크레인 가설 · 해체 · 수직이동",
|
||||
"이름": "철골세우기용 장비의 가설 및 해체이동",
|
||||
"결과단위": "원/대",
|
||||
"출처": "건설품셈 건축 1-2-7",
|
||||
"변수": [
|
||||
|
||||
@@ -641,6 +641,55 @@
|
||||
"끝전": "비목버림"
|
||||
}
|
||||
},
|
||||
{
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000100",
|
||||
"이름": "암파쇄(유압식 할암공법)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "건설품셈 공통 3-3-8",
|
||||
"변수": [
|
||||
{"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}},
|
||||
{"열쇠": "보정작업", "기호": "S", "아래": "보정작업", "위": "", "이름": "보정작업", "단위": "", "출처": "건설품셈 공통 8-1-7 1", "연결": {"갈래": "선택", "목록": ["해당 없음", "암석작업(연암・보통암・경암)", "전석섞인토사"], "값": "해당 없음"}},
|
||||
{"열쇠": "드릴경비", "기호": "C", "아래": "드릴", "위": "", "이름": "드릴경비", "단위": "", "연결": {"갈래": "식"}},
|
||||
{"열쇠": "브레이커경비", "기호": "C", "아래": "브레이커", "위": "", "이름": "브레이커경비", "단위": "", "연결": {"갈래": "식"}},
|
||||
{"열쇠": "P_기계시간당", "기호": "P", "아래": "기계시간당", "위": "", "이름": "기계 시간당 기계경비(손료 보정 없는 기종)", "단위": "원/시간", "연결": {"갈래": "단가산출", "키": "GC000268", "입력": {"기계": "'5405-0110'", "지역": "S_지역"}, "칸": "계"}},
|
||||
{"열쇠": "Q_유압식크롤드^표", "기호": "Q", "아래": "유압식크롤드", "위": "표", "이름": "암파쇄(유압식 할암공법).유압식크롤러드릴", "단위": "hr", "연결": {"갈래": "표", "표": "QC000201", "조건": {}, "칸": "유압식크롤러드릴"}},
|
||||
{"열쇠": "P_굴착기무한궤", "기호": "P", "아래": "굴착기무한궤", "위": "", "이름": "굴착기(무한궤도)+대형브레이커 시간당 기계경비", "단위": "원/시간", "연결": {"갈래": "단가산출", "키": "GC000271", "입력": {"기계": "'0201-0100'", "브레이커": "'0230-0010'", "지역": "S_지역", "보정작업": "S_보정작업"}, "칸": "계"}},
|
||||
{"열쇠": "Q_굴착기대형브^표", "기호": "Q", "아래": "굴착기대형브", "위": "표", "이름": "암파쇄(유압식 할암공법).굴착기_대형브레이커", "단위": "hr", "연결": {"갈래": "표", "표": "QC000201", "조건": {}, "칸": "굴착기_대형브레이커"}},
|
||||
{"열쇠": "Q_기계설비공^표", "기호": "Q", "아래": "기계설비공", "위": "표", "이름": "암파쇄(유압식 할암공법).기계설비공", "단위": "인", "연결": {"갈래": "표", "표": "QC000201", "조건": {}, "칸": "기계설비공"}},
|
||||
{"열쇠": "w_기계설비공", "기호": "w", "아래": "기계설비공", "위": "", "이름": "기계설비공노임", "단위": "", "연결": {"갈래": "요소", "키": "LB000051"}},
|
||||
{"열쇠": "Q_특별인부^표", "기호": "Q", "아래": "특별인부", "위": "표", "이름": "암파쇄(유압식 할암공법).특별인부", "단위": "인", "연결": {"갈래": "표", "표": "QC000201", "조건": {}, "칸": "특별인부"}},
|
||||
{"열쇠": "w_특별인부", "기호": "w", "아래": "특별인부", "위": "", "이름": "특별인부노임", "단위": "", "연결": {"갈래": "요소", "키": "LB000003"}},
|
||||
{"열쇠": "P_기계시간당2", "기호": "P", "아래": "기계시간당2", "위": "", "이름": "기계 시간당 기계경비(손료 보정 없는 기종)", "단위": "원/시간", "연결": {"갈래": "단가산출", "키": "GC000268", "입력": {"기계": "'5405-0110'", "지역": "S_지역"}}},
|
||||
{"열쇠": "Q_발전기^표", "기호": "Q", "아래": "발전기", "위": "표", "이름": "암파쇄(유압식 할암공법).발전기", "단위": "hr", "연결": {"갈래": "표", "표": "QC000201", "조건": {}, "칸": "발전기"}},
|
||||
{"열쇠": "P_기계시간당3", "기호": "P", "아래": "기계시간당3", "위": "", "이름": "기계 시간당 기계경비(손료 보정 없는 기종)", "단위": "원/시간", "연결": {"갈래": "단가산출", "키": "GC000268", "입력": {"기계": "'7505-0025'", "지역": "S_지역"}}},
|
||||
{"열쇠": "Q_유압식할암기^표", "기호": "Q", "아래": "유압식할암기", "위": "표", "이름": "암파쇄(유압식 할암공법).유압식할암기", "단위": "hr", "연결": {"갈래": "표", "표": "QC000201", "조건": {}, "칸": "유압식할암기"}},
|
||||
{"열쇠": "P_기계시간당4", "기호": "P", "아래": "기계시간당4", "위": "", "이름": "기계 시간당 손료(연료·조종원 없는 기종)", "단위": "원/시간", "연결": {"갈래": "단가산출", "키": "GC000273", "입력": {"기계": "'5501-0080'"}}},
|
||||
{"열쇠": "P_굴착기무한2", "기호": "P", "아래": "굴착기무한2", "위": "", "이름": "굴착기(무한궤도)+대형브레이커 시간당 기계경비", "단위": "원/시간", "연결": {"갈래": "단가산출", "키": "GC000271", "입력": {"기계": "'0201-0100'", "브레이커": "'0230-0010'", "지역": "S_지역", "보정작업": "S_보정작업"}}},
|
||||
{"열쇠": "Q_기계경비의^표", "기호": "Q", "아래": "기계경비의", "위": "표", "이름": "소모자재 요율.기계경비의", "단위": "%", "연결": {"갈래": "표", "표": "QC000202", "조건": {"장비": "'유압식 크롤러드릴'"}, "칸": "기계경비의"}},
|
||||
{"열쇠": "Q_기계경비의2^표", "기호": "Q", "아래": "기계경비의2", "위": "표", "이름": "소모자재 요율.기계경비의", "단위": "%", "연결": {"갈래": "표", "표": "QC000202", "조건": {"장비": "'굴착기+대형브레이커'"}, "칸": "기계경비의"}}
|
||||
],
|
||||
"식": [
|
||||
{"기호": "C_드릴", "식": "P_기계시간당 × Q_유압식크롤드^표"},
|
||||
{"기호": "C_브레이커", "식": "P_굴착기무한궤 × Q_굴착기대형브^표"}
|
||||
],
|
||||
"금액": [
|
||||
{"이름": "기계설비공", "단위": "인", "비목": "노무비", "수량": "Q_기계설비공^표", "단가": "w_기계설비공", "묶음": "인력"},
|
||||
{"이름": "특별인부", "단위": "인", "비목": "노무비", "수량": "Q_특별인부^표", "단가": "w_특별인부", "묶음": "인력"},
|
||||
{"이름": "유압식 크롤러드릴 110㎾", "단위": "시간", "비목": "묶음", "수량": "Q_유압식크롤드^표", "단가": "P_기계시간당2", "묶음": "유압식 크롤러드릴 110㎾"},
|
||||
{"이름": "발전기 25㎾", "단위": "시간", "비목": "묶음", "수량": "Q_발전기^표", "단가": "P_기계시간당3", "묶음": "발전기 25㎾"},
|
||||
{"이름": "유압식할암기 ∅80", "단위": "시간", "비목": "묶음", "수량": "Q_유압식할암기^표", "단가": "P_기계시간당4", "묶음": "유압식할암기 ∅80"},
|
||||
{"이름": "굴착기+대형브레이커 1.0㎥", "단위": "시간", "비목": "묶음", "수량": "Q_굴착기대형브^표", "단가": "P_굴착기무한2", "묶음": "굴착기+대형브레이커 1.0㎥"},
|
||||
{"이름": "소모자재(크롤러드릴)", "단위": "식", "비목": "재료비", "단가": "C_드릴 × Q_기계경비의^표 ÷ 100", "출처": "건설품셈 공통 3-3-8 주④", "묶음": "소모자재(크롤러드릴) · 소모자재(대형브레이커)"},
|
||||
{"이름": "소모자재(대형브레이커)", "단위": "식", "비목": "재료비", "단가": "C_브레이커 × Q_기계경비의2^표 ÷ 100", "출처": "건설품셈 공통 3-3-8 주④", "묶음": "소모자재(크롤러드릴) · 소모자재(대형브레이커)"}
|
||||
],
|
||||
"끝수": {
|
||||
"줄": {"방법": "버림", "자리": 1},
|
||||
"비목": {"방법": "버림", "자리": 0},
|
||||
"끝전": "비목버림"
|
||||
}
|
||||
},
|
||||
{
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
|
||||
@@ -285,7 +285,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000169",
|
||||
"이름": "앵커 천공(크롤러형 보링장비)",
|
||||
"이름": "앵커 천공",
|
||||
"결과단위": "원/공",
|
||||
"출처": "건설품셈 공통 5-1-7",
|
||||
"변수": [
|
||||
@@ -1029,7 +1029,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000184",
|
||||
"이름": "PBD천공기 조립 및 해체",
|
||||
"이름": "PBD 장비조립 및 해체",
|
||||
"결과단위": "원/회",
|
||||
"출처": "건설품셈 공통 5-2-3",
|
||||
"변수": [
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000631",
|
||||
"이름": "방수프라이머 바름(롤러 1회)",
|
||||
"이름": "방수프라이머 바름",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 건축 6-1-2",
|
||||
"변수": [
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000260",
|
||||
"이름": "메쌓기(굴착기+부착용 집게)",
|
||||
"이름": "메쌓기",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 공통 7-1-1",
|
||||
"변수": [
|
||||
@@ -39,7 +39,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000261",
|
||||
"이름": "찰쌓기(굴착기+부착용 집게)",
|
||||
"이름": "찰쌓기",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 공통 7-1-2",
|
||||
"변수": [
|
||||
@@ -72,7 +72,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000262",
|
||||
"이름": "메붙임(굴착기+부착용 집게)",
|
||||
"이름": "메붙임",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 공통 7-2-1",
|
||||
"변수": [
|
||||
@@ -105,7 +105,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000263",
|
||||
"이름": "찰붙임(굴착기+부착용 집게)",
|
||||
"이름": "찰붙임",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 공통 7-2-2",
|
||||
"변수": [
|
||||
@@ -138,7 +138,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000264",
|
||||
"이름": "전석쌓기(굴착기+부착용 집게)",
|
||||
"이름": "전석쌓기",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 공통 7-3-1",
|
||||
"변수": [
|
||||
@@ -177,7 +177,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000265",
|
||||
"이름": "전석깔기(굴착기+부착용 집게)",
|
||||
"이름": "전석깔기",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 공통 7-3-2",
|
||||
"변수": [
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000822",
|
||||
"이름": "바탕만들기(블라스트 · 공구 · 와이어브러시)",
|
||||
"이름": "바탕만들기",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "건설품셈 기계설비 9-2-1",
|
||||
"변수": [
|
||||
@@ -245,7 +245,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000827",
|
||||
"이름": "가스배관 기밀시험",
|
||||
"이름": "기밀시험",
|
||||
"결과단위": "원/회",
|
||||
"출처": "건설품셈 기계설비 9-4-1",
|
||||
"변수": [
|
||||
|
||||
@@ -873,7 +873,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000900",
|
||||
"이름": "응력제거(Ring Burner · 전기저항)",
|
||||
"이름": "응력제거(Ring Burner, Electric, Resistence Heating)",
|
||||
"결과단위": "원/개소",
|
||||
"출처": "건설품셈 기계설비 13-2-6",
|
||||
"변수": [
|
||||
@@ -1357,7 +1357,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000912",
|
||||
"이름": "화력 보일러 설치(공정별)",
|
||||
"이름": "화력 보일러 설치",
|
||||
"결과단위": "원/기",
|
||||
"출처": "건설품셈 기계설비 13-5-1",
|
||||
"변수": [
|
||||
@@ -1479,7 +1479,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000914",
|
||||
"이름": "보일러 드럼 설치(공정별)",
|
||||
"이름": "보일러 드럼 설치",
|
||||
"결과단위": "원/대",
|
||||
"출처": "건설품셈 기계설비 13-5-2",
|
||||
"변수": [
|
||||
@@ -1619,7 +1619,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000916",
|
||||
"이름": "덕트 설치(공정별)",
|
||||
"이름": "덕트 설치",
|
||||
"결과단위": "원/식",
|
||||
"출처": "건설품셈 기계설비 13-5-4",
|
||||
"변수": [
|
||||
@@ -1690,7 +1690,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000917",
|
||||
"이름": "공기예열기(Preheater) 설치(공정별)",
|
||||
"이름": "공기예열기(Preheater) 설치",
|
||||
"결과단위": "원/식",
|
||||
"출처": "건설품셈 기계설비 13-5-5",
|
||||
"변수": [
|
||||
@@ -1841,7 +1841,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000920",
|
||||
"이름": "터빈 설치(공정별)",
|
||||
"이름": "터빈 설치",
|
||||
"결과단위": "원/기",
|
||||
"출처": "건설품셈 기계설비 13-5-8",
|
||||
"변수": [
|
||||
@@ -1929,7 +1929,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000921",
|
||||
"이름": "발전기 설치(공정별)",
|
||||
"이름": "발전기 설치",
|
||||
"결과단위": "원/기",
|
||||
"출처": "건설품셈 기계설비 13-5-9",
|
||||
"변수": [
|
||||
@@ -2003,7 +2003,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000922",
|
||||
"이름": "복수기 설치(공정별)",
|
||||
"이름": "복수기 설치",
|
||||
"결과단위": "원/기",
|
||||
"출처": "건설품셈 기계설비 13-5-10",
|
||||
"변수": [
|
||||
@@ -2357,7 +2357,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000931",
|
||||
"이름": "수차 설치(Kaplan · Francis · Propeller)",
|
||||
"이름": "수차 설치",
|
||||
"결과단위": "원/ton",
|
||||
"출처": "건설품셈 기계설비 13-6-1",
|
||||
"변수": [
|
||||
@@ -4950,7 +4950,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000967",
|
||||
"이름": "Mill Line 기기류 조립설치(공정별)",
|
||||
"이름": "Mill Line 기기류 조립설치",
|
||||
"결과단위": "원/식",
|
||||
"출처": "건설품셈 기계설비 13-7-19",
|
||||
"변수": [
|
||||
@@ -4993,7 +4993,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GC000968",
|
||||
"이름": "Roller Table 조립설치(공정별)",
|
||||
"이름": "Roller Table 조립설치",
|
||||
"결과단위": "원/식",
|
||||
"출처": "건설품셈 기계설비 13-7-20",
|
||||
"변수": [
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000004",
|
||||
"이름": "임산물 운반로 및 작업로 신설(굴착기 0.2㎥)",
|
||||
"이름": "임산물 운반로 및 작업로 신설비 산정",
|
||||
"결과단위": "원/m",
|
||||
"출처": "산림품셈 3-4-2",
|
||||
"변수": [
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000288",
|
||||
"이름": "단목베기(1,000㎡당 · 굴착기 0.2㎥ + 부착용 집게)",
|
||||
"이름": "단목베기(1,000㎡당)",
|
||||
"결과단위": "원/1,000㎡",
|
||||
"출처": "산림품셈 4-2-2",
|
||||
"변수": [
|
||||
@@ -181,7 +181,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000289",
|
||||
"이름": "위험목 베기(굴착기 우드그랩 0.2㎥ · 크레인 5톤)",
|
||||
"이름": "위험목 베기",
|
||||
"결과단위": "원/본",
|
||||
"출처": "산림품셈 4-3",
|
||||
"변수": [
|
||||
|
||||
@@ -1119,7 +1119,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000043",
|
||||
"이름": "복사이식(굴착기 1.0㎥)",
|
||||
"이름": "복사이식",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 5-29",
|
||||
"변수": [
|
||||
|
||||
@@ -313,7 +313,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000321",
|
||||
"이름": "지상방제 차량살포(동력분무기)",
|
||||
"이름": "지상방제 차량살포",
|
||||
"결과단위": "원/일",
|
||||
"출처": "산림품셈 8-6-3",
|
||||
"변수": [
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000089",
|
||||
"이름": "토사깍기(기계 · 굴착기 0.7㎥)",
|
||||
"이름": "토사깍기(기계)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-3-2",
|
||||
"변수": [
|
||||
@@ -102,7 +102,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000252",
|
||||
"이름": "암파쇄(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "암파쇄",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-4-1",
|
||||
"변수": [
|
||||
@@ -166,7 +166,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000091",
|
||||
"이름": "암절취 집토(굴착기 0.7㎥)",
|
||||
"이름": "암절취 집토",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-4-2",
|
||||
"변수": [
|
||||
@@ -239,7 +239,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000253",
|
||||
"이름": "발파암 깎기 90%(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "발파암 깎기(90%)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-5-2",
|
||||
"변수": [
|
||||
@@ -303,7 +303,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000093",
|
||||
"이름": "발파암 집토(굴착기 0.7㎥)",
|
||||
"이름": "발파암 집토",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-5-3",
|
||||
"변수": [
|
||||
@@ -326,7 +326,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000255",
|
||||
"이름": "발파암 기계소할(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "발파암 기계소할(15%)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-6-1",
|
||||
"변수": [
|
||||
@@ -378,7 +378,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000095",
|
||||
"이름": "콘크리트 깨기 집토(굴착기 0.7㎥)",
|
||||
"이름": "무근콘크리트 깨기 집토",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-7-1 주⑥",
|
||||
"변수": [
|
||||
@@ -401,7 +401,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000256",
|
||||
"이름": "무근콘크리트 깨기(T=30㎝ 미만) 깨기(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "무근콘크리트 깨기(T=30㎝ 미만)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-7-1",
|
||||
"변수": [
|
||||
@@ -432,7 +432,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000257",
|
||||
"이름": "무근콘크리트 깨기(T=30㎝ 이상) 깨기(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "무근콘크리트 깨기(T=30㎝ 이상)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-7-2",
|
||||
"변수": [
|
||||
@@ -495,7 +495,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000258",
|
||||
"이름": "철근콘크리트 깨기(T=30㎝ 미만) 깨기(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "철근콘크리트 깨기(T=30㎝ 미만)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-8-1",
|
||||
"변수": [
|
||||
@@ -526,7 +526,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000259",
|
||||
"이름": "철근콘크리트 깨기(T=30㎝ 이상) 깨기(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "철근콘크리트 깨기(T=30㎝ 이상)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-8-2",
|
||||
"변수": [
|
||||
@@ -581,7 +581,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000260",
|
||||
"이름": "기존포장 깨기 콘크리트 깨기(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "기존포장 깨기 콘크리트",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-10-1",
|
||||
"변수": [
|
||||
@@ -612,7 +612,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000261",
|
||||
"이름": "기존포장 깨기 아스팔트 깨기(대형브레이커 · 굴착기 0.7㎥)",
|
||||
"이름": "기존포장 깨기 아스팔트",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-10-2",
|
||||
"변수": [
|
||||
@@ -1624,7 +1624,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000249",
|
||||
"이름": "다짐(플레이트콤펙터 1.5ton)",
|
||||
"이름": "다짐(플레이트콤펙터)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-14-2",
|
||||
"변수": [
|
||||
@@ -1673,7 +1673,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000116",
|
||||
"이름": "표토제거 답(畓)구간(굴착기 0.7㎥)",
|
||||
"이름": "표토제거 답(畓)구간",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "산림품셈 9-15-1",
|
||||
"변수": [
|
||||
@@ -1729,7 +1729,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000250",
|
||||
"이름": "표토제거 답(畓)외구간(불도저 19ton)",
|
||||
"이름": "표토제거 답(畓)외구간",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "산림품셈 9-15-2",
|
||||
"변수": [
|
||||
@@ -1778,7 +1778,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000119",
|
||||
"이름": "노체포설(굴착기 0.7㎥ 무한궤도)",
|
||||
"이름": "노체포설",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-16-1",
|
||||
"변수": [
|
||||
@@ -1902,7 +1902,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000251",
|
||||
"이름": "비다짐(정지 · 불도저 19ton)",
|
||||
"이름": "비다짐(정지)",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-17-2",
|
||||
"변수": [
|
||||
@@ -1951,7 +1951,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000123",
|
||||
"이름": "층따기(굴착기 0.7㎥)",
|
||||
"이름": "층따기",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-18",
|
||||
"변수": [
|
||||
@@ -2023,7 +2023,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000126",
|
||||
"이름": "토사면 성토면 고르기(기계시공 · 굴착기 0.6㎥)",
|
||||
"이름": "토사면 성토면 고르기(기계시공)",
|
||||
"결과단위": "원/10㎡",
|
||||
"출처": "산림품셈 9-19-1",
|
||||
"변수": [
|
||||
@@ -2212,7 +2212,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000132",
|
||||
"이름": "섞기(굴착기 0.7㎥)",
|
||||
"이름": "섞기",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-22",
|
||||
"변수": [
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000134",
|
||||
"이름": "골재운반 모래(덤프 15ton)",
|
||||
"이름": "골재운반 모래",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 10-3-1",
|
||||
"변수": [
|
||||
@@ -57,7 +57,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000135",
|
||||
"이름": "골재운반 잡석(덤프 15ton)",
|
||||
"이름": "골재운반 잡석",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 10-3-2",
|
||||
"변수": [
|
||||
@@ -83,7 +83,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000136",
|
||||
"이름": "골재운반 혼합골재(덤프 15ton)",
|
||||
"이름": "골재운반 혼합골재",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 10-3-3",
|
||||
"변수": [
|
||||
@@ -109,7 +109,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000137",
|
||||
"이름": "중기운반 트레일러(20ton)",
|
||||
"이름": "중기운반",
|
||||
"결과단위": "원/회",
|
||||
"출처": "산림품셈 10-4",
|
||||
"변수": [
|
||||
@@ -507,7 +507,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000283",
|
||||
"이름": "덤프트럭 자재 운반(1회 표준 운반량)",
|
||||
"이름": "덤프트럭 운반(자재의 1회당 표준 운반량)",
|
||||
"결과단위": "원/ton",
|
||||
"출처": "산림품셈 10-9-1",
|
||||
"변수": [
|
||||
@@ -653,7 +653,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000284",
|
||||
"이름": "덤프운반(굴착기 0.7㎥ 적재 · 덤프트럭 15ton)",
|
||||
"이름": "덤프운반 토사",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 10-12-1",
|
||||
"변수": [
|
||||
@@ -761,7 +761,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000335",
|
||||
"이름": "시멘트운반(구역화물 운임 + 하차비)",
|
||||
"이름": "시멘트운반",
|
||||
"결과단위": "원/포",
|
||||
"출처": "산림품셈 10-1",
|
||||
"변수": [
|
||||
@@ -791,7 +791,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000336",
|
||||
"이름": "철근운반(구역화물 운임 + 하차비)",
|
||||
"이름": "철근운반",
|
||||
"결과단위": "원/ton",
|
||||
"출처": "산림품셈 10-2",
|
||||
"변수": [
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000154",
|
||||
"이름": "쇄석·혼합석 부설(굴착기 0.7㎥)",
|
||||
"이름": "쇄석·혼합석 부설",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 11-4",
|
||||
"변수": [
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"구분": null,
|
||||
"상세구분": null,
|
||||
"단위": "식",
|
||||
"담은로직": [{ "로직": "GF000219", "메모": "메쌓기 — 돌쌓기 인력" }, { "로직": "GF000119", "메모": "노체포설 — 굴착기 0.7㎥" }, { "로직": "GF000137", "메모": "운반 — 중기운반 트레일러(20ton)" }],
|
||||
"담은로직": [{ "로직": "GF000219", "메모": "메쌓기 — 돌쌓기 인력" }, { "로직": "GF000119", "메모": "노체포설 — 굴착기 0.7㎥" }, { "로직": "GF000137", "메모": "운반 — 중기운반" }],
|
||||
"본뜬키": null,
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"키": "SK-000019"
|
||||
},
|
||||
"별칭": null,
|
||||
"메모": "운반 — 중기운반 트레일러(20ton)",
|
||||
"메모": "운반 — 중기운반",
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
@@ -913,7 +913,7 @@
|
||||
"원문번호": "9-16-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "노체포설(굴착기 0.7㎥ 무한궤도)",
|
||||
"이름": "노체포설",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-16-1",
|
||||
"개정": [
|
||||
@@ -992,7 +992,7 @@
|
||||
"원문번호": "10-4",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "중기운반 트레일러(20ton)",
|
||||
"이름": "중기운반",
|
||||
"결과단위": "원/회",
|
||||
"출처": "산림품셈 10-4",
|
||||
"개정": [
|
||||
@@ -10625,7 +10625,7 @@
|
||||
"원문번호": "9-16-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "노체포설(굴착기 0.7㎥ 무한궤도)",
|
||||
"이름": "노체포설",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-16-1",
|
||||
"개정": [
|
||||
@@ -10704,7 +10704,7 @@
|
||||
"원문번호": "10-4",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "중기운반 트레일러(20ton)",
|
||||
"이름": "중기운반",
|
||||
"결과단위": "원/회",
|
||||
"출처": "산림품셈 10-4",
|
||||
"개정": [
|
||||
@@ -19683,7 +19683,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000137",
|
||||
"이름": "중기운반 트레일러(20ton)",
|
||||
"이름": "중기운반",
|
||||
"결과단위": "원/회",
|
||||
"출처": "산림품셈 10-4",
|
||||
"변수": [
|
||||
@@ -20333,7 +20333,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000119",
|
||||
"이름": "노체포설(굴착기 0.7㎥ 무한궤도)",
|
||||
"이름": "노체포설",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-16-1",
|
||||
"변수": [
|
||||
|
||||
@@ -786,7 +786,7 @@
|
||||
"원문번호": "4-3",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "04장 나무베기",
|
||||
"이름": "위험목 베기(굴착기 우드그랩 0.2㎥ · 크레인 5톤)",
|
||||
"이름": "위험목 베기",
|
||||
"결과단위": "원/본",
|
||||
"출처": "산림품셈 4-3",
|
||||
"개정": [
|
||||
@@ -10826,7 +10826,7 @@
|
||||
"원문번호": "4-3",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "04장 나무베기",
|
||||
"이름": "위험목 베기(굴착기 우드그랩 0.2㎥ · 크레인 5톤)",
|
||||
"이름": "위험목 베기",
|
||||
"결과단위": "원/본",
|
||||
"출처": "산림품셈 4-3",
|
||||
"개정": [
|
||||
@@ -20114,7 +20114,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "GF000289",
|
||||
"이름": "위험목 베기(굴착기 우드그랩 0.2㎥ · 크레인 5톤)",
|
||||
"이름": "위험목 베기",
|
||||
"결과단위": "원/본",
|
||||
"출처": "산림품셈 4-3",
|
||||
"변수": [
|
||||
|
||||
@@ -772,7 +772,7 @@
|
||||
"원문번호": "4-3",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "04장 나무베기",
|
||||
"이름": "위험목 베기(굴착기 우드그랩 0.2㎥ · 크레인 5톤)",
|
||||
"이름": "위험목 베기",
|
||||
"결과단위": "원/본",
|
||||
"출처": "산림품셈 4-3",
|
||||
"개정": [
|
||||
@@ -10822,7 +10822,7 @@
|
||||
"원문번호": "4-3",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "04장 나무베기",
|
||||
"이름": "위험목 베기(굴착기 우드그랩 0.2㎥ · 크레인 5톤)",
|
||||
"이름": "위험목 베기",
|
||||
"결과단위": "원/본",
|
||||
"출처": "산림품셈 4-3",
|
||||
"개정": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"종류": "단가산출",
|
||||
"키": "SK-000002",
|
||||
"이름": "위험목 베기(굴착기 우드그랩 0.2㎥ · 크레인 5톤)",
|
||||
"이름": "위험목 베기",
|
||||
"단위": "본",
|
||||
"바탕": {
|
||||
"로직": "GF000289"
|
||||
@@ -772,7 +772,7 @@
|
||||
"원문번호": "4-3",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "04장 나무베기",
|
||||
"이름": "위험목 베기(굴착기 우드그랩 0.2㎥ · 크레인 5톤)",
|
||||
"이름": "위험목 베기",
|
||||
"결과단위": "원/본",
|
||||
"출처": "산림품셈 4-3",
|
||||
"개정": [
|
||||
@@ -10823,7 +10823,7 @@
|
||||
"원문번호": "4-3",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "04장 나무베기",
|
||||
"이름": "위험목 베기(굴착기 우드그랩 0.2㎥ · 크레인 5톤)",
|
||||
"이름": "위험목 베기",
|
||||
"결과단위": "원/본",
|
||||
"출처": "산림품셈 4-3",
|
||||
"개정": [
|
||||
@@ -21000,7 +21000,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "SK-000002",
|
||||
"이름": "위험목 베기(굴착기 우드그랩 0.2㎥ · 크레인 5톤)",
|
||||
"이름": "위험목 베기",
|
||||
"결과단위": "원/본",
|
||||
"출처": "산림품셈 4-3",
|
||||
"변수": [
|
||||
|
||||
@@ -674,7 +674,7 @@
|
||||
"원문번호": "9-16-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "노체포설(굴착기 0.7㎥ 무한궤도)",
|
||||
"이름": "노체포설",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-16-1",
|
||||
"개정": [
|
||||
@@ -753,7 +753,7 @@
|
||||
"원문번호": "10-4",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "중기운반 트레일러(20ton)",
|
||||
"이름": "중기운반",
|
||||
"결과단위": "원/회",
|
||||
"출처": "산림품셈 10-4",
|
||||
"개정": [
|
||||
@@ -10392,7 +10392,7 @@
|
||||
"원문번호": "9-16-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "노체포설(굴착기 0.7㎥ 무한궤도)",
|
||||
"이름": "노체포설",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-16-1",
|
||||
"개정": [
|
||||
@@ -10471,7 +10471,7 @@
|
||||
"원문번호": "10-4",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "중기운반 트레일러(20ton)",
|
||||
"이름": "중기운반",
|
||||
"결과단위": "원/회",
|
||||
"출처": "산림품셈 10-4",
|
||||
"개정": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"종류": "단가산출",
|
||||
"키": "SK-000018",
|
||||
"이름": "노체포설(굴착기 0.7㎥ 무한궤도)",
|
||||
"이름": "노체포설",
|
||||
"단위": "㎥",
|
||||
"바탕": {
|
||||
"로직": "GF000119"
|
||||
@@ -674,7 +674,7 @@
|
||||
"원문번호": "9-16-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "노체포설(굴착기 0.7㎥ 무한궤도)",
|
||||
"이름": "노체포설",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-16-1",
|
||||
"개정": [
|
||||
@@ -753,7 +753,7 @@
|
||||
"원문번호": "10-4",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "중기운반 트레일러(20ton)",
|
||||
"이름": "중기운반",
|
||||
"결과단위": "원/회",
|
||||
"출처": "산림품셈 10-4",
|
||||
"개정": [
|
||||
@@ -10391,7 +10391,7 @@
|
||||
"원문번호": "9-16-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "노체포설(굴착기 0.7㎥ 무한궤도)",
|
||||
"이름": "노체포설",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-16-1",
|
||||
"개정": [
|
||||
@@ -10470,7 +10470,7 @@
|
||||
"원문번호": "10-4",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "중기운반 트레일러(20ton)",
|
||||
"이름": "중기운반",
|
||||
"결과단위": "원/회",
|
||||
"출처": "산림품셈 10-4",
|
||||
"개정": [
|
||||
@@ -19939,7 +19939,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "SK-000018",
|
||||
"이름": "노체포설(굴착기 0.7㎥ 무한궤도)",
|
||||
"이름": "노체포설",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-16-1",
|
||||
"변수": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"종류": "단가산출",
|
||||
"키": "SK-000019",
|
||||
"이름": "중기운반 트레일러(20ton)",
|
||||
"이름": "중기운반",
|
||||
"단위": "회",
|
||||
"바탕": {
|
||||
"로직": "GF000137"
|
||||
@@ -674,7 +674,7 @@
|
||||
"원문번호": "9-16-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "노체포설(굴착기 0.7㎥ 무한궤도)",
|
||||
"이름": "노체포설",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-16-1",
|
||||
"개정": [
|
||||
@@ -753,7 +753,7 @@
|
||||
"원문번호": "10-4",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "중기운반 트레일러(20ton)",
|
||||
"이름": "중기운반",
|
||||
"결과단위": "원/회",
|
||||
"출처": "산림품셈 10-4",
|
||||
"개정": [
|
||||
@@ -10393,7 +10393,7 @@
|
||||
"원문번호": "9-16-1",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "09장 토공",
|
||||
"이름": "노체포설(굴착기 0.7㎥ 무한궤도)",
|
||||
"이름": "노체포설",
|
||||
"결과단위": "원/㎥",
|
||||
"출처": "산림품셈 9-16-1",
|
||||
"개정": [
|
||||
@@ -10472,7 +10472,7 @@
|
||||
"원문번호": "10-4",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": "10장 자재․장비 운반",
|
||||
"이름": "중기운반 트레일러(20ton)",
|
||||
"이름": "중기운반",
|
||||
"결과단위": "원/회",
|
||||
"출처": "산림품셈 10-4",
|
||||
"개정": [
|
||||
@@ -19878,7 +19878,7 @@
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": "SK-000019",
|
||||
"이름": "중기운반 트레일러(20ton)",
|
||||
"이름": "중기운반",
|
||||
"결과단위": "원/회",
|
||||
"출처": "산림품셈 10-4",
|
||||
"변수": [
|
||||
|
||||
@@ -1520,5 +1520,5 @@
|
||||
"GF000336": "a9b9e0bb0f7da1f0",
|
||||
"GX000004": "a3f4c454a5764d6e"
|
||||
},
|
||||
"마스터": "152f8ebe50d98a4c"
|
||||
"마스터": "e65874951c3ab169"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
파일마다 프로세스 · 안쪽 산근(형제)은 같은 파일 블록을 우리 단가로 먼저 셈해 묶음 값으로 넣음(저장 층의
|
||||
형제 SK 읽기와 같은 값) · 확정은 M01 문서를 찾은 옵션(입력값 꼴)으로 다시 셈해 금액 대조.
|
||||
마지막 줄 = JSON {블록, 판정, 근접항, 오류, 확정다시셈다름} · `--rows <pkl>` = 줄 목록 저장(목록 xlsx 용).
|
||||
마지막 줄 = JSON {블록, 판정, 근접항, 확정참고, 확정참고블록, 오류, 확정다시셈다름} · `--rows <pkl>` = 줄 목록 저장(목록 xlsx 용).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -96,9 +96,12 @@ def sweep(arg: tuple[str, str, int]) -> list[dict]:
|
||||
row |= {"규격": (doc.get("가져온") or {}).get("규격"), **res}
|
||||
if res.get("판정") == dm.SURE:
|
||||
again = dm._calc(docs[res["키"]], dm.ml.Links(whole, docs), dict(res["옵션"]))
|
||||
money = res["금액"] # 다시 셈 = 우리 · 참고 없으면 가져온 = 우리(812-2)
|
||||
row["다시셈같음"] = (
|
||||
again is not None and dm._plain(dm._totals(again)) == res["금액"]["가져온"]
|
||||
) # noqa: E501
|
||||
again is not None
|
||||
and dm._plain(dm._totals(again)) == money["우리"]
|
||||
and (bool(res.get("참고")) or money["우리"] == money["가져온"])
|
||||
)
|
||||
row["초"] = round(time.time() - start, 2)
|
||||
rows.append(row)
|
||||
return rows
|
||||
@@ -121,6 +124,12 @@ def main() -> None:
|
||||
"근접항": dict(
|
||||
collections.Counter(d["항"] for r in real if r["판정"] == "근접" for d in r["다른"])
|
||||
), # noqa: E501
|
||||
"확정참고": dict(
|
||||
collections.Counter(
|
||||
d["항"] for r in real if r["판정"] == "확정" for d in r.get("참고") or []
|
||||
)
|
||||
),
|
||||
"확정참고블록": sum(1 for r in real if r["판정"] == "확정" and r.get("참고")),
|
||||
"오류": sum(1 for r in real if r["판정"] == "오류"),
|
||||
"확정다시셈다름": sum(1 for r in real if r.get("다시셈같음") is False),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/* 812-3 가져온 단가산출 차림 헬퍼 — 줄 쌓기 모델(common_util_line_stack.ts)을 그 자리에서 트랜스파일해 Node 로 돌림.
|
||||
* stdin = [{키, 문서, 파이썬 [{제목, 가나다?, 줄 [{꼴, i | 기호 | 글}]}]}]
|
||||
* stdout = [{키, 같음(문서 → 줄 → 문서 · 칸 차례 무시), 거울(TS stackOf = 파이썬 stack_of), 항목 · 제목 · 공식 · 글 · 쌓기}]
|
||||
* 짝 = test_812_3_native_layout.py · 파이썬 거울 = M02_Doc_NativeLayout.py */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const cache = new Map();
|
||||
function load(at) {
|
||||
if (cache.has(at)) return cache.get(at);
|
||||
const js = ts.transpileModule(fs.readFileSync(at, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(at, box.exports);
|
||||
const need = (p) =>
|
||||
p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p);
|
||||
new Function("exports", "module", "require", js)(box.exports, box, need);
|
||||
return box.exports;
|
||||
}
|
||||
const ls = load(path.join(__dirname, "..", "..", "common_util", "common_util_line_stack.ts"));
|
||||
|
||||
function canon(v) {
|
||||
if (Array.isArray(v)) return v.map(canon);
|
||||
if (v && typeof v === "object") {
|
||||
const out = {};
|
||||
for (const k of Object.keys(v).sort()) out[k] = canon(v[k]);
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
const same = (a, b) => JSON.stringify(canon(a)) === JSON.stringify(canon(b));
|
||||
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = input.map(({ 키, 문서, 파이썬 }) => {
|
||||
const items = ls.stackOf(문서);
|
||||
const back = ls.docOf(문서, items);
|
||||
const lines = items.flatMap((it) => it.줄);
|
||||
return {
|
||||
키,
|
||||
같음: same(back, 문서),
|
||||
거울: same(items, 파이썬),
|
||||
항목: items.length,
|
||||
제목: items.filter((it) => it.제목.trim()).length,
|
||||
공식: lines.filter((l) => l.꼴 === "공식" && l.기호.length).length,
|
||||
글: lines.filter((l) => l.꼴 === "글").length,
|
||||
쌓기: "쌓기" in 문서,
|
||||
...(same(items, 파이썬) ? {} : { ts: items }),
|
||||
};
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -76,7 +76,7 @@ def test_돈_하위_로직_산근_호표_번호():
|
||||
|
||||
got = st.render_sangeun_text(_whole(["GF000089", "GF000123"], edit), "GF000089", {}, _price_of)
|
||||
line = next(t for t in got if t.startswith("참고 = "))
|
||||
assert line.startswith("참고 = 층따기(굴착기 0.7㎥)(산근 3호표).노무비 = ")
|
||||
assert line.startswith("참고 = 층따기(산근 3호표).노무비 = ")
|
||||
|
||||
|
||||
def test_만약_걸린_갈래_입력_설명_기계_이름():
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
"""793-1 지킴 — 경쟁사 산근 글 식 파서 · 참조 평가기(`M02_San_*`) · 실무 여덟 파일 금액 그대로.
|
||||
|
||||
STmate = 숨은 G 열 원문 식 → 233/233 블록 · 금액 줄 1,315 · 계표시 줄 695 다 맞음.
|
||||
EST = 보이는 글 → 식 복원 → 222/222 블록 · 돈 · 값 줄 2,901/2,903 · 모호 단가 토큰 9.
|
||||
단가 바뀜 전파 견본 = `_분석_649-G2` 4 장(보통인부 → 180,000).
|
||||
엑셀 = `helper_m02_hopyo_golden.FILES`(읽기만) · 없으면 skip(다른 PC).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
import helper_m02_hopyo_golden as golden
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
from M02_MasterTemplete import M02_San_Est as est
|
||||
from M02_MasterTemplete import M02_San_Eval as ev
|
||||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||||
|
||||
# 파일: (블록, 금액 줄, 계표시 줄, 끝전)
|
||||
STMATE = {
|
||||
"봉화": (62, 349, 184, "비목버림"),
|
||||
"동부청영월": (69, 411, 230, "합버림_큰나머지"),
|
||||
"울진대흥": (47, 261, 128, "비목버림"),
|
||||
"영덕계류": (15, 86, 41, "비목버림"),
|
||||
"울진소광": (40, 208, 112, "비목버림"),
|
||||
}
|
||||
# 파일: (블록, 돈 · 값 줄, 틀린 줄 상한, 모호 토큰)
|
||||
EST = {"북부청임도": (94, 1252, 2, 0), "북부청사방": (67, 849, 0, 7), "거창": (61, 802, 0, 2)}
|
||||
LABOR = ("노임", "보통인부", "", "L")
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _load(tag: str) -> dict:
|
||||
path = golden.FILES[tag]
|
||||
if not path.exists():
|
||||
pytest.skip(f"실무 엑셀 없음 {path.name}")
|
||||
return book.load(path)
|
||||
|
||||
|
||||
def _stmate_runs(tag: str) -> list[tuple]:
|
||||
data = _load(tag)
|
||||
out = []
|
||||
for blk in data["블록"]:
|
||||
if not any(ln["G"].strip() for ln in blk["몸"]):
|
||||
continue
|
||||
res = ev.stmate_block(blk, stm.parse_block(blk["몸"]), data["단가"], STMATE[tag][3])
|
||||
out.append((blk, res, ev.stmate_check(blk, res)))
|
||||
return out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", list(STMATE))
|
||||
def test_stmate_blocks_equal_excel(tag: str) -> None:
|
||||
blocks, amount_lines, total_lines, _ = STMATE[tag]
|
||||
runs = _stmate_runs(tag)
|
||||
assert len(runs) == blocks
|
||||
wrong = [b["코드"] for b, _, c in runs if not c["맞음"]]
|
||||
assert not wrong, wrong
|
||||
assert sum(c["금액줄"] for _, _, c in runs) == amount_lines
|
||||
assert [x for _, _, c in runs for x in c["틀린줄"]] == []
|
||||
assert sum(c["계줄"] for _, _, c in runs) == total_lines
|
||||
assert sum(c["계줄맞음"] for _, _, c in runs) == total_lines
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", list(EST))
|
||||
def test_est_blocks_equal_excel(tag: str) -> None:
|
||||
blocks, lines, bad_max, vague = EST[tag]
|
||||
data = _load(tag)
|
||||
n, wrong, got_lines, got_bad, got_vague = 0, [], 0, 0, 0
|
||||
for blk in data["블록"]:
|
||||
want = data["총괄"].get(blk["번"])
|
||||
if not want or not (want["T"] or any(want[c] for c in "LMS")):
|
||||
continue
|
||||
parsed = est.parse_block(blk["글"], data["단가"])
|
||||
chk = ev.est_check(parsed, want)
|
||||
n += 1
|
||||
if not chk["맞음"]:
|
||||
wrong.append(blk["번"])
|
||||
got_lines += chk["줄수"]
|
||||
got_bad += len(chk["틀린줄"])
|
||||
got_vague += sum(1 for L in parsed for r in (L.get("역할") or {}).values() if r.get("모호"))
|
||||
assert (n, wrong) == (blocks, [])
|
||||
assert got_lines == lines
|
||||
assert got_bad <= bad_max
|
||||
assert got_vague == vague
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag, code, labor, before, after",
|
||||
[
|
||||
("봉화", "D00102", "L00007", (34119, 11192, 341), (34697, 11192, 346)),
|
||||
("동부청영월", "D00003", "L00002", (4764, 310, 397), (5097, 309, 397)),
|
||||
("울진소광", "D01489", "L00016", (182223, 230359, 8152), (185067, 230359, 8209)),
|
||||
],
|
||||
)
|
||||
def test_stmate_price_propagates(tag, code, labor, before, after) -> None:
|
||||
data = _load(tag)
|
||||
blk = next(b for b in data["블록"] if b["코드"] == code)
|
||||
nodes = stm.parse_block(blk["몸"])
|
||||
rule = STMATE[tag][3]
|
||||
prices = dict(data["단가"])
|
||||
prices[labor] = {**prices[labor], "T": 180000, "L": 180000}
|
||||
for p, want in ((data["단가"], before), (prices, after)):
|
||||
got = ev.stmate_block(blk, nodes, p, rule)["계"]
|
||||
assert (got["L"], got["M"], got["S"]) == want
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag, no, after",
|
||||
[
|
||||
("북부청사방", 31, (14877, 5491, 0)),
|
||||
("북부청임도", 2, (22774, 3235, 3830)),
|
||||
("거창", 1, (3485, 832, 1049)),
|
||||
("북부청사방", 3, (20147, 391, 0)),
|
||||
],
|
||||
)
|
||||
def test_est_price_propagates(tag, no, after) -> None:
|
||||
data = _load(tag)
|
||||
blk = next(b for b in data["블록"] if b["번"] == no)
|
||||
parsed = est.parse_block(blk["글"], data["단가"])
|
||||
des = ev.designate(parsed, data["총괄"][no])
|
||||
got = ev.est_block(parsed, des, {LABOR: 180000.0})["금액"]
|
||||
assert (got["L"], got["M"], got["S"]) == after
|
||||
|
||||
|
||||
# ── 엑셀 없이 도는 문법 견본(`_분석_648-B` 3-1 봉화 D00102) ──
|
||||
def test_stmate_grammar_sample() -> None:
|
||||
prices = {
|
||||
"M00081": {"T": 11468, "L": 0, "M": 11468, "S": 0, "meta": {}},
|
||||
"L00007": {"T": 165545, "L": 165545, "M": 0, "S": 0, "meta": {}},
|
||||
"L00099": {"T": 2, "L": 2, "M": 0, "S": 0, "meta": {"UNT": "L%"}},
|
||||
}
|
||||
text = [
|
||||
"'합판(내수) :'&M00081& * 1.03 * 0.327 :=A01",
|
||||
";;Q = 2.5 / 3 =",
|
||||
"'보통인부' &L00007& * [Q] / 4 :=A02",
|
||||
"&PA01M:M& * 11'%' =",
|
||||
"&PA02L:S& * 1'%' = // 공구손료",
|
||||
"(=)",
|
||||
"&L00099& * 2 =",
|
||||
"(-=)",
|
||||
]
|
||||
nodes = stm.parse_block([{"행": i, "G": g} for i, g in enumerate(text)])
|
||||
assert [n["종류"] for n in nodes] == ["참조", "대입", "참조", "율", "율", "계", "참조", "계"]
|
||||
assert nodes[1]["숨김"] and nodes[4]["덧글"] == "공구손료"
|
||||
assert nodes[3]["율"] == {"곱": False, "범위": "A", "번지": ["A01"], "비목": "M", "옮김": "M"}
|
||||
lines, tot = ev.run_stmate(nodes, prices)
|
||||
assert lines[0]["값"]["M"] == 3862.5
|
||||
assert lines[2]["값"]["L"] == 34350.5 # 165,545 × 0.83(Q 반올림) / 4 → 0.1 버림
|
||||
assert lines[3]["값"]["M"] == 424.8 # A01 재료 3,862.5 × 11 %
|
||||
assert lines[4]["값"]["S"] == 343.5 # A02 노무 34,350.5 × 1 % → 경비
|
||||
assert lines[5]["값"] == pytest.approx({"L": 34350.5, "M": 4287.3, "S": 343.5})
|
||||
assert lines[6]["종류"] == "특수" and lines[6]["값"]["L"] == 687.0 # 노무 누계 × 2 %
|
||||
assert lines[7]["값"]["L"] == 687.0 # (-=) 찍되 합에서 뺌
|
||||
assert tot == pytest.approx({"L": 34350.5, "M": 4287.3, "S": 343.5})
|
||||
|
||||
|
||||
def test_est_grammar_sample() -> None:
|
||||
prices = [{"종류": "노임", "이름": "보통인부", "규격": "", "비목": "L", "값": 172068.0}]
|
||||
text = [
|
||||
"보통인부 : 172,068 × 0.09 인 = 15,486.1",
|
||||
"공구손료 : 15,486.1 × 3 % = 464.5",
|
||||
"소 계 : 15,950.6",
|
||||
"노무비 : 15,950.6 × 1 = 15,950",
|
||||
]
|
||||
lines = est.parse_block(text, prices)
|
||||
assert [L.get("종류") for L in lines] == ["단가줄", "참조줄", "값줄", "참조줄"]
|
||||
assert lines[3]["규칙"] == "버림0" and lines[2]["숨은"]["갈래"] == "합"
|
||||
val = ev.run_est(lines, {LABOR: 180000.0})
|
||||
assert val[0] == 16200.0 and val[1] == 486.0 and val[3] == 16686.0
|
||||
@@ -1,6 +1,6 @@
|
||||
"""793-2a 지킴 — 경쟁사 산근 → 우리 엔진 로직 줄 꼴(`M02_San_Emit`) · 엔진 셈 = 엑셀.
|
||||
|
||||
여덟 파일 STmate 233 · EST 222 블록을 로직 JSON 으로 내보내 `master_formula.run` →
|
||||
STmate 233 블록을 로직 JSON 으로(EST 엑셀 = 812-1 엑셀 읽기기 → 원파일 길 · `test_812_1` 이 지킴) 내보내 `master_formula.run` →
|
||||
`master_cut.engine` 으로 셈 — 노 · 재 · 경 · 계 = 엑셀(STmate 계 꼬리 · EST 총괄표) =
|
||||
참조 평가기(`M02_San_Eval`) · 줄마다(STmate 금액 줄 · EST 돈 · 값 줄) · 계표시(단계) 같음.
|
||||
단가 전파 견본 = `_분석_649-G2` 4 장(보통인부 → 180,000) · 계약단가(낙찰률)는 B09 몫 — 엔진 계 ×
|
||||
@@ -19,7 +19,6 @@ import pytest
|
||||
from M01_MasterData import M01_MasterData_Store as m01 # noqa: F401 — scripts 길을 둠
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
from M02_MasterTemplete import M02_San_Emit as em
|
||||
from M02_MasterTemplete import M02_San_Est as est
|
||||
from M02_MasterTemplete import M02_San_Eval as ev
|
||||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||||
|
||||
@@ -34,9 +33,6 @@ STMATE = { # 파일: (블록, 금액 줄, 계표시 줄, 끝전)
|
||||
"영덕계류": (15, 86, 41, "비목버림"),
|
||||
"울진소광": (40, 208, 112, "비목버림"),
|
||||
}
|
||||
EST = {"북부청임도": (94, 1252), "북부청사방": (67, 849), "거창": (61, 802)} # (블록, 돈 · 값 줄)
|
||||
# 엑셀 글 식이 보인 값과 안 맞는 줄(793-1 틀린 줄 2) — 보인 값으로 얼림 · 블록 금액은 같음
|
||||
EST_FROZEN = {"북부청임도": {27: 1, 33: 1}}
|
||||
K = ("노무비", "재료비", "경비", "계")
|
||||
LABOR = {"종류": "노임", "이름": "보통인부", "규격": "", "열쇠비목": "L"}
|
||||
|
||||
@@ -71,7 +67,8 @@ def _load(tag: str) -> dict:
|
||||
path = golden.FILES[tag]
|
||||
if not path.exists():
|
||||
pytest.skip(f"실무 엑셀 없음 {path.name}")
|
||||
return book.load(path)
|
||||
sheets = book.sheets(path.name, path.read_bytes())
|
||||
return {"블록": book.stmate_blocks(sheets), "단가": book.stmate_prices(sheets)}
|
||||
|
||||
|
||||
def _stmate(tag: str, blk: dict) -> tuple[list, dict]:
|
||||
@@ -127,30 +124,6 @@ def test_stmate_engine_equals_excel(tag: str) -> None:
|
||||
assert (lines, steps) == (n_lines, n_steps)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", list(EST))
|
||||
def test_est_engine_equals_excel(tag: str) -> None:
|
||||
data = _load(tag)
|
||||
n, wrong, lines = 0, [], 0
|
||||
for blk in data["블록"]:
|
||||
want = data["총괄"].get(blk["번"])
|
||||
if not want or not (want["T"] or any(want[c] for c in "LMS")):
|
||||
continue
|
||||
n += 1
|
||||
parsed = est.parse_block(blk["글"], data["단가"])
|
||||
out = em.est(blk, parsed, want)
|
||||
got = engine(out)
|
||||
ref = ev.est_block(parsed, ev.designate(parsed, want))["금액"]
|
||||
same = money(got) == (want["L"], want["M"], want["S"], want["T"])
|
||||
same &= money(got) == (ref["L"], ref["M"], ref["S"], ref["T"])
|
||||
bad, a, _ = _lines_equal(out, got)
|
||||
bad += ms.check(out["로직"], str(blk["번"]))
|
||||
lines += a
|
||||
frozen = len(hard(out))
|
||||
if not same or bad or frozen != EST_FROZEN.get(tag, {}).get(blk["번"], 0):
|
||||
wrong.append((blk["번"], money(got), bad[:3], hard(out)))
|
||||
assert (n, wrong, lines) == (EST[tag][0], [], EST[tag][1])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag, code, labor, before, after",
|
||||
[
|
||||
@@ -167,23 +140,6 @@ def test_stmate_price_propagates(tag, code, labor, before, after) -> None:
|
||||
assert money(engine(out))[:3] == after
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag, no, after",
|
||||
[
|
||||
("북부청사방", 31, (14877, 5491, 0)),
|
||||
("북부청임도", 2, (22774, 3235, 3830)),
|
||||
("거창", 1, (3485, 832, 1049)),
|
||||
("북부청사방", 3, (20147, 391, 0)),
|
||||
],
|
||||
)
|
||||
def test_est_price_propagates(tag, no, after) -> None:
|
||||
data = _load(tag)
|
||||
blk = next(b for b in data["블록"] if b["번"] == no)
|
||||
out = em.est(blk, est.parse_block(blk["글"], data["단가"]), data["총괄"][no])
|
||||
assert em.reprice(out["로직"], LABOR, {"L": 180000.0}) >= 1
|
||||
assert money(engine(out))[:3] == after
|
||||
|
||||
|
||||
def test_link_keys_m01() -> None:
|
||||
"""호표 줄 = 793-2 꼴(종류 · 단가 · 이음 = 1차 줄 판정 M01 키) · 덧칸 자원 · 원본."""
|
||||
tag = "봉화"
|
||||
@@ -266,28 +222,3 @@ def test_stmate_grammar_sample_engine() -> None:
|
||||
assert (
|
||||
float(by["공구_2"]["비목"]["노무비"]) == lines[10]["값"]["L"] == 113.3
|
||||
) # 34,350.5 × 0.33 %
|
||||
|
||||
|
||||
def test_est_grammar_sample_engine() -> None:
|
||||
prices = [{"종류": "노임", "이름": "보통인부", "규격": "", "비목": "L", "값": 172068.0}]
|
||||
text = [
|
||||
"보통인부 : 172,068 × 0.09 인 = 15,486.1",
|
||||
"공구손료 : 15,486.1 × 3 % = 464.5",
|
||||
"소 계 : 15,950.6",
|
||||
"노무비 : 15,950.6 × 1 = 15,950",
|
||||
]
|
||||
lines = est.parse_block(text, prices)
|
||||
want = {"T": 15950.0, "L": 15950.0, "M": 0.0, "S": 0.0}
|
||||
out = em.est({"번": 1, "이름": "견본", "단위": "㎡"}, lines, want)
|
||||
logic = out["로직"]
|
||||
assert [x["식"] for x in logic["덧줄"]] == [
|
||||
"((줄.'보통인부' * 3) / 100)",
|
||||
"줄.'보통인부' + 공구손료",
|
||||
"소계",
|
||||
]
|
||||
assert logic["호표"][0]["종류"] == "인력" and logic["호표"][0]["단가"] == 172068
|
||||
assert logic["덧줄"][2]["자리"] == {"방법": "버림", "자리": 0}
|
||||
assert [x.get("합밖") for x in (*logic["호표"], *logic["덧줄"])] == [True, True, True, None]
|
||||
assert money(engine(out)) == (15950, 0, 0, 15950)
|
||||
em.reprice(logic, LABOR, {"L": 180000.0})
|
||||
assert money(engine(out)) == (16686, 0, 0, 16686) # 793-1 run_est 전파와 같음
|
||||
|
||||
@@ -337,26 +337,20 @@ def test_원본_보존_사본():
|
||||
|
||||
# ── 실무 블록(793-2a 내보내기 → 가져온 식 SK) ─────────────────────────────
|
||||
REAL = [("봉화", "D00102"), ("동부청영월", "D00003"), ("울진소광", "D01489"), ("북부청임도", 18)]
|
||||
RULE = {"봉화": "비목버림", "동부청영월": "합버림_큰나머지", "울진소광": "비목버림"}
|
||||
|
||||
|
||||
def _emitted(tag, code):
|
||||
"""실무 블록 → 812-1 엑셀 읽기기 → 원파일 길(`M02_Doc_FromNative.emit`)."""
|
||||
import helper_m02_hopyo_golden as golden
|
||||
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
from M02_MasterTemplete import M02_San_Emit as em
|
||||
from M02_MasterTemplete import M02_San_Est as est
|
||||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||||
from M02_MasterTemplete import M02_Doc_FromNative as fn
|
||||
from M02_MasterTemplete import M02_San_Xlsx as sx
|
||||
|
||||
if not golden.FILES[tag].exists():
|
||||
path = golden.FILES[tag]
|
||||
if not path.exists():
|
||||
pytest.skip(f"실무 엑셀 없음 {tag}")
|
||||
data = book.load(golden.FILES[tag])
|
||||
if tag in RULE:
|
||||
blk = next(b for b in data["블록"] if b["코드"] == code)
|
||||
return em.stmate(blk, stm.parse_block(blk["몸"]), data["단가"], RULE[tag], True, tag)
|
||||
blk = next(b for b in data["블록"] if b["번"] == code)
|
||||
want = data["총괄"][blk["번"]]
|
||||
return em.est(blk, est.parse_block(blk["글"], data["단가"]), want, True, tag)
|
||||
saved = fn.pack(sx.read(path.name, path.read_bytes()), path.name)
|
||||
return fn.emit(saved, int(str(code).lstrip("D")))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("tag", "code"), REAL)
|
||||
|
||||
@@ -87,14 +87,16 @@ RULES = [("봉화", "D00102", "비목버림"), ("동부청영월", "D00003", "
|
||||
|
||||
@pytest.mark.parametrize(("tag", "code", "rule"), RULES)
|
||||
def test_STmate_끝전_엑셀_계와_맞는_쪽(tag, code, rule):
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||||
"""끝전 = 파일 투표(812-1 `M02_San_Xlsx_Stmate.vote` · 블록 계가 맞는 수가 많은 쪽)."""
|
||||
from M02_MasterTemplete import M02_San_Xlsx as sx
|
||||
from M02_MasterTemplete import M02_San_Xlsx_Stmate as xs
|
||||
|
||||
if not golden.FILES[tag].exists():
|
||||
pytest.skip(f"실무 엑셀 없음 {tag}")
|
||||
data = book.load(golden.FILES[tag])
|
||||
blk = next(b for b in data["블록"] if b["코드"] == code)
|
||||
assert fh._rule(blk, stm.parse_block(blk["몸"]), data["단가"]) == rule
|
||||
path = golden.FILES[tag]
|
||||
got = sx.read(path.name, path.read_bytes())
|
||||
assert got["옵션"]["SAN_TYP"] == xs.RULE[rule]
|
||||
assert any(b["코드"] == code for b in got["산근"])
|
||||
|
||||
|
||||
# ── 화면 소스 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -148,14 +148,13 @@ def test_nested_stmate_follows_inner(world) -> None:
|
||||
def test_nested_est_by_part_matches_excel(world) -> None:
|
||||
folder, _ = _hp(world, "북부청임도")
|
||||
with store.at(folder):
|
||||
made = fh.make(folder, KEY, 67) # 단산68 을 노무비 · 재료비 · 경비 세 줄로 부름
|
||||
made = fh.make(folder, KEY, 67) # 단산68 세 줄 = 묶음 한 줄로 부름(812-1 · 원파일 길)
|
||||
doc = _read(made["이름"])
|
||||
inner = [x for x in doc[imp.KEY]["로직"]["호표"] if x.get("참조")]
|
||||
assert [x["참조비목"] for x in inner] == ["노무비", "재료비", "경비"]
|
||||
assert len({x["참조"]["키"] for x in inner}) == 1
|
||||
assert len(inner) == 1 and "참조비목" not in inner[0]
|
||||
assert made["검산"]["같음"] is True and eqm.read(doc)["ok"] is True
|
||||
# 엑셀 단가 그대로 금액 = 굳힌 값 합과 같음
|
||||
frozen = sum(float(x["값묶음"][x["참조비목"]]) * 0.03 for x in inner)
|
||||
frozen = sum(float(v) for v in inner[0]["값묶음"].values())
|
||||
assert float(made["검산"]["셈"]["계"]) == pytest.approx(
|
||||
float(made["검산"]["엑셀"]["계"]), abs=1
|
||||
)
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
"""797-1a 지킴 — 엑셀 불러오기 빠진 정보 표지 다섯 · 줄 단위 합격 · 표본 밖 둘 고정(요구 658).
|
||||
"""797-1a 지킴 — 엑셀 불러오기 빠진 정보 표지(요구 658) · 812-1 뒤 남은 것.
|
||||
|
||||
표지 다섯(`M02_San_Emit` · `M02_San_Est` · `M02_San_Book` · `M02_Sangeun_FromHopyo`) —
|
||||
① 금액 줄 못 읽음(글끝 · 식못읽음 · 결과 뒤 꼬리 글은 떼고 읽음) ② 단가 후보 여럿 · 추정
|
||||
③ 당초 / 적용 칸 ④ 조정 비율 · 낙찰률 미지원 ⑤ 읽기 불가 사유 — 작은 견본(엑셀 없이).
|
||||
줄 단위 합격 = 블록 합만이 아니라 표지 없는 줄 중 틀린 줄 0(표지 달린 줄은 틀림에서 뺌) ·
|
||||
표본 밖(개발에 안 쓴 파일: 2026 수목진료 142 · 울진대흥 공통 46 · 2016 사방 374)을 합격 수로 고정 ·
|
||||
EST 단가줄 버림1 일치율 감시(낮아지면 실패). 실무문서 엑셀 없으면 skip(읽기만).
|
||||
③ 당초 / 적용 칸(`M02_San_Book.applied_how` → 엑셀 읽기기 블록 표지 → 원본 표지) ④ 조정 비율 · 낙찰률
|
||||
미지원(`M02_San_Emit.stmate`) ⑤ 읽기 불가 사유(`M02_Sangeun_FromHopyo.keep`) — 작은 견본(엑셀 없이).
|
||||
옛 EST 글 추정 표지(① 금액 줄 못 읽음 · ② 단가 후보 여럿) · 줄 단위 합격 · 표본 밖 수 · 버림1 감시는
|
||||
옛 엔진과 함께 걷음 — 812-1 `test_812_1_xlsx_golden`(실제 짝 · 합성 · 실무 11 금액)이 지킴.
|
||||
`FILES`(실무 11) = 812-1 시험이 씀.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import helper_m02_hopyo_golden as golden
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_FromNative as fn
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
from M02_MasterTemplete import M02_San_Emit as em
|
||||
from M02_MasterTemplete import M02_San_Est as est
|
||||
from M02_MasterTemplete import M02_San_Eval as ev
|
||||
from M02_MasterTemplete import M02_San_Expr as ex
|
||||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||||
from M02_MasterTemplete import M02_San_Xlsx as sx
|
||||
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
|
||||
|
||||
NOTE = em.NOTE
|
||||
@@ -37,63 +33,6 @@ def hard(out: dict) -> list[str]:
|
||||
return [m for m in out["원본"]["표지"] if NOTE not in m]
|
||||
|
||||
|
||||
# ── ① 금액 줄 못 읽음 ──────────────────────────────────────────────────
|
||||
TAIL = " 보통인부 : 94,338 ×0.0328인 = 3,094.2 : 채집"
|
||||
PRICE_L = {"종류": "노임", "이름": "보통인부", "규격": "", "비목": "L", "값": 94338.0}
|
||||
|
||||
|
||||
def _est_out(text: list[str], prices: list[dict], want: dict):
|
||||
rows = list(range(10, 10 + len(text)))
|
||||
blk = {"이름": "견본", "단위": "m", "번": 1, "글": text, "글행": rows}
|
||||
lines = est.parse_block(text, prices)
|
||||
return lines, em.est(blk, lines, want)
|
||||
|
||||
|
||||
def test_tail_text_is_stripped_and_read_as_money() -> None:
|
||||
one = est.parse_line(TAIL)
|
||||
assert (one["꼴"], one["값"], one["라벨"]) == ("돈", 3094.2, "보통인부")
|
||||
want = {"T": 3094, "L": 3094, "M": 0, "S": 0}
|
||||
lines, out = _est_out([TAIL, " 노무비 : 3,094"], [PRICE_L], want)
|
||||
assert [x["종류"] for x in lines] == ["단가줄", "값줄"]
|
||||
assert hard(out) == [] and notes(out) == []
|
||||
assert out["로직"]["호표"][0]["이름"] == "보통인부"
|
||||
|
||||
|
||||
def test_unreadable_money_line_is_flagged_with_shown_value() -> None:
|
||||
cut = " 절취량 : (0.5×0.6×1.0) / 2 = 0.15㎥/m"
|
||||
note = " 야산지 25%, 경사도 중(15~30˚)) 5% => 30% 할증"
|
||||
bad = " 산출 : 가나다 = 12.5"
|
||||
assert est.parse_line(cut) == {"꼴": "글끝", "라벨": "절취량", "보인": 0.15}
|
||||
assert est.parse_line(note) == {"꼴": "글끝"} # 라벨 없는 설명 글 — 표지 안 함
|
||||
assert est.parse_line(bad)["꼴"] == "식못읽음"
|
||||
zero = {"T": 0, "L": 0, "M": 0, "S": 0}
|
||||
_, out = _est_out([cut, note, bad], [], zero)
|
||||
got = {g["행"]: g for g in out["원본"]["글줄"]}
|
||||
flag = f"{NOTE}금액 줄 못 읽음 · 보인 값 얼림"
|
||||
assert (got[10]["표지"], got[10]["값"]) == (flag, 0.15)
|
||||
assert "표지" not in got[11] and "값" not in got[11]
|
||||
assert (got[12]["표지"], got[12]["값"]) == (flag, 12.5)
|
||||
assert len(notes(out)) == 2 and hard(out) == []
|
||||
|
||||
|
||||
# ── ② 단가 후보 여럿 · 추정 ────────────────────────────────────────────
|
||||
def test_ambiguous_price_is_flagged_not_silently_picked() -> None:
|
||||
prices = [
|
||||
{"종류": "자재", "이름": "철근", "규격": "", "비목": "M", "값": 500.0},
|
||||
{"종류": "자재", "이름": "시멘트", "규격": "", "비목": "M", "값": 500.0},
|
||||
]
|
||||
text = [" 철근 : 500 ×2개 = 1,000", " 자재비 : 500 ×3개 = 1,500"]
|
||||
want = {"T": 1500, "L": 0, "M": 1500, "S": 0}
|
||||
lines, out = _est_out(text, prices, want)
|
||||
assert [x["역할"][0]["갈림"] for x in lines] == ["문맥", "문맥"]
|
||||
assert [x["역할"][0]["모호"] for x in lines] == [False, True]
|
||||
got = {g["행"]: g["표지"] for g in out["원본"]["글줄"]}
|
||||
assert got[10] == f"{NOTE}추정 — 단가 정체 근거 문맥"
|
||||
assert got[11] == f"{NOTE}단가 후보 여럿(2) · 사람 확인"
|
||||
assert hard(out) == []
|
||||
assert [it["자원"].get("모호") for it in out["로직"]["호표"]] == [None, True]
|
||||
|
||||
|
||||
# ── ③ 당초 / 적용 칸 ───────────────────────────────────────────────────
|
||||
def _row(applied: tuple, first: tuple) -> list:
|
||||
r = [None] * 29
|
||||
@@ -126,13 +65,18 @@ def test_applied_or_first_column_is_flagged() -> None:
|
||||
|
||||
def test_first_column_note_reaches_block_flags() -> None:
|
||||
head = [[None] * 29 for _ in range(3)]
|
||||
sheet = [*head, [None, 1, "견본", ""] + [None] * 25]
|
||||
sheet[3][25:29] = [90, 40, 30, 20]
|
||||
prices, san = book.est_prices({"단가산출총괄표": sheet})
|
||||
assert san[1]["칸"] == "당초 칸 사용 — 적용 칸 비어 있음" and san[1]["T"] == 90
|
||||
assert {(p["종류"], p["비목"], p["값"]) for p in prices} >= {("산근", "T", 90.0)}
|
||||
_, out = _est_out([" 노무비 : 40"], [], {**san[1]})
|
||||
total = [*head, [None, "1", "견본", "", "m"] + [None] * 24]
|
||||
total[2][1:5] = ["No", "품명", "규격", "단위"]
|
||||
total[3][25:29] = [90, 40, 30, 20]
|
||||
sheet = [[None, "단가산출"], [], [None, "산출근거", "합계", "노무비", "재료비", "경비"]]
|
||||
sheet += [
|
||||
[None, "1.견본[m]", 90, "40", "30", "20"],
|
||||
[None, " 계 : 90", 90, "40", "30", "20"],
|
||||
]
|
||||
got = sx.read_sheets({"단가산출": sheet, "단가산출총괄표": total})
|
||||
out = fn.emit(fn.pack(got, "견본.xls"), 1)
|
||||
assert f"{NOTE}당초 칸 사용 — 적용 칸 비어 있음" in out["원본"]["표지"]
|
||||
assert out["원본"]["엑셀"] is True
|
||||
|
||||
|
||||
# ── ④ 조정 비율 · 낙찰률 미지원 ────────────────────────────────────────
|
||||
@@ -172,7 +116,7 @@ def test_unreadable_reason_is_returned(tmp_path: Path) -> None:
|
||||
assert not fh.has(tmp_path, "HP990001") # 못 읽은 파일은 안 둠
|
||||
|
||||
|
||||
# ── 줄 단위 합격 · 표본 밖 고정 · 버림1 감시 ───────────────────────────
|
||||
# ── 실무 엑셀 11(812-1 시험이 씀) ──────────────────────────────────────
|
||||
def _oos() -> dict[str, Path]:
|
||||
pack = DOCS / "2026_실무일위대가 모음"
|
||||
common = DOCS / "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료"
|
||||
@@ -185,102 +129,3 @@ def _oos() -> dict[str, Path]:
|
||||
|
||||
|
||||
FILES = {**golden.FILES, **_oos()}
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _load(tag: str) -> dict:
|
||||
path = FILES[tag]
|
||||
if not path.exists():
|
||||
pytest.skip(f"실무 엑셀 없음 {path.name}")
|
||||
return book.load(path)
|
||||
|
||||
|
||||
def _stmate_tally(tag: str) -> dict:
|
||||
data = _load(tag)
|
||||
t = dict.fromkeys(("블록", "맞음", "금액줄", "표지없는틀린줄", "계줄", "계줄맞음"), 0)
|
||||
for blk in data["블록"]:
|
||||
if not any(ln["G"].strip() for ln in blk["몸"]):
|
||||
continue
|
||||
nodes = stm.parse_block(blk["몸"])
|
||||
rule = fh._rule(blk, nodes, data["단가"])
|
||||
chk = ev.stmate_check(blk, ev.stmate_block(blk, nodes, data["단가"], rule))
|
||||
out = em.stmate(blk, nodes, data["단가"], rule, False, tag)
|
||||
marked = {g["행"] for g in out["원본"]["글줄"] if g.get("표지")}
|
||||
t["블록"] += 1
|
||||
t["맞음"] += chk["맞음"]
|
||||
t["금액줄"] += chk["금액줄"]
|
||||
t["계줄"] += chk["계줄"]
|
||||
t["계줄맞음"] += chk["계줄맞음"]
|
||||
t["표지없는틀린줄"] += sum(1 for b in chk["틀린줄"] if b["행"] not in marked)
|
||||
return t
|
||||
|
||||
|
||||
def _est_tally(tag: str) -> dict:
|
||||
data = _load(tag)
|
||||
t = dict.fromkeys(("블록", "맞음", "줄수", "표지없는틀린줄", "표지줄", "단가줄", "버림1"), 0)
|
||||
for blk in data["블록"]:
|
||||
want = data["총괄"].get(blk["번"])
|
||||
if not want or not (want["T"] or any(want[c] for c in "LMS")):
|
||||
continue
|
||||
parsed = est.parse_block(blk["글"], data["단가"])
|
||||
chk = ev.est_check(parsed, want)
|
||||
out = em.est(blk, parsed, want, False, tag)
|
||||
rows = blk.get("글행") or []
|
||||
marked = {g["행"] for g in out["원본"]["글줄"] if g.get("표지")}
|
||||
t["블록"] += 1
|
||||
t["맞음"] += chk["맞음"]
|
||||
t["줄수"] += chk["줄수"]
|
||||
t["표지줄"] += len(marked)
|
||||
for b in chk["틀린줄"]:
|
||||
k = b["차례"]
|
||||
t["표지없는틀린줄"] += (rows[k] if k < len(rows) else k) not in marked
|
||||
for ln in parsed:
|
||||
if ln.get("종류") == "단가줄" and ln.get("셈") is not None:
|
||||
t["단가줄"] += 1
|
||||
t["버림1"] += abs(ex.RULE["버림1"](ln["셈"]) - ln["값"]) < ex.EPS
|
||||
return t
|
||||
|
||||
|
||||
# 표본 밖 = 개발에 안 쓴 파일(합격 수 고정) · 골든 = 개발에 쓴 파일
|
||||
OOS_FIXED = {
|
||||
"2026수목": {"블록": 142, "맞음": 142, "줄수": 1654},
|
||||
"대흥공통": {"블록": 46, "맞음": 46, "금액줄": 258, "계줄": 127, "계줄맞음": 127},
|
||||
"2016사방": {"블록": 374, "맞음": 374, "줄수": 1870},
|
||||
}
|
||||
# 단가줄 버림1 일치 수 / 단가줄 수(낮아지면 실패 — 다른 규칙으로 갈리는 줄이 늘었다는 뜻)
|
||||
FLOOR_1 = {
|
||||
"북부청임도": (539, 552),
|
||||
"북부청사방": (363, 364),
|
||||
"거창": (381, 382),
|
||||
"2016사방": (718, 718),
|
||||
"2026수목": (653, 653),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", list(FILES))
|
||||
def test_no_unmarked_wrong_line(tag: str) -> None:
|
||||
"""줄 단위 — 표지 없는 틀린 줄 0 · 블록 다시 셈 = 엑셀 전부."""
|
||||
est_form = _load(tag)["꼴"] == "EST"
|
||||
t = _est_tally(tag) if est_form else _stmate_tally(tag)
|
||||
assert t["블록"] > 0
|
||||
assert (t["표지없는틀린줄"], t["맞음"]) == (0, t["블록"]), t
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", list(OOS_FIXED))
|
||||
def test_out_of_sample_counts_fixed(tag: str) -> None:
|
||||
if tag not in FILES:
|
||||
pytest.skip("표본 밖 엑셀 없음")
|
||||
t = _est_tally(tag) if tag != "대흥공통" else _stmate_tally(tag)
|
||||
assert {k: t[k] for k in OOS_FIXED[tag]} == OOS_FIXED[tag]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", list(FLOOR_1))
|
||||
def test_unit_price_line_floor1_rate_does_not_drop(tag: str) -> None:
|
||||
if tag not in FILES:
|
||||
pytest.skip("실무 엑셀 없음")
|
||||
t = _est_tally(tag)
|
||||
hit, total = FLOOR_1[tag]
|
||||
assert t["단가줄"] >= total and t["버림1"] / t["단가줄"] >= hit / total, (
|
||||
t["버림1"],
|
||||
t["단가줄"],
|
||||
)
|
||||
|
||||
@@ -17,9 +17,9 @@ import helper_m02_hopyo_golden as golden
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Doc_FromNative as fn
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hs
|
||||
from M02_MasterTemplete import M02_San_Basis_Index as bi
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
from M02_MasterTemplete import M02_San_Emit as em
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
|
||||
@@ -271,11 +271,10 @@ def _north(tmp_path):
|
||||
|
||||
|
||||
def test_real_north_block_has_unique_candidate_and_flags(tmp_path) -> None:
|
||||
sheets = _north(tmp_path)
|
||||
_, total = book.est_prices(sheets)
|
||||
nos = [int(b["번"]) for b in book.est_blocks(sheets) if b["번"] in total]
|
||||
_north(tmp_path)
|
||||
excel = json.loads(fh._path(tmp_path, "HP-000001").read_bytes())["엑셀"]
|
||||
found = None
|
||||
for no in nos:
|
||||
for no in sorted(fn.numbers(excel)):
|
||||
out = fh.emit(tmp_path, "HP-000001", no)
|
||||
if "야면석채집및운반(계곡내)" in out["로직"]["이름"]:
|
||||
found = out
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
① --check — 다시 돌린 결과 = 저장본(`resources/master_data/수식문서/`) · 목록 xlsx.
|
||||
② 로직 1,519 전수 기본 옵션 = 옛 엔진 노 · 재 · 경 · 계(결과) 원 단위 같음.
|
||||
③ 옵션 표본 — 로직마다 고르기 입력의 모든 갈래 한 번 이상 · 같음 수 · 다름 = 목록 · 까닭 모름 0.
|
||||
④ 저장 = 같은 것만(부르는 단가산출까지) · 저장본을 읽어 다시 셈해도 같음.
|
||||
④ 저장 = 같은 것만(부르는 단가산출까지 · 채택된 차이 GC000100 지역=경북 재료비 1 원 빼고) · 저장본을 읽어 다시 셈해도 같음.
|
||||
⑤ 791 실무 손 옮김 견본 넷(번지 · 단계 · 합밖 · 줄 자리 · 끝전 · 조정 · 값묶음)도 변환 셈 = 엑셀 금액.
|
||||
"""
|
||||
|
||||
@@ -56,7 +56,7 @@ def test_옵션_표본_모든_갈래_한_번_이상(result):
|
||||
assert (row["키"], f"{spec['이름']}={opt}") in seen
|
||||
info = result["요약"]
|
||||
assert info["옵션 표본"] == want == info["옵션 같음"] + info["옵션 다름"]
|
||||
assert info["옵션 같음"] >= 18700 and info["옵션 다름"] <= 1
|
||||
assert info["옵션 같음"] >= 18700 and info["옵션 다름"] == info["채택된 차이"] == 1
|
||||
|
||||
|
||||
def _whole():
|
||||
@@ -69,15 +69,25 @@ def test_다름_목록_까닭(result):
|
||||
assert sheet[0] == list(build.HEAD) and sheet[1:] == rows
|
||||
diff = [x for x in result["대조"] if not x["같음"]]
|
||||
assert all(x["까닭"] != "까닭 모름" for x in diff)
|
||||
assert [(x["로직"], x["옵션"], x["까닭"]) for x in diff] == [
|
||||
("GC000100", "지역=경북", build.RAW_SUB)
|
||||
]
|
||||
# 채택된 차이(요구 714-3) — 식 속 단가산출 부름 값 = 끝수 뒤 · 재료비 1 원 · 저장은 함
|
||||
assert [(x["로직"], x["옵션"], x["채택"]) for x in diff] == [("GC000100", "지역=경북", True)]
|
||||
assert diff[0]["까닭"].startswith(build.RAW_SUB)
|
||||
assert diff[0]["옛"]["재료비"] != diff[0]["새"]["재료비"]
|
||||
assert abs(diff[0]["새"]["재료비"] - diff[0]["옛"]["재료비"]) == 1
|
||||
assert {k: diff[0]["옛"][k] for k in ("노무비", "경비")} == {
|
||||
k: diff[0]["새"][k] for k in ("노무비", "경비")
|
||||
}
|
||||
|
||||
|
||||
def test_저장_같은_것만_다시_셈(result):
|
||||
saved = ml.load_all()
|
||||
assert set(saved) == result["저장"]
|
||||
assert "GC000100" not in saved and len(saved) == result["요약"]["저장"] >= 1518
|
||||
assert "GC000100" in saved and len(saved) == result["요약"]["저장"] >= 1519
|
||||
base = next(x for x in result["대조"] if x["로직"] == "GC000100" and not x["옵션"])
|
||||
assert (
|
||||
base["같음"]
|
||||
and build.new_answer(ml.Links(_whole(), saved), saved["GC000100"]) == base["옛"]
|
||||
)
|
||||
for key in ("GC000211", "GC001147", "GF000294", "GC000087"):
|
||||
doc = saved[key]
|
||||
got = build.new_answer(ml.Links(_whole(), saved), doc)
|
||||
|
||||
@@ -67,7 +67,11 @@ def test_읽기_미리셈_연결기록(client):
|
||||
|
||||
|
||||
def test_없는문서_빈문서_없는키_404(client):
|
||||
fresh = _read(client, "GC000100") # 802-2a 로 문서 안 넣음
|
||||
assert (
|
||||
_read(client, "GC000100")["새"] is False
|
||||
) # 802-2a 로 문서 넣음 — 임시 사본에서 지워 빈 문서 길을 봄
|
||||
doc_screen.drop("GC000100")
|
||||
fresh = _read(client, "GC000100")
|
||||
assert fresh["새"] is True and fresh["문서"]["식"] == [] and fresh["문서"]["금액"] == []
|
||||
assert fresh["셈"]["셈됨"] is False and fresh["셈"]["계"] is None
|
||||
assert client.get("/api/m01/logic/doc", params={"key": "GX999999"}).status_code == 404
|
||||
|
||||
@@ -166,7 +166,9 @@ def test_stmate_nested_is_sibling_link(world) -> None:
|
||||
def test_est_by_part_and_frozen_vars(world) -> None:
|
||||
folder = _hp(world, "북부청임도")
|
||||
with store.at(folder):
|
||||
made = fh.make(folder, KEY, 67) # 단산68 을 노무비 · 재료비 · 경비 세 줄로 부름
|
||||
made = fh.make(
|
||||
folder, KEY, 67
|
||||
) # 단산68 세 줄(노 · 재 · 경) = 묶음 한 줄(812-1 · 원파일 길)
|
||||
doc = _read(made["이름"])
|
||||
root = dim.derive(doc)
|
||||
parts = [
|
||||
@@ -174,7 +176,7 @@ def test_est_by_part_and_frozen_vars(world) -> None:
|
||||
for x in root["금액"]
|
||||
if (_var(root, x["이름"]).get("연결") or {}).get("키", "").startswith("SK-")
|
||||
]
|
||||
assert parts == [("노무비", "노무비"), ("재료비", "재료비"), ("경비", "경비")]
|
||||
assert parts == [("묶음", None)]
|
||||
assert not any(x["식"].strip().replace(".", "").isdigit() for x in root["식"])
|
||||
excel = dim.run(root, doc)
|
||||
assert float(excel["계"]) == pytest.approx(float(made["검산"]["엑셀"]["계"]), abs=1)
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
(a) 거울 — node `tools/est-test/import-estx.cjs` 결과와 표 이름 · 행 수 · 모든 칸 값 1:1(다름 0)
|
||||
(b) 블록 줄합(저장 결과만 · 셈 없음) = 제목 표 저장 금액(TC NC GC JC SC) 100 %
|
||||
(c) 거창 = 지금 엑셀 엔진(`M02_San_Book` · `M02_San_Est` · `M02_San_Eval`)과 대조 — 블록 수 · 금액 ·
|
||||
줄마다 갈래(단가 · 참조 수) · 다른 곳 = 엑셀 추정이 틀린 곳(고치지 않음 · 수만 지킴)
|
||||
(c) 엑셀 짝 대조는 812-1 엑셀 읽기기 골든(`test_812_1_xlsx_golden` · 옛 엑셀 엔진 걷음)
|
||||
원파일 · node 없으면 skip(다른 PC).
|
||||
"""
|
||||
|
||||
@@ -12,7 +11,6 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -156,90 +154,3 @@ def test_structure_marks() -> None:
|
||||
assert [ln["비목"] for ln in b["줄"] if ln["합계"]] == ["L", "M", "S", "T"]
|
||||
sub = [ln for d in [parsed("거창")] for b in d["산근"] for ln in b["줄"] if ln["산근"]]
|
||||
assert sub and all(ln["자원"]["종류"] == "산근" for ln in sub)
|
||||
|
||||
|
||||
# ── (c) 엑셀 엔진 대조 ─────────────────────────────────────────────────
|
||||
def _key(s: str) -> str:
|
||||
num = r"\d[\d,]*\.?\d*|\.\d+"
|
||||
s = re.sub(num, lambda m: repr(float(m.group().replace(",", "").rstrip(".") or 0)), s)
|
||||
return re.sub(r"\s", "", s)
|
||||
|
||||
|
||||
def excel_diffs(tag: str = "거창") -> tuple[list[dict], dict]:
|
||||
"""엑셀 엔진 블록 · 줄 갈래 대 원파일 → (다른 곳 목록, 셈)."""
|
||||
sys.path.insert(0, str(ROOT / "resources" / "tester"))
|
||||
import helper_m02_hopyo_golden as golden
|
||||
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
from M02_MasterTemplete import M02_San_Est as est
|
||||
from M02_MasterTemplete import M02_San_Eval as ev
|
||||
|
||||
if not golden.FILES[tag].exists():
|
||||
pytest.skip("엑셀 없음")
|
||||
xl = book.load(golden.FILES[tag])
|
||||
by_no = {b["번"]: b for b in xl["블록"]}
|
||||
out, cnt = [], {"블록": 0, "금액같음": 0, "글같음": 0, "줄": 0, "갈래같음": 0}
|
||||
for no, b in enumerate(parsed(tag)["산근"], 1):
|
||||
x = by_no.get(no)
|
||||
if x is None:
|
||||
out.append({"블록": no, "코드": b["코드"], "항목": "엑셀에 블록 없음"})
|
||||
continue
|
||||
cnt["블록"] += 1
|
||||
want = xl["총괄"].get(no)
|
||||
lines = est.parse_block(x["글"], xl["단가"])
|
||||
chk = ev.est_check(lines, want) if want else {"금액": None}
|
||||
saved = {c: b["저장"].get(k) or 0 for c, k in (("L", "L"), ("M", "M"), ("S", "S"))}
|
||||
if chk["금액"] and all(abs(chk["금액"][c] - saved[c]) < 0.5 for c in "LMS"):
|
||||
cnt["금액같음"] += 1
|
||||
else:
|
||||
out.append(
|
||||
{
|
||||
"블록": no,
|
||||
"코드": b["코드"],
|
||||
"항목": "금액",
|
||||
"엑셀": chk["금액"],
|
||||
"원파일": saved,
|
||||
}
|
||||
)
|
||||
mine = [ln for ln in b["줄"] if ln["보임"].strip()]
|
||||
if len(mine) == len(x["글"]) and all(
|
||||
_key(a["보임"]) == _key(c) for a, c in zip(mine, x["글"])
|
||||
):
|
||||
cnt["글같음"] += 1
|
||||
for L, ln in zip(lines, mine):
|
||||
if not (ln["연결단가"] or ln["변수"] or ln["줄참조"] or L.get("꼴") == "돈"):
|
||||
continue
|
||||
cnt["줄"] += 1
|
||||
roles = [r["갈래"] for r in (L.get("역할") or {}).values()]
|
||||
hidden = len((L.get("숨은") or {}).get("줄") or []) # 값 줄 = 숨은 복사 · 합
|
||||
got = (roles.count("단가"), roles.count("앞줄") + hidden)
|
||||
truth = (len(ln["연결단가"]), len(ln["변수"]) + len(ln["줄참조"]))
|
||||
money = L.get("꼴") == "돈" or L.get("종류") == "값줄"
|
||||
auto = ln["합계"] or ln["표지"] == "소계" # 자동 합 · 소계 = 엑셀 숨은 합과 같은 뜻
|
||||
ok = money and got[0] == truth[0] and (auto or got[1] == truth[1])
|
||||
cnt["갈래같음"] += ok
|
||||
if not ok:
|
||||
out.append(
|
||||
{
|
||||
"블록": no,
|
||||
"코드": b["코드"],
|
||||
"행": ln["행"],
|
||||
"항목": "갈래",
|
||||
"글": ln["보임"].strip(),
|
||||
"엑셀": f"{L.get('꼴')} 단가 {got[0]} · 앞줄 {got[1]}",
|
||||
"원파일": f"단가 {truth[0]} · 변수 {len(ln['변수'])} · "
|
||||
f"줄참조 {len(ln['줄참조'])}",
|
||||
}
|
||||
)
|
||||
return out, cnt
|
||||
|
||||
|
||||
def test_excel_geochang() -> None:
|
||||
out, cnt = excel_diffs("거창")
|
||||
assert (cnt["블록"], cnt["금액같음"], cnt["글같음"]) == (61, 61, 61)
|
||||
assert [o for o in out if o["항목"] != "갈래"] == []
|
||||
assert (cnt["줄"], cnt["갈래같음"]) == EXCEL_LINES
|
||||
|
||||
|
||||
#: 거창 (원파일 · 엑셀 돈 줄 수, 엑셀 갈래 = 원파일) — 다른 곳 = `_목록_810-1_원파일대조.xlsx`
|
||||
EXCEL_LINES = (547, 282)
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
칸 · 산근 원문 1:1(다름 0) · 줄 풀이 = `M02_San_Stmate.parse_line`
|
||||
(b) 블록 다시 셈(`M02_San_Eval.stmate_block` · 차수 1 단가 · 낙찰 · 결과 조정 · 끝전 갈래 = 옵션
|
||||
SAN_TYP 1 비목버림 / 0 합버림_큰나머지) = COSTN 저장 금액(TAMT1 · LAMT1 · MAMT1 · SAMT1) 100 %
|
||||
(c) 엑셀 짝 다섯(`helper_m02_hopyo_golden`) = 지금 엑셀 엔진 블록 금액 · 숨은 G 열 원문 ·
|
||||
목록표 단가와
|
||||
대조 — 다른 곳 = 엑셀 추정이 틀린 곳(수만 지킴) · 엑셀에 안 찍힌 블록 수 기록
|
||||
(c) 엑셀 짝 대조는 812-1 엑셀 읽기기 골든(`test_812_1_xlsx_golden` · 옛 엑셀 엔진 걷음)
|
||||
원파일 없으면 skip(다른 PC).
|
||||
"""
|
||||
|
||||
@@ -117,71 +115,3 @@ def test_structure_line() -> None:
|
||||
assert [ln["자원"]["코드"] for ln in refs] == ["M00080", "M00002", "M00001", "L00001", "L00007"]
|
||||
assert refs[0]["자원"]["종류"] == "자재" and refs[3]["자원"]["종류"] == "노임"
|
||||
assert [ln["단"] for ln in b["줄"] if ln["합계"]] == [1, 1, 2]
|
||||
|
||||
|
||||
# ── (c) 엑셀 엔진 대조 ─────────────────────────────────────────────────
|
||||
PAIRED = ("봉화", "동부청영월", "울진대흥", "영덕계류", "울진소광")
|
||||
|
||||
|
||||
def excel_diffs(tag: str) -> tuple[list[dict], dict]:
|
||||
"""엑셀 엔진(숨은 G 열 · 목록표 단가) 대 원파일 → (다른 곳, 셈)."""
|
||||
import helper_m02_hopyo_golden as golden
|
||||
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
|
||||
if not golden.FILES[tag].exists():
|
||||
pytest.skip("엑셀 없음")
|
||||
xl = book.load(golden.FILES[tag])
|
||||
d = parsed(tag)
|
||||
rule = RULE[d["옵션"]["SAN_TYP"]]
|
||||
mine = {b["코드"]: b for b in d["산근"]}
|
||||
out = []
|
||||
cnt = {"엑셀블록": len(xl["블록"]), "원파일블록": len(mine), "같이": 0, "금액같음": 0}
|
||||
cnt |= {"글같음": 0, "단가": 0, "단가같음": 0}
|
||||
for x in xl["블록"]:
|
||||
b = mine.get(x["코드"])
|
||||
if b is None:
|
||||
out.append({"코드": x["코드"], "항목": "원파일에 블록 없음"})
|
||||
continue
|
||||
cnt["같이"] += 1
|
||||
res = ev.stmate_block(x, stm.parse_block(x["몸"]), xl["단가"], rule)["금액"]
|
||||
if all(abs(res[c] - b["저장"][c]) < 0.5 for c in "TLMS"):
|
||||
cnt["금액같음"] += 1
|
||||
else:
|
||||
out.append({"코드": x["코드"], "항목": "금액", "엑셀": res, "원파일": b["저장"]})
|
||||
g = [ln["G"].strip() for ln in x["몸"] if ln["G"].strip()]
|
||||
if g == [ln["글"].strip() for ln in b["줄"] if ln["글"].strip()]:
|
||||
cnt["글같음"] += 1
|
||||
else:
|
||||
out.append({"코드": x["코드"], "항목": "원문 글"})
|
||||
for code, p in xl["단가"].items():
|
||||
q = d["단가"].get(code)
|
||||
cnt["단가"] += 1
|
||||
if q and all(abs((p[c] or 0) - q[c]) < 0.5 for c in "TLMS"):
|
||||
cnt["단가같음"] += 1
|
||||
else:
|
||||
out.append({"코드": code, "항목": "단가", "엑셀": p, "원파일": q})
|
||||
for code in sorted(set(mine) - {x["코드"] for x in xl["블록"]}):
|
||||
out.append({"코드": code, "항목": "엑셀에 안 찍힌 블록"})
|
||||
return out, cnt
|
||||
|
||||
|
||||
#: 엑셀 짝 → (엑셀 블록, 같이 있는 블록, 엑셀 단가 줄, 그중 같음, 엑셀에 안 찍힌 원파일 블록)
|
||||
#: 울진대흥 = 엑셀(신설사업 판)과 원파일(설계원본 판)이 다른 판 — 코드 겹침 14 · 단가 40 빠짐
|
||||
EXCEL = {
|
||||
"봉화": (65, 65, 203, 203, 0),
|
||||
"동부청영월": (69, 69, 201, 201, 0),
|
||||
"울진대흥": (47, 14, 153, 113, 32),
|
||||
"영덕계류": (15, 15, 54, 54, 20),
|
||||
"울진소광": (40, 40, 138, 138, 22),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", PAIRED)
|
||||
def test_excel_engine(tag: str) -> None:
|
||||
out, cnt = excel_diffs(tag)
|
||||
n_xl, both, n_price, same_price, extra = EXCEL[tag]
|
||||
assert (cnt["엑셀블록"], cnt["같이"], cnt["단가"]) == (n_xl, both, n_price)
|
||||
assert cnt["금액같음"] == cnt["같이"] and cnt["글같음"] == cnt["같이"]
|
||||
assert cnt["단가같음"] == same_price
|
||||
assert sum(o["항목"] == "엑셀에 안 찍힌 블록" for o in out) == extra
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
(a) 화면 셈 거울 — EST 여섯 · STC 일곱 산근 블록 모두: 화면 길(TS importView → calcDoc) = 서버 길
|
||||
(`M02_Doc_Import.run` 계산기) 답 전부 같음(줄 · 비목 · 합 · 표지)
|
||||
(b) 옵션 칸 — 원파일 수 하나 변수(출처 「원파일」) = 옵션 가로 칸(`sourceOf`) ·
|
||||
줄 쌓기 공식 묶음에 안 들어감 · 금액 줄 단가로 쓰는 수 · 엑셀 단가 기억 연결 = 옵션 칸 아님
|
||||
줄 쌓기 공식 묶음에도 보임(812-3) · 금액 줄 단가로 쓰는 수 · 엑셀 단가 기억 연결 = 옵션 칸 아님
|
||||
(c) 추정 칸 — 문서 머리 `가져온.추정` 줄 수 = 원본 확정 추정 수(EST) · 원코드 · 규격 머리
|
||||
(d) 소계 줄 — 합 밖 · 비목 없음(화면 비목 칸 빈칸 · 종류 「소계」 —
|
||||
`line_stack_edit` · `line_stack_out` 글 대조)
|
||||
@@ -121,7 +121,8 @@ def test_screen_calc_mirror_and_option_cells(kind: str, tag: str) -> None:
|
||||
and not any(str(m.get("단가")) == v["열쇠"] for m in doc.get("금액") or [])
|
||||
]
|
||||
assert set(nums) <= set(shape["옵션"]), (tag, key, nums, shape["옵션"])
|
||||
assert not set(shape["옵션"]) & set(shape["공식"]), (tag, key) # 옵션 칸 = 공식 묶음 밖
|
||||
# 옵션 칸 = 공식 묶음에도 보임(812-3 · 같은 값)
|
||||
assert set(shape["옵션"]) <= set(shape["공식"]), (tag, key)
|
||||
for v in doc["변수"]:
|
||||
if v["열쇠"] in shape["옵션"]:
|
||||
assert v["연결"]["갈래"] == "숫자" and "엑셀" not in v["연결"]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
근접(합판거푸집 4회 = GC000211 보통 · 4회 — 단가 차이 · 비목 · 율) · 요구 704 파형강관 매설 Φ800 =
|
||||
GF000179 관경 800 근접 · 다른 항 = 단위 환산(× 6) · 기계 규격 · 율 줄 · 없음(후보 0)
|
||||
(b) 옵션 풀이 — 줄 수량이 의존하는 변수만 돌려 좁힘 · 글 근거 값과 다르면 「옵션 글」 다른 항(확정 아님)
|
||||
(c) 줄 대조 — 같음 · 단가 차이 · 단위 환산 · 율 줄 · 끝수(지어 맞추기 없음 — 1 원 달라도 확정 아님)
|
||||
(c) 줄 대조 — 같음 · 단위 환산 · 율 줄 = 다른 항 · 단가 차이 · 끝수 = 참고(812-2 · 확정을 안 막음)
|
||||
(d) 바꾸기 — 임시 층 개인: 확정 SK = 같은 키에 M01 사본(바탕 · 입력값) · 금액 = M01 문서 셈 · 원래 몸 보관 →
|
||||
되돌리기 = 원래 금액 · 근접 SK 바꾸기 = 409 · M01 문서 파일 그대로
|
||||
(e) 전수(1,518 문서 × 원파일 산근 2,220 블록 · 파일마다 프로세스) — 확정은 모두 다시 셈해도 금액 같음 ·
|
||||
@@ -70,7 +70,8 @@ def test_near_formwork_lists_differences() -> None:
|
||||
assert got["판정"] == dm.NEAR and got["키"] == "GC000211"
|
||||
assert got["옵션"]["사용횟수"] == "4회" and got["옵션"]["유형"] == "보통"
|
||||
kinds = {d["항"] for d in got["다른"]}
|
||||
assert "단가 차이" in kinds and "비목" in kinds # 대전 합판 단가 · 공구손료 재료비 ↔ 경비
|
||||
assert kinds == {"비목"} # 공구손료 1 % 재료비 ↔ 경비 = 로직(812-2)
|
||||
assert [d["항"] for d in got["참고"]] == ["단가 차이"] # 대전 합판 단가 = 값(참고)
|
||||
|
||||
|
||||
def test_req704_pipe_is_near_with_unit_rate_machine() -> None:
|
||||
@@ -118,10 +119,11 @@ def test_compare_kinds_and_no_tolerance() -> None:
|
||||
tot = {"노무비": 9, "재료비": 0, "경비": 0, "계": 9}
|
||||
assert dm.verdict(a, list(a), tot, tot)["판정"] == dm.SURE
|
||||
off = dm.verdict(a, list(a), tot, {**tot, "노무비": 10, "계": 10})
|
||||
assert off["판정"] != dm.SURE and off["다른"][0]["항"] == "끝수" # 1 원도 확정 아님
|
||||
assert off["판정"] == dm.SURE and off["참고"][0]["항"] == "끝수" # 끝수 = 참고(812-2)
|
||||
b = [_line("인부", "노임", "0.24", "100"), _line("공", "노임", "0.5", "11")]
|
||||
res = dm.verdict(a, b, tot, tot)
|
||||
assert [d["항"] for d in res["다른"]] == ["단가 차이", "단위 환산"] and res["판정"] == dm.NEAR
|
||||
assert [d["항"] for d in res["다른"]] == ["단위 환산"] and res["판정"] == dm.NEAR
|
||||
assert [d["항"] for d in res["참고"]] == ["단가 차이"]
|
||||
rate = {**_line("율", "율", "1", "3", "경비"), "수량": None, "단가": None}
|
||||
res = dm.verdict([*a, rate], a, tot, tot)
|
||||
assert res["다른"] == [{"항": "율 줄", "글": "가져온 쪽만 — 율"}] and res["판정"] == dm.NEAR
|
||||
@@ -197,7 +199,8 @@ def test_every_block_table() -> None:
|
||||
assert got["블록"] == 2220 and got["오류"] == 0 and got["확정다시셈다름"] == 0
|
||||
assert set(got["판정"]) <= {dm.SURE, dm.NEAR, dm.NONE}
|
||||
assert sum(got["판정"].values()) == 2220 and got["판정"].get(dm.SURE, 0) >= 1
|
||||
assert collections.Counter(got["근접항"]).get("단가 차이", 0) >= 1
|
||||
assert not {"단가 차이", "끝수"} & set(got["근접항"]) # 값 차이 = 참고(812-2)
|
||||
assert collections.Counter(got["확정참고"]).get("단가 차이", 0) >= 1
|
||||
|
||||
|
||||
def test_parent_reads_swapped_sibling(world) -> None:
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
닫는 표지 2 칸 · 합계 표지 1 칸 · 소계 61 = 비목 칸만 — 거창 실제와 글 배치 같음(수 꼴 빼고) 먼저 확인) →
|
||||
읽기기 → 원파일 구조 줄과 같음(EST 1,849 · STC 371 블록) · 원파일 자체 엇갈림(같은 글 · 다른 표지)은
|
||||
`ODD` 로 고정
|
||||
③ 금액 — 실무 엑셀 11 = 읽기기 → `M02_Doc_FromNative` → 수식 문서 셈 = 엑셀 블록 금액(`ODD_MONEY` 고정)
|
||||
③ 금액 — 실무 엑셀 11 = 읽기기 → `M02_Doc_FromNative` → 수식 문서 셈 = 엑셀 블록 금액 100 %
|
||||
빠른 견본 기본 · 전수 = 환경 S8121_ALL=1 · 파일 없으면 skip(다른 PC).
|
||||
"""
|
||||
|
||||
@@ -52,20 +52,8 @@ ODD = {
|
||||
*(("main", 1308, r) for r in (8, 16, 26)), # 금액 칸 없는 「소 계 : 수 kg」 = 61(861 = 글)
|
||||
*(("main", 1441, r) for r in (64, 65, 66, 67)), # 단가가 기계표와 다른 세 줄 · 비목 없음
|
||||
}
|
||||
#: 금액 — 셈 ≠ 엑셀 블록(까닭 고정)
|
||||
ODD_MONEY = {
|
||||
(
|
||||
"북부청임도",
|
||||
33,
|
||||
), # 노무비 줄 「36,248 ÷ 0.096」 오타 — 절 소계 덧셈이 그 줄을 뺌(글 덧셈만 앎)
|
||||
(
|
||||
"북부청임도",
|
||||
3,
|
||||
), # 아래 넷 = `M02_Doc_NativeEst.triple` 자리 규칙 dict 묶음 TypeError(812-3 몫)
|
||||
("북부청임도", 54),
|
||||
("북부청임도", 55),
|
||||
("북부청사방", 24),
|
||||
}
|
||||
#: 금액 — 셈 ≠ 엑셀 블록(없음 · 생기면 까닭과 함께 고정)
|
||||
ODD_MONEY: set[tuple] = set()
|
||||
|
||||
|
||||
# ── 합성(경쟁사 내보내기 꼴) ─────────────────────────────────────────────
|
||||
@@ -396,12 +384,8 @@ def sweep(tag: str) -> dict:
|
||||
out["자원줄"] += bool(ln.get("자원"))
|
||||
for no, blk in fn.numbers(saved).items():
|
||||
out["블록"] += 1
|
||||
try:
|
||||
emitted = fn.emit(saved, no)
|
||||
_, _, got = nd.doc_and_money(emitted)
|
||||
except TypeError:
|
||||
out["다름"].add((tag, no))
|
||||
continue
|
||||
emitted = fn.emit(saved, no)
|
||||
_, _, got = nd.doc_and_money(emitted)
|
||||
mine = {c: got.get(k) for c, k in nd.COST}
|
||||
nag = (emitted["원본"].get("계약") or {}).get("낙찰률")
|
||||
if nag:
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""812-2 지킴 — 연결 찾기 확정 = 로직(요구 712) · 값 차이 = 참고(`M02_Doc_Match_Note`).
|
||||
|
||||
(a) 단가만 다름 = 확정 + 참고 「단가 차이」(원파일 기계경비 묶음 ↔ 우리 기계 = 이름 종류 같을 때) ·
|
||||
끝수만 다름 = 확정 + 참고 「끝수」
|
||||
(b) 로직 차이 = 다른 항 — 율 대상 · 기계 규격 · 노임 종류(「자원」) · 산출 단가 · 비목
|
||||
(c) 율 = 비율 — 거창 합판거푸집(블록 41): 가져온 「주자재소계 × 9 %」 = M01 소모자재 9 % 짝 · 율_2 1 % 재료비 ↔
|
||||
공구손료 1 % 경비 = 「비목」 · 대전 합판 단가 = 참고
|
||||
(d) 요구 704 배수관부설 D=800 ↔ GF000179 = 여전히 근접(단위 환산 × 6 · 기계 규격 · 율 줄)
|
||||
(e) 전수(S8122_ALL=1) — 2,220 블록 · 확정 다시 셈 같음 · 근접 까닭에 단가 차이 · 끝수 없음 · 확정 참고 있음
|
||||
원파일 없으면 (c) · (d) · (e) skip(다른 PC).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from decimal import Decimal as D
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
for p in (ROOT, HERE):
|
||||
if str(p) not in sys.path:
|
||||
sys.path.insert(0, str(p))
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Match as dm # noqa: E402
|
||||
|
||||
TOT = {"노무비": 9, "재료비": 0, "경비": 0, "계": 9}
|
||||
|
||||
|
||||
def _line(name, kind, qty, price, cost="노무비", code=""):
|
||||
q, p = D(qty), D(price)
|
||||
return {"번": 0, "이름": name, "갈래": kind, "코드": code, "수량": dm._key(q),
|
||||
"단가": dm._key(p), "비목": {cost: dm._key(q * p)}, "금액": dm._key(q * p), "합밖": False} # fmt: skip
|
||||
|
||||
|
||||
def _rate(name, rate, target=(("노임", "노무비"),), cost="경비"):
|
||||
return {"번": 0, "이름": name, "갈래": "율", "코드": "", "수량": None, "단가": None,
|
||||
"비목": {cost: D(1)}, "금액": D(1), "합밖": False, "율": D(rate), "대상": frozenset(target)} # fmt: skip
|
||||
|
||||
|
||||
A = [_line("인부", "노임", "0.04", "100", code="LB1"), _line("공", "노임", "0.5", "10", code="LB2")]
|
||||
|
||||
|
||||
# ── (a) 값 차이 = 참고 ───────────────────────────────────────────────────
|
||||
def test_price_only_is_sure_with_note() -> None:
|
||||
b = [_line("인부", "노임", "0.04", "120", code="LB1"), A[1]]
|
||||
res = dm.verdict(A, b, TOT, {**TOT, "노무비": 10, "계": 10})
|
||||
assert res["판정"] == dm.SURE and not res["다른"] and res["같음"] == 2
|
||||
assert [d["항"] for d in res["참고"]] == ["단가 차이"]
|
||||
mach = [_line("굴삭기", "기계", "0.1", "500", "경비", "0201-0070")]
|
||||
other = [_line("굴삭기", "기계", "0.1", "530", "경비", "0201-0070")]
|
||||
assert dm.verdict(mach, other, TOT, TOT)["판정"] == dm.SURE # 기계 시간당 단가 = 참고
|
||||
file = [
|
||||
_line("머캐덤로울러(자주식)", "산출", "0.1", "530", "경비")
|
||||
] # 원파일 기계경비 묶음(코드 없음)
|
||||
ours = [_line("탠덤롤러", "기계", "0.1", "500", "경비", "1206-0008")]
|
||||
ours.append(_line("머캐덤롤러 10∼12ton", "기계", "0.1", "500", "경비", "1106-0012"))
|
||||
res = dm.verdict(file, ours, TOT, TOT) # 이름 종류 같은 기계와 짝 · 단가 = 참고
|
||||
assert (
|
||||
not [d for d in res["다른"] if d["항"] != "줄 없음"] and res["참고"][0]["항"] == "단가 차이"
|
||||
)
|
||||
run = [{**_line("드릴", "산출", "0.1", "530", "경비"), "비목": {"노무비": D(1), "경비": D(2)}}]
|
||||
res = dm.verdict(run, [_line("드릴", "기계", "0.1", "500", "경비", "0901-0010")], TOT, TOT)
|
||||
assert res["판정"] == dm.SURE # 기계경비 구성(운전 노무비 포함) 차이 = 시간당 단가 = 참고
|
||||
none = [{**_line("모래", "재료", "1", "0", "재료비"), "비목": {}}]
|
||||
assert (
|
||||
dm.verdict([_line("모래", "재료", "1", "9", "재료비")], none, TOT, TOT)["판정"] == dm.SURE
|
||||
)
|
||||
res = dm.verdict(file, ours[:1], TOT, TOT)
|
||||
assert [d["항"] for d in res["다른"]] == ["기계 규격"]
|
||||
|
||||
|
||||
def test_rounding_only_is_sure_with_note() -> None:
|
||||
res = dm.verdict(A, list(A), TOT, {**TOT, "노무비": 10, "계": 10})
|
||||
assert res["판정"] == dm.SURE and [d["항"] for d in res["참고"]] == ["끝수"]
|
||||
|
||||
|
||||
# ── (b) 로직 차이 = 다른 항 ──────────────────────────────────────────────
|
||||
def test_rate_value_is_note_structure_is_logic() -> None:
|
||||
res = dm.verdict([*A, _rate("공구손료", "0.02")], [*A, _rate("공구손료", "0.03")], TOT, TOT)
|
||||
assert not res["다른"] and res["판정"] == dm.SURE
|
||||
assert [d["항"] for d in res["참고"]] == ["율 값"] # 율 값(%) 다름 = 참고(812-4) · 로직 같음
|
||||
assert "2 % ↔" in res["참고"][0]["글"] and "3 %" in res["참고"][0]["글"]
|
||||
same = dm.verdict([*A, _rate("율", "0.02")], [*A, _rate("공구손료", "0.02")], TOT, TOT)
|
||||
assert same["판정"] == dm.SURE # 율 · 대상 · 비목 같음 = 이름 달라도 같은 율 줄
|
||||
tgt = dm.verdict(
|
||||
[*A, _rate("율", "0.02", (("재료", "재료비"),))], [*A, _rate("율", "0.02")], TOT, TOT
|
||||
)
|
||||
assert [d["항"] for d in tgt["다른"]] == ["율 대상"]
|
||||
cost = dm.verdict([*A, _rate("율", "0.02", cost="재료비")], [*A, _rate("율", "0.02")], TOT, TOT)
|
||||
assert [d["항"] for d in cost["다른"]] == ["비목"]
|
||||
|
||||
|
||||
def test_machine_labor_kind_differs_blocks() -> None:
|
||||
a = [*A, _line("굴삭기", "기계", "0.1", "500", "경비", "0201-0070")]
|
||||
b = [*A, _line("굴삭기", "기계", "0.1", "500", "경비", "0201-0080")]
|
||||
res = dm.verdict(a, b, TOT, TOT)
|
||||
assert [d["항"] for d in res["다른"]] == ["기계 규격"] and res["판정"] == dm.NEAR
|
||||
c = [_line("특별인부", "노임", "0.04", "120", code="LB9"), A[1]]
|
||||
res = dm.verdict(A, c, TOT, TOT)
|
||||
assert [d["항"] for d in res["다른"]] == ["자원"] and res["판정"] != dm.SURE
|
||||
sub = [_line("모르타르", "산출", "0.1", "500", "재료비")]
|
||||
res = dm.verdict(sub, [_line("모르타르", "산출", "0.1", "520", "재료비")], TOT, TOT)
|
||||
assert [d["항"] for d in res["다른"]] == ["산출 단가"] # 안쪽 산출 = 로직 결과
|
||||
|
||||
|
||||
# ── (c) · (d) 원파일 블록 ────────────────────────────────────────────────
|
||||
def _judge(no: int) -> dict:
|
||||
t4 = pytest.importorskip("test_810_4_match")
|
||||
try:
|
||||
return t4.judge(no)
|
||||
except FileNotFoundError:
|
||||
pytest.skip("원파일 없음")
|
||||
|
||||
|
||||
def test_formwork_rate_ratio_pairs() -> None:
|
||||
got = _judge(41) # 합판거푸집 4회 ↔ GC000211
|
||||
assert got["판정"] == dm.NEAR and got["키"] == "GC000211"
|
||||
assert [d["항"] for d in got["다른"]] == ["비목"] and "1 %" in got["다른"][0]["글"]
|
||||
assert [d["항"] for d in got["참고"]] == ["단가 차이"]
|
||||
|
||||
|
||||
def test_req704_still_near() -> None:
|
||||
got = _judge(19) # 배수관부설 D=800 ↔ GF000179
|
||||
kinds = [d["항"] for d in got["다른"]]
|
||||
assert got["판정"] == dm.NEAR and got["키"] == "GF000179"
|
||||
assert kinds.count("단위 환산") == 2 and "기계 규격" in kinds and "율 줄" in kinds
|
||||
|
||||
|
||||
# ── (e) 전수 ─────────────────────────────────────────────────────────────
|
||||
@pytest.mark.skipif(os.environ.get("S8122_ALL") != "1", reason="전수 = S8122_ALL=1")
|
||||
def test_every_block_logic() -> None:
|
||||
out = subprocess.run(
|
||||
[sys.executable, str(HERE / "helper_810_4_sweep.py")], capture_output=True, text=True
|
||||
)
|
||||
assert out.returncode == 0, out.stderr[-2000:]
|
||||
got = json.loads(out.stdout.strip().splitlines()[-1])
|
||||
assert got["블록"] == 2220 and got["오류"] == 0 and got["확정다시셈다름"] == 0
|
||||
assert not {"단가 차이", "끝수"} & set(got["근접항"])
|
||||
assert got["확정참고블록"] >= 1 and got["판정"][dm.SURE] >= 98
|
||||
|
||||
|
||||
def test_rate_target_scope() -> None:
|
||||
"""율 대상 — 줄 기호 = 그 줄 · 비목 이름 = 그 비목 몫(기계 운전 노무비 포함) · 번지 합 = 핵심 줄 전부."""
|
||||
from M02_MasterTemplete import M02_Doc_Match_Note as note
|
||||
|
||||
def run(rate_text: str) -> dict:
|
||||
money = [
|
||||
{"이름": "인부", "수량": "1", "단가": "100", "기호": "C_인부"},
|
||||
{"이름": "굴삭기", "수량": "1", "단가": "300"},
|
||||
{"이름": "율", "단가": rate_text},
|
||||
]
|
||||
rows = [
|
||||
{"수량": D(1), "단가": D(100), "금액": D(100)},
|
||||
{"수량": D(1), "단가": D(300), "금액": D(300)},
|
||||
{"수량": D(1), "단가": D(0), "금액": D(0)},
|
||||
]
|
||||
facts = [
|
||||
{"번": 0, "갈래": "노임", "비목": {"노무비": D(100)}, "합밖": False},
|
||||
{"번": 1, "갈래": "산출", "비목": {"노무비": D(100), "경비": D(200)}, "합밖": False},
|
||||
{"번": 2, "갈래": "율", "비목": {"경비": D(4)}, "합밖": False},
|
||||
]
|
||||
doc = {"변수": [], "식": [], "금액": money}
|
||||
rows[2]["단가"] = {"C_인부 × 4%": D(4), "노무비 × 2%": D(4), "번지.A01.계 × 1%": D(4)}[
|
||||
rate_text
|
||||
]
|
||||
note.rates(doc, {"줄": rows}, facts)
|
||||
return facts[2]
|
||||
|
||||
one = run("C_인부 × 4%")
|
||||
assert one["율"] == D("0.04") and one["대상"] == {("노임", "노무비")}
|
||||
labor = run("노무비 × 2%") # 노무비 200(인부 100 + 굴삭기 운전 100)
|
||||
assert labor["율"] == D("0.02") and labor["대상"] == {("노임", "노무비"), ("기계", "노무비")}
|
||||
whole = run("번지.A01.계 × 1%") # 400 × 1 %
|
||||
assert whole["율"] == D("0.01") and ("기계", "경비") in whole["대상"]
|
||||
@@ -0,0 +1,223 @@
|
||||
"""812-3 지킴 — 가져온 단가산출(원파일) 줄 쌓기 차림 = 경쟁사 산출근거 꼴(요구 713).
|
||||
|
||||
차림 = `M02_Doc_NativeLayout`.
|
||||
(a) 견본(거창 덤프 꼴) — 1 측구터파기(토사) 「기 계 … 굴삭기」 항목 + 공식 묶음
|
||||
q · K · f · E · Cm · Q + 보조인부 항목 · 41 합판거푸집(4회) 근거 글 ·
|
||||
재료비(재료 · 소계 · 율 · 글) · 인건비(인력 · 소계 · 율 · 글) ·
|
||||
15 전석 메쌓기 별산 · 단산 참조 Q 직접 · 기계 · 인부 ·
|
||||
2 가 · 나 소항목 · 봉화 STC = 계 단마다 항목
|
||||
(b) 전수 — 원파일 EST · STC 산근 블록 모두: 차림 전후 셈(비목 · 계 · 줄 금액) 같음 ·
|
||||
TS 왕복(문서 → 줄 → 문서) 같음 · TS `stackOf` = 파이썬 거울 `stack_of` ·
|
||||
수 표 = tmp/s8123/sweep.json(일회 기록)
|
||||
(c) M01 문서 1,518 — 차림 안 바뀜(쌓기 없음 · 왕복 같음 · 거울 같음)
|
||||
원파일 없으면 그 시험만 skip(다른 PC).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HERE = Path(__file__).resolve().parent
|
||||
for p in (ROOT, HERE):
|
||||
if str(p) not in sys.path:
|
||||
sys.path.insert(0, str(p))
|
||||
|
||||
import test_810_2_native_doc as t2 # noqa: E402
|
||||
from test_810_1_estx import FILES as EST # noqa: E402
|
||||
from test_810_1_stc import FILES as STC # noqa: E402
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc # noqa: E402
|
||||
from M02_MasterTemplete import M02_Doc_Import as dim # noqa: E402
|
||||
from M02_MasterTemplete import M02_Doc_NativeLayout as lay # noqa: E402
|
||||
|
||||
COSTS = ("노무비", "재료비", "경비", "계")
|
||||
|
||||
|
||||
def _ts(cases: list[dict]) -> list[dict]:
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_812_3_layout.cjs")],
|
||||
input=json.dumps(dc.ml._norm(cases), ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr[-3000:]
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _doc(kind: str, tag: str, no: int, laid: bool = True) -> tuple[dict, dict]:
|
||||
sk = t2.sk_of(t2.fn.emit(t2.native(kind, tag), no))
|
||||
keep = lay.apply
|
||||
if not laid:
|
||||
lay.apply = lambda doc, meta: [x.pop(lay.KEEP, None) for x in doc.get("금액") or []]
|
||||
try:
|
||||
return sk, dim.derive(sk, None, t2.whole())
|
||||
finally:
|
||||
lay.apply = keep
|
||||
|
||||
|
||||
def _items(doc: dict) -> list[dict]:
|
||||
return lay.stack_of(doc)
|
||||
|
||||
|
||||
def _show(doc: dict) -> list[tuple]:
|
||||
"""(제목, 가나다, [줄 글]) — 금액 = 이름 · 공식 = 「=기호…」 · 글 = 「#글」."""
|
||||
money = doc["금액"]
|
||||
out = []
|
||||
for it in _items(doc):
|
||||
lines = []
|
||||
for x in it["줄"]:
|
||||
if x["꼴"] == "금액":
|
||||
lines.append(money[x["i"]]["이름"])
|
||||
elif x["꼴"] == "공식":
|
||||
lines.append("=" + " ".join(x["기호"]))
|
||||
else:
|
||||
lines.append("#" + x["글"])
|
||||
out.append((it["제목"], bool(it.get("가나다")), lines))
|
||||
return out
|
||||
|
||||
|
||||
# ── (a) 견본 ─────────────────────────────────────────────────────────────
|
||||
def test_sample_excavation_machine_and_formula() -> None:
|
||||
_, doc = _doc("EST", "거창", 1)
|
||||
got = _show(doc)
|
||||
assert got == [
|
||||
("기 계(100%) : 굴삭기(무한궤도) (0.7㎥)", False, ["=q K f E Cm Q", "굴삭기(무한궤도)"]),
|
||||
("보조인부(1일당 1인)", False, ["보통인부"]),
|
||||
], got
|
||||
assert [x["묶음"] for x in doc["금액"]] == [got[0][0], got[1][0]]
|
||||
|
||||
|
||||
def test_sample_formwork_text_rate_subtotal() -> None:
|
||||
_, doc = _doc("EST", "거창", 41)
|
||||
got = _show(doc)
|
||||
assert got[0] == (
|
||||
"",
|
||||
False,
|
||||
["#< 토목품셈 6-3-1 합판거푸집(2022개정) >", "#※ 사용횟수 4회 : 보통"],
|
||||
)
|
||||
assert got[1] == (
|
||||
"재료비",
|
||||
False,
|
||||
[
|
||||
"합판(내수),대전",
|
||||
"외송각재,대전",
|
||||
"주자재소계",
|
||||
"#소모자재(박리재등) (주자재비의 9%)",
|
||||
"율",
|
||||
],
|
||||
)
|
||||
assert got[2][0] == "인건비 (24년 품셈 개정)" and got[2][2][:3] == [
|
||||
"형틀목공",
|
||||
"보통인부",
|
||||
"인부소계",
|
||||
]
|
||||
assert got[2][2][3].startswith("#공구손료") and len(got) == 3
|
||||
sub = next(x for x in doc["금액"] if x["이름"] == "주자재소계")
|
||||
assert sub["합밖"] and "비목" not in sub # 소계 = 합 밖 · 비목 칸 빈칸 그대로(810-2)
|
||||
|
||||
|
||||
def test_sample_stone_wall_reference_and_direct_q() -> None:
|
||||
_, doc = _doc("EST", "거창", 15)
|
||||
got = _show(doc)
|
||||
titles = [g[0] for g in got]
|
||||
assert titles[0] == "전석 구입운반 : 별산" and got[0][2] == []
|
||||
assert (
|
||||
titles[1].startswith("뒷채움돌 운반 및 채집")
|
||||
and got[1][2][-1] == "뒷채움돌기초잡석채집운반"
|
||||
)
|
||||
assert got[1][2][0].split()[0] == "=Q" # 단산 참조 Q 직접 = 그 항목 공식 묶음
|
||||
assert titles[3].startswith("전석쌓기 기계") and got[3][2] == ["=Q_3", "굴삭기(무한궤도)"]
|
||||
assert got[4] == ("전석쌓기 인부", False, ["석공", "보통인부"])
|
||||
assert "합" not in "".join(titles) # 합계 절 = 화면 자동 합계(두 번 안 보임)
|
||||
|
||||
|
||||
def test_sample_stc_steps_are_items() -> None:
|
||||
_, doc = _doc("STC", "봉화", 14)
|
||||
got = [
|
||||
(t, g, [x for x in ls if not x.startswith("=P_")]) for t, g, ls in _show(doc)
|
||||
] # 손 단가 = 기본 자리
|
||||
assert got == [
|
||||
("", False, ["#▣ 토공의 비탈규준틀[개소]2-6-1"]),
|
||||
("재 료 비", False, ["각재(거푸집용,외송)", "판재", "일반못(철못)"]),
|
||||
("인 건 비", False, ["건축목공", "보통인부"]),
|
||||
], got
|
||||
|
||||
|
||||
def test_sample_ganada_sub_items() -> None:
|
||||
_, doc = _doc("EST", "거창", 2)
|
||||
got = _show(doc)
|
||||
assert got[0] == ("기 계(100%)", False, [])
|
||||
assert got[1][:2] == ("굴삭기(무한궤도) (0.7+브레이카)", True)
|
||||
assert got[2] == ("치즐(0.7㎥용)", True, ["치즐(0.7㎥)"]) # 「다. 소 계」 절 = 뺌
|
||||
|
||||
|
||||
# ── (b) 전수 ─────────────────────────────────────────────────────────────
|
||||
def _money(sk: dict, doc: dict) -> tuple:
|
||||
got = dim.run(doc, sk, t2.whole())
|
||||
lines = sorted(str(x.get("금액")) for x in got.get("줄") or [])
|
||||
return tuple(str(got.get(c)) for c in COSTS), lines
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _sweep() -> dict:
|
||||
stat = {"블록": 0, "금액같음": 0, "다름": [], "cases": []}
|
||||
for kind, files in (("EST", EST), ("STC", STC)):
|
||||
for tag, path in files.items():
|
||||
if not path.exists():
|
||||
continue
|
||||
for no in t2.fn.numbers(t2.native(kind, tag)):
|
||||
sk, doc = _doc(kind, tag, no)
|
||||
_, plain = _doc(kind, tag, no, laid=False)
|
||||
stat["블록"] += 1
|
||||
if _money(sk, doc) == _money(sk, plain):
|
||||
stat["금액같음"] += 1
|
||||
else:
|
||||
stat["다름"].append((kind, tag, no))
|
||||
stat["cases"].append(
|
||||
{"키": f"{kind}|{tag}|{no}", "문서": doc, "파이썬": _items(doc)}
|
||||
)
|
||||
return stat
|
||||
|
||||
|
||||
def test_sweep_money_same_and_ts_round_trip() -> None:
|
||||
stat = _sweep()
|
||||
if not stat["블록"]:
|
||||
pytest.skip("원파일 없음")
|
||||
assert not stat["다름"] and stat["금액같음"] == stat["블록"], stat["다름"][:5]
|
||||
got = _ts(stat["cases"])
|
||||
bad = {f: [g["키"] for g in got if not g[f]] for f in ("같음", "거울")}
|
||||
assert bad == {"같음": [], "거울": []}, {k: v[:5] for k, v in bad.items()}
|
||||
by = {}
|
||||
for g in got:
|
||||
kind = g["키"].split("|")[0]
|
||||
row = by.setdefault(
|
||||
kind, dict.fromkeys(("블록", "항목", "제목", "공식", "글", "쌓기", "무제목만"), 0)
|
||||
)
|
||||
row["블록"] += 1
|
||||
for f in ("항목", "제목", "공식", "글"):
|
||||
row[f] += g[f]
|
||||
row["쌓기"] += g["쌓기"]
|
||||
row["무제목만"] += g["항목"] > 0 and g["제목"] == 0
|
||||
out = ROOT / "tmp" / "s8123"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
(out / "sweep.json").write_text(json.dumps(by, ensure_ascii=False, indent=1), "utf-8")
|
||||
assert by.get("EST", {}).get("제목", 0) > by.get("EST", {}).get("블록", 0)
|
||||
|
||||
|
||||
# ── (c) M01 ──────────────────────────────────────────────────────────────
|
||||
def test_m01_docs_layout_unchanged() -> None:
|
||||
docs = dc.folder()
|
||||
assert len(docs) >= 1518
|
||||
cases = [{"키": k, "문서": d, "파이썬": _items(d)} for k, d in docs.items()]
|
||||
got = _ts(cases)
|
||||
bad = {f: [g["키"] for g in got if not g[f]] for f in ("같음", "거울")}
|
||||
assert bad == {"같음": [], "거울": []}, {k: v[:5] for k, v in bad.items()}
|
||||
assert not any(g["쌓기"] for g in got)
|
||||
@@ -0,0 +1,79 @@
|
||||
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
|
||||
"""812-4 지킴 — 율 값(%) 다름 = 참고(값은 M01 값으로 업데이트됨) · 율 대상 · 비목 다름 = 다른 항(로직).
|
||||
|
||||
(a) 원파일 main 블록 105(터파기 · 리퍼 2.42 % ↔ 우리 유압식 리퍼 2 %) · 275(절토면고르기 리퍼) =
|
||||
확정 + 참고 「율 값」 (다른 항 0) · 글에 「바꾸면 우리 값」
|
||||
(b) 확정 + 참고 블록 [바꾸기] → 금액 = M01 사본 셈(율도 M01 값) · [되돌리기] → 원래 금액 · M01 문서 파일 그대로
|
||||
(c) 대상이 다른 율(교량받침 1.14 % ↔ 공구손료 3 % 노임 노무비) = 여전히 다른 항 「율 대상」
|
||||
원파일 없으면 skip(다른 PC) — 합성 줄 시험은 test_812_2.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from decimal import Decimal as D
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
for p in (ROOT, HERE):
|
||||
if str(p) not in sys.path:
|
||||
sys.path.insert(0, str(p))
|
||||
|
||||
import test_810_2_native_doc as t2 # noqa: E402
|
||||
import test_810_4_match as t4 # noqa: E402
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Match as dm # noqa: E402
|
||||
from M02_MasterTemplete import M02_Doc_MatchSk as msk # noqa: E402
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402
|
||||
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh # noqa: E402
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: E402, F401 — 같은 임시 층
|
||||
|
||||
|
||||
def _judge(no: int, tag: str = "main") -> dict:
|
||||
whole, _, index, links = t4.ctx()
|
||||
try:
|
||||
out = t2.fn.emit(t2.native("EST", tag), no)
|
||||
doc = t4.dim.derive(t2.sk_of(out), None, t2.whole())
|
||||
except FileNotFoundError:
|
||||
pytest.skip("원파일 없음")
|
||||
return dm.match(doc, t4.dim.Links(whole, "우리"), index, links)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("no", "key"), [(105, "GC000089"), (275, "GC000111")])
|
||||
def test_rate_value_only_is_sure_with_note(no: int, key: str) -> None:
|
||||
got = _judge(no)
|
||||
assert got["판정"] == dm.SURE and got["키"] == key and not got["다른"]
|
||||
rate = [d for d in got["참고"] if d["항"] == "율 값"]
|
||||
assert len(rate) == 1 and "바꾸면 우리 값" in rate[0]["글"] and "2 %" in rate[0]["글"]
|
||||
|
||||
|
||||
def test_rate_target_differs_stays_logic() -> None:
|
||||
got = _judge(1533) # 교량받침설치 — 율 대상 기계 · 노임 ↔ 노임
|
||||
assert "율 대상" in {d["항"] for d in got["다른"]} and got["판정"] != dm.SURE
|
||||
|
||||
|
||||
def test_swap_rate_note_block_uses_m01_rate(world) -> None:
|
||||
folder = world["personal"]
|
||||
est = t2._file(t2.EST["main"])
|
||||
before = {p.name: p.read_bytes() for p in dm.ml.FOLDER.glob("수식_*.json")}
|
||||
with store.at(folder):
|
||||
hp = fh.upload_native(folder, est.name, est.read_bytes(), t2.WHO)["키"]
|
||||
name = fh.make(folder, hp, 105)["이름"]
|
||||
old = store.read("sangeun", name)
|
||||
money0 = t4._money(name)
|
||||
found = msk.find(name, old["판"])
|
||||
meta = found[dm.KEY]
|
||||
assert meta["판정"] == dm.SURE and any(d["항"] == "율 값" for d in meta["참고"])
|
||||
assert t4._money(name) == money0 # 찾기 = 기록만
|
||||
msk.swap(name, found["판"])
|
||||
doc = store.read("sangeun", name)["문서"]
|
||||
want = dm._calc(dm.ml.load_all()[meta["키"]], dm.ml.Links(t2.whole()), dict(meta["옵션"]))
|
||||
assert t4._money(name) == [want[k] for k in t4.COSTS] # 율도 M01 값
|
||||
assert doc["바탕"] == {"로직": meta["키"]} and sum(t4._money(name)) != sum(money0)
|
||||
msk.back(name, store.read("sangeun", name)["판"])
|
||||
assert t4._money(name) == money0 # 되돌리기 = 원래 금액
|
||||
assert D(sum(money0)) > 0
|
||||
assert before == {p.name: p.read_bytes() for p in dm.ml.FOLDER.glob("수식_*.json")}
|
||||
@@ -1,6 +1,28 @@
|
||||
from test_est_rea_parity import run
|
||||
|
||||
|
||||
def test_machine_fuel_role_name_is_not_a_source_name_mismatch():
|
||||
result = run(
|
||||
"math",
|
||||
"""(() => {
|
||||
const p=EstNative.empty();
|
||||
p.tables.StuffcDat=[{Code:'M1',Name:'경유',Unite:'L'}];
|
||||
p.tables.MachMainDat=[
|
||||
{Index:1,Code:'E1'},
|
||||
{Index:-2,Name:'주연료',Code:'M1',LinkIndex:5,Unite:'L'},
|
||||
{Index:-2,Name:'주연료',Code:'MISSING',LinkIndex:5},
|
||||
{Index:-2,Name:'주연료',Code:'M1',LinkIndex:5,Unite:'kg'},
|
||||
{Index:-2,Name:'다른재료',Code:'M1',LinkIndex:5,Unite:'L'}
|
||||
];
|
||||
return EstIssues.scan(p).map(x=>[x.index,x.message]);
|
||||
})()""",
|
||||
)
|
||||
assert [1, "연결 품명 차이"] not in result
|
||||
assert [2, "연결 자료 없음"] in result
|
||||
assert [3, "연결 단위 차이"] in result
|
||||
assert [4, "연결 품명 차이"] in result
|
||||
|
||||
|
||||
def test_data_issues_detect_duplicate_missing_and_mismatched_links_without_modifying_data():
|
||||
result = run(
|
||||
"math",
|
||||
@@ -533,4 +555,3 @@ def test_data_issues_detect_percentage_unit_mismatch():
|
||||
assert ("HeadMainDat", "R1", "식") in items
|
||||
assert ("HeadMainDat", "R2", "M") in items
|
||||
assert ("EstMainDat", "R5", "원") in items
|
||||
|
||||
|
||||
@@ -6,6 +6,75 @@ from test_est_rea_parity import run
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_jdl_duplicate_sources_and_internal_codes_do_not_change_estimate_amounts():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
const duplicate=structuredClone(empty);duplicate.name='duplicate';
|
||||
duplicate.tables.StuffcDat=[{Code:'M1',Name:'first',TC:100},{Code:'M1',Name:'second',TC:200},{Code:'M1',Name:'third',TC:300}];
|
||||
duplicate.tables.EstMainDat=[{Code:'M1',Name:'item',LinkIndex:5,GN:2,GU:200,GC:400,TC:400},{LinkIndex:54}];
|
||||
const first=j.output(duplicate),loaded=j.read(first.bytes,empty);
|
||||
const collision=structuredClone(empty);collision.name='collision';
|
||||
collision.tables.EstiMateDat=[{Code:'LITERAL2',Name:'existing',GC:999,TC:999}];
|
||||
collision.tables.EstMainDat=[{Code:'fixed',Name:'fixed',CalcIndex:1,GN:0,GC:80,TC:80},{LinkIndex:54}];
|
||||
const second=j.output(collision),files=a.unpack(second.bytes),parts=j.parse('mainpart.txt',files.get('mainpart.txt'));
|
||||
const sources=j.parse('w.txt',files.get('w.txt')),target=sources.find(r=>r.F_CODE===parts[0].F_CODE1);
|
||||
process.stdout.write(JSON.stringify({duplicateTotal:loaded.tables.EstMainDat.at(-1).TC,
|
||||
duplicateSources:j.parse('m.txt',a.unpack(first.bytes).get('m.txt')).length,
|
||||
warning:first.warnings.some(x=>x.includes('중복')),fixedPrice:target.F_MPRICE,sourceCount:sources.length}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert result == {
|
||||
"duplicateTotal": 400,
|
||||
"duplicateSources": 3,
|
||||
"warning": True,
|
||||
"fixedPrice": 80,
|
||||
"sourceCount": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_jdl_duplicate_unit_codes_do_not_select_a_wrong_unit_group():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8')),p=structuredClone(empty);p.name='units';
|
||||
p.tables.StuffcDat=[{Code:'M',Name:'material',TC:100}];
|
||||
p.tables.HeadTitleDat=[{Code:'U',Name:'first',GC:200},{Code:'U',Name:'second',GC:300}];
|
||||
p.tables.HeadMainDat=[{Index:1,Code:'U'},{Index:0,Code:'M',LinkIndex:5,GN:2,GU:100,GC:200}];
|
||||
p.tables.EstMainDat=[{Code:'U',Name:'item',LinkIndex:103,GN:1,GU:300,GC:300,TC:300},{LinkIndex:54}];
|
||||
const out=j.output(p),files=a.unpack(out.bytes),loaded=j.read(out.bytes,empty);
|
||||
process.stdout.write(JSON.stringify({units:j.parse('unit.txt',files.get('unit.txt')).length,
|
||||
total:loaded.tables.EstMainDat.at(-1).TC,warning:out.warnings.some(x=>x.includes('중복'))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert result == {"units": 0, "total": 300, "warning": True}
|
||||
|
||||
|
||||
def test_jdl_duplicate_unit_detail_groups_do_not_select_the_last_group():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
|
||||
const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='groups';
|
||||
p.tables.StuffcDat=[{Code:'M',Name:'material',TC:100}];
|
||||
p.tables.HeadTitleDat=[{Code:'U',Name:'unit',GC:200,TC:200}];
|
||||
p.tables.HeadMainDat=[{Index:1,Code:'U'},{Index:0,Code:'M',LinkIndex:5,GN:2,GU:100,GC:200},
|
||||
{Index:1,Code:'U'},{Index:0,Code:'M',LinkIndex:5,GN:1,GU:100,GC:100}];
|
||||
p.tables.EstMainDat=[{Code:'U',Name:'item',LinkIndex:103,GN:1,GU:200,GC:200,TC:200},{LinkIndex:54}];
|
||||
const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,empty);
|
||||
process.stdout.write(JSON.stringify({units:j.parse('unit.txt',files.get('unit.txt')).length,
|
||||
total:q.tables.EstMainDat.at(-1).TC}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert result == {"units": 0, "total": 200}
|
||||
|
||||
|
||||
def test_jdl_basic_tables_use_native_separators_encoding_and_preserve_direct_money():
|
||||
p = run(
|
||||
"math",
|
||||
@@ -39,6 +108,35 @@ def test_jdl_basic_tables_use_native_separators_encoding_and_preserve_direct_mon
|
||||
assert result["warnings"]
|
||||
|
||||
|
||||
def test_large_jdl_import_keeps_group_order_and_rejects_orphans_and_duplicates():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const n=3000,groups=Array.from({length:n},(_,i)=>({F_CODE:String(i),F_DES:'group '+i}));
|
||||
const parts=groups.slice().reverse().flatMap(g=>[{F_CODE:g.F_CODE,F_SEQ:'1',F_CODE1:'M',F_DES:'first',F_QQTY:1,F_MPRICE:3},
|
||||
{F_CODE:g.F_CODE,F_SEQ:'2',F_CODE1:'M',F_DES:'second',F_QQTY:2,F_MPRICE:3}]);
|
||||
function archive(gs,ps){return a.pack(new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['m.txt',table('m.txt',[{F_CODE:'M',F_DES:'material',F_MPRICE:3}])],['gongjong.txt',table('gongjong.txt',gs)],['mainpart.txt',table('mainpart.txt',ps)]]))}
|
||||
const result=j.read(archive(groups,parts),structuredClone(empty));let orphan=false,duplicate=false;
|
||||
try{j.read(archive(groups.slice(0,1),parts.slice(0,1)),structuredClone(empty))}catch{orphan=true}
|
||||
try{j.read(archive([groups[0],groups[0]],[]),structuredClone(empty))}catch{duplicate=true}
|
||||
const rows=result.tables.EstMainDat;
|
||||
process.stdout.write(JSON.stringify({count:rows.length,first:rows.slice(0,3).map(r=>r.Name),total:rows.at(-1).TC,orphan,duplicate}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT, timeout=30
|
||||
)
|
||||
)
|
||||
assert result == {
|
||||
"count": 9001,
|
||||
"first": ["group 0", "first", "second"],
|
||||
"total": 27000,
|
||||
"orphan": True,
|
||||
"duplicate": True,
|
||||
}
|
||||
|
||||
|
||||
def test_unchanged_import_preserves_original_jdl_and_edited_work_is_not_silently_discarded():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),s=require('./tools/est-test/server.cjs');
|
||||
@@ -68,9 +166,10 @@ def test_basic_jdl_unit_price_parts_import_with_recorded_costs_and_warn_for_spec
|
||||
const common={F_CODE:'U1',F_CODE1:'M1',F_DES:'material',F_QQTY:2,F_MPRICE:100,F_MCOST:200,F_LOADBM:'T',F_SAVEBM:'T',F_SPROPER:'0',F_KWAN:'0',F_CLASS:'0'};
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['m.txt',table('m.txt',[{F_CODE:'M1',F_DES:'material',F_MPRICE:100}])],
|
||||
['unit.txt',table('unit.txt',[{F_CODE:'U1',F_DES:'unit',F_MPRICE:200}])],
|
||||
['unit.txt',table('unit.txt',[{F_CODE:'U1',F_DES:'unit',F_MPRICE:300}])],
|
||||
['unitpart.txt',table('unitpart.txt',[{...common,F_SEQ:'1'},{...common,F_SEQ:'2',F_SPROPER:'1'}])]]);
|
||||
const out=s.calculate(j.read(a.pack(files),empty));process.stdout.write(JSON.stringify({rows:out.tables.HeadMainDat,warnings:out.importWarnings}));"""
|
||||
const out=s.calculate(j.read(a.pack(files),empty));out.options.AutoCalc=true;out.options.SourceRefresh=true;
|
||||
const recalculated=s.calculate(structuredClone(out));process.stdout.write(JSON.stringify({rows:out.tables.HeadMainDat,warnings:out.importWarnings,title:recalculated.tables.HeadTitleDat[0]}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script],
|
||||
@@ -78,9 +177,11 @@ def test_basic_jdl_unit_price_parts_import_with_recorded_costs_and_warn_for_spec
|
||||
cwd=ROOT,
|
||||
).decode("utf-8")
|
||||
)
|
||||
header, detail = result["rows"]
|
||||
assert header["Index"] == 1 and header["TC"] == 200
|
||||
header, detail, remainder = result["rows"]
|
||||
assert header["Index"] == 1 and header["TC"] == 300
|
||||
assert detail["LinkIndex"] == 5 and detail["GN"] == 2 and detail["GC"] == 200
|
||||
assert remainder["CalcIndex"] == 1 and remainder["GC"] == 100
|
||||
assert result["title"]["TC"] == 300
|
||||
assert any("특수 구성" in warning for warning in result["warnings"])
|
||||
|
||||
|
||||
@@ -111,3 +212,393 @@ def test_basic_jdl_unit_price_parts_export_and_reimport():
|
||||
assert result["rows"][1]["LinkIndex"] == 5 and result["rows"][1]["GC"] == 200
|
||||
assert result["main"][1]["LinkIndex"] == 103
|
||||
assert result["specialUnits"] == 0 and result["specialWarnings"] > 0
|
||||
|
||||
|
||||
def test_jdl_unit_price_with_machine_detail_survives_round_trip():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
|
||||
const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='machine unit';
|
||||
p.tables.MachTitleDat=[{Code:'J',Name:'machine',GC:100,NC:50,TC:150}];
|
||||
p.tables.MachMainDat=[{Index:1,Code:'J',Name:'machine',JdlSummary:true},
|
||||
{Index:0,CalcIndex:1,JdlSummary:true,Name:'fuel',GC:100,NC:50,TC:150}];
|
||||
p.tables.HeadTitleDat=[{Code:'U',Name:'unit',GC:100,NC:50,TC:150}];
|
||||
p.tables.HeadMainDat=[{Index:1,Code:'U'},
|
||||
{Index:0,Code:'J',Name:'machine',LinkIndex:105,GN:1,GU:100,NU:50,GC:100,NC:50,TC:150}];
|
||||
p.tables.EstMainDat=[{Code:'U',Name:'unit',LinkIndex:103,GN:2,GU:100,NU:50,GC:200,NC:100,TC:300},{LinkIndex:54}];
|
||||
const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,empty);
|
||||
process.stdout.write(JSON.stringify({unit:j.parse('unitpart.txt',files.get('unitpart.txt')),
|
||||
rows:q.tables.HeadMainDat,machine:q.tables.MachMainDat,total:q.tables.EstMainDat.at(-1).TC}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert result["unit"][0]["F_CODE1"].startswith("J")
|
||||
assert result["rows"][1]["LinkIndex"] == 105
|
||||
assert result["machine"][1]["Name"] == "fuel"
|
||||
assert result["total"] == 300
|
||||
|
||||
|
||||
def test_jdl_unit_price_preserves_other_source_costs_as_fixed_resources():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
|
||||
const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='sources';
|
||||
for(const [table,code] of [['SilJukDat','S'],['EstiMateDat','E'],['CostTitleDat','D'],['MachTitleDat','J']])
|
||||
p.tables[table]=[{Code:code,Name:code,GC:10,TC:10}];
|
||||
p.tables.HeadTitleDat=[{Code:'U',Name:'unit',GC:40,TC:40}];
|
||||
p.tables.HeadMainDat=[{Index:1,Code:'U'},...[['S',21],['E',101],['D',104],['J',105]].map(([Code,LinkIndex])=>
|
||||
({Index:0,Code,Name:Code,LinkIndex,GN:1,GU:10,GC:10,TC:10}))];
|
||||
p.tables.EstMainDat=[{Code:'U',Name:'unit',LinkIndex:103,GN:1,GU:40,GC:40,TC:40},{LinkIndex:54}];
|
||||
const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,empty);
|
||||
process.stdout.write(JSON.stringify({parts:j.parse('unitpart.txt',files.get('unitpart.txt')).length,
|
||||
links:q.tables.HeadMainDat.slice(1).map(r=>r.LinkIndex),total:q.tables.EstMainDat.at(-1).TC}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert result == {"parts": 4, "links": [101, 101, 104, 101], "total": 40}
|
||||
|
||||
|
||||
def test_jdl_cost_title_survives_direct_and_unit_price_links():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
|
||||
const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='cost';
|
||||
p.tables.CostTitleDat=[{Code:'D',Name:'cost',GC:100,NC:20,JC:5,TC:125}];
|
||||
p.tables.HeadTitleDat=[{Code:'U',Name:'unit',GC:100,NC:20,JC:5,TC:125}];
|
||||
p.tables.HeadMainDat=[{Index:1,Code:'U'},
|
||||
{Index:0,Code:'D',Name:'cost',LinkIndex:104,GN:1,GU:100,NU:20,JU:5,GC:100,NC:20,JC:5,TC:125}];
|
||||
p.tables.EstMainDat=[{Code:'D',Name:'direct',LinkIndex:104,GN:2,GU:100,NU:20,JU:5,GC:200,NC:40,JC:10,TC:250},
|
||||
{Code:'U',Name:'unit',LinkIndex:103,GN:3,GU:100,NU:20,JU:5,GC:300,NC:60,JC:15,TC:375},{LinkIndex:54}];
|
||||
const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,empty);
|
||||
process.stdout.write(JSON.stringify({danga:j.parse('danga.txt',files.get('danga.txt')).length,
|
||||
costs:q.tables.CostMainDat,unit:q.tables.HeadMainDat[1].LinkIndex,
|
||||
direct:q.tables.EstMainDat[1].LinkIndex,total:q.tables.EstMainDat.at(-1).TC,warnings:out.warnings}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert result["danga"] == 1
|
||||
assert [row["TC"] for row in result["costs"][1:]] == [100, 20, 5]
|
||||
assert result["unit"] == result["direct"] == 104
|
||||
assert result["total"] == 625
|
||||
assert result["warnings"]
|
||||
|
||||
|
||||
def test_jdl_duplicate_cost_titles_fall_back_without_wrong_link():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
|
||||
const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='duplicates';
|
||||
p.tables.CostTitleDat=[{Code:'D',Name:'one',GC:10},{Code:'D',Name:'two',GC:20}];
|
||||
p.tables.EstMainDat=[{Code:'D',Name:'item',LinkIndex:104,GN:2,GU:20,GC:40,TC:40},{LinkIndex:54}];
|
||||
const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,empty);
|
||||
process.stdout.write(JSON.stringify({danga:j.parse('danga.txt',files.get('danga.txt')).length,
|
||||
kind:q.tables.EstMainDat[1].LinkIndex,total:q.tables.EstMainDat.at(-1).TC,
|
||||
warning:out.warnings.some(x=>x.includes('중복'))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert result == {"danga": 0, "kind": 101, "total": 40, "warning": True}
|
||||
|
||||
|
||||
def test_jdl_nested_units_preserve_detail_or_warn_on_forward_reference():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
|
||||
const blank=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
function convert(order,self=false,cycle=false){const p=structuredClone(blank);p.name='nested';
|
||||
p.tables.StuffcDat=[{Code:'M',Name:'material',TC:10}];
|
||||
const titles={A:{Code:'A',Name:'parent',GC:60,TC:60},B:{Code:'B',Name:'child',GC:20,TC:20}};
|
||||
p.tables.HeadTitleDat=order.map(code=>titles[code]);
|
||||
p.tables.HeadMainDat=[{Index:1,Code:'B'}, {Index:0,Code:cycle?'A':'M',Name:'material',LinkIndex:cycle?103:5,GN:2,GU:10,GC:20},
|
||||
{Index:1,Code:'A'}, {Index:0,Code:self?'A':'B',Name:'child',LinkIndex:103,GN:3,GU:20,GC:60}];
|
||||
p.tables.EstMainDat=[{Code:'A',Name:'parent',LinkIndex:103,GN:2,GU:60,GC:120,TC:120},{LinkIndex:54}];
|
||||
const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,structuredClone(blank));
|
||||
return {parts:j.parse('unitpart.txt',files.get('unitpart.txt')),total:q.tables.EstMainDat.at(-1).TC,
|
||||
units:j.parse('unit.txt',files.get('unit.txt')),warnings:out.warnings};}
|
||||
process.stdout.write(JSON.stringify({ordered:convert(['B','A']),forward:convert(['A','B']),
|
||||
self:convert(['A','B'],true),cycle:convert(['A','B'],false,true)}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert result["ordered"]["total"] == result["forward"]["total"] == 120
|
||||
assert result["ordered"]["parts"][-1]["F_CODE1"].startswith("U")
|
||||
assert result["forward"]["parts"][-1]["F_CODE1"].startswith("U")
|
||||
assert not any("하위 일위대가" in warning for warning in result["forward"]["warnings"])
|
||||
assert len(result["self"]["units"]) == 1
|
||||
assert result["cycle"]["total"] == 120
|
||||
assert any("하위 일위대가" in warning for warning in result["cycle"]["warnings"])
|
||||
|
||||
|
||||
def test_jdl_nested_unit_order_handles_deep_dependencies():
|
||||
script = """const {orderUnits}=require('./tools/est-test/jdl-unit-order.cjs'),n=15000;
|
||||
const titles=Array.from({length:n},(_,i)=>({Code:String(i)})),groups=new Map();
|
||||
for(let i=0;i<n-1;i++)groups.set(String(i),[{LinkIndex:103,Code:String(i+1)}]);
|
||||
const result=orderUnits(titles,groups);
|
||||
process.stdout.write(JSON.stringify([result.length,result[0].Code,result.at(-1).Code]));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT, timeout=30))
|
||||
assert result == [15000, "14999", "0"]
|
||||
|
||||
|
||||
def test_jdl_machine_saved_amount_is_editable_and_survives_recalculation():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['junit.txt',table('junit.txt',[{F_CODE:'J1',F_DES:'machine',F_MPRICE:100.25,F_LPRICE:50.12,F_EPRICE:25.03}])],
|
||||
['jpart.txt',table('jpart.txt',[{F_CODE:'J1',F_SEQ:'1',F_CODE1:'M1'}])],
|
||||
['gongjong.txt',table('gongjong.txt',[{F_CODE:'01',F_DES:'work'}])],
|
||||
['mainpart.txt',table('mainpart.txt',[{F_CODE:'01',F_SEQ:'1',F_CODE1:'J1',F_DES:'machine',F_QQTY:2,F_MPRICE:100,F_LPRICE:50,F_EPRICE:25}])]]);
|
||||
const original=a.pack(files),p=j.markOriginal(s.calculate(j.read(original,empty)));
|
||||
const same=original.equals(j.output(s.calculate(structuredClone(p))).bytes);
|
||||
p.tables.MachMainDat[1].GC=120.25;
|
||||
p.options.AutoCalc=true;p.options.SourceRefresh=true;
|
||||
const q=s.calculate(JSON.parse(JSON.stringify(p)));
|
||||
process.stdout.write(JSON.stringify({same,rows:q.tables.MachMainDat,title:q.tables.MachTitleDat[0],
|
||||
estimate:q.tables.EstMainDat[1],warnings:p.importWarnings,errors:q.calculation.errors}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script],
|
||||
input=json.dumps(project, ensure_ascii=False).encode("utf-8"),
|
||||
cwd=ROOT,
|
||||
).decode("utf-8")
|
||||
)
|
||||
assert result["same"]
|
||||
assert result["rows"][1]["CalcIndex"] == 1
|
||||
assert abs(result["rows"][1]["TC"] - 195.4) < 1e-8
|
||||
assert abs(result["title"]["TC"] - 195.4) < 1e-8
|
||||
assert result["estimate"]["TC"] == 390
|
||||
assert result["warnings"] and not result["errors"]
|
||||
|
||||
|
||||
def test_jdl_machine_simple_detail_is_editable_and_mismatches_remain_summary():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['junit.txt',table('junit.txt',[{F_CODE:'J1',F_DES:'machine',F_MPRICE:100,F_LPRICE:20,F_EPRICE:10},
|
||||
{F_CODE:'J2',F_DES:'mismatch',F_MPRICE:50},{F_CODE:'J3',F_DES:'quantity',F_MPRICE:50}])],
|
||||
['jpart.txt',table('jpart.txt',[{F_CODE:'J1',F_SEQ:'1',F_CODE1:'M1',F_DES:'fuel',F_QQTY:1,F_MPRICE:60,F_EPRICE:10},
|
||||
{F_CODE:'J1',F_SEQ:'2',F_CODE1:'L1',F_DES:'operator',F_QQTY:1,F_MPRICE:40,F_LPRICE:20},
|
||||
{F_CODE:'J2',F_SEQ:'1',F_DES:'incomplete',F_QQTY:1,F_MPRICE:10},
|
||||
{F_CODE:'J3',F_SEQ:'1',F_DES:'unknown quantity semantics',F_QQTY:2,F_MPRICE:50}])]]);
|
||||
const p=j.read(a.pack(files),empty);p.options.AutoCalc=true;p.options.SourceRefresh=true;
|
||||
p.tables.MachMainDat[1].GC=70;const q=s.calculate(p);
|
||||
process.stdout.write(JSON.stringify({rows:q.tables.MachMainDat,titles:q.tables.MachTitleDat,errors:q.calculation.errors,warnings:p.importWarnings}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script],
|
||||
input=json.dumps(project, ensure_ascii=False).encode("utf-8"),
|
||||
cwd=ROOT,
|
||||
).decode("utf-8")
|
||||
)
|
||||
assert [row["Name"] for row in result["rows"][:3]] == ["machine", "fuel", "operator"]
|
||||
assert result["titles"][0]["TC"] == 140
|
||||
assert result["titles"][1]["TC"] == 50
|
||||
assert result["titles"][2]["TC"] == 50
|
||||
assert "상세 산식 미변환" in result["rows"][-1]["Name"]
|
||||
assert result["warnings"] and not result["errors"]
|
||||
|
||||
|
||||
def test_jdl_machine_quantity_detail_uses_consistent_amounts_only():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8')),blank=structuredClone(empty);
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['junit.txt',table('junit.txt',[{F_CODE:'J1',F_DES:'machine',F_MPRICE:100,F_LPRICE:50},
|
||||
{F_CODE:'J2',F_DES:'ambiguous',F_MPRICE:50},{F_CODE:'J3',F_DES:'both match',F_MPRICE:150}])],
|
||||
['jpart.txt',table('jpart.txt',[{F_CODE:'J1',F_SEQ:'1',F_DES:'part',F_QQTY:2,F_MPRICE:50,F_LPRICE:25},
|
||||
{F_CODE:'J2',F_SEQ:'1',F_DES:'uncertain',F_QQTY:2,F_MPRICE:50},
|
||||
{F_CODE:'J3',F_SEQ:'1',F_DES:'first',F_QQTY:2,F_MPRICE:50},
|
||||
{F_CODE:'J3',F_SEQ:'2',F_DES:'second',F_QQTY:0.5,F_MPRICE:100}])],
|
||||
['gongjong.txt',table('gongjong.txt',[{F_CODE:'01',F_DES:'work'}])],
|
||||
['mainpart.txt',table('mainpart.txt',[{F_CODE:'01',F_SEQ:'1',F_CODE1:'J1',F_DES:'machine',F_QQTY:2,F_MPRICE:100,F_LPRICE:50}])]]);
|
||||
const p=j.read(a.pack(files),empty),before=structuredClone(p.tables.MachMainDat);
|
||||
p.options.AutoCalc=true;p.options.SourceRefresh=true;p.tables.MachMainDat[1].GC=110;const q=s.calculate(p);
|
||||
const warnings=p.importWarnings.slice(),out=j.output(q),back=j.read(out.bytes,blank);
|
||||
process.stdout.write(JSON.stringify({before,rows:q.tables.MachMainDat,titles:q.tables.MachTitleDat,
|
||||
saved:back.tables.MachMainDat,total:back.tables.EstMainDat.at(-1).TC,warnings,errors:q.calculation.errors}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert result["before"][1]["GC"] == 100
|
||||
assert result["before"][1]["NC"] == 50
|
||||
assert result["before"][1]["JdlSourceQuantity"] == 2
|
||||
assert result["titles"][0]["TC"] == 160
|
||||
assert result["saved"][1]["Name"] == "part"
|
||||
assert result["saved"][1]["GC"] == 110
|
||||
assert result["saved"][1]["NC"] == 50
|
||||
assert result["total"] == 320
|
||||
assert "상세 산식 미변환" in result["rows"][-3]["Name"]
|
||||
assert "상세 산식 미변환" in result["rows"][-1]["Name"]
|
||||
assert any("수량×단가" in warning for warning in result["warnings"])
|
||||
assert not result["errors"]
|
||||
|
||||
|
||||
def test_jdl_machine_export_rejects_ambiguous_or_inconsistent_detail_groups():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
|
||||
const p=JSON.parse(fs.readFileSync(0,'utf8'));p.name='machine';
|
||||
p.tables.MachTitleDat=[{Code:'J1',Name:'duplicate',GC:10},{Code:'J2',Name:'mismatch',GC:20}];
|
||||
p.tables.MachMainDat=[{Index:1,Code:'J1'},{Index:0,CalcIndex:1,JdlSummary:true,GC:10},
|
||||
{Index:1,Code:'J1'},{Index:0,CalcIndex:1,JdlSummary:true,GC:10},
|
||||
{Index:1,Code:'J2'},{Index:0,CalcIndex:1,JdlSummary:true,GC:5}];
|
||||
const out=j.output(p),files=a.unpack(out.bytes);
|
||||
process.stdout.write(JSON.stringify({units:j.parse('junit.txt',files.get('junit.txt')).length,
|
||||
parts:j.parse('jpart.txt',files.get('jpart.txt')).length}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert result == {"units": 0, "parts": 0}
|
||||
|
||||
|
||||
def test_jdl_cost_saved_amount_calculates_and_can_be_edited():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['danga.txt',table('danga.txt',[{F_CODE:'D1',F_DES:'cost',F_MPRICE:100.25,F_LPRICE:50.12,F_EPRICE:25.03,F_MEMO:'source'},{F_CODE:'D2',F_EPRICE:0.000000000001}])],
|
||||
['gongjong.txt',table('gongjong.txt',[{F_CODE:'01',F_DES:'work'}])],
|
||||
['mainpart.txt',table('mainpart.txt',[{F_CODE:'01',F_SEQ:'1',F_CODE1:'D1',F_DES:'cost',F_QQTY:2,F_MPRICE:100,F_LPRICE:50,F_EPRICE:25}])]]);
|
||||
const p=j.read(a.pack(files),empty);p.tables.CostMainDat[1].LineData='120.25 = \\x12100.25\\x12';
|
||||
p.options.AutoCalc=true;p.options.SourceRefresh=true;const q=s.calculate(JSON.parse(JSON.stringify(p)));
|
||||
process.stdout.write(JSON.stringify({title:q.tables.CostTitleDat[0],tiny:q.tables.CostTitleDat[1],estimate:q.tables.EstMainDat[1],errors:q.calculation.errors,warnings:p.importWarnings}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script],
|
||||
input=json.dumps(project, ensure_ascii=False).encode("utf-8"),
|
||||
cwd=ROOT,
|
||||
).decode("utf-8")
|
||||
)
|
||||
assert abs(result["title"]["TC"] - 195.4) < 1e-8
|
||||
assert result["tiny"]["JC"] == 0.000000000001
|
||||
assert result["estimate"]["TC"] == 390
|
||||
assert result["warnings"] and not result["errors"]
|
||||
|
||||
|
||||
def test_jdl_complete_unit_preserves_difference_from_saved_title():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['m.txt',table('m.txt',[{F_CODE:'M1',F_MPRICE:100}])],
|
||||
['unit.txt',table('unit.txt',[{F_CODE:'U1',F_MPRICE:205.25},{F_CODE:'U2',F_LPRICE:30.12}])],
|
||||
['unitpart.txt',table('unitpart.txt',[{F_CODE:'U1',F_CODE1:'M1',F_QQTY:2,F_MPRICE:100,F_MCOST:200}])]]);
|
||||
const p=j.read(a.pack(files),empty);p.options.AutoCalc=true;p.options.SourceRefresh=true;
|
||||
const q=s.calculate(p);process.stdout.write(JSON.stringify({rows:q.tables.HeadMainDat,titles:q.tables.HeadTitleDat}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script],
|
||||
input=json.dumps(project, ensure_ascii=False).encode("utf-8"),
|
||||
cwd=ROOT,
|
||||
).decode("utf-8")
|
||||
)
|
||||
assert result["rows"][2]["GC"] == 5.25
|
||||
assert [row["TC"] for row in result["titles"]] == [205.25, 30.12]
|
||||
|
||||
|
||||
def test_jdl_fixed_unit_detail_and_saved_difference_survive_edit_and_export():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8')),blank=structuredClone(empty);
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['m.txt',table('m.txt',[{F_CODE:'M1',F_MPRICE:100}])],
|
||||
['unit.txt',table('unit.txt',[{F_CODE:'U1',F_DES:'unit',F_MPRICE:205.25}])],
|
||||
['unitpart.txt',table('unitpart.txt',[{F_CODE:'U1',F_SEQ:'1',F_CODE1:'M1',F_DES:'material',F_QQTY:2,F_MPRICE:100,F_MCOST:200}])],
|
||||
['gongjong.txt',table('gongjong.txt',[{F_CODE:'01',F_DES:'work'}])],
|
||||
['mainpart.txt',table('mainpart.txt',[{F_CODE:'01',F_SEQ:'1',F_CODE1:'U1',F_DES:'unit',F_QQTY:1,F_MPRICE:205.25}])]]);
|
||||
const p=j.read(a.pack(files),empty);p.options.AutoCalc=true;p.options.SourceRefresh=true;
|
||||
p.tables.HeadMainDat[2].GC=7;const q=s.calculate(p),out=j.output(q),entries=a.unpack(out.bytes),back=j.read(out.bytes,blank);
|
||||
process.stdout.write(JSON.stringify({parts:j.parse('unitpart.txt',entries.get('unitpart.txt')),
|
||||
rows:back.tables.HeadMainDat,total:back.tables.EstMainDat.at(-1).TC,errors:q.calculation.errors}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert len(result["parts"]) == 2
|
||||
assert [row["GC"] for row in result["rows"][1:3]] == [200, 7]
|
||||
assert result["total"] == 207
|
||||
assert not result["errors"]
|
||||
|
||||
|
||||
def test_jdl_unit_inconsistent_detail_keeps_recorded_amount_without_double_counting():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['m.txt',table('m.txt',[{F_CODE:'M1',F_MPRICE:100}])],
|
||||
['unit.txt',table('unit.txt',[{F_CODE:'U1',F_MPRICE:200}])],
|
||||
['unitpart.txt',table('unitpart.txt',[{F_CODE:'U1',F_CODE1:'M1',F_QQTY:2,F_MPRICE:100,F_MCOST:0}])]]);
|
||||
const p=j.read(a.pack(files),empty);p.options.AutoCalc=true;p.options.SourceRefresh=true;
|
||||
const q=s.calculate(p);process.stdout.write(JSON.stringify({rows:q.tables.HeadMainDat,title:q.tables.HeadTitleDat[0],warnings:p.importWarnings}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script],
|
||||
input=json.dumps(project, ensure_ascii=False).encode("utf-8"),
|
||||
cwd=ROOT,
|
||||
).decode("utf-8")
|
||||
)
|
||||
assert result["rows"][1]["CalcIndex"] == 1
|
||||
assert result["rows"][2]["GC"] == 200
|
||||
assert result["title"]["TC"] == 200
|
||||
assert any("고정" in warning for warning in result["warnings"])
|
||||
|
||||
|
||||
def test_edge_jdl_machine_amount_edit():
|
||||
from playwright.sync_api import sync_playwright
|
||||
import pytest
|
||||
|
||||
script = """const a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json');
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
process.stdout.write(a.pack(new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['junit.txt',table('junit.txt',[{F_CODE:'J1',F_DES:'machine',F_MPRICE:100,F_LPRICE:50,F_EPRICE:25}])],
|
||||
['danga.txt',table('danga.txt',[{F_CODE:'D1',F_DES:'cost',F_MPRICE:100,F_LPRICE:50,F_EPRICE:25}])]])));"""
|
||||
data = subprocess.check_output(["node", "-e", script], cwd=ROOT)
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(channel="msedge", headless=True)
|
||||
page = browser.new_page()
|
||||
try:
|
||||
page.goto("http://100.65.141.15:5175", timeout=5000)
|
||||
except Exception:
|
||||
browser.close()
|
||||
pytest.skip("EST server unavailable")
|
||||
page.on("dialog", lambda dialog: dialog.accept())
|
||||
page.wait_for_function("() => Boolean(EstApp.state.project)")
|
||||
page.locator("#jdl-file").set_input_files(
|
||||
{"name": "sample.jdl", "mimeType": "application/zip", "buffer": data}
|
||||
)
|
||||
page.wait_for_function("() => EstApp.state.project.tables.MachMainDat.length === 2")
|
||||
assert page.locator("#dialog").is_visible()
|
||||
assert "상세 산식" in page.locator("#dialog-content").inner_text()
|
||||
page.locator("#dialog").evaluate("(dialog) => dialog.close()")
|
||||
page.evaluate("""() => {
|
||||
EstApp.state.project.options.AutoCalc=true;
|
||||
EstApp.navigate('MachMainDat','J1');
|
||||
}""")
|
||||
amount = page.locator('[data-table="MachMainDat"][data-row="1"][data-field="GC"]')
|
||||
amount.fill("120")
|
||||
amount.press("Tab")
|
||||
page.wait_for_function("() => EstApp.state.project.tables.MachTitleDat[0].TC === 195")
|
||||
assert page.evaluate("EstApp.state.project.tables.MachMainDat[1].TC") == 195
|
||||
page.evaluate("EstApp.navigate('CostMainDat','D1')")
|
||||
formula = page.locator('[data-table="CostMainDat"][data-row="1"][data-field="LineData"]')
|
||||
formula.fill(formula.input_value().replace("100", "130.25", 1))
|
||||
formula.press("Tab")
|
||||
page.wait_for_function("() => EstApp.state.project.tables.CostTitleDat[0].TC === 205.25")
|
||||
browser.close()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from test_est_rea_parity import run
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_jdl_special_unit_detail_with_matching_totals_is_editable():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8')),blank=structuredClone(empty);
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['m.txt',table('m.txt',[{F_CODE:'M1',F_DES:'material',F_MPRICE:100}])],
|
||||
['unit.txt',table('unit.txt',[{F_CODE:'U1',F_DES:'unit',F_MPRICE:300,F_LPRICE:50}])],
|
||||
['unitpart.txt',table('unitpart.txt',[{F_CODE:'U1',F_SEQ:'1',F_CODE1:'M1',F_DES:'normal',F_QQTY:2,F_MPRICE:100,F_MCOST:200},
|
||||
{F_CODE:'U1',F_SEQ:'2',F_CODE1:'M1',F_DES:'special',F_QQTY:1,F_MCOST:100,F_LCOST:50,F_SPROPER:'1'}])],
|
||||
['gongjong.txt',table('gongjong.txt',[{F_CODE:'01',F_DES:'work'}])],
|
||||
['mainpart.txt',table('mainpart.txt',[{F_CODE:'01',F_SEQ:'1',F_CODE1:'U1',F_DES:'unit',F_QQTY:1,F_MPRICE:300,F_LPRICE:50}])]]);
|
||||
const p=j.read(a.pack(files),empty),before=structuredClone(p.tables.HeadMainDat);p.options.AutoCalc=true;p.options.SourceRefresh=true;
|
||||
p.tables.HeadMainDat[2].GC=110;const q=s.calculate(p),out=j.output(q),back=j.read(out.bytes,blank);
|
||||
process.stdout.write(JSON.stringify({before,title:q.tables.HeadTitleDat[0],rows:back.tables.HeadMainDat,
|
||||
total:back.tables.EstMainDat.at(-1).TC,warnings:p.importWarnings,errors:q.calculation.errors}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert len(result["before"]) == 3
|
||||
assert result["before"][2]["Name"] == "special"
|
||||
assert result["before"][2]["CalcIndex"] == 1
|
||||
assert result["title"]["TC"] == 360
|
||||
assert result["rows"][2]["GC"] == 110
|
||||
assert result["rows"][2]["NC"] == 50
|
||||
assert result["total"] == 360
|
||||
assert any("특수 구성" in warning for warning in result["warnings"])
|
||||
assert not result["errors"]
|
||||
|
||||
|
||||
def test_jdl_special_unit_detail_without_source_keeps_recorded_amount():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['unit.txt',table('unit.txt',[{F_CODE:'U1',F_DES:'unit',F_MPRICE:70}])],
|
||||
['unitpart.txt',table('unitpart.txt',[{F_CODE:'U1',F_SEQ:'1',F_CODE1:'missing',F_DES:'special',F_QQTY:1,F_MCOST:70,F_SPROPER:'1'}])]]);
|
||||
const p=j.read(a.pack(files),empty);process.stdout.write(JSON.stringify({rows:p.tables.HeadMainDat,warnings:p.importWarnings}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert len(result["rows"]) == 2
|
||||
assert result["rows"][1]["GC"] == 70
|
||||
assert result["rows"][1]["LinkIndex"] == 0
|
||||
assert result["rows"][1]["JdlSourceCode"] == "missing"
|
||||
assert any("특수 구성" in warning for warning in result["warnings"])
|
||||
|
||||
|
||||
def test_jdl_special_machine_detail_is_fixed_only_when_amounts_match():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8')),blank=structuredClone(empty);
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['junit.txt',table('junit.txt',[{F_CODE:'J1',F_DES:'machine',F_MPRICE:100,F_LPRICE:20},
|
||||
{F_CODE:'J2',F_DES:'mismatch',F_MPRICE:50},{F_CODE:'J3',F_DES:'percent',F_MPRICE:50},
|
||||
{F_CODE:'J4',F_DES:'weighted',F_MPRICE:100}])],
|
||||
['jpart.txt',table('jpart.txt',[{F_CODE:'J1',F_SEQ:'1',F_DES:'normal',F_QQTY:1,F_MPRICE:60},
|
||||
{F_CODE:'J1',F_SEQ:'2',F_DES:'special',F_QQTY:1,F_MPRICE:40,F_LPRICE:20,F_SPROPER:'1'},
|
||||
{F_CODE:'J2',F_SEQ:'1',F_DES:'mismatch',F_QQTY:1,F_MPRICE:10,F_SPROPER:'1'},
|
||||
{F_CODE:'J3',F_SEQ:'1',F_DES:'percent',F_UNIT:'%',F_QQTY:1,F_MPRICE:50,F_SPROPER:'1'},
|
||||
{F_CODE:'J4',F_SEQ:'1',F_DES:'weighted part',F_QQTY:2,F_MPRICE:50,F_SPROPER:'1'}])]]);
|
||||
const p=j.read(a.pack(files),empty),before=structuredClone(p.tables.MachMainDat);p.options.AutoCalc=true;
|
||||
p.tables.MachMainDat[2].GC=50;const q=s.calculate(p),out=j.output(q),back=j.read(out.bytes,blank);
|
||||
process.stdout.write(JSON.stringify({before,titles:q.tables.MachTitleDat,back:back.tables.MachMainDat,
|
||||
warnings:p.importWarnings,errors:q.calculation.errors}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert result["before"][2]["Name"] == "special"
|
||||
assert result["before"][2]["CalcIndex"] == 1
|
||||
assert result["titles"][0]["TC"] == 130
|
||||
assert result["back"][2]["GC"] == 50
|
||||
assert result["titles"][3]["TC"] == 100
|
||||
assert "상세 산식 미변환" in result["before"][4]["Name"]
|
||||
assert "상세 산식 미변환" in result["before"][6]["Name"]
|
||||
assert any("특수 기계 상세" in warning for warning in result["warnings"])
|
||||
assert not result["errors"]
|
||||
@@ -8,6 +8,265 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_quantity_list_append_roundtrips_without_changing_indexes_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),sheet=p.quantitySheets[0];
|
||||
const before=structuredClone(sheet),codes=new Set(sheet.items.map(x=>x.Code));let n=0;
|
||||
while(codes.has('r'+String(n).padStart(6,'0')))n++;
|
||||
const item={Code:'r'+String(n).padStart(6,'0'),Name:'추가',Size:'',Unite:'m',GN:0,TargetCode:''};
|
||||
sheet.items.push(item);sheet.Name='목록 시험';const out=e.exportEstx(p),q=m.importEstx(out),a=m.unzip(bytes),b=m.unzip(out);
|
||||
const indexes=[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)));
|
||||
const added=q.quantitySheets[0].items.at(-1),sameRows=JSON.stringify(before.rows)===JSON.stringify(q.quantitySheets[0].rows);
|
||||
const existing=JSON.stringify(before.items)===JSON.stringify(q.quantitySheets[0].items.slice(0,-1));
|
||||
sheet.items.push({...item});let duplicate=false;try{e.exportEstx(p)}catch{duplicate=true}
|
||||
process.stdout.write(JSON.stringify({added,name:q.quantitySheets[0].Name,indexes,sameRows,existing,duplicate}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=100).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result["added"]["Name"] == "추가"
|
||||
assert result["name"] == "목록 시험"
|
||||
assert result["added"]["Unite"] == "m"
|
||||
assert result["added"]["GN"] == 0
|
||||
assert all(result[key] for key in ("indexes", "sameRows", "existing", "duplicate"))
|
||||
|
||||
|
||||
def test_quantity_list_append_rejects_unrepresentable_data():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs');
|
||||
const base={Code:'N1',Name:'sheet',options:{},rows:[],items:[{Code:'r000001',GN:0}]};
|
||||
const bad=[{Code:'r000001'},{Code:'r000002',TargetCode:'E1'},
|
||||
{Code:'r000002',Unite:'123456789'},{Code:'r000002',GN:'1'},
|
||||
{Code:'r000002',Unknown:'lost'}];
|
||||
const rejects=bad.map(item=>{const next=structuredClone(base);next.items.push(item);
|
||||
try{w.editDocument('',base,next);return false}catch{return true}});
|
||||
process.stdout.write(JSON.stringify(rejects));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
assert all(result)
|
||||
|
||||
|
||||
def test_quantity_list_remove_unreferenced_item_roundtrips_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes);
|
||||
const sheet=p.quantitySheets.find(s=>s.items.some(item=>!item.TargetCode&&!item.LinkCode&&!item.LinkIndex&&
|
||||
!s.rows.some(r=>r.markers.includes(item.Code)||r.LineData.includes(item.Code))));
|
||||
const old=structuredClone(sheet),i=sheet.items.findIndex(item=>!item.TargetCode&&!item.LinkCode&&!item.LinkIndex&&
|
||||
!sheet.rows.some(r=>r.markers.includes(item.Code)||r.LineData.includes(item.Code)));
|
||||
const removed=sheet.items.splice(i,1)[0],out=e.exportEstx(p),q=m.importEstx(out);
|
||||
const reread=q.quantitySheets.find(s=>s.Code===sheet.Code),expected=old.items.filter(x=>x.Code!==removed.Code);
|
||||
const a=m.unzip(bytes),b=m.unzip(out);
|
||||
process.stdout.write(JSON.stringify({items:JSON.stringify(reread.items)===JSON.stringify(expected),
|
||||
rows:JSON.stringify(reread.rows)===JSON.stringify(old.rows),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=120).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert all(result.values())
|
||||
|
||||
|
||||
def test_quantity_list_remove_referenced_item_is_rejected():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs');
|
||||
const old={Code:'N1',Name:'sheet',options:{},rows:[{LineData:'Q',markers:['r000001']}],items:[{Code:'r000001'}]};
|
||||
const next=structuredClone(old);next.items=[];
|
||||
try{w.editDocument('',old,next);process.stdout.write('false')}catch(e){process.stdout.write(JSON.stringify(e.message.includes('참조')))}"""
|
||||
assert json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
|
||||
|
||||
def test_quantity_list_remove_rejects_hidden_native_reference():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs');
|
||||
const sheet={Code:'N1',Name:'sheet',options:{},rows:[],items:[{Code:'r000001'}]};
|
||||
const row={Code:'N1',Editor:{binary:'AA=='},MulList:{binary:'AA=='}};
|
||||
const target={MulCode:{binary:Buffer.from('N1\tr000001').toString('base64')}};
|
||||
const base={quantitySheets:[sheet],tables:{MulMainDat:[row],EstMainDat:[target]}};
|
||||
const project=structuredClone(base);project.quantitySheets[0].items=[];
|
||||
const rejected=()=>{try{w.updateQuantities(project,base,()=>{},()=>{});return false}
|
||||
catch(e){return e.message.includes('내역에서 참조')}};
|
||||
const original=rejected();base.tables.EstMainDat=[];const changed=rejected();
|
||||
process.stdout.write(JSON.stringify({original,changed}));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
assert result == {"original": True, "changed": True}
|
||||
|
||||
|
||||
def test_quantity_list_existing_missing_codes_can_still_edit_names_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
|
||||
const p=m.importEstx(fs.readFileSync(process.argv[1]));
|
||||
const sheet=p.quantitySheets.find(s=>s.items.some(x=>!x.Code));
|
||||
if(!sheet){process.stdout.write('null');process.exit()}
|
||||
sheet.items[0].Name='수정';const q=m.importEstx(e.exportEstx(p));
|
||||
process.stdout.write(JSON.stringify(q.quantitySheets.find(s=>s.Code===sheet.Code).items[0].Name));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=90).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result == "수정"
|
||||
|
||||
|
||||
def test_quantity_sheet_name_roundtrips_without_modifying_document_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),sheet=p.quantitySheets[0];
|
||||
const before=structuredClone(p.tables.MulMainDat.find(row=>row.Code===sheet.Code));
|
||||
sheet.Name='수량산출서 시험';const out=e.exportEstx(p),q=m.importEstx(out),a=m.unzip(bytes),b=m.unzip(out);
|
||||
const after=q.tables.MulMainDat.find(row=>row.Code===sheet.Code);
|
||||
process.stdout.write(JSON.stringify({name:q.quantitySheets[0].Name,unchanged:JSON.stringify(before)===JSON.stringify(after),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=90).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result == {"name": "수량산출서 시험", "unchanged": True, "indexes": True}
|
||||
|
||||
|
||||
def test_quantity_sheet_name_rejects_conflicting_title_changes():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs');
|
||||
const sheet={Code:'N1',Name:'old',options:{},rows:[],items:[]};
|
||||
const row={Code:'N1',Editor:{binary:'AA=='},MulList:{binary:'AA=='}};
|
||||
const base={quantitySheets:[sheet],tables:{MulMainDat:[row],MulTitleDat:[{Code:'N1',Name:'old'}],EstMainDat:[]}};
|
||||
const project=structuredClone(base);project.quantitySheets[0].Name='new';project.tables.MulTitleDat[0].Name='other';
|
||||
try{w.updateQuantities(project,base,()=>{},()=>{});process.stdout.write('false')}
|
||||
catch(e){process.stdout.write(JSON.stringify(e.message.includes('제목 데이터 충돌')))}"""
|
||||
assert json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
|
||||
|
||||
def test_quantity_list_add_to_empty_original_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),sheet=p.quantitySheets.find(s=>!s.items.length);
|
||||
const before=structuredClone(sheet.rows);sheet.items.push({Code:'r000000',Name:'새 항목',Size:'',Unite:'m',GN:0,TargetCode:''});
|
||||
const out=e.exportEstx(p),q=m.importEstx(out),saved=q.quantitySheets.find(s=>s.Code===sheet.Code),a=m.unzip(bytes),b=m.unzip(out);
|
||||
process.stdout.write(JSON.stringify({name:saved.items[0].Name,unit:saved.items[0].Unite,rows:JSON.stringify(before)===JSON.stringify(saved.rows),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=90).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result == {"name": "새 항목", "unit": "m", "rows": True, "indexes": True}
|
||||
|
||||
|
||||
def test_quantity_append_calculated_row_roundtrips_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'),c=require('./tools/est-test/quantity-calculate.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes);
|
||||
const sheet=p.quantitySheets.find(s=>{const x=c.calculateQuantity(s),n=s.rows.reduce((a,r)=>a+r.markers.length,0);
|
||||
return s.items.length&&x.errors.length===0&&x.checks.length===n&&
|
||||
x.checks.every(v=>Number.isFinite(v.stored)&&v.stored===v.calculated)&&x.items.every((v,i)=>v.GN===s.items[i].GN)});
|
||||
const before=structuredClone(sheet),codes=new Set(sheet.items.map(x=>x.Code));let n=0;
|
||||
while(codes.has('r'+String(n).padStart(6,'0')))n++;
|
||||
const code='r'+String(n).padStart(6,'0');sheet.items.push({Code:code,Name:'new',Size:'',Unite:'',GN:0,TargetCode:''});
|
||||
sheet.rows.push({LineData:'2+3 = \x120\x12',markers:[code]});
|
||||
const out=e.exportEstx(p),q=m.importEstx(out),saved=q.quantitySheets.find(s=>s.Code===sheet.Code),a=m.unzip(bytes),b=m.unzip(out);
|
||||
process.stdout.write(JSON.stringify({value:saved.items.at(-1).GN,line:saved.rows.at(-1),
|
||||
rows:JSON.stringify(before.rows)===JSON.stringify(saved.rows.slice(0,-1)),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=90).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result["value"] == 5
|
||||
assert result["line"]["LineData"] == "2+3 = \x125\x12"
|
||||
assert result["rows"] and result["indexes"]
|
||||
|
||||
|
||||
def test_quantity_append_row_rejects_duplicate_and_unrepresentable_markers():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs');
|
||||
const doc='<cs nr=1/><div><begin Tag=1 style="Name:'r000001';"/>1<end Tag=1/></div>';
|
||||
const sheet={items:[{Code:'r000001'},{Code:'r000002'}]};
|
||||
const bad=[{LineData:'1=\x120\x12',markers:['r000001']},
|
||||
{LineData:'1=\x120\x12',markers:['r000003']},
|
||||
{LineData:'1=\x120\x12\x121\x12',markers:['r000002']},
|
||||
{LineData:'^(2)=\x120\x12',markers:['r000002']}];
|
||||
process.stdout.write(JSON.stringify(bad.map(row=>{try{w.appendRows(doc,sheet,[row]);return false}catch{return true}})));"""
|
||||
assert all(json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT)))
|
||||
|
||||
|
||||
def test_quantity_insert_calculated_row_in_middle_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'),c=require('./tools/est-test/quantity-calculate.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes);
|
||||
const sheet=p.quantitySheets.find(s=>{const x=c.calculateQuantity(s),n=s.rows.reduce((a,r)=>a+r.markers.length,0);
|
||||
return s.rows.length>3&&s.items.length&&x.errors.length===0&&x.checks.length===n&&
|
||||
x.checks.every(v=>Number.isFinite(v.stored)&&v.stored===v.calculated)&&x.items.every((v,i)=>v.GN===s.items[i].GN)});
|
||||
const before=structuredClone(sheet),codes=new Set(sheet.items.map(x=>x.Code));let n=0;
|
||||
while(codes.has('r'+String(n).padStart(6,'0')))n++;
|
||||
const code='r'+String(n).padStart(6,'0');sheet.items.push({Code:code,Name:'new',Size:'',Unite:'',GN:0,TargetCode:''});
|
||||
sheet.rows.splice(2,0,{LineData:'2+3 = \x120\x12',markers:[code]});
|
||||
const out=e.exportEstx(p),q=m.importEstx(out),saved=q.quantitySheets.find(s=>s.Code===sheet.Code),a=m.unzip(bytes),b=m.unzip(out);
|
||||
const rows=saved.rows.slice();const added=rows.splice(2,1)[0];
|
||||
process.stdout.write(JSON.stringify({value:saved.items.at(-1).GN,line:added.LineData,
|
||||
rows:JSON.stringify(before.rows)===JSON.stringify(rows),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=90).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result == {"value": 5, "line": "2+3 = \x125\x12", "rows": True, "indexes": True}
|
||||
|
||||
|
||||
def test_quantity_insert_rejects_position_before_default_style():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs');
|
||||
const doc='<div><cs nr=1/>original</div>',sheet={items:[{Code:'r000001'}]};
|
||||
const row={LineData:'1=\x120\x12',markers:['r000001']};
|
||||
try{w.insertRows(doc,sheet,[{row,at:0}]);process.stdout.write('false')}
|
||||
catch(e){process.stdout.write(JSON.stringify(e.message.includes('기본 글자 서식')))}"""
|
||||
assert json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
|
||||
|
||||
def test_quantity_delete_calculated_row_roundtrips_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),sheet=p.quantitySheets.find(s=>s.Code==='N0000000092');
|
||||
const old=structuredClone(sheet),index=sheet.rows.findIndex(row=>row.markers.includes('r000007'));
|
||||
sheet.rows.splice(index,1);sheet.items=sheet.items.filter(item=>item.Code!=='r000007');
|
||||
const out=e.exportEstx(p),q=m.importEstx(out),saved=q.quantitySheets.find(s=>s.Code===sheet.Code),a=m.unzip(bytes),b=m.unzip(out);
|
||||
old.rows.splice(index,1);old.items=old.items.filter(item=>item.Code!=='r000007');
|
||||
process.stdout.write(JSON.stringify({rows:JSON.stringify(saved.rows)===JSON.stringify(old.rows),
|
||||
items:JSON.stringify(saved.items)===JSON.stringify(old.items),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=90).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result == {"rows": True, "items": True, "indexes": True}
|
||||
|
||||
|
||||
def test_quantity_delete_preserves_styles_and_rejects_shared_tag():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs'),q=require('./tools/est-test/native-quantity.cjs');
|
||||
const doc='<cs nr=1/><div><cs nr=2/><begin Tag=1 style="Name:'r000001';"/>1<end Tag=1/></div>';
|
||||
const sheet=q.fromWpt('N1','sheet',doc,[{Code:'r000001'}],{}),codes=new Set(['r000001']);
|
||||
const result=w.removeRows(doc,sheet,codes);let rejected=false;
|
||||
try{w.removeRows(doc+'<end Tag=1/>',sheet,codes)}catch{rejected=true}
|
||||
process.stdout.write(JSON.stringify({style:result.includes('<cs nr=2/>'),removed:!result.includes('<begin'),rejected}));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
assert result == {"style": True, "removed": True, "rejected": True}
|
||||
|
||||
|
||||
def test_existing_quantity_links_respect_native_mode_and_avoid_ambiguous_targets():
|
||||
script = """const {existingTargets}=require('./tools/est-test/native-links.cjs');
|
||||
const sheet={Code:'N1',items:[{Code:'r1'},{Code:'r2'},{Code:'r3'},{Code:'r4'}]};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_estx_rejects_corrupted_last_engine_signature_byte_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),a=require('./tools/est-test/archive.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),files=m.unzip(bytes),table=files.get('estmaindat.dat');
|
||||
const valid=m.importEstx(bytes).tables.EstMainDat.length;table[24]^=1;let rejected=false;
|
||||
try{m.importEstx(a.pack(files))}catch(e){rejected=e.message.includes('DBISAM')}
|
||||
table[24]^=1;files.get('stuffcdat.dat')[9]^=1;let missingRejected=false;
|
||||
try{m.importEstx(a.pack(files))}catch(e){missingRejected=e.message.includes('stuffcdat.dat')}
|
||||
process.stdout.write(JSON.stringify({valid:valid>0,rejected,missingRejected}));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT))
|
||||
assert result == {"valid": True, "rejected": True, "missingRejected": True}
|
||||
@@ -0,0 +1,111 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_reorder_preserves_marker_structure_and_rejects_late_styles():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs'),q=require('./tools/est-test/native-quantity.cjs');
|
||||
const div=(tag,code,value)=>'<div cs=1><c nr=1/><begin Tag='+tag+' style="Name:''+code+'';"/>'+value+'<end Tag='+tag+'/></div>';
|
||||
const doc='<cs nr=1/>'+div(1,'r000001','1')+div(2,'r000002','2');
|
||||
const sheet=q.fromWpt('N','sheet',doc,[],{}),rows=[sheet.rows[1],sheet.rows[0]],out=w.reorderRows(doc,sheet,rows);
|
||||
const parsed=q.fromWpt('N','sheet',out.text,[],{});let rejected=false;
|
||||
const late='<cs nr=1/>'+div(1,'r000001','1')+'<cs nr=2/><div cs=2>2</div>';
|
||||
const other=q.fromWpt('N','sheet',late,[],{});
|
||||
try{w.reorderRows(late,other,[other.rows[1],other.rows[0]])}catch{rejected=true}
|
||||
const redefined='<cs nr=1/>'+div(1,'r000001','1')+'<cs nr=1/>'+div(2,'r000002','2');
|
||||
const third=q.fromWpt('N','sheet',redefined,[],{});let changed=false;
|
||||
try{w.reorderRows(redefined,third,[third.rows[1],third.rows[0]])}catch{changed=true}
|
||||
process.stdout.write(JSON.stringify({rows:JSON.stringify(parsed.rows)===JSON.stringify(rows),rejected,changed}));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
assert result == {"rows": True, "rejected": True, "changed": True}
|
||||
|
||||
|
||||
def test_append_handles_many_existing_tag_numbers():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs');
|
||||
const doc='<cs nr=1/>'+Array.from({length:150000},(_,i)=>'<end Tag='+i+'/>').join('');
|
||||
const row={LineData:'1=\x120\x12',markers:['r000001']};
|
||||
const out=w.appendRows(doc,{items:[{Code:'r000001'}]},[row]);
|
||||
process.stdout.write(JSON.stringify(out.includes('<begin Tag=150000 ')));"""
|
||||
assert json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT, timeout=15))
|
||||
|
||||
|
||||
def test_move_checks_shared_tags_without_confusing_similar_numbers():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs'),q=require('./tools/est-test/native-quantity.cjs');
|
||||
const div=(tag,code)=>'<div cs=1><c nr=1/><begin Tag='+tag+' style="Name:''+code+'';"/>1<end Tag='+tag+'/></div>';
|
||||
const noise=Array.from({length:10000},(_,i)=>'<end Tag='+(i+100)+'/>').join('');
|
||||
const doc='<cs nr=1/>'+noise+div(1,'r000001')+div(10,'r000002');
|
||||
const sheet=q.fromWpt('N','sheet',doc,[],{}),rows=[sheet.rows[1],sheet.rows[0]];
|
||||
const output=w.reorderRows(doc,sheet,rows);let rejected=false;
|
||||
try{w.reorderRows(doc+'<end Tag=1/>',sheet,rows)}catch{rejected=true}
|
||||
process.stdout.write(JSON.stringify({rows:JSON.stringify(q.fromWpt('N','sheet',output.text,[],{}).rows)===JSON.stringify(rows),rejected}));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT, timeout=30))
|
||||
assert result == {"rows": True, "rejected": True}
|
||||
|
||||
|
||||
def test_row_parser_preserves_global_and_local_tags_with_many_rows():
|
||||
script = r"""const q=require('./tools/est-test/native-quantity.cjs'),{rowParser}=require('./tools/est-test/wpt-parse.cjs');
|
||||
const one=(tag,code)=>'<div><c nr=2/><begin Tag='+tag+' style="Name:''+code+'';"/>1<end Tag='+tag+'/></div>';
|
||||
const styles=Array.from({length:3000},(_,i)=>'<cs nr='+(i+2)+' CharStyleON:0/>').join('');
|
||||
const doc='<cs nr=2 CharStyleON:16/>'+styles+Array.from({length:3000},(_,i)=>one(i,'r'+String(i).padStart(6,'0'))).join('')+
|
||||
'<div><end Tag=1/>2</div>'+one(1,'r009999');
|
||||
const declarations=[...doc.matchAll(/<(?:cs|begin)\b[^>]*>/gi)].map(m=>m[0]).join('');
|
||||
const divs=[...doc.matchAll(/<div\b[^>]*>[\s\S]*?<\/div>/gi)].map(m=>m[0]),parse=rowParser(doc);
|
||||
const samples=[divs[0],divs[1],divs[100],divs.at(-2),divs.at(-1)];
|
||||
const same=samples.every(div=>JSON.stringify(parse(div))===JSON.stringify(q.fromWpt('','',declarations+div,[],{}).rows));
|
||||
const count=divs.reduce((n,div)=>n+parse(div).length,0);
|
||||
process.stdout.write(JSON.stringify({same,count}));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT, timeout=30))
|
||||
assert result == {"same": True, "count": 3002}
|
||||
|
||||
|
||||
def test_move_and_edit_keeps_original_row_marker():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs'),q=require('./tools/est-test/native-quantity.cjs');
|
||||
const div=(tag,code,value)=>'<div cs=1><c nr=1/>Q='+value+'=<begin Tag='+tag+' style="Name:''+code+'';"/>'+value+'<end Tag='+tag+'/></div>';
|
||||
const doc='<cs nr=1/>'+div(1,'r000001','1')+div(2,'r000002','2');
|
||||
const sheet=q.fromWpt('N','sheet',doc,[],{}),rows=structuredClone([sheet.rows[1],sheet.rows[0]]);
|
||||
rows[0].LineData=rows[0].LineData.replace('Q=2','Q=3');
|
||||
const moved=w.reorderRows(doc,sheet,rows),old={...sheet,rows:moved.rows},edited={...sheet,rows};
|
||||
const result=w.editDocument(moved.text,old,edited),parsed=q.fromWpt('N','sheet',result,[],{});
|
||||
process.stdout.write(JSON.stringify(parsed.rows));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
assert result[0]["markers"] == ["r000002"]
|
||||
assert result[0]["LineData"].startswith("Q=3")
|
||||
assert result[1]["markers"] == ["r000001"]
|
||||
|
||||
|
||||
def test_move_quantity_rows_in_installed_file_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),z=require('zlib'),m=require('./tools/est-test/import-estx.cjs'),
|
||||
e=require('./tools/est-test/export-estx.cjs'),w=require('./tools/est-test/export-quantity.cjs'),c=require('./tools/est-test/quantity-calculate.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes);let candidate=null;
|
||||
const valid=s=>{const x=c.calculateQuantity(s),n=s.rows.reduce((a,r)=>a+r.markers.length,0);
|
||||
return !x.errors.length&&x.checks.length===n&&x.checks.every(v=>Number.isFinite(v.stored)&&v.stored===v.calculated)&&x.items.every((v,i)=>v.GN===s.items[i].GN)};
|
||||
for(const s of p.quantitySheets){if(!valid(s))continue;const r=p.tables.MulMainDat.find(x=>x.Code===s.Code);
|
||||
let b=Buffer.from(r.Editor.binary,'base64');if(b[0]===120)b=z.inflateSync(b);const doc=new TextDecoder('euc-kr').decode(b);
|
||||
for(let i=1;i<s.rows.length;i++){if(s.rows[i-1].markers.length!==1||s.rows[i].markers.length!==1)continue;
|
||||
const rows=s.rows.slice();[rows[i-1],rows[i]]=[rows[i],rows[i-1]];
|
||||
if(JSON.stringify(rows)===JSON.stringify(s.rows))continue;
|
||||
rows[i-1]={...rows[i-1],LineData:' '+rows[i-1].LineData};
|
||||
try{const result=w.reorderRows(doc,s,rows);if(result.text===doc||!valid({...s,rows}))continue;
|
||||
w.editDocument(result.text,{...s,rows:result.rows},{...s,rows});
|
||||
candidate={sheet:s,rows};break}catch{}}
|
||||
if(candidate)break;
|
||||
}
|
||||
if(!candidate)throw Error('no safe move fixture');
|
||||
const s=candidate.sheet,before=structuredClone(s);s.rows=candidate.rows;
|
||||
const out=e.exportEstx(p),q=m.importEstx(out),saved=q.quantitySheets.find(x=>x.Code===s.Code),a=m.unzip(bytes),b=m.unzip(out);
|
||||
process.stdout.write(JSON.stringify({rows:JSON.stringify(saved.rows)===JSON.stringify(s.rows),
|
||||
items:JSON.stringify(saved.items)===JSON.stringify(before.items),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=90).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result == {"rows": True, "items": True, "indexes": True}
|
||||
@@ -0,0 +1,68 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_delete_plain_row_rejects_document_bookmark_and_preserves_styles():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs'),q=require('./tools/est-test/native-quantity.cjs');
|
||||
const doc='<cs nr=1/><div><cs nr=2/>plain</div>',sheet=q.fromWpt('N','sheet',doc,[],{});
|
||||
const out=w.removeRows(doc,sheet,new Set(),new Set([0]));
|
||||
const marked='<cs nr=1/><div><begin Tag=1 style="Name:'T1';"/>title<end Tag=1/></div>';
|
||||
const other=q.fromWpt('N','sheet',marked,[],{});let rejected=false;
|
||||
try{w.removeRows(marked,other,new Set(),new Set([0]))}catch{rejected=true}
|
||||
process.stdout.write(JSON.stringify({style:out.includes('<cs nr=2/>'),removed:!out.includes('plain'),rejected}));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
assert result == {"style": True, "removed": True, "rejected": True}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("edit", [False, True])
|
||||
def test_delete_plain_row_in_installed_file_when_sample_exists(edit):
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),z=require('zlib'),m=require('./tools/est-test/import-estx.cjs'),
|
||||
e=require('./tools/est-test/export-estx.cjs'),w=require('./tools/est-test/export-quantity.cjs'),c=require('./tools/est-test/quantity-calculate.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes);let candidate=null;
|
||||
const valid=s=>{const x=c.calculateQuantity(s),n=s.rows.reduce((a,r)=>a+r.markers.length,0);
|
||||
return !x.errors.length&&x.checks.length===n&&x.checks.every(v=>Number.isFinite(v.stored)&&v.stored===v.calculated)&&x.items.every((v,i)=>v.GN===s.items[i].GN)};
|
||||
for(const s of p.quantitySheets){if(!valid(s))continue;const r=p.tables.MulMainDat.find(x=>x.Code===s.Code);
|
||||
let b=Buffer.from(r.Editor.binary,'base64');if(b[0]===120)b=z.inflateSync(b);const doc=new TextDecoder('euc-kr').decode(b);
|
||||
for(let i=0;i<s.rows.length;i++){const row=s.rows[i];if(row.markers.length||s.rows.filter(x=>JSON.stringify(x)===JSON.stringify(row)).length!==1)continue;
|
||||
const rows=s.rows.filter((_,j)=>j!==i);
|
||||
if(process.argv[2]==='edit'){
|
||||
const j=rows.findIndex(row=>row.markers.length===1&&rows.filter(x=>JSON.stringify(x.markers)===JSON.stringify(row.markers)).length===1);
|
||||
if(j<0)continue;rows[j]={...rows[j],LineData:' '+rows[j].LineData};
|
||||
}
|
||||
try{const source=w.removeRows(doc,s,new Set(),new Set([i]));if(!valid({...s,rows}))continue;
|
||||
w.editDocument(source,{...s,rows:s.rows.filter((_,j)=>j!==i)},{...s,rows});
|
||||
candidate={sheet:s,rows};break}catch{}}
|
||||
if(candidate)break;
|
||||
}
|
||||
if(!candidate)throw Error('no safe plain-row fixture');
|
||||
const s=candidate.sheet,before=structuredClone(s);s.rows=candidate.rows;
|
||||
const out=e.exportEstx(p),q=m.importEstx(out),saved=q.quantitySheets.find(x=>x.Code===s.Code),a=m.unzip(bytes),b=m.unzip(out);
|
||||
process.stdout.write(JSON.stringify({rows:JSON.stringify(saved.rows)===JSON.stringify(s.rows),
|
||||
items:JSON.stringify(saved.items)===JSON.stringify(before.items),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script, str(sample), "edit" if edit else "plain"], cwd=ROOT, timeout=90
|
||||
).decode("utf-8")
|
||||
)
|
||||
assert result == {"rows": True, "items": True, "indexes": True}
|
||||
|
||||
|
||||
def test_delete_duplicate_plain_row_is_rejected_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
|
||||
const p=m.importEstx(fs.readFileSync(process.argv[1])),sheet=p.quantitySheets.find(s=>s.Code==='N0000000071');
|
||||
const index=sheet.rows.findIndex((r,i)=>!r.markers.length&&sheet.rows.some((x,j)=>i!==j&&JSON.stringify(x)===JSON.stringify(r)));
|
||||
if(index<0)throw Error('duplicate fixture unavailable');sheet.rows.splice(index,1);
|
||||
try{e.exportEstx(p);process.stdout.write('false')}catch(error){process.stdout.write(JSON.stringify(error.message.includes('중복')))}"""
|
||||
assert json.loads(subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT))
|
||||
@@ -249,6 +249,25 @@ def test_insert_and_move_quantity_rows_keeps_item_results_linked():
|
||||
assert result["forward"]["errors"]
|
||||
|
||||
|
||||
def test_new_quantity_codes_avoid_items_and_existing_markers():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const handlers={};globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn};
|
||||
const sheet={Code:'N1',Name:'test',options:{},items:[{Code:'r000000'}],
|
||||
rows:[{LineData:'1 = \\x120\\x12',markers:['r000001']}]};
|
||||
const state={quantitySheet:'N1',project:{quantitySheets:[sheet],tables:{EstMainDat:[]}}};
|
||||
globalThis.EstApp={state,change:fn=>fn(),render:()=>{}};EstQuantity.install();
|
||||
const click=selector=>handlers.click({target:{closest:s=>s===selector?{dataset:{}}:null}});
|
||||
click('[data-quantity-add]');click('[data-quantity-add-item]');
|
||||
const code=sheet.rows[1].markers[0];
|
||||
const doc='<div><begin Tag=10 style="Name:''+code+'';"/>1<end Tag=10/></div>';
|
||||
return {codes:sheet.items.map(x=>x.Code),parsed:EstQuantity.fromWpt('N1','test',doc,[],{}).rows[0].markers};
|
||||
})()""",
|
||||
)
|
||||
assert result == {"codes": ["r000000", "r000002", "r000003"], "parsed": ["r000002"]}
|
||||
|
||||
|
||||
def test_manual_and_selected_sheet_totals_validate_before_application():
|
||||
result = run(
|
||||
"math",
|
||||
|
||||
@@ -651,3 +651,29 @@ def test_text_total_without_result_marker_evaluates_its_expression():
|
||||
assert result["rows"][0]["TC"] == 20
|
||||
assert result["rows"][1]["TC"] == 20
|
||||
assert result["rows"][2]["TC"] == 0
|
||||
|
||||
|
||||
def test_numbered_speed_variable_attached_to_korean_label():
|
||||
result = run(
|
||||
"math",
|
||||
r"""EstText.calculate([
|
||||
{Index:1,Code:'C'},
|
||||
{LineData:' 주유소V41=25 입구 V51=12'},
|
||||
{LineData:' Q = V41 + V51 = \x1237\x12'}
|
||||
])""",
|
||||
)
|
||||
assert result["errors"] == []
|
||||
assert result["rows"][2]["TC"] == 37
|
||||
|
||||
|
||||
def test_invalid_unmarked_component_does_not_reuse_previous_declaration():
|
||||
result = run(
|
||||
"math",
|
||||
r"""EstText.calculate([
|
||||
{Index:1,Code:'C'},
|
||||
{SCheck:-3,LineData:' C = \x03100\x03'},
|
||||
{SCheck:-3,LineData:' C = \x03200\x03 + MISSING'}
|
||||
])""",
|
||||
)
|
||||
assert result["errors"]
|
||||
assert result["rows"][2].get("TC") != 100
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user