diff --git a/common_util/common_util_machine_coef.ts b/common_util/common_util_machine_coef.ts new file mode 100644 index 000000000..b5a66f2d8 --- /dev/null +++ b/common_util/common_util_machine_coef.ts @@ -0,0 +1,92 @@ +/* ============================================================================= + * common_util_machine_coef.ts + * 기계 손료계수 · 시간당 경비 미리보기(PLAN 821-2 · 요구서 796 1 장) — 기계 양식 즉시 셈. + * + * ⚠ 짝: `resources/master_data/scripts/master_machine_coef.py` — 같은 셈 · 같은 차례 + * (거울 시험 `resources/tester/test_821_2_machine_coef.py`). 한쪽만 고치면 화면과 저장이 갈림. + * 계수 = 건설품셈 공통 8-1-5(계수마다 정수 반올림 · 손료계수 = 합) · 미리보기 끝수 = 서버 + * 기계경비식(줄 0.1 원 아래 버림 · 잡품 = 버린 주연료 × % · 비목 합 원 미만 버림) · 정본은 서버 식. + * ========================================================================== */ + +export const MACHINE_INPUTS = [ + "내용시간", + "연간표준가동시간", + "상각비율", + "정비비율", + "연간관리비율", +] as const; +export const MACHINE_COEFS = ["상각비계수", "정비비계수", "관리비계수", "손료계수"] as const; + +export type MachineCoefs = Record<(typeof MACHINE_COEFS)[number], number>; + +const E = 1e-6; // 끊는 경계 부동소수 흔들림 막음 + +const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); +const halfUp = (x: number): number => Math.floor(x + 0.5 + E); +/** 줄 금액 = 0.1 원 아래 버림(B09 끝수 줄 자리). */ +const tenth = (x: number): number => Math.floor(x * 10 + E) / 10; + +/** (내용시간 · 연간표준가동시간 · 상각비율 · 정비비율 · 연간관리비율) → 계수 넷 · 못 셈 = null. */ +export function machineCoef( + life: unknown, + yearly: unknown, + dep: unknown, + rep: unknown, + mgmt: unknown, +): MachineCoefs | null { + const [t, h, a, m, k] = [life, yearly, dep, rep, mgmt].map(num); + if (t <= 0 || h <= 0) return null; + const n = t / h; + const s = halfUp((a / t) * 1e7); + const j = halfUp((m / t) * 1e7); + const g = halfUp(((k * (1.1 * n + 0.9)) / (2 * n) / h) * 1e7); + return { 상각비계수: s, 정비비계수: j, 관리비계수: g, 손료계수: s + j + g }; +} + +/** 값 묶음에서 품셈 입력 다섯이 다 수일 때만 계수 넷. */ +export function machineCoefOf(values: Record): MachineCoefs | null { + if (!MACHINE_INPUTS.every((k) => typeof values[k] === "number")) return null; + return machineCoef( + ...(MACHINE_INPUTS.map((k) => values[k]) as [number, number, number, number, number]), + ); +} + +export interface MachineHourly { + 손료: number; + 주연료: number; + 잡품: number; + 전력: number; + 조종원: number; + 노무비: number; + 재료비: number; + 경비: number; + 계: number; +} + +/** 값 묶음 + 유가(원/ℓ) · 노임(원/일) · 전력(원/kWh) · 시간환산 → 시간당 줄 · 비목 · 계. */ +export function machineHourly( + values: Record, + fuel: number, + wage: number, + power = 0, + hour = 1 / 8, +): MachineHourly { + const loss = Math.floor((num(values["가격"]) * 1000 * num(values["손료계수"])) / 1e7 + E); + const oil = tenth(num(values["주연료"]) * num(fuel)); + const misc = tenth((oil * num(values["잡재료율"])) / 100); + const elec = tenth(num(values["전력"]) * num(power)); + const crew = tenth(num(values["조종원"]) * hour * num(wage)); + const labor = Math.floor(crew + E); + const material = Math.floor(oil + misc + elec + E); + return { + 손료: loss, + 주연료: oil, + 잡품: misc, + 전력: elec, + 조종원: crew, + 노무비: labor, + 재료비: material, + 경비: loss, + 계: labor + material + loss, + }; +} diff --git a/resources/master_data/scripts/master_machine_coef.py b/resources/master_data/scripts/master_machine_coef.py new file mode 100644 index 000000000..61ff5dc53 --- /dev/null +++ b/resources/master_data/scripts/master_machine_coef.py @@ -0,0 +1,78 @@ +"""기계 손료계수 · 시간당 경비 미리보기(PLAN 821-2 · 요구서 796 1 장). + +⚠ 짝: `common_util/common_util_machine_coef.ts` — 같은 셈 · 같은 차례(거울 시험 +`resources/tester/test_821_2_machine_coef.py`). 한쪽만 고치면 화면과 저장이 갈림. + +계수(건설품셈 공통 8-1-5 · 시간당 10^-7): + 상각비계수 = 상각비율 ÷ 내용시간 · 정비비계수 = 정비비율 ÷ 내용시간 · + 관리비계수 = 연간관리비율 × 평균취득가격 비율 ÷ 연간표준가동시간 · + 평균취득가격 비율 = (1.1 N + 0.9) ÷ 2N · N = 내용시간 ÷ 연간표준가동시간 · + 계수마다 정수로 반올림 · 손료계수 = 셋의 합. +미리보기 = 서버 기계경비식(GC000267 ~)을 기본 옵션(보정 없음)으로 부른 값과 같은 끝수 — + 손료 = 가격(천원) × 1000 × 손료계수 ÷ 10^7 원 미만 버림 · 줄 금액은 0.1 원 아래 버림 · + 잡품 = 버린 주연료 금액 × 잡재료율 % · 비목 합은 원 미만 버림(B09 끝수 `master_cut.engine`). + 정본은 서버 식(`M02_Machine_Hourly`) — 이것은 화면 즉시 셈용. +""" + +from __future__ import annotations + +import math + +INPUTS = ("내용시간", "연간표준가동시간", "상각비율", "정비비율", "연간관리비율") +COEFS = ("상각비계수", "정비비계수", "관리비계수", "손료계수") +_E = 1e-6 # 끊는 경계 부동소수 흔들림 막음 + + +def _num(v) -> float: + return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else 0.0 + + +def _half_up(x: float) -> int: + return math.floor(x + 0.5 + _E) + + +def _tenth(x: float) -> float: + """줄 금액 = 0.1 원 아래 버림(B09 끝수 줄 자리).""" + return math.floor(x * 10 + _E) / 10 + + +def coef(life, yearly, dep, rep, mgmt) -> dict | None: + """(내용시간 · 연간표준가동시간 · 상각 · 정비 · 연간관리 비율) → 계수 넷 · 못 셈 = None.""" + life, yearly, dep, rep, mgmt = (_num(x) for x in (life, yearly, dep, rep, mgmt)) + if life <= 0 or yearly <= 0: + return None + n = life / yearly + s = _half_up(dep / life * 1e7) + j = _half_up(rep / life * 1e7) + g = _half_up(mgmt * (1.1 * n + 0.9) / (2 * n) / yearly * 1e7) + return {"상각비계수": s, "정비비계수": j, "관리비계수": g, "손료계수": s + j + g} + + +def coef_of(values: dict) -> dict | None: + """값 묶음(기계 줄 `값`)에서 품셈 입력 다섯이 다 수일 때만 계수 넷.""" + if not all(isinstance(values.get(k), (int, float)) for k in INPUTS): + return None + return coef(*(values[k] for k in INPUTS)) + + +def preview( + values: dict, fuel: float, wage: float, power: float = 0.0, hour: float = 1 / 8 +) -> dict: + """값 묶음 + 유가(원/ℓ) · 노임(원/일) · 전력(원/kWh) · 시간환산 → 시간당 줄 · 비목 · 계.""" + loss = math.floor(_num(values.get("가격")) * 1000 * _num(values.get("손료계수")) / 1e7 + _E) + oil = _tenth(_num(values.get("주연료")) * _num(fuel)) + misc = _tenth(oil * _num(values.get("잡재료율")) / 100) + elec = _tenth(_num(values.get("전력")) * _num(power)) + crew = _tenth(_num(values.get("조종원")) * hour * _num(wage)) + labor, material = math.floor(crew + _E), math.floor(oil + misc + elec + _E) + return { + "손료": loss, + "주연료": oil, + "잡품": misc, + "전력": elec, + "조종원": crew, + "노무비": labor, + "재료비": material, + "경비": loss, + "계": labor + material + loss, + } diff --git a/resources/tester/test_821_2_machine_coef.py b/resources/tester/test_821_2_machine_coef.py new file mode 100644 index 000000000..9a1e0065e --- /dev/null +++ b/resources/tester/test_821_2_machine_coef.py @@ -0,0 +1,133 @@ +"""PLAN 821-2 기계 계수 계산 짝 — 지킴 시험. + +① 계수 전수 대조: M01 기계표에서 품셈 입력 다섯 · 계수 넷이 다 있는 줄 = 파이썬 계수 셈과 같음 + (다른 줄 = 원문 표 값 그대로인 점보드릴 2 줄만 · 원문 777 · 셈 777.8). +② 파이썬 · TS 거울: 같은 입력 → 같은 계수 · 같은 시간당 미리보기. +③ 미리보기 = 서버 기계경비식(`M02_Machine_Hourly.hourly`) 견본 스무 대 · 비목 셋 같음. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "resources" / "master_data" / "scripts")) + +import master_machine_coef as mc # noqa: E402 + +DATA = ROOT / "resources" / "master_data" +TS = ROOT / "common_util" / "common_util_machine_coef.ts" +# 원문 표 값 ≠ 식(정비비계수 0.7 ÷ 9,000 × 10^7 = 777.8 → 원문 777 · 계 2,515) — 식을 비틀지 않음 +ORIGINAL_ODD = {"EQ000347", "EQ000348"} +SAMPLES = ( + "EQ000001 EQ000006 EQ000022 EQ000026 EQ000040 EQ000051 EQ000073 EQ000076 EQ000126 EQ000151 " + "EQ000176 EQ000201 EQ000226 EQ000229 EQ000246 EQ000326 EQ000401 EQ000434 EQ000455 EQ000544" +).split() + + +def _load(name: str) -> list[dict]: + return json.loads((DATA / name).read_text(encoding="utf-8"))["줄"] + + +MACHINES = _load("기계.json") +FUEL = {r["원문번호"]: r["값"] for r in _load("재료_유가전력.json")} +WAGE = {r["원문번호"]: r["값"] for r in _load("인력.json")} + + +def _full(row: dict) -> bool: + v = row["값"] + return all(isinstance(v.get(k), (int, float)) for k in (*mc.INPUTS, *mc.COEFS)) + + +def _prices(v: dict) -> tuple[float, float]: + """전국평균 유가 · 노임(직종 비면 기계경비식 조종원직종 첫 고르기 1048).""" + return FUEL.get(f"{v.get('주연료종류')}:전국평균", 0), WAGE.get( + str(v.get("조종원직종") or "1048"), 0 + ) + + +def test_coef_matches_master_table(): + rows = [r for r in MACHINES if _full(r)] + assert len(rows) >= 590 + odd = {r["키"] for r in rows if mc.coef_of(r["값"]) != {k: r["값"][k] for k in mc.COEFS}} + assert odd == ORIGINAL_ODD + + +def test_coef_excavator_example(): + got = mc.coef(10000, 1250, 0.9, 0.7, 0.1) + assert got == {"상각비계수": 900, "정비비계수": 700, "관리비계수": 485, "손료계수": 2085} + assert mc.coef(0, 1250, 0.9, 0.7, 0.1) is None + assert mc.coef_of({"내용시간": 10000}) is None + + +@pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) +def test_ts_mirror(tmp_path): + coef_cases = [[r["값"][k] for k in mc.INPUTS] for r in MACHINES if _full(r)] + coef_cases += [[10000, 1250, 0.9, 0.7, 0.1], [0, 1, 1, 1, 1], [3600, 540, 0.9, 1.15, 0.1]] + hour_cases = [] + for r in MACHINES: + fuel, wage = _prices(r["값"]) + hour_cases.append([r["값"], fuel, wage, 114.4, 0.125]) + hour_cases.append( + [ + {"가격": 2680, "손료계수": 5313, "주연료": 1.0, "잡재료율": 10, "전력": 3.73}, + 2000, + 0, + 114.4, + 0.125, + ] + ) + src = tmp_path / "in.json" + src.write_text( + json.dumps({"coef": coef_cases, "hour": hour_cases}, ensure_ascii=False), encoding="utf-8" + ) + script = f""" +const fs = require("fs"); +const ts = require({json.dumps(str(ROOT / "config" / "node_modules" / "typescript"))}); +const opt = {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }}; +const src = fs.readFileSync({json.dumps(str(TS))}, "utf8"); +const js = ts.transpileModule(src, {{ compilerOptions: opt }}).outputText; +const m = {{ exports: {{}} }}; +new Function("exports", "module", js)(m.exports, m); +const inp = JSON.parse(fs.readFileSync({json.dumps(str(src))}, "utf8")); +process.stdout.write(JSON.stringify({{ + coef: inp.coef.map((c) => m.exports.machineCoef(...c)), + hour: inp.hour.map((h) => m.exports.machineHourly(...h)), +}})); +""" + out = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, encoding="utf-8", check=True + ) + got = json.loads(out.stdout) + want_coef = [mc.coef(*c) for c in coef_cases] + want_hour = [mc.preview(*h) for h in hour_cases] + assert got["coef"] == want_coef + assert len(got["hour"]) == len(want_hour) + for a, b in zip(got["hour"], want_hour): + assert a == pytest.approx(b, abs=1e-9) + + +def test_preview_equals_server_hourly(): + from M02_MasterTemplete import M02_Machine_Hourly as mh + + rows = {r["키"]: r for r in MACHINES} + for key in SAMPLES: + h = mh.hourly(key) + assert h["ok"], (key, h) + v = rows[key]["값"] + p = mc.preview(v, *_prices(v)) + assert (p["노무비"], p["재료비"], p["경비"], p["계"]) == ( + h["노무비"], + h["재료비"], + h["경비"], + h["계"], + ), (key, h["로직"]) diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 4812d3d12..5f8404ec5 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,8 @@ 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] 묶음 인쇄 간지 설정값 일괄적용·기본양식에저장·전체선택/선택해제(원본 TFRMESTPRINTPRIVATE btnGangiAllSet·btnSaveToMain, TFRMESTPRINT2 btnNoselect) — 간지 인쇄·간지 겹테두리를 모든 서식에 같게, 고른 서식·간지 설정을 「묶음기본양식」(양식 보관함 종류 print)으로 저장해 묶음 설정 없는 공사에서 열 때 씀. + - 검증완료: 묶음 인쇄·바인딩 시험 통과. ORCA 브라우저에서 선택해제 0 · 전체선택 40, 겹테두리 끔 → 미리보기 간지 테두리 없음, 기본양식 저장 뒤 설정 지우고 다시 열면 1개·겹테두리 끔 복원, 양식 지우고 샘플 복구. - [x] 갑지 공사금액 자동변경(원본 TFRMGABGI popupCostChange) — 「공사금액 자동변경」 체크 시 고른 원가 비목·방식을 기억해 인쇄 때마다 현재 원가 금액으로 공사금액 글을 다시 만듦(다시 열면 고른 비목·방식 그대로). - 검증완료: 문서 시험 3개 통과. ORCA 브라우저에서 도급액 고정 풀고 자동변경 저장 → 수량 2배 뒤 인쇄 금액 「칠천구백육십만삼천삼백이십사원」→「팔천이백칠만사천팔백구십일원」, 저장 글은 그대로, 샘플 복구. - [x] 선택 행 예정공정표에 표시/해제(원본 mnuShowSchedule · popMainShowChart) — 내역서에서 고른 상세 행만 공정표에 넣음(이미 있는 행은 건너뜀), 고른 행이 모두 있으면 뺌(검증: 2행 넣기 → 다시 눌러 0행). diff --git a/tools/est-test/native-print-batch.js b/tools/est-test/native-print-batch.js index 36f4ad5c4..d7b7e7396 100644 --- a/tools/est-test/native-print-batch.js +++ b/tools/est-test/native-print-batch.js @@ -18,7 +18,7 @@ const EstPrintBatch = (() => { Name: row.Name2 || row.Name || String(row.Index), checked: saved ? saved.includes(row.Index) : Number(row.Checked) === 1, gangi: project.printBatch?.gangi ?? Number(row.PrintGangiShow) === 1, - outline: Number(row.PrintGangiOutLineShow ?? 1) === 1, + outline: project.printBatch?.outline ?? Number(row.PrintGangiOutLineShow ?? 1) === 1, })); } @@ -54,7 +54,21 @@ const EstPrintBatch = (() => { } let catalog = []; + // 간지 설정값 일괄적용(btnGangiAllSet): 간지 인쇄·겹테두리를 모든 서식에 같게. 기본양식에저장(btnSaveToMain): + // 고른 서식·간지 설정을 서버 양식 보관함(종류 print, 이름 「묶음기본양식」)에 — 묶음 설정이 없는 공사에서 열 때 씀. + const DEFAULT = "묶음기본양식"; + const post = (body) => ({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); async function open(state) { + if (!state.project.printBatch) { + const saved = (await EstApp.api("/api/forms")).find( + (f) => f.kind === "print" && f.name === DEFAULT, + ); + if (saved) EstApp.change((project) => (project.printBatch = saved.data), false); + } const defaults = await EstApp.api("/api/print-catalog"); const original = new Map((state.project.printCatalog || []).map((row) => [row.Index, row])); catalog = defaults.map((row) => ({ ...row, ...original.get(row.Index) })); @@ -64,16 +78,17 @@ const EstPrintBatch = (() => { const list = forms(catalog, state.project); EstApp.showDialog( "묶음 인쇄(설계서 일체)", - `

