Merge remote-tracking branches 'origin/CODEX' and 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-11-05 18:05:11 +09:00
18 changed files with 583 additions and 70 deletions
+9 -9
View File
@@ -36,13 +36,13 @@ from __future__ import annotations
import copy
import hashlib
import json
import re
from decimal import Decimal
from pathlib import Path
from typing import Any
from B08_Quantity.B08_Quantity_Haul_Cycle import fill_given
from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total
from B09_Estimation import B09_Estimation_Sangeun_Sum as ss
from B09_Estimation.B09_Estimation_Calc_Base import gap, num, rounddown, total
from B09_Estimation.B09_Estimation_Store import ref_rows
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
@@ -64,7 +64,6 @@ PARTS = ("labor", "material", "expense")
COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"}
PCT_OF = {**COST_PART, "인건비": "people"} # % 덧줄 밑수 → 덮개 다시 셈(719-3)
DEPTH = 4 # 하위 산근 깊이 상한
_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …)
NO_SOURCE = "조합 밖 토공 · 운반 · 사토 · 준비공은 산근 없음"
EMPTY = "연결된 구조물 조합 사본에 단가산출 줄 없음 — 연결 없음"
@@ -239,9 +238,7 @@ def _env(whole: Any, row: dict, given: dict) -> dict:
except mf.EmptyInput as e:
env[step["이름"]] = mf.Blank(str(e))
continue
if _Q.fullmatch(step["이름"]) and isinstance(value, (Decimal, int, float)):
value = Decimal(str(round_half(float(value), 2)))
env[step["이름"]] = value
env[step["이름"]] = ss.shown(row, step, value) # 표시 · Q ROUND 2(792-3)
return env
@@ -375,11 +372,12 @@ class Solver:
with mf.mp.choosing(src.get("picks")):
for n, item in enumerate(row.get("호표", [])):
lines.append(self._line(whole, item, n, env, times, rate, depth))
ss.places(row, lines) # 줄 자리(792-3)
self._extras(whole, row, env, lines)
_annotate(whole, row, env, lines)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return self._stop(code, name, str(e) or type(e).__name__)
sums = block_totals(lines)
sums = ss.totals(row, lines) or block_totals(lines) # 끝전 · 조정(792-3)
why = next((ln["why"] for ln in lines if ln.get("why")), None)
head, foot = self._text(whole, src, times, lines)
spec, spec_raw = spec_text(whole, src["given"])
@@ -513,7 +511,7 @@ class Solver:
whole, row, heads + [{} for _ in row.get("덧줄", [])], dict(env)
)
for extra, pct in zip(row.get("덧줄", []), pcts[len(heads) :]):
sums = {name: total([ln[p] for ln in lines]) for name, p in COST_PART.items()}
sums = ss.sums(row, lines) # 합밖 · 제외(792-3)
part = COST_PART.get(extra.get("비목"), "material")
line = {"kind": "extra", "name": extra["이름"], "spec": extra["식"], "qty": 1.0,
"unit": "식", "ref": None, **dict.fromkeys(PARTS, 0.0)} # fmt: skip
@@ -525,6 +523,7 @@ class Solver:
lines.append({**line, part: None, "price": {}, "why": "밑수 빈칸"})
continue
env.update({k: Decimal(str(v)) for k, v in sums.items()})
ss.scope(row, lines, env) # 번지 · 단계(792-3)
env["줄"] = {
ln["name"]: Decimal(str(sum(ln[p] or 0 for p in PARTS)))
for ln in lines
@@ -535,7 +534,8 @@ class Solver:
except (mf.FormulaError, ArithmeticError, TypeError, ValueError) as e:
lines.append({**line, part: None, "price": {}, "why": str(e)})
continue
lines.append({**line, "price": {part: value}, part: rounddown(value, 1), "why": None})
money = ss.extra(row, extra, value, env) # 줄 자리(792-3)
lines.append({**line, "price": {part: value}, part: money, "why": None})
def _annotate(whole: Any, row: dict, env: dict, lines: list[dict[str, Any]]) -> None:
@@ -0,0 +1,89 @@
"""B09 산근 풀이 셈 의미(PLAN 792-3) — 791-1 엔진 새 의미(줄 자리 · 합밖 · 번지 · 단계 · 블록 끝전 ·
결과 조정 · 변수 표시)를 쓰는 로직만 엔진 규칙(`master_cut` · `master_formula_sum`) 그대로 ·
새 칸 없는 로직 = 옛 B09 규칙(작업량 Q = ROUND 2 · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0).
쓰는 곳 = `B09_Estimation_Sangeun.Solver` · 지킴 시험 = `test_792_3_engine_paths.py`(엔진 · M02 SK ·
SC · B09 산근 네 길 같은 금액).
"""
from __future__ import annotations
import importlib
import re
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_Calc_Base import round_half, rounddown, total
from M01_MasterData import M01_MasterData_Store as _m01 # noqa: F401 — scripts 길을 둠
mcut = importlib.import_module("master_cut")
ms = importlib.import_module("master_formula_sum")
PARTS = ("labor", "material", "expense")
COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"}
_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …)
def shown(row: dict, step: dict, value: Any) -> Any:
"""중간값 — `표시` 칸 · 새 의미 로직 = 엔진 그대로(`표시` 자리 반올림) · 옛 = Q ROUND 2."""
if step.get("표시") is not None or ms.uses(row):
return ms.shown(step, value)
if _Q.fullmatch(step["이름"]) and isinstance(value, (Decimal, int, float)):
return Decimal(str(round_half(float(value), 2)))
return value
def _cut(value: float, rule: dict | None) -> float:
return float(mcut._line(value, rule)) if rule else rounddown(value, 1)
def places(row: dict, lines: list[dict]) -> None:
"""호표 줄 `자리`(줄 자리) — 그 줄 금액만 단가 × 수량을 그 규칙으로 다시 버림(제자리)."""
for item, ln in zip(row.get("호표", []), lines):
rule = item.get("자리")
if not rule or ln.get("qty") is None:
continue
for part, price in (ln.get("price") or {}).items():
if part in PARTS and price is not None:
ln[part] = _cut(price * ln["qty"], rule)
def _engine(lines: list[dict]) -> list[dict]:
"""B09 줄 → 엔진 줄 꼴 `{비목}`(번지 · 단계 셈)."""
return [
{"비목": {k: Decimal(str(ln.get(p) or 0)) for k, p in COST_PART.items()}} for ln in lines
]
def sums(row: dict, lines: list[dict]) -> dict[str, float | None]:
"""덧줄 밑 비목 합 — 새 의미 로직은 합밖 · 제외 단계 줄을 뺌."""
skip = ms.skipped(row) if ms.uses(row) else set()
kept = [ln for n, ln in enumerate(lines) if n not in skip]
return {name: total([ln[p] for ln in kept]) for name, p in COST_PART.items()}
def scope(row: dict, lines: list[dict], env: dict) -> None:
"""덧줄 식 이름 `번지` · `단계`(지금까지 줄) — 새 의미 로직만."""
if ms.uses(row):
env.update(ms.scope(row, _engine(lines)))
def extra(row: dict, item: dict, value: float, env: dict) -> float:
"""덧줄 금액 — 줄 `자리` 면 그 규칙 · 아니면 ROUNDDOWN 1 ·
새 의미 로직은 뒤 덧줄이 그 값을 부름."""
money = _cut(value, item.get("자리"))
if ms.uses(row):
env[item["이름"]] = Decimal(str(money))
return money
def totals(row: dict, lines: list[dict]) -> dict[str, float | None] | None:
"""총계 — 새 의미 로직 = 합밖 · 제외 빼고 블록 끝전 · `끝수` · 조정(`master_cut.settle`) ·
옛 로직 · 빈칸 비목 있음 = None(옛 `block_totals`)."""
if not ms.uses(row):
return None
got = sums(row, lines)
if any(v is None for v in got.values()):
return None
costs = mcut.settle(row, {k: Decimal(str(v)) for k, v in got.items()})
out = {COST_PART[k]: float(v) for k, v in costs.items()}
return {**out, "sum": sum(out.values())}
+16 -10
View File
@@ -27,6 +27,7 @@ from decimal import Decimal
from typing import Any
from B09_Estimation import B09_Estimation_Sangeun as sg
from B09_Estimation import B09_Estimation_Sangeun_Sum as ss
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
@@ -58,8 +59,8 @@ def _qty_text(item: dict, env: dict, whole: Any, times: Decimal, rate: Decimal |
return text
def _line(name: str, price: Any, qty_text: str, q: float) -> tuple[str, float | None]:
money = None if price is None else sg._money(float(price), q)
def _line(name: str, price: Any, qty_text: str, q: float, rule=None) -> tuple[str, float | None]:
money = None if price is None else ss._cut(float(price) * q, rule) # 줄 자리(792-3)
return f"{name} : {_price(price)} * {qty_text} = {_money(money)}", money
@@ -308,21 +309,24 @@ def _pct_specs(whole, row, env) -> list[str]:
return [str(one.get("규격") or "") for one in got]
def _full(amounts: list[dict]) -> list[dict]:
return [{**dict.fromkeys(sg.PARTS, 0.0), **a} for a in amounts]
def _extra_lines(whole, row, env, amounts: list[dict]) -> list[tuple[str, dict]]:
"""% 줄(덧줄) — 밑수 = 그때까지 B09 비목 합(`Sangeun._extras` 와 같음) · 금액 ROUNDDOWN 1."""
out: list[tuple[str, dict]] = []
specs = _pct_specs(whole, row, env)
for extra, spec in zip(row.get("덧줄", []), specs):
part = sg.COST_PART.get(extra.get("비목"), "material")
sums = {
name: sg.total([a.get(p, 0.0) for a in amounts]) for name, p in sg.COST_PART.items()
}
sums = ss.sums(row, _full(amounts)) # 합밖 · 제외(792-3)
head = f"{extra['이름']}{f' ({spec})' if spec else ''} : "
if any(v is None for v in sums.values()):
out.append((f"{head}밑수 빈칸", {part: None}))
amounts.append({part: None})
continue
env.update({k: Decimal(str(v)) for k, v in sums.items()})
ss.scope(row, _full(amounts), env) # 번지 · 단계(792-3)
try:
node = mf.parse(extra["식"])
value = float(mf.evaluate(node, env, whole, 0))
@@ -331,7 +335,7 @@ def _extra_lines(whole, row, env, amounts: list[dict]) -> list[tuple[str, dict]]
out.append((f"{head}계산 못 함 — {e}", {part: None}))
amounts.append({part: None})
continue
money = sg.rounddown(value, 1)
money = ss.extra(row, extra, value, env) # 줄 자리(792-3)
out.append((f"{head}{shown} = {_money(money)}", {part: money}))
amounts.append({part: money})
return out
@@ -377,13 +381,15 @@ def render_sangeun_text(
q = float(Decimal(str(raw)) / times * (scale or 1))
kind = item.get("종류")
if kind == "로직":
merged: dict = {} # 줄 하나 = 금액 하나(중기 세 줄 · 792-3 합밖 · 번지 차례)
for text, money in _logic_lines(whole, item, env, qty_text, q, price_of):
out.append(text)
amounts.append(money)
merged.update(money)
amounts.append(merged)
continue
if kind == "인력":
key = mf.fill(item["요소"], env) if isinstance(item["요소"], str) else ""
text, money = _line(name, price_of(key), qty_text, q)
text, money = _line(name, price_of(key), qty_text, q, item.get("자리"))
out.append(text)
amounts.append({"labor": money})
continue
@@ -397,12 +403,12 @@ def render_sangeun_text(
if q == 0 and price is None:
price = 0.0 # 안 쓰는 재료 줄 — 금액 0(`Sangeun._line` 과 같음)
label = f"{name}({spec})" if spec else name
text, money = _line(label, price, qty_text, q)
text, money = _line(label, price, qty_text, q, item.get("자리"))
out.append(text)
amounts.append({sg.COST_PART.get(item.get("비목"), "material"): money})
for text, _money_part in _extra_lines(whole, row, env, amounts):
out.append(text)
sums = sg.block_totals([{**dict.fromkeys(sg.PARTS, 0.0), **a} for a in amounts])
sums = ss.totals(row, _full(amounts)) or sg.block_totals(_full(amounts)) # 끝전 · 조정(792-3)
parts = " + ".join(
f"{PART_NAME[p]} {'빈칸' if sums[p] is None else f'{sums[p]:,.0f}'}" for p in sg.PARTS
)
+2 -3
View File
@@ -267,8 +267,7 @@ def drop_apart(got: dict[str, Any], shown: Any) -> dict[str, Any]:
}
if not out or not got.get("기준"):
return got
kept = [x for i, x in enumerate(lines) if i not in out] # 기준 = 남은 줄 합(751-3 끝수)
got = mcut.plain({**got, "줄": kept})
got = mcut.plain(got, out) # 기준 = 남은 줄 합(751-3 끝수 · 791-1 셈법)
return {**got, "기준": _json(got["기준"])}
@@ -559,7 +558,7 @@ def run_line(
money = {k: got[k] for k in (*COSTS, "계")}
mats = logic_mats(whole, logic, got["줄"], picks)
lines = pct_extras(whole, logic, got["줄"], got.get("중간") or {})
out = {"기준": money, "줄": lines, "재료": mats, "까닭": None}
out = {"기준": money, "줄": lines, "재료": mats, "까닭": None, **mcut.kept(got)}
return out if raw or rate in (None, 100) else mcut.plain(out) # 켜쌓기율 곱 뒤 다시 버림
+33 -9
View File
@@ -133,7 +133,7 @@ def engine(master, ref: str, got: dict, depth: int = 0) -> dict:
# 중간 = 덧줄이 본 값(버린 줄 합 · 덧줄 값 · `줄`) — 시험 계산 중간 칸
return {**got, "줄": lines, **costs, "계": sum(costs.values()), "중간": env}
costs = settle(logic, total)
more = {"단계": ms.steps(logic, lines)}
more = {"단계": ms.steps(logic, lines), "셈법": ms.rule(logic)}
return {**got, "줄": lines, **costs, "계": sum(costs.values()), "중간": env, **more}
@@ -150,17 +150,41 @@ def settle(logic: dict, total: dict) -> dict:
return {k: down(v, 0) for k, v in costs.items()}
def plain(got: dict) -> dict:
def kept(got: dict) -> dict:
"""`run_line` 이 엔진 답에서 그대로 넘기는 덧칸(792-3) — 단계 · 셈법."""
return {k: got[k] for k in ("단계", "셈법") if k in got}
def plain(got: dict, gone=()) -> dict:
"""로직 줄 답(`run_line` 꼴 `{기준, 줄, 재료}`)을 줄만 다시 버림 —
켜쌓기율 곱 · 별도계상 뺀 뒤 · 기준 = 남은 줄 비목 합 ROUNDDOWN 0."""
켜쌓기율 곱 · 별도계상 뺀 뒤(`gone` = 뺄 줄 차례) · 기준 = 남은 줄 비목 합 ROUNDDOWN 0 ·
답 `셈법`(791-1 새 의미 로직) = 줄 자리 · 합밖 · 단계 · 블록 끝전 · 조정 그대로(`settle`)."""
if not got.get("기준"):
return got
rule = got.get("셈법")
places = [x.get("자리") for x in ms.items(rule)] if rule else []
skip = set(gone) | (ms.skipped(rule) if rule else set())
def at(n):
return places[n] if isinstance(n, int) and 0 <= n < len(places) else None
lines, total = [], dict.fromkeys(COSTS, Decimal(0))
for line in got.get("줄") or []:
cut, money = _parts(dict(line.get("비목") or {}))
for n, line in enumerate(got.get("줄") or []):
cut, money = _parts(dict(line.get("비목") or {}), at(n))
for k, v in cut.items():
total[k] += Decimal(str(v or 0))
total[k] += 0 if n in skip else Decimal(str(v or 0))
lines.append({**line, "비목": cut, "금액": money})
mats = [{**m, "금액": down(m.get("금액"), 1)} for m in got.get("재료") or []]
base = {k: down(v, 0) for k, v in total.items()}
return {**got, "줄": lines, "재료": mats, "기준": {**base, "계": sum(base.values())}}
mats = [{**m, "금액": _line(m.get("금액"), at(m.get("차례")))} for m in got.get("재료") or []]
kept = [x for n, x in enumerate(lines) if n not in gone]
if not rule:
base = {k: down(v, 0) for k, v in total.items()}
return {**got, "줄": kept, "재료": mats, "기준": {**base, "계": sum(base.values())}}
base = settle(rule, total)
steps = ms.steps(rule, [{} if n in gone else x for n, x in enumerate(lines)])
return {
**got,
"줄": kept,
"재료": mats,
"기준": {**base, "계": sum(base.values())},
"단계": steps,
}
@@ -33,6 +33,17 @@ def uses(row: dict) -> bool:
return any(k in row for k in ROW_KEYS) or any(k in x for x in items(row) for k in LINE_KEYS)
def rule(row: dict) -> dict:
"""셈법(792-3) — 새 의미 칸만 뗀 작은 로직 꼴(이 모듈 · `master_cut.settle` 이 읽음) ·
엔진 답 `셈법` 으로 실어 켜쌓기율 · 별도계상 뒤 다시 버림(`master_cut.plain`)이 씀."""
keep = ("이름", *LINE_KEYS)
lines = {
k: [{x: one[x] for x in keep if x in one} for one in row.get(k, [])]
for k in ("호표", "덧줄")
}
return {**lines, **{k: row[k] for k in (*ROW_KEYS, "끝수") if k in row}}
def shown(step: dict, value):
"""중간 `표시` = 값을 n 자리 반올림해 뒤 줄에 넘김(STmate `X = 식 =`)."""
if step.get("표시") is None or not isinstance(value, Decimal):
+3 -2
View File
@@ -22,6 +22,7 @@ if str(ROOT) not in sys.path:
from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402
from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402
from B09_Estimation import B09_Estimation_Sangeun as sg # noqa: E402
from B09_Estimation import B09_Estimation_Sangeun_Sum as ss # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402
from B09_Estimation.B09_Estimation_Linked import used_keys # noqa: E402
from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402
@@ -163,13 +164,13 @@ def test_작업량_Q는_소수_둘():
whole = m01.loaded()[1]
for code, row in sorted((whole.index.get("GF") or {}).items()):
if not any(sg._Q.fullmatch(s["이름"]) for s in row.get("중간", [])):
if not any(ss._Q.fullmatch(s["이름"]) for s in row.get("중간", [])):
continue
try:
env = sg._env(whole, row, {})
except Exception: # noqa: BLE001 — 입력이 필요한 로직은 건너뜀
continue
q = next(env[s["이름"]] for s in row["중간"] if sg._Q.fullmatch(s["이름"]))
q = next(env[s["이름"]] for s in row["중간"] if ss._Q.fullmatch(s["이름"]))
if hasattr(q, "as_tuple"):
assert q == round(q, 2), code
return
+17 -11
View File
@@ -25,6 +25,7 @@ SRC = os.path.join(HERE, "..", "..", "M02_MasterTemplete")
MIX = "GF000156" # 기계비빔타설 — 호표 3 시멘트 · 4 모래 · 5 자갈
GIVEN = {"재료배합": "(A)", "골재최대치수": 40}
MAT = "MT000008"
COSTS = ("노무비", "재료비", "경비")
DIRECT = {"별도계상": "직접계산"}
@@ -36,6 +37,15 @@ def _rows(got):
return {x["이름"]: {**x, "수량": float(x["수량"])} for x in got["줄"]}
def _kept(raw, names=None):
"""별도계상 뺀 기준(751-3 끝수) = 남은 줄 비목 합마다 원 버림 · 계 = 셋 합 ·
`names` = 뺄 재료 이름(없으면 재료 전부)."""
gone = {m["차례"] for m in raw["재료"] if names is None or m["이름"] in names}
lines = [x for n, x in enumerate(raw["줄"]) if n not in gone]
out = {k: int(sum(Decimal(str(x["비목"].get(k) or 0)) for x in lines)) for k in COSTS}
return {**out, "계": sum(out.values())}
# ── ① 단위 ──────────────────────────────────────────────────────────
def test_단위중량_칸에_t_값이면_1000배_0이면_표_위끝():
wrong = labor.run_line(_whole(), MIX, {**GIVEN, "모래단위중량": 2, "자갈단위중량": 2.1})
@@ -60,16 +70,14 @@ def _calc(shown=None, mats=()):
def test_로직_자재_기본_별도계상은_기준_줄에서_빠지고_재료엔_남음():
raw = labor.run_line(_whole(), MIX, GIVEN)
cut = sum(Decimal(str(m["금액"])) for m in raw["재료"])
one = _calc()["줄"][0]
names = {x["이름"] for x in one["줄"]}
assert not names & {"시멘트", "모래(건조상태)", "자갈 또는 부순돌"}
assert {"콘크리트공", "보통인부"} <= names
assert len(one["재료"]) == 3 # 자재목록(세 칸 고침)엔 남음
want = Decimal(str(raw["기준"]["재료비"])) - cut
assert one["기준"]["재료비"] == pytest.approx(float(want))
assert one["기준"]["계"] == pytest.approx(float(Decimal(str(raw["기준"]["계"])) - cut))
assert one["기준"]["노무비"] == pytest.approx(float(raw["기준"]["노무비"]))
want = _kept(raw)
assert {k: one["기준"][k] for k in (*COSTS, "계")} == want
assert one["기준"]["노무비"] == raw["기준"]["노무비"]
def test_직접계산은_그대로_섞이면_그_자재만_빠짐():
@@ -80,9 +88,8 @@ def test_직접계산은_그대로_섞이면_그_자재만_빠짐():
part = _calc({"3": DIRECT})["줄"][0] # 시멘트만 셈
names = {x["이름"] for x in part["줄"]}
assert "시멘트" in names and "모래(건조상태)" not in names
cement = next(m for m in raw["재료"] if m["이름"] == "시멘트")
others = sum(Decimal(str(m["금액"])) for m in raw["재료"]) - Decimal(str(cement["금액"]))
assert part["기준"]["계"] == pytest.approx(float(Decimal(str(raw["기준"]["계"])) - others))
others = {m["이름"] for m in raw["재료"]} - {"시멘트"}
assert part["기준"]["계"] == _kept(raw, others)["계"]
def test_사용자_자재_별도계상은_합에서_빠지고_반영은_보임():
@@ -106,7 +113,6 @@ def test_사용자_자재_별도계상_파이썬과_TS_같은_답(mark):
def test_사전조합_안_줄도_별도계상_뺌():
raw = labor.run_line(_whole(), MIX, GIVEN)
cut = sum(Decimal(str(m["금액"])) for m in raw["재료"])
doc = {
"이름": "비빔 조합",
"단위": "㎥",
@@ -120,8 +126,8 @@ def test_사전조합_안_줄도_별도계상_뺌():
assert [x.get("자재") for x in got["안"]] == [None]
pill = got["안"][0]
times = Decimal(1) # 기계비빔타설 알약 배 1
assert pill["기준"]["계"] * times == pytest.approx(Decimal(str(raw["기준"]["계"])) - cut)
assert got["기준"]["계"] == pytest.approx(Decimal(str(raw["기준"]["계"])) - cut)
assert pill["기준"]["계"] * times == _kept(raw)["계"]
assert got["기준"]["계"] == _kept(raw)["계"]
def test_화면_별도계상_바꾸면_다시_셈():
@@ -324,7 +324,7 @@ def test_검사():
# ── ③ 실무 손 옮김 견본 넷 ─────────────────────────────────────────────
def test_봉화_D00102_합판거푸집_STmate_번지_율():
def 봉화():
"""봉화 단가산출근거 2909~2943 행 — `&M00081& * 1.03 * 0.327 :=A01` · `&PA01M:M& * 11'%'` ·
`&PA02L:S& * 1'%'` · `(=)` · `(==)` · 총계 45,652 · 34,119 · 11,192 · 341."""
row = _logic(
@@ -358,16 +358,21 @@ def test_봉화_D00102_합판거푸집_STmate_번지_율():
{"LB990101": ("형틀목공", 274978), "LB990102": ("보통인부", 165545)},
{"MT990101": ("합판(내수)", "㎡", 11468), "MT990102": ("각재", "㎥", 500661)},
)
return master, row, {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652}
def test_봉화_D00102_합판거푸집_STmate_번지_율():
master, row, want = 봉화()
assert mf.check_logic(master, row)[0] == []
got = _calc(master, "GX990101")
assert _money(got) == {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652}
assert _money(got) == want
assert [x["금액"] for x in got["줄"]] == [
D(x) for x in ("3862.5", "6221.2", "27497.8", "6621.8", "1109.2", "341.1")
]
assert [x["계"] for x in got["단계"]] == [D("11192.9"), D("34460.7"), D("45653.6")]
def test_울진소광_D01489_낙석방지책_STmate_L퍼센트():
def 소광():
"""울진소광 32 호표(D01489) — `'공구손료 :'&S01045& * 2'%'`(S01045 단위 L% = 처음부터
노무비 누계) · `L = Q/10 =` 표시 2 · 계 줄 420,734 · 182,223 · 230,359 · 8,152
(계약단가 87.745 % 는 B09 몫)."""
@@ -412,15 +417,20 @@ def test_울진소광_D01489_낙석방지책_STmate_L퍼센트():
},
{"MT990103": ("낙석방지책", "경간", 227000)},
)
return master, row, {"노무비": 182223, "재료비": 230359, "경비": 8152, "계": 420734}
def test_울진소광_D01489_낙석방지책_STmate_L퍼센트():
master, row, want = 소광()
assert mf.check_logic(master, row)[0] == []
got = _calc(master, "GX990102")
assert _money(got) == {"노무비": 182223, "재료비": 230359, "경비": 8152, "계": 420734}
assert _money(got) == want
assert _line(got, "공구손료")["금액"] == D("3644.4")
assert _step(got, "지주 계")["계"] == D("49824.2")
assert _step(got, "합계")["계"] == D("420735.3")
def test_동부청영월_D00003_터파기_STmate_큰나머지_끝전():
def 영월():
"""동부청영월 단가산출근거 54~103 행(D00003) — `f = 1/1.3 =` · `E=…=` ·
`Q = 3600*q*K*f*E/Cm =`(표시 2) · `&X00205L/M/S& / Q * 0.9` · 계 원 버림 + 큰 나머지
5,471 · 4,764 · 310 · 397."""
@@ -446,16 +456,21 @@ def test_동부청영월_D00003_터파기_STmate_큰나머지_끝전():
끝전="큰나머지",
)
master = _master([row, _HOUR], {"LB990106": ("보통인부", 165545)})
return master, row, {"노무비": 4764, "재료비": 310, "경비": 397, "계": 5471}
def test_동부청영월_D00003_터파기_STmate_큰나머지_끝전():
master, row, want = 영월()
assert mf.check_logic(master, row)[0] == []
got = _calc(master, "GX990103")
assert got["중간"]["Q"] == D("52.39")
assert _money(got) == {"노무비": 4764, "재료비": 310, "경비": 397, "계": 5471}
assert _money(got) == want
assert _step(got, "계")["계"] == D("5471.0")
row["끝전"] = "비목버림" # 비목 원 버림이면 1 원 모자람
assert _calc(master, "GX990103")["계"] == 5470
def test_북부청임도_18_문양거푸집_EST_줄_자리():
def 임도():
"""북부청임도 단가산출 18 번(문양거푸집) — `5,786.1 × 2% = 116`(그 줄만 반올림 0) ·
`인부소계 : 24,467.3` · 노무비 24,467 · 재료비 5,902 · 계 30,369."""
row = _logic(
@@ -480,11 +495,16 @@ def test_북부청임도_18_문양거푸집_EST_줄_자리():
{"LB990107": ("형틀목공", 275790), "LB990108": ("보통인부", 172068)},
{"MT990104": ("문양거푸집(ABS코팅폼)", "㎡", 4166)},
)
return master, row, {"노무비": 24467, "재료비": 5902, "경비": 0, "계": 30369}
def test_북부청임도_18_문양거푸집_EST_줄_자리():
master, row, want = 임도()
assert mf.check_logic(master, row)[0] == []
got = _calc(master, "GX990104")
assert _line(got, "잡재료비")["금액"] == 116
assert _step(got, "인부소계")["계"] == D("24467.3")
assert _money(got) == {"노무비": 24467, "재료비": 5902, "경비": 0, "계": 30369}
assert _money(got) == want
del row["덧줄"][0]["자리"] # 기본 0.1 버림이면 엑셀과 1 원 다름
assert _calc(master, "GX990104")["계"] == 30368
+148
View File
@@ -0,0 +1,148 @@
"""792-3 엔진 새 의미 네 길 지킴 시험 — 같은 로직 JSON 이 ① 엔진 직접(`master_cut.engine`) ·
② M02 단가산출 SK 셈(`M02_Sangeun_Calc.run`) · ③ 일위대가 SC 한 줄(수량 1 · `M02_Labor_Doc.calc`) ·
④ B09 산근 풀이(`B09_Estimation_Sangeun.Solver` · 단가 = 같은 값의 B09 목록표)에서 같은 금액.
견본 = 791-1 실무 손 옮김 넷(봉화 D00102 · 울진소광 D01489 · 동부청영월 D00003 · 북부청임도 18 =
엑셀 금액) + 합밖 · 제외 · 조정 단위 로직. 켜쌓기율 · 별도계상 뒤 다시 버림(`master_cut.plain`)도
엔진 셈법(줄 자리 · 끝전 · 조정)을 지킴. 옛 로직(새 칸 없음) 전후 같음 = `test_791_1` M01 지문.
"""
import copy
import importlib
from decimal import Decimal
import pytest
import resources.tester.test_791_1_engine_semantics as t
from B09_Estimation import B09_Estimation_Sangeun as sg
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Sangeun_Calc as sc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
mcut = importlib.import_module("master_cut")
D = Decimal
COSTS = ("노무비", "재료비", "경비", "계")
PART = {"labor": "노무비", "material": "재료비", "expense": "경비", "sum": "계"}
EQ = "EQ990001"
def _units():
"""합밖 · 제외 단계 · 조정 · 큰나머지 · 줄 자리를 한 로직에(791-1 단위 견본 바탕)."""
master = t._base(
덧줄=[
{"이름": "공구손료", "식": "단계.'인부 소계'.노무비 * 3 / 100", "비목": "경비"},
{"이름": "잡", "식": "번지.A01.재료비 * 1.5 / 100", "비목": "재료비"},
{"이름": "덧", "식": "0.6", "비목": "노무비", "자리": {"방법": "올림", "자리": 0}},
],
단계=[
{"이름": "자재 소계", "단": 1, "줄": ["갑", "을"]},
{"이름": "인부 소계", "단": 1, "줄": ["보통", "특별"]},
{"이름": "경비 소계", "단": 1, "줄": ["공구손료"], "제외": True},
],
끝전="큰나머지",
조정={"배율": {"재료비": D("0.9")}},
)
row = master.logic("GX990001")
row["호표"][1]["합밖"] = True
return master, row, None
CASES = {"봉화": t.봉화, "소광": t.소광, "영월": t.영월, "임도": t.임도, "단위": _units}
def _key(row) -> str:
return row["키"]
def _engine(master, row) -> dict:
got = t._calc(master, _key(row))
return {k: got[k] for k in COSTS}
def _sk(master, row) -> dict:
doc = {"키": "SK-990001", "이름": "시험", "단위": "식", "바탕": {"로직": _key(row)}}
got = sc.run(sd.clean(doc, None, master), master)
return {k: got["기준"][k] for k in COSTS}
def _sc(master, row) -> dict:
mats = {
str(n): {"별도계상": "직접계산"} for n, x in enumerate(row["호표"]) if x["종류"] == "재료"
}
line = {"로직": _key(row), "입력값": {}, "수량": {"손": 1}, "자재표시": mats}
got = labor.calc({"담은로직": [line], "자재": []}, {}, whole=master, pills=[])
return {k: got["반영합"][k] for k in COSTS}
def _b09_master(master, row):
"""B09 = 기계 줄 단가를 중기목록표에서 — 기계경비 로직이 부르는 EQ 키를 잡게
시간당 로직에 한 줄."""
hour = copy.deepcopy(master.index["GX"].get("GX990009") or {})
if hour:
hour["호표"] = [t._mat(EQ, "1", "기계", 비목="경비")]
master.index["GX"]["GX990009"] = hour
master.index["EQ"] = {EQ: {"키": EQ, "이름": "시험 기계", "값": D(0)}}
return master
def _b09(build, row) -> dict:
master, row, _ = build()
master = _b09_master(master, row)
files = master.index
machine = next((x["값묶음"] for x in row["호표"] if "값묶음" in x), None)
sheets = {
"labor": [{"key": k, "price": float(r["값"])} for k, r in files.get("LB", {}).items()],
"compare": [
{"key": k, "applied": float(r["물가자료"])} for k, r in files.get("MT", {}).items()
],
"machine": [{"key": EQ, **{p: float(machine[c]) for p, c in PART.items() if c in machine}}]
if machine
else [],
}
block = sg.Solver(sheets).add(master, {"code": _key(row), "given": {}}, None)
assert block["why"] is None, block["why"]
texts = [x for ln in block["lines"] for x in ln.get("text") or []]
assert not [x for x in texts if "못" in x or "빈칸" in x], texts # 산근 글도 같은 셈
assert block["foot"].endswith(f"= {block['sum']:,.0f}"), block["foot"]
return {PART[p]: D(str(block[p])) for p in PART}
@pytest.mark.parametrize("name", list(CASES))
def test_네_길_같은_금액(name):
build = CASES[name]
master, row, want = build()
one = _engine(master, row)
if want is not None:
assert one == want # 엑셀 금액
assert _sk(*build()[:2]) == one
assert _sc(*build()[:2]) == one
assert _b09(build, row) == one
def test_단위_로직_새_의미가_금액을_바꿈():
master, row, _ = _units()
got = _engine(master, row)
plain = copy.deepcopy(row)
for k in ("끝전", "조정", "단계"):
plain.pop(k)
for x in (*plain["호표"], *plain["덧줄"]):
for k in ("합밖", "자리", "번지"):
x.pop(k, None)
assert got != _engine(t._master([plain], t.LAB, t.MAT), plain) # 시험이 새 길을 거침
@pytest.mark.parametrize("name", ["영월", "단위"])
def test_다시_버림도_셈법_지킴(name):
"""켜쌓기율 · 별도계상 뒤 다시 버림(`plain`) — 셈법이 있으면
줄 자리 · 합밖 · 끝전 · 조정 그대로."""
master, row, _ = CASES[name]()
out = labor.run_line(master, _key(row), {})
assert "셈법" in out and out["단계"]
again = mcut.plain(out)
assert again["기준"] == out["기준"]
assert [x["계"] for x in again["단계"]] == [x["계"] for x in out["단계"]]
old = mcut.plain({k: v for k, v in out.items() if k != "셈법"})
if name == "영월":
assert (
old["기준"]["계"] == 5470 and again["기준"]["계"] == 5471
) # 옛 다시 버림 = 1 원 모자람
+67
View File
@@ -47,6 +47,73 @@ def test_annual_fixed_amount_with_zero_quantity_and_duplicate_names():
assert len(result["invalid"]["errors"]) == 1
def test_annual_procurement_fee_is_allocated_and_kept_in_government_costs():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.id='123456789012345678901234';p.options.GangubExceptJodalRatio=true;
p.tables.EstMainDat=[
{PkID:1,Name:'labor',GN:1,NU:1000},
{LinkIndex:54},
{PkID:2,Name:'관급자재',Level:1},
{PkID:3,Name:'material',GN:1,GU:301},
{PkID:4,Name:'조달수수료',LinkIndex:57,GN:10,Unite:'%',OCheck:4,SCheck:3}];
p.tables.Wonga=[
{Gubun:'F',Name:'직접노무비',BaseField:'NC'},
{Gubun:'G',Name:'관급자재대'},
{Gubun:'S',Name:'산업안전보건관리비',VSusic:'G*0.1'},
{Gubun:'T',Name:'총공사비',LinkIndex:55,VSusic:'F+G+S'}];
p.annual=[{name:'1차',formula:'[기준]*0.5'},{name:'2차',formula:'[기준]-[1차]'}];
const q=EstNative.calculate(p),before=JSON.stringify(q),output=EstBusiness.annual(q);
return {q,output,preserved:before===JSON.stringify(q),
again:output.results.map(item=>EstNative.calculate(item.project))};
})()""",
)
assert result["preserved"]
assert result["output"]["errors"] == []
assert result["q"]["calculation"]["total"] == 1361
projects = [item["project"] for item in result["output"]["results"]]
for project in projects + result["again"]:
assert "id" not in project
fee = project["tables"]["EstMainDat"][4]
assert fee["LinkIndex"] == 99
assert fee["CalcIndex"] == 1
assert fee["GN"] == 10
assert fee["TC"] == fee["JC"] == 15
assert fee["TU"] == fee["JU"] == 0
government = project["tables"]["Wonga"][1]
assert government["NC"] == 15
assert project["tables"]["Wonga"][2]["Cost"] == 15
assert [project["calculation"]["total"] for project in projects] == [680, 681]
assert [project["calculation"]["total"] for project in result["again"]] == [680, 681]
def test_annual_excluded_fee_does_not_reduce_the_government_safety_base():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.options.GangubExceptJodalRatio=true;
p.tables.EstMainDat=[{LinkIndex:54},{Level:1,Name:'관급자재'},
{GN:1,GU:301},{LinkIndex:57,Name:'조달수수료',GN:10,CalcIndex:2}];
p.tables.Wonga=[{Gubun:'G',Name:'관급자재대'},
{Gubun:'S',Name:'산업안전보건관리비',VSusic:'G*0.1'},
{Gubun:'T',Name:'총공사비',VSusic:'G+S',LinkIndex:55}];
p.annual=[{name:'first',formula:'[기준]*0.5'}];
const q=EstNative.calculate(p);
return {q,output:EstBusiness.annual(q)};
})()""",
)
assert result["q"]["tables"]["Wonga"][0]["NC"] == 0
project = result["output"]["results"][0]["project"]
assert project["tables"]["EstMainDat"][3]["CalcIndex"] == 3
assert project["tables"]["EstMainDat"][3]["TC"] == 15
assert project["tables"]["Wonga"][0]["Cost"] == 150
assert project["tables"]["Wonga"][0]["NC"] == 0
assert project["tables"]["Wonga"][1]["Cost"] == 15
def test_resource_expansion_preserves_different_labor_and_material_bid_rates():
result = run(
"math",
+122
View File
@@ -135,3 +135,125 @@ def test_subtotal_includes_price_only_rows_and_updates_truncated_final_amount():
149380,
158440,
]
def test_operator_followed_by_spaces_does_not_start_a_new_assignment():
result = run(
"math",
r"""[1700,2000].map(weight=>EstText.calculate([
{Index:1,Code:'forest',LineData:'forest'},
{LineData:` N = 54.93 q = 250 W = ${weight}`},
{LineData:' Q = N × q ÷ W = 54.93 × 250 ÷ 1,700 = \x128.08\x12 ㎥/hr'},
{LineData:' V = Q × 2 = \x1216.16\x12'}
]))""",
)
assert all(item["errors"] == [] for item in result)
assert [item["rows"][2]["TC"] for item in result] == [8.08, 6.87]
assert [item["rows"][3]["TC"] for item in result] == [16.16, 13.74]
def test_curly_wrapped_results_and_explanatory_calculations_keep_their_text():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'forest',LineData:'forest'},
{LineData:' {0.91m×0.91m×0.6m = \x120.49686\x12㎥}'},
{LineData:' {0.49686 ×2.6Ton = \x121.29\x12}'},
{LineData:' ※ ㎡당 중량 1.6개 ×1,325÷1,000 = \x122.12\x12Ton/㎡'}
])""",
)
assert result["errors"] == []
assert [row["TC"] for row in result["rows"][1:]] == [0.49686, 1.29, 2.12]
assert result["rows"][1]["LineData"].strip().startswith("{")
assert result["rows"][1]["LineData"].endswith("}")
def test_won_unit_glyphs_are_units_instead_of_operators():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'money',LineData:'money'},
{LineData:' 0.0347㎥ / 2.5 × 0 \\/㎥ = \x120\x12'},
{LineData:' 0.0347㎥ / 2.5 × 2500 \\/㎥ = \x1234.7\x12'},
{LineData:' 2㎥ × 2500₩/㎥ = \x125000\x12'}
])""",
)
assert result["errors"] == []
assert [row["TC"] for row in result["rows"][1:]] == [0, 34.7, 5000]
def test_range_conditions_are_not_assigned_arbitrary_values():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'range',LineData:'range'},
{LineData:' 앙카핀 (D=13, L=450~600mm) 1.0㎡ : 별도계상'},
{LineData:' H = 0~4m : (기계80%, 인력20%)'},
{LineData:' Q = 2 × 3 = \x126\x12'},
{LineData:' V = H × 2 = \x128\x12'}
])""",
)
assert len(result["errors"]) == 1
assert "참조 항목 없음: H" in result["errors"][0]
assert result["rows"][3]["TC"] == 6
def test_quantity_subtotal_keeps_six_digits_before_pricing():
result = run(
"math",
r"""[4.4,5].map(length=>EstText.calculate([
{Index:1,Code:'sign',LineData:'sign'},
{Level:2,LineData:'각재 수량'},
{Diget:7,DigetIndex:1,LineData:` 0.09 ×0.09 ×${length}m ×2개 = \x120.07128\x12`},
{Diget:7,DigetIndex:1,LineData:' 0.06 ×0.06 ×2.28m ×5개 = \x120.04104\x12'},
{Diget:7,DigetIndex:1,LineData:' 0.06 ×0.06 ×2.28m ×7개 = \x120.057456\x12'},
{Diget:7,DigetIndex:1,LinkIndex:61,LineData:' 소 계 : Q1 = \x0c0.169776\x0c ㎥/개소'},
{SCheck:-2,LinkIndex:5,LineData:' 재료비 : \x03577,842\x03 × Q1 = 577,842 × 0.169776 = \x1298,103.7\x12'}
]))""",
)
assert all(item["errors"] == [] for item in result)
assert [item["rows"][5]["TC"] for item in result] == [0.169776, 0.179496]
assert [item["rows"][6]["TC"] for item in result] == [98103.7, 103720.3]
def test_declaration_can_follow_an_hour_unit_with_a_single_space():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'transport',LineData:'transport'},
{LineData:' L = 1.0Km{?} V1 = 5.5Km/hr V2 = 9.3Km/hr t=35분'},
{LineData:' Q = t × 2 = \x1270\x12'}
])""",
)
assert result["errors"] == []
assert result["rows"][2]["TC"] == 70
def test_fullwidth_unit_slash_and_ground_level_annotations():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'annotations',LineData:'annotations'},
{LineData:' Q = 23.32 ㎥/hr'},
{LineData:' D1 = {-}6.6m [평균지반고]'},
{LineData:' D2 = {-}18.6m [기초터파기 평균 저면 EL.]'},
{LineData:' V = Q + D1 - D2 = \x1235.32\x12',Precision:2}
])""",
)
assert result["errors"] == []
assert result["rows"][4]["TC"] == 35.32
def test_spaced_metre_unit_is_not_a_second_variable_declaration():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'spaced_unit',LineData:'spaced_unit'},
{LineData:' L7 = 4.5m'},
{LineData:' Q2 = L7 / 0.8 M = 4.5 / 0.8 = \x125.63\x12 개'},
{SCheck:-2,LineData:' 재료비 : \x033,000\x03 × Q2 = 3,000 × 5.63 = \x1216,890\x12'}
])""",
)
assert result["errors"] == []
assert result["rows"][2]["TC"] == 5.63
assert result["rows"][3]["TC"] == 16890
+6 -2
View File
@@ -32,15 +32,19 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
연차공사의 `[기준]*0.5` 수식은 수량과 각 성분 금액에 각각 적용됩니다. 금액은 정수 절사하고, 다음 차수의 `[기준]-[1차]`로 절사 후 잔액을 배분합니다. 차수 사본은 단가를 비운 고정 금액으로 저장하여 다시 열어도 금액을 유지합니다. `[계약수량]`은 이전 작업 파일과의 호환 표기로 `[기준]`과 같은 역할을 합니다.
연차의 조달수수료는 원본처럼 연결 종류 99로 전환해 금액을 배분하며 요율은 유지합니다. 관급 합계와 안전관리비의 수수료 제외 계산에 배분액을 반영하고, 합산 제외 행은 제외 상태를 유지합니다. 차수 내역 열기는 해당 내역서를 표시하며, 저장하면 원본과 다른 파일 ID를 부여해 원본을 덮어쓰지 않습니다.
동원 집계는 비목별 낙찰률과 단위환산을 하위 산근·일위대가에도 전달합니다. 연결 단가가 없는 금액 입력분은 원본 전용 코드로 따로 합산하고, 장비 시간으로 바꿀 수 없는 경비는 `제외금액` 단위로 보존합니다. 산근에서 연결을 해제한 단가는 앞 행의 원천을 가져오지 않습니다.
설계변경 비교는 웹에서 확정한 당초 사본이 없을 때 원본 파일의 당초 수량·금액을 사용합니다. 별도 당초 확정 사본이 있으면 그 사본이 우선합니다. 원본 당초 전체 자료와 원가 서식의 활성 선택을 자동 추정하지는 않습니다.
원본 산근의 `배`·`번`·`줄`·`set`·`kg/set`·`㎜`, 괄호 뒤 면적·체적 단위와 별표·따옴표로 감싼 변수 선언도 계산합니다. 측량설계 저장값 대조는 80,659/80,682 일치·해석 오류 5건, 가시설은 13,978/13,979 일치·해석 오류 4건입니다. 범위 조건과 누락된 참조 등은 임의 값으로 대체하지 않습니다.
원본 산근의 `배`·`번`·`줄`·`set`·`kg/set`·`㎜`, 괄호 뒤 면적·체적 단위와 별표·따옴표로 감싼 변수 선언도 계산합니다. 연산자 뒤 공백과 새 선언을 구분하며, `\\/㎥`·`₩/㎥` 금액 단위와 전각 단위 구분자도 읽습니다. 측량설계 저장값 대조는 80,671/80,682 일치·해석 오류 0, 산림토목은 15,049/15,055 일치·해석 오류 0, 가시설은 13,978/13,979 일치·해석 오류 3건입니다. 범위 조건과 누락된 참조 등은 임의 값으로 대체하지 않습니다.
소계는 원본처럼 정밀도를 유지하며 합산한 뒤 행의 자릿수를 적용합니다. 수량 `0.169776`을 4자리로 미리 줄이지 않습니다. 원본 산식과 저장 표지가 다른 경우는 구분합니다. 예를 들어 상수공사 원문의 `{ 소요살수량 1.7 ÷ 0.95 = ⟦결과⟧1.7⟦결과⟧9 Ton/㎥ }`는 저장 표지 1.7과 재계산 1.79가 다릅니다.
구간 합계와 소계는 결과 금액뿐 아니라 단가만 적힌 지정 행도 포함합니다. 구간 합계의 표지와 최종 합계 표지를 구분하며, 이미 정수로 절사된 최종 합계도 앞 성분 금액과의 합산 관계가 확인되면 변경액을 다시 집계합니다. 단가 550→600 변경이 구간 비율 27.5→30, 최종 합계 27→30, 내역 270→300으로 전달되는 것을 저장·취소까지 확인했습니다.
이 보완 후 해양 자료는 소계 표지까지 포함해 134,133/134,208 저장값 일치·해석 오류 99건입니다. 계산·이력 전용 시험 64개가 통과했으며, 남은 불일치와 원본 실행 미확인은 별개로 유지합니다.
이 보완 후 해양 자료는 소계 표지까지 포함해 134,133/134,208 저장값 일치·해석 오류 95건입니다. 계산·이력·연차 전용 시험 74개가 통과했으며, 남은 불일치와 원본 실행 미확인은 별개로 유지합니다.
**전체 원본 동등성은 아직 확보하지 못했습니다.** 기성·물가변동·연차 등은 분석 분기와 합성 시나리오를 시험했으며 원본 업무 샘플 대조는 없습니다. 원본 화면 실행 비교는 못했습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다.
+3 -1
View File
@@ -415,8 +415,10 @@ const EstActions = (() => {
else if (name === "open-annual") {
const result = EstBusiness.annual(project);
if (result.errors.length) throw Error(result.errors.join("\n"));
if (confirm("현재 화면을 차수 내역 사본으로 바꾸시겠습니까?"))
if (confirm("현재 화면을 차수 내역 사본으로 바꾸시겠습니까?")) {
EstApp.setProject(result.results[Number(node.dataset.row)].project);
EstApp.navigate("EstMainDat");
}
} else if (name === "delete-extra") {
if (confirm("이 항목을 삭제하시겠습니까?"))
EstApp.change(
+5 -5
View File
@@ -369,6 +369,7 @@ const EstBusiness = (() => {
for (const year of project.annual || []) {
const next = copy(project);
next.name = `${project.name} · ${year.name}`;
delete next.id;
next.progress = [];
next.annual = [];
next.options.TotalFix = false;
@@ -376,10 +377,9 @@ const EstBusiness = (() => {
for (let index = 0; index < next.tables.EstMainDat.length; index++) {
const row = next.tables.EstMainDat[index];
if (num(row.LinkIndex) === 57) {
row.CalcIndex = 3;
components.forEach((k) => (row[k] = 0));
row.TC = 0;
continue;
// f0fcc0 keeps the fee rate and allocates the amount as a fixed row (99).
row.AnnualSourceLink = 57;
row.LinkIndex = 99;
}
if (num(row.Level) > 0 || [54, 55, 58, 59, 62, 67].includes(num(row.LinkIndex))) continue;
try {
@@ -391,7 +391,7 @@ const EstBusiness = (() => {
const previous = results.find((result) => result.name === name);
return previous ? num(previous.project.tables.EstMainDat[index]?.[field]) : undefined;
});
row.GN = value("GN");
if (num(row.LinkIndex) !== 99) row.GN = value("GN");
components.forEach((key) => (row[key] = Math.trunc(value(key))));
row.TC = sum(row);
units.forEach((key) => (row[key] = 0));
+6 -1
View File
@@ -251,7 +251,12 @@ const EstNative = (() => {
i < tables.EstMainDat.length && num(tables.EstMainDat[i].Level) < level;
i++
)
if (num(tables.EstMainDat[i].LinkIndex) === 57)
if (
num(tables.EstMainDat[i].CalcIndex) < 2 &&
(num(tables.EstMainDat[i].LinkIndex) === 57 ||
(num(tables.EstMainDat[i].LinkIndex) === 99 &&
num(tables.EstMainDat[i].AnnualSourceLink) === 57))
)
procurementFees += num(tables.EstMainDat[i].TC);
}
if (options.AutoWongaBase !== false)
+17 -9
View File
@@ -18,19 +18,21 @@ const EstText = (() => {
.replace(/[;;]/g, ":");
function expression(text) {
return clean(
String(text).replace(
/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|〔[^〕]*〕|[[^]]*]|\([가-힣][^()]*\)|\(\s*\d+(?:\.\d+)?\s*[˚°]\s*\)|<[^>]*>/g,
"",
),
String(text)
.replace(///g, "/")
.replace(
/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|〔[^〕]*〕|[[^]]*]|\[[가-힣][^\]]*\]|\([가-힣][^()]*\)|\(\s*\d+(?:\.\d+)?\s*[˚°]\s*\)|<[^>]*>/g,
"",
),
)
.replace(
/(?<![A-Za-z0-9_가-힣.])(\d[\d,.]*|\.\d+)\s*(?:공\/?㎥|공|단|면|겹|경간|개월|월|년|인|㎥|㎢|㎠|㎡|hr|sec|min|set|kwh|kw|km|ton|kg|㎏|㎞|ℓ|회|개소|개|대|분|초|본|장|매|병|조|식|일|시간|원|포|동|주|톤|배|번|줄|μ|₩|EA|㎜|㎝|mm|cm|m3|m2|m|g|L|a)(?:\/(?:개소|경간|개|대|본|인|분|포|매|주|톤|공|회|조|span|set|ton|\$|hr|일|㎥|㎡|m3|m2|m|a|EA))?/gi,
/(?<![A-Za-z0-9_가-힣.])(\d[\d,.]*|\.\d+)\s*(?:공\/?㎥|공|단|면|겹|경간|개월|월|년|인|㎥|㎢|㎠|㎡|hr|sec|min|set|kwh|kw|km|ton|kg|㎏|㎞|ℓ|회|개소|개|대|분|초|본|장|매|병|조|식|일|시간|원|포|동|주|톤|배|번|줄|μ|₩|₩|\\|EA|㎜|㎝|mm|cm|m3|m2|m|g|L|a)(?:\/(?:개소|경간|개|대|본|인|분|포|매|주|톤|공|회|조|span|set|ton|\$|hr|일|㎥|㎡|m3|m2|m|a|EA))?/gi,
"$1",
)
.replace(/(?<=\d)\s*\/\s*(?:일|㎥|㎡|hr)(?=\s|$|[×*+)])/gi, "")
.replace(/[】]〕]\s*$/, "")
.replace(/(?<=\))\s*(?:개|인|본|면|단|회|㎥|㎡)(?:\/(?:㎡|㎥|hr))?/g, "")
.replace(/^\s*[-*•]?\s*[가-힣][가-힣\s]*\s+(?=[\d.(])/, "")
.replace(/^\s*[-*•※]?\s*[가-힣㎡㎥][가-힣㎡㎥\s]*\s+(?=[\d.(])/, "")
.replace(/[①-⑳]/g, "")
.replace(/=\s*$/, "");
}
@@ -180,7 +182,8 @@ const EstText = (() => {
? EstMath.precision(row)
: { digits: 1, method: 2 };
const subtotal = floating(
sumCurrency(values),
// f251d5-f251df accumulates Extended values before applying the row precision.
values.reduce((total, value) => total + value, 0),
options.CostValueTrunc && sc < 0 ? 0 : precision.digits,
precision.method,
);
@@ -318,9 +321,9 @@ const EstText = (() => {
? []
: [
...assignmentText.matchAll(
/(?:^\s*| {2,}|[,,]\s*|(?<=[가-힣])\s+|[::]\s*|['"]\s*)([A-Za-z][A-Za-z0-9]*)\s*=/g,
/(?:^\s*| {2,}|[,,]\s*|(?<=[가-힣])\s+|(?<=hr)\s+|[::]\s*|['"]\s*)([A-Za-z][A-Za-z0-9]*)\s*=/g,
),
];
].filter((match) => !/[+*/÷×−-]\s*$/.test(assignmentText.slice(0, match.index)));
for (let j = 0; j < assignments.length; j++) {
const assignment = assignments[j],
end = assignments[j + 1]?.index ?? text.length;
@@ -336,6 +339,10 @@ const EstText = (() => {
continue;
}
const result = body.match(marker);
if (!result && /\d\s*[~~]\s*\d/.test(body)) {
delete variables[assignment[1]];
continue;
}
const parts = body.split("=");
body = parts[0];
body = expression(body)
@@ -385,6 +392,7 @@ const EstText = (() => {
.filter((x) => x.trim())
.pop();
if (body.includes(":")) body = body.slice(body.indexOf(":") + 1);
if (/^\s*\{[\s\S]*\}\s*$/.test(text)) body = body.replace(/^\s*\{/, "");
body = expression(body)
.replace(/\s*([×÷·+*/−-])\s*/g, "$1")
.trim()
+1
View File
@@ -20,6 +20,7 @@ const EstViews = (() => {
[62, "구간비율"],
[67, "내역비율"],
[68, "묶음끝"],
[99, "연차 배분"],
],
CalcIndex: [
[0, "계산·합산"],