Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1

This commit is contained in:
2026-11-12 14:45:44 +09:00
36 changed files with 1476 additions and 988 deletions
+12 -1
View File
@@ -50,6 +50,9 @@ def pack(got: dict, name: str) -> dict[str, Any]:
"""파서 답 → 둘 꼴(셈 · 변환에 쓰는 칸만 · JSON)."""
out: dict[str, Any] = {"꼴": got["꼴"], "파일": name, "표지": got.get("표지") or []}
out |= {"엑셀": True} if got.get("엑셀") else {} # 812-1 엑셀 읽기기(`M02_San_Xlsx`) — 같은 꼴
out |= (
{"기계구성": got["기계구성"]} if got.get("기계구성") else {}
) # 821-4 EST 원파일 기계 구성
head = ("코드", "번", "이름", "규격", "단위", "저장")
if got.get(
"엑셀"
@@ -104,7 +107,15 @@ def numbers(saved: dict) -> dict[int, dict]:
def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]:
"""원파일 산근 블록 하나 → `{로직, 원본}`(원본 `원파일` True · 코드 · 확정 수) · 없는 번 = KeyError."""
"""원파일 산근 블록 하나 → `{로직, 원본}`(원본 `원파일` True · 코드 · 확정 수) · 없는 번 = KeyError ·
기계 찾기 = M01 만(821-4 — 못 이은 기계 → 내 기계는 `M02_San_MyMachine.attach`)."""
from M02_MasterTemplete.M02_Machine_Find import m01_only
with m01_only():
return _emit(saved, no, keys)
def _emit(saved: dict, no: int, keys: bool) -> dict[str, Any]:
blk = numbers(saved)[int(no)]
if saved["꼴"] == "EST":
names = {b["코드"]: n for n, b in numbers(saved).items()}
+2
View File
@@ -325,6 +325,8 @@ def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None:
if not item.get("참조") and var["연결"].get("값") != excel:
continue # SK 덮개 단가 = 사용자 값
link, note = _link_of(item)
if mine := (item.get("자원") or {}).get("내기계"): # 821-4 꼬리표 「내 기계」
note = f"내 기계 {mine}" + ("" if link else " · 시간당 값 직접 — 엑셀 단가 그대로")
if note:
var["설명"] = note
if link is None:
+19
View File
@@ -19,6 +19,9 @@ import math
import re
import time
import unicodedata
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from functools import lru_cache
from typing import Any
@@ -150,10 +153,26 @@ def _table(whole: Any) -> tuple[list[Row], frozenset[str], dict[str, Row]]:
_SEEN: list = [0.0, None, None] # (때, 표, 층) — M01 · 내 기계 판 확인은 층마다 1 초에 한 번
_M01_ONLY: ContextVar[bool] = ContextVar("m01_only", default=False)
@contextmanager
def m01_only() -> Iterator[None]:
"""이 안의 찾기 = M01 기계만(821-4 불러오기 — 내 기계 잇기는 `M02_San_MyMachine.attach` 한 곳)."""
token = _M01_ONLY.set(True)
try:
yield
finally:
_M01_ONLY.reset(token)
def table() -> tuple[list[Row], frozenset[str], dict[str, Row]]:
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
if _M01_ONLY.get():
return _table(m01.loaded()[1])
now, here = time.monotonic(), store.root()
if _SEEN[1] is None or now - _SEEN[0] > 1.0 or _SEEN[2] != here:
_SEEN[:] = [now, _table(mym.whole()), here]
@@ -5,6 +5,7 @@
* 바꾸기 전후 금액 · [원본 글 보기]
* · 줄 2 = 이음 없는 자원 = 표지 + [이음 고르기]
* · 줄 2-2 = 소요량 · 계수 이음(797-2 · 후보 집계 + [이음 보기] → `Hopyo_QtyLinks` 모달)
* · 줄 2-3 = 못 이은 기계 → 내 기계(821-4) — 「내 기계 생성 n」 · 줄마다 「내 기계」 꼬리표 · 개인 층 밖 = 「만들어질 내 기계 n」
* · 경쟁사 로직 그대로 — 기초로 바꾸기 · 근거 기초 제안 없음(809-1 · 요구 703)
* 부품 = 단추 · 배지 · 글 · 입력 · 띠 · 표 · 모달(날 단추 · 입력 없음) · 서버 길은 부르는 쪽.
* ========================================================================== */
@@ -47,6 +48,12 @@ const TEXT = {
Gap: ["바꾸기 전 {a} → 후 {b} ({d})", "Before {a} → after {b} ({d})"],
QtyLinks: ["소요량 · 계수 이음 {n}", "Quantity / coefficient links {n}"],
QtyOpen: ["이음 보기", "Show links"],
MyMade: ["내 기계 생성 {n}", "My machines created {n}"],
MyUsed: ["내 기계 이음 {n}", "Linked to my machines {n}"],
MyPlan: ["만들어질 내 기계 {n}", "My machines to create {n}"],
MyTag: ["내 기계", "My machine"],
MyNew: ["이번에 만듦 · 값 확인 필요", "Created now · check values"],
MyOld: ["있던 내 기계에 이음", "Linked to an existing one"],
Close: ["닫기", "Close"],
} as const satisfies Record<string, readonly [string, string]>;
export const tx = (k: keyof typeof TEXT): string =>
@@ -116,6 +123,34 @@ function checkBadge(doc: ImportedDoc): HTMLElement {
return b;
}
/** 못 이은 기계 → 내 기계(821-4) — 생성 수 · 이은 수 · 줄마다 꼬리표 · 예정 수 */
function myMachines(src: NonNullable<ImportedDoc["가져온"]>["원본"]): HTMLElement | null {
const got = src?.내기계 ?? [];
const plan = src?.내기계예정 ?? [];
if (!got.length && !plan.length) return null;
const made = got.filter((m) => m.새로).length;
const items: HTMLElement[] = [];
if (made) items.push(createBadge(tx("MyMade").replace("{n}", String(made)), "info"));
if (got.length > made) items.push(createBadge(tx("MyUsed").replace("{n}", String(got.length - made)), "neutral")); // prettier-ignore
for (const m of got) {
const b = createBadge(
`${tx("MyTag")} · ${[m.이름, m.규격].filter(Boolean).join(" ")}`,
"accent",
);
b.title = `${m.키} · ${m.새로 ? tx("MyNew") : tx("MyOld")}`;
b.dataset.importedMine = m.키;
items.push(b);
}
if (plan.length) {
const b = createBadge(tx("MyPlan").replace("{n}", String(plan.length)), "warning");
b.title = plan.map((m) => [m.이름, m.규격].filter(Boolean).join(" ")).join("\n");
items.push(b);
}
const bar = createToolbar({ items });
bar.dataset.importedMineBar = String(made);
return bar;
}
/** 머리 판 */
export function importedPanel(s: PanelState, a: PanelActs): HTMLElement {
const meta = s.doc.가져온 ?? {};
@@ -163,6 +198,8 @@ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement {
];
kids.push(createToolbar({ items: [...tags, ...items] }));
}
const mine = myMachines(src);
if (here && mine) kids.push(mine);
if (here && s.qty?.자리) {
const open = createButton({ label: tx("QtyOpen"), size: 24, variant: "ghost", onClick: a.qty });
open.dataset.importedQty = String(s.qty.자리);
+1
View File
@@ -21,6 +21,7 @@ STM_SHEETS = (
"재료비목록표",
"경비목록표",
"중기목록표",
"중기사용료", # 821-4 기계 구성(못 이은 기계 → 내 기계 값)
)
EST_SHEETS = (
"단가산출총괄표",
+3
View File
@@ -444,5 +444,8 @@ def read(data: bytes) -> dict[str, Any]:
blk["줄합"] = head_sum(lines)
out[key].append({**blk, "줄": lines, "표지": tags})
out["자원"] = {f"{LINK[k[0]]}:{k[1]}": v for k, v in res.items()}
from M02_MasterTemplete.M02_San_MyMachine import est_makes
out["기계구성"] = est_makes(t) # 821-4 못 이은 기계 → 내 기계 값
out["표지"] = sorted(warn) + ["옵션(datestoption.opt) · 원가 서식(wongadat) 안 읽음"]
return out
+312
View File
@@ -0,0 +1,312 @@
"""불러오기 못 이은 기계 → 내 기계 자동 생성(PLAN 821-4 · 요구서_796 4 장 · 방향 장부 Q1 · S) — 서버 단독.
구성 읽기(그 파일 값 → 우리 틀 값 · 경쟁사 조종원 노임 환산 ×16/12 ×25/20 은 안 가져옴):
EST 원파일 MachMainDat(`est_makes` · 올릴 때 둘 꼴 칸 `기계구성`) · EST 엑셀 「기계경비」 ·
STmate 엑셀 「중기사용료」(`xlsx_makes` · 둔 산근 시트에서 그때 읽음) → {열쇠: 구성}
구성 = {이름, 규격, 손료 [[계수 10⁻⁷, 가격 원, 달러?]], 주연료종류, 주연료 ℓ, 잡재료율 %, 조종원 인,
조종원직종(글), 전력 kWh} · 열쇠 = 자원 코드 · 「이름|규격」(표기 정규화 `M02_Machine_Find.norm`).
고름(`plan`) = 가져온 로직 호표 기계 줄 가운데 이음 없음 · 찾기 판정 「못 찾음」(별칭 · 표기 · 같은 파일 대조 뒤 ·
후보 여럿 = 사람이 고름이라 안 만듦) · 조합 기계 = 조각마다(첫 조각 = 첫 손료 + 연료 · 조종원 ·
다음 조각 = 그 차례 손료만 · 구성 없는 조합 = 안 만듦).
몸 = 구성 있음 → 방식 바로(가격 천원 · 달러 = × 환율 FX000001 + 비고 · 손료계수 · 주연료 → 우리 유가 이름 ·
잡품 % · 조종원 → 우리 직종 원문번호 · 전력) · 못 이은 연료 · 직종 = 칸 비움 + 비고 「확인 필요」 ·
구성 없음 → 직접(그 파일 시간당 노 · 재 · 경).
잇기(`attach` · 층 저장 길 `M02_Sangeun_FromHopyo.make` 만) = 그 층 내 기계 같은 이름 · 규격이면 그 줄 · 없으면
새로(`M02_MyMachine.create`) · 바로 = 기계 줄 이음 키 · 로직 줄 = 시간당 부름 글 / 직접 = 이음 없이 자원
`내기계` 키(셈 = 그 파일 값 · [우리 단가로] = 내 기계 시간당 · `M02_Sangeun_Imported.row_of`) ·
원본 `내기계` [{키, 이름, 규격, 새로}] · 개인 층 아님 = 안 만듦(원본 `내기계예정` [{이름, 규격}] 만).
금액(엑셀 단가 그대로)은 줄 값을 안 건드려 그대로.
"""
from __future__ import annotations
import re
from typing import Any
from M02_MasterTemplete.M02_Machine_Find import NONE, norm
NOTE = "불러오기 자동 생성 — {file} · 값 확인 필요"
CHECK = "확인 필요"
COST = {"L": "노무비", "M": "재료비", "S": "경비"}
def key_of(name: Any, spec: Any) -> str:
return f"{norm(name)}|{norm(spec)}"
def _num(v: Any) -> float | None:
if isinstance(v, bool) or v is None:
return None
if isinstance(v, (int, float)):
return float(v)
s = str(v).strip().replace(",", "")
return float(s) if re.fullmatch(r"-?\d+(?:\.\d*)?|-?\.\d+", s) else None
def _blank(name: str = "", spec: str = "") -> dict[str, Any]:
return {"이름": name, "규격": spec, "손료": []}
def _fuel(make: dict, kind: Any, litre: Any) -> None:
"""주연료 종류 · ℓ(처음 것) — 종류 「전력」 = 전력 kWh 칸."""
if re.sub(r"\s", "", str(kind or "")) == "전력" and (_num(litre) or 0) > 0:
make["전력"] = _num(litre)
elif make.get("주연료") is None and (_num(litre) or 0) > 0:
make["주연료종류"], make["주연료"] = str(kind or "").strip(), _num(litre)
# ── 구성 읽기 ─────────────────────────────────────────────────────────
def est_makes(t: dict[str, list[dict]]) -> dict[str, dict]:
"""EST 원파일 MachMainDat → {자원 코드: 구성} — TitleCode 머리 다음 줄부터 빈 줄까지 ·
Index -3 손료(GN 계수 · TU 가격 · 단위 「$」 = 달러) · -2 「주연료」(Size 종류) · 연료 이름 줄(선박용경유 등 ℓ) ·
잡품 · 잡유 % · 전력 KWH · -1 조종원(직종 · GN 인)."""
heads = {r.get("Code"): r for r in t.get("MachTitleDat", [])}
out: dict[str, dict] = {}
cur = None
for r in t.get("MachMainDat", []):
if r.get("TitleCode"):
head = heads.get(r["TitleCode"]) or r
cur = out.setdefault(
r["TitleCode"], _blank(head.get("Name") or "", head.get("Size") or "")
)
elif not r.get("Name"):
cur = None
elif cur is not None:
i, gn, word = r.get("Index"), r.get("GN"), re.sub(r"\s", "", str(r["Name"]))
unit = str(r.get("Unite") or "").strip().upper()
if i == -3 and gn:
cur["손료"].append([gn, r.get("TU"), unit == "$"])
elif i == -2 and word == "주연료":
_fuel(cur, r.get("Size"), gn)
elif i == -2 and word in ("잡품", "잡유") and cur.get("잡재료율") is None:
cur["잡재료율"] = gn
elif i == -2 and word == "전력":
cur["전력"] = gn
elif i == -2 and unit == "ℓ":
_fuel(cur, r["Name"], gn)
elif i == -1 and (gn or 0) > 0 and not cur.get("조종원"):
cur["조종원"], cur["조종원직종"] = gn, str(r["Name"]).strip()
return {k: v for k, v in out.items() if v["손료"]}
def _est_sheet(rows: list[list]) -> dict[str, dict]:
"""EST 엑셀 「기계경비」 — 「N. 이름 · 규격」 머리 아래 빈 줄까지: 「손료 가격 ₩×(달러 $×) 계수 × 10(-7)」 ·
「주연료 종류 ℓ」 · 「잡품 주연료의 % 」 · 「직종 인수 인」 · 「전력 … kWh」."""
out: dict[str, dict] = {}
cur = None
for row in rows:
v = [c for c in row if c not in (None, "")]
head = re.match(r"\d+\.\s*(.+)", str(v[0])) if v else None
if head:
spec = v[1] if len(v) > 1 and isinstance(v[1], str) else ""
cur = out.setdefault(key_of(head.group(1), spec), _blank(head.group(1).strip(), spec))
elif not v:
cur = None
elif cur is not None:
word = re.sub(r"\s", "", str(v[0]))
mark = next((x for x in v if x in ("₩×", "$×")), None)
if "손료" in word and mark and (at := v.index(mark) + 1) < len(v):
cur["손료"].append([_num(v[at]), _num(v[1]), mark == "$×"])
elif word == "주연료" and len(v) > 2:
_fuel(cur, v[1], v[2])
elif word in ("잡품", "잡유") and len(v) > 2 and cur.get("잡재료율") is None:
cur["잡재료율"] = _num(v[2])
elif word == "전력" and len(v) > 2:
cur["전력"] = _num(v[1]) if _num(v[1]) is not None else _num(v[2])
elif len(v) > 2 and v[2] == "인" and (_num(v[1]) or 0) > 0 and not cur.get("조종원"):
cur["조종원"], cur["조종원직종"] = _num(v[1]), str(v[0]).strip()
elif len(v) > 3 and str(v[3]).upper() in ("ℓ", "L") and (_num(v[2]) or 0) > 0:
_fuel(cur, v[0], v[2])
return {k: m for k, m in out.items() if m["손료"] and None not in m["손료"][0][:2]}
def _stm_sheet(rows: list[list]) -> dict[str, dict]:
"""STmate 엑셀 「중기사용료」 — 「제 N 호표」 뒤 머리 [이름, 규격, 시간, 코드] · 손료 [이름, 규격, 계수(천원당),
「천원」, 가격 천원 …] · 조종원 [직종, 인, 「인」 …] · 연료 [이름, 등급, ℓ, 「L」 …] · 잡품 [잡품, 주연료의%, % …] ·
「합 계」 끝 → {코드 · 이름|규격: 구성}(손료계수 = 계수 × 10⁴ · 가격 = 천원 × 1000 원)."""
out: dict[str, dict] = {}
cur = None
for row in rows:
v = [c for c in row if c not in (None, "")]
first = re.sub(r"\s", "", str(v[0])) if v else ""
if re.fullmatch(r"제\d+호표", first):
cur = {}
continue
if cur is None or not v:
continue
if first == "합계":
cur = None
continue
if (
not cur
and len(v) >= 4
and str(v[2]) in ("시간", "hr")
and re.fullmatch(r"X\d+", str(v[-1]))
):
cur.update(_blank(str(v[0]), str(v[1])))
out[str(v[-1])] = out[key_of(v[0], v[1])] = cur
elif not cur:
continue
elif len(v) > 4 and str(v[3]) == "천원" and _num(v[2]) and _num(v[4]):
cur["손료"].append([_num(v[2]) * 10_000, _num(v[4]) * 1000, False])
elif len(v) > 2 and str(v[2]) == "인" and (_num(v[1]) or 0) > 0 and not cur.get("조종원"):
cur["조종원"], cur["조종원직종"] = _num(v[1]), str(v[0]).strip()
elif first == "잡품" and len(v) > 2 and cur.get("잡재료율") is None:
cur["잡재료율"] = _num(v[2])
elif len(v) > 3 and str(v[3]).upper() in ("L", "ℓ"):
_fuel(cur, v[0], v[2])
elif len(v) > 3 and str(v[3]).upper() == "KWH":
cur["전력"] = _num(v[2])
return {k: m for k, m in out.items() if m.get("손료")}
def xlsx_makes(sheets: dict[str, list]) -> dict[str, dict]:
"""엑셀 산근 시트 → 구성(EST 「기계경비」 · STmate 「중기사용료」 · 없음 = {})."""
if sheets.get("기계경비"):
return _est_sheet(sheets["기계경비"])
if sheets.get("중기사용료"):
return _stm_sheet(sheets["중기사용료"])
return {}
# ── 고름 · 몸 ─────────────────────────────────────────────────────────
def _todo(logic: dict) -> list[tuple[dict, int]]:
"""(줄, 조각 차례) — 기계 줄 · 이음 없음 · 찾기 판정 「못 찾음」."""
out, n = [], 0
for line in logic.get("호표") or []:
res = line.get("자원") or {}
if res.get("갈래") != "기계":
continue
n = n + 1 if res.get("조합") else 0
if line.get("이음") or res.get("조합식") or res.get("내기계"):
continue
if (res.get("찾기") or {}).get("판정") == NONE:
out.append((line, n))
return out
def _make_of(res: dict, makes: dict) -> dict | None:
return makes.get(str(res.get("코드") or "")) or makes.get(
key_of(res.get("이름"), res.get("규격"))
)
def _kinds(make: dict, notes: list[str]) -> dict[str, Any]:
"""연료 · 조종원 → 우리 유가 이름 · 직종 원문번호(못 이음 = 칸 비움 + 비고)."""
from M01_MasterData import M01_MasterData_Store as m01
index, out = m01.loaded()[1], {}
fuel, litre = str(make.get("주연료종류") or ""), make.get("주연료") or 0
if litre:
ours = next(
(n for n in ("경유", "휘발유", "실내등유") if n == re.sub(r"\s", "", fuel)), None
)
ours = ours or next((n for n in ("경유", "휘발유", "실내등유") if fuel.startswith(n)), None)
if ours and f"{ours}:전국평균" in (index.numbers.get("MO") or {}):
out |= {"주연료": litre, "주연료종류": ours}
out |= {"잡재료율": make["잡재료율"]} if make.get("잡재료율") else {}
else:
notes.append(f"주연료 「{fuel}」 {litre:g}ℓ — 우리 유가에 못 이음({CHECK})")
if make.get("조종원"):
job = norm(make.get("조종원직종"))
hit = [k for k, r in (index.numbers.get("LB") or {}).items() if norm(r.get("이름")) == job]
if len(hit) == 1:
out |= {"조종원": make["조종원"], "조종원직종": hit[0]}
else:
notes.append(
f"조종원 「{make.get('조종원직종')}」 {make['조종원']:g}인 — 우리 직종에 못 이음({CHECK})"
)
if make.get("전력"):
out["전력"] = make["전력"]
return out
def body(make: dict | None, n: int, line: dict, file: str) -> dict | None:
"""내 기계 몸(`M02_MyMachine.create`) — 구성 있음 = 바로 · 없음 = 직접(조합 조각 = 안 만듦)."""
from M01_MasterData import M01_MasterData_Store as m01
res = line.get("자원") or {}
found = res.get("찾기") or {}
name = str(found.get("이름") or res.get("이름") or line.get("이름") or "").strip()
spec = str((found.get("규격") if found.get("이름") else res.get("규격")) or "").strip()
head = {"이름": name, "규격": spec}
notes = [NOTE.format(file=file)]
if make is None:
if n or res.get("조합첫"):
return None
got = res.get("단가") or {}
pack = line.get("값묶음") or {}
hour = {v: _num(got.get(c)) or _num(pack.get(v)) or 0 for c, v in COST.items()}
return {**head, "방식": "직접", "시간당": hour, "비고": " · ".join(notes)}
if n >= len(make["손료"]):
return None
coef, price, usd = make["손료"][n]
if usd:
rate = float(m01.loaded()[1].index["FX"]["FX000001"]["값"])
notes.append(f"가격 ${price:g} × 환율 {rate:,} 원/USD")
price = price * rate
vals: dict[str, Any] = {"가격": round(float(price) / 1000, 4), "손료계수": coef}
if n == 0:
vals |= _kinds(make, notes)
return {**head, "방식": "바로", "값": vals, "비고": " · ".join(notes)}
def plan(logic: dict, makes: dict, file: str) -> list[dict]:
"""만들(또는 이을) 내 기계 [{줄, 열쇠, 몸}] — 글 · 층 안 건드림."""
out = []
for line, n in _todo(logic):
got = body(_make_of(line.get("자원") or {}, makes), n, line, file)
if got:
out.append({"줄": line, "열쇠": key_of(got["이름"], got["규격"]), "몸": got})
return out
def preview(out: dict, makes: dict) -> None:
"""미리 보기 · 분석 길 — 원본 `내기계예정` [{이름, 규격}](안 만듦)."""
todo = plan(out["로직"], makes, str(out["원본"].get("파일") or ""))
seen = {t["열쇠"]: {"이름": t["몸"]["이름"], "규격": t["몸"]["규격"]} for t in todo}
if seen:
out["원본"]["내기계예정"] = list(seen.values())
# ── 잇기(층 쓰기) ─────────────────────────────────────────────────────
def link(line: dict, row: dict) -> None:
"""가져온 줄 → 내 기계 — 바로 = 기계 줄 키 · 로직 줄 시간당 부름 글 / 직접 = 자원 `내기계` 만."""
from M02_MasterTemplete.M02_San_Link import hourly_text
line["자원"]["내기계"] = row["키"]
if row.get("방식") == "직접":
return
line["이음"] = hourly_text(row["키"]) if line.get("종류") == "로직" else row["키"]
def attach(out: dict, makes: dict) -> list[dict]:
"""층 저장 길 — 못 이은 기계 줄을 그 층 내 기계(같은 이름 · 규격) · 새 내 기계에 이음 · 원본 `내기계`."""
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
src = out["원본"]
todo = plan(out["로직"], makes, str(src.get("파일") or ""))
if not todo:
return []
try:
mym._user()
except store.StoreError: # 개인 층 아님 — 안 만듦(예정만)
preview(out, makes)
return []
done: dict[str, dict] = {}
for one in todo:
if one["열쇠"] not in done:
have = next(
(r for r in mym.rows() if key_of(r.get("이름"), r.get("규격")) == one["열쇠"]), None
)
row = have or mym.create(one["몸"])["줄"]
done[one["열쇠"]] = {"줄": row, "새로": have is None}
link(one["줄"], done[one["열쇠"]]["줄"])
made = [
{**{k: d["줄"][k] for k in ("키", "이름", "규격", "방식")}, "새로": d["새로"]}
for d in done.values()
]
src["내기계"] = made
src.pop("내기계예정", None)
return made
+17 -6
View File
@@ -25,6 +25,7 @@ from M02_MasterTemplete import M02_Doc_FromNative as fn
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_San_MyMachine as mm
DIRNAME = "산근"
RELOAD = "가져온 식 자료 없음 — 엑셀을 다시 올림"
@@ -109,7 +110,15 @@ def has(folder: str | Path, key: str) -> bool:
def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
"""HP 단산 블록 하나 → `{로직, 원본}`(원파일 우선 · 아니면 엑셀 읽기기 같은 길 · 이음 = M01 키)."""
"""HP 단산 블록 하나 → `{로직, 원본}`(원파일 우선 · 아니면 엑셀 읽기기 같은 길 · 이음 = M01 키) ·
못 이은 기계 = 원본 `내기계예정`(821-4 · 안 만듦)."""
out, makes = _emit(folder, key, no)
mm.preview(out, makes)
return out
def _emit(folder: str | Path, key: str, no: int) -> tuple[dict[str, Any], dict]:
"""(블록 `{로직, 원본}`, 그 파일 기계 구성 — 원파일 둘 꼴 `기계구성` · 엑셀 = 둔 시트 · 821-4)."""
hopyo_store.read(folder, key) # 없는 HP = 404
path = _path(folder, key)
try:
@@ -118,13 +127,13 @@ def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
raise store.StoreError(409, RELOAD) from e
native = saved.get("원파일")
if isinstance(native, dict) and int(no) in fn.numbers(native): # 원파일 우선(810-2)
return fn.emit(native, int(no))
return fn.emit(native, int(no)), native.get("기계구성") or {}
if not saved.get("시트"):
raise store.StoreError(404, f"없는 단산{no}")
excel = _excel_of(path, saved)
if int(no) not in fn.numbers(excel):
raise store.StoreError(404, f"없는 단산{no}")
return fn.emit(excel, int(no))
return fn.emit(excel, int(no)), mm.xlsx_makes(saved["시트"])
def _inner(
@@ -173,7 +182,7 @@ def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) ->
안쪽 산근 블록은 형제 SK 를 먼저 만들어 참조(797-1)."""
from M02_MasterTemplete import M02_Sangeun_Imported as imp
out = emit(source, key, no)
out, makes = _emit(source, key, no)
out["원본"]["가져온때"] = utc_now_text() # 원문 패널 「어디서 왔나」(807-1)
_link_inner(source, key, no, out, _trail)
try: # 소요량 · 계수 후보 표지(797-2 · 사람 확인) — 못 셈 = 표지 없이 만듦
@@ -182,13 +191,15 @@ def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) ->
out["원본"]["표지"] += qty.flags(out["로직"])
except Exception: # noqa: BLE001 — 역색인 짓기 실패가 가져온 식 만들기를 막지 않음
pass
made = mm.attach(out, makes) # 821-4 못 이은 기계 = 내 기계(그 층 같은 것 · 새로)
mine = {"내기계": made} if made else {}
title = str(name or "").strip() or str(out["로직"].get("이름") or f"단산{no}")
try:
got = imp.create(out["로직"], out["원본"], title)
return {**got, "다시씀": False}
return {**got, "다시씀": False, **mine}
except store.StoreError as e:
same = (e.detail or {}).get("같은것") if isinstance(e.detail, dict) else None
if e.status != 409 or not same:
raise
got = imp.create(out["로직"], out["원본"], title, use=same[0]["키"])
return {**got, "다시씀": True}
return {**got, "다시씀": True, **mine}
+19 -4
View File
@@ -63,9 +63,10 @@ def _mf() -> Any:
def _whole() -> Any:
from M01_MasterData import M01_MasterData_Store as m01
"""M01 + 지금 층 내 기계(821-4 · 못 이은 기계를 이은 개인 키 `EQP…`)."""
from M02_MasterTemplete import M02_MyMachine as mym
return m01.loaded()[1]
return mym.whole()
def is_imported(doc: Any) -> bool:
@@ -80,7 +81,7 @@ def hourly_of(key: str) -> dict:
"""이음 기계(EQ) 시간당 노 · 재 · 경 — M01 판이 바뀌면 다시(캐시 = M01 묶음 하나에 매임)."""
from M02_MasterTemplete import M02_Machine_Hourly as machine
whole = _whole()
whole = _whole() # 내 기계 고침 = 색인이 바뀜 → 다시
if _HOURLY["whole"] is not whole:
_HOURLY.update(whole=whole, rows={})
if key not in _HOURLY["rows"]:
@@ -112,6 +113,8 @@ def row_of(doc: Any) -> dict | None:
"참조"
): # 안쪽 산근 = 형제 SK 1 단위 값(셈이 채움 · 엑셀 값묶음은 얼린 사본으로 둠)
item |= {"요소": None, "값묶음": None, "기준": "우리" if ours else "엑셀"}
if ours and not link and (mine := (one.get("자원") or {}).get("내기계")):
_direct(item, one, mine) # 821-4 시간당 값 직접 내기계 = 그 줄 값
if ours and link:
item.pop("단가", None)
item.pop("값묶음", None)
@@ -124,6 +127,18 @@ def row_of(doc: Any) -> dict | None:
return {**row, "키": doc.get("키"), "호표": items}
def _direct(item: dict, one: dict, key: str) -> None:
"""[우리 단가로] 이음 없이 이은 내 기계(시간당 값 직접 · 821-4) — 단가 = 그 비목 · 값묶음 = 셋 ·
지운 내 기계 = 엑셀 값 그대로."""
got = hourly_of(key)
if not got.get("ok"):
return
if "단가" in item and one.get("비목") in COSTS:
item["단가"] = sd.plain(Decimal(str(got[one["비목"]])))
elif "값묶음" in item:
item["값묶음"] = {k: sd.plain(Decimal(str(got[k]))) for k in COSTS}
def base_row(doc: dict) -> dict[str, Any]:
"""바탕 없는 SK 바탕 줄(`sd.empty_row`) — 가져온 식 = 그 줄 · 아니면 입력 · 줄 없음."""
return row_of(doc) or {
@@ -314,7 +329,7 @@ def unlinked(doc: dict) -> list[dict]:
{"자리": ids[n], "이름": one.get("이름") or "", "까닭": link.why(one)}
| ({"견적": True} if (one.get("자원") or {}).get("갈래") == "고정" else {})
for n, one in enumerate(doc[KEY]["로직"].get("호표") or [])
if not one.get("이음") and not one.get("참조")
if not one.get("이음") and not one.get("참조") and not (one.get("자원") or {}).get("내기계")
]
@@ -27,6 +27,10 @@ export interface ImportedMeta {
갈래?: string;
엑셀금액?: Money4;
글줄?: { 행?: number | null; 글?: string; 자리?: string; 값?: number | null }[];
/** 못 이은 기계 → 내 기계(821-4 · 새로 = 이번에 만듦 · 아니면 그 층 같은 것) */
내기계?: { 키: string; 이름: string; 규격?: string; 방식?: string; 새로?: boolean }[];
/** 개인 층 밖에서 연 것 — 만들어질 내 기계(안 만듦) */
내기계예정?: { 이름: string; 규격?: string }[];
};
검산?: {
같음: boolean;
@@ -97,21 +97,6 @@ last_updated: 2026-08-15
| 환율·유가 원천과 스냅샷 | ECOS·오피넷 실사 2026-08-14 | [원가_기초데이터_원천 §4](원가_기초데이터_원천.md) |
| ESTX 기계경비 조립 관측 | 2026-07 배포본 재검증 | [원가_기초데이터_원천 §3](원가_기초데이터_원천.md) |
## 7. 품셈에 없는 소형·보조 기계 — EST 구성 기준 · 가격 갱신 필요
## 7. 품셈에 없는 기계 = 사용자 내 기계(요구서 796)
현행 품셈 기계 목록(발전기 25㎾~ 등)에 없는 기계를 EST 단가 구성대로 우리 기계표에 둔 줄. 손료계수 · 연료 · 잡품 · 조종원 = 품셈 값(같은 계열 차용) · 가격 = EST 씨앗(임시) — 조달청 · 행안부 건설기계 표준시장가격 · 제조사 값으로 바꿀 것.
| 기종 | 규격 | 손료계수(10⁻⁷/hr) | 연료 · 잡품 · 전력 | 조종원 | 가격 씨앗(천원) | 시간당 식 | 출처 |
|---|---|---|---|---|---|---|---|
| 발전기 | 5.5㎾ | 2,362(발전기 계열) | 경유 0.625ℓ · 11 % | 일반기계운전사 1 | 1,063 | GC000268 | EST · 2016 사방품셈(안) 같은 꼴 |
| 발전기 | 7㎾ | 2,362 | — | — | 1,828.9 | GC000273(손료만) | 2016 사방품셈(안) 기계경비 |
| 발전기 | 10㎾ | 2,362 | — | — | 3,213 | GC000273 | 2016 사방품셈(안) 기계경비 |
| 대형 브레이커 | 0.18㎥ | 6,601(0.2㎥ 계수) | — | — | 4,559(0.2㎥ 값) | GC000273 | 굴착기 부속 · 우리 0.2㎥ 줄 |
| 에어 호스 | 1.91㎝ | 5,625 | — | — | 105 | GC000273 | EST |
| 모노펌프 | 5-20㎥/hr | 4,346 | — | — | 4,900 | GC000273 | EST |
| 파워트로웰 | 3.73㎾ | 5,313 | 휘발유 1.0ℓ · 10 % · 전력 3.73㎾h | — | 2,680 | GC001195 | EST |
| 콘크리트펌프용 파이프(압송관) | 150㎜ × 1 · 2 · 3m · 곡관 45 · 60 · 90° | 4,500 | — | — | $25 · 37 · 46 · 67 · 84 · 103 × 환율 | GC000273 | EST($ 표기) · 환율 FX000001 |
- 손료 = 버림(가격 × 1,000 × 손료계수 ÷ 10⁷) · 조종원 = 하루 노임 × 1/8(품셈 본문 · EST 의 16/12 · 25/20 뺌).
- 파워트로웰 잡품 = 주연료 줄의 % (전력 줄엔 안 붙임).
- 파이프 가격 = 달러 표기 → 환율 적용값 · 원화 갱신 필요.
품셈 기계표(M01)는 품셈 정본만 둠. 품셈에 없는 기계(발전기 5.5 · 7 · 10㎾ · 소형 브레이커 · 에어 호스 · 모노펌프 · 파워트로웰 · 압송관 등)는 사용자 개인 층의 「내 기계」 — 가격 · 손료계수 · 주연료 ℓ · 잡품 % · 조종원 인 · 직종(우리 노임) · 전력을 사용자가 넣고 시간당 경비는 M01 기계와 같은 식(손료 = 버림(가격 × 1,000 × 손료계수 ÷ 10⁷) · 조종원 = 하루 노임 × 1/8)으로 셈. 경쟁사 불러오기에서 끝내 못 이은 기계는 그 파일 구성(EST 원파일 구성표 · 엑셀 기계경비 · STmate 중기사용료)으로 내 기계를 자동으로 만들고(경쟁사 조종원 노임 환산 ×16/12 · ×25/20 은 안 가져옴 · 달러 표기 = 환율 적용 · 비고 「값 확인 필요」) · 구성 없는 파일은 시간당 값을 그대로 담음.
File diff suppressed because one or more lines are too long
@@ -1,241 +0,0 @@
"""품셈에 없는 소형 · 보조 기계를 우리 기계표 줄로(PLAN 820-1 · 요구 785 ~ 787 · 방향 장부 Q1 · R).
EST 단가 구성 → 우리 줄 — 손료계수 · 연료 · 잡품 · 조종원 = 품셈 값(발전기 계열 2,362 · 대형 브레이커 6,601) ·
가격 = EST 씨앗(출처 「자체」 · 비고 「갱신 필요」) · 콘크리트펌프용 파이프 = 달러값 × 한국은행 환율(FX000001).
계산식:
손료만(브레이커 0.18 · 에어 호스 · 모노펌프 · 파이프 · 발전기 7 · 10㎾) = GC000273 고르기에 더함
발전기 5.5㎾(경유 · 잡품 · 일반기계운전사) = GC000268 고르기에 더함
→ 고르기 더하기는 `build_기계목록_빠진기계_814_1`(로직 · 수식 문서 목록 같이)
파워트로웰(휘발유 · 잡품 · 전력 · 조종원 없음) = 새 기계경비식 GC001195 — 기계 줄 값을 바로 읽음
(`M02_Machine_Hourly._own` 이 고름) · 잡품 = 주연료 줄의 % (전력 줄엔 안 붙임)
더하기만 — 다른 줄 · 서식 그대로 · 두 번 돌려도 같음.
쓰기: venv python resources/master_data/scripts/build_기계_소형보조_820_1.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
DATA = HERE.parent
ROOT = DATA.parents[1]
MACHINE = DATA / "기계.json"
BOOK = DATA / "_키대장.json"
LOGIC = DATA / "로직_건설품셈_08장_건설기계.json"
DOC = DATA / "수식문서" / "수식_건설품셈_08장_건설기계.json"
USD = 1414.9 # 원/USD — 한국은행 환율 FX000001(2026-08-14)
SEED = "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요)"
GEN = {"상각비계수": 1125, "정비비계수": 563, "관리비계수": 674, "손료계수": 2362} # 발전기 계열
BRK = {"상각비계수": 3000, "정비비계수": 2833, "관리비계수": 768, "손료계수": 6601} # 0230-0002
UNIT = {
"가격": "천원", "상각비계수": "10^-7/시간", "정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간", "손료계수": "10^-7/시간", "주연료": "ℓ/hr", "주연료종류": "",
"잡재료율": "주연료의 %", "조종원": "인/일", "조종원직종": "", "전력": "㎾h/hr",
} # fmt: skip
FIX_SRC = {"발전기": "건설품셈 공통 8-3-8", "브레이커": "건설품셈 공통 8-3-1"}
WAGE = "건설품셈 공통 8-1-3 5 · 건설노임 1050"
SAB = "2016 사방사업표준품셈(안) 기계경비"
PIPE = {"150㎜×1m": 25, "150㎜×2m": 37, "150㎜×3m": 46,
"곡관 150㎜ 45°": 67, "곡관 150㎜ 60°": 84, "곡관 150㎜ 90°": 103} # fmt: skip
def _row(n, gubun, detail, name, spec, value, note, fix=None):
src = {k: (FIX_SRC[fix] if fix and k in GEN else "자체") for k in value}
if "조종원직종" in value:
src["조종원직종"] = WAGE
return {
"키": f"EQ{n:06d}",
"원문번호": f"자체 {name} {spec}",
"원문": "건설품셈",
"구분": gubun,
"상세구분": detail,
"이름": name,
"규격": spec,
"단위": {k: UNIT[k] for k in value},
"값": value,
"출처": src,
"비고": f"{SEED} · {note}",
}
def rows() -> list[dict]:
g, c, t = "[70]기타기계", "[40]콘크리트기계", "[00]토공기계"
fuel5 = {
"주연료": 0.625,
"주연료종류": "경유",
"잡재료율": 11,
"조종원": 1,
"조종원직종": "1050",
}
out = [
_row(656, g, "발전기", "발전기", "5.5㎾", {"가격": 1063, **GEN, **fuel5},
"EST 계 37,760 · 주연료 · 잡품 · 조종원(일반기계운전사) = " + SAB + "과 같음", "발전기"),
_row(657, g, "발전기", "발전기", "7㎾", {"가격": 1828.9, **GEN},
f"가격 = {SAB}(1,828,900 원) · 그 표 = 손료만(연료 · 조종원 없음)", "발전기"),
_row(658, g, "발전기", "발전기", "10㎾", {"가격": 3213, **GEN},
f"가격 = {SAB}(3,213,000 원) · 그 표 = 손료만(연료 · 조종원 없음)", "발전기"),
_row(659, t, "브레이커", "대형 브레이커", "0.18㎥", {"가격": 4559, **BRK},
"손료만(굴착기 부속) · 가격 = 0.2㎥(0230-0002) 값 · 계수 = 0.2㎥ 계수", "브레이커"),
_row(660, g, "에어 호스", "에어 호스", "1.91㎝", {"가격": 105, "손료계수": 5625},
"손료만 · EST 계 59"),
_row(661, g, "모노펌프", "모노펌프", "5-20㎥/hr", {"가격": 4900, "손료계수": 4346},
"손료만 · EST 계 2,129"),
_row(662, c, "파워트로웰", "파워트로웰", "3.73㎾",
{"가격": 2680, "손료계수": 5313, "주연료": 1.0, "주연료종류": "휘발유", "잡재료율": 10, "전력": 3.73},
"주연료 휘발유 · 잡품 · 전력 3.73 ㎾h · 조종원 없음 · EST 계 3,858 · 시간당 경비 = GC001195"),
] # fmt: skip
for i, (spec, usd) in enumerate(PIPE.items()):
out.append(
_row(663 + i, c, "콘크리트펌프용 파이프", "콘크리트펌프용 파이프", spec,
{"가격": round(usd * USD / 1000, 4), "손료계수": 4500},
f"압송관 · 손료만 · ${usd} 표기 · 환율 적용({USD:,} 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요")
) # fmt: skip
return out
EQ = "EQ000662"
LOGIC_ROW = {
"키": "GC001195",
"원문번호": "8",
"구분": "건설품셈 공통",
"상세구분": "08장 건설기계",
"이름": "파워트로웰 시간당 기계경비",
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"부품": {"갈래": "기계경비식"},
"소유": "공용",
"비고": f"품셈에 없는 기계 — {SEED} · 기계표 {EQ} 파워트로웰 3.73㎾ 값을 읽음 · 손료 = 가격 × 손료계수 · 잡품 = 주연료 줄의 % · 전력 = 기계표 전력 칸 · 조종원 없음",
"입력": [
{
"이름": "지역",
"고르기": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"],
"출처": "건설품셈 공통 8-1-7 5",
},
{
"이름": "전력 계약종별",
"고르기": ["주택용 저압", "일반용(갑)Ⅰ 저압"],
"출처": "유가전력 한전 전기요금표 2025-01-01",
"비고": "품셈·계약예규에 계약종별 규정 없음(전력비는 실비 계상) · 임시전력(갑)은 주택용 저압 요금 그대로(요금표 6.가)",
},
],
"중간": [
{"이름": "손료", "식": f"버림({EQ}.가격 * 1000 * {EQ}.손료계수 / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4"},
],
"호표": [
{"종류": "재료", "요소": "MO:휘발유:{지역}", "이름": "주연료", "단위": "ℓ", "수량": f"{EQ}.주연료", "비목": "재료비", "출처": "건설품셈 공통 8-4-1"},
{"종류": "재료", "요소": {"이름": "전력량요금", "계약종별": "입력"}, "이름": "전력", "단위": "㎾h", "수량": f"{EQ}.전력", "비목": "재료비", "출처": "자체"},
],
"덧줄": [
{"이름": "잡재료비", "식": f"줄.'주연료' * {EQ}.잡재료율 / 100", "비목": "재료비", "출처": "건설품셈 공통 8-4 주②"},
{"이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2"},
],
"끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음",
"변수표": {
"지역": {"기호": "S", "아래": "지역"},
"전력 계약종별": {"기호": "S", "아래": "전력계약종별"},
"손료": {"기호": "C", "아래": "손료"},
},
} # fmt: skip
def _block(obj: dict, nl: str) -> str:
"""줄 하나 — 두 칸 들여쓴 JSON 을 넷 칸 자리에."""
text = json.dumps(obj, ensure_ascii=False, indent=2)
return nl.join(" " + line for line in text.split("\n"))
def _insert_tail(path: Path, objs: list[dict]) -> None:
"""「줄」 목록 끝(`]` 앞)에 더함 — 이미 있는 키는 건너뜀."""
text = path.read_text("utf-8")
have = {r["키"] for r in json.loads(text)["줄"]}
new = [o for o in objs if o["키"] not in have]
if not new:
return
nl = "\r\n" if "\r\n" in text else "\n"
close = text.rstrip().rindex("]")
head = text[:close].rstrip()
body = "".join(f",{nl}{_block(o, nl)}" for o in new)
out = head + body + nl + " " + text[close:]
json.loads(out)
path.write_text(out, "utf-8", newline="")
def _insert_after(path: Path, after_key: str, obj: dict) -> None:
"""키 `after_key` 블록 뒤에 줄 하나 — 이미 있으면 건너뜀."""
text = path.read_text("utf-8")
if f'"키": "{obj["키"]}"' in text:
return
nl = "\r\n" if "\r\n" in text else "\n"
data = json.loads(text)
at = text.index(f'"키": "{after_key}"')
nxt = [r["키"] for r in data["줄"]]
follow = nxt[nxt.index(after_key) + 1]
cut = text.rindex("{", 0, text.index(f'"키": "{follow}"'))
cut = text.rindex(",", at, cut) + 1
out = text[:cut] + nl + _block(obj, nl) + "," + text[cut:]
json.loads(out)
path.write_text(out, "utf-8", newline="")
def _book(machines: list[dict]) -> None:
text = BOOK.read_text("utf-8")
nl = "\r\n" if "\r\n" in text else "\n"
adds = [(r["키"], r["원문번호"], "기계.json", "EQ000655") for r in machines]
adds.append(("GC001195", "공통 8 파워트로웰 시간당 경비", LOGIC.name, "GC001194"))
for key, number, file, after in adds:
if f'"{key}":' in text:
continue
line = f' "{key}": {json.dumps({"원문번호": number, "파일": file}, ensure_ascii=False)},'
prev = max((k for k, *_ in adds if k[:2] == key[:2] and f'"{k}":' in text), default=after)
at = text.index(f' "{prev}":')
end = text.index(nl, at) + len(nl)
text = text[:end] + line + nl + text[end:]
book = json.loads(text)
nums = {"EQ": 669, "GC": 1196}
for tid, n in nums.items():
old = book["다음"][tid]
if old < n:
text = text.replace(f'"{tid}": {old},', f'"{tid}": {n},', 1)
json.loads(text)
BOOK.write_text(text, "utf-8", newline="")
def _doc() -> None:
"""GC001195 수식 문서 — 옛 로직 변환(`master_doc_convert`) · 파일 끝에 더함."""
for p in (str(ROOT), str(HERE)):
if p not in sys.path:
sys.path.insert(0, p)
import master_doc_convert as cv
import master_doc_link as ml
from M01_MasterData import M01_MasterData_Store as m01
data = json.loads(DOC.read_text("utf-8"))
if any(d.get("키") == "GC001195" for d in data["줄"]):
return
files, whole = m01.loaded()
doc, notes = cv.convert(whole, LOGIC_ROW)
if notes:
print("변환 알림:", notes)
data["줄"].append(doc)
DOC.write_text(ml.dump(data), "utf-8", newline="")
def main() -> None:
machines = rows()
_insert_tail(MACHINE, machines)
_book(machines)
_insert_after(LOGIC, "GC000276", LOGIC_ROW)
_doc()
import build_기계목록_빠진기계_814_1 as b814
b814.main()
if __name__ == "__main__":
sys.path.insert(0, str(HERE))
main()
-349
View File
@@ -39717,355 +39717,6 @@
"가격": "산림품셈 10-9"
},
"비고": "원문 이름: 4륜구동 1톤 덤프트럭 · 중기가격 = 조달청 등재가격(기본값 28,000 천원 = EST 2026-07 「덤프트럭 1.0Ton」 · 바꿀 수 있음) · 작업량은 건설품셈 · 손료 및 운전경비 = 2.5톤 덤프트럭(0602-0025) · 주 연료 = 2.5톤의 80% · 시간당 경비 = GF000145"
},
{
"키": "EQ000656",
"원문번호": "자체 발전기 5.5㎾",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "발전기",
"이름": "발전기",
"규격": "5.5㎾",
"단위": {
"가격": "천원",
"상각비계수": "10^-7/시간",
"정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간",
"손료계수": "10^-7/시간",
"주연료": "ℓ/hr",
"주연료종류": "",
"잡재료율": "주연료의 %",
"조종원": "인/일",
"조종원직종": ""
},
"값": {
"가격": 1063,
"상각비계수": 1125,
"정비비계수": 563,
"관리비계수": 674,
"손료계수": 2362,
"주연료": 0.625,
"주연료종류": "경유",
"잡재료율": 11,
"조종원": 1,
"조종원직종": "1050"
},
"출처": {
"가격": "자체",
"상각비계수": "건설품셈 공통 8-3-8",
"정비비계수": "건설품셈 공통 8-3-8",
"관리비계수": "건설품셈 공통 8-3-8",
"손료계수": "건설품셈 공통 8-3-8",
"주연료": "자체",
"주연료종류": "자체",
"잡재료율": "자체",
"조종원": "자체",
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1050"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · EST 계 37,760 · 주연료 · 잡품 · 조종원(일반기계운전사) = 2016 사방사업표준품셈(안) 기계경비과 같음"
},
{
"키": "EQ000657",
"원문번호": "자체 발전기 7㎾",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "발전기",
"이름": "발전기",
"규격": "7㎾",
"단위": {
"가격": "천원",
"상각비계수": "10^-7/시간",
"정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 1828.9,
"상각비계수": 1125,
"정비비계수": 563,
"관리비계수": 674,
"손료계수": 2362
},
"출처": {
"가격": "자체",
"상각비계수": "건설품셈 공통 8-3-8",
"정비비계수": "건설품셈 공통 8-3-8",
"관리비계수": "건설품셈 공통 8-3-8",
"손료계수": "건설품셈 공통 8-3-8"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 가격 = 2016 사방사업표준품셈(안) 기계경비(1,828,900 원) · 그 표 = 손료만(연료 · 조종원 없음)"
},
{
"키": "EQ000658",
"원문번호": "자체 발전기 10㎾",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "발전기",
"이름": "발전기",
"규격": "10㎾",
"단위": {
"가격": "천원",
"상각비계수": "10^-7/시간",
"정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 3213,
"상각비계수": 1125,
"정비비계수": 563,
"관리비계수": 674,
"손료계수": 2362
},
"출처": {
"가격": "자체",
"상각비계수": "건설품셈 공통 8-3-8",
"정비비계수": "건설품셈 공통 8-3-8",
"관리비계수": "건설품셈 공통 8-3-8",
"손료계수": "건설품셈 공통 8-3-8"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 가격 = 2016 사방사업표준품셈(안) 기계경비(3,213,000 원) · 그 표 = 손료만(연료 · 조종원 없음)"
},
{
"키": "EQ000659",
"원문번호": "자체 대형 브레이커 0.18㎥",
"원문": "건설품셈",
"구분": "[00]토공기계",
"상세구분": "브레이커",
"이름": "대형 브레이커",
"규격": "0.18㎥",
"단위": {
"가격": "천원",
"상각비계수": "10^-7/시간",
"정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 4559,
"상각비계수": 3000,
"정비비계수": 2833,
"관리비계수": 768,
"손료계수": 6601
},
"출처": {
"가격": "자체",
"상각비계수": "건설품셈 공통 8-3-1",
"정비비계수": "건설품셈 공통 8-3-1",
"관리비계수": "건설품셈 공통 8-3-1",
"손료계수": "건설품셈 공통 8-3-1"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 손료만(굴착기 부속) · 가격 = 0.2㎥(0230-0002) 값 · 계수 = 0.2㎥ 계수"
},
{
"키": "EQ000660",
"원문번호": "자체 에어 호스 1.91㎝",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "에어 호스",
"이름": "에어 호스",
"규격": "1.91㎝",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 105,
"손료계수": 5625
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 손료만 · EST 계 59"
},
{
"키": "EQ000661",
"원문번호": "자체 모노펌프 5-20㎥/hr",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "모노펌프",
"이름": "모노펌프",
"규격": "5-20㎥/hr",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 4900,
"손료계수": 4346
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 손료만 · EST 계 2,129"
},
{
"키": "EQ000662",
"원문번호": "자체 파워트로웰 3.73㎾",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "파워트로웰",
"이름": "파워트로웰",
"규격": "3.73㎾",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간",
"주연료": "ℓ/hr",
"주연료종류": "",
"잡재료율": "주연료의 %",
"전력": "㎾h/hr"
},
"값": {
"가격": 2680,
"손료계수": 5313,
"주연료": 1.0,
"주연료종류": "휘발유",
"잡재료율": 10,
"전력": 3.73
},
"출처": {
"가격": "자체",
"손료계수": "자체",
"주연료": "자체",
"주연료종류": "자체",
"잡재료율": "자체",
"전력": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 주연료 휘발유 · 잡품 · 전력 3.73 ㎾h · 조종원 없음 · EST 계 3,858 · 시간당 경비 = GC001195"
},
{
"키": "EQ000663",
"원문번호": "자체 콘크리트펌프용 파이프 150㎜×1m",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "150㎜×1m",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 35.3725,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $25 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
},
{
"키": "EQ000664",
"원문번호": "자체 콘크리트펌프용 파이프 150㎜×2m",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "150㎜×2m",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 52.3513,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $37 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
},
{
"키": "EQ000665",
"원문번호": "자체 콘크리트펌프용 파이프 150㎜×3m",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "150㎜×3m",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 65.0854,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $46 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
},
{
"키": "EQ000666",
"원문번호": "자체 콘크리트펌프용 파이프 곡관 150㎜ 45°",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "곡관 150㎜ 45°",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 94.7983,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $67 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
},
{
"키": "EQ000667",
"원문번호": "자체 콘크리트펌프용 파이프 곡관 150㎜ 60°",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "곡관 150㎜ 60°",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 118.8516,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $84 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
},
{
"키": "EQ000668",
"원문번호": "자체 콘크리트펌프용 파이프 곡관 150㎜ 90°",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "곡관 150㎜ 90°",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 145.7347,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $103 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
}
]
}
@@ -284,8 +284,7 @@
"9090-1300",
"9090-1400",
"9090-1500",
"9090-1600",
"자체 발전기 5.5㎾"
"9090-1600"
]
},
{
@@ -1089,18 +1088,7 @@
"9080-1500",
"9080-1750",
"9080-2000",
"9080-3000",
"자체 발전기 7㎾",
"자체 발전기 10㎾",
"자체 대형 브레이커 0.18㎥",
"자체 에어 호스 1.91㎝",
"자체 모노펌프 5-20㎥/hr",
"자체 콘크리트펌프용 파이프 150㎜×1m",
"자체 콘크리트펌프용 파이프 150㎜×2m",
"자체 콘크리트펌프용 파이프 150㎜×3m",
"자체 콘크리트펌프용 파이프 곡관 150㎜ 45°",
"자체 콘크리트펌프용 파이프 곡관 150㎜ 60°",
"자체 콘크리트펌프용 파이프 곡관 150㎜ 90°"
"9080-3000"
]
}
],
@@ -1416,114 +1404,6 @@
"시간환산": { "기호": "Q", "아래": "시간환산" }
}
},
{
"키": "GC001195",
"원문번호": "8",
"구분": "건설품셈 공통",
"상세구분": "08장 건설기계",
"이름": "파워트로웰 시간당 기계경비",
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"부품": {
"갈래": "기계경비식"
},
"소유": "공용",
"비고": "품셈에 없는 기계 — EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 기계표 EQ000662 파워트로웰 3.73㎾ 값을 읽음 · 손료 = 가격 × 손료계수 · 잡품 = 주연료 줄의 % · 전력 = 기계표 전력 칸 · 조종원 없음",
"입력": [
{
"이름": "지역",
"고르기": [
"전국평균",
"전국",
"서울",
"경기",
"강원",
"충북",
"충남",
"전북",
"경북",
"경남",
"부산",
"제주",
"대구",
"인천",
"대전",
"울산",
"세종",
"전남광주"
],
"출처": "건설품셈 공통 8-1-7 5"
},
{
"이름": "전력 계약종별",
"고르기": [
"주택용 저압",
"일반용(갑)Ⅰ 저압"
],
"출처": "유가전력 한전 전기요금표 2025-01-01",
"비고": "품셈·계약예규에 계약종별 규정 없음(전력비는 실비 계상) · 임시전력(갑)은 주택용 저압 요금 그대로(요금표 6.가)"
}
],
"중간": [
{
"이름": "손료",
"식": "버림(EQ000662.가격 * 1000 * EQ000662.손료계수 / 10000000, 0)",
"출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4"
}
],
"호표": [
{
"종류": "재료",
"요소": "MO:휘발유:{지역}",
"이름": "주연료",
"단위": "ℓ",
"수량": "EQ000662.주연료",
"비목": "재료비",
"출처": "건설품셈 공통 8-4-1"
},
{
"종류": "재료",
"요소": {
"이름": "전력량요금",
"계약종별": "입력"
},
"이름": "전력",
"단위": "㎾h",
"수량": "EQ000662.전력",
"비목": "재료비",
"출처": "자체"
}
],
"덧줄": [
{
"이름": "잡재료비",
"식": "줄.'주연료' * EQ000662.잡재료율 / 100",
"비목": "재료비",
"출처": "건설품셈 공통 8-4 주②"
},
{
"이름": "기계손료",
"식": "손료",
"비목": "경비",
"출처": "건설품셈 공통 8-1-6 2"
}
],
"끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음",
"변수표": {
"지역": {
"기호": "S",
"아래": "지역"
},
"전력 계약종별": {
"기호": "S",
"아래": "전력계약종별"
},
"손료": {
"기호": "C",
"아래": "손료"
}
}
},
{
"키": "GC001143",
"원문번호": "8-1-4",
@@ -62,7 +62,7 @@
"갈래": "기계경비식"
},
"변수": [
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["1730-0015", "7204-0055", "0602-0150", "2702-0020", "0101-0007", "0101-0010", "0101-0012", "0101-0019", "0101-0032", "2105-0005", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "4504-0032", "4504-0036", "4504-0041", "4504-0043", "4504-0047", "4504-0052", "4205-0010", "4205-0017", "4205-0020", "4205-0030", "4205-0040", "4205-0045", "2105-0002", "2105-0003", "2105-0010", "2105-0015", "2105-0018", "2502-0025", "0602-0025", "2502-0050", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "7204-0018", "7204-0160", "5205-0210", "5405-0110", "7505-0450", "5205-0255", "7505-0150", "5205-0103", "6701-0147", "6701-0184", "5205-0170", "7505-0350", "0302-0134", "7505-0100", "7505-0050", "5205-0071", "7505-0025", "0502-0036", "1306-0100", "1506-0032", "0121-0004", "1305-0007", "7204-0038", "7204-0065", "0302-0057", "5701-0020", "0302-0095", "3201-0003", "1106-0012", "1406-0015", "1206-0008", "3601-0102", "1306-0025", "0211-0018", "0602-0045", "0602-0060", "0602-0105", "1630-0080", "3901-0300", "7505-0250", "2502-0035", "1306-0120", "1106-0010", "3201-0001", "1406-0008", "3601-0202", "3601-0402", "3611-0142", "2502-0020", "2502-0075", "5205-0035", "2702-0030", "2702-0060", "0211-0060", "0211-0080", "0211-0100", "0221-0040", "0221-0070", "0302-0025", "0302-0172", "0302-0229", "0302-0287", "0302-0350", "0302-0500", "0406-0054", "0406-0115", "0406-0161", "0406-0206", "0503-0036", "0602-0200", "0602-0240", "0602-0320", "1106-0015", "1206-0010", "1206-0014", "1209-0001", "1209-0002", "1209-0004", "1209-0006", "1209-0007", "1209-0008", "1209-0013", "1306-0044", "1306-0060", "1406-0025", "1506-0011", "1506-0012", "1506-0015", "1506-0019", "1506-0025", "1506-0030", "1506-0037", "2602-0015", "2602-0025", "2602-0035", "2602-0045", "2702-0040", "3530-0015", "3530-0036", "3601-0204", "3601-0302", "3701-0200", "3805-0120", "4504-0021", "4504-0028", "5405-0150", "5701-0010", "7106-0035", "7505-0125", "7505-0200", "7505-0500", "7505-0700", "9010-0003", "9010-0006", "9010-0010", "9010-0012", "9010-0020", "9010-0022", "9010-0033", "9010-0040", "9010-0044", "9010-0060", "9010-0080", "9020-0010", "9020-0015", "9020-0016", "9020-0022", "9020-0035", "9020-0050", "9020-0072", "9020-0160", "9020-0180", "9020-0200", "9050-0075", "9050-0150", "9050-0450", "9050-0750", "9050-0850", "9090-0800", "9090-1000", "9090-1200", "9090-1300", "9090-1400", "9090-1500", "9090-1600", "자체 발전기 5.5㎾"], "값": "1730-0015"}},
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["1730-0015", "7204-0055", "0602-0150", "2702-0020", "0101-0007", "0101-0010", "0101-0012", "0101-0019", "0101-0032", "2105-0005", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "4504-0032", "4504-0036", "4504-0041", "4504-0043", "4504-0047", "4504-0052", "4205-0010", "4205-0017", "4205-0020", "4205-0030", "4205-0040", "4205-0045", "2105-0002", "2105-0003", "2105-0010", "2105-0015", "2105-0018", "2502-0025", "0602-0025", "2502-0050", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "7204-0018", "7204-0160", "5205-0210", "5405-0110", "7505-0450", "5205-0255", "7505-0150", "5205-0103", "6701-0147", "6701-0184", "5205-0170", "7505-0350", "0302-0134", "7505-0100", "7505-0050", "5205-0071", "7505-0025", "0502-0036", "1306-0100", "1506-0032", "0121-0004", "1305-0007", "7204-0038", "7204-0065", "0302-0057", "5701-0020", "0302-0095", "3201-0003", "1106-0012", "1406-0015", "1206-0008", "3601-0102", "1306-0025", "0211-0018", "0602-0045", "0602-0060", "0602-0105", "1630-0080", "3901-0300", "7505-0250", "2502-0035", "1306-0120", "1106-0010", "3201-0001", "1406-0008", "3601-0202", "3601-0402", "3611-0142", "2502-0020", "2502-0075", "5205-0035", "2702-0030", "2702-0060", "0211-0060", "0211-0080", "0211-0100", "0221-0040", "0221-0070", "0302-0025", "0302-0172", "0302-0229", "0302-0287", "0302-0350", "0302-0500", "0406-0054", "0406-0115", "0406-0161", "0406-0206", "0503-0036", "0602-0200", "0602-0240", "0602-0320", "1106-0015", "1206-0010", "1206-0014", "1209-0001", "1209-0002", "1209-0004", "1209-0006", "1209-0007", "1209-0008", "1209-0013", "1306-0044", "1306-0060", "1406-0025", "1506-0011", "1506-0012", "1506-0015", "1506-0019", "1506-0025", "1506-0030", "1506-0037", "2602-0015", "2602-0025", "2602-0035", "2602-0045", "2702-0040", "3530-0015", "3530-0036", "3601-0204", "3601-0302", "3701-0200", "3805-0120", "4504-0021", "4504-0028", "5405-0150", "5701-0010", "7106-0035", "7505-0125", "7505-0200", "7505-0500", "7505-0700", "9010-0003", "9010-0006", "9010-0010", "9010-0012", "9010-0020", "9010-0022", "9010-0033", "9010-0040", "9010-0044", "9010-0060", "9010-0080", "9020-0010", "9020-0015", "9020-0016", "9020-0022", "9020-0035", "9020-0050", "9020-0072", "9020-0160", "9020-0180", "9020-0200", "9050-0075", "9050-0150", "9050-0450", "9050-0750", "9050-0850", "9090-0800", "9090-1000", "9090-1200", "9090-1300", "9090-1400", "9090-1500", "9090-1600"], "값": "1730-0015"}},
{"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류"}},
@@ -306,7 +306,7 @@
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"변수": [
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["7620-0003", "7621-0100", "7995-0050", "5210-0013", "5210-0027", "5220-0015", "7730-0150", "6516-0060", "6516-0120", "6516-0250", "6516-0300", "6105-0190", "6202-0060", "0230-0002", "6410-0080", "6410-0100", "6410-0120", "6410-0150", "6410-0200", "6901-0010", "6530-0090", "6530-0120", "6517-0100", "6517-0150", "6517-0200", "6517-0250", "6517-0300", "6510-0100", "6510-0150", "6510-0200", "6510-0250", "6510-0300", "6518-0100", "6518-0150", "6518-0200", "6518-0250", "6518-0300", "5501-0080", "7750-0016", "7750-0025", "7730-0050", "5401-0017", "5202-0127", "5202-0240", "5202-0300", "7611-0200", "7612-0200", "5204-0200", "5204-0300", "5204-0400", "5204-0500", "5204-0600", "5702-0095", "3430-0400", "7431-1300", "7740-0080", "7740-0150", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "2208-5008", "2208-5010", "2208-5012", "2208-5016", "2208-5020", "2105-0002", "2105-0003", "2105-0005", "2105-0010", "2105-0015", "2105-0018", "0201-0012", "0201-0020", "0201-0040", "0201-0060", "0201-0070", "0201-0080", "0201-0100", "0201-0120", "0201-0200", "6105-0390", "6202-0125", "7830-0081", "7950-0149", "7951-0066", "7952-0246", "7953-3400", "7954-1119", "7611-0300", "7431-1100", "7431-2300", "7431-2500", "7611-0400", "7611-0500", "7614-0300", "0250-0100", "5210-0010", "5210-0019", "0103-0016", "0103-0019", "0103-0023", "0103-0027", "0103-0032", "0230-0004", "0230-0006", "0230-0007", "0230-0008", "0230-0010", "0240-0007", "0250-0080", "0407-0054", "0407-0092", "0407-0107", "0407-0161", "0407-0206", "0610-0150", "0610-0200", "0610-0240", "2115-0024", "2115-0031", "2115-0036", "2116-0031", "2116-0036", "2117-0022", "2117-0027", "2118-0010", "2210-0145", "3430-0300", "3450-0642", "4115-0100", "4115-0150", "4115-0200", "4115-0300", "4505-0015", "4505-0026", "4611-0075", "5105-0050", "5105-0100", "5105-0150", "5105-0200", "5111-0040", "5111-0050", "5111-0060", "5111-0076", "5111-0091", "5112-0001", "5112-0002", "5112-0003", "5112-0004", "5112-0005", "5113-0001", "5113-0002", "5113-0003", "5113-0004", "5113-0005", "5113-0006", "5113-0007", "5113-0008", "5113-0009", "5113-0010", "5113-0011", "5114-0001", "5114-0002", "5114-0003", "5114-0004", "5114-0005", "5114-0006", "5114-0007", "5114-0008", "5115-0030", "5115-0055", "5115-0075", "5115-0095", "5116-0001", "5116-0002", "5116-0003", "5116-0004", "5116-0005", "5116-0006", "5116-0007", "5116-0008", "5117-0001", "5117-0002", "5117-0003", "5117-0004", "5117-0005", "5117-0006", "5117-0007", "5117-0008", "5118-0001", "5118-0002", "5118-0003", "5118-0004", "5118-0005", "5118-0006", "5118-0007", "5119-0625", "5203-1800", "5203-2200", "5203-2700", "5203-3500", "5203-4500", "5330-0074", "5805-0002", "5805-0003", "5901-0006", "5901-0010", "5901-0016", "6202-0200", "6408-0015", "6408-0020", "6408-0030", "6408-0040", "6408-0050", "6408-0085", "6408-0100", "6515-0090", "6530-0030", "6530-0040", "6530-0045", "6530-0060", "6532-0220", "6550-0130", "7103-0010", "7104-0010", "7202-0008", "7202-0010", "7205-0125", "7206-0020", "7206-0070", "7206-0100", "7430-1100", "7430-1300", "7430-1500", "7430-2300", "7430-2500", "7612-0300", "7612-0400", "7613-0075", "7613-0150", "7613-0300", "7613-0400", "7613-0600", "7613-0900", "7620-0002", "7730-0080", "7730-0100", "7730-0125", "7740-0100", "7770-0004", "7930-0001", "7930-0002", "7930-0003", "7930-0005", "7930-0007", "7930-0010", "7930-0015", "7930-0020", "7930-0025", "7930-0030", "7930-0040", "7930-0050", "7930-0075", "7930-0100", "7935-0180", "7991-0050", "7991-0100", "7991-0500", "7993-0020", "8201-0100", "8202-0100", "8203-0019", "8204-0100", "8205-0100", "9060-0060", "9060-0100", "9060-0200", "9060-0300", "9060-0500", "9060-0600", "9080-0050", "9080-0080", "9080-0100", "9080-0120", "9080-0150", "9080-0200", "9080-0300", "9080-0500", "9080-0700", "9080-1000", "9080-1100", "9080-1400", "9080-1500", "9080-1750", "9080-2000", "9080-3000", "자체 발전기 7㎾", "자체 발전기 10㎾", "자체 대형 브레이커 0.18㎥", "자체 에어 호스 1.91㎝", "자체 모노펌프 5-20㎥/hr", "자체 콘크리트펌프용 파이프 150㎜×1m", "자체 콘크리트펌프용 파이프 150㎜×2m", "자체 콘크리트펌프용 파이프 150㎜×3m", "자체 콘크리트펌프용 파이프 곡관 150㎜ 45°", "자체 콘크리트펌프용 파이프 곡관 150㎜ 60°", "자체 콘크리트펌프용 파이프 곡관 150㎜ 90°"], "값": "7620-0003"}},
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["7620-0003", "7621-0100", "7995-0050", "5210-0013", "5210-0027", "5220-0015", "7730-0150", "6516-0060", "6516-0120", "6516-0250", "6516-0300", "6105-0190", "6202-0060", "0230-0002", "6410-0080", "6410-0100", "6410-0120", "6410-0150", "6410-0200", "6901-0010", "6530-0090", "6530-0120", "6517-0100", "6517-0150", "6517-0200", "6517-0250", "6517-0300", "6510-0100", "6510-0150", "6510-0200", "6510-0250", "6510-0300", "6518-0100", "6518-0150", "6518-0200", "6518-0250", "6518-0300", "5501-0080", "7750-0016", "7750-0025", "7730-0050", "5401-0017", "5202-0127", "5202-0240", "5202-0300", "7611-0200", "7612-0200", "5204-0200", "5204-0300", "5204-0400", "5204-0500", "5204-0600", "5702-0095", "3430-0400", "7431-1300", "7740-0080", "7740-0150", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "2208-5008", "2208-5010", "2208-5012", "2208-5016", "2208-5020", "2105-0002", "2105-0003", "2105-0005", "2105-0010", "2105-0015", "2105-0018", "0201-0012", "0201-0020", "0201-0040", "0201-0060", "0201-0070", "0201-0080", "0201-0100", "0201-0120", "0201-0200", "6105-0390", "6202-0125", "7830-0081", "7950-0149", "7951-0066", "7952-0246", "7953-3400", "7954-1119", "7611-0300", "7431-1100", "7431-2300", "7431-2500", "7611-0400", "7611-0500", "7614-0300", "0250-0100", "5210-0010", "5210-0019", "0103-0016", "0103-0019", "0103-0023", "0103-0027", "0103-0032", "0230-0004", "0230-0006", "0230-0007", "0230-0008", "0230-0010", "0240-0007", "0250-0080", "0407-0054", "0407-0092", "0407-0107", "0407-0161", "0407-0206", "0610-0150", "0610-0200", "0610-0240", "2115-0024", "2115-0031", "2115-0036", "2116-0031", "2116-0036", "2117-0022", "2117-0027", "2118-0010", "2210-0145", "3430-0300", "3450-0642", "4115-0100", "4115-0150", "4115-0200", "4115-0300", "4505-0015", "4505-0026", "4611-0075", "5105-0050", "5105-0100", "5105-0150", "5105-0200", "5111-0040", "5111-0050", "5111-0060", "5111-0076", "5111-0091", "5112-0001", "5112-0002", "5112-0003", "5112-0004", "5112-0005", "5113-0001", "5113-0002", "5113-0003", "5113-0004", "5113-0005", "5113-0006", "5113-0007", "5113-0008", "5113-0009", "5113-0010", "5113-0011", "5114-0001", "5114-0002", "5114-0003", "5114-0004", "5114-0005", "5114-0006", "5114-0007", "5114-0008", "5115-0030", "5115-0055", "5115-0075", "5115-0095", "5116-0001", "5116-0002", "5116-0003", "5116-0004", "5116-0005", "5116-0006", "5116-0007", "5116-0008", "5117-0001", "5117-0002", "5117-0003", "5117-0004", "5117-0005", "5117-0006", "5117-0007", "5117-0008", "5118-0001", "5118-0002", "5118-0003", "5118-0004", "5118-0005", "5118-0006", "5118-0007", "5119-0625", "5203-1800", "5203-2200", "5203-2700", "5203-3500", "5203-4500", "5330-0074", "5805-0002", "5805-0003", "5901-0006", "5901-0010", "5901-0016", "6202-0200", "6408-0015", "6408-0020", "6408-0030", "6408-0040", "6408-0050", "6408-0085", "6408-0100", "6515-0090", "6530-0030", "6530-0040", "6530-0045", "6530-0060", "6532-0220", "6550-0130", "7103-0010", "7104-0010", "7202-0008", "7202-0010", "7205-0125", "7206-0020", "7206-0070", "7206-0100", "7430-1100", "7430-1300", "7430-1500", "7430-2300", "7430-2500", "7612-0300", "7612-0400", "7613-0075", "7613-0150", "7613-0300", "7613-0400", "7613-0600", "7613-0900", "7620-0002", "7730-0080", "7730-0100", "7730-0125", "7740-0100", "7770-0004", "7930-0001", "7930-0002", "7930-0003", "7930-0005", "7930-0007", "7930-0010", "7930-0015", "7930-0020", "7930-0025", "7930-0030", "7930-0040", "7930-0050", "7930-0075", "7930-0100", "7935-0180", "7991-0050", "7991-0100", "7991-0500", "7993-0020", "8201-0100", "8202-0100", "8203-0019", "8204-0100", "8205-0100", "9060-0060", "9060-0100", "9060-0200", "9060-0300", "9060-0500", "9060-0600", "9080-0050", "9080-0080", "9080-0100", "9080-0120", "9080-0150", "9080-0200", "9080-0300", "9080-0500", "9080-0700", "9080-1000", "9080-1100", "9080-1400", "9080-1500", "9080-1750", "9080-2000", "9080-3000"], "값": "7620-0003"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
{"열쇠": "k_기계손료", "기호": "k", "아래": "기계손료", "위": "", "이름": "EQ:{기계}.손료계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "손료계수"}}
@@ -2213,43 +2213,6 @@
"비목": {"방법": "버림", "자리": 0},
"끝전": "비목버림"
}
},
{
"꼴": "수식문서",
"판": 1,
"키": "GC001195",
"이름": "파워트로웰 시간당 기계경비",
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"부품": {
"갈래": "기계경비식"
},
"변수": [
{"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}},
{"열쇠": "전력 계약종별", "기호": "S", "아래": "전력계약종별", "위": "", "이름": "전력 계약종별", "단위": "", "출처": "유가전력 한전 전기요금표 2025-01-01", "연결": {"갈래": "선택", "목록": ["주택용 저압", "일반용(갑)Ⅰ 저압"], "값": "주택용 저압"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "P_파워트로웰", "기호": "P", "아래": "파워트로웰", "위": "", "이름": "EQ000662.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ000662", "칸": "가격", "계약종별": "S_전력계약종별"}},
{"열쇠": "k_파워트로웰", "기호": "k", "아래": "파워트로웰", "위": "", "이름": "EQ000662.손료계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ000662", "칸": "손료계수", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_파워트로웰2", "기호": "P", "아래": "파워트로웰2", "위": "", "이름": "EQ000662.주연료", "단위": "", "연결": {"갈래": "요소", "키": "EQ000662", "칸": "주연료", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_휘발유", "기호": "P", "아래": "휘발유", "위": "", "이름": "휘발유단가", "단위": "", "연결": {"갈래": "요소", "키": "MO:휘발유:{S_지역}", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_파워트로웰3", "기호": "P", "아래": "파워트로웰3", "위": "", "이름": "EQ000662.전력", "단위": "", "연결": {"갈래": "요소", "키": "EQ000662", "칸": "전력", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_전력", "기호": "P", "아래": "전력", "위": "", "이름": "전력단가", "단위": "", "연결": {"갈래": "요소", "재료": {"이름": "전력량요금", "계약종별": "입력"}, "단위": "㎾h", "계약종별": "S_전력계약종별"}},
{"열쇠": "r_파워트로웰", "기호": "r", "아래": "파워트로웰", "위": "", "이름": "EQ000662.잡재료율", "단위": "", "연결": {"갈래": "요소", "키": "EQ000662", "칸": "잡재료율", "계약종별": "S_전력계약종별"}}
],
"식": [
{"기호": "C_손료", "식": "버림(P_파워트로웰 × 1000 × k_파워트로웰 ÷ 10000000, 0)"}
],
"금액": [
{"이름": "주연료", "단위": "ℓ", "비목": "재료비", "수량": "P_파워트로웰2", "단가": "P_휘발유", "출처": "건설품셈 공통 8-4-1", "기호": "C_주연료"},
{"이름": "전력", "단위": "㎾h", "비목": "재료비", "수량": "P_파워트로웰3", "단가": "P_전력", "출처": "자체"},
{"이름": "잡재료비", "단위": "식", "비목": "재료비", "단가": "C_주연료 × r_파워트로웰 ÷ 100", "출처": "건설품셈 공통 8-4 주②"},
{"이름": "기계손료", "단위": "식", "비목": "경비", "단가": "C_손료", "출처": "건설품셈 공통 8-1-6 2"}
],
"끝수": {
"줄": {"방법": "버림", "자리": 1},
"비목": {"방법": "버림", "자리": 0},
"끝전": "비목버림"
}
}
]
}
@@ -1,173 +0,0 @@
"""820-1 지킴 — 품셈에 없는 소형 · 보조 기계 13 줄(EST 구성 씨앗 · 가격 갱신 필요 · 요구 785 ~ 787).
(a) 새 줄 · 값 — 발전기 손료계수 2362 공통(품셈 발전기 계열) · 브레이커 0.18 = 0.2㎥ 계수 · 가격 = 씨앗 ·
파이프 = 달러 × 환율(비고에 $ 값) · 비고 「갱신 필요」
(b) 계산식 — 손료 = 버림(가격 × 1000 × 손료계수 ÷ 10⁷) · 손료만 = GC000273 · 발전기 5.5 = GC000268 ·
파워트로웰 = GC001195(휘발유 + 잡품(주연료 줄의 %) + 전력) · 옛 엔진 = 새 수식 문서 · 시간당 이음 한 길
(c) 이음 — 가져온 이름 · 규격 → 새 줄 · 옛 기계 그대로
(d) 다른 문서 그대로 — 새 고르기는 목록 끝에만 · 기본값 그대로
"""
from __future__ import annotations
import importlib
import json
import sys
from decimal import Decimal
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
for p in (ROOT, ROOT / "resources/master_data/scripts"):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
from test_815_1_import_link import no_aliases # noqa: E402 — 진짜 마스터 별칭 빼고
from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402
from M02_MasterTemplete import M02_Doc_Import as di # noqa: E402
from M02_MasterTemplete import M02_Machine_Find as mf # noqa: E402
from M02_MasterTemplete import M02_Machine_Hourly as mh # noqa: E402
ml = importlib.import_module("master_doc_link")
mc = importlib.import_module("master_doc_calc")
b802 = importlib.import_module("build_수식문서_802_2")
meq = importlib.import_module("master_equation")
NEW = {f"EQ{n:06d}" for n in range(656, 669)}
SEED = { # 키 → (이름, 규격, 가격, 손료계수, 시간당 식)
"EQ000656": ("발전기", "5.5㎾", 1063, 2362, "GC000268"),
"EQ000657": ("발전기", "7㎾", 1828.9, 2362, "GC000273"),
"EQ000658": ("발전기", "10㎾", 3213, 2362, "GC000273"),
"EQ000659": ("대형 브레이커", "0.18㎥", 4559, 6601, "GC000273"),
"EQ000660": ("에어 호스", "1.91㎝", 105, 5625, "GC000273"),
"EQ000661": ("모노펌프", "5-20㎥/hr", 4900, 4346, "GC000273"),
"EQ000662": ("파워트로웰", "3.73㎾", 2680, 5313, "GC001195"),
}
USD = {"150㎜×1m": 25, "150㎜×2m": 37, "150㎜×3m": 46,
"곡관 150㎜ 45°": 67, "곡관 150㎜ 60°": 84, "곡관 150㎜ 90°": 103} # fmt: skip
def _eq() -> dict[str, dict]:
return m01.loaded()[1].index["EQ"]
def _docs():
docs = ml.load_all()
return docs, ml.Links(m01.loaded()[1], docs)
def _loss(price, factor) -> Decimal:
return (Decimal(str(price)) * 1000 * Decimal(factor) / 10_000_000).quantize(Decimal(1), "ROUND_DOWN") # fmt: skip
def test_rows_and_values() -> None:
eq = _eq()
assert NEW <= set(eq)
for key, (name, spec, price, factor, _) in SEED.items():
row = eq[key]
assert (row["이름"], row["규격"]) == (name, spec), key
assert row["값"]["가격"] == Decimal(str(price)) and row["값"]["손료계수"] == factor, key
assert "갱신 필요" in row["비고"] and row["출처"]["가격"] == "자체", key
gens = [r for r in eq.values() if r.get("상세구분") == "발전기"]
assert {int(r["값"]["손료계수"]) for r in gens} == {2362} # 발전기 계열 공통
small, big = eq["EQ000659"]["값"], eq["EQ000031"]["값"]
for k in ("가격", "상각비계수", "정비비계수", "관리비계수", "손료계수"):
assert small[k] == big[k], k
pipes = {r["규격"]: r for r in eq.values() if r.get("상세구분") == "콘크리트펌프용 파이프"}
assert set(pipes) == set(USD)
rate = m01.loaded()[1].index["FX"]["FX000001"]["값"]
for spec, usd in USD.items():
row = pipes[spec]
assert row["값"]["손료계수"] == 4500 and f"${usd} 표기" in row["비고"]
assert abs(float(row["값"]["가격"]) - usd * float(rate) / 1000) < 1e-3
trowel = eq["EQ000662"]["값"]
assert (trowel["주연료종류"], trowel["주연료"], trowel["잡재료율"], trowel["전력"]) == ("휘발유", 1, 10, Decimal("3.73")) # fmt: skip
def test_formula_loss_and_one_way() -> None:
docs, links = _docs()
files, whole = m01.loaded()
logic = {r["키"]: r for _, r in b802.rows_of(files)}
for key in sorted(NEW):
row = _eq()[key]
got = mh.hourly(key)
assert got["ok"], (key, got)
want = SEED.get(key, (None, None, None, None, "GC000273"))[4]
assert got["로직"] == want, key
lg = logic[want]
given = meq.given_of(whole, lg)
changed = {"기계": row["원문번호"]} if mh.takes_machine(lg) else None
new = b802.new_answer(links, docs[want], changed)
old = b802.old_answer(whole, lg, {**given, **(changed or {})})
assert old == new and float(new["계"]) == got["계"], key # 옛 엔진 = 새 문서 = 시간당 이음
assert new["경비"] == _loss(row["값"]["가격"], row["값"]["손료계수"]), (
key
) # 손료 = 가격 × 계수
if want == "GC000273":
assert new["노무비"] == new["재료비"] == 0, key
assert di.hourly_link("EQ000662", "재료비")["키"] == "GC001195"
assert di.hourly_link("EQ000656", "노무비")["입력"]["기계"] == "'자체 발전기 5.5㎾'"
def test_trowel_lines() -> None:
docs, links = _docs()
got = mc.calc(docs["GC001195"], links)
lines = {x["이름"]: x for x in got["줄"]}
assert set(lines) == {"주연료", "전력", "잡재료비", "기계손료"}
assert lines["전력"]["수량"] == Decimal("3.73") and lines["주연료"]["수량"] == 1
assert lines["잡재료비"]["금액"] == (lines["주연료"]["금액"] * Decimal("0.1")).quantize(Decimal("0.1"), "ROUND_DOWN") # fmt: skip
assert got["노무비"] == 0 and got["경비"] == _loss(2680, 5313)
def test_find_links() -> None:
cases = {
("발전기", "5.5kW"): "EQ000656", ("발전기", "7kW"): "EQ000657",
("발전기", "10kW"): "EQ000658", ("발전기", "25kW"): "EQ000452",
("에어 호스", "1.91cm"): "EQ000660", ("에어호스", "1.91cm"): "EQ000660",
("모노펌프", "5-20㎥/hr"): "EQ000661", ("모노펌프", ""): "EQ000661",
("브레이카", "0.18㎥"): "EQ000659", ("대형브레이카", "0.2㎥"): "EQ000031",
("파워트로웰", "3.73kW"): "EQ000662",
("콘크리트펌프용 파이프", "150mm×1m"): "EQ000663",
("콘크리트펌프용 파이프", "150㎜×3m"): "EQ000665",
("콘크리트펌프용 파이프", "곡관 45˚"): "EQ000666",
("콘크리트펌프용 파이프", "곡관 150㎜ 90°"): "EQ000668",
} # fmt: skip
with no_aliases("EQ"):
for (name, spec), key in cases.items():
got = mf.find(name, spec)
assert (got["판정"], got["키"]) == (mf.LINKED, key), (name, spec, got)
def test_other_docs_same() -> None:
"""새 고르기는 기계경비식 목록 끝에만 · 기본값은 옛 기계 그대로."""
text = (ROOT / "resources/master_data/로직_건설품셈_08장_건설기계.json").read_text("utf-8")
codes = {r["원문번호"] for k, r in _eq().items() if k in NEW}
for row in json.loads(text)["줄"]:
for spec in row.get("입력") or []:
pick = [str(c) for c in spec.get("고르기") or []]
mine = [i for i, c in enumerate(pick) if c in codes]
if mine:
assert mine == list(range(len(pick) - len(mine), len(pick))), row["키"]
assert pick[0] not in codes, row["키"]
old = [k for k, r in _eq().items() if k not in NEW]
assert not [k for k in old if str(_eq()[k].get("원문번호") or "").startswith("자체 ")]
def test_blank_input_name_link_roundtrip() -> None:
"""820-2 — 빈칸 든 입력 이름(「전력 계약종별」)이 이음 글 → 읽기 왕복 · EST main 504 번 = 저장 금액 · 우리 단가 셈."""
from M02_MasterTemplete import M02_San_Link as sl
from M02_MasterTemplete import M02_Sangeun_Imported as imp
text = sl.hourly_text("EQ000662")
assert "'전력 계약종별'=" in text
node = di.dc.cv.mf.parse(text)
assert node[0] == "logic" and "전력 계약종별" in [k for k, _ in node[2]]
assert imp._link(text, "로직", "시험", imp._whole()) == text
import test_810_2_native_doc as t
out = t.fn.emit(t.native("EST", "main"), 504)
sk, _, got = t.doc_and_money(out)
assert float(got["계"]) == 546 # 원파일 저장 금액
sk[imp.KEY]["단가기준"] = "우리"
ours = di.run(di.derive(sk, None, t.whole()), sk, t.whole())
assert ours.get("셈됨") is not False and ours["계"] is not None
@@ -0,0 +1,216 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""821-4 지킴 — 불러오기에서 끝내 못 이은 기계 = 내 기계 자동 생성(요구서_796 4 · 5 장 · 임시 storage).
① 구성 → 우리 틀 값(EST 원파일 발전기 5.5kW — 손료계수 · 가격 · 경유 0.625ℓ · 잡품 11 % · 일반기계운전사 1 인 ·
경쟁사 노임 환산 안 가져옴) · 엑셀 「기계경비」 · STmate 「중기사용료」 · 구성 없음 = 직접 꼴 · 달러 = 환율
② 층 저장 길(`FromHopyo.make`) = 만듦 · 다시 불러와도 중복 없음 · 같은 이름 있으면 그것 · 미리 보기는 안 만듦 ·
금액(엑셀 단가 그대로) 그대로 · [우리 단가로] = 내 기계 값 · 「못 이음」 에서 빠짐 · 사본에 박힘
③ 820-1 정리 — 13 줄 · GC001195 없음(대장 폐기 · 결번) · 덤프 1 톤 · 체인톱 식 · 별칭 그대로 ·
빈칸 든 입력 이름 이음 글 왕복(820-2 지킴 옮김) · EST main 504 파워트로웰 블록 금액 546 그대로
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
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_Doc_Import as dim
from M02_MasterTemplete import M02_Machine_Hourly as mh
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Estx as estx
from M02_MasterTemplete import M02_San_MyMachine as mm
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
from M02_MasterTemplete import M02_Sangeun_Imported as imp
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
ROOT = Path(__file__).resolve().parents[2]
MAIN = ROOT / "resources/knowledge/original/경쟁사 프로그램/EST Plus 2/다산소프트/2026-07-main.estx"
GEN = "E0000001016" # EST main 발전기 5.5kW
GONE = [f"EQ{n:06d}" for n in range(656, 669)]
@lru_cache(maxsize=1)
def _main_raw() -> bytes:
return MAIN.read_bytes()
@lru_cache(maxsize=1)
def _main() -> dict:
return fn.read(MAIN.name, _main_raw())
def _excel(tag: str) -> dict:
import test_797_1a_import_flags as practice
p = practice.FILES.get(tag)
if not p or not p.exists():
pytest.skip(f"실무 엑셀 없음 {tag}")
return book.sheets(p.name, p.read_bytes())
def _line(name: str, spec: str, **res) -> dict:
found = {"판정": "못 찾음", "이름": name, "규격": spec}
return {"종류": "기계", "이름": name, "자원": {"갈래": "기계", "이름": name, "규격": spec, "찾기": found, **res}} # fmt: skip
def _labor(name: str) -> str:
return next(k for k, r in m01.loaded()[1].numbers["LB"].items() if r["이름"] == name)
# ── ① 구성 → 우리 틀 값 ───────────────────────────────────────────────
def test_est_native_generator_values() -> None:
make = estx.makes # 816-2 대조 꼴은 그대로(손료 · 주연료 · 조종원 있음만)
assert make is not None
got = _main()["기계구성"][GEN]
assert got["손료"] == [[2362.0, 1063000.0, False]] and got["주연료"] == 0.625
assert (got["주연료종류"], got["잡재료율"], got["조종원"], got["조종원직종"]) == ("경유", 11.0, 1.0, "일반기계운전사") # fmt: skip
body = mm.body(got, 0, _line("발전기", "5.5kW", 코드=GEN), "main.estx")
want = {"가격": 1063.0, "손료계수": 2362.0, "주연료": 0.625, "주연료종류": "경유", "잡재료율": 11.0,
"조종원": 1.0, "조종원직종": _labor("일반기계운전사")} # fmt: skip
assert body["방식"] == "바로" and body["값"] == want
assert body["비고"].startswith("불러오기 자동 생성 — main.estx · 값 확인 필요")
def test_dollar_and_combo_pieces() -> None:
made = _main()["기계구성"]
pipe = mm.body(made["E0000000623"], 0, _line("콘크리트펌프용 파이프", "150㎜×1m"), "f")
rate = float(m01.loaded()[1].index["FX"]["FX000001"]["값"])
assert abs(pipe["값"]["가격"] - 25 * rate / 1000) < 1e-3 and "$25" in pipe["비고"]
combo = made["E0000001170"] # 대형브레이카 +굴착기(1.0㎥) — 조각 둘
first = mm.body(combo, 0, _line("굴착기", "1.0㎥"), "f")["값"]
second = mm.body(combo, 1, _line("대형브레이카", "1.0㎥"), "f")["값"]
assert first["손료계수"] == 2085 and first["주연료"] == 19.5 and first["조종원"] == 1
assert second == {"가격": 28692.0, "손료계수": 6601.0} # 다음 조각 = 손료만
assert mm.body(None, 1, _line("x", "1"), "f") is None # 구성 없는 조합 조각 = 안 만듦
def test_excel_sheets() -> None:
est = mm.xlsx_makes(_excel("2016사방"))[mm.key_of("발전기", "5.5kW")]
assert est["손료"] == [[2294.0, 1167000.0, False]] and est["주연료"] == 0.625
assert (est["잡재료율"], est["조종원"], est["조종원직종"]) == (11.0, 1.0, "일반기계운전사")
stm = mm.xlsx_makes(_excel("봉화"))["X00001"] # STmate 중기사용료 — 계수(천원당) × 10⁴
assert stm["손료"] == [[2085.0, 62550000.0, False]]
assert (stm["주연료종류"], stm["주연료"], stm["잡재료율"], stm["조종원직종"]) == ("경유", 5.0, 21.0, "건설기계운전사") # fmt: skip
def test_direct_without_make() -> None:
line = _line("소형기계", "1대", 단가={"L": 10, "M": 20, "S": 30})
got = mm.body(None, 0, line, "a.stc")
assert got["방식"] == "직접" and got["시간당"] == {"노무비": 10, "재료비": 20, "경비": 30}
# ── ② 층 저장 길 ──────────────────────────────────────────────────────
TROWEL = 504 # EST main 「콘크리트 바닥 마감(Power Trowel)」 — 파워트로웰 3.73kW = 이제 품셈에 없음
WANT = ("파워트로웰", "3.73kW")
def test_generator_ours_formula(world) -> None:
"""발전기 5.5kW 내 기계 시간당 노무비 = 우리 노임 ÷ 8 × 1 인(EST 의 ×16/12 ×25/20 안 씀)."""
me = world["personal"]
me.mkdir(parents=True, exist_ok=True)
body = mm.body(_main()["기계구성"][GEN], 0, _line("발전기", "5.5kW"), "main.estx")
with store.at(me):
key = mym.create(body)["줄"]["키"]
hour = mh.hourly(key)
lb = m01.loaded()[1].numbers["LB"][_labor("일반기계운전사")]
assert hour["ok"] and hour["로직"] == "GC000268"
assert abs(hour["노무비"] - float(lb["값"]) / 8) < 1
def test_make_flow(world) -> None:
me = world["personal"]
me.mkdir(parents=True, exist_ok=True)
with store.at(me):
hp = fh.upload_native(me, MAIN.name, _main_raw(), {"이름": "sub3임시"})["키"]
out = fh.emit(me, hp, TROWEL) # 미리 보기 = 안 만듦 · 예정만
assert {"이름": WANT[0], "규격": WANT[1]} in out["원본"]["내기계예정"]
assert mym.rows() == []
got = fh.make(me, hp, TROWEL)
made = {(m["이름"], m["규격"]): m for m in got["내기계"]}
one = made[WANT]
assert one["새로"] and one["키"].startswith("EQP42-") and one["방식"] == "바로"
assert got["검산"]["같음"] # 금액(엑셀 단가 그대로) 그대로
n = len(mym.rows())
again = fh.make(me, hp, TROWEL) # 다시 불러옴 = 중복 없음 · 같은 SK
assert len(mym.rows()) == n and again["다시씀"]
assert not any(m["새로"] for m in again["내기계"])
doc = store.read("sangeun", got["이름"])["문서"]
assert doc[imp.KEY]["원본"]["내기계"][0]["키"] == one["키"]
items = doc[imp.KEY]["로직"]["호표"]
mine = [x for x in items if (x.get("자원") or {}).get("내기계") == one["키"]]
assert mine and all(f"개인 {one['키']}" in str(x.get("이음")) for x in mine)
assert not [
u for u in imp.unlinked(doc) if "파워트로웰" in u["이름"]
] # 「못 이음」 에서 빠짐
row = next(r for r in mym.rows() if r["키"] == one["키"])
assert row["값"]["가격"] == 2680 and row["값"]["주연료종류"] == "휘발유"
assert row["값"]["전력"] == 3.73 and "값 확인 필요" in row["비고"]
ours = imp.to_price(doc, "우리") # [우리 단가로] = 내 기계 값
assert ours["후"]["계"] is not None and ours["후"] != ours["전"]
assert not [u for u in ours["이음없음"] if "파워트로웰" in u["이름"]]
assert one["키"] in {r["키"] for r in (doc.get(skc.KEY) or {}).get("단가") or []} # 사본
conv = dim.derive(doc, None, imp._whole())
assert f"내 기계 {one['키']}" in json.dumps(
conv["변수"], ensure_ascii=False, default=str
) # 꼬리표
def test_reuse_same_name(world) -> None:
me = world["personal"]
me.mkdir(parents=True, exist_ok=True)
with store.at(me):
have = mym.create({"이름": WANT[0], "규격": WANT[1], "방식": "바로", "값": {"가격": 9, "손료계수": 9}}) # fmt: skip
out = fn.emit(_main(), TROWEL)
made = mm.attach(out, _main()["기계구성"])
assert [(m["키"], m["새로"]) for m in made] == [(have["줄"]["키"], False)]
assert len(mym.rows()) == 1
def test_not_personal_makes_nothing(world) -> None:
out = fn.emit(_main(), TROWEL)
with store.at(world["company"]):
assert mm.attach(out, _main()["기계구성"]) == []
assert out["원본"]["내기계예정"] == [{"이름": WANT[0], "규격": WANT[1]}]
# ── ③ 820-1 정리 ─────────────────────────────────────────────────────
def test_cleanup_820_1() -> None:
files, whole = m01.loaded()
eq = whole.index["EQ"]
assert not set(GONE) & set(eq) and "EQ000655" in eq and eq["EQ000655"]["규격"] == "1ton"
assert not [r for r in eq.values() if str(r.get("원문번호") or "").startswith("자체 ")]
logics = {r["키"] for d in files.values() if d.get("그룹") == "로직" for r in d.get("줄") or []}
assert "GC001195" not in logics and "GF000337" in logics and "GF000145" in logics
text = json.dumps(
[d for d in files.values() if d.get("그룹") == "로직"], ensure_ascii=False, default=str
)
assert "자체 발전기" not in text and "자체 콘크리트펌프용" not in text
led = json.loads((ROOT / "resources/master_data/_키대장.json").read_text("utf-8"))
assert all(k in led["폐기"] and k not in led["키"] for k in [*GONE, "GC001195"])
assert led["다음"]["EQ"] == 669 and led["다음"]["GC"] == 1196 # 결번 · 재사용 안 함
alias = [r for r in eq.values() if r.get("별칭")]
assert len(alias) >= 10
def test_blank_input_name_roundtrip() -> None:
"""820-2 지킴 옮김 — 빈칸 든 입력 이름은 따옴표로 이음 글 → 읽기 왕복."""
text = f"로직(GC000268, {dim._q('전력 계약종별')}='일반용(갑)Ⅰ 저압', 기계='0101-0007')"
got = dim._logic_link(text)
assert got and got["입력"]["전력 계약종별"] == "'일반용(갑)Ⅰ 저압'"
def test_main_504_amount_same() -> None:
"""EST main 504 「콘크리트 바닥 마감(Power Trowel)」 = 원파일 저장 금액 546(파워트로웰 = 이제 못 이음 · 엑셀 값)."""
import test_810_2_native_doc as t
out = fn.emit(_main(), 504)
_, _, got = t.doc_and_money(out)
assert float(got["계"]) == 546
+45
View File
@@ -0,0 +1,45 @@
"""EST 테스트 앱 단가산출 전역변수(원본 \x05 표지 — 호표를 넘어 값 유지, 지정·해제, 목록)."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_global_variable_crosses_groups_and_toggle():
code = r"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({ structuredClone });
for (const f of ['native-math.js', 'native-formula.js', 'native-text.js', 'native-global-vars.js'])
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, { filename: f });
const T = vm.runInContext('EstText', ctx), G = vm.runInContext('EstGlobalVars', ctx);
const rows = [
{ Index: 1, Code: 'D1', LineData: '호표1' },
{ Index: 0, SCheck: 0, LineData: '\x05Q\x05 = 2 × 3 = \x126\x12' },
{ Index: 1, Code: 'D2', LineData: '호표2' },
{ Index: 0, SCheck: -1, LineData: '노무 = Q × 10 = \x120\x12' },
{ Index: 1, Code: 'D3', LineData: '호표3' },
{ Index: 0, SCheck: 0, LineData: 'R = 1 = \x121\x12' },
{ Index: 1, Code: 'D4', LineData: '호표4' },
{ Index: 0, SCheck: -1, LineData: '노무 = R × 10 = \x120\x12' },
];
const r = T.calculate(rows, {});
let bad = ''; try { G.toggle('가 = 3', 0); } catch (e) { bad = e.message.slice(0, 12); }
process.stdout.write(JSON.stringify({
used: r.rows[3].LineData.match(/\x12([^\x12]+)\x12/)[1], kept: r.rows[1].LineData.startsWith('\x05Q\x05'),
local: r.errors.length, globals: r.globals,
on: G.toggle('Q = 2', 0), off: G.toggle('⟦전역⟧Q⟦전역⟧ = 2', 5), bad,
clean: T.clean('\x05Q\x05 = 1'), shown: T.display('\x05V1\x05'), back: T.encode('⟦전역⟧V1⟦전역⟧') === '\x05V1\x05',
}));
"""
out = json.loads(subprocess.check_output(["node", "-e", code], cwd=ROOT).decode("utf-8"))
assert out["used"] == "60"
assert out["kept"] is True
assert out["local"] == 1 # R 은 전역이 아니라 D4 에서 못 찾음
assert out["globals"] == [{"name": "Q", "value": 6, "index": 1}]
assert out["on"] == "⟦전역⟧Q⟦전역⟧ = 2"
assert out["off"] == "Q = 2"
assert out["bad"] == "선택한 변수는 전역변수"
assert out["clean"] == "Q = 1"
assert out["shown"] == "⟦전역⟧V1⟦전역⟧" and out["back"] is True
+32
View File
@@ -0,0 +1,32 @@
"""EST 테스트 앱 JDLExcel과 단가비교(원본 ActionJDLCompare — Excel 코드 칸이 코드를 품으면 같은 행 합계와 TC 비교)."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_compare_contains_code_limit_and_xls_tables():
code = r"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({});
vm.runInContext(fs.readFileSync('tools/est-test/native-jdl-compare.js', 'utf8'), ctx);
const J = vm.runInContext('EstJdlCompare', ctx);
const rows = [{ Code: 'U001', Name: '가', TC: 1000 }, { Code: 'U002', Name: '나', TC: 500 },
{ Code: 'U003', Name: '다', TC: 7 }, { Code: '', Name: '머리' }];
const table = [['코드', '', '합계'], ['[U001] 가', '', '1,000'], ['U002', '', 499]];
const out = J.compare(rows, table, 0, 2).map((r) => [r.Code, r.OTC, r.GN, r.Checked]);
const far = Array.from({ length: 60 }, () => ['x']); far.push(['U009', '', 5]);
const limit = J.compare([{ Code: 'U009', TC: 5 }], far, 0, 2)[0].OTC;
const xls = J.xlsTables([{ name: 'S1', cells: { '0,0': { value: 'U001' }, '0,7': { value: 1000 }, '2,1': { value: 'b' } } }]);
process.stdout.write(JSON.stringify({ out, limit, xls: xls.S1 }));
"""
out = json.loads(subprocess.check_output(["node", "-e", code], cwd=ROOT).decode("utf-8"))
assert out["out"] == [
["U001", 1000, 0, False],
["U002", 499, 1, True],
["U003", None, None, True],
]
assert out["limit"] is None
assert out["xls"][0][0] == "U001" and out["xls"][0][7] == 1000 and out["xls"][2][1] == "b"
+19
View File
@@ -34,3 +34,22 @@ def test_search_finds_words_in_selected_kinds(tmp_path):
assert result["none"]["total"] == 0
assert result["excluded"]["total"] == 0
assert result["err"] == ["검색어를", "검색할 "]
def test_search_price_range_and_main_data_only(tmp_path):
sample = json.loads((ROOT / "tools/est-test/sample.json").read_text(encoding="utf-8"))
(tmp_path / ("a" * 24 + ".json")).write_text(json.dumps(sample), encoding="utf-8")
(tmp_path / ("c" * 24 + ".json")).write_text(json.dumps(sample), encoding="utf-8")
script = """
const s = require('./tools/est-test/search-store.cjs'), dir = process.argv[1];
const range = s.search(dir, '1~999999999', ['5']);
const exact = s.search(dir, String(range.hits[0].price), ['5']);
const only = s.search(dir, '1~999999999', ['5'], '', ['c'.repeat(24)]);
process.stdout.write(JSON.stringify({ range: range.hits.map((h) => h.price), exact: exact.total, only: only.hits.map((h) => h.id), total: range.total }));
"""
out = json.loads(
subprocess.check_output(["node", "-e", script, str(tmp_path)], cwd=ROOT).decode("utf-8")
)
assert out["range"] and all(1 <= p <= 999999999 for p in out["range"])
assert out["exact"] >= 2
assert set(out["only"]) == {"c" * 24} and len(out["only"]) == out["total"] // 2
+53
View File
@@ -0,0 +1,53 @@
"""EST 테스트 앱 줄 연결 도구(원본 비목별 단가 불러오기 · SUM 색인지정 · 하위데이타 연결/끊기)."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def run_js(code: str):
runner = f"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({{ structuredClone }});
for (const f of ['native-autolink.js', 'native-row-links.js'])
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
"""
return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
def test_cost_part_sum_index_and_toggle():
out = run_js(
r"""(() => {
const L = EstRowLinks, p = { tables: {
CostTitleDat: [{ Code: 'D1', Name: '거푸집', Size: '합판', Unite: 'm2', TC: 60, NC: 40, GC: 15, JC: 5, SC: 0 }],
StuffcDat: [{ Code: 'M1', Name: '시멘트', Size: '40kg', Unite: '포', TC: 5000, GC: 5000 }],
LaborcDat: [], HeadTitleDat: [], MachTitleDat: [], SilJukDat: [], EstiMateDat: [] } };
const r = { LinkIndex: 104, Code: 'D1', NU: 1, GU: 1, JU: 1, SU: 1, TU: 9 };
L.costPart(p, r, 3);
const jc = [r.NU, r.GU, r.JU, r.SU, r.OCheck, r.TU];
L.costPart(p, r, 0);
const all = [r.NU, r.GU, r.JU, r.OCheck];
let refused = ''; try { L.costPart(p, { LinkIndex: 5 }, 0); } catch (e) { refused = e.message; }
const est = { Code: '', Name: '시멘트', Size: '40kg', Unite: '포', LinkIndex: 0 };
const linked = [L.toggle(p, 'EstMainDat', est), est.Code, est.LinkIndex, est.GU, est.TU];
const unlinked = [L.toggle(p, 'EstMainDat', est), est.LinkIndex, est.Code];
let none = ''; try { L.toggle(p, 'EstMainDat', { Name: '없음', LinkIndex: 0 }); } catch (e) { none = e.message; }
const skip = L.toggle(p, 'EstMainDat', { LinkIndex: 54 });
const s = { LinkIndex: 0, Index: 0 }; L.sumIndex('EstMainDat', s, 3);
const bad = []; for (const row of [{ Level: 1, GN: 0 }, { LinkIndex: 53 }, { Index: 1 }])
try { L.sumIndex('EstMainDat', row, 1); } catch (e) { bad.push(e.message.slice(0, 6)); }
return { jc, all, refused, linked, unlinked, none, skip, skind: s.SKind, bad };
})()"""
)
assert out["jc"] == [0, 0, 5, 0, 3, 9]
assert out["all"] == [40, 15, 5, 0]
assert out["refused"] == "노임,자재,표준시장단가는 적용할수 없습니다."
assert out["linked"] == ["link", "M1", 5, 5000, 5000]
assert out["unlinked"] == ["unlink", 0, "M1"]
assert out["none"] == "해당 하위data를 찾지못해 연결시키지 못했습니다."
assert out["skip"] == "skip"
assert out["skind"] == 3
assert out["bad"] == ["레벨지정한 ", "현재라인의 ", "현재라인의 "]
+17
View File
@@ -489,6 +489,23 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일).
- [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지.
- 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구.
- [x] 수량산출서 삽입·선택공종 삭제(원본 TFRMMULLANG btnInsert·btnSelectDel) — 「삽입」: 현재 산출서 앞에 공종명칭·규격을 물어 새 산출서, 「선택공종 삭제」: 고른 여러 산출서를 한 번에 지움.
- 검증완료: 산출서·바인딩 시험 7개 통과. ORCA 브라우저에서 둘째 위치에 「끼운 공종/1m」 삽입, 1·3번째 고른 삭제 → 「끼운 공종」만 남음, 샘플 복구.
- [x] 새내역서 파일로저장(원본 mnuSaveNewEst 0x113384c) — 현재 화면은 그대로, 표 12개의 「_」 당초값 칸 비움·신규 표시 끔·원가 안전관리비 특례 줄(CalcIndex 4)→1·당초 사본 뺀 새 작업을 이름 붙여 서버에 따로 저장하고 내려받기. 재계산 없음.
- 검증완료: 바인딩 시험 통과. ORCA 브라우저에서 GN_ 5·신규 표시·당초 사본 둔 샘플 → 새 작업에는 GN_ 없음·신규 끔·당초 사본 없음, 현재 화면은 GN_ 5 그대로, 시험 작업 지우고 샘플 복구.
- [x] 단가산출 전역변수(원본 mnuCostSetGlobalVar 0xf053cc · mnuCostGlobalvarList · 전역표 0x93c22c — spec_cost_tools §2) — LineData 의 이름(화면 ⟦전역⟧) 로 대입한 변수는 호표가 바뀌어도 남아 다른 호표에서 표지 없이 써도 찾음(그 호표 변수 → 전역표 차례). 「전역변수 지정,해제」: 마지막 산출근거 칸 커서 아래 낱말(영문자 포함 [0-9A-Za-z_])을 감싸거나 풂(원본 거부 문구). 「전역변수 목록」: 처음부터 계산한 대입마다 변수명·값·이동. 원본처럼 전역표는 저장하지 않고 계산 때마다 다시 만듦.
- 검증완료: `test_est_global_vars.py`(다른 호표 Q×10 = 60, 표지 유지, 비전역 R 은 못 찾음, 지정·해제·거부, 표시·되돌림) 및 REA 대조 39개 통과. ORCA 브라우저에서 샘플 단가산출 q 줄 지정 → 목록 「q 0.7」·이동으로 그 줄 선택, 도급액 불변, 샘플 복구.
- [x] JDLExcel과 단가비교(원본 ActionJDLCompare, 숨은 Ctrl+Alt+J → TfrmJDLExcelCompare) — 일위대가·단가산출·기계경비 탭, 시트번호·코드칼럼(A)·합계셀칼럼(H), Excel(.xlsx·.xls — `/api/read-xls`) 1행~(호표 수+50)행에서 코드를 품은 칸의 같은 행 합계와 TC 비교, 차이·없음 표시. 보기 전용. 웹은 메뉴 단추로 엶.
- 검증완료: `test_est_jdl_compare.py`(같음·차이 1·없음·+50행 한계·xls 셀 변환) 통과. ORCA 브라우저에서 일위대가 8개 중 같음 1·차이 10·없음 6 표시.
- 제외: TfrmMultiEditor(인쇄 부품의 설계용 편집기 — 실행 파일에 클래스 없음), Est Plus(구버전 .est) 단가비교·내보내기(숨은 단축키, 394바이트 레코드 형식 — 구버전 불러오기와 함께 남음).
- [x] 검색 결과 근거자료보기(원본 TfrmFoundDetail btnClose 0xefa7e8) — 통합 검색 결과의 일위·단산·기계 줄 「근거」: 그 작업의 호표 세부 줄(단산은 산출근거 글)과 총액·노무·재료·경비, 「적용」은 그 결과를 고르고 검색 창으로(가져오기는 검색 창에서). 고른 결과는 창을 오가도 유지.
- 검증완료: 바인딩·통합 검색 시험 통과. ORCA 브라우저에서 「거푸집」 일위 → 「[합판거푸집]의 근거자료보기」 총액 53,029·노무 38,289·재료 14,740·세부 6줄, 적용 → 그 결과 체크.
- [x] ES 정보(원본 mnuESInfo → TfrmESInfo 0x10b838c) — 기준시점·비교시점·최종기성일·선급금지급일(기본 오늘)·선급금지급액을 `project.esInfo` 에 기록(원본 [EscInfo] SDATE·CDATE·ACDATE·PreDATE·PreCost). 원본도 ES 제출 파일 작성에만 씀 — 웹 물가변동 계산과 잇지 않음, ESTX 에는 남지 않음.
- 검증완료: 바인딩 시험 통과. ORCA 브라우저에서 기본 오늘 · 기준시점 2025-01-15·선급금 1,000,000 저장 → 다시 열면 그대로, 지워 복구.
- [x] 설계참고철(Main Data)과 단가 범위 검색(원본 TfrmMainTableList 「MAIN DATA(설계참고철) 추가 및 삭제」 · TfrmFolderSerch 「Main Data에서 검색」·범위 검색 0xefefb0) — 통합 검색 「설계참고철 관리」에서 저장된 작업 중 최대 10개 지정(양식 보관함 종류 options), 검색위치 「설계참고철」이면 그 작업만, 검색어 「a~b」·숫자 하나는 단가 범위·같음. 원본의 검색중지는 서버 검색이 바로 끝나 두지 않음.
- 검증완료: `test_est_multi_search.py` 2개(범위·같음·지정 작업만) 통과. ORCA 브라우저에서 2개 지정 → 「1000~2000」 24건 모두 지정 작업·범위 안, 목록 지워 복구.
- [x] 줄 연결 도구(원본 TfrmHead popupCostNC·GC·JC·All 0xf1b54c · popMainSumIndex · mnuDataLink 0xf00e6c — 디스어셈블 근거) — 비목별 단가 불러오기(일위대가 줄: 하위 견적·일위·단산·기계에서 고른 비목 단가만 남기고 나머지 0, 기준비목 지정, 노임·자재·표준 거부), SUM 색인지정(해제·A~Z → 구간 SKind, 레벨·소계·도급액·조달수수료·호표머리 줄 거부), 하위데이타 연결/끊기(연결 줄은 끊고 값 유지, 끊긴 줄은 일위→단산→기계→표준→견적→자재→노임 차례로 코드 또는 품명·규격·단위 맞춤으로 연결, 자재는 재료단가·노임은 노무단가 = 원본 TC). 원본은 초점 한 줄, 웹은 고른 줄 모두.
- 검증완료: `test_est_row_links.py`(경비만 5·전체 40/15/5, 거부, 자재 연결 M1·5000·끊기, 못 찾음 문구, 색인 3·거부 3종) 통과. ORCA 브라우저에서 일위대가 합판거푸집 노무비만 → 0, 재료비만 → 재료단가 34,666·금액 13,173, 내역 일위 연결 줄 끊기 → 다시 연결 103·도급액 불변, SUM 색인 B, 샘플 복구.
- [x] 묶음 인쇄 간지 설정값 일괄적용·기본양식에저장·전체선택/선택해제(원본 TFRMESTPRINTPRIVATE btnGangiAllSet·btnSaveToMain, TFRMESTPRINT2 btnNoselect) — 간지 인쇄·간지 겹테두리를 모든 서식에 같게, 고른 서식·간지 설정을 「묶음기본양식」(양식 보관함 종류 print)으로 저장해 묶음 설정 없는 공사에서 열 때 씀.
- 검증완료: 묶음 인쇄·바인딩 시험 통과. ORCA 브라우저에서 선택해제 0 · 전체선택 40, 겹테두리 끔 → 미리보기 간지 테두리 없음, 기본양식 저장 뒤 설정 지우고 다시 열면 1개·겹테두리 끔 복원, 양식 지우고 샘플 복구.
- [x] 갑지 공사금액 자동변경(원본 TFRMGABGI popupCostChange) — 「공사금액 자동변경」 체크 시 고른 원가 비목·방식을 기억해 인쇄 때마다 현재 원가 금액으로 공사금액 글을 다시 만듦(다시 열면 고른 비목·방식 그대로).
+14 -1
View File
@@ -60,6 +60,11 @@
<script defer src="native-doc-forms.js"></script>
<script defer src="native-block-file.js"></script>
<script defer src="native-cell-clip.js"></script>
<script defer src="native-row-links.js"></script>
<script defer src="native-es-info.js"></script>
<script defer src="native-jdl-compare.js"></script>
<script defer src="native-global-vars.js"></script>
<script defer src="native-new-estimate.js"></script>
<script defer src="native-wonga-tools.js"></script>
<script defer src="native-estimate-print.js"></script>
<script defer src="native-xml-check.js"></script>
@@ -93,6 +98,7 @@
<hr />
<button data-action="save">저장하기 Ctrl+S</button
><button data-action="save-as">다른 이름으로 저장</button
><button data-action="save-new-estimate">새내역서 파일로저장</button
><button data-action="sample">REA 샘플 열기</button>
<hr />
<button data-action="csv">현재 자료 CSV 내보내기</button
@@ -139,7 +145,12 @@
><button data-action="block-load-file">블럭 파일에서 붙여넣기</button
><button data-action="cell-copy">셀복사</button
><button data-action="cell-paste">셀붙여넣기</button
><button data-action="schedule-mark">선택 행 예정공정표에 표시/해제</button>
><button data-action="schedule-mark">선택 행 예정공정표에 표시/해제</button
><button data-action="data-link">하위데이타 연결/끊기</button
><button data-action="sum-index">SUM 색인지정</button
><button data-action="cost-part">비목별 단가 불러오기(일위대가)</button
><button data-action="global-var">전역변수 지정,해제</button
><button data-action="global-var-list">전역변수 목록</button>
<hr />
<button data-action="find">찾기·다음찾기(Ctrl+F)</button>
<button data-action="multi-search">통합 검색(저장된 다른 작업)</button>
@@ -241,6 +252,8 @@
><button data-view="machine-change">기계경비 설계변경</button
><button data-view="change">설계변경</button><button data-view="progress">기성고</button
><button data-view="escalation">물가변동</button
><button data-action="es-info">ES 정보</button
><button data-action="jdl-compare">JDL엑셀과 단가비교</button
><button data-view="annual">연차공사</button><button data-view="review">계약심사</button
><button data-view="resources">동원인원·자재·장비</button>
<button data-view="schedule">예정공정표</button>
+7 -2
View File
@@ -612,7 +612,7 @@ const EstBusinessActions = (() => {
(typeof EstCleanup !== "undefined" && EstCleanup.handle(name, state)) ||
(typeof EstNameReg !== "undefined" && EstNameReg.handle(name, state)) ||
(typeof EstTidy !== "undefined" && EstTidy.handle(name, state)) ||
(typeof EstMultiSearch !== "undefined" && EstMultiSearch.handle(name, state)) ||
(typeof EstMultiSearch !== "undefined" && EstMultiSearch.handle(name, state, node)) ||
(typeof EstDocs !== "undefined" && EstDocs.handle(name, state, node)) ||
(typeof EstWongaTools !== "undefined" && EstWongaTools.handle(name, state)) ||
(typeof EstEstimatePrint !== "undefined" && EstEstimatePrint.handle(name, state)) ||
@@ -631,7 +631,12 @@ const EstBusinessActions = (() => {
(typeof EstDocForms !== "undefined" && EstDocForms.handle(name, state, node)) ||
(typeof EstBlockFile !== "undefined" && EstBlockFile.handle(name, state)) ||
(typeof EstDisplay !== "undefined" && EstDisplay.handle(name, state)) ||
(typeof EstCellClip !== "undefined" && EstCellClip.handle(name, state))
(typeof EstCellClip !== "undefined" && EstCellClip.handle(name, state)) ||
(typeof EstRowLinks !== "undefined" && EstRowLinks.handle(name, state)) ||
(typeof EstEsInfo !== "undefined" && EstEsInfo.handle(name, state)) ||
(typeof EstJdlCompare !== "undefined" && EstJdlCompare.handle(name, state, node)) ||
(typeof EstGlobalVars !== "undefined" && EstGlobalVars.handle(name, state, node)) ||
(typeof EstNewEstimate !== "undefined" && EstNewEstimate.handle(name, state))
) {
EstApp.render();
} else if (name === "insert-annual") {
+30
View File
@@ -0,0 +1,30 @@
// 원본 「ES 정보」(ES도구 mnuESInfo → TfrmESInfo 0x10b838c) — 조달청 물가변동(ES) 제출용 기준시점·비교시점·최종기성일·
// 선급금지급일·선급금지급액. 원본은 작업 폴더 ProjectDataInfo.Ini [EscInfo] SDATE·CDATE·ACDATE·PreDATE·PreCost 에 두고
// 날짜 기본값은 오늘. 웹은 project.esInfo 에 둔다(물가변동 계산과는 직접 잇지 않음 — 원본도 ES 제출 파일 작성에만 씀).
const EstEsInfo = (() => {
const today = () => new Date().toISOString().slice(0, 10);
function dialog(state) {
const e = state.project.esInfo || {};
EstActions.form(
"ES 정보",
[
{ name: "SDATE", label: "기준시점", type: "date", value: e.SDATE || today() },
{ name: "CDATE", label: "비교시점", type: "date", value: e.CDATE || today() },
{ name: "ACDATE", label: "최종기성일", type: "date", value: e.ACDATE || today() },
{ name: "PreDATE", label: "선급금지급일", type: "date", value: e.PreDATE || today() },
{ name: "PreCost", label: "선급금지급액(원)", type: "number", value: e.PreCost ?? 0 },
],
(values) => {
if (!Number.isFinite(values.PreCost) || values.PreCost < 0)
throw Error("선급금지급액은 0 이상 수치입니다.");
EstApp.change((project) => (project.esInfo = { ...values }), false);
},
);
}
function handle(name, state) {
if (name !== "es-info") return false;
dialog(state);
return true;
}
return { handle };
})();
+12 -1
View File
@@ -299,5 +299,16 @@ const EstExcelMap = (() => {
return true;
}
return { fields, columnIndex, columnName, records, rows, insert, load, shift, handle };
return {
fields,
columnIndex,
columnName,
records,
rows,
insert,
load,
shift,
sheetTables,
handle,
};
})();
+70
View File
@@ -0,0 +1,70 @@
// 원본 단가산출 「전역변수 지정,해제」(mnuCostSetGlobalVar 0xf053cc)·「전역변수 목록」(mnuCostGlobalvarList → TfrmGlobalVarList).
// 지정·해제: 마지막으로 누른 산출근거 칸의 커서 아래 낱말([0-9A-Za-z_], 영문자 하나 이상)을 ⟦전역⟧ 표지로 감싸거나 푼다.
// 목록: 단가산출을 처음부터 계산해 전역변수 대입마다 변수명·값과 그 줄로 이동.
const EstGlobalVars = (() => {
const MARK = "⟦전역⟧";
let last = null;
if (typeof document !== "undefined" && document.addEventListener)
document.addEventListener("focusin", (event) => {
const t = event.target.closest?.('[data-table="CostMainDat"][data-field="LineData"]');
if (t) last = t;
});
function toggle(text, caret) {
const word = /[0-9A-Za-z_]/;
let a = caret,
b = caret;
while (a > 0 && word.test(text[a - 1])) a--;
while (b < text.length && word.test(text[b])) b++;
const name = text.slice(a, b);
if (!name || !/[A-Za-z]/.test(name))
throw Error(
'선택한 변수는 전역변수로 지정할수 없습니다.\n\n변수는 알파벳과 숫자,"_"로 이루어져야합니다.\n\n예) Q , V1 , L_1',
);
const before = text.slice(0, a),
after = text.slice(b);
if (before.endsWith(MARK) && after.startsWith(MARK))
return before.slice(0, -MARK.length) + name + after.slice(MARK.length);
return `${before}${MARK}${name}${MARK}${after}`;
}
function list(state) {
const out = EstText.calculate(state.project.tables.CostMainDat || [], state.project.options);
const { esc, number } = EstApp;
EstApp.showDialog(
"전역변수 목록",
out.globals.length
? `<table class="sheet"><thead><tr><th>변수명</th><th>값</th><th>이동</th></tr></thead><tbody>${out.globals.map((g) => `<tr><td>${esc(g.name)}</td><td>${number(g.value)}</td><td><button data-action="global-var-go" data-row="${g.index}">이동</button></td></tr>`).join("")}</tbody></table>`
: "<p>전역변수가 없습니다. 단가산출 산출근거의 변수에 커서를 두고 「전역변수 지정,해제」를 누르세요.</p>",
);
}
function handle(name, state, node) {
if (name === "global-var") {
if (!last) throw Error("단가산출 산출근거 칸의 변수에 커서를 두세요.");
const index = Number(last.dataset.row),
text = last.value,
next = toggle(text, last.selectionStart ?? 0);
EstApp.change(
(project) => (project.tables.CostMainDat[index].LineData = EstText.encode(next)),
);
} else if (name === "global-var-list") list(state);
else if (name === "global-var-go") {
EstApp.byId("dialog").close();
const index = Number(node.dataset.row);
state.view = "CostMainDat";
const rows = state.project.tables.CostMainDat;
let head = index;
while (head > 0 && !(Number(rows[head].Index) > 0)) head--;
state.group = rows[head]?.Code || state.group;
state.selection.clear();
state.selection.add(index);
EstApp.render();
document
.querySelector(`[data-table="CostMainDat"][data-row="${index}"][data-field="LineData"]`)
?.scrollIntoView({ block: "center" });
} else return false;
return true;
}
return { toggle, handle };
})();
+124
View File
@@ -0,0 +1,124 @@
// 원본 「JDLExcel과 단가비교」(ActionJDLCompare, 숨은 단축키 Ctrl+Alt+J → TfrmJDLExcelCompare) — 일위대가·단가산출·기계경비 호표마다
// Excel 의 코드 열에서 그 코드를 품은 셀(Pos(코드, 셀) > 0)을 1행~(호표 수+50)행 안에서 찾아 같은 행 합계 열 값과 TC 를 비교한다.
// 차이(|TC − Excel|)가 있으면 표시, 못 찾으면 「없음」. 자료는 바꾸지 않는다(보기 전용). Excel 은 .xlsx·.xls 모두.
const EstJdlCompare = (() => {
const TABLES = [
["HeadTitleDat", "일위대가"],
["CostTitleDat", "단가산출"],
["MachTitleDat", "기계경비"],
];
function compare(rows, table, codeCol, totalCol) {
const limit = rows.length + 50;
return rows
.filter((r) => String(r.Code ?? "").trim())
.map((r) => {
const code = String(r.Code).trim();
let found = null;
for (let i = 0; i < Math.min(limit, table.length); i++) {
const cell = table[i]?.[codeCol];
if (cell != null && String(cell).includes(code)) {
found = Number(String(table[i][totalCol] ?? "").replace(/,/g, "")) || 0;
break;
}
}
const tc = Number(r.TC) || 0;
return {
Code: code,
Name: r.Name ?? "",
Size: r.Size ?? "",
Unite: r.Unite ?? "",
TC: tc,
OTC: found,
GN: found == null ? null : Math.abs(tc - found),
Checked: found == null || Math.abs(tc - found) > 1e-9,
};
});
}
// .xls(BIFF) 셀 → 행 배열
function xlsTables(sheets) {
const out = {};
for (const s of sheets) {
const table = [];
for (const [k, c] of Object.entries(s.cells)) {
const [r, col] = k.split(",").map(Number);
(table[r] ||= [])[col] = c.value;
}
out[s.name] = table;
}
return out;
}
function form(state) {
const c = (state.jdlCompare ||= {
tab: 0,
sheet: 1,
code: "A",
total: "H",
tables: null,
result: [],
}),
{ esc, number } = EstApp,
diff = c.result.filter((r) => r.Checked).length;
EstApp.showDialog(
"JDL엑셀 단가 비교",
`<div class="business-tools">${TABLES.map(([, l], i) => `<button data-action="jdl-compare-tab" data-i="${i}"${i === c.tab ? ' style="font-weight:bold"' : ""}>${l}</button>`).join("")}</div><div class="form-fields"><label>시트번호<input name="jc-sheet" type="number" min="1" value="${c.sheet}"></label><label>코드칼럼<input name="jc-code" value="${esc(c.code)}" size="3"></label><label>합계셀칼럼<input name="jc-total" value="${esc(c.total)}" size="3"></label></div><button data-action="jdl-compare-open">엑셀파일열기</button> <button data-action="jdl-compare-run">검 색</button> ${c.tables ? `시트 ${Object.keys(c.tables).length}개` : "Excel 을 여세요"} · 다른 금액·없음 ${diff}건<div style="max-height:380px;overflow:auto"><table class="sheet"><thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>EST 금액</th><th>Excel 금액</th><th>차이</th></tr></thead><tbody>${c.result.map((r) => `<tr${r.Checked ? ' style="background:#fde2e2"' : ""}><td>${esc(r.Code)}</td><td style="text-align:left">${esc(r.Name)}</td><td>${esc(r.Size)}</td><td>${esc(r.Unite)}</td><td>${number(r.TC)}</td><td>${r.OTC == null ? "없음" : number(r.OTC)}</td><td>${r.GN == null ? "" : number(r.GN)}</td></tr>`).join("")}</tbody></table></div>`,
);
}
function read(c) {
const v = (n) => document.querySelector(`#dialog-content [name="${n}"]`)?.value;
c.sheet = Math.max(1, Number(v("jc-sheet")) || 1);
c.code = String(v("jc-code") || "A").toUpperCase();
c.total = String(v("jc-total") || "H").toUpperCase();
}
async function run(name, state, node) {
if (name === "jdl-compare") return form(state);
const c = state.jdlCompare;
read(c);
if (name === "jdl-compare-tab") {
c.tab = Number(node.dataset.i);
c.result = [];
} else if (name === "jdl-compare-open") {
const input = document.createElement("input");
input.type = "file";
input.accept = ".xlsx,.xls";
input.addEventListener("change", async () => {
try {
const file = input.files[0];
if (!file) return;
const body = await file.arrayBuffer();
c.tables = /\.xls$/i.test(file.name)
? xlsTables(await EstApp.api("/api/read-xls", { method: "POST", body }))
: EstExcelMap.sheetTables(
await EstApp.api("/api/import-xlsx", { method: "POST", body }),
);
} catch (error) {
state.notice = error.message;
}
form(state);
});
return input.click();
} else if (name === "jdl-compare-run") {
if (!c.tables) throw Error("엑셀파일을 먼저 여세요.");
const table = Object.values(c.tables)[c.sheet - 1];
if (!table) throw Error(`시트 ${c.sheet} 이 없습니다.`);
const col = (x) => EstExcelMap.columnIndex(x);
c.result = compare(
state.project.tables[TABLES[c.tab][0]] || [],
table,
col(c.code),
col(c.total),
);
}
form(state);
}
const names = new Set(["jdl-compare", "jdl-compare-tab", "jdl-compare-open", "jdl-compare-run"]);
function handle(name, state, node) {
if (!names.has(name)) return false;
run(name, state, node).catch((error) => {
state.notice = error.message;
EstApp.render();
});
return true;
}
return { compare, xlsTables, handle };
})();
+91 -6
View File
@@ -18,16 +18,25 @@ const EstMultiSearch = (() => {
const rows = saved.hits
.map(
(h, i) =>
`<tr><td><input type="checkbox" name="ms-hit" value="${i}"></td><td>${esc(h.project)}</td><td>${esc(h.label)}</td><td>${esc(h.Code)}</td><td style="text-align:left">${esc(h.Name)}</td><td>${esc(h.Size)}</td><td>${esc(h.Unite)}</td><td style="text-align:right">${EstApp.number(h.price)}</td></tr>`,
`<tr><td><input type="checkbox" name="ms-hit" value="${i}" ${saved.picked?.has(i) ? "checked" : ""}>${[103, 104, 105].includes(h.kind) ? `<button data-action="multi-search-detail" data-i="${i}" title="근거자료보기">근거</button>` : ""}</td><td>${esc(h.project)}</td><td>${esc(h.label)}</td><td>${esc(h.Code)}</td><td style="text-align:left">${esc(h.Name)}</td><td>${esc(h.Size)}</td><td>${esc(h.Unite)}</td><td style="text-align:right">${EstApp.number(h.price)}</td></tr>`,
)
.join("");
EstApp.showDialog(
"통합 검색",
`<p>검색위치: 이 앱에 저장된 다른 작업</p><div class="form-fields">${kinds.map(([k, label]) => `<label><input type="checkbox" name="ms-kind" value="${k}" ${saved.kinds.includes(k) ? "checked" : ""}>${label}</label>`).join("")}<label>검색어<input name="ms-query" value="${esc(saved.query)}"></label></div><button data-action="multi-search-run">검색</button> <button data-action="multi-search-take">고른 자료를 이 공사로 가져오기</button><div style="max-height:360px;overflow:auto;border:1px solid #ccc;margin-top:6px"><table class="sheet" style="font-size:12px"><thead><tr><th></th><th>작업</th><th>구분</th><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>단가</th></tr></thead><tbody>${rows}</tbody></table></div><p>찾은 자료 ${saved.total}건${saved.total > saved.hits.length ? ` 중 앞 ${saved.hits.length}건` : ""}</p>`,
`<p>검색위치: <label><input type="radio" name="ms-where" value="all" ${saved.where !== "main" ? "checked" : ""}>저장된 모든 작업</label> <label><input type="radio" name="ms-where" value="main" ${saved.where === "main" ? "checked" : ""}>설계참고철(Main Data)</label> <button data-action="main-list">설계참고철 관리</button> · 검색어에 「10000~20000」처럼 쓰면 단가 범위</p><div class="form-fields">${kinds.map(([k, label]) => `<label><input type="checkbox" name="ms-kind" value="${k}" ${saved.kinds.includes(k) ? "checked" : ""}>${label}</label>`).join("")}<label>검색어<input name="ms-query" value="${esc(saved.query)}"></label></div><button data-action="multi-search-run">검색</button> <button data-action="multi-search-take">고른 자료를 이 공사로 가져오기</button><div style="max-height:360px;overflow:auto;border:1px solid #ccc;margin-top:6px"><table class="sheet" style="font-size:12px"><thead><tr><th></th><th>작업</th><th>구분</th><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>단가</th></tr></thead><tbody>${rows}</tbody></table></div><p>찾은 자료 ${saved.total}건${saved.total > saved.hits.length ? ` 중 앞 ${saved.hits.length}건` : ""}</p>`,
);
}
async function run(name, state) {
async function run(name, state, node) {
if (name === "multi-search-detail") return detail(state, Number(node.dataset.i));
if (name === "multi-search-pick")
(state.multiSearch.picked ||= new Set()).add(Number(node.dataset.i));
if (name === "multi-search-pick" || name === "multi-search-back") return form(state);
if (name === "main-list") return mainForm(state);
if (name === "main-list-save") {
await mainSave();
return form(state);
}
if (name === "multi-search") form(state);
else if (name === "multi-search-run") {
const saved = state.multiSearch;
@@ -35,9 +44,17 @@ const EstMultiSearch = (() => {
saved.kinds = [...document.querySelectorAll('#dialog-content [name="ms-kind"]:checked')].map(
(n) => Number(n.value),
);
saved.where = document.querySelector('#dialog-content [name="ms-where"]:checked')?.value;
const params = new URLSearchParams({ q: saved.query, kinds: saved.kinds.join(",") });
if (saved.where === "main") {
const ids = await mainList();
if (!ids.length)
throw Error("설계참고철에 등록한 작업이 없습니다. 설계참고철 관리에서 추가하세요.");
params.set("only", ids.join(","));
}
if (state.project.id) params.set("exclude", state.project.id);
Object.assign(saved, await EstApp.api(`/api/search?${params}`));
saved.picked = new Set();
form(state);
} else if (name === "multi-search-take") {
const picked = [...document.querySelectorAll('#dialog-content [name="ms-hit"]:checked')].map(
@@ -65,10 +82,78 @@ const EstMultiSearch = (() => {
EstApp.render();
}
}
const names = new Set(["multi-search", "multi-search-run", "multi-search-take"]);
function handle(name, state) {
// 설계참고철(TfrmMainTableList 「MAIN DATA 추가 및 삭제」): 저장된 작업 중 최대 10개 — 양식 보관함(종류 options, 이름 「설계참고철」)
const MAIN = "설계참고철";
async function mainList() {
return (
(await EstApp.api("/api/forms")).find((f) => f.kind === "options" && f.name === MAIN)?.data
?.ids || []
);
}
async function mainForm(state) {
const ids = await mainList(),
projects = await EstApp.api("/api/projects"),
{ esc } = EstApp;
EstApp.showDialog(
"MAIN DATA(설계참고철) 추가 및 삭제",
`<p>통합 검색 「설계참고철」에서 찾을 작업을 고르세요(최대 10개).</p><div style="max-height:360px;overflow:auto">${projects.map((p) => `<label style="display:block"><input type="checkbox" name="ml-id" value="${esc(p.id)}" ${ids.includes(p.id) ? "checked" : ""}>${esc(p.name || p.id)}</label>`).join("")}</div><button data-action="main-list-save">적용</button> <button data-action="multi-search">통합 검색으로</button>`,
);
}
async function mainSave() {
const ids = [...document.querySelectorAll('#dialog-content [name="ml-id"]:checked')].map(
(n) => n.value,
);
if (ids.length > 10) throw Error("Main Data는 10개 이상 추가할수없습니다.");
await EstApp.api("/api/forms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "options", name: MAIN, data: { ids } }),
});
}
// 근거자료보기(TfrmFoundDetail): 찾은 일위·단산·기계 호표의 세부 줄과 총액·노무·재료·경비, 「적용」 = 그 결과를 고름
const BODY = { 103: "HeadMainDat", 104: "CostMainDat", 105: "MachMainDat" };
async function detail(state, i) {
remember(state);
const hit = state.multiSearch.hits[i],
project = await EstApp.api(`/api/projects/${hit.id}`),
{ esc, number } = EstApp,
rows = project.tables[BODY[hit.kind]] || [],
group = EstNative.groups(rows).get(hit.Code);
if (!group) throw Error("근거 자료를 찾지 못했습니다.");
const head = group.rows[0],
body = group.rows
.slice(1)
.map((r) =>
hit.kind === 104
? `<tr><td colspan="5" style="text-align:left">${esc(EstText.clean(r.LineData || ""))}</td></tr>`
: `<tr><td>${esc(r.Code)}</td><td style="text-align:left">${esc(r.Name)}</td><td>${esc(r.Size)}</td><td>${esc(r.Unite)}</td><td style="text-align:right">${number(r.GN)}</td></tr>`,
)
.join("");
EstApp.showDialog(
`[${hit.Name}]의 근거자료보기`,
`<p>총 액 : ${number(head.TC)} · 노무비 : ${number(head.NC)} · 재료비 : ${number(head.GC)} · 경 비 : ${number(head.JC)}</p><div style="max-height:360px;overflow:auto"><table class="sheet"><tbody>${body}</tbody></table></div><button data-action="multi-search-pick" data-i="${i}">적 용</button> <button data-action="multi-search-back">돌아가기</button>`,
);
}
function remember(state) {
const boxes = document.querySelectorAll('#dialog-content [name="ms-hit"]');
if (boxes.length)
state.multiSearch.picked = new Set(
[...boxes].filter((n) => n.checked).map((n) => Number(n.value)),
);
}
const names = new Set([
"multi-search",
"multi-search-run",
"multi-search-take",
"main-list",
"main-list-save",
"multi-search-detail",
"multi-search-pick",
"multi-search-back",
]);
function handle(name, state, node) {
if (!names.has(name)) return false;
run(name, state).catch((error) => {
run(name, state, node).catch((error) => {
state.notice = error.message;
EstApp.render();
});
+54
View File
@@ -0,0 +1,54 @@
// 원본 「새내역서 파일로저장」(mnuSaveNewEst 0x113384c) — 현재 문서는 그대로 두고 설계변경 흔적을 지운 새 작업을 따로 저장.
// 표 12개(노임·자재·견적·표준·기계기준·기계 본문/호표·단산 본문/호표·일위 본문/호표·내역서)의 「_」로 끝나는 칸(당초값)을 비우고
// 신규 표시(NewItem)를 끄며, 원가 안전관리비 특례 줄(CalcIndex 4)은 1 로, 당초 사본(baseline)은 뺀다. 재계산 없음.
const EstNewEstimate = (() => {
const TABLES = [
"LaborcDat",
"StuffcDat",
"EstiMateDat",
"SilJukDat",
"MachGijunDat",
"MachMainDat",
"MachTitleDat",
"CostMainDat",
"CostTitleDat",
"HeadMainDat",
"HeadTitleDat",
"EstMainDat",
];
function build(project, name) {
const next = structuredClone(project);
delete next.id;
delete next.baseline;
next.name = name;
for (const table of TABLES)
for (const row of next.tables[table] || []) {
for (const key of Object.keys(row)) if (key.endsWith("_")) row[key] = null;
if ("NewItem" in row) row.NewItem = false;
}
for (const row of next.tables.Wonga || [])
if ((Number(row.CalcIndex) & 0xff) === 4) row.CalcIndex = 1;
return next;
}
async function run(state) {
const name = prompt("새 내역서 이름", `${state.project.name || "내역서"} (새 내역서)`)?.trim();
if (!name) return;
const result = await EstApp.api("/api/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(build(state.project, name)),
});
EstApp.download(`${result.name}.estweb.json`, JSON.stringify(result, null, 2));
state.notice = `새 내역서 「${result.name}」를 따로 저장했습니다. 지금 화면은 그대로입니다.`;
EstApp.render();
}
function handle(name, state) {
if (name !== "save-new-estimate") return false;
run(state).catch((error) => {
state.notice = error.message;
EstApp.render();
});
return true;
}
return { build, handle };
})();
+50 -1
View File
@@ -129,7 +129,7 @@ const EstQuantitySheets = (() => {
`<option value="${esc(sheet.Code)}" ${state.quantitySheet === sheet.Code ? "selected" : ""}>${esc(sheet.Name || sheet.Code)}</option>`,
)
.join("");
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${options}</select></label><input data-quantity-query value="${esc(state.quantityQuery || "")}" placeholder="명칭·규격" aria-label="산출서 검색"><button data-action="quantity-sheet-search">검색</button><button data-action="quantity-sheet-all">전체보기</button><button data-quantity-new>새 산출서</button><button data-quantity-excel>Excel 셀 수량 연결</button><button data-quantity-excel-links>Excel 연결 관리</button></div>`;
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${options}</select></label><input data-quantity-query value="${esc(state.quantityQuery || "")}" placeholder="명칭·규격" aria-label="산출서 검색"><button data-action="quantity-sheet-search">검색</button><button data-action="quantity-sheet-all">전체보기</button><button data-quantity-new>새 산출서</button><button data-action="quantity-sheet-insert">삽입</button><button data-action="quantity-sheet-multi-delete">선택공종 삭제</button><button data-quantity-excel>Excel 셀 수량 연결</button><button data-quantity-excel-links>Excel 연결 관리</button></div>`;
}
function handle(name, state, node) {
@@ -153,6 +153,55 @@ const EstQuantitySheets = (() => {
state.quantitySheet = first.Code;
return true;
}
if (name === "quantity-sheet-insert") {
// 삽입(TFRMMULLANG btnInsert): 현재 산출서 앞에 새 산출서, 공종명칭(품명·규격) 입력
const title = prompt("공종명칭", "새 수량산출서");
if (title == null) return true;
const size = prompt("규격", "") ?? "";
EstApp.change((project) => {
const sheets = (project.quantitySheets ||= []),
at = Math.max(
0,
sheets.findIndex((s) => s.Code === code),
),
next = `Q${EstApp.uid()}`;
sheets.splice(at, 0, {
Code: next,
Name: title.trim() || "새 수량산출서",
Size: size.trim(),
rows: [],
items: [],
options: { digits: 2, roundMode: 2 },
});
state.quantitySheet = next;
}, false);
return true;
}
if (name === "quantity-sheet-multi-delete") {
// 선택공종 삭제(btnSelectDel): 고른 산출서들을 한 번에 지움
const { esc } = EstApp;
EstApp.showDialog(
"선택공종 삭제",
`<div style="max-height:360px;overflow:auto">${(state.project.quantitySheets || []).map((s) => `<label style="display:block"><input type="checkbox" name="qd-sheet" value="${esc(s.Code)}">${esc(s.Name || s.Code)} ${esc(s.Size || "")}</label>`).join("")}</div><button data-action="quantity-sheet-multi-delete-ok">삭제</button>`,
);
return true;
}
if (name === "quantity-sheet-multi-delete-ok") {
const codes = new Set(
[...document.querySelectorAll('#dialog-content [name="qd-sheet"]:checked')].map(
(n) => n.value,
),
);
if (!codes.size) throw Error("지울 산출서를 고르세요.");
if (!confirm(`산출서 ${codes.size}개를 삭제하시겠습니까?`)) return true;
EstApp.byId("dialog").close();
EstApp.change((project) => {
project.quantitySheets = project.quantitySheets.filter((s) => !codes.has(s.Code));
if (codes.has(state.quantitySheet))
state.quantitySheet = project.quantitySheets[0]?.Code || "manual";
}, false);
return true;
}
if (name === "quantity-sheet-copy") {
EstApp.change((project) => {
state.quantitySheet = copy(project, code, EstApp.uid());
+160
View File
@@ -0,0 +1,160 @@
// 원본 줄 단위 연결 도구(spec_part 1~3):
// 비목별 단가 불러오기(TfrmHead popupCostNC·GC·JC·All 0xf1b54c) — 일위대가 줄의 하위 자료(견적·일위·단산·기계)에서 한 비목 단가만
// 남기고 나머지 단가 0, 기준비목(OCheck) := 고른 비목(0 전체·1 재료·2 노무·3 경비). 노임·자재·표준 줄은 거부.
// SUM 색인지정(popMainSumIndex, TfrmSetSumIndex): 고른 줄 SKind := 해제(0)·A(1)…Z(26) — 비율 줄(62)이 코드 글자로 모아 더함.
// 하위데이타 연결/끊기(mnuDataLink 0xf00e6c): 연결 줄은 끊고(LinkIndex 0, 값은 남김), 끊긴 줄은 일위→단산→기계→표준→견적→자재→노임
// 차례로 코드(없으면 품명·규격·단위 맞춤)를 찾아 코드·명칭·규격·단위·단가를 가져와 연결. 원본은 초점 한 줄, 웹은 고른 줄 모두.
const EstRowLinks = (() => {
const num = (v) => Number(v) || 0;
const SRC = {
101: "EstiMateDat",
103: "HeadTitleDat",
104: "CostTitleDat",
105: "MachTitleDat",
21: "SilJukDat",
5: "StuffcDat",
6: "LaborcDat",
};
const ORDER = [103, 104, 105, 21, 101, 5, 6];
const LINKED = new Set([5, 6, 21, 101, 103, 104, 105, 211, 212, 213, 214, 215]);
const norm = (s) => String(s ?? "").replace(/\s/g, "");
function byCode(project, kind, code) {
const c = norm(code);
return c ? (project.tables[SRC[kind]] || []).find((r) => norm(r.Code) === c) : null;
}
function costPart(project, row, tag) {
const kind = num(row.LinkIndex);
if ([5, 6, 21].includes(kind)) throw Error("노임,자재,표준시장단가는 적용할수 없습니다.");
const src = SRC[kind] ? byCode(project, kind, row.Code) : null,
v = (k) => num(src?.[k]);
row.OCheck = tag;
row.NU = row.GU = row.JU = row.SU = 0;
if (tag === 0) Object.assign(row, { NU: v("NC"), GU: v("GC"), JU: v("JC"), SU: v("SC") });
else if (tag === 2) row.NU = v("NC");
else if (tag === 1) row.GU = v("GC");
else Object.assign(row, { JU: v("JC"), SU: v("SC") });
}
function sumIndex(view, row, k) {
if (view === "EstMainDat") {
if (num(row.Level) > 0 && !num(row.GN))
throw Error("레벨지정한 라인의 값을 더할값으로 지정할수없습니다.");
if ([52, 53, 55, 57].includes(num(row.LinkIndex)) || num(row.Index) !== 0)
throw Error("현재라인의 값은 더할값으로 지정할수없습니다.");
} else if (num(row.LinkIndex) === 53 || num(row.Index) > 0)
throw Error("현재라인의 값은 더할값으로 지정할수없습니다.");
row.SKind = k;
}
function toggle(project, view, row) {
const li = num(row.LinkIndex);
if (li) {
if (LINKED.has(li)) row.LinkIndex = 0;
return li && LINKED.has(li) ? "unlink" : "skip";
}
const tag = { EstMainDat: 1, HeadMainDat: 2, MachMainDat: 4 }[view] ?? 0;
for (const kind of ORDER) {
const src = norm(row.Code)
? byCode(project, kind, row.Code)
: EstAutoLink.find(project, kind, EstAutoLink.key(row, tag));
if (!src) continue;
Object.assign(row, {
Code: src.Code,
Name: src.Name ?? "",
Size: src.Size ?? "",
Unite: src.Unite ?? "",
LinkIndex: kind,
NU: num(src.NC),
GU: num(src.GC),
JU: num(src.JC),
SU: num(src.SC),
});
if (kind === 5) row.GU = num(src.TC);
if (kind === 6) row.NU = num(src.TC);
row.TU = row.NU + row.GU + row.JU + row.SU;
return "link";
}
throw Error("해당 하위data를 찾지못해 연결시키지 못했습니다.");
}
function picked(state, views) {
const rows = state.project.tables[state.view] || [],
list = [...state.selection].filter((i) => rows[i]).sort((a, b) => a - b);
if (!views.includes(state.view) || !list.length)
throw Error(
`${views.length > 2 ? "내역서·일위대가·단가산출·기계경비" : "내역서·일위대가"}에서 줄을 고르세요.`,
);
return list;
}
function handle(name, state) {
if (name === "cost-part") {
if (state.view !== "HeadMainDat") throw Error("일위대가 화면에서 쓰세요.");
const list = picked(state, ["HeadMainDat"]);
EstActions.form(
"비목별 단가 불러오기",
[
{
name: "tag",
label: "불러올 비목",
value: 0,
choices: [
[0, "전체비목 불러오기"],
[2, "노무비만 불러오기"],
[1, "재료비만 불러오기"],
[3, "경비만 불러오기"],
],
},
],
(values) => {
const probe = structuredClone(state.project);
list.forEach((i) => costPart(probe, probe.tables.HeadMainDat[i], Number(values.tag)));
EstApp.change((project) =>
list.forEach((i) =>
costPart(project, project.tables.HeadMainDat[i], Number(values.tag)),
),
);
},
);
} else if (name === "sum-index") {
const list = picked(state, ["EstMainDat", "HeadMainDat"]);
EstActions.form(
"Sum 색인",
[
{
name: "k",
label: "색인",
value: 1,
choices: [
[0, "해제"],
...Array.from({ length: 26 }, (_, i) => [i + 1, String.fromCharCode(65 + i)]),
],
},
],
(values) => {
const rows = state.project.tables[state.view];
list.forEach((i) => sumIndex(state.view, structuredClone(rows[i]), 0));
EstApp.change((project) =>
list.forEach((i) =>
sumIndex(state.view, project.tables[state.view][i], Number(values.k)),
),
);
},
);
} else if (name === "data-link") {
const list = picked(state, ["EstMainDat", "HeadMainDat", "CostMainDat", "MachMainDat"]);
const probe = structuredClone(state.project);
list.forEach((i) => toggle(probe, state.view, probe.tables[state.view][i]));
let out = [];
EstApp.change((project) => {
out = list.map((i) => toggle(project, state.view, project.tables[state.view][i]));
});
const linked = out.filter((r) => r === "link").length,
unlinked = out.filter((r) => r === "unlink").length;
state.notice = `연결 ${linked}줄 · 끊기 ${unlinked}줄`;
} else return false;
return true;
}
return { costPart, sumIndex, toggle, handle };
})();
+25 -6
View File
@@ -1,4 +1,6 @@
// REA f2a1fc/f254c8: LineData 표지와 일반·성분·마지막 합계의 정밀도.
// 전역변수(spec_cost_tools §2 · 0x93c22c): \x05이름\x05 표지로 대입한 변수는 호표가 바뀌어도 남아(전역표) 다른 호표에서 표지 없이 써도 찾는다.
// 찾는 차례 = 그 호표 변수 → 전역표. 표지는 계산 동안 떼고 계산 뒤 같은 낱말에 다시 붙인다.
const EstText = (() => {
const { num, components, floating, sum } = EstMath;
const marker = /\x12([-\d.,]+)\x12/;
@@ -6,7 +8,7 @@ const EstText = (() => {
const clean = (s) =>
String(s || "")
.replace(/[\x06\x07][^\x06\x07]*[\x06\x07]/g, "")
.replace(/[\x03\x0c\x0e\x11\x12\x13\x15\x1a]/g, "");
.replace(/[\x03\x05\x0c\x0e\x11\x12\x13\x15\x1a]/g, "");
const numeric = (s) => Number(String(s).replaceAll(",", ""));
const sumCurrency = (values) =>
values.reduce((n, value) => n + EstMath.even(num(value) * 10000), 0) / 10000;
@@ -56,8 +58,10 @@ const EstText = (() => {
titles = [],
errors = [],
checks = [];
const globals = Object.create(null),
globalList = [];
let head = null,
variables = Object.create(null),
variables = Object.create(globals),
changed = new Map(),
currentSource = null,
finalOverride = null,
@@ -94,7 +98,7 @@ const EstText = (() => {
if (num(row.Index) > 0) {
finish();
head = row;
variables = Object.create(null);
variables = Object.create(globals);
changed = new Map();
currentSource = null;
finalOverride = null;
@@ -170,7 +174,10 @@ const EstText = (() => {
errors.push(`${head.Code}:${index + 1} ${error.message}`);
}
}
let text = original.replace(/\{([+-])\}/g, "$1");
const globalNames = [...original.matchAll(/\x05([A-Za-z][A-Za-z0-9_]*)\x05/g)].map(
(m) => m[1],
);
let text = original.replace(/\x05/g, "").replace(/\{([+-])\}/g, "$1");
let selectionError = "";
if (num(row.LinkIndex) === 62) {
text = text.replace(/(\x07([A-Z])\x07\s*)\x1a[^\x1a]*\x1a/g, (whole, reference, group) => {
@@ -599,16 +606,28 @@ const EstText = (() => {
errors.push(`${head.Code}:${index + 1} ${error.message}`);
row.Error = error.message;
}
for (const name of globalNames) {
if (Object.hasOwn(variables, name)) {
globals[name] = variables[name];
globalList.push({ name, value: variables[name], index });
}
if (!String(row.LineData).includes(`\x05${name}\x05`))
row.LineData = String(row.LineData).replace(
new RegExp(`(?<![A-Za-z0-9_])${name}(?![A-Za-z0-9_])`),
`\x05${name}\x05`,
);
}
if (!hasAutoTotals && component[sc])
plainTotals[component[sc]] = num(plainTotals[component[sc]]) + num(row.TC);
}
finish();
return { rows, titles, errors, checks };
return { rows, titles, errors, checks, globals: globalList };
}
const names = {
"\x06": "구간지정",
"\x07": "구간합계",
"\x03": "단가",
"\x05": "전역",
"\x0c": "다른줄",
"\x0e": "품명",
"\x11": "변수",
@@ -620,7 +639,7 @@ const EstText = (() => {
};
const display = (text) =>
String(text || "").replace(
/[\x03\x06\x07\x0c\x0e\x11\x12\x13\x15\x16\x1a]/g,
/[\x03\x05\x06\x07\x0c\x0e\x11\x12\x13\x15\x16\x1a]/g,
(ch) => `⟦${names[ch]}⟧`,
);
const encode = (text) =>
+13 -4
View File
@@ -19,12 +19,14 @@ const fold = (value) =>
.toLowerCase()
.replace(/\s+/g, "");
function search(directory, query, kinds, exclude = "") {
// only = 설계참고철(Main Data) 작업 id 목록 — 있으면 그 작업만. 검색어가 「a~b」 또는 숫자 하나면 단가(TC) 범위·같음(원본 0xefefb0).
function search(directory, query, kinds, exclude = "", only = null) {
const words = String(query ?? "")
.split(/\s+/)
.map(fold)
.filter(Boolean);
if (!words.length) throw Error("검색어를 입력하세요.");
const range = /^(\d+(?:\.\d+)?)(?:~(\d+(?:\.\d+)?))?$/.exec(String(query).replace(/[\s,]/g, ""));
const wanted = (kinds?.length ? kinds : Object.keys(sources))
.map(Number)
.filter((k) => sources[k]);
@@ -34,7 +36,7 @@ function search(directory, query, kinds, exclude = "") {
let total = 0;
for (const name of fs.readdirSync(directory).filter((n) => /^[a-f0-9]{24}\.json$/.test(n))) {
const id = name.slice(0, -5);
if (id === exclude) continue;
if (id === exclude || (only && !only.includes(id))) continue;
let project;
try {
project = JSON.parse(fs.readFileSync(path.join(directory, name), "utf8"));
@@ -45,8 +47,15 @@ function search(directory, query, kinds, exclude = "") {
const [table, label] = sources[kind];
for (const row of project.tables?.[table] || []) {
if (!row?.Code) continue;
const text = fold(`${row.Code} ${row.Name} ${row.Size}`);
if (!words.every((w) => text.includes(w))) continue;
if (range) {
const tc = Number(row.TC) || 0,
lo = Number(range[1]),
hi = range[2] == null ? lo : Number(range[2]);
if (tc < Math.min(lo, hi) || tc > Math.max(lo, hi)) continue;
} else {
const text = fold(`${row.Code} ${row.Name} ${row.Size}`);
if (!words.every((w) => text.includes(w))) continue;
}
total++;
if (hits.length < LIMIT)
hits.push({
+11
View File
@@ -5,6 +5,7 @@ const http = require("node:http"),
vm = require("node:vm"),
crypto = require("node:crypto");
const { importEstx, table: dbisamTable } = require("./import-estx.cjs");
const { readXls } = require("./xls-read.cjs");
const exchange = require("./exchange.cjs");
const { exportEstx } = require("./export-estx.cjs");
const jdl = require("./jdl.cjs");
@@ -81,6 +82,11 @@ const allowed = new Set([
"native-block-file.js",
"native-display.js",
"native-cell-clip.js",
"native-row-links.js",
"native-es-info.js",
"native-jdl-compare.js",
"native-global-vars.js",
"native-new-estimate.js",
"native-wonga-tools.js",
"native-estimate-print.js",
"native-xml-check.js",
@@ -200,6 +206,7 @@ const server = http.createServer(async (req, res) => {
url.searchParams.get("q"),
kinds,
url.searchParams.get("exclude") || "",
url.searchParams.get("only") ? url.searchParams.get("only").split(",") : null,
),
);
return;
@@ -242,6 +249,10 @@ const server = http.createServer(async (req, res) => {
reply(res, 200, importEstx(bytes, name));
return;
}
if (req.method === "POST" && url.pathname === "/api/read-xls") {
reply(res, 200, readXls(await body(req)));
return;
}
if (req.method === "POST" && url.pathname === "/api/pps-table") {
// 조달청 코드 목록(원본 System\MainSys\PPS_STUFFC.dat · PPS_HEAD.dat, DBISAM) — 코드·명칭·규격·단위만
const rows = dbisamTable(await body(req), null, []);