diff --git a/M02_MasterTemplete/M02_Hopyo_Make.py b/M02_MasterTemplete/M02_Hopyo_Make.py
index 5f5067296..8becc8fef 100644
--- a/M02_MasterTemplete/M02_Hopyo_Make.py
+++ b/M02_MasterTemplete/M02_Hopyo_Make.py
@@ -7,6 +7,7 @@
SC(`M02_Hopyo_Build.Builder` 가 줄 차례 · 확인 안 한 줄 = 모두 고정 길 그대로) — 한 엑셀 줄 = 고른 것마다
우리 줄(같은 별칭 = 엑셀 품명 · 줄 `본뜬 {호표, 차례, 행}`) · 수량 {손 = 엑셀 수량 × 몫 × 배} ·
기초 = 로직 줄(같은 로직 둘째 = 한 줄 SC 사본) · 재료 · 유가 = 자재 목록(별도계상) · 인력 · 기계 · 유가 이음 ·
+ 별도계상 줄(`고정` + 별도계상 · 786-1) = 단가 0 고정 줄(합 밖 · 메모 「별도계상 · 품목은 프로젝트에서」) ·
율 = 고정 줄 `비율 {기준, 비목}` · 고정 = 엑셀 단가 고정 줄 · 단가산출 · 일위대가 = 참조
(`단산N` = 같은 옮기기 블록 SK · `제N호표` = 같은 HP 호표 SC — 하위 먼저 · 확인 안 된 하위 = 모두 고정).
SK(블록) = 바탕 없음 + 더한줄 — 기초 `{종류 로직, 입력값, 수량 = 몫}` · 참조 · 단위 줄(`항목`)에 이은
@@ -44,6 +45,7 @@ _SUB = {"단가산출": re.compile(r"^단산(\d+)$"), "일위대가": re.compile
LINK = {"인력": "노임", "기계": "기계", "유가": "유가"}
OLD_PICK = {"인력": "노임", "기계": "기계", "재료": "자재", "유가": "자재"}
NEED = "입력 필요"
+APART_MEMO = "별도계상 · 품목은 프로젝트에서"
COVER = "이미 옮긴 것이 있음 — 덮으려면 [덮어 옮기기](덮음)"
@@ -156,6 +158,13 @@ class Lines:
ref = {"키": "SC-미리"} if b.dry else hkids.save(wrap, b.same)[0] # 미리 보기 = 쓰기 없음
self.lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
+ def apart(self, line: dict, alias: str | None, mark: dict) -> None:
+ """별도계상 줄(786-1) = 합 밖 고정 줄 — 단가 0(셈에 안 듦) · 품명 · 규격 · 단위 · 수량 그대로 ·
+ 품목은 프로젝트에 복사할 때 정함(메모 표지)."""
+ zero = {**line, "단가": {}, "금액": {}}
+ self.put(zero, {"이음": dict.fromkeys(hfix.fixed.LINKS)}, alias, None, mark)
+ self.lines[-1]["메모"] = f"{APART_MEMO} · {self.lines[-1].get('메모') or ''}".strip(" ·")
+
def picks(self, at: int, line: dict, picks: list[dict]) -> None:
"""고른 것(검사 · 다시 셈 끝난 것) → 그 엑셀 줄의 우리 줄 · 자재."""
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
@@ -175,6 +184,8 @@ class Lines:
elif kind == "율":
self.put(line, {"이음": {"비율": p["비목"]}}, alias, None, mark)
self.lines[-1]["고정"]["비율"] = {"기준": p["기준"], "비목": p["비목"]}
+ elif kind == "고정" and p.get("별도계상"):
+ self.apart(line, alias, mark)
elif kind == "고정":
self.put(line, {"이음": dict.fromkeys(hfix.fixed.LINKS)}, alias, None, mark)
else:
diff --git a/M02_MasterTemplete/M02_Hopyo_Row.py b/M02_MasterTemplete/M02_Hopyo_Row.py
index 2d534ef9e..c139154b7 100644
--- a/M02_MasterTemplete/M02_Hopyo_Row.py
+++ b/M02_MasterTemplete/M02_Hopyo_Row.py
@@ -13,7 +13,8 @@
그 밖 = 고정.
후보 찾기 차례 = 장부(`Ledger.recall` · 고른 것 꼴) › M01 표마다(LB · MT · MO · EQ) › M02 층
SK · SC 같은 이름(위 층까지 · 참고).
-판정 = 제안 있음(장부 짝 · 후보 하나 · 별도계상 · 하위 호표 · 율) · 2차 필요(단가산출 줄 =
+판정 = 제안 있음(장부 짝 · 후보 하나 · 별도계상 = 고정 줄 하나(`품목` 표지) · 하위 호표 · 율) ·
+ 2차 필요(단가산출 줄 =
`Bind` · `Solve` 몫) · 후보 여럿 · 후보 없음(고정 금액 제안).
고른 것 = `{갈래 기초|단가산출|일위대가|인력|재료|유가|기계|율|고정, 키, 이름, 규격, 단위, 단가,
입력값, 출처, 몫, 비목?, 기준?, 별도계상?, 층?, 주인?}`(설계 2-1 · 만들기 = `Make` 몫).
@@ -310,9 +311,13 @@ def _rate_pick(line: dict[str, Any]) -> dict[str, Any]:
return one
+# 별도계상 줄 표지 `품목` — 품목은 프로젝트에 복사할 때 정함(요구 645-2)
+ITEM_LATER = "프로젝트에서"
+
+
def _fixed(line: dict[str, Any], apart: bool = False) -> dict[str, Any]:
price = {k: _plain((line.get("단가") or {}).get(k)) or 0 for k in COSTS}
- more = {"별도계상": True} if apart else {}
+ more = {"별도계상": True, "품목": ITEM_LATER} if apart else {}
return pick(
"고정", None, line.get("이름"), line.get("규격"), line.get("단위"), 단가=price, **more
)
@@ -354,12 +359,13 @@ def judge(
out["번"] = no
qty = {"엑셀": line.get("수량"), "배": 1, "갈래": "같음"}
picks: list[dict[str, Any]] = []
+ if why == "별도계상": # 모아서 한 번에 셈 — 품목 후보 · 장부 짝 안 찾음(고정 줄 하나 · 786-1)
+ return {**out, "후보": [_fixed(line, True)], "판정": "제안 있음", "수량": qty}
found = None
if folder is not None:
found = ledger.recall(line, folder, book, doc=doc)
if found and found.get("갈래") == "짝":
picks = _from_ledger(found)
- apart = why == "별도계상"
for k in kinds if not picks else ():
if k == "일위대가" and no is not None:
picks.append(pick("일위대가", f"제{no}호표", line.get("이름"), line.get("규격"),
@@ -374,8 +380,6 @@ def judge(
picks += m01_picks(k, line)
if not found:
picks += [p for p in (docs or {}).get(mm.plain(line.get("이름")), []) if p["갈래"] in kinds]
- if apart:
- picks = [{**p, "별도계상": True} for p in picks] or [_fixed(line, True)]
picks = list({(p["갈래"], p.get("키"), p.get("층")): p for p in picks}.values())
if found and found.get("갈래") in ("짝", "우리에 없음", "안 씀"):
out["장부"] = {k: found.get(k) for k in ("갈래", "층", "제안", "규격다름")}
@@ -385,7 +389,7 @@ def judge(
found and found.get("갈래") in ("짝", "안 씀", "우리에 없음") and not found.get("규격다름")
):
verdict = "제안 있음"
- elif apart or kinds[0] in ("일위대가", "율") or len(picks) == 1:
+ elif kinds[0] in ("일위대가", "율") or len(picks) == 1:
verdict = "제안 있음"
elif picks:
verdict = "후보 여럿"
diff --git a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts
index 0e7aa0988..cc53a6caa 100644
--- a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts
+++ b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts
@@ -6,6 +6,7 @@
* · 제안 = 미리 체크로만(캐시에 없으면 서버 제안) · 확인 = 사용자 [확인] · [호표 제안 모두 확인] 누름만(자동 없음)
* · 기초 고른 것 `필요` = 엔진이 쓰는데 못 읽은 칸(입력 필요) + 엇갈림 칸 — 값이 비면 그 줄 확인 · 옮김 막음
* · 옮기기 몸 = 확인한 줄 · 블록만 · `필요` 걷음 · 줄 `배`(단위 환산 사용자 값) 얹음 · 금액 · 품 칸 안 보냄(서버가 다시 셈)
+ * · 별도계상 줄 = 서버 제안이 고정 줄 하나(`apartPick`) — 고름 없이 [확인] · [옮기기] 됨(막는 칸 없음)
* · 칠 = 확인함 success · 품 다름 warning · 고를 것 info · 후보 없음 danger
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
@@ -95,6 +96,9 @@ export const linePicks = (d: JudgeDraft, no: number, row: JudgeRow): DraftPick[]
export const blockPicks = (d: JudgeDraft, s: Solved): DraftPick[] =>
d.블록[String(s.번)]?.고른 ?? solvedPicks(s);
+/** 별도계상 줄(786-1) — 고정 줄 하나 · 품목은 프로젝트에 복사할 때 정함 · 고름 없이 확인 · 옮김 */
+export const apartPick = (p: Pick): boolean => p.갈래 === "고정" && p.별도계상 === true;
+
export const samePick = (a: Pick, b: Pick): boolean =>
a.갈래 === b.갈래 && a.키 === b.키 && a.이름 === b.이름 && (a.규격 ?? "") === (b.규격 ?? "");
diff --git a/resources/tester/helper_est_archive.cjs b/resources/tester/helper_est_archive.cjs
index d4076ca83..9400e156b 100644
--- a/resources/tester/helper_est_archive.cjs
+++ b/resources/tester/helper_est_archive.cjs
@@ -12,6 +12,7 @@ for (const table of [
"HeadTitleDat",
"MachTitleDat",
"CostTitleDat",
+ "Wonga",
]) {
let checked = 0,
matched = 0,
@@ -41,7 +42,7 @@ for (const table of [
});
}
} else
- for (const key of ["NC", "GC", "JC", "SC", "TC"]) {
+ for (const key of table === "Wonga" ? ["Cost"] : ["NC", "GC", "JC", "SC", "TC"]) {
if (a[key] === null || a[key] === undefined) continue;
checked++;
if (b && Math.abs(Number(a[key]) - Number(b[key] || 0)) < 1e-6) matched++;
diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs
index efa963256..22ceb96bd 100644
--- a/resources/tester/helper_est_native.cjs
+++ b/resources/tester/helper_est_native.cjs
@@ -40,7 +40,11 @@ else if (command === "text")
else if (command === "wonga")
result = vm.runInContext("EstWonga.calculate(project.tables.Wonga, project.options)", context);
else if (command === "all") result = vm.runInContext("EstNative.calculate(project)", context);
-else if (command === "refresh")
+else if (command === "manual") {
+ project.options.AutoCalc = false;
+ project.tables.EstMainDat[1].GN += 100;
+ result = require(path.join(base, "server.cjs")).calculate(project);
+} else if (command === "refresh")
result = vm.runInContext(
"project.options.SourceRefresh=true; EstNative.calculate(project)",
context,
@@ -52,7 +56,28 @@ else if (command === "repeat")
);
else if (command === "import")
result = require(path.join(base, "import-estx.cjs")).importEstx(fs.readFileSync(process.argv[3]));
-else if (command === "extended") {
+else if (command === "blob-tail") {
+ const { blob } = require(path.join(base, "import-estx.cjs")),
+ bytes = Buffer.alloc(1536);
+ bytes.writeUInt32LE(2, 516);
+ bytes.writeUInt16LE(3, 520);
+ bytes.writeUInt32LE(7, 522);
+ bytes.writeUInt32LE(3, 526);
+ bytes.write("abc", 530);
+ bytes.writeUInt32LE(1, 1024);
+ bytes.writeUInt16LE(3, 1032);
+ bytes.write("xyz", 1042);
+ const value = blob(bytes, 1, 7).toString();
+ bytes.writeUInt32LE(4, 526);
+ bytes.writeUInt32LE(0, 516);
+ let truncated = "";
+ try {
+ blob(bytes, 1, 7);
+ } catch (error) {
+ truncated = error.message;
+ }
+ result = { value, truncated };
+} else if (command === "extended") {
context.extended = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures/est_native_extended.json"), "utf8"),
);
@@ -96,7 +121,15 @@ else if (command === "extended") {
changed.tables.EstMainDat.splice(1,0,{PkID:3,Code:'M',Name:'new',GN:1,LinkIndex:5,NewItem:true});
const originals=[0,1,2].map(method=>EstBusiness.original(changed,method));
const bid=EstBusiness.bid(q,[90]);const bidAgain=EstBusiness.bid(bid,[90]);
- ({first,second,years,resources,escalation,originals,bid,bidAgain,project:q});
+ const bidResources=EstResources.calculate(bid);
+ const converted=structuredClone(q);converted.tables.HeadMainDat.push({Index:0,GN:0.5,LinkIndex:68,Size:'unit conversion'});
+ const convertedResources=EstResources.calculate(converted);
+ const equipment=EstNative.empty();
+ equipment.tables.MachMainDat=[{Index:1,Code:'E',Name:'equipment',Unite:'hr'},{Index:-2,Name:'주연료',GN:1,TU:100}];
+ equipment.tables.HeadMainDat=[{Index:1,Code:'H',Name:'head'},{Index:0,LinkIndex:105,Code:'E',GN:2}];
+ equipment.tables.HeadTitleDat=[{Code:'H'}];equipment.tables.EstMainDat=[{PkID:1,LinkIndex:103,Code:'H',GN:10},{LinkIndex:54}];
+ const equipmentResources=EstResources.calculate(equipment);
+ ({first,second,years,resources,escalation,originals,bid,bidAgain,bidResources,convertedResources,equipmentResources,project:q});
`,
context,
);
diff --git a/resources/tester/test_642_3_row.py b/resources/tester/test_642_3_row.py
index 12578d2f3..b40e1997e 100644
--- a/resources/tester/test_642_3_row.py
+++ b/resources/tester/test_642_3_row.py
@@ -87,7 +87,23 @@ def test_북부청_줄_판_판정과_후보(bukbu):
assert all(set(x["갈래"]) <= set(row.KINDS) and x["후보"] is not None for x in lines)
first = {x["행"]: x for x in out[1]} # 제1호표(설계 2-10 기대표)
assert first[6]["갈래"] == ["재료"] and first[6]["근거"] == "별도계상"
- assert first[6]["판정"] == "제안 있음" and all(p["별도계상"] for p in first[6]["후보"])
+ assert first[6]["판정"] == "제안 있음" and len(first[6]["후보"]) == 1
+ apart = [x for x in lines if x["근거"] == "별도계상"] # 786-1 별도계상 64 줄 = 고정 줄 하나
+ assert len(apart) == 64 and all(
+ x["갈래"] == ["재료"] and x["판정"] == "제안 있음" for x in apart
+ )
+ for x in apart:
+ (p,) = x["후보"]
+ assert (p["갈래"], p["키"], p["별도계상"], p["품목"]) == (
+ "고정",
+ None,
+ True,
+ "프로젝트에서",
+ )
+ assert p["단위"] and p["단가"] == {"노무비": 0, "재료비": 0, "경비": 0} and "장부" not in x
+ assert all(
+ p["갈래"] != "고정" or "품목" not in p for x in lines for p in x["후보"] if x not in apart
+ )
assert first[7]["번"] == 46 and first[7]["판정"] == "2차 필요"
assert first[10]["갈래"] == ["재료"] and first[10]["근거"].startswith("사전 자재단가")
rate = next(x for x in lines if x["갈래"] == ["율"])
diff --git a/resources/tester/test_642_5_make.py b/resources/tester/test_642_5_make.py
index aa449eb04..e9d96cf21 100644
--- a/resources/tester/test_642_5_make.py
+++ b/resources/tester/test_642_5_make.py
@@ -85,8 +85,11 @@ def test_판정_검산_옮기기(client):
a, b = _base(rebar, 0), _base(rebar, 1)
b["입력값"] = {**b["입력값"], "구분": "토목"} # 엇갈림 = 사용자 고름
a["몫"] = 0.5
- mat = first["줄"][0] # 레미콘 별도계상
- cement = {**mat["후보"][0], "별도계상": True}
+ mat = first["줄"][0] # 레미콘 별도계상 — 786-1 후보 = 고정 줄 하나(품목은 프로젝트에서)
+ assert mat["판정"] == "제안 있음" and len(mat["후보"]) == 1
+ cement = mat["후보"][0]
+ assert (cement["갈래"], cement["키"], cement["별도계상"]) == ("고정", None, True)
+ assert cement["품목"] == "프로젝트에서"
stuck = {"갈래": "기초", "키": "GC000211", "입력값": {"유형": "보통"}}
ex3 = _base(form) # 예시 3 GC000211 보통 · 4회 · 높이감률 · 구멍땜 = 입력 필요
url = f"{API}/hopyo/{KEY}/check"
@@ -155,9 +158,16 @@ def test_판정_검산_옮기기(client):
if (x.get("고정") or {}).get("출처", {}).get("줄") == form["차례"]
)
assert "본뜬" not in held # 막힌 줄 = 고정 줄(엑셀 숫자)
- cement_row = next(m for m in sc1["자재"] if m["코드"] == cement["키"])
- assert labor.mark_of(cement_row, "별도계상") == "별도계상"
- assert cement_row["수량"]["손"] == mat["수량"]["엑셀"]
+ held_apart = next(
+ x for x in sc1["담은로직"] if (x.get("본뜬") or {}).get("차례") == mat["차례"]
+ )
+ fx = held_apart["고정"] # 별도계상 줄 = 합 밖 고정 줄(단가 0 · 자재 목록에 안 넣음)
+ assert fx["이름"] == mat["이름"] and fx["규격"] == "별도계상" and not any(fx["단가"].values())
+ assert held_apart["수량"]["손"] == mat["수량"]["엑셀"]
+ assert held_apart["메모"].startswith("별도계상 · 품목은 프로젝트에서")
+ without = {**sc1, "담은로직": [x for x in sc1["담은로직"] if x is not held_apart]}
+ got_sum, base_sum = labor.server(sc1)["계산값"], labor.server(without)["계산값"]
+ assert got_sum["반영합"] == base_sum["반영합"] # 서버 정본 셈 — 별도계상 줄은 합에 안 듦
sk = _doc("sangeun", out["단가산출"][0]["키"])
assert sk["바탕"] is None and [x["로직"] for x in sk["더한줄"]] == ["GC001140"]
diff --git a/resources/tester/test_642_6_screen.py b/resources/tester/test_642_6_screen.py
index f6f5fe10a..fbd4cc05b 100644
--- a/resources/tester/test_642_6_screen.py
+++ b/resources/tester/test_642_6_screen.py
@@ -125,6 +125,29 @@ def test_호표_제안_모두_확인은_다풀린_제안만(judged):
assert got[2]["수"] == 1 and list(got[2]["body"]["호표"]["1"]) == ["0"]
+def test_별도계상_줄은_고름없이_확인_옮김(judged):
+ """786-1 — 별도계상 줄 = 고정 줄 하나 · 막는 칸 없음 · [확인] 하면 옮기기 몸에 들어감."""
+ row = judged[1][0] # 레미콘 별도계상
+ got = _run(
+ [
+ {"fn": "rowDefaults", "args": [row]},
+ {"fn": "suggestReady", "args": [row]},
+ {"fn": "confirmable", "args": [{"$": 0}]},
+ {"fn": "blockedOf", "args": [{"$": 0}]},
+ {"fn": "apartPick", "args": [row["후보"][0]]},
+ {"fn": "emptyDraft", "args": []},
+ {"fn": "putLine", "args": [{"$": 5}, 1, 0, {"고른": {"$": 0}, "확인": True}]},
+ {"fn": "moveBody", "args": [{"$": 6}, FILE]},
+ ]
+ )
+ (p,) = got[0]
+ assert p["갈래"] == "고정" and p["별도계상"] is True and p["품목"] == "프로젝트에서"
+ assert got[1] is True and got[2] is True and got[3] == [] and got[4] is True
+ assert got[7]["막힘"] == [] and got[7]["수"] == 1
+ (moved,) = got[7]["body"]["호표"]["1"]["0"]
+ assert moved["갈래"] == "고정" and moved["별도계상"] is True and "필요" not in moved
+
+
def test_예시1_단산10_입력필요0(judged):
row = _row(judged, 2, 10)
got = _run(
diff --git a/resources/tester/test_est_rea_parity.py b/resources/tester/test_est_rea_parity.py
index 073edd8cc..9f5bb6205 100644
--- a/resources/tester/test_est_rea_parity.py
+++ b/resources/tester/test_est_rea_parity.py
@@ -53,6 +53,91 @@ def test_numeric_helpers_distinguish_rounding_modes():
) == [2, 4, -1.3, 1.24]
+def test_large_currency_boundary_and_unit_conversion_caption():
+ assert run("math", "EstMath.floating(172068*0.1*756,1,2)") == 13008340.8
+ result = run(
+ "math",
+ """EstRows.head([
+ {Index:1,Code:'H'},
+ {Index:0,GN:1,NU:14341},
+ {Index:0,GN:0.1,LinkIndex:68,Size:'소계 / 10단자'}
+ ],{HeadDigits:0})""",
+ )
+ assert result["rows"][0]["NC"] == 1434
+ assert result["rows"][2]["NU"] == 14341
+ assert result["rows"][2]["NC"] == 1434
+
+
+def test_procurement_zero_place_keeps_integer_total():
+ row = run(
+ "math",
+ "EstRows.procurement({Index:3,LinkIndex:57,GN:0.54,Gangub:0,OCheck:1,SCheck:3},[{LinkIndex:5,GC:30029967,TC:30029967}])",
+ )
+ assert row["JC"] == row["TC"] == 162161
+
+
+def test_blob_reused_capacity_and_incomplete_payload_are_distinct():
+ result = run("blob-tail")
+ assert result["value"] == "abc"
+ assert "BLOB 총 길이 오류" in result["truncated"]
+
+
+def test_procurement_exclusion_only_changes_industrial_safety_base():
+ result = run(
+ "math",
+ """EstWonga.calculate([
+ {Gubun:'J',Name:'관급자재대',Cost:1000,NC:100,VSusic:'원금액:1,000, {조달수수료:100}'},
+ {Gubun:'S',Name:'산업안전보건관리비',VSusic:'J*0.1'},
+ {Gubun:'P',Name:'other',VSusic:'J*0.1'}
+ ],{GangubExceptJodalRatio:true})""",
+ )
+ assert result["errors"] == []
+ assert [row["Cost"] for row in result["rows"]] == [1000, 90, 100]
+
+
+def test_resource_factor_includes_trailing_multiplier():
+ row = run(
+ "math",
+ r"""EstText.calculate([
+ {Index:1,Code:'C',LineData:''},
+ {Index:0,LinkIndex:5,SCheck:-2,LineData:'\x0eM\x0e : \x03100\x03 * 2 = \x12200\x12 * 0.5 = \x12100\x12'}
+ ],{},()=>({Code:'M',Name:'M',SourceKind:5,GC:100,TC:100})).rows[1]""",
+ )
+ assert row["ResourceFactor"] == 1
+ assert row["TC"] == 100
+
+
+def test_direct_material_scope_is_the_marked_section():
+ result = run(
+ "math",
+ """(() => {
+ const rows=[{Level:2,Name:'순직접공사비'},{Level:0,LinkIndex:5,GC:100},
+ {Level:2,Name:'사급자재대*'},{Level:0,LinkIndex:5,GC:200},{LinkIndex:54}];
+ return [EstNative.materialBase(rows,{GCToWonga:false}),EstNative.materialBase(rows,{GCToWonga:true})];
+ })()""",
+ )
+ assert result == [200, 300]
+
+
+def test_practical_fire_projects_keep_stored_values_and_report_bad_source_formula():
+ files = list((ROOT / "resources/knowledge/original/실무문서").rglob("*.estx"))
+ assert len(files) >= 3
+ for file in files:
+ output = subprocess.check_output(
+ ["node", str(HELPER.with_name("helper_est_archive.cjs")), str(file)],
+ text=True,
+ encoding="utf-8",
+ timeout=120,
+ )
+ result = json.loads(output)
+ assert result["differences"] == [], file.name
+ # The original text has an unclosed parenthesis in this transport
+ # calculation; preserve its stored price and show the source error.
+ assert all(
+ error.startswith("D0000000194:") and "괄호" in error for error in result["errors"]
+ ), (file.name, result["errors"])
+
+
def test_head_41_stored_checks():
result = run("head")
assert result["errors"] == []
@@ -169,6 +254,15 @@ def test_progress_accepted_rounds_annual_allocation_and_resources():
] == [2120, 2520, 2520]
assert result["bid"]["calculation"]["direct"]["TC"] == 1800
assert result["bidAgain"]["tables"]["EstMainDat"] == result["bid"]["tables"]["EstMainDat"]
+ assert result["bidResources"]["errors"] == []
+ assert result["bidResources"]["rows"][0]["quantity"] == 18
+ assert result["bidResources"]["rows"][0]["amount"] == 1800
+ assert result["convertedResources"]["errors"] == []
+ assert result["convertedResources"]["rows"][0]["quantity"] == 10
+ assert result["convertedResources"]["rows"][0]["amount"] == 1000
+ assert result["equipmentResources"]["errors"] == []
+ assert result["equipmentResources"]["rows"][0]["quantity"] == 20
+ assert result["equipmentResources"]["rows"][0]["amount"] == 2000
def test_estx_import_reads_original_tables_options_and_templates(tmp_path):
@@ -265,3 +359,72 @@ def test_original_quantity_sheets_and_three_saved_formula_disagreements():
assert abs((before.get("GN") or 0) - (after.get("GN") or 0)) < 1e-6, key
assert checked == 449
assert differences == set(expected)
+
+
+def test_legacy_project_preserves_running_cost_basis_and_final_total():
+ result = run(
+ "math",
+ """EstNative.calculate(EstModel.legacy({
+ sources:[{id:'s',kind:'재료비',price:10.129}],
+ units:[{id:'u',lines:[{sourceId:'s',quantity:2,digits:2}]}],
+ estimate:[{unitId:'u',quantity:3}],
+ costs:[{base:'직접공사비',rate:10},{base:'누계',rate:5}]
+ })).calculation""",
+ )
+ assert result["errors"] == []
+ assert result["direct"]["TC"] == 60
+ assert result["total"] == 69
+
+
+def test_manual_calculation_server_preserves_stored_amounts():
+ result = run("manual")
+ assert result["tables"]["EstMainDat"][1]["GN"] == SAMPLE["tables"]["EstMainDat"][1]["GN"] + 100
+ for table in ["EstMainDat", "HeadMainDat", "Wonga"]:
+ for original, saved in zip(SAMPLE["tables"][table], result["tables"][table]):
+ for key in ["TC", "NC", "GC", "JC", "SC", "Cost"]:
+ assert saved.get(key) == original.get(key)
+
+
+def test_text_fraction_spacing_ascii_multiplication_and_tree_units():
+ result = run(
+ "math",
+ r"""EstText.calculate([
+ {Index:1,Code:'T'},
+ {LineData:' n = 6.55 Cms = 37.4 A = 1 N = 1'},
+ {LineData:' L = 50m 25kg/1회'},
+ {LineData:' ○ 거리 Z=17m, 7m마다 1인으로 하면'},
+ {LineData:' n × Cms 6.55 × 37.4'},
+ {LineData:' t1 = ────── = ─────── = \x1211.67\x12'},
+ {LineData:' 60×0.35 60×0.35'},
+ {LineData:' D=0.2xAxN+2 = \x122.2\x12'},
+ {LineData:' 길이 = L + Z = \x1267\x12'},
+ {LineData:' * 윤 주 : 0.94m (0.65×0.65×3.14÷4)÷2 = \x120.17\x12㎥'},
+ {LineData:' 노무비: \x03235204\x03 × 3인 / 125주 = \x125644.8\x12',SCheck:-1}
+ ])""",
+ )
+ assert result["errors"] == []
+ assert [row["TC"] for row in result["rows"] if "TC" in row][1:] == [
+ 11.67,
+ 2.2,
+ 67,
+ 0.17,
+ 5644.8,
+ ]
+
+
+def test_text_material_only_subtotal_does_not_reuse_prior_labor():
+ result = run(
+ "math",
+ r"""EstText.calculate([
+ {Index:1,Code:'T'},
+ {LineData:' \x1a노무비: 100\x1a',SCheck:-1,Level:-1},
+ {LineData:' \x1a재료비: 20\x1a',SCheck:-2,Level:-1},
+ {LineData:' \x1a계:\x1a \x12120\x12',SCheck:-5,Level:-1},
+ {LineData:' 다음 재료 구획',Level:1},
+ {LineData:' \x1a재료비: 30\x1a',SCheck:-2,Level:-1},
+ {LineData:' \x1a계:\x1a \x1230\x12',SCheck:-5,Level:-1}
+ ])""",
+ )
+ assert result["errors"] == []
+ assert result["rows"][3]["TC"] == 120
+ assert result["rows"][6]["TC"] == 30
diff --git a/tools/est-test/README.md b/tools/est-test/README.md
index e9696da64..7b0ed7522 100644
--- a/tools/est-test/README.md
+++ b/tools/est-test/README.md
@@ -16,6 +16,8 @@
원본 수량산출서 75개의 WPTools 글·결과 표지·위첨자를 읽어 편집하고 내역 수량에 연결합니다. 저장 수량 449개 중 446개가 재계산 결과와 일치합니다. 나머지 3개는 원본 글의 산식과 저장 수량이 서로 다릅니다(예: `0.35×1.3×220`의 저장값 286, 계산값 100.1). 화면에 원본 저장 수량과 재계산 수량을 함께 표시합니다. 원본 수치를 맞추려고 산식을 변경하지 않았습니다.
+실무 산불 내역 3건은 DBISAM의 재사용 BLOB 할당 공간을 논리 길이까지만 읽도록 수정한 뒤 저장 금액 8,312개 모두 일치했습니다. 다만 세 파일에 공통으로 닫히지 않은 괄호가 있는 원본 수식 한 건이 남아 해석 오류로 표시됩니다. 저장 금액 일치와 수식 오류 0을 구분합니다. 직접재료비 구획·관급 조달수수료·산업안전보건관리비 제외 옵션, 낙찰 단가를 유지한 동원 집계도 시험합니다. 자동계산을 끄면 열기·편집·저장 시 기존 금액을 보존합니다.
+
**전체 원본 동등성은 아직 확보하지 못했습니다.** 기성·물가변동·연차 등은 분석 분기와 합성 시나리오를 시험했으며 원본 업무 샘플 대조는 없습니다. 원본 화면 실행 비교는 못했습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다.
검사:
diff --git a/tools/est-test/import-estx.cjs b/tools/est-test/import-estx.cjs
index ae6d0d1de..ab8ad2f29 100644
--- a/tools/est-test/import-estx.cjs
+++ b/tools/est-test/import-estx.cjs
@@ -65,7 +65,8 @@ function blob(file, start, id) {
seen = new Set();
let block = start,
previous = 0,
- expected = 0;
+ expected = 0,
+ received = 0;
while (block) {
const offset = block * 512;
if (seen.has(block) || offset + 512 > file.length) throw Error("BLOB 체인 범위/순환 오류");
@@ -78,12 +79,20 @@ function blob(file, start, id) {
expected = file.readUInt32LE(offset + 14);
if (expected > LIMIT) throw Error("BLOB 크기 한도 초과");
}
- parts.push(file.subarray(offset + 18, offset + 18 + length));
+ const take = Math.min(length, expected - received);
+ parts.push(file.subarray(offset + 18, offset + 18 + take));
+ received += take;
+ // Reused DBISAM BLOB allocations may retain linked capacity after the
+ // logical end. The first block's declared length owns the payload boundary.
+ if (received === expected) break;
previous = block;
block = file.readUInt32LE(offset + 4);
}
const result = Buffer.concat(parts);
- if (result.length !== expected) throw Error("BLOB 총 길이 오류");
+ if (result.length !== expected)
+ throw Error(
+ `BLOB 총 길이 오류: 블록 ${start}, ID ${id}, 선언 ${expected}, 읽음 ${result.length}`,
+ );
return result;
}
function table(buffer, blobs, warnings) {
@@ -265,7 +274,12 @@ function importEstx(buffer, name = "가져온 내역서") {
!data.subarray(8, 24).equals(Buffer.from("068abe8e592364cb403d71d2e3bc64d0", "hex"))
)
continue;
- const rows = table(data, files.get(filename.replace(/\.dat$/, ".blb")), warnings);
+ let rows;
+ try {
+ rows = table(data, files.get(filename.replace(/\.dat$/, ".blb")), warnings);
+ } catch (error) {
+ throw Error(`${filename}: ${error.message}`);
+ }
const key = filename.replace(/\.dat$/, "");
const canonical =
{
@@ -310,6 +324,8 @@ function importEstx(buffer, name = "가져온 내역서") {
const props = Object.fromEntries(objects.map((o) => [o.className, o.properties]));
const options = {
GCToWonga: props.TWongaOption?.GCToWonga ?? false,
+ GangubExceptJodalRatio: props.TWongaOption?.GangubExceptJodalRatio ?? false,
+ EYunUp: props.TWongaOption?.EYunUp ?? false,
SourceRefresh: false,
VerFormat: props.TWongaOption?.VerFormat ?? true,
TotalFix: props.TWongaOption?.TotalFix ?? tables.Wonga.some((r) => r.LinkIndex === 55),
@@ -356,4 +372,4 @@ function importEstx(buffer, name = "가져온 내역서") {
originalArchive: buffer.toString("base64"),
};
}
-module.exports = { importEstx, unzip, table, memoryRows, optionObjects, crc32 };
+module.exports = { importEstx, unzip, table, blob, memoryRows, optionObjects, crc32 };
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index a5a5451c8..eb1c51b4f 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -18,6 +18,7 @@
+
diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js
index ccc278742..eeabb1356 100644
--- a/tools/est-test/native-actions.js
+++ b/tools/est-test/native-actions.js
@@ -162,6 +162,16 @@ const EstActions = (() => {
copies.forEach((r) => {
r.PkID = ++next;
r.NewItem = true;
+ if (["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"].includes(state.view)) {
+ const base = r.Code || "U";
+ let suffix = next;
+ do {
+ r.Code = `${base}_${suffix++}`;
+ } while (
+ rows.some((row) => row.Code === r.Code) ||
+ copies.some((row) => row !== r && row.Code === r.Code)
+ );
+ }
if (grouped && Number(r.Index) > 0 && r.Code) {
r.Code = `${r.Code}_${next}`;
r.Index = ++nextIndex;
@@ -230,74 +240,7 @@ const EstActions = (() => {
"REA 샘플 대조",
`
${result.checked}개 중 ${result.passed}개 일치 · 불일치 ${result.failed}개
보관된 EST 샘플의 저장값과 계산 결과를 비교했습니다. 원본 실행 비교는 포함하지 않습니다.
${result.errors.length ? `${esc(result.errors.join(" / "))}
` : ""}| 영역 | 검사 수 | 일치 |
${[...new Set(result.results.map((r) => r.group))].map((group) => `| ${esc(group)} | ${result.results.filter((r) => r.group === group).length} | ${result.results.filter((r) => r.group === group && r.pass).length} |
`).join("")}
`,
);
- } else if (name === "settings")
- form(
- "계산 환경 설정",
- [
- {
- name: "head",
- label: "일위대가 성분 소수자리",
- type: "number",
- value: project.options.HeadDigits || 0,
- },
- {
- name: "estimate",
- label: "내역 성분 소수자리",
- type: "number",
- value: project.options.EstDigits || 0,
- },
- {
- name: "total",
- value: project.options.TotalFix ? "1" : "0",
- label: "도급액 고정",
- choices: [
- ["0", "해제"],
- ["1", "원가 LinkIndex 55행 금액에 고정"],
- ],
- },
- {
- name: "fit",
- value: project.options.TotalFit ? "1" : "0",
- label: "성분 합계 맞춤",
- choices: [
- ["0", "해제"],
- ["1", "최대 나머지 배분"],
- ],
- },
- {
- name: "dollar",
- value: project.options.DollarCostCut ? "1" : "0",
- label: "외화 원화환산 천원 절사",
- choices: [
- ["0", "원 단위"],
- ["1", "천원 단위"],
- ],
- },
- {
- name: "jdl",
- value: project.options.JDLCalcMethod ? "1" : "0",
- label: "운전사 중간금액 소수1자리 절사",
- choices: [
- ["0", "해제"],
- ["1", "사용"],
- ],
- },
- ],
- (values) => {
- if (![values.head, values.estimate].every((n) => Number.isInteger(n) && n >= 0 && n <= 6))
- throw Error("소수 자릿수는 0~6 정수입니다.");
- EstApp.change(() =>
- Object.assign(project.options, {
- HeadDigits: values.head,
- EstDigits: values.estimate,
- TotalFix: values.total === "1",
- TotalFit: values.fit === "1",
- DollarCostCut: values.dollar === "1",
- JDLCalcMethod: values.jdl === "1",
- }),
- );
- },
- );
+ } else if (name === "settings") EstSettings.show();
else if (name === "raw-line") {
const row = activeRows()[Number(node.dataset.row)];
showDialog(
@@ -652,5 +595,5 @@ const EstActions = (() => {
}
});
}
- return { install };
+ return { install, form };
})();
diff --git a/tools/est-test/native-app.js b/tools/est-test/native-app.js
index 9a600c99a..1e53ba50b 100644
--- a/tools/est-test/native-app.js
+++ b/tools/est-test/native-app.js
@@ -81,7 +81,14 @@ const EstApp = (() => {
state.selection.clear();
state.group = "";
state.page = 0;
- calculate();
+ if (state.project.options.AutoCalc !== false) calculate();
+ else
+ state.project.calculation ||= {
+ errors: [],
+ warnings: [],
+ direct: state.project.tables.EstMainDat.find((row) => Number(row.LinkIndex) === 54) || {},
+ total: state.project.tables.Wonga.find((row) => Number(row.LinkIndex) === 55)?.Cost || 0,
+ };
cache();
render();
}
diff --git a/tools/est-test/native-business.js b/tools/est-test/native-business.js
index e202ae80f..2096a7365 100644
--- a/tools/est-test/native-business.js
+++ b/tools/est-test/native-business.js
@@ -201,11 +201,7 @@ const EstBusiness = (() => {
direct.reduce((n, r) => n + num(r[`${prefix}${key}`]), 0),
]),
);
- const material = project.options.GCToWonga
- ? bases.GC
- : direct
- .filter((r) => num(r.LinkIndex) === 5)
- .reduce((n, r) => n + num(r[`${prefix}GC`]), 0);
+ const material = EstNative.materialBase(rows, project.options, `${prefix}GC`);
const wonga = copy(project.tables.Wonga);
for (const row of wonga) {
if (row.Youl && !String(row.Name).replace(/\s/g, "").includes("부가")) {
diff --git a/tools/est-test/native-engine.js b/tools/est-test/native-engine.js
index 0830aa4d4..b213cc45c 100644
--- a/tools/est-test/native-engine.js
+++ b/tools/est-test/native-engine.js
@@ -88,6 +88,28 @@ const EstNative = (() => {
});
return result;
}
+ function materialBase(rows, options = {}, field = "GC") {
+ const levels = [];
+ let amount = 0;
+ for (const row of rows) {
+ if (num(row.LinkIndex) === 54) break;
+ const level = num(row.Level);
+ if (level > 0) {
+ while (levels.length && levels.at(-1).level <= level) levels.pop();
+ levels.push({
+ level,
+ direct: String(row.Name || "")
+ .trim()
+ .endsWith("*"),
+ });
+ } else if (
+ num(row.CalcIndex) < 2 &&
+ (options.GCToWonga || levels.some((group) => group.direct))
+ )
+ amount += num(row[field]);
+ }
+ return amount;
+ }
function calculate(input) {
const project = clone(input),
tables = project.tables,
@@ -139,7 +161,11 @@ const EstNative = (() => {
const result =
kind === 103
? EstRows.head(group.rows, options, resolve)
- : EstText.calculate(group.rows, options, options.SourceRefresh ? resolve : () => null);
+ : EstText.calculate(
+ group.rows,
+ options,
+ options.SourceRefresh || options.ResourceTracing ? resolve : () => null,
+ );
const title = {
...tables[sourceTables[kind]].find((row) => row.Code === code),
...result.titles[0],
@@ -208,36 +234,46 @@ const EstNative = (() => {
errors.push(...estimate.errors);
const direct = estimate.direct;
const boundary = tables.EstMainDat.findIndex((row) => num(row.LinkIndex) === 54);
- const leaves = tables.EstMainDat.slice(
- 0,
- boundary < 0 ? tables.EstMainDat.length : boundary,
- ).filter((row) => !num(row.Level) && num(row.CalcIndex) < 2);
- const directMaterial = options.GCToWonga
- ? num(direct.GC)
- : leaves.filter((row) => num(row.LinkIndex) === 5).reduce((n, row) => n + num(row.GC), 0);
+ const directMaterial = materialBase(tables.EstMainDat, options);
const governmentHeading = tables.EstMainDat.findIndex(
(row, i) => i > boundary && num(row.Level) > 0 && normalize(row.Name).includes("관급"),
);
- let government = null;
+ let government = null,
+ procurementFees = 0;
if (governmentHeading >= 0) {
- government = 0;
+ government = num(tables.EstMainDat[governmentHeading].TC);
const level = num(tables.EstMainDat[governmentHeading].Level);
for (
let i = governmentHeading + 1;
i < tables.EstMainDat.length && num(tables.EstMainDat[i].Level) < level;
i++
)
- if (!num(tables.EstMainDat[i].Level)) government += num(tables.EstMainDat[i].TC);
+ if (num(tables.EstMainDat[i].LinkIndex) === 57)
+ procurementFees += num(tables.EstMainDat[i].TC);
}
if (options.AutoWongaBase !== false)
for (const row of tables.Wonga) {
- if (row.VSusic || [1, 3].includes(num(row.CalcIndex)) || row.BaseField) continue;
+ if (
+ (row.VSusic && !/^원금액\s*:/.test(row.VSusic)) ||
+ [1, 3].includes(num(row.CalcIndex)) ||
+ row.BaseField
+ )
+ continue;
const name = normalize(row.Name);
if (name.includes("직접재료")) row.Cost = directMaterial;
else if (name.includes("간접재료")) row.Cost = num(direct.GC) - directMaterial;
else if (name.includes("직접노무")) row.Cost = num(direct.NC);
else if (name.includes("산출경비")) row.Cost = num(direct.JC);
- else if (name.includes("관급자재") && government !== null) row.Cost = government;
+ else if (name.includes("관급자재") && government !== null) {
+ row.Cost = government;
+ row.TC = government;
+ row.NC = procurementFees;
+ if (/^원금액\s*:/.test(row.VSusic))
+ row.VSusic = row.VSusic.replace(
+ /(원금액\s*:)[\d,.]+/,
+ `$1${government.toLocaleString("en-US")}`,
+ ).replace(/(조달수수료\s*:)[\d,.]+/, `$1${procurementFees.toLocaleString("en-US")}`);
+ }
}
const wonga = EstWonga.calculate(tables.Wonga, options, {
NC: num(direct.NC),
@@ -282,5 +318,5 @@ const EstNative = (() => {
drawings: [],
};
}
- return { calculate, price, groups, empty, allTables, sourceTables, normalize };
+ return { calculate, price, groups, empty, allTables, sourceTables, normalize, materialBase };
})();
diff --git a/tools/est-test/native-formula.js b/tools/est-test/native-formula.js
index cf7fc46be..b47b53e0e 100644
--- a/tools/est-test/native-formula.js
+++ b/tools/est-test/native-formula.js
@@ -9,6 +9,8 @@ const EstExpression = (() => {
.replace(/%/g, "%")
.replace(/√\s*\(/g, "SQRT(")
.replace(/π/g, "PI")
+ .replace(/(?<=[\d.)])\s*[xX]\s*(?=[\d.(A-Za-z])/g, "*")
+ .replace(/(?<=[A-Z])x(?=[A-Z])/g, "*")
.replace(/(?<=\d),(?=\d{3}(?:\D|$))/g, "");
const tokens = [];
const pattern =
diff --git a/tools/est-test/native-math.js b/tools/est-test/native-math.js
index 28e5ec007..96df0d286 100644
--- a/tools/est-test/native-math.js
+++ b/tools/est-test/native-math.js
@@ -26,7 +26,12 @@ const EstMath = (() => {
}
function floating(value, digits = 0, method = 2) {
const k = 10 ** digits;
- return mode((value + Math.sign(value) * 2e-10) * k, method) / k;
+ // The original keeps x87 extended intermediates. Account for one binary64
+ // rounding unit before truncation so exact decimal boundaries survive JS.
+ const scaled = value * k;
+ return (
+ mode(scaled + Math.sign(value) * (2e-10 * k + Number.EPSILON * Math.abs(scaled)), method) / k
+ );
}
function precision(row, fallback = 0) {
return num(row.DigetIndex)
@@ -63,7 +68,9 @@ const EstMath = (() => {
const result = Object.fromEntries(
components.map((key) => [
key,
- floating(rows.filter(predicate).reduce((n, row) => n + num(row[key]), 0)),
+ floating(
+ rows.filter(predicate).reduce((n, row) => n + even(num(row[key]) * 10000), 0) / 10000,
+ ),
]),
);
result.TC = sum(result);
diff --git a/tools/est-test/native-model.js b/tools/est-test/native-model.js
index c0b562406..6a1a9f4ae 100644
--- a/tools/est-test/native-model.js
+++ b/tools/est-test/native-model.js
@@ -353,15 +353,25 @@ const EstModel = (() => {
project.tables.EstMainDat.push({ Name: "순공사비", LinkIndex: 54 });
const bases = { 재료비: "GC", 노무비: "NC", 경비: "JC", 실적: "SC", 직접공사비: "TC" };
project.tables.Wonga = [{ Gubun: "기초", Name: "직접공사비", BaseField: "TC", Cost: 0 }];
- for (const row of data.costs)
+ const running = ["기초"];
+ for (const [index, row] of data.costs.entries()) {
+ const id = row.id || `추가${index + 1}`;
project.tables.Wonga.push({
- Gubun: row.id,
+ Gubun: id,
Name: row.name,
Youl: `${row.rate}%`,
VSusic:
row.formula ||
- `[${row.base === "누계" ? "기초" : bases[row.base] || "TC"}]*${row.rate}/100`,
+ `(${row.base === "누계" ? running.map((key) => `[${key}]`).join("+") : `[${bases[row.base] || "TC"}]`})*${row.rate}/100`,
});
+ running.push(id);
+ }
+ project.tables.Wonga.push({
+ Gubun: "도급계",
+ Name: "도급액",
+ LinkIndex: 55,
+ VSusic: running.map((key) => `[${key}]`).join("+"),
+ });
return validate(project);
}
function newRow(table, rows) {
@@ -370,13 +380,19 @@ const EstModel = (() => {
PkID: max + 1,
Index: 0,
Name: "새 항목",
+ NewItem: true,
GN: 1,
CalcIndex: 0,
LinkIndex: 0,
Level: 0,
};
if (["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"].includes(table))
- Object.assign(row, { Code: `U${Date.now().toString(36)}`, ValueIndex: 4, InputTC: 0, TC: 0 });
+ Object.assign(row, {
+ Code: `U${Date.now().toString(36)}_${max + 1}`,
+ ValueIndex: 4,
+ InputTC: 0,
+ TC: 0,
+ });
if (table === "Wonga") Object.assign(row, { Gubun: `R${max + 1}`, VSusic: "", Cost: 0 });
if (table === "CostMainDat") Object.assign(row, { LineData: "Q = 1 + 1 = \x122\x12" });
return row;
diff --git a/tools/est-test/native-resources.js b/tools/est-test/native-resources.js
index 187647607..6521c42d4 100644
--- a/tools/est-test/native-resources.js
+++ b/tools/est-test/native-resources.js
@@ -3,14 +3,14 @@ const EstResources = (() => {
function calculate(input) {
const project = EstNative.calculate({
...structuredClone(input),
- options: { ...input.options, SourceRefresh: true },
+ options: { ...input.options, ResourceTracing: true },
});
const tables = project.tables,
heads = EstNative.groups(tables.HeadMainDat),
costs = EstNative.groups(tables.CostMainDat),
output = new Map(),
errors = [...project.calculation.errors];
- function record(kind, code, quantity, amount = 0) {
+ function record(kind, code, quantity, amount = null) {
const source = (tables[EstNative.sourceTables[kind]] || []).find((row) => row.Code === code);
if (!source) {
errors.push(`동원 원천 없음: ${kind}/${code}`);
@@ -25,9 +25,11 @@ const EstResources = (() => {
kind,
quantity: 0,
amount: 0,
+ amountForHours: 0,
};
row.quantity += quantity;
- row.amount += amount;
+ row.amount += amount ?? num(source.TC) * quantity;
+ if (kind === 105 && quantity === 0 && amount !== null) row.amountForHours += amount;
output.set(id, row);
}
function expand(kind, code, multiplier, path) {
@@ -70,15 +72,23 @@ const EstResources = (() => {
let rate = 1;
const bid = group.rows.find((row) => num(row.LinkIndex) === 52);
if (bid) rate = num(bid.GN) / 100;
+ const conversion = group.rows.find((row) => num(row.LinkIndex) === 68);
+ if (conversion)
+ rate *= num(conversion.GN) / (["%", "%"].includes(conversion.Unite) ? 100 : 1);
for (const row of group.rows.slice(1)) {
if (num(row.CalcIndex) >= 2 || [51, 52, 58, 62, 68].includes(num(row.LinkIndex)))
continue;
+ const source = tables[EstNative.sourceTables[num(row.LinkIndex)]]?.find(
+ (r) => r.Code === row.Code,
+ );
+ const unitRatio = row.ApplyNacChal && num(source?.TC) ? num(row.TU) / num(source.TC) : 1;
const quantity =
- (multiplier * num(row.GN) * rate) / (["%", "%"].includes(row.Unite) ? 100 : 1);
+ (multiplier * num(row.GN) * rate * unitRatio) /
+ (["%", "%"].includes(row.Unite) ? 100 : 1);
if ([103, 104].includes(num(row.LinkIndex)))
expand(num(row.LinkIndex), row.Code, quantity, [...path, id]);
else if (EstNative.sourceTables[num(row.LinkIndex)])
- record(num(row.LinkIndex), row.Code, quantity, multiplier * num(row.TC));
+ record(num(row.LinkIndex), row.Code, quantity, multiplier * num(row.TC) * rate);
}
return;
}
@@ -92,20 +102,25 @@ const EstResources = (() => {
!EstNative.sourceTables[num(row.LinkIndex)]
)
continue;
- expand(num(row.LinkIndex), row.Code, num(row.GN), []);
+ const source = tables[EstNative.sourceTables[num(row.LinkIndex)]]?.find(
+ (r) => r.Code === row.Code,
+ );
+ const unitRatio = row.ApplyNacChal && num(source?.TC) ? num(row.TU) / num(source.TC) : 1;
+ expand(num(row.LinkIndex), row.Code, num(row.GN) * unitRatio, []);
}
const rows = [...output.values()];
for (const row of rows)
- if (row.kind === 105 && row.amount) {
+ if (row.kind === 105 && row.amountForHours) {
const price = num(tables.MachTitleDat.find((r) => r.Code === row.Code)?.TC);
if (price) {
- row.quantity += row.amount / price;
+ row.quantity += row.amountForHours / price;
row.Unite = "HR";
} else {
row.Unite = "제외금액";
row.quantity = 0;
}
}
+ rows.forEach((row) => delete row.amountForHours);
return { rows, errors: [...new Set(errors)] };
}
return { calculate };
diff --git a/tools/est-test/native-rows.js b/tools/est-test/native-rows.js
index 8f3a87a91..5e1b1455d 100644
--- a/tools/est-test/native-rows.js
+++ b/tools/est-test/native-rows.js
@@ -56,9 +56,11 @@ const EstRows = (() => {
const rounded =
num(row.Index) === 1
? EstMath.half(quotient)
- : num(row.Index) === 2
- ? Math.trunc(quotient)
- : EstMath.half(quotient + 0.5);
+ : num(row.Gangub) < 1
+ ? quotient
+ : num(row.Index) === 2
+ ? Math.trunc(quotient)
+ : EstMath.half(quotient + 0.5);
components.forEach((k) => (row[k] = 0));
row[target[num(row.SCheck)] || "JC"] = rounded * step - base;
row.TU = base;
@@ -70,8 +72,8 @@ const EstRows = (() => {
const values = Object.fromEntries(
components.map((k) => [k, eligible.reduce((n, r) => n + num(r[k]), 0)]),
);
- const kind = baseKind(row),
- sc = target[num(row.SCheck)];
+ const kind = num(row.LinkIndex) === 68 ? null : baseKind(row),
+ sc = num(row.LinkIndex) === 68 ? undefined : target[num(row.SCheck)];
units.forEach((key) => (row[key] = 0));
if (sc) row[units[components.indexOf(sc)]] = kind === "TC" ? sum(values) : num(values[kind]);
else
diff --git a/tools/est-test/native-settings.js b/tools/est-test/native-settings.js
new file mode 100644
index 000000000..c361945ca
--- /dev/null
+++ b/tools/est-test/native-settings.js
@@ -0,0 +1,93 @@
+const EstSettings = (() => {
+ function show() {
+ const project = EstApp.state.project;
+ EstActions.form(
+ "계산 환경 설정",
+ [
+ {
+ name: "head",
+ label: "일위대가 성분 소수자리",
+ type: "number",
+ value: project.options.HeadDigits || 0,
+ },
+ {
+ name: "estimate",
+ label: "내역 성분 소수자리",
+ type: "number",
+ value: project.options.EstDigits || 0,
+ },
+ {
+ name: "total",
+ value: project.options.TotalFix ? "1" : "0",
+ label: "도급액 고정",
+ choices: [
+ ["0", "해제"],
+ ["1", "원가 LinkIndex 55행 금액에 고정"],
+ ],
+ },
+ {
+ name: "fit",
+ value: project.options.TotalFit ? "1" : "0",
+ label: "성분 합계 맞춤",
+ choices: [
+ ["0", "해제"],
+ ["1", "최대 나머지 배분"],
+ ],
+ },
+ {
+ name: "dollar",
+ value: project.options.DollarCostCut ? "1" : "0",
+ label: "외화 원화환산 천원 절사",
+ choices: [
+ ["0", "원 단위"],
+ ["1", "천원 단위"],
+ ],
+ },
+ {
+ name: "jdl",
+ value: project.options.JDLCalcMethod ? "1" : "0",
+ label: "운전사 중간금액 소수1자리 절사",
+ choices: [
+ ["0", "해제"],
+ ["1", "사용"],
+ ],
+ },
+ {
+ name: "materials",
+ value: project.options.GCToWonga ? "1" : "0",
+ label: "순직접공사비 재료비 처리",
+ choices: [
+ ["0", "간접재료비"],
+ ["1", "직접재료비"],
+ ],
+ },
+ {
+ name: "procurement",
+ value: project.options.GangubExceptJodalRatio ? "1" : "0",
+ label: "산업안전보건관리비의 관급자재대에서 조달수수료",
+ choices: [
+ ["0", "포함"],
+ ["1", "제외"],
+ ],
+ },
+ ],
+ (values) => {
+ if (![values.head, values.estimate].every((n) => Number.isInteger(n) && n >= 0 && n <= 6))
+ throw Error("소수 자릿수는 0~6 정수입니다.");
+ EstApp.change(() =>
+ Object.assign(project.options, {
+ HeadDigits: values.head,
+ EstDigits: values.estimate,
+ TotalFix: values.total === "1",
+ TotalFit: values.fit === "1",
+ DollarCostCut: values.dollar === "1",
+ JDLCalcMethod: values.jdl === "1",
+ GCToWonga: values.materials === "1",
+ GangubExceptJodalRatio: values.procurement === "1",
+ }),
+ );
+ },
+ );
+ }
+ return { show };
+})();
diff --git a/tools/est-test/native-text.js b/tools/est-test/native-text.js
index 165303115..5ec45d61a 100644
--- a/tools/est-test/native-text.js
+++ b/tools/est-test/native-text.js
@@ -11,20 +11,23 @@ const EstText = (() => {
const sumCurrency = (values) =>
values.reduce((n, value) => n + EstMath.even(num(value) * 10000), 0) / 10000;
const stripLabels = (text) =>
- String(text).replace(/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15/g, "");
+ String(text)
+ .replace(/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\([가-힣][^()]*\)/g, "")
+ .replace(/[;;]/g, ":");
function expression(text) {
return clean(
String(text).replace(
- /\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|\([가-힣][^()]*\)/g,
+ /\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|\([가-힣][^()]*\)|\(\s*\d+(?:\.\d+)?\s*[˚°]\s*\)|<[^>]*>/g,
"",
),
)
.replace(
- /(\d)\s*(?:공\/?㎥|공|단|면|경간|개월|월|인|㎥|㎢|㎡|hr|sec|min|km|ton|kg|㎏|㎞|ℓ|회|개소|개|대|분|초|본|장|매|병|조|식|일|시간|원|포|동|μ|EA|mm|cm|m|g|L)(?:\/(?:개소|개|대|본|인|분|포|\$|hr|일|㎥|㎡|m|a))?/gi,
+ /(\d)\s*(?:공\/?㎥|공|단|면|겹|경간|개월|월|년|인|㎥|㎢|㎡|hr|sec|min|kwh|kw|km|ton|kg|㎏|㎞|ℓ|회|개소|개|대|분|초|본|장|매|병|조|식|일|시간|원|포|동|주|톤|μ|₩|EA|mm|cm|m3|m2|m|g|L|a)(?:\/(?:개소|경간|개|대|본|인|분|포|매|주|톤|\$|hr|일|㎥|㎡|m|a|EA))?/gi,
"$1",
)
.replace(/(?<=\d)\s*\/(?:일|㎥|㎡)(?=\s|$)/g, "")
- .replace(/(?<=\))\s*(?:개|인|본|면|단)(?:\/(?:㎡|㎥|hr))?/g, "")
+ .replace(/(?<=\))\s*(?:개|인|본|면|단|회)(?:\/(?:㎡|㎥|hr))?/g, "")
+ .replace(/^\s*[-*•]?\s*[가-힣][가-힣\s]*\s+(?=[\d.(])/, "")
.replace(/[①-⑳]/g, "")
.replace(/=\s*$/, "");
}
@@ -38,11 +41,15 @@ const EstText = (() => {
changed = new Map(),
currentSource = null,
totals = {},
- levelTotals = new Map(),
- finalLevel = -1;
+ finalLevel = -1,
+ plainTotals = {},
+ singleComponent = -1,
+ hasAutoTotals = false;
function finish() {
if (!head) return;
- components.forEach((key) => (head[key] = num(totals[key])));
+ components.forEach(
+ (key) => (head[key] = !hasAutoTotals ? floating(num(plainTotals[key])) : num(totals[key])),
+ );
head.TC = sum(head);
titles.push({ ...head });
}
@@ -55,7 +62,7 @@ const EstText = (() => {
changed = new Map();
currentSource = null;
totals = {};
- levelTotals = new Map();
+ plainTotals = {};
for (const [key, tag] of [
["Name", "\x0e"],
["Size", "\x13"],
@@ -64,10 +71,18 @@ const EstText = (() => {
const match = String(row.LineData || "").match(new RegExp(`${tag}([^${tag}]+)${tag}`));
if (match) row[key] = match[1];
}
- const levels = [];
+ const levels = [],
+ componentRows = [];
for (let j = index + 1; j < rows.length && num(rows[j].Index) <= 0; j++)
if (String(rows[j].LineData || "").includes("\x1a")) levels.push(num(rows[j].Level));
+ else if (component[num(rows[j].SCheck)] && marker.test(String(rows[j].LineData || "")))
+ componentRows.push(j);
finalLevel = levels.length ? Math.min(...levels) : -1;
+ hasAutoTotals = levels.length > 0;
+ singleComponent =
+ !hasAutoTotals && num(head.SCheck) > 0 && componentRows.length === 1
+ ? componentRows[0]
+ : -1;
continue;
}
if (!head || !row.LineData) continue;
@@ -117,12 +132,18 @@ const EstText = (() => {
digits = 0;
const assignedResults = new Map();
const assignedNames = new Map();
+ let componentDeclaration;
try {
if (matched && /─/.test(text)) {
const above = clean(rows[index - 1]?.LineData),
below = clean(rows[index + 1]?.LineData);
- const tops = above.trim().split(/\s{2,}/),
- bottoms = below.trim().split(/\s{2,}/);
+ const columns = (value) =>
+ value
+ .replace(/\s*([×÷+*/−-])\s*/g, "$1")
+ .trim()
+ .split(/\s{2,}/);
+ const tops = columns(above),
+ bottoms = columns(below);
let part = 0;
const formula = text
.slice(text.indexOf("=") + 1)
@@ -133,9 +154,17 @@ const EstText = (() => {
});
calculated = EstExpression.evaluate(expression(formula), (key) => variables[key]);
} else if (matched && text.includes("\x1a")) {
- if (sc === -5)
- calculated = sumCurrency(Object.values(levelTotals.get(num(row.Level)) || totals));
- else {
+ if (sc === -5) {
+ const section = [];
+ for (let j = index - 1; j >= 0; j--) {
+ const previous = rows[j];
+ if (!previous.LineData) continue;
+ if (!String(previous.LineData).includes("\x1a") || !component[num(previous.SCheck)])
+ break;
+ section.push(num(previous.TC));
+ }
+ calculated = sumCurrency(section);
+ } else {
let body = clean(text.slice(0, text.indexOf("\x12")))
.replace(/=\s*$/, "")
.trim();
@@ -157,7 +186,11 @@ const EstText = (() => {
} else if (matched && sc < 0) {
let body = stripLabels(text.slice(0, text.indexOf("\x12"))).replace(/=\s*$/, "");
if (body.includes(":")) body = body.slice(body.indexOf(":") + 1);
- body = body.split("=")[0];
+ const parts = body.split("=");
+ if (parts.length > 1 && /^[A-Za-z][A-Za-z0-9]*$/.test(parts[0].trim())) {
+ componentDeclaration = parts[0].trim();
+ body = parts[1];
+ } else body = parts[0];
calculated = EstExpression.evaluate(expression(body), (key) => variables[key]);
}
const assignmentText = text.replace(
@@ -176,6 +209,7 @@ const EstText = (() => {
const assignment = assignments[j],
end = assignments[j + 1]?.index ?? text.length;
let body = text.slice(assignment.index + assignment[0].length, end);
+ if (!matched && /^\s*\*.*\*\s*$/.test(text)) body = body.replace(/\s+\*\s*$/, "");
body = body.split(/\{(?!%)|\s+[::]\s*/)[0].replace(/[},,]\s*$/, "");
if (body.includes("─") && calculated !== null) {
variables[assignment[1]] = floating(calculated, 2, 1);
@@ -188,15 +222,22 @@ const EstText = (() => {
.trim()
.replace(/\s+(?:min|hr|sec)\s*$/i, "")
.split(/\s{2,}(?=\d+\s*[가-힣]|[가-힣])/)[0];
+ if (!result)
+ body = body
+ .replace(/\s*,\s+[가-힣].*$/, "")
+ .replace(/^([-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?)(?:\s{2,}|\s*,\s+).*$/, "$1");
// 단위가 붙은 상수와 주석은 수치 부분만 사용한다.
const literal = body.match(/^([-+]?\d[\d,.]*)(?:\s*[A-Za-z가-힣㎥㎡ℓ˚°()].*)$/);
- if (literal) body = literal[1];
+ if (literal && !/^[xX](?=[A-Za-z\d.(])/.test(body.slice(literal[1].length).trim()))
+ body = literal[1];
let value;
try {
value = EstExpression.evaluate(body, (key) => variables[key]);
} catch (error) {
if (!result && error.message.startsWith("참조 항목 없음:")) continue;
- if (parts.length > 1)
+ if (!result && parts.length > 1)
+ value = EstExpression.evaluate(expression(parts.at(-1)), (key) => variables[key]);
+ else if (parts.length > 1)
value = EstExpression.evaluate(
expression(parts[parts.length - 2]),
(key) => variables[key],
@@ -223,7 +264,12 @@ const EstText = (() => {
.filter((x) => x.trim())
.pop();
if (body.includes(":")) body = body.slice(body.indexOf(":") + 1);
- calculated = EstExpression.evaluate(expression(body), (key) => variables[key]);
+ body = expression(body)
+ .replace(/\s*([×÷+*/−-])\s*/g, "$1")
+ .trim()
+ .split(/\s{2,}/)
+ .pop();
+ calculated = EstExpression.evaluate(body, (key) => variables[key]);
}
if (matched && calculated !== null) {
const explicit = num(row.DigetIndex) ? EstMath.precision(row) : null;
@@ -231,16 +277,23 @@ const EstText = (() => {
row.Precision ??
explicit?.digits ??
(sc < 0 || num(row.LinkIndex) !== 0
- ? num(row.Level) === finalLevel && text.includes("\x1a")
+ ? (num(row.Level) === finalLevel && text.includes("\x1a")) ||
+ index === singleComponent
? 0
: 1
: Math.max(2, matched[1].split(".")[1]?.length || 0));
const method =
row.RoundMode || explicit?.method || (sc < 0 || num(row.LinkIndex) !== 0 ? 2 : 1);
- calculated = floating(calculated, digits, method);
+ const results = [...text.matchAll(/\x12([-\d.,]+)\x12/g)];
+ const intermediate =
+ results.length > 1 && sc < 0 && !explicit && row.Precision === undefined;
+ calculated = floating(
+ calculated,
+ intermediate ? Math.max(2, matched[1].split(".")[1]?.length || 0) : digits,
+ intermediate ? 1 : method,
+ );
const firstValue = calculated;
let rendered = text.replace(marker, `\x12${calculated}\x12`);
- const results = [...text.matchAll(/\x12([-\d.,]+)\x12/g)];
let assignmentName = assignedNames.get(results[0]?.index);
for (let part = 1; part < results.length; part++) {
const previous = results[part - 1],
@@ -256,8 +309,11 @@ const EstText = (() => {
expression(`${calculated}${operation}`),
(key) => variables[key],
),
- format?.digits ?? digits,
- format?.method ?? method,
+ format?.digits ??
+ (intermediate && part < results.length - 1
+ ? Math.max(2, next[1].split(".")[1]?.length || 0)
+ : digits),
+ format?.method ?? (intermediate && part < results.length - 1 ? 1 : method),
);
assignmentName = assignedNames.get(next.index) || assignmentName;
if (assignmentName) variables[assignmentName] = calculated;
@@ -270,6 +326,7 @@ const EstText = (() => {
checks.push({ slot: index, part, stored: old, calculated });
}
row.TC = calculated;
+ if (componentDeclaration) variables[componentDeclaration] = calculated;
if (currentSource && text.includes("\x03")) {
let body = stripLabels(text.slice(0, text.indexOf("\x12"))).split("=")[0];
if (body.includes(":")) body = body.slice(body.indexOf(":") + 1);
@@ -278,6 +335,18 @@ const EstText = (() => {
expression(body.replace(/\x03[-\d.,]+\x03/g, "1")),
(key) => variables[key],
);
+ for (let part = 1; part < results.length; part++) {
+ const operation = expression(
+ text.slice(
+ results[part - 1].index + results[part - 1][0].length,
+ results[part].index,
+ ),
+ );
+ row.ResourceFactor = EstExpression.evaluate(
+ expression(`${row.ResourceFactor}${operation}`),
+ (key) => variables[key],
+ );
+ }
row.ResourceCode = currentSource.Code;
row.ResourceKind = currentSource.SourceKind;
row.ResourceComponent = component[sc] || "";
@@ -289,8 +358,6 @@ const EstText = (() => {
changed.set(oldValue, firstValue);
checks.push({ slot: index, stored: oldValue, calculated: firstValue });
if (text.includes("\x1a") && component[sc]) {
- if (sc === -1 || !levelTotals.has(num(row.Level))) levelTotals.set(num(row.Level), {});
- levelTotals.get(num(row.Level))[component[sc]] = calculated;
if (num(row.Level) === finalLevel) totals[component[sc]] = calculated;
}
} else if (!matched && component[sc] && text.includes("\x03") && !text.includes("\x1a")) {
@@ -306,14 +373,15 @@ const EstText = (() => {
changed.get(numeric(value)) ?? numeric(value),
num(row.Level) === finalLevel ? 0 : 1,
);
- if (sc === -1 || !levelTotals.has(num(row.Level))) levelTotals.set(num(row.Level), {});
- levelTotals.get(num(row.Level))[component[sc]] = calculated;
+ row.TC = calculated;
if (num(row.Level) === finalLevel) totals[component[sc]] = calculated;
}
} catch (error) {
errors.push(`${head.Code}:${index + 1} ${error.message}`);
row.Error = error.message;
}
+ if (!hasAutoTotals && component[sc])
+ plainTotals[component[sc]] = num(plainTotals[component[sc]]) + num(row.TC);
}
finish();
return { rows, titles, errors, checks };
diff --git a/tools/est-test/native-wonga.js b/tools/est-test/native-wonga.js
index d91b14e79..31560c06c 100644
--- a/tools/est-test/native-wonga.js
+++ b/tools/est-test/native-wonga.js
@@ -17,19 +17,29 @@ const EstWonga = (() => {
function pass() {
const done = new Set(),
active = new Set();
- function resolve(name) {
+ function effective(index, owner) {
+ const cost = evaluate(index);
+ if (
+ options.GangubExceptJodalRatio &&
+ /산업안전|안전보건/.test(compact(rows[owner]?.Name)) &&
+ compact(rows[index].Name).includes("관급")
+ )
+ return cost - num(rows[index].NC);
+ return cost;
+ }
+ function resolve(name, owner) {
if (Object.hasOwn(bases, name)) return bases[name];
const index = keys.get(name) ?? keys.get(compact(name));
- return index === undefined ? undefined : evaluate(index);
+ return index === undefined ? undefined : effective(index, owner);
}
- function range(start, end) {
+ function range(start, end, owner) {
let a = keys.get(start),
b = keys.get(end);
if (a === undefined || b === undefined) throw Error(`범위 참조 없음: ${start}:${end}`);
if (a > b) [a, b] = [b, a];
let sum = 0;
for (let i = a; i <= b; i++)
- if ([0, 1, 4].includes(num(rows[i].CalcIndex))) sum += evaluate(i);
+ if ([0, 1, 4].includes(num(rows[i].CalcIndex))) sum += effective(i, owner);
return sum;
}
function evaluate(index) {
@@ -39,9 +49,16 @@ const EstWonga = (() => {
if (active.has(index)) throw Error(`원가 수식 순환 참조: ${key(row)}`);
active.add(index);
if (row.BaseField && Object.hasOwn(bases, row.BaseField)) row.Cost = bases[row.BaseField];
- else if (row.VSusic && ![1, 3].includes(num(row.CalcIndex)))
+ else if (
+ row.VSusic &&
+ !/^원금액\s*:/.test(row.VSusic) &&
+ ![1, 3].includes(num(row.CalcIndex))
+ )
row.Cost = Math.trunc(
- EstExpression.evaluate(row.VSusic, resolve, { numericReferences: true, range }),
+ EstExpression.evaluate(row.VSusic, (name) => resolve(name, index), {
+ numericReferences: true,
+ range: (a, b) => range(a, b, index),
+ }),
);
else row.Cost = num(row.Cost);
active.delete(index);
diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs
index acbc28081..65d09a512 100644
--- a/tools/est-test/server.cjs
+++ b/tools/est-test/server.cjs
@@ -46,6 +46,7 @@ const allowed = new Set([
"native-views.js",
"native-actions.js",
"native-extra.js",
+ "native-settings.js",
...scripts,
]);
function reply(res, status, data, type = "application/json; charset=utf-8") {