diff --git a/A00_Common/formula_doc_copy.ts b/A00_Common/formula_doc_copy.ts index fd5107cb1..1b74e2e7c 100644 --- a/A00_Common/formula_doc_copy.ts +++ b/A00_Common/formula_doc_copy.ts @@ -14,7 +14,9 @@ import { createBadge } from "@ui/ui_template_badge"; import { createButton } from "@ui/ui_template_button"; import { eqText } from "@ui/ui_template_equation"; import { showConfirmDialog } from "@ui/ui_template_elements"; +import type { FormulaDoc } from "../common_util/common_util_formula_doc"; import type { Basis } from "../common_util/common_util_formula_doc_import"; +import { swappedCount } from "../common_util/common_util_line_stack_pick"; import { ft } from "./formula_doc_text"; export type CopyJob = "단가갱신" | "재생성"; @@ -77,6 +79,15 @@ export function copyBadges(m: CopyMark | undefined, edit: boolean): HTMLElement[ return out; } +/** 가져온 식 바꾼 줄 배지(823-1) — 「바꾼 줄 n — 원본과 다름」 · 없으면 없음 */ +export function swapBadge(doc: FormulaDoc): HTMLElement[] { + const n = swappedCount(doc); + if (!n) return []; + const b = createBadge(ft("Swapped_N").replace("{n}", String(n)), "info"); + b.dataset.swappedN = String(n); + return [b]; +} + export function copyTools(edit: boolean, run: (job: CopyJob) => void): HTMLElement[] { if (!edit) return []; return (["단가갱신", "재생성"] as const).map((job) => { diff --git a/A00_Common/formula_doc_text.ts b/A00_Common/formula_doc_text.ts index 010f39b62..dd7607219 100644 --- a/A00_Common/formula_doc_text.ts +++ b/A00_Common/formula_doc_text.ts @@ -67,6 +67,7 @@ const TEXT = { Gone: ["지워짐", "Deleted"], Gone_Note: ["마지막 값 유지", "last value kept"], Gap: ["사본 없는 줄", "Uncopied rows"], + Swapped_N: ["바꾼 줄 {n} — 원본과 다름", "{n} swapped — differs from source"], Gap_Note: ["저장하면 지금 값으로 굳음", "saving freezes current values"], Price: ["단가 갱신", "Refresh prices"], Regen: ["재생성", "Regenerate"], diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts index bea153692..224846c8a 100644 --- a/A00_Common/line_stack_edit.ts +++ b/A00_Common/line_stack_edit.ts @@ -42,6 +42,7 @@ import { repick, shownUnit, tableAlias, + unswap, } from "../common_util/common_util_line_stack_pick"; import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick"; import { reach } from "./formula_doc_render"; @@ -176,6 +177,24 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] { head.push(pick); const mark = machineMark(found, link); if (mark) head.push(mark); + // 823-1 가져온 줄 바꿔 끼움 = 우리 값 · 「바꿈」 + 되돌리기(원래 것 · 엑셀 값) + const sw = (link as { 바꿈?: { 이름?: string; 규격?: string } }).바꿈; + if (sw) { + const was = `${sw.이름 ?? ""} ${sw.규격 ?? ""}`.trim(); + const tag = createBadge(lt("Swapped"), "info", { size: 18 }); + tag.title = `${lt("Swapped_Note")} — ${was}`; + tag.dataset.swapped = String(i); + const back = createButton({ + label: lt("Unswap"), + variant: "ghost", + size: 24, + iconStart: "undo", + onClick: () => h.change((doc) => unswap(doc, i, own)), + }); + back.title = was; + back.dataset.unswap = String(i); + head.push(tag, back); + } // 821-3 품셈 기계 줄 → 복사해 내 기계로(개인 층 SK) const mine = kind === "기계" diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts index e9727b88f..8ae8b8bfe 100644 --- a/A00_Common/line_stack_screen.ts +++ b/A00_Common/line_stack_screen.ts @@ -45,6 +45,7 @@ import { type CopyMark, type CopyOptions, type JobReply, + swapBadge, } from "./formula_doc_copy"; import { docFoot } from "./formula_doc_foot"; import { buildOptions } from "./formula_doc_options"; @@ -317,6 +318,7 @@ export function createStackScreen(opt: StackScreenOptions) { children: [ ...(busy ? [createBadge(ft("Dirty"), "warning")] : []), ...(cp ? copyBadges(mark, opt.admin) : []), + ...(mark?.단가기준 ? swapBadge(doc) : []), // 823-1 가져온 줄 바꿔 끼움 ], }), el("div", { className: "lstk__tools", children: opt.tools ? [] : tools }), diff --git a/A00_Common/line_stack_text.ts b/A00_Common/line_stack_text.ts index 2c3191e3a..88476c37a 100644 --- a/A00_Common/line_stack_text.ts +++ b/A00_Common/line_stack_text.ts @@ -78,6 +78,12 @@ const TEXT = { Tbl_Missing: ["표를 못 읽음", "Table not found"], Mach_Many: ["기계 후보 여럿 — 고르기", "Several machines — pick one"], Mach_None: ["기계 못 찾음 — 고르기", "Machine not found — pick one"], + Swapped: ["바꿈", "Swapped"], + Swapped_Note: [ + "가져온 줄을 바꿔 끼움 — 이 줄만 우리 값", + "Imported line swapped — our price on this line", + ], + Unswap: ["되돌리기", "Restore"], Mach_Combo: ["조합 기계 — 경비는 첫 줄", "Combined machine — cost on first line"], Tbl_Hint: ["값 칸을 누르면 그 값이 들어감", "Click a value cell to use it"], Tbl_Bound: [ diff --git a/M02_MasterTemplete/M02_Doc_Gap.py b/M02_MasterTemplete/M02_Doc_Gap.py index b8e22a8e5..af21c0afc 100644 --- a/M02_MasterTemplete/M02_Doc_Gap.py +++ b/M02_MasterTemplete/M02_Doc_Gap.py @@ -64,3 +64,19 @@ def fill(copy: dict) -> dict: if not keys: return copy return {**copy, **skc.grow(dc.values_of(copy), skc.snap([], keys))} + + +def fill_imported(sk: dict, doc: Any) -> dict: + """가져온 식 SK(823-1) — 문서 셈(단가기준 · 바꿔 끼운 줄)이 읽은 줄 가운데 + SK 로직 사본에 없는 것 + 내 기계 = 지금 값으로 로직 사본에 더함 · + 사본 없음 · 빈틈 없음 = 그대로.""" + from M02_MasterTemplete import M02_Doc_Import as dim + + if not skc.has(sk) or not isinstance(doc, dict): + return sk + seen = _Seen(dim._index(sk, None, doc)) + dim.run(doc, sk, index=seen) # 셈 틀림 = 읽은 데까지(run 이 받음) + keys = sorted({*seen.keys, *mym.keys_in(doc)} - _have(sk[skc.KEY])) + if not keys: + return sk + return {**sk, skc.KEY: skc.grow(sk[skc.KEY], skc.snap([], keys))} diff --git a/M02_MasterTemplete/M02_Doc_Import.py b/M02_MasterTemplete/M02_Doc_Import.py index 96d2084b8..511a943a9 100644 --- a/M02_MasterTemplete/M02_Doc_Import.py +++ b/M02_MasterTemplete/M02_Doc_Import.py @@ -51,12 +51,18 @@ def basis_of(sk: dict) -> str: return "우리" if (sk.get(imp.KEY) or {}).get("단가기준") == "우리" else "엑셀" -def _index(sk: dict, master: Any = None) -> Any: - """셈 색인 — 우리 = 로직 사본 위(822-1 · 이음 값이 M01 따라 안 바뀜 · [단가 갱신] 으로만) · - 엑셀 = 받은 색인 · 없으면 지금 M01.""" +def swapped(doc: Any) -> int: + """바꿔 끼운 가져온 줄 수(823-1 · 연결 `바꿈` = 원래 · 엑셀 값 뗌 = 우리 값).""" + vars_ = (doc or {}).get("변수") or [] if isinstance(doc, dict) else [] + return sum(1 for v in vars_ if isinstance(v.get("연결"), dict) and v["연결"].get("바꿈")) + + +def _index(sk: dict, master: Any = None, doc: Any = None) -> Any: + """셈 색인 — 우리 · 바꾼 줄 있음(823-1) = 로직 사본 위(822-1 · 이음 값이 M01 따라 안 바뀜 · + [단가 갱신] 으로만) · 엑셀 = 받은 색인 · 없으면 지금 M01.""" from M02_MasterTemplete import M02_Sangeun_Copy as skc - if basis_of(sk) == "우리": + if basis_of(sk) == "우리" or swapped(doc): return skc.over(sk, imp._whole()) return master or imp._whole() @@ -524,7 +530,7 @@ def record(doc: dict, sk: dict) -> dict[str, Any]: import master_doc_record as mr # `M02_Doc_Copy` 가 scripts 를 길에 둔 뒤 basis = basis_of(sk) - links = Links(_index(sk), basis) + links = Links(_index(sk, None, doc), basis) mine = view(doc, basis) rec = mr.Rec(links) try: @@ -536,10 +542,11 @@ def record(doc: dict, sk: dict) -> dict[str, Any]: return rec.out() -def run(doc: dict, sk: dict, master: Any = None) -> dict: - """가져온 식 문서 셈(SK 단가기준) — 계산기 = M01 과 같은 `master_doc_calc`.""" +def run(doc: dict, sk: dict, master: Any = None, index: Any = None) -> dict: + """가져온 식 문서 셈(SK 단가기준) — 계산기 = M01 과 같은 `master_doc_calc` · + `index` = 셈 색인 갈음(빈틈 적기 · 823-1).""" basis = basis_of(sk) - links = Links(_index(sk, master), basis) + links = Links(index or _index(sk, master, doc), basis) mine = view(doc, basis) try: links.learn(mine) diff --git a/M02_MasterTemplete/M02_Doc_Sk.py b/M02_MasterTemplete/M02_Doc_Sk.py index 6e4d6acf7..fa3c5ed75 100644 --- a/M02_MasterTemplete/M02_Doc_Sk.py +++ b/M02_MasterTemplete/M02_Doc_Sk.py @@ -49,6 +49,8 @@ def _strip(v: Any) -> Any: return {k: _strip(x) for k, x in v.items() if k not in _STAMPED} if isinstance(v, list): return [_strip(x) for x in v] + if isinstance(v, float) and v.is_integer(): # 파일 24554.0 = 화면 왕복 24554(823-1) + return int(v) return v @@ -188,6 +190,8 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict: raw = raw if isinstance(raw, dict) else {} mine = SAVING.get() if mine is not None and mine.get("키") == out.get("키"): # 수식 길 저장 — 서버가 지은 사본 + if dim.is_imported(out): # 가져온 식 — 셈이 읽은 줄(바꿔 끼운 줄 등)을 로직 사본에(823-1) + out = gap.fill_imported(out, mine["사본"].get("문서")) return {**out, KEY: norm(mine["사본"])} file = stored(out.get("키")) or {} have = file.get(KEY) if dc.has(file) else (raw.get(KEY) if dc.has(raw) else None) diff --git a/M02_MasterTemplete/M02_Machine_Hourly.py b/M02_MasterTemplete/M02_Machine_Hourly.py index 0e4fb4952..f7b76b2cf 100644 --- a/M02_MasterTemplete/M02_Machine_Hourly.py +++ b/M02_MasterTemplete/M02_Machine_Hourly.py @@ -136,8 +136,6 @@ def why(row: dict, logic: dict | None) -> str: return f"주연료 「{vals['주연료']}」 — 수 아님" if float(vals.get("조종원") or 0) > 0 and not vals.get("조종원직종"): return "조종원직종 없음" - if vals.get("주연료") and vals.get("전력"): - return "주연료 · 전력 둘 다 — 식 없음" return "손료 계수 셋(상각 · 정비 · 관리) 없음" text = " ".join(str(x.get(k)) for k in ("식", "수량") for p in ("중간", "호표", "덧줄") for x in logic.get(p) or []) # fmt: skip diff --git a/M02_MasterTemplete/M02_MyMachine.py b/M02_MasterTemplete/M02_MyMachine.py index af0326b21..1187be642 100644 --- a/M02_MasterTemplete/M02_MyMachine.py +++ b/M02_MasterTemplete/M02_MyMachine.py @@ -12,6 +12,7 @@ 이 색인을 읽음 — 층을 모르는 길(`/api/m02/logic-lib/machine-hourly` 등)은 M01 만. 식 고름(채운 칸 · `logic_key` — M01 기계 목록 밖 줄도 같은 규칙 · 822-2) = 연료 + 조종원 GC000268(직종 없음 GC000275) · 연료만 GC000272(손료계수 한 칸 GC001198) · 조종원만 GC000276 · 전력 GC001196(+ 조종원 GC001197) · +연료 + 전력 GC001200(+ 조종원 GC001201 · 822-2b 경쟁사 기계) · 없음 GC000273 · 직접 GC001199(시간당 값 = 셈 꼴 값 칸 노무비 · 재료비 · 경비). 권한(본인 개인 층 · 맡은 회사 관리자)은 길(`M02_MyMachine_Router`) 몫. """ @@ -46,6 +47,7 @@ LOGIC = {(True, True): "GC000268", (True, False): "GC000272", (False, True): "GC000276", (False, False): "GC000273"} # fmt: skip JOB_IN, ONE_FUEL = "GC000275", "GC001198" # 직종 입력 · 손료계수 한 칸 연료 POWER, POWER_CREW, DIRECT_KEY = "GC001196", "GC001197", "GC001199" +FUEL_POWER, FUEL_POWER_CREW = "GC001200", "GC001201" # 연료 + 전력(+ 조종원) — 경쟁사 구성(822-2b) def mine(key: Any) -> bool: @@ -136,7 +138,7 @@ def _pos(v: Any) -> bool: def logic_key(row: dict) -> str | None: - """꼴(채운 칸) → 시간당 경비 식 키 · 못 고름(직종 없는 조종원만 · 연료 + 전력 · 수 아닌 연료 글) = None · + """꼴(채운 칸) → 시간당 경비 식 키 · 못 고름(직종 없는 조종원 · 수 아닌 연료 글) = None · 계수 셋(상각 · 정비 · 관리) 없음 = 손료계수 한 칸 식(연료만) · 그 밖은 그 식 + 모자란 칸 까닭.""" if row.get("방식") == DIRECT: return DIRECT_KEY @@ -144,8 +146,10 @@ def logic_key(row: dict) -> str | None: fuel, crew, power = (_pos(vals.get(k)) for k in ("주연료", "조종원", "전력")) job = bool(vals.get("조종원직종")) three = all(vals.get(k) is not None for k in COEFS) - if vals.get("주연료") not in (None, "", 0) and not fuel or fuel and power: + if vals.get("주연료") not in (None, "", 0) and not fuel: return None + if fuel and power: + return FUEL_POWER_CREW if crew and job else None if crew else FUEL_POWER if power: return POWER_CREW if crew and job else None if crew else POWER if fuel and not crew: @@ -181,7 +185,7 @@ def hourly(row: dict, index: Any) -> dict[str, Any]: mf = m01.cm.mf key = logic_key(row) if key is None: - return {"ok": False, **head, "reason": "식 없음 — 연료 · 전력은 하나만"} + return {"ok": False, **head, "reason": "식 없음 — 조종원직종 없음"} logic = index.logic(key) given = {i["이름"]: (i.get("고르기") or [""])[0] for i in logic.get("입력") or []} given["기계"] = str(row.get("원문번호")) @@ -239,8 +243,6 @@ def clean(raw: Any) -> dict[str, Any]: need = ("가격", "손료계수") if how == EASY else ("가격", *RATIOS) if lost := [k for k in need if not vals.get(k)]: raise _bad(f"빈 칸 — {' · '.join(lost)}") - if vals.get("주연료") and vals.get("전력"): - raise _bad("주연료 · 전력은 하나만(원문 운전경비 표 한 칸)") drop = (*RATIOS, *COEFS) if how == EASY else () vals = {k: v for k, v in vals.items() if k not in drop} if how == BOOK: diff --git a/common_util/common_util_line_stack_pick.ts b/common_util/common_util_line_stack_pick.ts index 41252cbb1..f03add116 100644 --- a/common_util/common_util_line_stack_pick.ts +++ b/common_util/common_util_line_stack_pick.ts @@ -129,20 +129,68 @@ export function addPicked(doc: FormulaDoc, item: StackItem, kind: MoneyKind, p: return id; } -/** 있는 줄 바꿔 고름 — 그 줄 단가 기호 연결 · 줄 이름 · 규격(단위 · 수량은 그대로 · 같은 로직이면 넘길 입력 그대로) */ +/** 가져온 줄 바꿔 끼움 표지(823-1) — 원래 연결(엑셀 값 기억째) · 줄 이름 · 규격 · 되돌리기용 */ +export interface Swap { + 연결: Link; + 이름?: string; + 규격?: string; + /** 단가 기호 이름 */ + 기호이름?: string; +} +type Kept = Link & { 엑셀?: unknown; 바꿈?: Swap }; + +const sameRes = (a: Link, b: Link): boolean => { + const args = (l: Link): string => + JSON.stringify(Object.entries(l.입력 ?? {}).sort(([x], [y]) => (x < y ? -1 : 1))); + return a.갈래 === b.갈래 && a.키 === b.키 && args(a) === args(b); +}; + +/** 바꾼 가져온 줄 수(823-1 · 「바꾼 줄 n — 원본과 다름」) */ +export const swappedCount = (doc: FormulaDoc): number => + (doc.변수 ?? []).filter((v) => (v.연결 as Kept | undefined)?.바꿈).length; + +/** 바꾼 가져온 줄 되돌리기(823-1) — 원래 연결(엑셀 값) · 이름 · 규격 */ +export function unswap(doc: FormulaDoc, i: number, id: string): void { + const v = (doc.변수 ?? []).find((x) => varId(x) === id); + const line = (doc.금액 ?? [])[i] as { 이름?: string; 규격?: string } | undefined; + const sw = (v?.연결 as Kept | undefined)?.바꿈; + if (!v || !line || !sw) return; + v.연결 = sw.연결; + if (sw.기호이름 !== undefined) v.이름 = sw.기호이름; + else delete v.이름; + if (sw.이름 !== undefined) line.이름 = sw.이름; + else delete line.이름; + if (sw.규격) line.규격 = sw.규격; + else delete line.규격; +} + +/** 있는 줄 바꿔 고름 — 그 줄 단가 기호 연결 · 줄 이름 · 규격(단위 · 수량은 그대로 · 같은 로직이면 넘길 입력 그대로) · + * 가져온 이은 줄(엑셀 값 기억)을 다른 것으로 = 그 줄만 우리 값(엑셀 값 뗌 · `바꿈` = 원래 · 823-1) · 원래 것 다시 고름 = 되돌림 */ export function repick(doc: FormulaDoc, i: number, id: string, kind: MoneyKind, p: Picked): void { const v = (doc.변수 ?? []).find((x) => varId(x) === id); const line = (doc.금액 ?? [])[i] as { 이름?: string; 규격?: string } | undefined; if (!v || !line) return; - const was = v.연결 ?? {}; - const now = linkOf(kind, p); + const was = (v.연결 ?? {}) as Kept; + const now = linkOf(kind, p) as Kept; if (now.갈래 === "요소" && was.지역) now.지역 = was.지역; // 지역 옵션 이음 그대로 if (now.갈래 === "단가산출" && was.키 === now.키) now.입력 = { ...was.입력, ...now.입력 }; // 같은 로직 = 다른 넘길 입력 그대로 - // 가져온 기계 줄(812-9) — 엑셀 값 기억 그대로(엑셀 단가 기준 금액 안 바뀜 · 못 이은 기계 = 숫자 값 · 묶음) - const kept = (was as { 엑셀?: unknown }).엑셀 ?? (was.갈래 === "숫자" ? was.값 : undefined); - if (kind === "기계" && kept !== undefined) (now as { 엑셀?: unknown }).엑셀 = kept; + const orig = (was.바꿈?.연결 ?? was) as Kept; + if (was.바꿈 && sameRes(orig, now)) return unswap(doc, i, id); // 원래 것 다시 고름 const cost = (line as { 비목?: string }).비목 ?? ""; if (kind === "기계" && ["노무비", "재료비", "경비"].includes(cost)) now.칸 = cost; // 비목 한 칸 줄 = 그 칸 + if ("엑셀" in orig && orig.갈래 !== "숫자") { + if (sameRes(orig, now)) return; // 같은 것 = 그대로 + now.바꿈 = was.바꿈 ?? { + 연결: was, + ...(line.이름 !== undefined ? { 이름: line.이름 } : {}), + ...(line.규격 ? { 규격: line.규격 } : {}), + ...(v.이름 !== undefined ? { 기호이름: v.이름 } : {}), + }; + } else { + // 못 이은 가져온 기계 줄(812-9) 고름 = 이음 — 엑셀 값 기억(엑셀 단가 기준 금액 안 바뀜 · 숫자 값 · 묶음) + const kept = was.엑셀 ?? (was.갈래 === "숫자" ? was.값 : undefined); + if (kind === "기계" && kept !== undefined) now.엑셀 = kept; + } v.연결 = now; v.이름 = p.이름; line.이름 = p.이름; diff --git a/docs/raw/요구_1104/_목록_802-2_변환대조.xlsx b/docs/raw/요구_1104/_목록_802-2_변환대조.xlsx index ada56c156..49ffa570f 100644 Binary files a/docs/raw/요구_1104/_목록_802-2_변환대조.xlsx and b/docs/raw/요구_1104/_목록_802-2_변환대조.xlsx differ diff --git a/resources/master_data/_키대장.json b/resources/master_data/_키대장.json index 1d30cd486..ca4e86c1a 100644 --- a/resources/master_data/_키대장.json +++ b/resources/master_data/_키대장.json @@ -1,5 +1,5 @@ { - "다음": {"LB": 409, "EQ": 669, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 338, "CC": 528, "GC": 1200, "QC": 2060, "GX": 5, "UA": 38}, + "다음": {"LB": 409, "EQ": 669, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 338, "CC": 528, "GC": 1202, "QC": 2060, "GX": 5, "UA": 38}, "폐기": {"MT000001": "자재품목 휘발유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000002": "자재품목 경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000003": "자재품목 선박용경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000004": "자재품목 중유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000005": "자재품목 등유 — 값이 비고 글뿐 · 유가 테이블로 감", "LB000399": "이름 「〃」 — 직종이 아니라 되풀이 기호 · 소요량 13-6-9 에서 윗줄 직종으로 바로잡음", "MN": "재료_나라장터자재 — old 로 옮김 · 로직은 자재품목·품셈재료로 갈아 끼움", "LB000372": "미확보 고급기능사 — 측량노임 항공사진고급기능사 옛이름으로 옮김", "LB000393": "미확보 리베팅공 — 건설노임 철골공 옛이름으로 옮김(2010 통합표 연번10)", "LB000405": "미확보 Belt Conveyor 설치공 — 품셈 13-10-1 주② 배분으로 다섯 직종에 나눔", "MT000006": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "MT000007": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "LB000368": "미확보 기술사 — 기술사(건설) 옛이름으로 옮김", "LB000369": "미확보 특급기술자 — 특급기술자(건설) 옛이름으로 옮김", "LB000370": "미확보 고급기술자 — 고급기술자(건설) 옛이름으로 옮김", "LB000361": "미확보 중급기술자 — 중급기술자(건설) 옛이름으로 옮김", "LB000364": "미확보 초급기술자 — 초급기술자(건설) 옛이름으로 옮김", "LB000365": "미확보 고급숙련기술자 — 고급숙련기술자(건설) 옛이름으로 옮김", "LB000366": "미확보 중급숙련기술자 — 중급숙련기술자(건설) 옛이름으로 옮김", "LB000367": "미확보 초급숙련기술자 — 초급숙련기술자(건설) 옛이름으로 옮김", "LB000389": "미확보 기계기사 — 초급기술자(기계·설비) 옛이름으로 옮김", "LB000388": "미확보 기계산업기사 — 초급숙련기술자(기계·설비) 옛이름으로 옮김", "MO000055": {"원문번호": "주택용저압:기본_200이하", "파일": "재료_유가전력.json"}, "MO000056": {"원문번호": "주택용저압:기본_201~400", "파일": "재료_유가전력.json"}, "MO000057": {"원문번호": "주택용저압:기본_400초과", "파일": "재료_유가전력.json"}, "MO000059": {"원문번호": "주택용저압:전력량_다음200", "파일": "재료_유가전력.json"}, "MO000060": {"원문번호": "주택용저압:전력량_400초과", "파일": "재료_유가전력.json"}, "MO000061": {"원문번호": "주택용저압:월간최저요금", "파일": "재료_유가전력.json"}, "MO000062": {"원문번호": "일반용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000063": {"원문번호": "일반용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000064": {"원문번호": "일반용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000065": {"원문번호": "일반용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000066": {"원문번호": "산업용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000067": {"원문번호": "산업용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000068": {"원문번호": "산업용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000069": {"원문번호": "산업용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000070": {"원문번호": "임시전력갑:기본요금적용", "파일": "재료_유가전력.json"}, "MO000071": {"원문번호": "임시전력갑:전력량요금적용", "파일": "재료_유가전력.json"}, "MO000072": {"원문번호": "임시전력갑:월간최저요금", "파일": "재료_유가전력.json"}, "GC001145": "작업량당 기계경비 — 부르는 곳 0 · 필요하면 단가산출에 기계 하나 넣어 만듦(요구 734-4 · 735-4 · 813-2)", "GC001148": "작업량당 기계경비 — 부르는 곳 0 · 필요하면 단가산출에 기계 하나 넣어 만듦(요구 734-4 · 735-4 · 813-2)", "GC001151": "작업량당 기계경비 — 부르는 곳 0 · 필요하면 단가산출에 기계 하나 넣어 만듦(요구 734-4 · 735-4 · 813-2)", "GC001156": "작업량당 기계경비 — 부르는 곳 0 · 필요하면 단가산출에 기계 하나 넣어 만듦(요구 734-4 · 735-4 · 813-2)", "GC001159": "작업량당 기계경비 — 부르는 곳 0 · 필요하면 단가산출에 기계 하나 넣어 만듦(요구 734-4 · 735-4 · 813-2)", "GC001166": "작업량당 기계경비 — 부르는 곳 0 · 필요하면 단가산출에 기계 하나 넣어 만듦(요구 734-4 · 735-4 · 813-2)", "EQ000656": "자체 발전기 5.5㎾ — 품셈에 없는 기계 = 사용자 내 기계(요구서 796 · 821-4) · 결번", "EQ000657": "자체 발전기 7㎾ — 품셈에 없는 기계 = 사용자 내 기계(요구서 796 · 821-4) · 결번", "EQ000658": "자체 발전기 10㎾ — 품셈에 없는 기계 = 사용자 내 기계(요구서 796 · 821-4) · 결번", "EQ000659": "자체 대형 브레이커 0.18㎥ — 품셈에 없는 기계 = 사용자 내 기계(요구서 796 · 821-4) · 결번", "EQ000660": "자체 에어 호스 1.91㎝ — 품셈에 없는 기계 = 사용자 내 기계(요구서 796 · 821-4) · 결번", "EQ000661": "자체 모노펌프 5-20㎥/hr — 품셈에 없는 기계 = 사용자 내 기계(요구서 796 · 821-4) · 결번", "EQ000662": "자체 파워트로웰 3.73㎾ — 품셈에 없는 기계 = 사용자 내 기계(요구서 796 · 821-4) · 결번", "EQ000663": "자체 콘크리트펌프용 파이프 150㎜×1m — 품셈에 없는 기계 = 사용자 내 기계(요구서 796 · 821-4) · 결번", "EQ000664": "자체 콘크리트펌프용 파이프 150㎜×2m — 품셈에 없는 기계 = 사용자 내 기계(요구서 796 · 821-4) · 결번", "EQ000665": "자체 콘크리트펌프용 파이프 150㎜×3m — 품셈에 없는 기계 = 사용자 내 기계(요구서 796 · 821-4) · 결번", "EQ000666": "자체 콘크리트펌프용 파이프 곡관 150㎜ 45° — 품셈에 없는 기계 = 사용자 내 기계(요구서 796 · 821-4) · 결번", "EQ000667": "자체 콘크리트펌프용 파이프 곡관 150㎜ 60° — 품셈에 없는 기계 = 사용자 내 기계(요구서 796 · 821-4) · 결번", "EQ000668": "자체 콘크리트펌프용 파이프 곡관 150㎜ 90° — 품셈에 없는 기계 = 사용자 내 기계(요구서 796 · 821-4) · 결번", "GC001195": "공통 8 파워트로웰 시간당 경비 — 품셈에 없는 기계 = 사용자 내 기계(요구서 796 · 821-4) · 결번"}, "키": { "CC000001": {"원문번호": "공통 1-3-1 콘크리트 및 포장용 재료", "파일": "계수_건설품셈_01장_적용기준.json"}, @@ -2539,6 +2539,8 @@ "GC001197": {"원문번호": "공통 8 기계 시간당 경비(전력 조종원)", "파일": "로직_건설품셈_08장_건설기계.json"}, "GC001198": {"원문번호": "공통 8 기계 시간당 경비(손료계수 한 칸)", "파일": "로직_건설품셈_08장_건설기계.json"}, "GC001199": {"원문번호": "공통 8 기계 시간당 경비(직접 값)", "파일": "로직_건설품셈_08장_건설기계.json"}, + "GC001200": {"원문번호": "공통 8 기계 시간당 경비(연료 전력)", "파일": "로직_건설품셈_08장_건설기계.json"}, + "GC001201": {"원문번호": "공통 8 기계 시간당 경비(연료 전력 조종원)", "파일": "로직_건설품셈_08장_건설기계.json"}, "GF000001": {"원문번호": "3-1 경계표시", "파일": "로직_산림품셈_03장_작업장_관리.json"}, "GF000002": {"원문번호": "3-3 작업로 설치", "파일": "로직_산림품셈_03장_작업장_관리.json"}, "GF000003": {"원문번호": "3-4-2 임산물 운반로 신설 작업량", "파일": "로직_산림품셈_03장_작업장_관리.json"}, diff --git a/resources/master_data/로직_건설품셈_08장_건설기계.json b/resources/master_data/로직_건설품셈_08장_건설기계.json index abb927060..ffa3f53be 100644 --- a/resources/master_data/로직_건설품셈_08장_건설기계.json +++ b/resources/master_data/로직_건설품셈_08장_건설기계.json @@ -3599,6 +3599,114 @@ ], "끝수": null, "변수표": { "기계": { "기호": "S", "아래": "기계" } } + }, + { + "키": "GC001200", + "원문번호": "8", + "구분": "건설품셈 공통", + "상세구분": "08장 건설기계", + "결과단위": "원/시간", + "출처": "건설품셈 공통 8-1-5 · 8-1-6 · 8-1-6 3 가", + "부품": { "갈래": "기계경비식" }, + "소유": "공용", + "이름": "기계 시간당 기계경비(연료 · 전력 기종)", + "비고": "자체 — 주연료와 전력이 둘 다 있는 기계(경쟁사 불러오기 내 기계 · 파워트로웰 등) — 주연료비 + 전력비 + 잡재료((주연료비 + 전력비)의 %)", + "입력": [ + { "이름": "기계", "고르기": [] }, + { + "이름": "지역", + "고르기": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], + "출처": "건설품셈 공통 8-1-7 5" + }, + { "이름": "전력 계약종별", "고르기": ["일반용(갑)Ⅰ 저압", "주택용 저압"], "출처": "유가전력 한전 전기요금표 2025-01-01", "비고": "품셈·계약예규에 계약종별 규정 없음(전력비는 실비 계상) — 기본 = 불러오기 기본값" } + ], + "중간": [ + { "이름": "손료", "식": "버림(EQ:{기계}.가격 * 1000 * EQ:{기계}.손료계수 / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4" }, + { "이름": "연료", "식": "EQ:{기계}.주연료종류", "출처": "건설품셈 공통 8-4 주①㉰" } + ], + "호표": [ + { "종류": "재료", "요소": "MO:{연료}:{지역}", "이름": "주연료", "단위": "ℓ", "수량": "EQ:{기계}.주연료", "비목": "재료비", "출처": "건설품셈 공통 8-4-8" }, + { + "종류": "재료", + "요소": { "이름": "전력량요금", "계약종별": "입력" }, + "이름": "전력", + "단위": "㎾h", + "수량": "EQ:{기계}.전력", + "비목": "재료비", + "출처": "건설품셈 공통 8-1-6 3 가", + "비고": "기계 줄 전력(㎾) 그대로 — 전동기 부하율 규정 없음" + } + ], + "덧줄": [{ "이름": "잡재료비", "식": "재료비 * EQ:{기계}.잡재료율 / 100", "비목": "재료비", "출처": "건설품셈 공통 8-4 주②" }, { "이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2" }], + "끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음", + "변수표": { + "기계": { "기호": "S", "아래": "기계" }, + "지역": { "기호": "S", "아래": "지역" }, + "전력 계약종별": { "기호": "S", "아래": "전력계약종별" }, + "손료": { "기호": "C", "아래": "손료" }, + "연료": { "기호": "S", "아래": "연료" } + } + }, + { + "키": "GC001201", + "원문번호": "8", + "구분": "건설품셈 공통", + "상세구분": "08장 건설기계", + "결과단위": "원/시간", + "출처": "건설품셈 공통 8-1-5 · 8-1-6 · 8-1-6 3 가", + "부품": { "갈래": "기계경비식" }, + "소유": "공용", + "이름": "기계 시간당 기계경비(연료 · 전력 · 조종원 기종)", + "비고": "자체 — 주연료 · 전력 · 조종원이 다 있는 기계(경쟁사 불러오기 내 기계) — 주연료비 + 전력비 + 잡재료((주연료비 + 전력비)의 %) + 조종원", + "입력": [ + { "이름": "기계", "고르기": [] }, + { + "이름": "지역", + "고르기": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], + "출처": "건설품셈 공통 8-1-7 5" + }, + { "이름": "전력 계약종별", "고르기": ["일반용(갑)Ⅰ 저압", "주택용 저압"], "출처": "유가전력 한전 전기요금표 2025-01-01", "비고": "품셈·계약예규에 계약종별 규정 없음(전력비는 실비 계상) — 기본 = 불러오기 기본값" } + ], + "중간": [ + { "이름": "손료", "식": "버림(EQ:{기계}.가격 * 1000 * EQ:{기계}.손료계수 / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4" }, + { "이름": "연료", "식": "EQ:{기계}.주연료종류", "출처": "건설품셈 공통 8-4 주①㉰" }, + { "이름": "직종", "식": "EQ:{기계}.조종원직종", "출처": "건설품셈 공통 8-1-3 5" }, + { "이름": "시간환산", "식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)" } + ], + "호표": [ + { "종류": "재료", "요소": "MO:{연료}:{지역}", "이름": "주연료", "단위": "ℓ", "수량": "EQ:{기계}.주연료", "비목": "재료비", "출처": "건설품셈 공통 8-4-8" }, + { + "종류": "재료", + "요소": { "이름": "전력량요금", "계약종별": "입력" }, + "이름": "전력", + "단위": "㎾h", + "수량": "EQ:{기계}.전력", + "비목": "재료비", + "출처": "건설품셈 공통 8-1-6 3 가", + "비고": "기계 줄 전력(㎾) 그대로 — 전동기 부하율 규정 없음" + }, + { + "종류": "인력", + "요소": "LB:{직종}", + "이름": "조종원", + "단위": "인", + "수량": "EQ:{기계}.조종원 * 시간환산", + "비목": "노무비", + "출처": "건설품셈 공통 8-1-3 6", + "비고": "하루 노임 × 1/8(1일 8시간 · 건설품셈 공통 1-1-3 2)" + } + ], + "덧줄": [{ "이름": "잡재료비", "식": "재료비 * EQ:{기계}.잡재료율 / 100", "비목": "재료비", "출처": "건설품셈 공통 8-4 주②" }, { "이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2" }], + "끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음", + "변수표": { + "기계": { "기호": "S", "아래": "기계" }, + "지역": { "기호": "S", "아래": "지역" }, + "전력 계약종별": { "기호": "S", "아래": "전력계약종별" }, + "손료": { "기호": "C", "아래": "손료" }, + "연료": { "기호": "S", "아래": "연료" }, + "직종": { "기호": "S", "아래": "직종" }, + "시간환산": { "기호": "Q", "아래": "시간환산" } + } } ] } diff --git a/resources/master_data/수식문서/수식_건설품셈_08장_건설기계.json b/resources/master_data/수식문서/수식_건설품셈_08장_건설기계.json index 59a932359..57886345c 100644 --- a/resources/master_data/수식문서/수식_건설품셈_08장_건설기계.json +++ b/resources/master_data/수식문서/수식_건설품셈_08장_건설기계.json @@ -2352,6 +2352,92 @@ "비목": {"방법": "버림", "자리": 0}, "끝전": "비목버림" } + }, + { + "꼴": "수식문서", + "판": 1, + "키": "GC001200", + "이름": "기계 시간당 기계경비(연료 · 전력 기종)", + "결과단위": "원/시간", + "출처": "건설품셈 공통 8-1-5 · 8-1-6 · 8-1-6 3 가", + "부품": { + "갈래": "기계경비식" + }, + "변수": [ + {"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": [], "값": 0}}, + {"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}}, + {"열쇠": "전력 계약종별", "기호": "S", "아래": "전력계약종별", "위": "", "이름": "전력 계약종별", "단위": "", "출처": "유가전력 한전 전기요금표 2025-01-01", "연결": {"갈래": "선택", "목록": ["일반용(갑)Ⅰ 저압", "주택용 저압"], "값": "일반용(갑)Ⅰ 저압"}}, + {"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}}, + {"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_EQ기계", "기호": "P", "아래": "EQ기계", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_EQ기계2", "기호": "P", "아래": "EQ기계2", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "손료계수", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_EQ기계3", "기호": "P", "아래": "EQ기계3", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_MO연료지역", "기호": "P", "아래": "MO연료지역", "위": "", "이름": "MO:{연료}:{지역}", "단위": "", "연결": {"갈래": "요소", "키": "MO:{S_연료}:{S_지역}", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_EQ기계4", "기호": "P", "아래": "EQ기계4", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "전력", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_전력", "기호": "P", "아래": "전력", "위": "", "이름": "전력단가", "단위": "", "연결": {"갈래": "요소", "재료": {"이름": "전력량요금", "계약종별": "입력"}, "단위": "㎾h", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_EQ기계5", "기호": "P", "아래": "EQ기계5", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "잡재료율", "계약종별": "S_전력계약종별"}} + ], + "식": [ + {"기호": "C_손료", "식": "버림(P_EQ기계 × 1000 × P_EQ기계2 ÷ 10000000, 0)"} + ], + "금액": [ + {"이름": "주연료", "단위": "ℓ", "비목": "재료비", "수량": "P_EQ기계3", "단가": "P_MO연료지역", "출처": "건설품셈 공통 8-4-8"}, + {"이름": "전력", "단위": "㎾h", "비목": "재료비", "수량": "P_EQ기계4", "단가": "P_전력", "출처": "건설품셈 공통 8-1-6 3 가"}, + {"이름": "잡재료비", "단위": "식", "비목": "재료비", "단가": "재료비 × P_EQ기계5 ÷ 100", "출처": "건설품셈 공통 8-4 주②"}, + {"이름": "기계손료", "단위": "식", "비목": "경비", "단가": "C_손료", "출처": "건설품셈 공통 8-1-6 2"} + ], + "끝수": { + "줄": {"방법": "버림", "자리": 1}, + "비목": {"방법": "버림", "자리": 0}, + "끝전": "비목버림" + } + }, + { + "꼴": "수식문서", + "판": 1, + "키": "GC001201", + "이름": "기계 시간당 기계경비(연료 · 전력 · 조종원 기종)", + "결과단위": "원/시간", + "출처": "건설품셈 공통 8-1-5 · 8-1-6 · 8-1-6 3 가", + "부품": { + "갈래": "기계경비식" + }, + "변수": [ + {"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": [], "값": 0}}, + {"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}}, + {"열쇠": "전력 계약종별", "기호": "S", "아래": "전력계약종별", "위": "", "이름": "전력 계약종별", "단위": "", "출처": "유가전력 한전 전기요금표 2025-01-01", "연결": {"갈래": "선택", "목록": ["일반용(갑)Ⅰ 저압", "주택용 저압"], "값": "일반용(갑)Ⅰ 저압"}}, + {"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}}, + {"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류", "계약종별": "S_전력계약종별"}}, + {"열쇠": "직종", "기호": "S", "아래": "직종", "위": "", "이름": "직종", "단위": "", "출처": "건설품셈 공통 8-1-3 5", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원직종", "계약종별": "S_전력계약종별"}}, + {"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)", "연결": {"갈래": "식"}}, + {"열쇠": "P_EQ기계", "기호": "P", "아래": "EQ기계", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_EQ기계2", "기호": "P", "아래": "EQ기계2", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "손료계수", "계약종별": "S_전력계약종별"}}, + {"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}}, + {"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}}, + {"열쇠": "P_EQ기계3", "기호": "P", "아래": "EQ기계3", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_MO연료지역", "기호": "P", "아래": "MO연료지역", "위": "", "이름": "MO:{연료}:{지역}", "단위": "", "연결": {"갈래": "요소", "키": "MO:{S_연료}:{S_지역}", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_EQ기계4", "기호": "P", "아래": "EQ기계4", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "전력", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_전력", "기호": "P", "아래": "전력", "위": "", "이름": "전력단가", "단위": "", "연결": {"갈래": "요소", "재료": {"이름": "전력량요금", "계약종별": "입력"}, "단위": "㎾h", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_EQ기계5", "기호": "P", "아래": "EQ기계5", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_LB직종", "기호": "P", "아래": "LB직종", "위": "", "이름": "LB:{직종}", "단위": "", "연결": {"갈래": "요소", "키": "LB:{S_직종}", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_EQ기계6", "기호": "P", "아래": "EQ기계6", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "잡재료율", "계약종별": "S_전력계약종별"}} + ], + "식": [ + {"기호": "C_손료", "식": "버림(P_EQ기계 × 1000 × P_EQ기계2 ÷ 10000000, 0)"}, + {"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표"} + ], + "금액": [ + {"이름": "주연료", "단위": "ℓ", "비목": "재료비", "수량": "P_EQ기계3", "단가": "P_MO연료지역", "출처": "건설품셈 공통 8-4-8"}, + {"이름": "전력", "단위": "㎾h", "비목": "재료비", "수량": "P_EQ기계4", "단가": "P_전력", "출처": "건설품셈 공통 8-1-6 3 가"}, + {"이름": "조종원", "단위": "인", "비목": "노무비", "수량": "P_EQ기계5 × Q_시간환산", "단가": "P_LB직종", "출처": "건설품셈 공통 8-1-3 6"}, + {"이름": "잡재료비", "단위": "식", "비목": "재료비", "단가": "재료비 × P_EQ기계6 ÷ 100", "출처": "건설품셈 공통 8-4 주②"}, + {"이름": "기계손료", "단위": "식", "비목": "경비", "단가": "C_손료", "출처": "건설품셈 공통 8-1-6 2"} + ], + "끝수": { + "줄": {"방법": "버림", "자리": 1}, + "비목": {"방법": "버림", "자리": 0}, + "끝전": "비목버림" + } } ] } diff --git a/resources/tester/helper_823_1_swap.cjs b/resources/tester/helper_823_1_swap.cjs new file mode 100644 index 000000000..9b7b9efc9 --- /dev/null +++ b/resources/tester/helper_823_1_swap.cjs @@ -0,0 +1,34 @@ +/* 823-1 헬퍼 — common_util_line_stack_pick.ts(repick · unswap · swappedCount) · 가져온 식 보기(importView)를 + * 그 자리에서 트랜스파일해 Node 로 돌림. stdin = {문서, 걸음 [{고름: {i, id, kind, p}} | {되돌림: {i, id}}]} + * stdout = 걸음마다 {문서, 엑셀보기, 바꾼줄} */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const cache = new Map(); +function load(at) { + if (cache.has(at)) return cache.get(at); + const js = ts.transpileModule(fs.readFileSync(at, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(at, box.exports); + const need = (p) => + p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} +const util = (name) => load(path.join(__dirname, "..", "..", "common_util", `${name}.ts`)); +const pick = util("common_util_line_stack_pick"); +const imp = util("common_util_formula_doc_import"); + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const doc = input.문서; +const out = []; +for (const step of input.걸음) { + if (step.고름) pick.repick(doc, step.고름.i, step.고름.id, step.고름.kind, step.고름.p); + if (step.되돌림) pick.unswap(doc, step.되돌림.i, step.되돌림.id); + const now = JSON.parse(JSON.stringify(doc)); + out.push({ 문서: now, 엑셀보기: imp.importView(now, "엑셀"), 바꾼줄: pick.swappedCount(now) }); +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index 4113dbad9..6208af505 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -34,8 +34,11 @@ for (const file of [ "native-verify.js", "native-block-actions.js", "native-extra.js", + "native-applied-cost.js", + "native-progress-view.js", "native-business-actions.js", "native-forms.js", + "native-excel-to-est.js", ]) { vm.runInContext(fs.readFileSync(path.join(base, file), "utf8"), context, { filename: file }); } diff --git a/resources/tester/test_821_4_import_my_machine.py b/resources/tester/test_821_4_import_my_machine.py index 5b0910e21..21441b831 100644 --- a/resources/tester/test_821_4_import_my_machine.py +++ b/resources/tester/test_821_4_import_my_machine.py @@ -206,7 +206,9 @@ def test_cleanup_820_1() -> None: assert "자체 발전기" not in text and "자체 콘크리트펌프용" not in text led = json.loads((ROOT / "resources/master_data/_키대장.json").read_text("utf-8")) assert all(k in led["폐기"] and k not in led["키"] for k in [*GONE, "GC001195"]) - assert led["다음"]["EQ"] == 669 and led["다음"]["GC"] == 1196 # 결번 · 재사용 안 함 + for pre in ("EQ", "GC"): # 결번 · 재사용 안 함 — 다음 번호가 쓴 · 버린 번호 모두보다 뒤 + used = [int(k[2:]) for k in [*led["키"], *led["폐기"]] if k.startswith(pre)] + assert led["다음"][pre] > max(used) alias = [r for r in eq.values() if r.get("별칭")] assert len(alias) >= 10 diff --git a/resources/tester/test_822_2_machine_hourly_all.py b/resources/tester/test_822_2_machine_hourly_all.py index 6194dbbbe..a728bafc2 100644 --- a/resources/tester/test_822_2_machine_hourly_all.py +++ b/resources/tester/test_822_2_machine_hourly_all.py @@ -4,7 +4,7 @@ ① 모든 기계 = 식 있음 또는 까닭 글(「셀 자료가 없음」 0) ② 식 고름 = 목록 › 꼴(채운 칸) 한 함수 ③ M01 [새 기계] 저장 = 꼴 식 목록 등록 · 지우면 목록에서 뺌 · 로직 파일 글 그대로(임시 마스터) ④ 직접 값 식(GC001199) — 내 기계 「직접」 시간당 · 줄 쌓기 식 ⑤ 전력 줄 — 점보드릴 135㎾ × 전력 단가 + -잡재료 6 % + 손료 + 조종원 · 계약종별 바꿈 · 내 기계 전력 ⑥ 옮긴 기계 견본(아스팔트 믹싱플랜트 · 타워크레인 · +잡재료 6 % + 손료 + 조종원 · 계약종별 바꿈 · 내 기계 전력 · 연료 + 전력(822-2b GC001200 · + 조종원 GC001201) ⑥ 옮긴 기계 견본(아스팔트 믹싱플랜트 · 타워크레인 · 직종 입력 = 아스팔트 스프레어) ⑦ 미리보기 = 서버 값 ⑧ 안 옮긴 기계 금액 그대로 ⑨ POWER TROWEL 이름. """ @@ -36,6 +36,7 @@ import master_machine_coef as mc # noqa: E402 MACHINE, LOGIC = "기계.json", "로직_건설품셈_08장_건설기계.json" POWER, POWER_CREW, ONE_FUEL, DIRECT = "GC001196", "GC001197", "GC001198", "GC001199" +FUEL_POWER, FUEL_POWER_CREW = "GC001200", "GC001201" NAME = "sub1임시기계822" # 안 옮긴 기계 — 822-2 전 금액(전국평균 · 기본 옵션) KEEP = {"EQ000001": 68550, "EQ000022": 108958} @@ -87,7 +88,10 @@ def test_pick_listed_then_shape(): assert shape({"값": {**full, "전력": 5, "조종원": 1, "조종원직종": "1048"}}) == POWER_CREW assert shape({"값": full}) == "GC000273" assert shape({"방식": "직접", "값": {}}) == DIRECT - assert shape({"값": {**full, "주연료": 1, "전력": 5}}) is None + assert shape({"값": {**full, "주연료": 1, "전력": 5}}) == FUEL_POWER + both = {**full, "주연료": 1, "전력": 5, "조종원": 1} + assert shape({"값": {**both, "조종원직종": "1048"}}) == FUEL_POWER_CREW + assert shape({"값": both}) is None # 직종 없는 조종원 — 까닭 assert shape({"값": {**full, "조종원": 1}}) is None # 직종 없는 조종원만 — 까닭 assert shape({"값": {**full, "주연료": "73.7+"}}) is None @@ -138,7 +142,9 @@ def test_new_machine_registers_and_unregisters(copy: Path): _save(client, [{"op": "delete", "key": key}]) assert "9999-0822" not in _codes("GC000268") assert (copy / LOGIC).read_text(encoding="utf-8") == logic_text - _save(client, [{"op": "add", "row": {**row, "원문번호": ""}}]) # 빈 원문번호 = 「자체 EQ…」 받을 키 + _save( + client, [{"op": "add", "row": {**row, "원문번호": ""}}] + ) # 빈 원문번호 = 「자체 EQ…」 받을 키 key = max(k for k, r in _rows().items() if r["이름"] == NAME) assert _rows()[key]["원문번호"] == f"자체 {key}" and f"자체 {key}" in _codes("GC000268") _save(client, [{"op": "delete", "key": key}]) @@ -213,8 +219,18 @@ def test_power_my_machine(world): assert hour["로직"] == POWER want = mc.preview({"가격": 1000, "손료계수": 2000, "전력": 10, "잡재료율": 5}, 0, 0, 114.4) assert (hour["재료비"], hour["경비"]) == (want["재료비"], want["경비"]) == (1201, 200) - with pytest.raises(store.StoreError): - _at(me, mym.create, {**body, "값": {**body["값"], "주연료": 1, "주연료종류": "경유"}}) + # 822-2b 연료 + 전력 둘 다(경쟁사 파워트로웰 꼴) — 손료 + 주연료 + 잡품(둘의 %) + 전력 = 미리보기 + vals = {**body["값"], "주연료": 1, "주연료종류": "휘발유"} + both = _at(me, mym.create, {**body, "규격": "연료전력", "값": vals})["시간당"] + oil = float(m01.loaded()[1].numbers["MO"]["휘발유:전국평균"]["값"]) + want = mc.preview(vals, oil, 0, 114.4) + assert both["로직"] == FUEL_POWER and want["주연료"] > 0 and want["전력"] > 0 + assert (both["재료비"], both["경비"], both["계"]) == (want["재료비"], want["경비"], want["계"]) + vals |= {"조종원": 1, "조종원직종": "1048"} # + 조종원 = GC001201 + crew = _at(me, mym.create, {**body, "규격": "연료전력조종원", "값": vals})["시간당"] + want = mc.preview(vals, oil, float(_wage("1048")), 114.4) + assert crew["로직"] == FUEL_POWER_CREW and crew["노무비"] == want["노무비"] > 0 + assert (crew["재료비"], crew["경비"], crew["계"]) == (want["재료비"], want["경비"], want["계"]) # ⑥ · ⑦ 옮긴 기계 견본 ───────────────────────────────────────────────── diff --git a/resources/tester/test_823_1_imported_swap.py b/resources/tester/test_823_1_imported_swap.py new file mode 100644 index 000000000..3704b9561 --- /dev/null +++ b/resources/tester/test_823_1_imported_swap.py @@ -0,0 +1,189 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""823-1 불러온 단가산출 바꿔 끼움(요구 800-1 · 801-1) — 임시 storage. + +엑셀 단가 기준에서 이은 기계 줄을 다른 기계로 = 그 줄만 우리 값(엑셀 값 뗌 · `바꿈` = 원래) · +다른 줄 엑셀 값 그대로 · 되돌리기 · 원래 것 다시 고름 = 되돌림 · 내 기계로 · +저장 뒤 로직 사본에 박힘 · 「바꾼 줄 n」 · 인력 바꿈 같은 길 · +못 이은 기계 고름(812-9) = 엑셀 값 그대로 · 옛 칸 지문 24554.0 = 24554. +""" + +from __future__ import annotations + +import copy +import json +import os +import subprocess + +from M02_MasterTemplete import M02_Doc_Copy as dc +from M02_MasterTemplete import M02_Doc_Import as dim +from M02_MasterTemplete import M02_Doc_Sk as dsk +from M02_MasterTemplete import M02_Doc_Sk_Path as path +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MyMachine as mym +from M02_MasterTemplete import M02_Sangeun_Imported as imp +from resources.tester.test_822_1_copy_gap import _move +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +HERE = os.path.dirname(__file__) +DIG = "GC000267" # 굴착기 시간당 경비 +NOW, NEW, NEW_EQ = "'0201-0070'", "'0201-0100'", "EQ000022" # 0.7㎥ → 1.0㎥ +XL = {"노무비": 59020, "재료비": 19473, "경비": 24554} +MAN, MAN2 = "LB000002", "LB000001" # 보통인부 → 다른 직종 + + +def _at(folder, fn, *args): + with store.at(folder): + return fn(*args) + + +def _ts(doc: dict, steps: list[dict]) -> list[dict]: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_823_1_swap.cjs")], + input=json.dumps( + dc.ml._norm({"문서": doc, "걸음": steps}), ensure_ascii=False, default=str + ), + capture_output=True, + text=True, + encoding="utf-8", + ) + assert proc.returncode == 0, proc.stderr[-3000:] + return json.loads(proc.stdout) + + +def _made(folder) -> tuple[str, dict]: + """가져온 식 SK — 기계 줄(GC000267 0.7㎥ · 엑셀 묶음) + 인력 줄(보통인부 · 엑셀 단가).""" + mach = { + "종류": "로직", "이름": "굴착기(무한궤도)", "규격": "0.7㎥", "단위": "hr", "수량": "0.1", + "값묶음": XL, "이음": f"로직({DIG}, 기계={NOW}, 지역='전국평균', 보정작업='해당 없음')", + } # fmt: skip + man = {"종류": "인력", "이름": "보통인부", "단위": "인", "수량": "0.5", "비목": "노무비", + "단가": 165545, "이음": MAN} # fmt: skip + row = {"이름": "sub3임시 바꿈", "결과단위": "원/㎥", "호표": [mach, man]} + key = _at(folder, lambda: imp.create(row, None, "sub3임시 바꿈")["이름"]) + return key, _at(folder, path.read, key) + + +def _vars(doc: dict) -> dict: + return {dc.md.var_id(v): v for v in doc["변수"]} + + +def _sym(doc: dict, kind: str) -> tuple[int, str]: + """(금액 줄 차례, 단가 기호) — 기계 = 단가산출 연결 · 인력 = 요소 연결.""" + by = _vars(doc) + for i, line in enumerate(doc["금액"]): + var = by.get(str(line.get("단가"))) + if var and (var["연결"].get("갈래") == ("단가산출" if kind == "기계" else "요소")): + return i, dc.md.var_id(var) + raise AssertionError(kind) + + +def _lines(ans: dict) -> list: + return [None if x.get("금액") is None else float(x["금액"]) for x in ans.get("줄") or []] + + +def _pick(i, sym, kind, key, name, spec="", **given): + p = {"키": key, "이름": name, **({"규격": spec} if spec else {})} + p |= {"입력": given} if given else {} + return {"고름": {"i": i, "id": sym, "kind": kind, "p": p}} + + +def test_swap_machine_excel_basis(world, monkeypatch): + """기계 0.7 → 1.0㎥ = 그 줄 우리 값 · 인력 줄 엑셀 그대로 · TS 보기 = 서버 보기 · + 되돌리기 · 원래 것 다시 고름.""" + me = world["personal"] + key, got = _made(me) + doc, was = got["문서"], got["셈"] + i, sym = _sym(doc, "기계") + j, man = _sym(doc, "인력") + steps = _ts( + doc, + [ + _pick(i, sym, "기계", DIG, "굴착기(무한궤도)", "1.0㎥", 기계=NEW), + {"되돌림": {"i": i, "id": sym}}, + _pick(i, sym, "기계", DIG, "굴착기(무한궤도)", "1.0㎥", 기계=NEW), + _pick(i, sym, "기계", DIG, "굴착기(무한궤도)", "0.7㎥", 기계=NOW), + ], + ) + swapped = steps[0]["문서"] + link = _vars(swapped)[sym]["연결"] + assert steps[0]["바꾼줄"] == 1 and "엑셀" not in link and link["입력"]["기계"] == NEW + assert link["바꿈"]["연결"]["엑셀"] == XL and link["바꿈"]["규격"] == "0.7㎥" + # TS 엑셀 보기 = 서버 보기(짝) — 바꾼 줄 = 연결 · 인력 = 숫자 + assert dc.ml._norm(steps[0]["엑셀보기"]) == dc.ml._norm(dim.view(swapped, "엑셀")) + assert _vars(steps[0]["엑셀보기"])[man]["연결"]["갈래"] == "숫자" + # 서버 미리 셈 — 기계 줄 = 우리 값(1.0㎥) · 인력 줄 엑셀 그대로 + look = _at(me, path.preview, key, {"문서": swapped})["셈"] + sk = _at(me, lambda: store.read("sangeun", key)["문서"]) + ours = dim.run(swapped, {**sk, "가져온": {**sk["가져온"], "단가기준": "우리"}}) + assert _lines(look)[i] == _lines(ours)[i] != _lines(was)[i] + assert _lines(look)[j] == _lines(was)[j] + assert look["계"] != was["계"] + # 되돌리기 · 원래 것 다시 고름 = 처음 문서 그대로 + for at in (1, 3): + assert steps[at]["바꾼줄"] == 0 + assert dc.ml._norm(steps[at]["문서"]) == dc.ml._norm(doc) + # 저장 → 다시 읽음 그대로 · 바꾼 기계 줄이 로직 사본에 · M01 가격 바뀌어도 그대로 + 판 = _at(me, path.read, key)["판"] + _at(me, path.save, key, {"문서": swapped, "판": 판}) + again = _at(me, path.read, key) + assert again["셈"]["계"] == look["계"] and dim.swapped(again["문서"]) == 1 + kept = _at(me, lambda: store.read("sangeun", key)["문서"]["사본"]) + assert NEW_EQ in {r["키"] for r in [*kept["로직"], *kept["단가"]]} + _move(monkeypatch, NEW_EQ) + assert _at(me, path.read, key)["셈"]["계"] == look["계"] + + +def test_swap_to_my_machine(world): + """내 기계로 바꿔 끼움 = 그 내 기계 시간당 값 · 저장 뒤 사본에.""" + me = world["personal"] + made = _at(me, mym.create, {"이름": "sub3임시기계", "규격": "1㎾", "방식": "바로", + "값": {"가격": 1000, "손료계수": 2000}}) # fmt: skip + row = made["줄"] + key, got = _made(me) + doc = got["문서"] + i, sym = _sym(doc, "기계") + arg = f"'{row['원문번호']}'" + steps = _ts(doc, [_pick(i, sym, "기계", mym.logic_key(row), row["이름"], 기계=arg)]) + swapped = steps[0]["문서"] + assert steps[0]["바꾼줄"] == 1 + look = _at(me, path.preview, key, {"문서": swapped})["셈"] + hour = made["시간당"]["계"] + assert abs(_lines(look)[i] - hour * 0.1) < 1 # 수량 0.1 × 시간당 + 판 = _at(me, path.read, key)["판"] + _at(me, path.save, key, {"문서": swapped, "판": 판}) + kept = _at(me, lambda: store.read("sangeun", key)["문서"]["사본"]) + assert row["키"] in {r["키"] for r in [*kept["로직"], *kept["단가"]]} + + +def test_swap_labor_same_way(world): + """인력 직종 바꿔 끼움 = 같은 길(바꿈 · 우리 값 · 되돌리기).""" + me = world["personal"] + key, got = _made(me) + doc, was = got["문서"], got["셈"] + j, man = _sym(doc, "인력") + steps = _ts(doc, [_pick(j, man, "인력", MAN2, "다른 직종"), {"되돌림": {"i": j, "id": man}}]) + link = _vars(steps[0]["문서"])[man]["연결"] + assert steps[0]["바꾼줄"] == 1 and "엑셀" not in link and link["바꿈"]["연결"]["키"] == MAN + look = _at(me, path.preview, key, {"문서": steps[0]["문서"]})["셈"] + assert _lines(look)[j] != _lines(was)[j] + assert dc.ml._norm(steps[1]["문서"]) == dc.ml._norm(doc) + + +def test_unlinked_machine_pick_keeps_excel(world): + """못 이은 가져온 기계 줄(숫자 연결) 고름 = 이음(812-9) — 엑셀 값 그대로 · 바꿈 아님.""" + me = world["personal"] + _, got = _made(me) + doc = copy.deepcopy(got["문서"]) + i, sym = _sym(doc, "기계") + _vars(doc)[sym]["연결"] = {"갈래": "숫자", "값": XL} + steps = _ts(doc, [_pick(i, sym, "기계", DIG, "굴착기(무한궤도)", "0.7㎥", 기계=NOW)]) + link = _vars(steps[0]["문서"])[sym]["연결"] + assert steps[0]["바꾼줄"] == 0 and link["엑셀"] == XL and "바꿈" not in link + + +def test_old_fp_float_roundtrip(): + """파일 24554.0 · 화면 왕복 24554 = 같은 옛 칸 지문(페이지 [저장] 409 막음).""" + one = {"가져온": {"로직": {"호표": [{"자원": {"단가": {"S": 24554.0, "q": 0.5}}}]}}} + two = {"가져온": {"로직": {"호표": [{"자원": {"단가": {"S": 24554, "q": 0.5}}}]}}} + one["가져온"]["단가기준"] = two["가져온"]["단가기준"] = "엑셀" + assert dsk.old_fp(one) == dsk.old_fp(two) diff --git a/resources/tester/test_est_accom_option.py b/resources/tester/test_est_accom_option.py new file mode 100644 index 000000000..10c919faa --- /dev/null +++ b/resources/tester/test_est_accom_option.py @@ -0,0 +1,86 @@ +"""기성 환경설정: 옵션 기본값·비목 열 순서·표 머리 반영.""" + +from test_est_rea_parity import run + + +def test_defaults_and_part_order(): + result = run( + "math", + """(()=>{ + const d=EstProgressView.get({}); + const o=(x)=>EstProgressView.parts(EstProgressView.get({Display:{order:x.order},AccomOption:x})); + return {code:d.code,sajung:d.sajung,gubun:d.gubun, + a:o({order:0}),b:o({order:2,siljuk:true}),c:o({order:3}),d:o({order:5})}; + })()""", + ) + assert result == { + "code": True, + "sajung": True, + "gubun": False, + "a": ["T", "N", "G", "J"], + "b": ["N", "G", "J", "S", "T"], + "c": ["G", "N", "J", "T"], + "d": ["T", "N", "G", "J"], + } + + +def test_view_headers_follow_options_and_zero_hiding(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{Code:'A',Name:'a',GN:10,GU:100},{LinkIndex:54}]; + const q=EstNative.calculate(p);q.progress=[{id:'r1',name:'1회',inputs:{},locked:false}]; + const g=globalThis;g.EstApp={...(g.EstApp||{}),esc:String,number:String}; + g.EstProgressWonga={render:()=>''}; + const box=(t,tools,body)=>body,button=()=>'',table=(h,rows)=>JSON.stringify({h,rows}); + const st={progressPage:0,selection:new Set()}; + const head=(o)=>{q.options.Display=o.d;q.options.AccomOption=o.a; + return JSON.parse(EstProgressView.view({...st},q,{box,button,table}).split('
{ + const rows=[{Index:2,Index_:1,Name:'b',Name_:'a',Size:'s',Size_:' ',GN:5,GN_:3,GU:10,GU_:8,GC:50,GC_:24}, + {Name:'c',GN:1,GU:4}]; + const o=EstProgress.originRows(rows); + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{Code:'A',Name:'a',GN:10,GN_:4,GU:100,GU_:100},{LinkIndex:54}]; + const q=EstNative.calculate(p); + q.progress=[{id:'r1',name:'1회',inputs:{},locked:false,loadMethod:'origin'}, + {id:'r2',name:'2회',inputs:{},locked:false}]; + const a=EstProgress.calculate(q,'r1').rows[0].GN; + q.progress=[q.progress[1]]; + const b=EstProgress.calculate(q,'r2').rows[0].GN; + return {o:o.map(r=>[r.Index,r.Name,r.Size,r.GN,r.GU,r.GC]),a,b}; + })()""", + ) + assert result == { + "o": [[1, "a", "s", 3, 8, 24], [None, "c", None, 1, 4, None]], + "a": 4, + "b": 10, + } diff --git a/resources/tester/test_est_applied_cost.py b/resources/tester/test_est_applied_cost.py new file mode 100644 index 000000000..9e26b5e68 --- /dev/null +++ b/resources/tester/test_est_applied_cost.py @@ -0,0 +1,86 @@ +"""적용대가 분류 내역서: 수량불러오기(위치 짝)·적용대가집계표·보기 변경.""" + +from test_est_rea_parity import run + + +def test_gn_load_copies_quantity_by_position_and_wonga_cost(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{Code:'A',GN:10,GU:80},{Code:'B',GN:20,GU:50},{Code:'C',GN:5,GU:10}]; + p.tables.Wonga=[{Name:'재료비',Cost:0},{Name:'노무비',Cost:0}]; + const src={tables:{EstMainDat:[{GN:3},{GN:7}],Wonga:[{Cost:111},{Cost:222},{Cost:333}]}}; + const n=EstAppliedCost.load(p,3,src); + const q=EstBusiness.baseline(EstNative.calculate(structuredClone(p))); + EstAppliedCost.load(q,0,{tables:{EstMainDat:[{GN:9}],Wonga:[{Cost:500}]}}); + const r=structuredClone(p);delete r.baseline; + EstAppliedCost.load(r,0,{tables:{EstMainDat:[{GN:4}]}}); + return {n,actual:p.tables.EstMainDat.map(x=>x.EscActual??null), + work:p.tables.Wonga.map(x=>x.EsWorkCost),base:q.baseline.tables.EstMainDat[0].GN, + contract:q.tables.Wonga[0].EsContractCost,old:r.tables.EstMainDat[0].GN_}; + })()""", + ) + assert result == { + "n": 2, + "actual": [3, 7, None], + "work": [111, 222], + "base": 9, + "contract": 500, + "old": 4, + } + + +def test_summary_uses_loaded_contract_cost_or_contract_calculation(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false;p.options.EscDesignTotal=1000; + p.tables.EstMainDat=[{Code:'A',Name:'a',GN:10,GU:100},{LinkIndex:54}]; + p.tables.Wonga=[{Name:'재료비',VSusic:'',BaseField:'GC',LinkIndex:1}]; + const q=EstBusiness.baseline(EstNative.calculate(p)); + q.tables.EstMainDat[0].EscActual=4; + const a=EstAppliedCost.summary(q).map(x=>[x.contract,x.except,x.apply]); + q.tables.Wonga[0].EsContractCost=2000; + const b=EstAppliedCost.summary(q).map(x=>[x.contract,x.except,x.apply]); + return {a,b}; + })()""", + ) + contract, except_, apply = result["a"][0] + assert apply == contract - except_ + assert result["b"][0][0] == 2000 + assert result["b"][0][2] == 2000 - result["b"][0][1] + + +def test_style_toggle_shows_quantity_band_only(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{Code:'A',Name:'a',GN:10,GU:100,EscPS:2}]; + const q=EstBusiness.baseline(EstNative.calculate(p)); + const state={project:q,view:'escalation',escalationPage:0,selection:new Set()}; + const box=(t,tools,body)=>tools+body,button=(a,t)=>``, + table=(h,rows)=>JSON.stringify({h,rows}); + const g=globalThis;g.EstApp={...(g.EstApp||{}),esc:String,number:String,render(){}};g.EstViews={cell:(t,i,k,v)=>String(v)}; + const plain=EstAppliedCost.view(state,q,{box,button,table}); + EstAppliedCost.handle('applied-style-toggle',state); + const band=EstAppliedCost.view(state,q,{box,button,table}); + const head=(s)=>JSON.parse(s.slice(s.indexOf('{"h"'))).h; + return {plain:head(plain).length,band:head(band)}; + })()""", + ) + assert result["plain"] == 18 + assert result["band"] == [ + "코드", + "품명", + "계약 수량", + "시공중 수량", + "예정공정 수량", + "실행공정 수량", + "기성 수량", + "PS 수량", + "선시공 수량", + "적용제외", + "적용수량", + ] diff --git a/resources/tester/test_est_excel_to_est.py b/resources/tester/test_est_excel_to_est.py new file mode 100644 index 000000000..bf4874a95 --- /dev/null +++ b/resources/tester/test_est_excel_to_est.py @@ -0,0 +1,36 @@ +"""EST 테스트 앱 엑셀데이타를 EST로 변환 7개 탭(원본 TfrmExcelToEst — 셀 규칙·머리 줄 꼴·호표 만들기).""" + +from test_est_rea_parity import run + + +def test_convert_tabs_and_heads(): + result = run( + "math", + r"""(()=>{ + const X=EstExcelToEst; + const sheets={노임단가:[['L1',' 보통인부 ','','M2',172698,'']], + 견적단가:[['','.펌프','','1','EA','1,500','500','1,000','0','']], + 표준시장단가:[['C1','포장','t=5','m2',1000,300,600,100,0.3,'포함',''],], + 단가산출:[['','산근 1 호표 : 거푸집 합판 (1 ㎡ 당)',100,60,40,0],['','합판 1*2',40,0,40,0],['','#.산근2|철근가공|D10|ton',9,9,0,0]], + 일위대가:[['','No 5 호표 터파기','','','',0,10,0,10,0,0,0,0,''],['','인부','','0.1','인',100,10,100,10,0,0,0,0,''],['','[합계]','','','',0,10,0,0,0,0,0,0,'']]}; + const cols=(n)=>Array.from({length:n},(_,i)=>String.fromCharCode(65+i)); + const tabs={LaborcDat:{sheet:'노임단가',start:1,end:1,columns:cols(6)},EstiMateDat:{sheet:'견적단가',start:1,end:1,columns:cols(10)}, + SilJukDat:{sheet:'표준시장단가',start:1,end:1,columns:cols(11)},CostMainDat:{sheet:'단가산출',start:1,end:3,columns:cols(6)}, + HeadMainDat:{sheet:'일위대가',start:1,end:3,columns:cols(14)},StuffcDat:{sheet:'없는시트',start:1,end:1,columns:cols(18)}}; + const r=X.convert(sheets,tabs,{name:'시험',wonga:[]}), t=r.project.tables; + return {failed:r.failed, lab:[t.LaborcDat[0].Name,t.LaborcDat[0].Unite,t.LaborcDat[0].TC], + est:[t.EstiMateDat[0].Name,t.EstiMateDat[0].TC,t.EstiMateDat[0].OTC,t.EstiMateDat[0].Code.slice(0,1)], + sil:[t.SilJukDat[0].NCRatio,t.SilJukDat[0].Cost1,t.SilJukDat[0].ONC], + cost:t.CostTitleDat.map(x=>[x.Name,x.Size,x.Unite]), line:t.CostMainDat[1].LineData, + head:[t.HeadTitleDat.map(x=>x.Name),t.HeadMainDat.length], rows:t.EstMainDat.length, auto:r.project.options.AutoCalc}; + })()""", + ) + assert result["failed"] == ["없는시트 시트가 없습니다."] + assert result["lab"] == ["보통인부", "㎡", 172698] + assert result["est"] == ["펌프", 1500, 1500, "N"] + assert result["sil"] == [30, 1000, 300] + assert result["cost"] == [["거푸집", "합판", "㎡"], ["철근가공", "D10", "ton"]] + assert result["line"] == "합판 1×2" + assert result["head"] == [["터파기"], 2] + assert result["rows"] == 36 + assert result["auto"] is False diff --git a/resources/tester/test_est_price_tools.py b/resources/tester/test_est_price_tools.py new file mode 100644 index 000000000..45cfbca5b --- /dev/null +++ b/resources/tester/test_est_price_tools.py @@ -0,0 +1,61 @@ +"""EST 테스트 앱 단가 도구(원본 중복 항목 정리 · 단가정의 엑셀 · 할증/낙찰/복원 · 항목선택계산 SUM 줄).""" + +import json +import subprocess +from pathlib import Path + +from test_est_rea_parity import run + +ROOT = Path(__file__).resolve().parents[2] + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone }}); + vm.runInContext(fs.readFileSync('tools/est-test/native-price-tools.js', 'utf8'), ctx); + process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx))); + """ + return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8")) + + +def test_price_tools_rules(): + out = run_js( + r"""(() => { + const P = EstPriceTools; + const same = P.samecheck([{ Name: '철 근', Size: 'D10', Unite: 'ton', TC: 5 }, { Name: '철근', Size: 'D10', Unite: 'ton', TC: 9 }, + { Name: '철근|', Size: 'D10', Unite: 'ton', TC: 9 }, { Name: '모래', TC: 1 }]); + const g = P.gungu([{ Code: 'C1', Index: 3, Name: '포장', Size: 't=5', Unite: 'm2', EXPLEAN: '포함: 재료\n제외: 운반' }, { Code: 'C2', Name: '빈' }]); + const rows = [{ Name: 'a', TC: 100 }, { Name: 'b', TC: 200, OTC: 210 }, { Name: '', TC: 0 }]; + const n = P.setRate(rows, [], 'NachalRatio', 88); + const one = [{ Name: 'x', TC: 1 }, { Name: 'y', TC: 2 }]; P.setRate(one, [1], 'Ratio', 5); + const r = { Name: '', Size: '', LinkIndex: 0 }; + P.sumRow('HeadMainDat', r, { name: '기구손료', size: '', rate: 2, base: 2, target: 3, picked: [1, 3] }); + const errs = []; + for (const [row, o] of [[{ Index: 1 }, {}], [{}, { base: 0, target: 2, picked: [1] }], [{}, { base: 1, target: 1, picked: [] }], + [{}, { base: 1, target: 1, picked: [1, 2, 3, 4, 5, 6] }]]) + try { P.sumRow('HeadMainDat', row, { name: '', size: '', rate: 1, ...o }); } catch (e) { errs.push(e.message.slice(0, 8)); } + return { same: [same.removed, same.rows.map((x) => [x.Name, x.TC])], g, n, rows: rows.map((x) => [x.NachalRatio ?? null, x.OTC ?? null]), + one: one.map((x) => x.Ratio ?? null), r: [r.Code, r.LinkIndex, r.GN, r.Biul, r.Unite], errs }; + })()""" + ) + assert out["same"] == [2, [["철근|", 9], ["모래", 1]]] + assert out["g"] == [["C1", "3. 포장 (t=5) [m2]"], ["", "포함: 재료"], ["", "제외: 운반"], ["", ""]] + assert out["n"] == 3 + assert out["rows"] == [[88, 100], [88, 210], [None, None]] + assert out["one"] == [5, 5] + assert out["r"] == ["A0103", 62, 2, 0.02, "%"] + assert out["errs"] == ["현재 라인에는 ", "비목별항목은 비", "더할항목의 색인", "더할항목의 색인"] + + +def test_sum_row_code_numbers_select_groups(): + result = run( + "math", + """(()=>{ + const rows=[{Index:1,Code:'H'},{Code:'a',GN:1,NC:100,NU:100,SKind:1},{Code:'b',GN:1,NC:50,NU:50,SKind:3}, + {Code:'c',GN:1,NC:7,NU:7,SKind:2},{Code:'A0103',LinkIndex:62,GN:10,OCheck:2,SCheck:2}]; + const out=EstRows.head(rows,{HeadDigits:0}); + return out.rows?out.rows[4].NC:out[4].NC; + })()""", + ) + assert result == 15 diff --git a/resources/tester/test_est_print_format.py b/resources/tester/test_est_print_format.py new file mode 100644 index 000000000..f0c9bedce --- /dev/null +++ b/resources/tester/test_est_print_format.py @@ -0,0 +1,34 @@ +"""EST 테스트 앱 인쇄양식 파일(.pfot — 원본 mnuPrintFormatSave·Load) 읽기·쓰기.""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +PFOT = Path("C:/Dasansoft/Est Plus 2/System/EstFormat") + + +def test_pfot_round_trip_is_byte_identical_and_edits_apply(): + if not (PFOT / "main.pfot").exists(): + pytest.skip("installed EST print formats unavailable") + code = r""" + const P = require(process.cwd() + '/tools/est-test/print-format.cjs'), fs = require('fs'); + const out = {}; + for (const f of ['main.pfot', 'main(가로양식).pfot']) { + const b = fs.readFileSync(process.argv[1] + '/' + f), rows = P.readFull(b); + out[f] = [rows.length, P.write(b, rows).equals(b)]; + } + const b = fs.readFileSync(process.argv[1] + '/main.pfot'), rows = P.readFull(b); + rows[1].Name2 = '원가계산서(수정)'; rows[1].Checked = 0; + const again = P.read(P.write(b, rows)); + out.edit = [again[1].Name2, again[1].Checked, again.length]; + process.stdout.write(JSON.stringify(out)); + """ + out = json.loads( + subprocess.check_output(["node", "-e", code, str(PFOT)], cwd=ROOT).decode("utf-8") + ) + assert out["main.pfot"] == [41, True] + assert out["main(가로양식).pfot"][1] is True + assert out["edit"] == ["원가계산서(수정)", 0, 41] diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 673b9f8c7..5438e7a19 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,24 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일). - [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지. - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구. + - [x] 표지·갑지 상세편집·용지모양, 산출 줄 글자모양, 수량산출서 인쇄, 엑셀형 셀 테두리·음영·수량붙여넣기(연결)(spec_pc §5 · spec_pa §1·6·8·9) — 상세편집: 인쇄 모양을 자유 편집해 저장하면 인쇄가 그 모양을 씀(해제 가능), 용지모양은 인쇄 창 용지·여백. 글자모양: 산출 줄 크기·굵게·색. 수량산출서 인쇄: 현재 또는 모두(간지 고르기), 줄·표·그림 그대로. 엑셀형: 셀 테두리·음영, 수량 목록에서 담은 항목을 고른 셀부터 가로/세로·명칭/규격/단위/수량으로(자기 산출서 거부, 수량 칸은 원본 항목 연결 기록). + - 검증완료: 문서·바인딩 시험 4개 통과. ORCA 브라우저에서 갑지 상세편집 제목 고침 → 인쇄 html 반영, 글자모양 18px·굵게·#800000, 인쇄 창 표·간지, 엑셀형 B2·B3 에 2.5·0.19(연결 기록), 샘플 복구. + - [x] 물가변동 적용대가 보기 변경·수량불러오기·적용대가집계표(spec_pb1 §5·§6) — 보기 변경: 단가 칸 숨기고 계약·구분별 수량 7종만. 수량불러오기: 수량분 7종 중 고른 뒤 내역서 파일의 행 i 수량을 이 내역 행 i 구분 칸에(계약분은 계약 확정본, 없으면 당초 칸), 그 파일 원가 행 i 금액을 원가 행 Es*Cost 에. 집계표: 원가 행마다 계약·적용제외·적용대가 금액(계약금액은 불러온 값, 없으면 계약 내역 계산). + - 검증완료: 적용대가 시험 3개 통과. ORCA 브라우저에서 보기 변경 머리 11칸·집계표 31행 확인. + - [x] 기성 환경설정(spec_pb1 §4) — 공통적용(코드·수량 0·금액 0·비목순서·품명정렬·두줄·Enter 뒤 셀 이동·바탕색)은 계산 환경 Display, 기성고(레벨·비고란 발췌근거=비고·집계공종 진하게·사정금액·노재경 구분·실적 비목)는 AccomOption. 칸을 바꾸면 바로 화면에, 취소면 되돌림. 주 메뉴 환경설정(기성 화면)·기성 원가 「환경 설정」·Ctrl+U. 원본도 처리기 없는 자동저장 분·계산 확인·가로 계산방법·재료비 처리·이윤 사사오입은 값만. + - 검증완료: 시험 2개 통과. ORCA 브라우저에서 코드 끔·레벨·노재경 구분·사정 끔·수량 0 숨김 반영과 취소 원복 확인, 임시 회차·옵션 원복. + - [x] 도급내역 선택(spec_pb1 §3) — 설계변경 내역서에서 기성 회차를 추가하면 당초/변경 고르기. 당초면 행마다 당초 칸(_)으로 Index·품명·규격·단위(당초가 비면 지금 값)·수량·단가·금액. 웹은 `_` = 당초(설계변경 시작 때 복사본) 해석을 따름(spec_pb1 은 원본 분기상 `_` = 변경으로 읽음 — 실무 샘플 3건의 `_` 값이 서로 같아 당초 쪽으로 판단). 원본의 GC 누락 결함은 재현하지 않음. + - 검증완료: 시험 1개 통과(당초 수량 4·변경 10). ORCA 브라우저에서 고르기 창·회차 loadMethod 확인, 원복. + - [x] 다른 공사 불러오기 창(원본 barbtnSelfLoad → TfrmOtherLoad, 찾기 popSerch) — 저장된 다른 작업을 골라 같은 표를 띄우고 찾기(품명·규격, A&B 둘 다)·고른 줄을 하위 자료와 함께 가져오기(내역서는 고른 줄 아래에 줄, 나머지는 자료·호표 합침, 같은 코드는 이 공사 것). 검증완료: 바인딩 통과, ORCA 브라우저에서 산불진화임도 작업의 일위대가 3개 → 「취수장」 1개 → 가져오기 호표 8→9·하위 41건, 샘플 복구. + - [x] 사용위치 검색 창(원본 TfrmUsesDataSerch btnSerch·btnAllDisplay) — 현재 표 항목을 품명·규격(빈칸·|·괄호 무시)으로 거르고 골라 사용위치 보기. 검증완료: 바인딩 통과, ORCA 브라우저에서 노임 9개 → 「보 통」 1개 → 보통인부 사용위치 11곳. + - [x] 인쇄양식 불러오기·저장하기(원본 mnuPrintFormatLoad·Save 0x10ed318·0x10ed000 — spec_pc §3) — .pfot(인쇄정보표 MIDAS 이진) 읽기·쓰기 `print-format.cjs`(설명 BLOB·빈 칸 표지까지 보존, 원본 파일 그대로 다시 쓰면 바이트 같음). 불러오면 공사 인쇄표를 그 값으로, 저장은 지금 인쇄표를 원본 설치 main.pfot 필드 설명자 위에(`EST_PFOT_TEMPLATE`). .gfot(cxGrid 열 배치)는 웹 격자와 호환되지 않아 하지 않음. + - 검증완료: `test_est_print_format.py`(설치 .pfot 두 개 바이트 같음·고친 값 반영) 및 바인딩 시험 통과, 서버 경로로 읽고 다시 쓴 파일 cmp 같음. ORCA 브라우저에서 샘플 인쇄양식 저장(32,755 바이트). + - [x] 엑셀데이타를 EST로 변환 7개 탭(원본 TfrmExcelToEst 0x109c3bc — spec_pc §1) — 노임·자재·견적·표준시장·단가산출·일위대가·내역서 탭마다 시트명·시작/끝행·필드별 열 글자(.xlsx·.xls), 노임 → 자재 → 견적 → 표준 → 단산 → 일위 → 내역 차례로 새 공사(현재 원가 서식, 자동계산 끔 — Excel 값 그대로). 셀 규칙(Trim·줄바꿈·앞 「.」·㎡/㎥·「*」→「×」·쉼표·노무비율 ×100·「B:D」 이어붙임), 노임 단가 → Cost1·TC, 견적·표준 → O 칸 함께, 빈 코드 N+10자리, 내역 36행 채움. 단가산출·일위대가 머리 줄 꼴로 호표 만들기(코드·TitleCode·Index·LineData 품명/규격/단위 표지, [합계] 줄 지움), 못 읽은 시트 안내. 하지 않음: 글자 굵기 판정·고정폭 머리말·단가산출 목록 시트형(굵기·폭 정보 없음). + - 검증완료: `test_est_excel_to_est.py`(없는 시트 안내, 노임 ㎡·TC, 견적 앞점·쉼표·O칸·N코드, 표준 노무비율 30, 단산 호표 2개·×, 일위 호표·[합계] 지움, 36행, 자동계산 끔) 통과. ORCA 브라우저에서 탭 7개·단가산출 열 6칸 표시(파일 고르기는 수동). + - [x] 전회기성 불러오기·조달수수료·원가에 내역 금액·원가 구성비(spec_pb1 1·9·10 · spec_pb2 8) — 전회기성: 완료 회차 아무것이나 골라 그 누계를 전회로(현재 회차 거부, `round.junFrom`). 조달수수료 입력(Ctrl+J): 현재 줄을 Code AJJ01·LinkIndex 57·GN %·자리방법 Index·자리수 Gangub·계산 비목 OCheck·저장 칸 SCheck 로(57 줄이면 수정, 허용 검사·원본 문구). 원가계산서에 내역 금액 넣기: 원본은 끌어 놓기 — 웹은 줄 고르기 + 합계/노무/재료/경비 고르기 → 원가 줄 Cost 고정(LinkIndex 1, 덮어쓰기 확인). 구성비 표시: 원가 요율 칸을 「비율」로 바꿔 기준 행(CalcBase 또는 도급액) 대비 대상 비목 Cost 를 0.01% 절사·최대 나머지로 100% 맞춤(계산 불변). + - 검증완료: 기성·바인딩 시험 14개 통과. ORCA 브라우저에서 구성비 20줄 합 100.00%·머리 「비율」, 내역 금액 넣기 노무비 384,804, Ctrl+J 조달수수료 AJJ01·57·0.54%·절하, 샘플 복구. + - [x] 단가 도구(spec_pb2) — 중복 항목 정리(자재단가: 품명·규격·단위 빈칸·| 무시 같으면 TC 가장 큰 행만, 같으면 뒤 행, 묻지 않음), 단가정의 엑셀변환(표준시장단가 설명문 있는 항목만 「코드 / 번호. 품명 (규격) [단위]」+설명 줄, .xlsx), 할증·낙찰(고른 2행 이상 또는 전 행 Ratio·NachalRatio, 원래 단가 OTC 보관, 100 이면 자리 해제)·복원(두 율 0), 바꾸기(Main Data 로 현재 표 하나만 갱신, 못 찾음 표시), SUM(현재 줄을 항목선택계산 62 줄로: Code 'A'+두 자리 색인·적용율·비목, 원본 거부 문구, 줄 자신 SKind = 묶음 최대+1). 엔진: 62 줄 Code 를 두 자리 색인 번호로 읽음(설치 main.estx 의 「A01」 꼴, 예전 글자 해석은 A0103 의 C 를 놓쳤음). + - 검증완료: `test_est_price_tools.py` 2개(중복 2개 지움·단가정의 줄·율 쓰기 범위·SUM Code A0103·거부 4종, 엔진 A0103 → 색인 1·3 합 150×10% = 15) 및 REA 대조 39개 통과. ORCA 브라우저에서 보통인부 낙찰 90% 172,068→154,861, 복원 →172,068, 바꾸기(Main) → 2026 하반기 값, 샘플 복구. - [x] 워드형 수량산출서 표와 편집 도구(원본 TFRMMULWORD·TFRMMULLANG — spec_word · spec_pa) — 가져오기가 WPT
| 영역 | 검사 수 | 일치 |
|---|---|---|
| ${esc(group)} | ${result.results.filter((r) => r.group === group).length} | ${result.results.filter((r) => r.group === group && r.pass).length} |
| 비목 | 약어 | 계약금액 | 적용제외금액 | 적용대가금액 | 산출근거 |
|---|---|---|---|---|---|
| ${esc([row.Bimog1, row.Bimog2, row.Name].filter(Boolean).join(" "))} | ${esc(row.SName ?? "")} | ${number(contract)} | ${number(except)} | ${number(apply)} | ${esc(row.VSusic ?? "")} |
${esc(year(c.Date))}
` : ""}${esc(c.CompanyName)}
${esc(x.file || "")} ${x.sheets ? `(시트 ${Object.keys(x.sheets).length}개)` : ""}
해당 칼럼명칭 입력(엑셀 열 글자, 비우면 건너뜀, 「B:D」 는 이어 붙임)
회차를 추가하세요.
', - tools = - button("add-round", "기성 회차 추가") + - '' + - (project.progress.length ? button("clear-progress", "기성 전체 초기화") : ""); - if (round) { - tools += - `` + - button( - round.locked ? "unlock-round" : "lock-round", - round.locked ? "확정 해제(이후 포함)" : "회차 확정·저장", - ) + - button("fill-progress", "전량·비율 일괄 입력") + - button("rename-round", "회차 이름") + - button("delete-round", "회차 삭제") + - button("reset-round-inputs", "회차 입력 초기화") + - `` + - ``; - let output; - try { - output = - round.locked && round.result ? round.result : EstBusiness.progress(project, round.id); - } catch (e) { - output = { rows: [], errors: [e.message] }; - } - state.business = output; - const page = EstBlockActions.progressPage(state, output.rows); - tools += - button("progress-prev", "이전", state.progressPage === 0 ? "disabled" : "") + - `${state.progressPage + 1} / ${page.pages}쪽 · ${page.total}행` + - button("progress-next", "다음", state.progressPage + 1 >= page.pages ? "disabled" : ""); - body = - table( - [ - "코드", - "공종", - "도급수량", - "전회수량", - "금회 입력", - "입력방법", - "사정 입력", - "입력방법", - "금회금액", - "사정금액", - "누계수량", - "누계금액", - ], - page.rows.map(({ row, index }) => { - const editable = ![54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex)); - const key = EstBusiness.rowKey(row, index), - input = round.inputs?.[key] || {}; - const edit = (prefix) => - `` + - (input[prefix + "Mode"] === "components" - ? EstMath.components - .map( - (field, i) => - ``, - ) - .join("") - : ""); - const method = (prefix) => - !editable - ? "자동계산" - : ``; - return [ - esc(row.Code), - esc(row.Name), - number(row.GN), - number(row.JunGN), - edit("Gum"), - method("Gum"), - edit("Sa"), - method("Sa"), - number(row.GumTC), - number(row.SaTC), - number(row.TotalGN), - number(row.TotalTC), - ]; - }), - ) + - `${esc((output.errors || []).join(" / "))} 금회 도급액 ${number(output.total)} · 기성비율 ${number(output.ratio)}%
`; - body += EstProgressWonga.render(state, round, output); - } - html = box( - "기성고", - tools, - body, - "전회는 직전 확정 회차 누계를 읽습니다. 금회·사정은 수량·금액·비율 중 하나를 입력해 계산합니다.", - ); + html = EstProgressView.view(state, project, { box, button, table }); } else if (view === "escalation") { - const output = EstBusiness.escalation(project); - state.business = output; - const comp = state.escalationComponent || "NU"; - const choices = EstMath.units - .map( - (key, i) => - ``, - ) - .join(""); - const page = EstBlockActions.escalationPage(state, project.tables.EstMainDat); - const rows = page.rows.map(({ row, index }) => { - const result = output.rows[index]; - return [ - esc(row.Code), - esc(row.Name), - EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]), - number(result[`Contract${comp}`]), - EstViews.cell("EstMainDat", index, comp, row[comp]), - number(result[`D${comp}`]), - number(result[`E${comp}`]), - number(result[`F${comp}`]), - EstViews.cell("EstMainDat", index, "EscPlanned", row.EscPlanned || 0), - EstViews.cell("EstMainDat", index, "EscActual", row.EscActual || 0), - EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0), - number(result.EscExcluded), - number(result.EscApplied), - ``, - EstViews.cell("EstMainDat", index, "EscManualQuantity", row.EscManualQuantity ?? 0), - EstViews.cell("EstMainDat", index, "EscConstruction", row.EscConstruction ?? 0), - EstViews.cell("EstMainDat", index, "EscPS", row.EscPS ?? 0), - EstViews.cell("EstMainDat", index, "EscBefore", row.EscBefore ?? 0), - ]; - }); - html = box( - "물가변동 품목조정", - `` + - button("capture-bid", "현재 단가를 입찰단가로 기록") + - button("load-bid", "입찰단가 불러오기") + - button("load-contract", "계약 내역 불러오기") + - button("load-progress-exclusion", "기성 누계수량 불러오기") + - button("reset-escalation", "물가변동 입력 초기화") + - button("capture-baseline", "계약 내역 확정") + - button("escalation-prev", "이전", state.escalationPage === 0 ? "disabled" : "") + - `${state.escalationPage + 1} / ${page.pages}쪽 · ${page.total}행` + - button( - "escalation-next", - "다음", - state.escalationPage + 1 >= page.pages ? "disabled" : "", - ) + - ``, - table( - [ - "코드", - "품명", - "입찰 당시", - "계약 단가", - "변동 당시", - "등락률 %", - "등락폭", - "조정단가", - "예정공정수량", - "실행공정수량", - "기성수량", - "적용제외", - "적용수량", - "제외 자동", - "수동 제외수량", - "시공중 수량(참고)", - "PS 수량(참고)", - "선시공 수량(참고)", - ], - rows, - ), - `조사 도급액 ${number(output.designTotal)} · 계약 도급액 ${number(output.contractTotal)} · 조정 도급액 ${number(output.adjustedTotal)} · 적용제외(간접비 포함) ${number(output.excludedTotal)} · 적용대가 ${number(output.applied)} · 조정률 ${number(output.rate)}% · 조정금액 ${number(output.adjustment)}. 계약·입찰 파일은 동일 행 순서로 불러옵니다. ${esc(output.errors.join(" / "))}`, - ); + html = EstAppliedCost.view(state, project, { box, button, table }); } else if (view === "annual") { const output = EstBusiness.annual(project, false); state.business = output; diff --git a/tools/est-test/native-misc-tools.js b/tools/est-test/native-misc-tools.js new file mode 100644 index 000000000..4c4c32c8f --- /dev/null +++ b/tools/est-test/native-misc-tools.js @@ -0,0 +1,319 @@ +// 잡다한 원본 대화(spec_pb1·pb2): 전회기성 불러오기(TfrmAccomJunLoad — 완료 회차 아무것이나 전회로), 조달수수료 입력(TfrmJodalBiul, Ctrl+J), +// 원가계산에 내역 금액 넣기(TfrmEstCostToWonga — 끌어 놓기 대신 줄 고르기), 원가 구성비 모드(popupBiulMode — 표시 전용 「비율」 열). +const EstMiscTools = (() => { + const num = (v) => Number(v) || 0; + const plain = (s) => String(s ?? "").replace(/\s/g, ""); + + // 구성비(ca19d8·ca06c4): 기준 행(CalcBase, 없으면 도급액 줄) 대비 대상 비목 Cost 비율, 0.01% 절사 뒤 최대 나머지로 100% 맞춤 + function biul(rows) { + let base = rows + .map((r, i) => (r.CalcBase ? i : -1)) + .filter((i) => i >= 0) + .at(-1); + if (base == null) + base = rows.findIndex((r) => num(r.LinkIndex) === 55 || /도급액/.test(plain(r.Name))); + if (base < 0 || !num(rows[base].Cost)) return null; + const total = num(rows[base].Cost), + target = new Set(); + let sum = 0; + rows.slice(0, base).forEach((r, i) => { + if (!num(r.Cost) || num(r.CalcIndex) >= 2) return; + const end = num(r.EndLineIndex); + if (i === 0 || end === 0 || end === 3 || (end === 1 && String(r.Youl ?? "").includes("%"))) { + target.add(i); + sum += num(r.Cost); + } + }); + for (let i = base - 1; i >= 0 && sum < total; i--) { + const r = rows[i]; + if ( + num(r.Cost) && + !target.has(i) && + num(r.EndLineIndex) === 1 && + !String(r.VSusic ?? "").trim() + ) { + target.add(i); + sum += num(r.Cost); + } + } + const exact = new Map([...target].map((i) => [i, num(rows[i].Cost) / total])), + cut = new Map([...exact].map(([i, v]) => [i, Math.trunc(v * 10000) / 10000])); + let t = [...cut.values()].reduce((a, b) => a + b, 0); + const bumped = new Set(); + for (let n = Math.round((1 - t) * 10000); n > 0 && t < 1; n--) { + const pick = [...exact.keys()] + .filter((i) => !bumped.has(i)) + .sort((a, b) => exact.get(b) - cut.get(b) - (exact.get(a) - cut.get(a)))[0]; + if (pick == null) break; + cut.set(pick, cut.get(pick) + 0.0001); + bumped.add(pick); + t += 0.0001; + } + return rows.map((_, i) => + i === base ? "100%" : cut.has(i) ? `${(cut.get(i) * 100).toFixed(2)}%` : "", + ); + } + + function jodal(state) { + if (state.view !== "EstMainDat") throw Error("내역서에서 쓰세요."); + const rows = state.project.tables.EstMainDat, + at = [...state.selection].sort((a, b) => a - b)[0], + row = rows[at]; + if (!row) throw Error("조달수수료를 넣을 줄을 고르세요."); + if ( + (num(row.Level) > 0 && plain(row.Name) && !plain(row.Name).includes("수수")) || + ![0, 57, 64].includes(num(row.LinkIndex)) + ) + throw Error("현재 라인에는 조달수수료를 입력할수 없습니다."); + const edit = num(row.LinkIndex) === 57; + EstActions.form( + "조달수수료", + [ + { name: "name", label: "명 칭", value: edit ? row.Name : "조달수수료" }, + { name: "biul", label: "비 율(%)", type: "number", value: edit ? num(row.GN) : 0.54 }, + { + name: "o", + label: "계산할 비목", + value: edit ? num(row.OCheck) || 1 : 1, + choices: [ + [1, "재료비의"], + [2, "노무비의"], + [3, "경비의"], + [4, "합계의"], + ], + }, + { + name: "s", + label: "계산결과 저장위치", + value: edit ? num(row.SCheck) || 1 : 1, + choices: [ + [1, "재료비에"], + [3, "경비에"], + ], + }, + { + name: "m", + label: "자리수 방법", + value: edit ? num(row.Index) || 2 : 2, + choices: [ + [1, "사사오입"], + [2, "절하"], + [3, "절상"], + ], + }, + { name: "d", label: "자리수", type: "number", value: edit ? num(row.Gangub) : 0 }, + ], + (v) => { + if (!Number.isFinite(v.biul)) throw Error("비율은 수치로 입력하세요."); + EstApp.change((project) => + Object.assign(project.tables.EstMainDat[at], { + Code: "AJJ01", + Name: String(v.name).trim(), + GN: v.biul, + Unite: "%", + LinkIndex: 57, + Index: Number(v.m), + Gangub: v.d, + OCheck: Number(v.o), + SCheck: Number(v.s), + }), + ); + }, + ); + } + + function costToWonga(state) { + if (state.view !== "Wonga") throw Error("원가계산서에서 넣을 줄을 고르세요."); + const at = [...state.selection].sort((a, b) => a - b)[0], + target = state.project.tables.Wonga[at]; + if (!target) throw Error("원가계산서에서 넣을 줄을 고르세요."); + const est = state.project.tables.EstMainDat; + EstActions.form( + "원가계산에 입력할 비목선택", + [ + { + name: "row", + label: "내역 줄", + value: "", + choices: est + .map((r, i) => [i, `${i + 1}. ${r.Name || r.Code || ""} ${num(r.TC).toLocaleString()}`]) + .filter(([i]) => est[i].Name || est[i].Code), + }, + { + name: "kind", + label: "입력할 비목", + value: -5, + choices: [ + [-5, "합계 금액"], + [-1, "노무비 금액"], + [-2, "재료비 금액"], + [-3, "경비 금액"], + ], + }, + ], + (v) => { + const src = est[Number(v.row)]; + if (!src) throw Error("내역 줄을 고르세요."); + if (plain(target.Name) && !confirm(`${target.Name}에 덮어쓰시겠습니까?`)) return; + const field = { "-5": "TC", "-1": "NC", "-2": "GC", "-3": "JC" }[v.kind]; + EstApp.change((project) => + Object.assign(project.tables.Wonga[at], { + CalcIndex: 0, + LinkIndex: 1, + Youl: "", + VSusic: "", + BaseField: "", + Name: src.Name, + TC: num(src.TC), + NC: num(src.NC), + GC: num(src.GC), + JC: num(src.JC), + SC: num(src.SC), + OCheck: Number(v.kind), + Cost: num(src[field]), + }), + ); + }, + ); + } + + function junLoad(state) { + const project = state.project, + round = project.progress?.find((r) => r.id === state.roundId); + if (!round) throw Error("기성 회차를 고르세요."); + const done = project.progress.filter((r) => r.locked && r.result); + if (!done.length) throw Error("전회없슴"); + EstActions.form( + "전회기성 불러오기", + [ + { + name: "id", + label: "불러올 차수선택", + value: round.junFrom || "", + choices: done.map((r) => [r.id, `${r.name || r.id}`]), + }, + ], + (v) => { + if (v.id === round.id) throw Error("현재작업중인 회차를 전회로 불러올수 없습니다."); + EstApp.change((p) => (p.progress.find((r) => r.id === round.id).junFrom = v.id), false); + }, + ); + } + + // 인쇄양식 불러오기·저장하기(mnuPrintFormatLoad·Save 0x10ed318·0x10ed000): .pfot = 인쇄정보표 한 벌(원본 이진 그대로). + // 불러오면 공사 인쇄표(printCatalog)를 그 값으로, 저장은 지금 인쇄표(불러온 원본 행 우선)를 .pfot 로. .gfot(격자 배치)는 다루지 않음. + async function pfot(name, state) { + if (name === "pfot-load") { + const file = await new Promise((resolve) => { + const picker = document.createElement("input"); + picker.type = "file"; + picker.accept = ".pfot"; + picker.addEventListener("change", () => resolve(picker.files[0])); + picker.click(); + }); + if (!file) return; + const rows = await EstApp.api("/api/pfot-read", { + method: "POST", + body: await file.arrayBuffer(), + }); + EstApp.change((project) => { + project.printFormat = rows; + project.printCatalog = rows.map((r) => + Object.fromEntries(Object.entries(r).filter(([k, v]) => k !== "__nulls" && !v?.binary)), + ); + }, false); + state.notice = `인쇄양식 「${file.name}」(서식 ${rows.length}개)을 불러왔습니다.`; + } else { + const defaults = await EstApp.api("/api/print-catalog"), + saved = new Map((state.project.printCatalog || []).map((r) => [r.Index, r])), + full = new Map((state.project.printFormat || []).map((r) => [r.Index, r])); + const rows = defaults.map((r) => ({ + ...r, + ...full.get(r.Index), + ...saved.get(r.Index), + __nulls: full.get(r.Index)?.__nulls, + })); + const response = await fetch("/api/pfot-write", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rows }), + }); + if (!response.ok) throw Error((await response.json()).error); + EstApp.download( + `${state.project.name || "인쇄양식"}.pfot`, + await response.blob(), + "application/octet-stream", + ); + } + EstApp.render(); + } + + // 사용위치 검색 창(TfrmUsesDataSerch btnSerch·btnAllDisplay): 현재 표 항목을 품명·규격(빈칸·|·괄호 무시)으로 거르고 골라 사용위치 보기 + function usesList(state, word) { + const rows = state.project.tables[state.view]; + if (!rows) throw Error("자료 화면에서 쓰세요."); + const norm = (x) => String(x ?? "").replace(/[\s|()]/g, ""), + w = norm(word), + { esc } = EstApp; + const list = rows + .map((r, i) => [r, i]) + .filter( + ([r]) => (r.Name || r.Code) && (!w || norm(r.Name).includes(w) || norm(r.Size).includes(w)), + ) + .slice(0, 300); + EstApp.showDialog( + "데이터 사용위치 검색", + `| ${esc(r.Code ?? "")} | ${esc(r.Name ?? "")} | ${esc(r.Size ?? "")} |
| ${esc(r.Code ?? "")} | ${esc(r.Name || EstText.clean(r.LineData || ""))} | ${esc(r.Size ?? "")} | ${esc(r.Unite ?? "")} | ${number(r.TC)} |
회차를 추가하세요.
', + tools = + button("add-round", "기성 회차 추가") + + '' + + (project.progress.length ? button("clear-progress", "기성 전체 초기화") : "") + + button("accom-option", "환경 설정"); + if (round) { + tools += + `` + + button( + round.locked ? "unlock-round" : "lock-round", + round.locked ? "확정 해제(이후 포함)" : "회차 확정·저장", + ) + + button("fill-progress", "전량·비율 일괄 입력") + + button("rename-round", "회차 이름") + + button("delete-round", "회차 삭제") + + button("reset-round-inputs", "회차 입력 초기화") + + `` + + ``; + let output; + try { + output = + round.locked && round.result ? round.result : EstBusiness.progress(project, round.id); + } catch (e) { + output = { rows: [], errors: [e.message] }; + } + state.business = output; + const page = EstBlockActions.progressPage(state, output.rows); + tools += + button("progress-prev", "이전", state.progressPage === 0 ? "disabled" : "") + + `${state.progressPage + 1} / ${page.pages}쪽 · ${page.total}행` + + button("progress-next", "다음", state.progressPage + 1 >= page.pages ? "disabled" : ""); + const seq = o.gubun ? parts(o) : ["T"]; + const nameStyle = [ + ["", "text-align:right", "text-align:center"][o.align] || "", + o.twoLine ? "white-space:normal;max-width:16em" : "white-space:nowrap", + ] + .filter(Boolean) + .join(";"); + const headers = [ + ...(o.code ? ["코드"] : []), + ...(o.level ? ["레벨"] : []), + "공종", + "도급수량", + "전회수량", + "금회 입력", + "입력방법", + ...(o.sajung ? ["사정 입력", "입력방법"] : []), + ...seq.map((p) => (p === "T" ? "금회금액" : `금회 ${PART[p]}`)), + ...(o.sajung ? ["사정금액"] : []), + "누계수량", + "누계금액", + ...(o.gungu ? ["비고"] : []), + ]; + body = + table( + headers, + page.rows.map(({ row, index }) => { + const editable = ![54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex)); + const key = EstBusiness.rowKey(row, index), + input = round.inputs?.[key] || {}; + const edit = (prefix) => + `` + + (input[prefix + "Mode"] === "components" + ? EstMath.components + .map( + (field, i) => + ``, + ) + .join("") + : ""); + const method = (prefix) => + !editable + ? "자동계산" + : ``; + const bold = o.sumBold && num(row.Level) > 0 ? ";font-weight:bold" : ""; + return [ + ...(o.code ? [esc(row.Code)] : []), + ...(o.level ? [num(row.Level) || ""] : []), + `${esc(row.Name)}`, + qty(row.GN), + qty(row.JunGN), + edit("Gum"), + method("Gum"), + ...(o.sajung ? [edit("Sa"), method("Sa")] : []), + ...seq.map((p) => cost(row[`Gum${p}C`])), + ...(o.sajung ? [cost(row.SaTC)] : []), + qty(row.TotalGN), + cost(row.TotalTC), + ...(o.gungu ? [esc(row.Bigo ?? "")] : []), + ]; + }), + ) + + `${esc((output.errors || []).join(" / "))} 금회 도급액 ${EstApp.number(output.total)} · 기성비율 ${EstApp.number(output.ratio)}%
`; + body += EstProgressWonga.render(state, round, output); + } + const html = box( + "기성고", + tools, + body, + "전회는 직전 확정 회차 누계를 읽습니다. 금회·사정은 수량·금액·비율 중 하나를 입력해 계산합니다.", + ); + return o.color ? `| ${esc(c.text)} | `)).join("")}