인쇄할 서식을 고르세요. 인쇄표 차례대로 이어 인쇄합니다.

${list.map((f) => ``).join("")}
`, + `

인쇄할 서식을 고르세요. 인쇄표 차례대로 이어 인쇄합니다.

(모든 서식에 일괄적용)
${list.map((f) => ``).join("")}
`, ); } function read(state) { const checked = [...document.querySelectorAll('#dialog-content [name="pb-form"]:checked')].map( (n) => (/^\d+$/.test(n.value) ? Number(n.value) : n.value), ); - const gangi = document.querySelector('#dialog-content [name="pb-gangi"]').checked; + const gangi = document.querySelector('#dialog-content [name="pb-gangi"]').checked, + outline = document.querySelector('#dialog-content [name="pb-outline"]').checked; if (!checked.length) throw Error("인쇄할 서식을 하나 이상 고르세요."); - const next = { checked, gangi }; + const next = { checked, gangi, outline }; if (JSON.stringify(next) !== JSON.stringify(state.project.printBatch)) EstApp.change((project) => (project.printBatch = next), false); return next; @@ -96,7 +111,18 @@ const EstPrintBatch = (() => { EstApp.render(); }); else if (name === "print-batch-preview") build(state); - else if (name === "print-batch-print") { + else if (name === "print-batch-all" || name === "print-batch-none") + for (const n of document.querySelectorAll('#dialog-content [name="pb-form"]')) + n.checked = name === "print-batch-all"; + else if (name === "print-batch-default") { + const data = read(state); + EstApp.api("/api/forms", post({ kind: "print", name: DEFAULT, data })) + .then( + () => + (EstApp.byId("print-batch-note").textContent = "묶음 인쇄 기본양식을 저장했습니다."), + ) + .catch((error) => (EstApp.byId("print-batch-note").textContent = error.message)); + } else if (name === "print-batch-print") { const frame = build(state); frame.onload = () => frame.contentWindow.print(); } else return false;