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

This commit is contained in:
2026-11-05 07:18:54 +09:00
25 changed files with 649 additions and 162 deletions
+11
View File
@@ -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:
+10 -6
View File
@@ -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 = "후보 여럿"
@@ -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.규격 ?? "");
+2 -1
View File
@@ -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++;
+36 -3
View File
@@ -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,
);
+17 -1
View File
@@ -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["갈래"] == ["율"])
+15 -5
View File
@@ -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"]
+23
View File
@@ -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(
+163
View File
@@ -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
+2
View File
@@ -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 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다.
검사:
+21 -5
View File
@@ -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 };
+1
View File
@@ -18,6 +18,7 @@
<script defer src="native-model.js"></script>
<script defer src="native-views.js"></script>
<script defer src="native-extra.js"></script>
<script defer src="native-settings.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-app.js"></script>
</head>
+12 -69
View File
@@ -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 샘플 대조",
`<p><b>${result.checked}개 중 ${result.passed}개 일치 · 불일치 ${result.failed}개</b></p><p>보관된 EST 샘플의 저장값과 계산 결과를 비교했습니다. 원본 실행 비교는 포함하지 않습니다.</p>${result.errors.length ? `<p>${esc(result.errors.join(" / "))}</p>` : ""}<table><thead><tr><th>영역</th><th>검사 수</th><th>일치</th></tr></thead><tbody>${[...new Set(result.results.map((r) => r.group))].map((group) => `<tr><td>${esc(group)}</td><td>${result.results.filter((r) => r.group === group).length}</td><td>${result.results.filter((r) => r.group === group && r.pass).length}</td></tr>`).join("")}</tbody></table>`,
);
} 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 };
})();
+8 -1
View File
@@ -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();
}
+1 -5
View File
@@ -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("부가")) {
+50 -14
View File
@@ -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 };
})();
+2
View File
@@ -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 =
+9 -2
View File
@@ -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);
+20 -4
View File
@@ -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;
+23 -8
View File
@@ -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 };
+7 -5
View File
@@ -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
+93
View File
@@ -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 };
})();
+95 -27
View File
@@ -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 };
+23 -6
View File
@@ -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);
+1
View File
@@ -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") {