Merge commit 'f8e6d0d14' into sub_laptop_3

This commit is contained in:
2026-11-12 13:36:27 +09:00
5 changed files with 336 additions and 5 deletions
+92
View File
@@ -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<string, unknown>): 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<string, unknown>,
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,
};
}
@@ -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,
}
+133
View File
@@ -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["로직"])
+2
View File
@@ -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행).
+31 -5
View File
@@ -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(
"묶음 인쇄(설계서 일체)",
`<p>인쇄할 서식을 고르세요. 인쇄표 차례대로 이어 인쇄합니다.</p><label><input type="checkbox" name="pb-gangi" ${list[0]?.gangi ? "checked" : ""}>간지 인쇄</label><div style="max-height:260px;overflow:auto;columns:2">${list.map((f) => `<label style="display:block"><input type="checkbox" name="pb-form" value="${esc(f.Index)}" ${f.checked ? "checked" : ""}>${esc(f.Name)}</label>`).join("")}</div><button data-action="print-batch-preview">미리보기</button> <button data-action="print-batch-print">인쇄</button><p id="print-batch-note"></p><iframe id="print-batch-frame" style="width:100%;height:360px;border:1px solid #ccc"></iframe>`,
`<p>인쇄할 서식을 고르세요. 인쇄표 차례대로 이어 인쇄합니다.</p><label><input type="checkbox" name="pb-gangi" ${list[0]?.gangi ? "checked" : ""}>간지 인쇄</label> <label><input type="checkbox" name="pb-outline" ${list[0]?.outline !== false ? "checked" : ""}>간지 겹테두리</label> (모든 서식에 일괄적용) <button data-action="print-batch-all">전체선택</button><button data-action="print-batch-none">선택해제</button><button data-action="print-batch-default">기본양식에저장</button><div style="max-height:260px;overflow:auto;columns:2">${list.map((f) => `<label style="display:block"><input type="checkbox" name="pb-form" value="${esc(f.Index)}" ${f.checked ? "checked" : ""}>${esc(f.Name)}</label>`).join("")}</div><button data-action="print-batch-preview">미리보기</button> <button data-action="print-batch-print">인쇄</button><p id="print-batch-note"></p><iframe id="print-batch-frame" style="width:100%;height:360px;border:1px solid #ccc"></iframe>`,
);
}
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;