Merge remote-tracking branch 'origin/dev' into sub_laptop_3
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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.규격 ?? "");
|
||||
|
||||
|
||||
@@ -154,3 +154,8 @@ M02 의 엑셀 불러오기에서 경쟁사 2개의 프로그램의 출력을
|
||||
## 요구 644 (11-05 · 사용자 · 수정 모달 되돌림 검토)
|
||||
|
||||
일위대가 불러오기가 메인페이지로 엑셀화면과 효표리스트가 메인페이지로 오는건 좋음. 하지만 수정버튼을 눌렀을때 나오던 모달창이었던 부분이 많이 바뀌었는데 모달창으로 다시변경 검토, 구조물 조합의 일위대가와 공용이었으니 조심해야함.
|
||||
|
||||
## 요구 645 (11-05 · 사용자 · 644 답 — 옛 방식 · 별도계상)
|
||||
|
||||
1. 대부분 이전 방식을 채택하되 신규 추가된 기능 별도로 표기
|
||||
2. 별도계상은 모아서 한번에 계산한다는의미임. 그러니 프로젝트 내부에 복사하였을때 품목결장을 해야함. 그러니 현재 엑셀 불러오기기능에서는 별도계상은 품목지정 필요없음. 이기능은 기존에 자재에서 선택할수 있었음.
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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["갈래"] == ["율"])
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다.
|
||||
|
||||
검사:
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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("부가")) {
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user