Merge remote-tracking branches 'origin/CODEX', 'origin/sub_laptop_1' and 'origin/sub_laptop_2' into sub_laptop_3

This commit is contained in:
2026-11-12 19:12:23 +09:00
103 changed files with 5603 additions and 541 deletions
@@ -184,6 +184,12 @@ body.cad-file-dragging::after {
border-top: 1px solid var(--cad-line);
}
/* 켜기 = 켜기 칩 부품(`ui-toggle--chip` · 단 22) */
.cad-statusbar__grid {
margin-left: 8px;
color: var(--cad-text-muted);
font-size: 10px;
white-space: nowrap;
}
.cad-statusbar__hint {
margin-left: auto;
color: var(--cad-text-muted);
@@ -4,12 +4,14 @@ import {
getAngleStep,
getGridEnabled,
getSnapEnabled,
getScreenCanvasDrawController,
getSnapTrackingEnabled,
setAngleStep,
setGridEnabled,
setSnapEnabled,
setSnapTrackingEnabled,
} from '../state';
import { formatGridStep, pickGridStep } from '../helpers/grid-steps';
interface StatusBarProps {
commandLineVisible: boolean;
@@ -22,7 +24,12 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
const ortho = getAngleStep() === 90;
const polar = getAngleStep() === 45;
const snapTracking = getSnapTrackingEnabled();
// 그리드 한 칸 크기(825-1) — 지금 확대 정도의 보조선 간격
const view = getScreenCanvasDrawController();
const gridStep =
grid && view
? pickGridStep(view.getScreenScale(), Math.max(view.getCanvasSize().x, view.getCanvasSize().y))
: null;
return (
<footer className="cad-statusbar controls">
<button
@@ -85,6 +92,9 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
>
명령행
</button>
{gridStep && (
<span className="cad-statusbar__grid">그리드 1 칸 = {formatGridStep(gridStep.minor)}</span>
)}
<span className="cad-statusbar__hint">휠: 줌 · 휠 드래그: 초점이동 · Esc: 취소</span>
</footer>
);
@@ -1,5 +1,6 @@
import { Point, type Vector } from '@flatten-js/core';
import { WHEEL_ZOOM_EXPONENT } from '../App.consts';
import { gridLines, pickGridStep } from '../helpers/grid-steps.ts';
import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts';
import { mapNumberRange } from '../helpers/map-number-range.ts';
import { StateVariable } from '../helpers/undo-stack.ts';
@@ -338,21 +339,40 @@ export class ScreenCanvasDrawController implements DrawController {
this.context.fillStyle = themeColor('--cad-canvas', CANVAS_BACKGROUND_FALLBACK);
this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y);
if (getGridEnabled()) {
this.context.strokeStyle = themeColor('--draw-grid', '#242b35');
this.context.lineWidth = 1;
this.context.setLineDash([]);
this.context.beginPath();
for (let x = 0.5; x < this.canvasSize.x; x += 24) {
this.context.moveTo(x, 0);
this.context.lineTo(x, this.canvasSize.y);
}
for (let y = 0.5; y < this.canvasSize.y; y += 24) {
this.context.moveTo(0, y);
this.context.lineTo(this.canvasSize.x, y);
}
this.context.stroke();
if (getGridEnabled()) this.drawGrid();
}
/** 도면 좌표에 붙은 그리드(825-1) — 팬 · 줌을 따라가고 확대 정도에 간격이 바뀜. 보이는 범위의 선만 긋는다. */
private drawGrid() {
const { x: width, y: height } = this.canvasSize;
const scale = this.screenScale;
const step = pickGridStep(scale, Math.max(width, height));
const minorPath = new Path2D();
const majorPath = new Path2D();
for (const { value, major } of gridLines(
this.screenOffset.x,
this.screenOffset.x + width / scale,
step
)) {
const x = Math.round((value - this.screenOffset.x) * scale) + 0.5;
(major ? majorPath : minorPath).moveTo(x, 0);
(major ? majorPath : minorPath).lineTo(x, height);
}
for (const { value, major } of gridLines(
this.screenOffset.y,
this.screenOffset.y + height / scale,
step
)) {
const y = Math.round(height - (value - this.screenOffset.y) * scale) + 0.5;
(major ? majorPath : minorPath).moveTo(0, y);
(major ? majorPath : minorPath).lineTo(width, y);
}
this.context.lineWidth = 1;
this.context.setLineDash([]);
this.context.strokeStyle = themeColor('--draw-grid', '#242b35');
this.context.stroke(minorPath);
this.context.strokeStyle = themeColor('--draw-grid-major', '#3a4352');
this.context.stroke(majorPath);
}
/**
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { formatGridStep, gridLines, MAX_LINES, MIN_MINOR_PX, pickGridStep } from './grid-steps';
describe('그리드 간격(825-1)', () => {
it('여러 확대 정도에서 화면 간격이 범위 안 · 선 수 상한', () => {
for (let exp = -4; exp <= 5; exp += 0.25) {
const scale = 10 ** exp; // px/m
for (const extent of [800, 1600, 3840]) {
const step = pickGridStep(scale, extent);
const px = step.minor * scale;
const floor = Math.max(MIN_MINOR_PX, extent / MAX_LINES);
expect(px).toBeGreaterThanOrEqual(floor * 0.999);
expect(px).toBeLessThanOrEqual(floor * 2.5 * 1.001);
const lines = gridLines(-1e6, -1e6 + extent / scale, step);
expect(lines.length).toBeLessThanOrEqual(MAX_LINES + 1);
}
}
});
it('주선은 늘 5 · 10 × 10^k 위', () => {
for (const scale of [0.001, 0.07, 0.3, 1, 4, 20, 900]) {
const step = pickGridStep(scale, 1600);
const major = step.minor * step.majorEvery;
expect(Math.log10(major / 5) % 1 === 0 || Math.log10(major / 10) % 1 === 0).toBe(true);
}
});
it('팬해도 같은 도면 좌표에 선 — 원점 기준', () => {
const step = pickGridStep(2, 1600); // 12 px → 6 m 이상 → 10 m
expect(step.minor).toBe(10);
const a = gridLines(3, 95, step).map((l) => l.value);
const b = gridLines(-47, 45, step).map((l) => l.value);
expect(a.filter((v) => v <= 45)).toEqual(b.filter((v) => v >= 3));
expect(gridLines(-20, 20, step).filter((l) => l.major).map((l) => l.value)).toEqual([0]);
});
it('문구', () => {
expect(formatGridStep(1)).toBe('1 m');
expect(formatGridStep(0.5)).toBe('0.5 m');
expect(formatGridStep(5000)).toBe('5 km');
expect(formatGridStep(0.002)).toBe('2 mm');
});
});
@@ -0,0 +1,57 @@
/**
* 그리드 간격 계산(825-1) — 화면 보조 표시 전용 순수 함수. 도면 단위 = m.
* 간격 단계 = 1 · 2 · 5 × 10^k. 화면에서 보조선 사이가 MIN_MINOR_PX 이상이 되는 가장 작은 단계를 고름
* (→ 보조선 사이 12~30 px · 확대하면 나뉘고 축소하면 커짐). 주선 = 1·2 단계는 5 칸마다 · 5 단계는 2 칸마다(늘 5·10 × 10^k).
* 한 방향 선 수 상한 MAX_LINES — 화면이 아주 커도 지킴.
*/
export const MIN_MINOR_PX = 12;
export const MAX_LINES = 200;
const MANTISSAS = [1, 2, 5];
export interface GridStep {
/** 보조선 간격 (m) */
minor: number;
/** 주선 = 보조선 몇 칸마다 */
majorEvery: number;
}
/** scale = 화면 px / m · extentPx = 화면 긴 변 px */
export function pickGridStep(scale: number, extentPx: number): GridStep {
if (!(scale > 0)) return { minor: 1, majorEvery: 5 };
const minPx = Math.max(MIN_MINOR_PX, extentPx / MAX_LINES);
const minWorld = minPx / scale;
let exponent = Math.floor(Math.log10(minWorld)) - 1;
for (let guard = 0; guard < 40; guard++, exponent++) {
for (const mantissa of MANTISSAS) {
const minor = Number(`${mantissa}e${exponent}`); // 1e-3 꼴 — 곱셈 부동소수 오차 없음
if (minor >= minWorld) return { minor, majorEvery: mantissa === 5 ? 2 : 5 };
}
}
return { minor: minWorld, majorEvery: 5 };
}
export interface GridLine {
/** 도면 좌표 값 */
value: number;
major: boolean;
}
/** [lo, hi] 안에서 원점 기준 minor 간격의 선 목록 */
export function gridLines(lo: number, hi: number, step: GridStep): GridLine[] {
const lines: GridLine[] = [];
const first = Math.ceil(lo / step.minor);
const last = Math.floor(hi / step.minor);
for (let index = first; index <= last; index++) {
lines.push({ value: index * step.minor, major: index % step.majorEvery === 0 });
}
return lines;
}
/** 상태줄 문구용 — 1 · 0.5 · 10 · 1 km · 5 mm */
export function formatGridStep(minor: number): string {
if (minor >= 1000) return `${Number((minor / 1000).toPrecision(6))} km`;
if (minor >= 1) return `${Number(minor.toPrecision(6))} m`;
if (minor >= 0.1) return `${Number(minor.toPrecision(6))} m`;
return `${Number((minor * 1000).toPrecision(6))} mm`;
}
@@ -47,6 +47,7 @@ import { MeasurementEntity } from '../entities/MeasurementEntity';
import { PolyLineEntity } from '../entities/PolyLineEntity';
import { TextEntity } from '../entities/TextEntity';
import { setEntities, setGridEnabled, setLayers } from '../state';
import { MAX_LINES } from './grid-steps';
import { drawScene, invalidateSceneCache, scenePerf } from './scene-cache';
import { queryEntitiesInBox } from './spatial-index';
@@ -188,4 +189,22 @@ describe('줌아웃 그리기 비용(227-1)', () => {
setGridEnabled(false);
expect(scenePerf.sceneRebuilds).toBe(rebuilds);
});
it('그리드를 켠 채 배율별 clear() — 선 수 상한(825-1) · 한 장 시간(옛 24px 고정 = 가로 67 + 세로 38 = 105 줄)', () => {
setGridEnabled(true);
const rows = [];
for (const factor of [100, 10, 1, 0.1, 0.01, 0.0001]) {
viewAt(controller, factor, fitScale);
for (const key of Object.keys(counts)) counts[key as keyof typeof counts] = 0;
controller.clear();
const lines = counts.segments;
const startedAt = performance.now();
for (let rep = 0; rep < 200; rep++) controller.clear();
const ms = (performance.now() - startedAt) / 200;
rows.push({ factor, lines, ms: Number(ms.toFixed(3)) });
expect(lines).toBeLessThanOrEqual(2 * (MAX_LINES + 1));
}
setGridEnabled(false);
console.table(rows);
});
});
+92 -2
View File
@@ -3,11 +3,15 @@
계수 = `master_machine_coef`(TS 짝 `common_util/common_util_machine_coef.ts`) ·
시간당 = 서버 기계경비식(`M02_Machine_Hourly.hourly` · 전국평균 · 기본 옵션) ·
쓰는 계산식 = `M02_Machine_Hourly.pick`(목록 › 꼴 · 822-2) · [새 기계] 저장 = 꼴에 맞는 식 목록에 원문번호 등록
(원문번호 빈 칸 = 「자체 EQ…」 받을 키) ·
M01 에서 만든 기계(출처 모두 「자체」)를 지우면 그 번호 기계가 더 없을 때 목록에서 뺌(`register`) ·
마스터 파일은 읽기만 — 쓰기는 `Store_Make.save`.
"""
from __future__ import annotations
import copy
import json
import re
from decimal import Decimal
@@ -102,6 +106,78 @@ def hourly(keys: list[str]) -> dict[str, dict]:
return out
def formula(row: dict) -> dict:
"""기계 줄(저장 전 고친 값도) → 쓰는 계산식 {키, 이름, 방법 목록|꼴|그 기계 식, 까닭}(822-2 상세 페이지)."""
from M02_MasterTemplete import M02_Machine_Hourly as mh # M02 가 M01 을 부름 — 늦게
code = str(row.get("원문번호") or "")
got = mh.pick({**row, "값": store._dec(row.get("값") or {})})
how = "목록" if mh.listed(code) else "그 기계 식" if got and mh._own(code) else "꼴"
base = {"방법": how, "까닭": mh.why({**row, "값": store._dec(row.get("값") or {})}, got)}
return base | ({"키": got["키"], "이름": got.get("이름")} if got else {})
def _family_files() -> dict[str, str]:
"""기계경비식 키 → 로직 파일 이름."""
return {
str(r.get("키")): name
for name, data in store.loaded()[0].items()
if data.get("그룹") == "로직" and "건설기계" in name
for r in data.get("줄") or []
}
def register(batch: list[dict]) -> None:
"""[새 기계] 저장 = 꼴 식 「기계」 목록에 그 원문번호 더함 · M01 에서 만든 기계(출처 모두 「자체」)를
지우면 같은 번호 기계가 더 없을 때 목록에서 뺌 — 로직 파일 고침을 같은 저장 묶음에 붙임(정본 저장 길)."""
from M02_MasterTemplete import M02_Machine_Hourly as mh # M02 가 M01 을 부름 — 늦게
from M02_MasterTemplete import M02_MyMachine as mym
part = next((p for p in batch if p.get("file") == FILE), None)
if part is None:
return
rows = _rows()
gone = {str(c.get("key")) for c in part.get("changes") or [] if c.get("op") == "delete"}
add: dict[str, list[str]] = {}
drop: set[str] = set()
for ch in part.get("changes") or []:
row = ch.get("row") if ch.get("op") == "add" else None
code = str((row or {}).get("원문번호") or "").strip()
if row and code and not mh.listed(code) and not mh._own(code):
key = mym.logic_key({**row, "값": store._dec(row.get("값") or {})})
if key and code not in add.get(key, []):
add.setdefault(key, []).append(code)
old = next((r for r in rows if r.get("키") == ch.get("key")), None)
if (
ch.get("op") == "delete"
and old
and set((old.get("출처") or {}).values()) == {OWN_SOURCE}
):
code = str(old.get("원문번호") or "")
if code and not any(
str(r.get("원문번호")) == code and r.get("키") not in gone for r in rows
):
drop.add(code)
if not add and not drop:
return
files = _family_files()
for name in dict.fromkeys(files.values()):
changes = []
for logic in store.read(name)[0].get("줄") or []:
ins = logic.get("입력") or []
pick = next((i for i in ins if i.get("이름") == "기계" and "고르기" in i), None)
if pick is None:
continue
codes = [c for c in pick["고르기"] if str(c) not in drop]
codes += [c for c in add.get(str(logic["키"]), []) if c not in codes]
if codes != pick["고르기"]:
new = copy.deepcopy(logic)
next(i for i in new["입력"] if i.get("이름") == "기계")["고르기"] = codes
changes.append({"op": "edit", "key": logic["키"], "row": new})
if changes:
batch.append({"file": name, "version": store.read(name)[1], "changes": changes})
# ── 쓰는 곳 ─────────────────────────────────────────────────────────
def _logic_index() -> tuple[dict[str, list[str]], dict[str, list[str]]]:
"""(원문번호 → 로직 「키 이름」 · 기계 키 → 로직) — 기계 고르기 · 식 안 EQ 키."""
@@ -200,8 +276,18 @@ def fix(part: dict) -> None:
return
stored = {str(r.get("키")): r for r in _rows()}
units = _units()
issue = int(store.mk.load_book(store.FOLDER / store.mk.BOOK.name)["다음"].get("EQ", 1))
for ch in part.get("changes") or []:
row = ch.get("row")
if (
ch.get("op") == "add"
and isinstance(row, dict)
and not str(row.get("원문번호") or "").strip()
):
row["원문번호"] = (
f"{OWN_SOURCE} EQ{issue:06d}" # 822-2 식 목록에 넣을 번호(빈 칸 = 받을 키)
)
issue += 1
if (
ch.get("op") == "delete"
or not isinstance(row, dict)
@@ -218,5 +304,9 @@ def fix(part: dict) -> None:
values.update(got)
was_unit = (old or {}).get("단위") or {}
was_src = (old or {}).get("출처") or {}
row["단위"] = {k: was_unit.get(k, units.get(k, "")) for k in values}
row["출처"] = {k: was_src.get(k, OWN_SOURCE) for k in values}
unit, src = (
row.get("단위") or {},
row.get("출처") or {},
) # 보낸 칸이 있으면 그 글(822-2 빌더)
row["단위"] = {k: unit.get(k) or was_unit.get(k, units.get(k, "")) for k in values}
row["출처"] = {k: src.get(k) or was_src.get(k, OWN_SOURCE) for k in values}
@@ -53,5 +53,25 @@ export const fetchMachineUses = async (key: string): Promise<string[]> =>
export const findMachines = async (q: string): Promise<Row[]> =>
(await get<{ rows: Row[] }>("/find", { q })).rows;
/** 쓰는 계산식(822-2) — 방법 = 목록 · 꼴 · 그 기계 식 · 까닭 = 값이 모자란 칸 */
export interface MachineFormula {
키?: string;
이름?: string;
방법: "목록" | "꼴" | "그 기계 식";
까닭: string;
}
export async function fetchMachineFormula(row: Row): Promise<MachineFormula> {
const res = await fetch(`${API_BASE_URL}/m01/machine/formula`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(row),
});
const body = (await res.json()) as MachineFormula & { detail?: unknown };
if (!res.ok) throw new Error(String(body.detail ?? `HTTP ${res.status}`));
return body;
}
export const fetchMachineRow = (key: string): Promise<{ version: string; row: Row }> =>
get("/row", { key });
+6
View File
@@ -288,6 +288,12 @@ def get_machine_row(key: str) -> dict:
return _call(machine.row, key)
@router.post("/machine/formula")
def post_machine_formula(row: dict[str, Any]) -> dict:
"""기계 줄(저장 전 고친 값도) → 쓰는 계산식 {키 · 이름 · 방법 · 까닭}(822-2)."""
return _call(machine.formula, row)
@router.post("/calc")
def post_calc(body: CalcBody) -> dict:
with store.cm.mf.mp.choosing(body.고름):
@@ -294,6 +294,7 @@ def save(batch: list[dict], confirmed: bool = False) -> dict:
row.update(별칭=text) if text else row.pop("별칭")
machine.fix(part) # 기계 줄 = 계수 서버 재계산 · 단위 · 출처 채움(821-2)
_keep_same(part)
machine.register(batch) # [새 기계] = 꼴 식 목록 등록(822-2 · 개정확인 밖 — 자동 고침)
fresh = False
for part in batch:
if part.get("file") != OWN:
+2 -1
View File
@@ -132,7 +132,8 @@ export function renderMachines(
if (!want.length) return;
try {
const got = await fetchMachineHourly(want);
for (const [k, h] of Object.entries(got)) hourly.set(k, h.계 === undefined ? "—" : num(h.계));
for (const [k, h] of Object.entries(got))
hourly.set(k, h.계 === undefined ? (h.reason ?? "—") : num(h.계));
} catch {
for (const k of want) hourly.set(k, "—");
}
@@ -1,7 +1,7 @@
/* =============================================================================
* M01_MasterData_UI_MachineDetail.ts
* M01 기계 상세 페이지(PLAN 821-2 · 요구 790-2 · 791) — 기계 양식 부품 + 원문번호 · 원문 · 구분 ·
* 상세구분 · 별칭 · 비고 · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인).
* 상세구분 · 별칭 · 비고 · 쓰는 계산식(822-2 · 서버 식 고름) · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인).
* 고침은 초안(sessionStorage)에 바로 쌓임 → 페이지 [저장] 한 번에 서버로(자동저장 없음) ·
* 서버가 계수를 계수 짝으로 다시 셈 · 단위 · 출처 칸을 채움(`M01_MasterData_Machine.fix`).
* 대상 = 키(EQ…) · 저장 전 새 줄 `add:<차례>` · 새 기계 `new`(비슷한 기계에서 시작이 펼쳐짐).
@@ -22,6 +22,7 @@ import { setLeaveGuard } from "../A00_Common/router";
import type { Row } from "./M01_MasterData_Api_Fetch";
import { addRow, editRow, peek, removeAdd, setAdd, toggleDelete } from "./M01_MasterData_Draft";
import {
fetchMachineFormula,
fetchMachineLists,
fetchMachineRow,
fetchMachineUses,
@@ -154,6 +155,29 @@ async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () =>
}
cur = ordered;
checkSame(s.name, s.spec);
paintFormula();
};
/* --- 쓰는 계산식(서버 식 고름 · 고친 값으로) --- */
const formulaBox = el("section", { className: "m01-machine-detail__uses" });
let formulaTimer: number | undefined;
const paintFormula = (): void => {
window.clearTimeout(formulaTimer);
formulaTimer = window.setTimeout(async () => {
const got = await fetchMachineFormula(cur).catch(() => null);
if (!got || !alive()) return;
const how =
got.방법 === "목록"
? L("M01_MachineFormulaListed")
: got.방법 === "그 기계 식"
? L("M01_MachineFormulaOwn")
: L("M01_MachineFormulaShape");
formulaBox.replaceChildren(
el("strong", { text: L("M01_MachineFormula") }),
createText(got.키 ? `${got.키} ${got.이름 ?? ""} · ${how}` : L("M01_MachineFormulaNone")),
...(got.까닭 ? [createText(got.까닭, "warning")] : []),
);
}, 300);
};
/* --- 같은 이름 · 규격 알림 --- */
@@ -275,10 +299,12 @@ async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () =>
notice,
metaBox,
form.root,
formulaBox,
usesBox,
],
}),
);
paintFormula();
if (opts.target === "new")
host.querySelector<HTMLDetailsElement>("details")?.setAttribute("open", "");
}
+9 -36
View File
@@ -258,59 +258,32 @@ def _user(uid: str, local: list[dict]) -> dict:
MACHINE_SEP = "#" # 입력 판(sub3) 기계 요소 ref 「EQ키#칸」
HOURLY = "시간당 경비" # 기계 칸 이름 — 기계 시간당 경비 로직으로 셈(기종 → 로직 짝)
_MACHINE_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용
def _machine_family() -> list[dict]:
"""기계 시간당 경비 로직 — 「건설기계」 장 파일 안 · 입력 「기계」 고르기가 있는 돈 로직."""
files = store.loaded()[0]
return [
row
for name, data in files.items()
if data.get("그룹") == "로직" and "건설기계" in name
for row in data.get("줄") or []
if str(row.get("결과단위") or "").startswith("원")
and any(i.get("이름") == "기계" and i.get("고르기") for i in row.get("입력") or [])
]
def _machine_rank(row: dict) -> tuple:
"""짝 차례(옛 EasyCalc `pickMachine` 와 같음) — 부착 전용 · 손료만 · 보정 · 기종 수 · 키."""
names = [i.get("이름") for i in row.get("입력") or []]
codes = next(i.get("고르기") for i in row["입력"] if i.get("이름") == "기계")
attach = any(n not in _MACHINE_PLAIN for n in names)
loss = "지역" not in names
return (attach, loss, loss and "보정작업" in names, len(codes), str(row.get("키")))
def _machine_hourly(key: str, inputs: dict) -> dict:
"""기계 한 시간 값 — 기종을 받는 기계경비 로직 가운데 짝 차례 첫째 · 빈 입력은 첫 고르기."""
"""기계 한 시간 값 — 식 고름 = `M02_Machine_Hourly.pick`(목록 › 꼴 · 822-2) · 빈 입력은 첫 고르기."""
from M02_MasterTemplete import M02_Machine_Hourly as mh
row, why = _brief(key, "기계")
if row is None:
return {"ok": False, "reason": why}
code = str(row.get("원문번호") or "")
fit = [r for r in _machine_family() if code in [str(c) for c in _codes(r)]]
if not fit:
return {"ok": False, "reason": f"「{row.get('이름')}」 은 시간당 경비를 셀 자료가 없음"}
logic = min(fit, key=_machine_rank)
logic = mh.pick(row)
if lack := mh.why(row, logic):
return {"ok": False, "reason": f"「{row.get('이름')}」 — {lack}"}
given = {}
for spec in logic.get("입력") or []:
name = spec.get("이름")
if name != "기계":
given[name] = inputs.get(name, (spec.get("고르기") or [None])[0])
args = "".join(f", {n}='{v}'" for n, v in given.items())
ref = f"로직({logic['키']}, 기계='{code}'{args})"
args = "".join(f", {n if n.isidentifier() else repr(n)}='{v}'" for n, v in given.items())
mine = f", 기계='{code}'" if mh.takes_machine(logic) else ""
ref = f"로직({logic['키']}{mine}{args})"
line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"}
got = _money(_calc({}, _row(line, ())), "계")
return {**got, "name": row.get("이름"), "unit": "원/시간"} if got["ok"] else got
def _codes(row: dict) -> list:
return next(
(i.get("고르기") or [] for i in row.get("입력") or [] if i.get("이름") == "기계"), []
)
def one_value(item: dict, local: list[dict] | None = None) -> dict:
"""물음 한 줄 `CfQuery {id, group, ref? · table? · unit? · field? · inputs?}` → `CfAnswer`.
+5 -4
View File
@@ -186,10 +186,11 @@ class _Collector:
return
self.add(base)
if field == dv.HOURLY:
code = str((self.whole.index.get(base[:2], {}).get(base) or {}).get("원문번호") or "")
for fit in dv._machine_family():
if code in [str(c) for c in dv._codes(fit)]:
self.logic(str(fit.get("키")))
from M02_MasterTemplete import M02_Machine_Hourly as mh
row = self.whole.index.get(base[:2], {}).get(base) or {}
if got := mh.pick(row): # 식 고름 한 곳(822-2)
self.logic(str(got.get("키")))
def logic(self, key: str) -> None:
if key in self.logics:
+9 -1
View File
@@ -24,6 +24,7 @@ from __future__ import annotations
import json
import re
from collections.abc import Callable
from contextvars import ContextVar
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -125,11 +126,18 @@ def copy_ref(body: dict[str, Any], key: str) -> Ref:
}
#: 참조 파일을 읽기 직전에 부름(826-1 층 목록 색인 기댐 · `M02_Layer_Index`) — None = 안 부름.
SEEN: ContextVar[Callable[[Path], None] | None] = ContextVar("m02_refs_seen", default=None)
def read_ref(ref: Ref) -> dict[str, Any] | None:
if is_copy(ref):
return ref["몸"]
try:
got = json.loads(ref_path(ref).read_bytes())
path = ref_path(ref)
if (note := SEEN.get()) is not None:
note(path)
got = json.loads(path.read_bytes())
except (OSError, ValueError):
return None
return got if isinstance(got, dict) else None
+221
View File
@@ -0,0 +1,221 @@
"""M02 층 목록 요약 색인(826-1 · 요구 804 · 조사 804 안 A) — 층 목록이 항목 파일을 다시 열지 않게.
항목 기록 `{자국, 판, 바탕?, 기댐?, 요약?}`(열쇠 「종류/이름」):
자국 파일 고친 때 ns : 크기 — 같으면 기억한 판(목록 표시만 · 저장 · 지움 409 는 그대로
`layers.version_of` 파일 내용)
요약 서고 줄 `M02_Labor_Ref.summary`(쓰는곳수 뺌) — 자국 · 바탕 · 기댐이 모두 같을 때만 씀
바탕 셈 코드 내용 · M01 폴더 · 층 곁 파일(내 기계 등 — 항목 폴더 · manifest 뺌) 자국 지문
기댐 셈 때 읽은 참조 파일(SC · SK) 자국(`refs.SEEN`)
쓰는곳수 = 요청마다 쓰는 곳 파일을 한 번 훑어 거꾸로 셈(`refs._ids_of` 기억 · 파일당 stat 한 번).
자리 = 프로세스 메모리 + storage `_derived/m02_layer_index/`(파생 저장본 · 층마다 한 파일 ·
참조 이름표 기억 `ids.json`) — 없거나 깨지면 다시 지음 · 지워도 됨 · 다른 PC · 옛 코드는 안 읽음.
"""
from __future__ import annotations
import hashlib
import threading
from functools import lru_cache
from pathlib import Path
from typing import Any
from common_util.common_util_derived_store import derived_dir, derived_key, file_stamp, read_derived
from common_util.common_util_json import atomic_write_json
from config import config_system
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
KIND = "m02_layer_index"
SHAPE = 1 # 기록 꼴이 바뀌면 올림(옛 기록 = 다시 셈)
IDS = "ids"
CODE_DIRS = ("M02_MasterTemplete", "M01_MasterData", "resources/master_data/scripts", "common_util")
_LOCK = threading.RLock()
_MEM: dict[str, dict[str, Any]] = {} # 층 열쇠 → {항목: 기록}
_IDS_LOADED: list[bool] = []
@lru_cache(maxsize=1)
def _code() -> str:
"""셈 코드 내용 지문 — 프로세스에 한 번(코드가 바뀌면 서버가 다시 뜸 · 같은 코드 = 같은 값)."""
digest = hashlib.blake2b(digest_size=16)
for sub in CODE_DIRS:
for path in sorted((config_system.PROJECT_ROOT / sub).glob("*.py")):
digest.update(path.name.encode())
digest.update(path.read_bytes())
return digest.hexdigest()
def _stamps(folder: Path, skip: tuple[str, ...] = ()) -> list[tuple[str, str | None]]:
"""폴더 안 파일 자국 — 첫 단 이름이 `skip` · `_` · `.` 머리인 것은 뺌."""
if not folder.is_dir():
return []
out = []
for path in sorted(folder.rglob("*")):
rel = path.relative_to(folder).parts
if rel[0] in skip or any(p.startswith(("_", ".")) for p in rel) or not path.is_file():
continue
out.append(("/".join(rel), file_stamp(path)))
return out
def _ground(folder: Path) -> str:
"""바탕 지문 — 셈 코드 · M01 폴더 · 이 층 · 지금 층 기본 자리의 곁 파일."""
from M01_MasterData import M01_MasterData_Store as m01
skip = (*layers.KINDS, layers.MANIFEST_NAME)
side = [_stamps(f, skip) for f in dict.fromkeys((folder, store.root()))]
return derived_key(SHAPE, _code(), str(m01.FOLDER), _stamps(Path(m01.FOLDER)), side)
# ── 기록 자리 ─────────────────────────────────────────────────────────
def _book(folder: Path) -> tuple[str, dict[str, Any]]:
key = derived_key(SHAPE, str(folder.resolve()))
if key not in _MEM:
got = read_derived(layers.storage_root(), KIND, key)
value = got.get("value") if got else None
_MEM[key] = value if isinstance(value, dict) else {}
return key, _MEM[key]
def _save(key: str, value: Any) -> None:
path = derived_dir(layers.storage_root(), KIND) / f"{key}.json"
try:
atomic_write_json(path, {"key": key, "value": value})
except OSError: # 파생 저장본 — 못 써도 다음에 다시 셈
pass
# ── 목록 · 판 ─────────────────────────────────────────────────────────
def list_rows(folder: str | Path) -> list[dict[str, Any]]:
"""`layers.list_templates` 와 같은 줄 — 판은 자국이 같으면 기억한 것(파일 안 엶)."""
folder = Path(folder)
with _LOCK:
key, recs = _book(folder)
dirty: list[str] = []
def version(path: Path) -> str | None:
name = f"{path.parent.name}/{path.stem}"
stamp = file_stamp(path) # 읽기 전 자국 — 그새 바뀌면 다음에 다시 셈
rec = recs.get(name)
if stamp and isinstance(rec, dict) and rec.get("자국") == stamp and "판" in rec:
return rec["판"]
recs[name] = {"자국": stamp, "판": layers.version_of(path)}
dirty.append(name)
return recs[name]["판"]
rows = layers.list_templates(folder, version)
have = {f"{row['종류']}/{row['이름']}" for row in rows}
gone = [name for name in recs if name not in have]
for name in gone:
del recs[name]
if dirty or gone:
_save(key, recs)
return rows
# ── 서고 요약 ─────────────────────────────────────────────────────────
def _good(rec: Any, stamp: str | None, ground: str) -> bool:
if not (stamp and isinstance(rec, dict) and rec.get("자국") == stamp):
return False
deps = rec.get("기댐")
if rec.get("바탕") != ground or not isinstance(rec.get("요약"), dict):
return False
return isinstance(deps, dict) and all(file_stamp(Path(p)) == s for p, s in deps.items())
def _fresh(folder: Path, kind: str, name: str, rec: Any, stamp: str | None, whole: Any) -> dict:
"""그 항목만 다시 읽어 셈 — 참조는 읽기 직전 자국을 기댐에 적음."""
from M02_MasterTemplete.M02_Labor_Expand import Expand
path = folder / kind / f"{name}.json"
deps: dict[str, str | None] = {}
def note(p: Path) -> None:
deps.setdefault(str(p), file_stamp(p))
token = refs.SEEN.set(note)
try: # 셈마다 새 펼침 — 다른 항목 셈 기억을 나눠 쓰면 기댐이 빠짐
out = refs.summary(folder, kind, name, Expand(whole), [])
finally:
refs.SEEN.reset(token)
out.pop("쓰는곳수", None)
same = isinstance(rec, dict) and rec.get("자국") == stamp and "판" in rec
return {
"자국": stamp,
"판": rec["판"] if same else layers.version_of(path),
"기댐": deps,
"요약": out,
}
def summaries(folder: str | Path, rows: list[dict[str, Any]]) -> None:
"""`refs.summaries` 와 같은 칸 · 차례 — 기록이 맞으면 파일 안 엶 · 다르면 그 항목만 셈."""
folder = Path(folder)
lib = [row for row in rows if row["종류"] in layers.LIBRARY_KINDS]
if not lib:
return
with _LOCK:
key, recs = _book(folder)
ground, whole, dirty = _ground(folder), None, False
for row in lib:
name = f"{row['종류']}/{row['이름']}"
path = folder / row["종류"] / f"{row['이름']}.json"
stamp = file_stamp(path)
if not _good(recs.get(name), stamp, ground):
if whole is None:
from M01_MasterData import M01_MasterData_Store as m01
whole = m01.loaded()[1]
rec = _fresh(folder, row["종류"], row["이름"], recs.get(name), stamp, whole)
recs[name] = {**rec, "바탕": ground}
dirty = True
row.update(recs[name]["요약"])
if dirty:
_save(key, recs)
sk = [row for row in lib if row["종류"] == "sangeun" and "고친줄수" in row]
if sk:
uses, (layer, owner) = _uses(), refs.where(folder)
for row in sk:
rid = refs.ref_id({"층": layer, "주인": owner, "키": row["이름"]})
row["쓰는곳수"] = uses.get(rid, 0)
# ── 쓰는 곳 수 ────────────────────────────────────────────────────────
def _uses() -> dict[str, int]:
"""참조 이름표 → 담은 파일 수(`refs.users` 개수와 같음) — 쓰는 곳 파일 한 번 훑음."""
_load_ids()
before = {path: hit[0] for path, hit in refs._IDS.items()}
files = [path for _what, _at, path in refs.use_files()]
count: dict[str, int] = {}
for path in files:
got = refs._ids_of(path)
for rid in got[1] if got else ():
count[rid] = count.get(rid, 0) + 1
if any(before.get(path) != refs._IDS[path][0] for path in files if path in refs._IDS):
_save_ids()
return count
def _load_ids() -> None:
"""참조 이름표 기억을 파생 저장본에서 채움 — 프로세스에 한 번 · 자국은 `_ids_of` 가 다시 봄."""
if _IDS_LOADED:
return
_IDS_LOADED.append(True)
got = read_derived(layers.storage_root(), KIND, IDS)
for text, one in ((got or {}).get("value") or {}).items():
try:
mtime, size, name, ids = one
refs._IDS.setdefault(Path(text), ((int(mtime), int(size)), (str(name), frozenset(ids))))
except (TypeError, ValueError):
continue
def _save_ids() -> None:
"""지금 기억을 디스크 것에 덧씀(다른 서버 몫 유지) · 없어진 파일 뺌."""
got = read_derived(layers.storage_root(), KIND, IDS)
value = dict((got or {}).get("value") or {})
for path, ((mtime, size), (name, ids)) in list(refs._IDS.items()):
value[str(path)] = [mtime, size, name, sorted(ids)]
value = {text: one for text, one in value.items() if Path(text).is_file()}
_save(IDS, value)
+76 -9
View File
@@ -7,17 +7,30 @@
마스터 파일은 읽기만 · 로그인 사용자 모두.
기계표 = M01 + 지금 층 내 기계(821-1 · `M02_MyMachine` — 식은 채운 칸으로 고른 같은 GC 식 · 직접 = 그 값) ·
층을 모르는 길(`/api/m02/logic-lib/machine-hourly`)은 M01 만.
식 고름 한 곳(822-2 · `pick`) = ㉠ 원문번호가 식 「기계」 목록에 있으면 그 식(짝 차례) ㉡ 그 기계만의 식
(`_own`) ㉢ 없으면 꼴(채운 칸 · `M02_MyMachine.logic_key`) — M01 [새 기계] · 불러오기 · 데이터 흐름 ·
줄 쌓기 · 단가산출 기초가 다 이것을 씀 · 못 고름 · 값 모자람 = 까닭 글(`why`).
"""
from __future__ import annotations
import re
from typing import Any
from M01_MasterData import M01_MasterData_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
cm = store.cm
_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용
_PLAIN = {
"기계",
"지역",
"보정작업",
"조종원직종",
"전력 계약종별",
} # 이 밖의 입력 = 부착 장비 전용
_TEXT = {"주연료종류", "조종원직종"} # 글 칸 — 나머지 기계 줄 값 칸은 수
_FIELD = re.compile(r"EQ:\{기계\}\.([^\s*/+()\-,]+)")
HOUR_UNIT = "10^-7/시간"
def _family() -> list[dict]:
@@ -54,12 +67,27 @@ def _operated(code: str) -> bool:
def logic_of(code: str) -> dict | None:
"""기계 원문번호 → 그 기계 시간당 경비 로직(짝 차례 첫째) · 없으면 None(813-2 M01 화면 ·
불러오기 `M02_Doc_Import.hourly_link` 도 씀). 기계표 조종원 > 0 = 조종원 호표 있는 식 먼저
(「조종원 없는 기종」 목록에 겹친 기계 — 진동롤러(핸드가이드식) · 고소작업차 …)."""
fit = [r for r in _family() if code in [str(c) for c in _codes(r)]]
"""기계 원문번호 → 그 기계 시간당 경비 로직 · 없으면 None(813-2 M01 화면 · 불러오기
`M02_Doc_Import.hourly_link` 도 씀) — `pick` 과 같음(그 원문번호 기계 줄로)."""
index = mym.whole()
row = (index.numbers.get("EQ") or {}).get(code) or {"원문번호": code}
return pick(row)
def listed(code: str) -> bool:
"""원문번호가 어느 기계경비식 「기계」 목록에 있는지(M01 [새 기계] 식 등록 · 상세 「쓰는 계산식」)."""
return bool(code) and any(code in [str(c) for c in _codes(r)] for r in _family())
def pick(row: dict) -> dict | None:
"""기계 줄 → 시간당 경비 식 — ㉠ 목록 ㉡ 그 기계만의 식 ㉢ 꼴 · 못 고름 = None(까닭 = `why`)."""
code = str(row.get("원문번호") or "")
fit = [r for r in _family() if code and code in [str(c) for c in _codes(r)]]
if not fit:
return _own(code) or mym.logic_for(code)
if got := _own(code):
return got
key = None if _day_loss(row) else mym.logic_key(row)
return mym.whole().logic(key) if key else None
op = _operated(code)
def order(row: dict) -> tuple:
@@ -84,6 +112,45 @@ def _own(code: str) -> dict | None:
return None
def _day_loss(row: dict) -> str:
"""손료계수 단위가 시간당(10⁻⁷)이 아님(산림품셈 「1일 1대당」) — 그 단위 글 · 아니면 빈 글."""
unit = str((row.get("단위") or {}).get("손료계수") or HOUR_UNIT)
return "" if unit == HOUR_UNIT or "손료계수" not in (row.get("값") or {}) else unit
def _is_num(v: Any) -> bool:
try:
float(v)
except (TypeError, ValueError):
return False
return not isinstance(v, bool)
def why(row: dict, logic: dict | None) -> str:
"""시간당 경비를 못 내는 까닭 한 줄(값이 정말 모자람) · 셀 수 있으면 빈 글."""
vals = row.get("값") or {}
if logic is None:
if unit := _day_loss(row):
return f"손료계수 단위 「{unit}」 — 시간당 식 없음(일 단위 손료)"
if not _is_num(vals.get("주연료")) and vals.get("주연료") not in (None, ""):
return f"주연료 「{vals['주연료']}」 — 수 아님"
if float(vals.get("조종원") or 0) > 0 and not 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
lost, odd = [], []
for name in dict.fromkeys(_FIELD.findall(text)):
v = vals.get(name)
if v in (None, ""):
lost.append(name)
elif name not in _TEXT and not _is_num(v):
odd.append(f"{name} 「{v}」 — {'범위' if '∼' in str(v) else '수 아님'}")
elif name == "주연료종류" and f"{v}:전국평균" not in (mym.whole().numbers.get("MO") or {}):
odd.append(f"주연료종류 「{v}」 — 유가 표에 없음")
return " · ".join([*([f"{' · '.join(lost)} 없음"] if lost else []), *odd])
def takes_machine(logic: dict) -> bool:
"""식이 「기계」 입력(원문번호 고르기)을 받는지 — 안 받는 식(GF000145)엔 기계='…' 를 안 넘김."""
return any(i.get("이름") == "기계" for i in logic.get("입력") or [])
@@ -100,9 +167,9 @@ def hourly(key: str) -> dict[str, Any]:
if mym.mine(key):
return mym.hourly(row, whole)
code = str(row.get("원문번호") or "")
logic = logic_of(code)
if not logic:
return {"ok": False, "reason": f"「{row.get('이름')}」 은 시간당 경비를 셀 자료가 없음"}
logic = pick(row)
if lack := why(row, logic):
return {"ok": False, "reason": lack, **({"로직": logic["키"]} if logic else {})}
args = "".join( # 빈칸 든 입력 이름(「전력 계약종별」 · 820-1 파워트로웰)은 따옴표로
f", {n if (n := i['이름']).isidentifier() else repr(n)}='{(i.get('고르기') or [''])[0]}'"
for i in logic.get("입력") or []
@@ -18,7 +18,7 @@
* · (785-3) `oil` = M01 유가전력(MO · `/logic-lib/elements?group=재료` 의 MO 줄) 고름 · 고정 줄 꼴 = 이음 유가 MO ·
* 재료비 = 지금 값(셈은 M01 값) · 「M01 전체」 탭 재료 아래
* · (821-3) `mine` = 개인 층 내 기계 묶음(품셈 기계 위 · 구분 「내 기계」 · 출처 「개인 키」 · 금액 = 서버 시간당) ·
* 머리 [새 기계 만들기](양식 창 → 저장하면 그 기계로 고름) · 「시간당 값 직접」 내 기계 = 줄 쌓기 식 없음 → 회색
* 머리 [새 기계 만들기](양식 창 → 저장하면 그 기계로 고름) · 「시간당 값 직접」 내 기계 = 직접 값 식 GC001199(822-2) · 식 없는 내 기계 = 회색
* ========================================================================== */
import { createDataTable } from "@ui/ui_template_data_table";
@@ -288,7 +288,7 @@ export function openFindPicker(opt: FindPickOptions): void {
: take(() => put(e)),
};
});
/** 821-3 내 기계 줄 — 금액 = 서버 시간당 · 직접 = 회색(줄 쌓기 식 없음) */
/** 821-3 내 기계 줄 — 금액 = 서버 시간당 · 식 없음(서버 로직 없음) = 회색 · 직접 = GC001199(822-2) */
const mineLines = (q: string): Line[] => {
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
return mines
@@ -27,6 +27,7 @@ from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool, run_with_connection
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_Layer_Index as index
from M02_MasterTemplete import M02_Library_Flat as flat
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
@@ -275,14 +276,14 @@ async def list_layer(
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
folder = await _layer_dir(session, layer, project_id=project_id, user_id=user_id, owner=owner)
rows = await asyncio.to_thread(layers.list_templates, folder)
rows = await asyncio.to_thread(index.list_rows, folder) # 판 = 자국 기억(826-1)
manifest = await asyncio.to_thread(layers.read_manifest, folder)
for row in rows:
row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}")
if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음
row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])]
# 서고(50-3) — 보이는 이름 · 단위 · 기준 금액(51-4)
await asyncio.to_thread(refs.summaries, folder, rows)
await asyncio.to_thread(index.summaries, folder, rows) # 요약 색인(826-1)
return {"층": layer, "양식": rows}
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` ·
* 50-3 · 134-3) › 「내 기계」 · 「내 품목」(821-3 · 개인 층 · 펼치면 가운데 화면) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
* 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 「개인관리」(824-1 · 단추 「기계」 · 「자재」 · 가운데 화면) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기).
* 구조물 옵션 정의는 M01 로 옮김(716-1).
* 도면 양식 = 시스템 관리자면 늘(마스터 층 것 · 구조물 조합 층과 무관 · 720-4).
@@ -157,17 +157,16 @@ export function buildSide(opt: SideOptions): SideHandle {
getOpen: opt.getOpen,
});
const sangeunSec = section(L("M02_SangeunTitle"), sangeun.body);
// 내 기계 · 내 품목(821-3) — 자기 개인 층 · 펼치면 가운데 화면(목록 · 양식)
const hint = (key: "Side_Hint" | "Items_Side_Hint"): HTMLElement =>
createEmpty({ text: mt(key), small: true });
const myMachSec = openOnExpand(section(mt("Title"), hint("Side_Hint")), async () => {
// 개인관리(824-1) — 단추 「기계」 · 「자재」(821-3 · 자기 개인 층) · 누르면 가운데 화면(목록 · 양식)
const myMachBtn = createButton({ label: mt("Title"), variant: "ghost", size: "sm" });
myMachBtn.addEventListener("click", async () => {
if (!(await opt.confirmLeave())) return;
let mark = (_d: boolean): void => {};
const node = buildMyMachineView({ owner: null, setDirty: (d) => mark(d) });
mark = opt.showView(mt("Title"), node);
mark = opt.showView(mt("Crumb_Machine"), node);
});
myMachSec.dataset.myMachines = "";
const myItemsSec = openOnExpand(section(mt("Items_Title"), hint("Items_Side_Hint")), async () => {
const myItemsBtn = createButton({ label: mt("Items_Title"), variant: "ghost", size: "sm" });
myItemsBtn.addEventListener("click", async () => {
if (!(await opt.confirmLeave())) return;
const node = buildMyItemsView({
owner: null,
@@ -184,8 +183,13 @@ export function buildSide(opt: SideOptions): SideHandle {
});
},
});
opt.showView(mt("Items_Title"), node);
opt.showView(mt("Crumb_Items"), node);
});
const personalSec = section(
mt("Personal_Title"),
el("div", { className: "m02-side__row", children: [myMachBtn, myItemsBtn] }),
);
personalSec.dataset.myMachines = "";
// 실무자료로 일위대가를 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝
const onBuilt = (): void =>
precombo.body.isConnected
@@ -205,9 +209,8 @@ export function buildSide(opt: SideOptions): SideHandle {
children: [
sangeunSec,
precomboSec,
myMachSec,
myItemsSec,
summary,
personalSec,
createDivider(),
section(hopyo.title, hopyo.body),
drawing,
@@ -16,6 +16,11 @@
gap: var(--spacing-4);
}
/* 개인관리(824-1) — 제목 아래 단추 줄 띄움 */
[data-my-machines] > .m02-side__row {
margin-top: var(--spacing-12);
}
/* 일위대가 조합 서고 목록표(51-4) — 표 꼴은 M01 목록(`m01-master__grid`) · 첫 칸 = 고름 */
.m01-master__grid .m02-lib__pick {
width: 2.5rem;
+1 -1
View File
@@ -28,7 +28,7 @@ export function buildMyItemsView(opt: MyItemsViewOptions): HTMLElement {
const uses = createFindList({ placeholder: mt("Open_Sk") });
uses.root.hidden = true;
const table = createDataTable({
caption: mt("Items_Title"),
caption: mt("Crumb_Items"),
columns: [
{ key: "이름", label: mt("Name"), width: 200 },
{ key: "규격", label: mt("Spec"), width: 140 },
+49 -9
View File
@@ -10,8 +10,10 @@
내 기계 없는 층 = M01 그대로. 단가산출 사본 · [단가 갱신] · 「단가새값」(`M02_Sangeun_Copy`) ·
기계 찾기(`M02_Machine_Find`) · 시간당(`M02_Machine_Hourly`) · 사본 모으기(`M02_Item_Prices.collect`)가
이 색인을 읽음 — 층을 모르는 길(`/api/m02/logic-lib/machine-hourly` 등)은 M01 만.
식 고름(채운 칸) = 연료 · 조종원 둘 다 GC000268 · 연료만 GC000272 · 조종원만 GC000276 · 없음 GC000273.
전력 칸은 담기만(M01 기계경비 식에 전력 줄 없음).
식 고름(채운 칸 · `logic_key` — M01 기계 목록 밖 줄도 같은 규칙 · 822-2) = 연료 + 조종원 GC000268(직종 없음
GC000275) · 연료만 GC000272(손료계수 한 칸 GC001198) · 조종원만 GC000276 · 전력 GC001196(+ 조종원 GC001197) ·
연료 + 전력 GC001200(+ 조종원 GC001201 · 822-2b 경쟁사 기계) ·
없음 GC000273 · 직접 GC001199(시간당 값 = 셈 꼴 값 칸 노무비 · 재료비 · 경비).
권한(본인 개인 층 · 맡은 회사 관리자)은 길(`M02_MyMachine_Router`) 몫.
"""
@@ -43,6 +45,9 @@ UNITS = {
} # fmt: skip
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:
@@ -80,6 +85,8 @@ def rows(folder: Path | None = None) -> list[dict]:
def engine(row: dict) -> dict:
"""셈 꼴 — 바로 = 상각비계수 ← 손료계수 · 정비 · 관리 0 · 빈 연료 · 조종원 칸 = 0 · 수 = Decimal."""
vals = dict(row.get("값") or {})
if row.get("방식") == DIRECT: # 직접 식(GC001199)이 읽는 칸
vals = {k: (row.get("시간당") or {}).get(k) or 0 for k in COSTS}
if row.get("방식") == EASY:
vals |= {"상각비계수": vals.get("손료계수") or 0, "정비비계수": 0, "관리비계수": 0}
if row.get("방식") != DIRECT:
@@ -123,18 +130,41 @@ def keys_in(doc: Any) -> list[str]:
return sorted(set(FIND.findall(json.dumps(doc, ensure_ascii=False, default=str))))
def logic_key(row: dict) -> str:
def _pos(v: Any) -> bool:
try:
return not isinstance(v, bool) and float(v) > 0
except (TypeError, ValueError):
return False
def logic_key(row: dict) -> str | None:
"""꼴(채운 칸) → 시간당 경비 식 키 · 못 고름(직종 없는 조종원 · 수 아닌 연료 글) = None ·
계수 셋(상각 · 정비 · 관리) 없음 = 손료계수 한 칸 식(연료만) · 그 밖은 그 식 + 모자란 칸 까닭."""
if row.get("방식") == DIRECT:
return DIRECT_KEY
vals = row.get("값") or {}
return LOGIC[(float(vals.get("주연료") or 0) > 0, float(vals.get("조종원") or 0) > 0)]
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:
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:
return LOGIC[(True, False)] if three else ONE_FUEL
if crew:
return LOGIC[(fuel, True)] if job else JOB_IN if fuel else None
return LOGIC[(False, False)]
def logic_for(code: str) -> dict | None:
"""내 기계 원문번호 → 시간당 경비 식(채운 칸으로 고름) · 내 기계 아님 = None."""
index = whole()
row = (index.numbers.get("EQ") or {}).get(code)
if row is None or not mine(row.get("키")) or row.get("방식") == DIRECT:
return None
return index.logic(logic_key(row))
key = logic_key(row) if row is not None and mine(row.get("키")) else None
return index.logic(key) if key else None
def hourly(row: dict, index: Any) -> dict[str, Any]:
@@ -142,11 +172,21 @@ def hourly(row: dict, index: Any) -> dict[str, Any]:
head = {"이름": row.get("이름"), "규격": row.get("규격"), "원문번호": row.get("원문번호")}
if row.get("방식") == DIRECT:
sums = {k: float((row.get("시간당") or {}).get(k) or 0) for k in COSTS}
return {"ok": True, **head, "로직": None, "직접": True, **sums, "계": sum(sums.values())}
return {
"ok": True,
**head,
"로직": DIRECT_KEY,
"직접": True,
**sums,
"계": sum(sums.values()),
}
import master_cut as mcut
mf = m01.cm.mf
logic = index.logic(logic_key(row))
key = logic_key(row)
if key is None:
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("원문번호"))
try:
+6 -5
View File
@@ -4,8 +4,11 @@
* ========================================================================== */
const TEXT = {
Title: "내 기계",
Items_Title: "내 품목",
Personal_Title: "개인관리",
Title: "기계",
Items_Title: "자재",
Crumb_Machine: "개인관리 › 기계",
Crumb_Items: "개인관리 › 자재",
New: "새 기계",
Copy_Title: "복사해 내 기계로",
Find: "이름 · 규격 · 키로 찾기",
@@ -38,8 +41,6 @@ const TEXT = {
No_Uses: "쓰는 단가산출 없음",
Leave_Ask: "저장 안 한 고침을 버리고 닫을까?",
Mine: "내 기계",
Side_Hint: "펼치면 가운데에 목록 · 양식",
Items_Side_Hint: "펼치면 가운데에 모아 보기(읽기)",
Won: "원",
Labor: "노",
Material: "재",
@@ -50,7 +51,7 @@ const TEXT = {
// 기계 고르기 창 · 줄 쌓기
Pick_Group: "내 기계",
Pick_New: "새 기계 만들기",
Pick_Direct: "시간당 값 직접 기계 — 줄 쌓기 식이 없어 못 넣음",
Pick_Direct: "시간당 경비 식이 없는 기계 — 줄 쌓기에 못 넣음",
To_Mine: "복사해 내 기계로",
// 내 품목
Items_Note:
+2 -2
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* M02_MyMachine_View.ts
* M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「내 기계」 칸을 펼치면 가운데에 뜸(개인 층).
* M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「개인관리」 › [기계] 단추로 가운데에 뜸(개인 층).
* 목록 = 이름 · 규격 · 방식 · 시간당 경비(서버 셈) · 쓰는 곳 수 · 넣은 날 · 고친 날 · 찾기 · [새 기계]
* 줄 누름 = 양식 한 장(`M02_MyMachine_Form`) · [목록] = 저장 안 한 고침이면 물음 · 저장 · 지우기 뒤 목록 다시
* ========================================================================== */
@@ -33,7 +33,7 @@ export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement {
const hint = createText("");
const fail = el("div");
const table = createDataTable({
caption: mt("Title"),
caption: mt("Crumb_Machine"),
columns: [
{ key: "이름", label: mt("Name"), width: 180 },
{ key: "규격", label: mt("Spec"), width: 120 },
+5 -3
View File
@@ -132,8 +132,10 @@ def _modified(path: Path) -> str:
return utc_from_timestamp(path.stat().st_mtime)
def list_templates(layer_dir: str | Path) -> list[dict[str, Any]]:
"""층 하나의 양식 목록 `[{종류, 이름, 판, 수정일}]` — 종류 · 이름 차례."""
def list_templates(layer_dir: str | Path, version: Any = None) -> list[dict[str, Any]]:
"""층 하나의 양식 목록 `[{종류, 이름, 판, 수정일}]` — 종류 · 이름 차례 ·
`version` = 판 셈(없으면 `version_of` · 층 목록은 자국 기억 `M02_Layer_Index`)."""
version = version or version_of
base = Path(layer_dir)
rows: list[dict[str, Any]] = []
for kind in KINDS:
@@ -147,7 +149,7 @@ def list_templates(layer_dir: str | Path) -> list[dict[str, Any]]:
{
"종류": kind,
"이름": path.stem,
"판": version_of(path),
"판": version(path),
"수정일": _modified(path),
}
)
+2 -2
View File
@@ -5,7 +5,7 @@
* ⚠ 짝: `resources/master_data/scripts/master_machine_coef.py` — 같은 셈 · 같은 차례
* (거울 시험 `resources/tester/test_821_2_machine_coef.py`). 한쪽만 고치면 화면과 저장이 갈림.
* 계수 = 건설품셈 공통 8-1-5(계수마다 정수 반올림 · 손료계수 = 합) · 미리보기 끝수 = 서버
* 기계경비식(줄 0.1 원 아래 버림 · 잡품 = 버린 주연료 × % · 비목 합 원 미만 버림) · 정본은 서버 식.
* 기계경비식(줄 0.1 원 아래 버림 · 잡품 = 버린 (주연료 + 전력) × % · 비목 합 원 미만 버림) · 정본은 서버 식.
* ========================================================================== */
export const MACHINE_INPUTS = [
@@ -73,8 +73,8 @@ export function machineHourly(
): 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 misc = tenth(((oil + elec) * num(values["잡재료율"])) / 100);
const crew = tenth(num(values["조종원"]) * hour * num(wage));
const labor = Math.floor(crew + E);
const material = Math.floor(oil + misc + elec + E);
Binary file not shown.
@@ -0,0 +1,63 @@
# 조사 804 — 영구저장소 꼴과 속도 (요구 804 · 실측 + 코드 조사 · 결정 전)
## 1. 저장 꼴(실측)
| 자료 | 꼴 | 크기 |
|---|---|---|
| M01 기계 | 파일 하나 | 1.5 MB |
| M01 자재 | 파일 하나(30,711 줄) | 15 MB |
| M01 로직 · 수식 문서 | 품셈 장마다(118 파일) | 11.7 MB |
| M02 단가산출(SK) | 항목마다 파일 | 평균 2.2 MB · 개인 층 47 건 102 MB · 마스터 층 34 건 126 MB |
| M02 일위대가 · 사전조합 | 항목마다 파일 | 1 ~ 3 MB(같은 사본을 품음) |
| DB(MariaDB) | 회사 · 사용자 · 프로젝트 행만 | 목록 · 색인 · 잠금은 전부 파일 |
- SK 한 건 속(SK-000048): 문서 본체 2.5 천 자 · 가져온 정보 3.6 천 자 · M01 값 사본 15 만 자가 `사본` · `수식` 두 자리에 같은 내용으로 두 번 → 파일의 약 99 % 가 사본.
- 같은 지문(단가판)의 값 묶음도 SK 마다 따로 저장(공유 없음) · 사본에는 기계 고르기 후보 줄까지 모두 들어감(177 줄).
## 2. 시간(메인 서버 · 따뜻한 상태 · 읽기만 잼)
| 조작 | 시간 |
|---|---|
| M01 기계 표 한 쪽 | 0.03 초 |
| M01 자재 표 한 쪽 · 찾기 | 0.2 초 |
| M01 단가산출 기초 목록 | 0.1 ~ 0.4 초 |
| M02 SK 한 건 열기 | 0.07 초 |
| M02 개인 층 목록(47 건) | 3.1 ~ 3.5 초 |
- 쓰기 시간은 사용자 자료를 건드리게 되어 재지 않음(코드로만 확인).
## 3. 느린 까닭(코드)
1. 층 목록 — 판을 만들려고 SK 파일을 통째로 읽고(sha256) · 요약 때 다시 파싱 · 기준 금액 셈 때 또 파싱 → SK 한 건에 읽기 세 번 + 셈 · 요청 사이 기억 없음 · 목록용 요약 파일 없음(`M02_Template_Layers.list_templates` · `M02_Labor_Ref.summaries`).
2. M01 저장(별칭 한 칸도) — 15 MB 파일 다시 씀 · 폴더 전체(26 MB) 두 번 읽음 · 전체 검사 두 번(모든 층 일위대가 · 사전조합 파일을 두 번 다 파싱) · 그 뒤 기억 다시 지음(`M01_MasterData_Store.save` · `Store_Cache.check_saved`).
3. SK 저장 — M01 줄 전체 훑기(사본 모으기)를 저장 한 번에 두 번 이상 · SK 파일을 디스크에서 여러 번 다시 읽음 · 셈 반복 · 2 MB 를 들여쓰기 + fsync 로 씀(`clean_doc` → `settle` → `stamp` · `grow` → `prices.collect`).
4. 사본 중복 — 위 1 ~ 3 의 읽기 · 쓰기 비용을 모두 키움.
5. M01 표 읽기(`rows` · `tables` · `groups`) — 기억을 안 쓰고 요청마다 파일 파싱(지금은 0.2 초라 급하지 않음).
## 4. 판단
- 「층마다 한 파일」 이 아님 — M02 는 항목마다 파일이고 · 문제는 그 한 건이 사본으로 부풀어 있는 것 + 목록 · 저장이 큰 파일을 되풀이 읽고 쓰는 것.
- 저장 꼴을 통째로 바꾸기(DB 이전)보다 아래 차례가 효과 대비 위험이 작음.
| 차례 | 안 | 기대 | 위험 |
|---|---|---|---|
| A | 층마다 목록 요약 색인(이름 · 판 · 기준 금액 · 쓰는 곳 수 — 저장 때 갱신 · 어긋나면 다시 지음) | 층 목록 3.3 초 → 0.1 ~ 0.3 초 | 작음 |
| B | 저장 길 줄이기 — M01 저장: 폴더 두 번 읽기 · 전체 검사 두 번을 바뀐 것만 · SK 저장: 모으기 · 다시 읽기 · 셈 한 번씩 | 저장 · 불러오기(항목 수 × 저장) 시간 | 중간 |
| C | 사본 줄이기 — ① `사본` · `수식` 한 번만 ② 실제로 읽은 줄만 ③ 같은 지문 묶음은 한 번만 저장하고 SK 는 지문만 가리킴 | SK 2.2 MB → 수십 KB · 층 100 MB → 수 MB · 열기 · 저장 · 복사 · 시놀로지 동기화 모두 가벼워짐 | 중간(기존 파일 옮기기 · 안 쓰는 묶음 치우기 · 프로젝트 복사본) |
| D | 목록 · 색인 · 잠금을 DB 로 | 여러 서버 · 동시 저장 안전 | 큼 · 지금 속도 문제엔 필요 없음 → 보류 |
- 「만들 때 복사 · 그 뒤 연결 끊김」 원칙은 A ~ C 어느 것에서도 그대로(C ③ 의 묶음은 지문으로 굳은 사본).
## 5. 사본에 든 줄의 실제 쓰임(요구 806 · SK-000048 실측)
- 이 단가산출이 실제 쓰는 값 = 기계 둘(굴착기(무한궤도) 0.7㎥ · 진동롤러(자주식) 12ton) + 경유 전국평균 한 줄 + 건설기계운전사 한 줄 = 4 줄.
- 사본에 든 값 = 177 줄: 기계 138(두 계산식의 「기계」 고르기 후보 전부 — GC000267 9 대 + GC000269 129 대 · 14.6 만 자) · 유가 36(휘발유 · 경유 × 지역 18) · 직종 3 → 173 줄(약 98 %)은 이 단가산출이 안 쓰는 값.
- 까닭 = 사본 모으기가 「식이 고를 수 있는 후보 전부」 를 닫힌 묶음으로 박음(옵션을 바꿔도 사본 안에서 셈하려는 뜻).
- 안 C ② 가 이것 — 실제로 읽은 줄만 박고 · 옵션 · 기계를 바꿔 새로 필요한 줄은 바꾸는 때의 M01 값으로 가져와 저장 때 박음(822-1 이 넣은 「읽은 줄을 저장 때 박기」 길 재사용).
## 6. 사용자 결정(요구 808) · 진행 차례
- 기계 = 항목 하나가 파일 하나(M01 기계 · 내 기계) · 자재는 한 파일 그대로 · A · B · C 진행.
- C 는 ②(실제로 읽은 줄만)부터 — 177 줄 → 4 줄이면 ①(두 번 든 것) · ③(공유 저장)의 남는 효과가 작고 · ② 는 옛 코드가 읽어도 무해(없는 줄은 지금 M01 값으로 읽음) · ① · ③ 은 파일 꼴이 바뀌어 **데스크탑(임시 배포 · 옛 코드)이 같은 storage 를 읽는 동안**은 깨질 수 있어 전역 깃 싱크와 묶을 때만.
- 바꿔 끼운 기계 · 옵션의 값 = 바꾸는 때의 M01 값(정함 · 브레인).
- 걸음: 826-1 층 목록 요약(sub2 · 바로) → 826-2 M01 기계 항목별 파일 · 내 기계 항목별 파일 · M01 저장 길 줄이기(sub1 · 822-2b 뒤) → 826-3 사본 = 읽은 줄만 · SK 저장 길 줄이기(sub3 · 823-1 뒤).
@@ -1041,3 +1041,38 @@ STmate 의 「굴삭기(1.0m3)은 무한궤도
1. 추가 확인사항 sk-000048 항목은 내가 다른 경쟁사의 프로그램에서 가져오기 테스트를 하고 있음. 정상적으로 가져온것같고 여기서 기계를 변경했는데 산출근거의 데이터가 업데이트 안됨. 조사 시작
2. 단가산출에서 기계의 우측에 복사해 내 기계로 항목이 있음. 복사해서 내기계로 가져갈 필요가 없어보임. 기계의 시스템 관리자를 제외한 회사관리자와 일반사용자는 에서 항목추가로 추가하는게 만들어서 하는게 어떤지. 검토 .
## 요구 801 (11-12 · 사용자 · 1 그 말이 아니라 단가산출 상세 화면에서 0.7㎥ 를 1.0㎥ 로 바꿨는데 우측 산출 로직 값이 안 바뀌어 확인하라는 것 · 다시 확인 · 2 새 기계 추가의 직종 = 내용이 많아 드롭다운에 검색 필요 · 검색되면 적당히만 보여 줌 · 지정된 경우 다시 열면 선택된 항목 주변을 보여 주는 기능은 유지)
1. 그말이 아니라 내가 단가산출 상세화면에 접근해서 0.7m2 을 1.0m2으로 변경해봤는데 우측 산출로직에서 값 변경이 없어서 확인하라고 한거야. 다시 확인
2. 새 기계 추가에서 직종의 경우 내용이 많아 드롭다운 항목들 중 검색도 있어야함. 검색이 되면 많은 항목을 보여줄필요없으니 적당히만 보여주고 추가로 지금은 지정된 경우 다시 열면 선택된 항목이 주변을 보여줌. 이 기능은 유지.
## 요구 802 (11-12 · 사용자 · M02 — 내 기계 · 내 품목을 「개인관리」 컨테이너로 병합 · 안에 단추 둘 · 이름 「기계」 · 「자재」 · 「구조물 조합」 컨테이너를 「일위대가」 바로 아래로)
M02에서 내 기계와 내 품목은 개인관리 컨테이너로 병합함. 내부에는 2개의 버튼으로 해당페이지를 열면됨. 내 기계의 이름은 기계, 내 품목은 자재로 변경, 그리고 일위대가 바로 아래에 구조물 조합 컨테이너가 들어보게 변경
## 요구 803 (11-12 · 사용자 · 내장 캐드 — 드래그하면 그리드 표현이 고정돼 있음 · 따라가는 게 맞는 것 같은데 어떻게 생각하는지)
내장 캐드에서 드래그를 하면 그리드 표현이 고정되어있음. 따라가는게 맞는것같은데? 어떻게 생각함.
## 요구 804 (11-12 · 사용자 · 영구저장소 데이터 형태 조사 — M01 기계 · 단가산출 기초 · M02 단가산출 · 일위대가 · 구조물 조합 · 내 기계가 마스터 · 회사 · 개인마다 각각 하나의 파일로 저장 → 불러오기 · 업데이트가 느린 것으로 판단 · 조사부터 하고 판단)
다음 조사는 영구저장소의 데이터 형태에 대한 고민, 내가 보기에는 현재 영구저장소에 저장되는 M01 기계, 단가산출 기초, M02의 단가산출, 일위대가, 구조물조합, 내 기계 등이 각각 마스터, 회사, 개인 항목에 따라 각각 하나의 파일로 저장되어있어. 불러오기나 업데이트에 속도가 느린걸로 판단됨. 너의 생각은 조사부터 해보고 판단하자.
## 요구 805 (11-12 · 사용자 · 내장 캐드 그리드 — 오토캐드처럼 줌 인 · 아웃에 따라 크기 · 표현되는 수를 제어 · 많이 줌 아웃해도 느려지지 않고 보기 좋게 · 확인하고 반영)
내장 캐드에서 그리드 표현은 오토캐드를 참고해보면 줌인아웃에 따라 크기나 표현되는 숫자를 제어하여 효과적으로 보여줌. 그래야 많이 줌아웃하면 느려지지 않고 보기 좋을테니까. 확인해보고 반영해줘.
## 요구 806 (11-12 · 사용자 · 속도 조사에서 이상한 점 — M01 정보를 사본으로 가져가는 건 좋은데 상관없는 내용도 같이 갖고 있는 것 아닌지)
나는 너가해준 속도 느림에서 이상하다고 느낀 부분이 M01의 정보를 사본으로 가져가는건 좋은데 내가 보기에는 상관없는 내용도 같이 가지고 있는거 아니야?
## 요구 807 (11-12 · 사용자 · A · B · C 는 잠시 대기 · 물음 먼저 — 단가산출의 항목 · 기계의 항목이 하나의 파일인지)
ABC는 잠시대기, 내 질문에 대한 대답부터 단가산출의 항목, 기계의 항목이 하나의 파일이야?
## 요구 808 (11-12 · 사용자 · 1 기계 하나의 항목은 하나의 파일로 분리하는 게 맞아 보임 · 2 자재는 어쩔 수 없음 · 3 A · B · C 진행)
1. 기계 하나의 항목은 하나의 파일로 분리하는게 맞아보임.
2. 자재는 어절수 없음.
3. ABC 진행
File diff suppressed because one or more lines are too long
@@ -0,0 +1,310 @@
"""기계 시간당 경비 식 완성(PLAN 822-2 · 요구 797 · 798 · 799) — 기계표 · 08장 로직 고침(정본 저장 길).
한 번만 돎(두 번째는 바뀐 것 없음):
기계.json
· 주연료 칸에 ㎾ 글이 든 전동 기계 → 숫자 칸 `전력`(㎾ · 전동기 부하율 규정 없음 → 그대로) · 주연료 비움
(원문 글은 `값원문` 그대로 · 비고에 한 줄) · 원문 잡재료 「-」 = 잡재료율 0
· 조종원직종 13 대 = 사용자 확정(요구 799) · 아스팔트 믹싱플랜트 잡재료 「-」 = 0
· EQ000534 이름 오자 「POWER TLOWEL」 → 「POWER TROWEL」(원문 8-4-8 표기 · `옛이름` 보존)
로직_건설품셈_08장_건설기계.json
· 새 식 넷 — 전력 기종 · 전력 + 조종원 · 손료계수 한 칸 연료 기종 · 시간당 값 직접
· 손료만 식(GC000273) 목록에서 옮긴 기계 뺌 → 맞는 식 목록에 더함(보정 식 GC000274 는 그대로)
저장 = `M01_MasterData_Store_Make.save`(검사 · 키 대장 · 줄 모양 그대로 · 개정확인).
수식 문서는 뒤에 `build_수식문서_802_2.py` 로 다시.
venv python resources/master_data/scripts/build_기계시간당_822_2.py [--check]
"""
from __future__ import annotations
import copy
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT))
from M01_MasterData import M01_MasterData_Store as store # noqa: E402
from M01_MasterData import M01_MasterData_Store_Make as make # noqa: E402
MACHINE = "기계.json"
LOGIC = "로직_건설품셈_08장_건설기계.json"
JOB_SRC = "건설품셈 공통 8-1-3 5 · 건설노임 {}"
JOBS = { # 요구 799 — (직종, 까닭)
**{f"EQ00016{i}": ("1048", "건설기계관리법상 건설기계") for i in range(5, 10)},
**{
k: ("1048", "원문 8-1-3 5 표의 「쇄석기」")
for k in ("EQ000249", "EQ000250", "EQ000251", "EQ000252")
},
"EQ000347": ("1048", "원문 8-1-3 5 표의 「천공기」"),
"EQ000348": ("1048", "원문 8-1-3 5 표의 「천공기」"),
"EQ000309": ("1050", "두 법에 없는 기계"),
"EQ000433": ("1050", "두 법에 없는 기계"),
}
POWER = { # 주연료 칸 ㎾ 글 → 전력(원문 값 그대로)
"EQ000345": 330, "EQ000347": 135, "EQ000348": 239, "EQ000426": 15.1, "EQ000427": 15.1,
"EQ000529": 3.73, "EQ000530": 7.46, "EQ000531": 37, "EQ000533": 1.49, "EQ000532": 1.87,
} # fmt: skip
MISC_DASH = ("EQ000426", "EQ000427", "EQ000529", "EQ000530", "EQ000531", "EQ000533",
*(f"EQ0001{n}" for n in range(87, 92))) # fmt: skip
TYPO = ("EQ000534", "POWER TLOWEL", "POWER TROWEL")
P1, P2, N3, DIRECT = "전력", "전력조종원", "손료한칸연료", "직접"
MOVE = { # 식 → 더할 원문번호(손료만 식 GC000273 에서는 뺌)
"GC000276": ["2208-5008", "2208-5010", "2208-5012", "2208-5016", "2208-5020", "5105-0050",
"5105-0100", "5105-0150", "5105-0200", "5119-0625", "7205-0125"],
"GC000268": ["3108-0060", "3108-0080", "3108-0100", "3108-0120", "9010-0120",
"9010-0200"],
P1: ["7991-0050", "7202-0008", "7202-0010", "7991-0100", "7991-0500",
"7992-0001", "7993-0020"],
P2: ["5805-0002", "5805-0003"],
N3: ["7994-0050"],
} # fmt: skip
LOSS_ONLY = "GC000273"
#: 손료만 식에서 빼기만 — 주연료 「73.7+ 휘발유54.5」 를 풀지 않음(꼴로 고름 → 까닭 글)
DROP_ONLY = ["3450-0642"]
#: 목록 밖(꼴로 고름 → 값 모자람 까닭 글) — 가격 없는 기계를 목록에 두면 식 기본 · 옵션 대조가 갈림(802-2):
#: 아스팔트 스프레어 둘(가격 · 손료 없음 → 직종 입력 식 꼴) · TBM 4.5m(가격 없음 → 전력 식 꼴)
#: 3108-0040 = 주연료 중유 — 유가 표에 중유 없음(검사가 막음) → 목록 밖 · 꼴로 고름 → 까닭 글
LABEL = {P1: "전력", P2: "전력 조종원", N3: "손료계수 한 칸", DIRECT: "직접 값"}
_HEAD = {
"원문번호": "8",
"구분": "건설품셈 공통",
"상세구분": "08장 건설기계",
"결과단위": "원/시간",
}
_SRC = "건설품셈 공통 8-1-5 · 8-1-6"
_LOSS_ONE = {
"이름": "손료",
"식": "버림(EQ:{기계}.가격 * 1000 * EQ:{기계}.손료계수 / 10000000, 0)",
"출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4",
}
_HOUR = {
"이름": "시간환산",
"식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모",
"출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)",
}
_JOB = {"이름": "직종", "식": "EQ:{기계}.조종원직종", "출처": "건설품셈 공통 8-1-3 5"}
_CREW = {
"종류": "인력",
"요소": "LB:{직종}",
"이름": "조종원",
"단위": "인",
"수량": "EQ:{기계}.조종원 * 시간환산",
"비목": "노무비",
"출처": "건설품셈 공통 8-1-3 6",
"비고": "하루 노임 × 1/8(1일 8시간 · 건설품셈 공통 1-1-3 2)",
}
_ELEC_IN = {
"이름": "전력 계약종별",
"고르기": ["일반용(갑)Ⅰ 저압", "주택용 저압"],
"출처": "유가전력 한전 전기요금표 2025-01-01",
"비고": "품셈·계약예규에 계약종별 규정 없음(전력비는 실비 계상) — 기본 = 불러오기 기본값",
}
_ELEC = {
"종류": "재료",
"요소": {"이름": "전력량요금", "계약종별": "입력"},
"이름": "전력",
"단위": "㎾h",
"수량": "EQ:{기계}.전력",
"비목": "재료비",
"출처": "건설품셈 공통 8-1-6 3 가",
"비고": "기계 줄 전력(㎾) 그대로 — 전동기 부하율 규정 없음",
}
_FUEL = {"이름": "연료", "식": "EQ:{기계}.주연료종류", "출처": "건설품셈 공통 8-4 주①㉰"}
_FUEL_LINE = {
"종류": "재료",
"요소": "MO:{연료}:{지역}",
"이름": "주연료",
"단위": "ℓ",
"수량": "EQ:{기계}.주연료",
"비목": "재료비",
"출처": "건설품셈 공통 8-4-8",
}
_REGION = {"이름": "지역", "고르기": None, "출처": "건설품셈 공통 8-1-7 5"}
_MISC = {
"이름": "잡재료비",
"식": "재료비 * EQ:{기계}.잡재료율 / 100",
"비목": "재료비",
"출처": "건설품셈 공통 8-4 주②",
}
_LOSS_ADD = {"이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2"}
_CUT = "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음"
_S = {"기호": "S"}
def _vars(*names: str) -> dict:
sign = {"손료": "C", "시간환산": "Q"}
return {
n: {"기호": sign.get(n, "S"), "아래": n.replace("전력 계약종별", "전력계약종별")}
for n in names
}
def _machine_in(codes: list[str]) -> dict:
return {"이름": "기계", "고르기": codes}
def new_rows(regions: list) -> dict[str, dict]:
"""새 식 넷(키 없음 — 저장이 대장 다음 번호)."""
region = {**_REGION, "고르기": regions}
return {
P1: {
**_HEAD,
"이름": "기계 시간당 기계경비(전력 기종)",
"출처": f"{_SRC} · 8-1-6 3 가",
"부품": {"갈래": "기계경비식"},
"소유": "공용",
"비고": "원문 운전경비 표 주연료 칸에 ㎾ 가 찍힌 전동 기종 — 전력비 = 전력(㎾) × 전력량요금 · 잡재료 = 전력비의 %(8-4 주②)",
"입력": [_machine_in(MOVE[P1]), _ELEC_IN],
"중간": [_LOSS_ONE],
"호표": [_ELEC],
"덧줄": [_MISC, _LOSS_ADD],
"끝수": _CUT,
"변수표": _vars("기계", "전력 계약종별", "손료"),
},
P2: {
**_HEAD,
"이름": "기계 시간당 기계경비(전력 · 조종원 기종)",
"출처": f"{_SRC} · 8-1-6 3 가",
"부품": {"갈래": "기계경비식"},
"소유": "공용",
"비고": "원문 운전경비 표 주연료 칸에 ㎾ 가 찍히고 조종원이 있는 전동 기종(점보드릴) — 전력비 + 잡재료(전력비의 %) + 조종원",
"입력": [_machine_in(MOVE[P2]), _ELEC_IN],
"중간": [_LOSS_ONE, _JOB, _HOUR],
"호표": [_ELEC, _CREW],
"덧줄": [_MISC, _LOSS_ADD],
"끝수": _CUT,
"변수표": _vars("기계", "전력 계약종별", "손료", "직종", "시간환산"),
},
N3: {
**_HEAD,
"이름": "기계 시간당 기계경비(조종원 없는 기종 · 손료계수 한 칸)",
"출처": _SRC,
"부품": {"갈래": "기계경비식"},
"소유": "공용",
"비고": "원문 손료 표가 시간당 손료계수 하나만 준 기종(8-3-8 기타기계 등) 가운데 주연료가 있고 조종원이 없는 것",
"입력": [_machine_in(MOVE[N3]), region],
"중간": [_LOSS_ONE, _FUEL],
"호표": [_FUEL_LINE],
"덧줄": [_MISC, _LOSS_ADD],
"끝수": _CUT,
"변수표": _vars("기계", "지역", "손료", "연료"),
},
DIRECT: {
**_HEAD,
"이름": "기계 시간당 기계경비(직접 값)",
"출처": "자체",
"부품": {"갈래": "기계경비식"},
"소유": "공용",
"비고": "자체 — 구성 없는 기계 · 구성(손료 · 연료 · 조종원) 없이 시간당 노무비 · 재료비 · 경비를 바로 둔 기계(내 기계 「시간당 값 직접」) — 기계 줄 값 그대로",
"입력": [{"이름": "기계", "고르기": []}],
"중간": [],
"호표": [],
"덧줄": [
{
"이름": "시간당 노무비",
"식": "EQ:{기계}.노무비",
"비목": "노무비",
"출처": "자체",
},
{
"이름": "시간당 재료비",
"식": "EQ:{기계}.재료비",
"비목": "재료비",
"출처": "자체",
},
{"이름": "시간당 경비", "식": "EQ:{기계}.경비", "비목": "경비", "출처": "자체"},
], # fmt: skip
"끝수": None,
"변수표": _vars("기계"),
},
}
def _note(row: dict, text: str) -> None:
was = str(row.get("비고") or "")
if text not in was:
row["비고"] = f"{was} · {text}" if was else text
def machine_changes() -> list[dict]:
rows = {r["키"]: r for r in store.read(MACHINE)[0]["줄"]}
out = []
for key, row in rows.items():
new = copy.deepcopy(row)
vals, unit, src = new["값"], new.setdefault("단위", {}), new.setdefault("출처", {})
if key in POWER and vals.get("전력") is None:
text = str(vals.get("주연료"))
text += "㎾" if unit.get("주연료") == "㎾" else "" # 모르타르 믹서 = 단위 칸에 ㎾
got = re.search(r"([\d.]+)\s*㎾", text)
assert got and float(got.group(1)) == POWER[key], (key, text)
vals["주연료"], vals["전력"] = None, POWER[key]
unit["주연료"], unit["전력"] = "ℓ/hr", "kWh/hr"
src["전력"] = src.get("주연료")
_note(new, f"전력 = 원문 주연료 칸 「{text}」(㎾ · 전동기 부하율 규정 없음 → 그대로)")
if key == "EQ000532":
_note(new, "원문 「휘발유1.3」 갈래는 주연료2 칸 · 시간당 식은 전력")
if key in MISC_DASH and vals.get("잡재료율") is None:
vals["잡재료율"] = 0
_note(new, "잡재료율 0 = 원문 잡재료 「-」")
if key in JOBS and not vals.get("조종원직종"):
job, why = JOBS[key]
vals["조종원직종"] = job
src["조종원직종"] = JOB_SRC.format(job)
_note(new, f"직종 = 사용자 확정(요구 799) — {why}")
if key == TYPO[0] and new.get("이름") == TYPO[1]:
items = list(new.items())
at = [k for k, _ in items].index("이름") + 1
items.insert(at, ("옛이름", TYPO[1]))
new = dict(items)
new["이름"] = TYPO[2]
if new.get("상세구분") == TYPO[1]:
new["상세구분"] = TYPO[2]
if new != row:
out.append({"op": "edit", "key": key, "row": new})
return out
def logic_changes() -> list[dict]:
data = store.read(LOGIC)[0]
rows = {r["키"]: r for r in data["줄"]}
if any(r.get("이름") == "기계 시간당 기계경비(직접 값)" for r in rows.values()):
return []
moved = {c for codes in MOVE.values() for c in codes} | set(DROP_ONLY)
out = []
for key in (LOSS_ONLY, *[k for k in MOVE if k.startswith("GC")]):
new = copy.deepcopy(rows[key])
pick = next(i for i in new["입력"] if i.get("이름") == "기계")
if key == LOSS_ONLY:
pick["고르기"] = [c for c in pick["고르기"] if str(c) not in moved]
else:
pick["고르기"] += [c for c in MOVE[key] if c not in pick["고르기"]]
out.append({"op": "edit", "key": key, "row": new})
regions = next(i["고르기"] for i in rows["GC000268"]["입력"] if i["이름"] == "지역")
out += [{"op": "add", "row": row} for row in new_rows(regions).values()]
return out
def main(check: bool) -> None:
parts = []
for file, changes in ((MACHINE, machine_changes()), (LOGIC, logic_changes())):
print(file, len(changes), [c.get("key") or c["row"]["이름"] for c in changes])
if changes:
parts.append({"file": file, "version": store.read(file)[1], "changes": changes})
if check or not parts:
return
# 대장 열쇠 = 원문번호 — 넷 다 「8」 이면 한 키로 겹침 → 옛 기계경비식처럼 이름 붙은 열쇠(줄은 「8」)
labels = {r["이름"]: f"공통 8 기계 시간당 경비({LABEL[t]})" for t, r in new_rows([]).items()}
plain = store.ledger_label
store.ledger_label = lambda book, tid, no, row: (
labels.get(row.get("이름")) or plain(book, tid, no, row)
)
got = make.save(parts, confirmed=True)
print("저장", got["added"])
if __name__ == "__main__":
main("--check" in sys.argv)
@@ -10,7 +10,8 @@
계수마다 정수로 반올림 · 손료계수 = 셋의 합.
미리보기 = 서버 기계경비식(GC000267 ~)을 기본 옵션(보정 없음)으로 부른 값과 같은 끝수 —
손료 = 가격(천원) × 1000 × 손료계수 ÷ 10^7 원 미만 버림 · 줄 금액은 0.1 원 아래 버림 ·
잡품 = 버린 주연료 금액 × 잡재료율 % · 비목 합은 원 미만 버림(B09 끝수 `master_cut.engine`).
잡품 = 버린 (주연료 + 전력) 금액 × 잡재료율 %(전력 기종 = 전력비의 % · 822-2 GC001196 · 7) ·
비목 합은 원 미만 버림(B09 끝수 `master_cut.engine`).
정본은 서버 식(`M02_Machine_Hourly`) — 이것은 화면 즉시 셈용.
"""
@@ -61,8 +62,8 @@ def preview(
"""값 묶음 + 유가(원/ℓ) · 노임(원/일) · 전력(원/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))
misc = _tenth((oil + elec) * _num(values.get("잡재료율")) / 100)
crew = _tenth(_num(values.get("조종원")) * hour * _num(wage))
labor, material = math.floor(crew + _E), math.floor(oil + misc + elec + _E)
return {
+5 -2
View File
@@ -52,9 +52,12 @@ def resolve(spec: dict, value, master=None):
def machine(spec: dict, value, master=None) -> bool:
"""기계 입력 — 고르기 목록 밖 원문 분류번호도 기계 마스터에 있으면 받음."""
"""기계 입력 — 고르기 목록 밖 원문 분류번호도 기계 마스터에 있으면 받음 · 빈 고르기(직접 값 식
GC001199 · 822-2) = 어느 기계든."""
known = _numbers(master)
picks = spec.get("고르기") or []
picks = spec.get("고르기")
if picks == [] and spec.get("이름") == "기계":
return str(value).strip() in known
return bool(picks) and str(value).strip() in known and all(str(o) in known for o in picks)
+146 -127
View File
@@ -10907,7 +10907,7 @@
"주연료종류": null,
"잡재료율": null,
"조종원": 1,
"조종원직종": null
"조종원직종": "1048"
},
"출처": {
"가격": "건설품셈 공통 8-5-3",
@@ -10924,9 +10924,9 @@
"주연료종류": "건설품셈 공통 8-4-3",
"잡재료율": "건설품셈 공통 8-4-3",
"조종원": "건설품셈 공통 8-4-3",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "규격 단위: (m×ton)"
"비고": "규격 단위: (m×ton) · 직종 = 사용자 확정(요구 799) — 건설기계관리법상 건설기계"
},
{
"키": "EQ000166",
@@ -10968,7 +10968,7 @@
"주연료종류": null,
"잡재료율": null,
"조종원": 1,
"조종원직종": null
"조종원직종": "1048"
},
"출처": {
"가격": "건설품셈 공통 8-5-3",
@@ -10985,9 +10985,9 @@
"주연료종류": "건설품셈 공통 8-4-3",
"잡재료율": "건설품셈 공통 8-4-3",
"조종원": "건설품셈 공통 8-4-3",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "규격 단위: (m×ton)"
"비고": "규격 단위: (m×ton) · 직종 = 사용자 확정(요구 799) — 건설기계관리법상 건설기계"
},
{
"키": "EQ000167",
@@ -11029,7 +11029,7 @@
"주연료종류": null,
"잡재료율": null,
"조종원": 1,
"조종원직종": null
"조종원직종": "1048"
},
"출처": {
"가격": "건설품셈 공통 8-5-3",
@@ -11046,9 +11046,9 @@
"주연료종류": "건설품셈 공통 8-4-3",
"잡재료율": "건설품셈 공통 8-4-3",
"조종원": "건설품셈 공통 8-4-3",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "규격 단위: (m×ton)"
"비고": "규격 단위: (m×ton) · 직종 = 사용자 확정(요구 799) — 건설기계관리법상 건설기계"
},
{
"키": "EQ000168",
@@ -11090,7 +11090,7 @@
"주연료종류": null,
"잡재료율": null,
"조종원": 1,
"조종원직종": null
"조종원직종": "1048"
},
"출처": {
"가격": "건설품셈 공통 8-5-3",
@@ -11107,9 +11107,9 @@
"주연료종류": "건설품셈 공통 8-4-3",
"잡재료율": "건설품셈 공통 8-4-3",
"조종원": "건설품셈 공통 8-4-3",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "규격 단위: (m×ton)"
"비고": "규격 단위: (m×ton) · 직종 = 사용자 확정(요구 799) — 건설기계관리법상 건설기계"
},
{
"키": "EQ000169",
@@ -11151,7 +11151,7 @@
"주연료종류": null,
"잡재료율": null,
"조종원": 1,
"조종원직종": null
"조종원직종": "1048"
},
"출처": {
"가격": "건설품셈 공통 8-5-3",
@@ -11168,9 +11168,9 @@
"주연료종류": "건설품셈 공통 8-4-3",
"잡재료율": "건설품셈 공통 8-4-3",
"조종원": "건설품셈 공통 8-4-3",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "규격 단위: (m×ton)"
"비고": "규격 단위: (m×ton) · 직종 = 사용자 확정(요구 799) — 건설기계관리법상 건설기계"
},
{
"키": "EQ000170",
@@ -12251,7 +12251,7 @@
"손료계수": 2501,
"주연료": 487.2,
"주연료종류": "중유",
"잡재료율": null,
"잡재료율": 0,
"조종원": 2,
"조종원직종": "1048"
},
@@ -12272,10 +12272,8 @@
"조종원": "건설품셈 공통 8-4-4",
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름",
"값원문": {
"8-4-4 주연료": "중유487.2"
}
"비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름 · 잡재료율 0 = 원문 잡재료 「-」",
"값원문": { "8-4-4 주연료": "중유487.2" }
},
{
"키": "EQ000188",
@@ -12315,7 +12313,7 @@
"손료계수": 2159,
"주연료": 614.7,
"주연료종류": "경유",
"잡재료율": null,
"잡재료율": 0,
"조종원": 2,
"조종원직종": "1048"
},
@@ -12336,7 +12334,7 @@
"조종원": "건설품셈 공통 8-4-4",
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름"
"비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름 · 잡재료율 0 = 원문 잡재료 「-」"
},
{
"키": "EQ000189",
@@ -12376,7 +12374,7 @@
"손료계수": 2159,
"주연료": 678.4,
"주연료종류": "경유",
"잡재료율": null,
"잡재료율": 0,
"조종원": 2,
"조종원직종": "1048"
},
@@ -12397,7 +12395,7 @@
"조종원": "건설품셈 공통 8-4-4",
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름"
"비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름 · 잡재료율 0 = 원문 잡재료 「-」"
},
{
"키": "EQ000190",
@@ -12437,7 +12435,7 @@
"손료계수": 2159,
"주연료": 746.7,
"주연료종류": "경유",
"잡재료율": null,
"잡재료율": 0,
"조종원": 2,
"조종원직종": "1048"
},
@@ -12458,7 +12456,7 @@
"조종원": "건설품셈 공통 8-4-4",
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름"
"비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름 · 잡재료율 0 = 원문 잡재료 「-」"
},
{
"키": "EQ000191",
@@ -12498,7 +12496,7 @@
"손료계수": 2159,
"주연료": 819.6,
"주연료종류": "경유",
"잡재료율": null,
"잡재료율": 0,
"조종원": 2,
"조종원직종": "1048"
},
@@ -12519,7 +12517,7 @@
"조종원": "건설품셈 공통 8-4-4",
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름"
"비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름 · 잡재료율 0 = 원문 잡재료 「-」"
},
{
"키": "EQ000192",
@@ -16081,7 +16079,7 @@
"주연료종류": null,
"잡재료율": null,
"조종원": 1,
"조종원직종": null
"조종원직종": "1048"
},
"출처": {
"가격": "건설품셈 공통 8-5-6",
@@ -16098,9 +16096,9 @@
"주연료종류": "건설품셈 공통 8-4-6",
"잡재료율": "건설품셈 공통 8-4-6",
"조종원": "건설품셈 공통 8-4-6",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "규격 단위: (ton/hr)(㎾)"
"비고": "규격 단위: (ton/hr)(㎾) · 직종 = 사용자 확정(요구 799) — 원문 8-1-3 5 표의 「쇄석기」"
},
{
"키": "EQ000250",
@@ -16142,7 +16140,7 @@
"주연료종류": null,
"잡재료율": null,
"조종원": 1,
"조종원직종": null
"조종원직종": "1048"
},
"출처": {
"가격": "건설품셈 공통 8-5-6",
@@ -16159,9 +16157,9 @@
"주연료종류": "건설품셈 공통 8-4-6",
"잡재료율": "건설품셈 공통 8-4-6",
"조종원": "건설품셈 공통 8-4-6",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "규격 단위: (ton/hr)(㎾)"
"비고": "규격 단위: (ton/hr)(㎾) · 직종 = 사용자 확정(요구 799) — 원문 8-1-3 5 표의 「쇄석기」"
},
{
"키": "EQ000251",
@@ -16203,7 +16201,7 @@
"주연료종류": null,
"잡재료율": null,
"조종원": 1,
"조종원직종": null
"조종원직종": "1048"
},
"출처": {
"가격": "건설품셈 공통 8-5-6",
@@ -16220,9 +16218,9 @@
"주연료종류": "건설품셈 공통 8-4-6",
"잡재료율": "건설품셈 공통 8-4-6",
"조종원": "건설품셈 공통 8-4-6",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "규격 단위: (ton/hr)(㎾)"
"비고": "규격 단위: (ton/hr)(㎾) · 직종 = 사용자 확정(요구 799) — 원문 8-1-3 5 표의 「쇄석기」"
},
{
"키": "EQ000252",
@@ -16264,7 +16262,7 @@
"주연료종류": null,
"잡재료율": null,
"조종원": 1,
"조종원직종": null
"조종원직종": "1048"
},
"출처": {
"가격": "건설품셈 공통 8-5-6",
@@ -16281,9 +16279,9 @@
"주연료종류": "건설품셈 공통 8-4-6",
"잡재료율": "건설품셈 공통 8-4-6",
"조종원": "건설품셈 공통 8-4-6",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "규격 단위: (ton/hr)(㎾)"
"비고": "규격 단위: (ton/hr)(㎾) · 직종 = 사용자 확정(요구 799) — 원문 8-1-3 5 표의 「쇄석기」"
},
{
"키": "EQ000253",
@@ -19689,7 +19687,7 @@
"주연료종류": null,
"잡재료율": null,
"조종원": 1,
"조종원직종": null
"조종원직종": "1050"
},
"출처": {
"가격": "건설품셈 공통 8-5-6",
@@ -19706,8 +19704,9 @@
"주연료종류": "건설품셈 공통 8-4-6",
"잡재료율": "건설품셈 공통 8-4-6",
"조종원": "건설품셈 공통 8-4-6",
"조종원직종": "건설품셈 공통 8-1-3 5"
}
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1050"
},
"비고": "직종 = 사용자 확정(요구 799) — 두 법에 없는 기계"
},
{
"키": "EQ000310",
@@ -21858,7 +21857,8 @@
"주연료종류": "",
"잡재료율": "주연료의 %",
"조종원": "인/일",
"조종원직종": ""
"조종원직종": "",
"전력": "kWh/hr"
},
"값": {
"가격": null,
@@ -21871,11 +21871,12 @@
"정비비계수": 167,
"관리비계수": 328,
"손료계수": 870,
"주연료": "동력330㎾",
"주연료": null,
"주연료종류": null,
"잡재료율": 10,
"조종원": null,
"조종원직종": null
"조종원직종": null,
"전력": 330
},
"출처": {
"가격": "건설품셈 공통 8-5",
@@ -21892,11 +21893,11 @@
"주연료종류": "건설품셈 공통 8-4-6",
"잡재료율": "건설품셈 공통 8-4-6",
"조종원": "건설품셈 공통 8-4-6",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5",
"전력": "건설품셈 공통 8-4-6"
},
"값원문": {
"8-4-6 주연료": "동력330㎾"
}
"값원문": { "8-4-6 주연료": "동력330㎾" },
"비고": "전력 = 원문 주연료 칸 「동력330㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로)"
},
{
"키": "EQ000346",
@@ -21981,7 +21982,8 @@
"주연료종류": "",
"잡재료율": "주연료의 %",
"조종원": "인/일",
"조종원직종": ""
"조종원직종": "",
"전력": "kWh/hr"
},
"값": {
"가격": 605472,
@@ -21994,11 +21996,12 @@
"정비비계수": 777,
"관리비계수": 738,
"손료계수": 2515,
"주연료": "135㎾",
"주연료": null,
"주연료종류": null,
"잡재료율": 6,
"조종원": 1,
"조종원직종": null
"조종원직종": "1048",
"전력": 135
},
"출처": {
"가격": "건설품셈 공통 8-5-6",
@@ -22015,11 +22018,11 @@
"주연료종류": "건설품셈 공통 8-4-6",
"잡재료율": "건설품셈 공통 8-4-6",
"조종원": "건설품셈 공통 8-4-6",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048",
"전력": "건설품셈 공통 8-4-6"
},
"값원문": {
"8-4-6 주연료": "135㎾"
}
"값원문": { "8-4-6 주연료": "135㎾" },
"비고": "전력 = 원문 주연료 칸 「135㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 직종 = 사용자 확정(요구 799) — 원문 8-1-3 5 표의 「천공기」"
},
{
"키": "EQ000348",
@@ -22044,7 +22047,8 @@
"주연료종류": "",
"잡재료율": "주연료의 %",
"조종원": "인/일",
"조종원직종": ""
"조종원직종": "",
"전력": "kWh/hr"
},
"값": {
"가격": 1153045,
@@ -22057,11 +22061,12 @@
"정비비계수": 777,
"관리비계수": 738,
"손료계수": 2515,
"주연료": "239㎾",
"주연료": null,
"주연료종류": null,
"잡재료율": 10,
"조종원": 1,
"조종원직종": null
"조종원직종": "1048",
"전력": 239
},
"출처": {
"가격": "건설품셈 공통 8-5-6",
@@ -22078,11 +22083,11 @@
"주연료종류": "건설품셈 공통 8-4-6",
"잡재료율": "건설품셈 공통 8-4-6",
"조종원": "건설품셈 공통 8-4-6",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048",
"전력": "건설품셈 공통 8-4-6"
},
"값원문": {
"8-4-6 주연료": "239㎾"
}
"값원문": { "8-4-6 주연료": "239㎾" },
"비고": "전력 = 원문 주연료 칸 「239㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 직종 = 사용자 확정(요구 799) — 원문 8-1-3 5 표의 「천공기」"
},
{
"키": "EQ000349",
@@ -26768,7 +26773,8 @@
"주연료종류": "",
"잡재료율": "주연료의 %",
"조종원": "인/일",
"조종원직종": ""
"조종원직종": "",
"전력": "kWh/hr"
},
"값": {
"가격": 16828,
@@ -26781,11 +26787,12 @@
"정비비계수": 2333,
"관리비계수": 1169,
"손료계수": 6502,
"주연료": "동력 15.1㎾",
"주연료": null,
"주연료종류": null,
"잡재료율": null,
"잡재료율": 0,
"조종원": null,
"조종원직종": null
"조종원직종": null,
"전력": 15.1
},
"출처": {
"가격": "건설품셈 공통 8-5-8",
@@ -26802,12 +26809,11 @@
"주연료종류": "건설품셈 공통 8-4-8",
"잡재료율": "건설품셈 공통 8-4-8",
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5",
"전력": "건설품셈 공통 8-4-8"
},
"비고": "규격 단위: (W×L×H)",
"값원문": {
"8-4-8 주연료": "동력 15.1㎾"
}
"비고": "규격 단위: (W×L×H) · 전력 = 원문 주연료 칸 「동력 15.1㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 잡재료율 0 = 원문 잡재료 「-」",
"값원문": { "8-4-8 주연료": "동력 15.1㎾" }
},
{
"키": "EQ000427",
@@ -26832,7 +26838,8 @@
"주연료종류": "",
"잡재료율": "주연료의 %",
"조종원": "인/일",
"조종원직종": ""
"조종원직종": "",
"전력": "kWh/hr"
},
"값": {
"가격": 21722,
@@ -26845,11 +26852,12 @@
"정비비계수": 2333,
"관리비계수": 1169,
"손료계수": 6502,
"주연료": "동력 15.1㎾",
"주연료": null,
"주연료종류": null,
"잡재료율": null,
"잡재료율": 0,
"조종원": null,
"조종원직종": null
"조종원직종": null,
"전력": 15.1
},
"출처": {
"가격": "건설품셈 공통 8-5-8",
@@ -26866,12 +26874,11 @@
"주연료종류": "건설품셈 공통 8-4-8",
"잡재료율": "건설품셈 공통 8-4-8",
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5",
"전력": "건설품셈 공통 8-4-8"
},
"비고": "규격 단위: (W×L×H)",
"값원문": {
"8-4-8 주연료": "동력 15.1㎾"
}
"비고": "규격 단위: (W×L×H) · 전력 = 원문 주연료 칸 「동력 15.1㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 잡재료율 0 = 원문 잡재료 「-」",
"값원문": { "8-4-8 주연료": "동력 15.1㎾" }
},
{
"키": "EQ000428",
@@ -27218,7 +27225,7 @@
"주연료종류": null,
"잡재료율": null,
"조종원": 1,
"조종원직종": null
"조종원직종": "1050"
},
"출처": {
"가격": "건설품셈 공통 8-5-8",
@@ -27235,8 +27242,9 @@
"주연료종류": "건설품셈 공통 8-4-8",
"잡재료율": "건설품셈 공통 8-4-8",
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
}
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1050"
},
"비고": "직종 = 사용자 확정(요구 799) — 두 법에 없는 기계"
},
{
"키": "EQ000434",
@@ -33047,7 +33055,8 @@
"주연료종류": "",
"잡재료율": "주연료의 %",
"조종원": "인/일",
"조종원직종": ""
"조종원직종": "",
"전력": "kWh/hr"
},
"값": {
"가격": 16913,
@@ -33060,11 +33069,12 @@
"정비비계수": null,
"관리비계수": null,
"손료계수": 4677,
"주연료": "3.73㎾",
"주연료": null,
"주연료종류": null,
"잡재료율": null,
"잡재료율": 0,
"조종원": null,
"조종원직종": null
"조종원직종": null,
"전력": 3.73
},
"출처": {
"가격": "건설품셈 공통 8-5-8",
@@ -33081,11 +33091,11 @@
"주연료종류": "건설품셈 공통 8-4-8",
"잡재료율": "건설품셈 공통 8-4-8",
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5",
"전력": "건설품셈 공통 8-4-8"
},
"값원문": {
"8-4-8 주연료": "3.73㎾"
}
"값원문": { "8-4-8 주연료": "3.73㎾" },
"비고": "전력 = 원문 주연료 칸 「3.73㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 잡재료율 0 = 원문 잡재료 「-」"
},
{
"키": "EQ000530",
@@ -33110,7 +33120,8 @@
"주연료종류": "",
"잡재료율": "주연료의 %",
"조종원": "인/일",
"조종원직종": ""
"조종원직종": "",
"전력": "kWh/hr"
},
"값": {
"가격": 21887,
@@ -33123,11 +33134,12 @@
"정비비계수": null,
"관리비계수": null,
"손료계수": 4677,
"주연료": "7.46㎾",
"주연료": null,
"주연료종류": null,
"잡재료율": null,
"잡재료율": 0,
"조종원": null,
"조종원직종": null
"조종원직종": null,
"전력": 7.46
},
"출처": {
"가격": "건설품셈 공통 8-5-8",
@@ -33144,11 +33156,11 @@
"주연료종류": "건설품셈 공통 8-4-8",
"잡재료율": "건설품셈 공통 8-4-8",
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5",
"전력": "건설품셈 공통 8-4-8"
},
"값원문": {
"8-4-8 주연료": "7.46㎾"
}
"값원문": { "8-4-8 주연료": "7.46㎾" },
"비고": "전력 = 원문 주연료 칸 「7.46㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 잡재료율 0 = 원문 잡재료 「-」"
},
{
"키": "EQ000531",
@@ -33173,7 +33185,8 @@
"주연료종류": "",
"잡재료율": "주연료의 %",
"조종원": "인/일",
"조종원직종": ""
"조종원직종": "",
"전력": "kWh/hr"
},
"값": {
"가격": 40769,
@@ -33186,11 +33199,12 @@
"정비비계수": null,
"관리비계수": null,
"손료계수": 4677,
"주연료": "37㎾",
"주연료": null,
"주연료종류": null,
"잡재료율": null,
"잡재료율": 0,
"조종원": null,
"조종원직종": null
"조종원직종": null,
"전력": 37
},
"출처": {
"가격": "건설품셈 공통 8-5-8",
@@ -33207,11 +33221,11 @@
"주연료종류": "건설품셈 공통 8-4-8",
"잡재료율": "건설품셈 공통 8-4-8",
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5",
"전력": "건설품셈 공통 8-4-8"
},
"값원문": {
"8-4-8 주연료": "37㎾"
}
"값원문": { "8-4-8 주연료": "37㎾" },
"비고": "전력 = 원문 주연료 칸 「37㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 잡재료율 0 = 원문 잡재료 「-」"
},
{
"키": "EQ000532",
@@ -33232,13 +33246,14 @@
"정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간",
"손료계수": "10^-7/시간",
"주연료": "㎾",
"주연료": "ℓ/hr",
"주연료종류": "",
"주연료2": "ℓ/hr",
"주연료2종류": "",
"잡재료율": "주연료의 %",
"조종원": "인/일",
"조종원직종": ""
"조종원직종": "",
"전력": "kWh/hr"
},
"값": {
"가격": 5696,
@@ -33251,13 +33266,14 @@
"정비비계수": null,
"관리비계수": null,
"손료계수": 3708,
"주연료": 1.87,
"주연료": null,
"주연료종류": null,
"주연료2": 1.3,
"주연료2종류": "휘발유",
"잡재료율": 2,
"조종원": null,
"조종원직종": null
"조종원직종": null,
"전력": 1.87
},
"출처": {
"가격": "건설품셈 공통 8-5-8",
@@ -33276,11 +33292,11 @@
"주연료2종류": "건설품셈 공통 8-4-8",
"잡재료율": "건설품셈 공통 8-4-8",
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5",
"전력": "건설품셈 공통 8-4-8"
},
"값원문": {
"8-4-8 주연료": "1.87㎾ 휘발유1.3"
}
"값원문": { "8-4-8 주연료": "1.87㎾ 휘발유1.3" },
"비고": "전력 = 원문 주연료 칸 「1.87㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 원문 「휘발유1.3」 갈래는 주연료2 칸 · 시간당 식은 전력"
},
{
"키": "EQ000533",
@@ -33305,7 +33321,8 @@
"주연료종류": "",
"잡재료율": "주연료의 %",
"조종원": "인/일",
"조종원직종": ""
"조종원직종": "",
"전력": "kWh/hr"
},
"값": {
"가격": 38,
@@ -33318,11 +33335,12 @@
"정비비계수": null,
"관리비계수": null,
"손료계수": 3375,
"주연료": "1.49㎾",
"주연료": null,
"주연료종류": null,
"잡재료율": null,
"잡재료율": 0,
"조종원": null,
"조종원직종": null
"조종원직종": null,
"전력": 1.49
},
"출처": {
"가격": "건설품셈 공통 8-5-8",
@@ -33339,19 +33357,20 @@
"주연료종류": "건설품셈 공통 8-4-8",
"잡재료율": "건설품셈 공통 8-4-8",
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
"조종원직종": "건설품셈 공통 8-1-3 5",
"전력": "건설품셈 공통 8-4-8"
},
"값원문": {
"8-4-8 주연료": "1.49㎾"
}
"값원문": { "8-4-8 주연료": "1.49㎾" },
"비고": "전력 = 원문 주연료 칸 「1.49㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 잡재료율 0 = 원문 잡재료 「-」"
},
{
"키": "EQ000534",
"원문번호": "7994-0050",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "POWER TLOWEL",
"이름": "POWER TLOWEL",
"상세구분": "POWER TROWEL",
"이름": "POWER TROWEL",
"옛이름": "POWER TLOWEL",
"규격": "3.73㎾",
"단위": {
"가격": "천원",
@@ -284,7 +284,13 @@
"9090-1300",
"9090-1400",
"9090-1500",
"9090-1600"
"9090-1600",
"3108-0060",
"3108-0080",
"3108-0100",
"3108-0120",
"9010-0120",
"9010-0200"
]
},
{
@@ -853,11 +859,6 @@
"2104-0220",
"2104-0250",
"2104-0300",
"2208-5008",
"2208-5010",
"2208-5012",
"2208-5016",
"2208-5020",
"2105-0002",
"2105-0003",
"2105-0005",
@@ -921,7 +922,6 @@
"2118-0010",
"2210-0145",
"3430-0300",
"3450-0642",
"4115-0100",
"4115-0150",
"4115-0200",
@@ -929,10 +929,6 @@
"4505-0015",
"4505-0026",
"4611-0075",
"5105-0050",
"5105-0100",
"5105-0150",
"5105-0200",
"5111-0040",
"5111-0050",
"5111-0060",
@@ -989,15 +985,12 @@
"5118-0005",
"5118-0006",
"5118-0007",
"5119-0625",
"5203-1800",
"5203-2200",
"5203-2700",
"5203-3500",
"5203-4500",
"5330-0074",
"5805-0002",
"5805-0003",
"5901-0006",
"5901-0010",
"5901-0016",
@@ -1018,9 +1011,6 @@
"6550-0130",
"7103-0010",
"7104-0010",
"7202-0008",
"7202-0010",
"7205-0125",
"7206-0020",
"7206-0070",
"7206-0100",
@@ -1058,10 +1048,6 @@
"7930-0075",
"7930-0100",
"7935-0180",
"7991-0050",
"7991-0100",
"7991-0500",
"7993-0020",
"8201-0100",
"8202-0100",
"8203-0019",
@@ -1370,7 +1356,30 @@
"소유": "공용",
"비고": "원문 운전경비 표에 주연료가 빈 기종(공기식 크롤러드릴 등 — 동력은 공기압축기) — 손료 + 조종원",
"입력": [
{ "이름": "기계", "고르기": ["5401-0017", "4108-0060", "4108-0090", "4108-0120", "4108-0150", "4108-0180", "4108-0210", "5401-0015"] },
{
"이름": "기계",
"고르기": [
"5401-0017",
"4108-0060",
"4108-0090",
"4108-0120",
"4108-0150",
"4108-0180",
"4108-0210",
"5401-0015",
"2208-5008",
"2208-5010",
"2208-5012",
"2208-5016",
"2208-5020",
"5105-0050",
"5105-0100",
"5105-0150",
"5105-0200",
"5119-0625",
"7205-0125"
]
},
{
"이름": "지역",
"고르기": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"],
@@ -3456,6 +3465,248 @@
"조건": { "기호": "S", "아래": "조건" },
"유속": { "기호": "v", "아래": "유속" }
}
},
{
"키": "GC001196",
"원문번호": "8",
"구분": "건설품셈 공통",
"상세구분": "08장 건설기계",
"결과단위": "원/시간",
"이름": "기계 시간당 기계경비(전력 기종)",
"출처": "건설품셈 공통 8-1-5 · 8-1-6 · 8-1-6 3 가",
"부품": { "갈래": "기계경비식" },
"소유": "공용",
"비고": "원문 운전경비 표 주연료 칸에 ㎾ 가 찍힌 전동 기종 — 전력비 = 전력(㎾) × 전력량요금 · 잡재료 = 전력비의 %(8-4 주②)",
"입력": [
{ "이름": "기계", "고르기": ["7991-0050", "7202-0008", "7202-0010", "7991-0100", "7991-0500", "7992-0001", "7993-0020"] },
{ "이름": "전력 계약종별", "고르기": ["일반용(갑)Ⅰ 저압", "주택용 저압"], "출처": "유가전력 한전 전기요금표 2025-01-01", "비고": "품셈·계약예규에 계약종별 규정 없음(전력비는 실비 계상) — 기본 = 불러오기 기본값" }
],
"중간": [{ "이름": "손료", "식": "버림(EQ:{기계}.가격 * 1000 * EQ:{기계}.손료계수 / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4" }],
"호표": [
{
"종류": "재료",
"요소": { "이름": "전력량요금", "계약종별": "입력" },
"이름": "전력",
"단위": "㎾h",
"수량": "EQ:{기계}.전력",
"비목": "재료비",
"출처": "건설품셈 공통 8-1-6 3 가",
"비고": "기계 줄 전력(㎾) 그대로 — 전동기 부하율 규정 없음"
}
],
"덧줄": [{ "이름": "잡재료비", "식": "재료비 * EQ:{기계}.잡재료율 / 100", "비목": "재료비", "출처": "건설품셈 공통 8-4 주②" }, { "이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2" }],
"끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음",
"변수표": { "기계": { "기호": "S", "아래": "기계" }, "전력 계약종별": { "기호": "S", "아래": "전력계약종별" }, "손료": { "기호": "C", "아래": "손료" } }
},
{
"키": "GC001197",
"원문번호": "8",
"구분": "건설품셈 공통",
"상세구분": "08장 건설기계",
"결과단위": "원/시간",
"이름": "기계 시간당 기계경비(전력 · 조종원 기종)",
"출처": "건설품셈 공통 8-1-5 · 8-1-6 · 8-1-6 3 가",
"부품": { "갈래": "기계경비식" },
"소유": "공용",
"비고": "원문 운전경비 표 주연료 칸에 ㎾ 가 찍히고 조종원이 있는 전동 기종(점보드릴) — 전력비 + 잡재료(전력비의 %) + 조종원",
"입력": [
{ "이름": "기계", "고르기": ["5805-0002", "5805-0003"] },
{ "이름": "전력 계약종별", "고르기": ["일반용(갑)Ⅰ 저압", "주택용 저압"], "출처": "유가전력 한전 전기요금표 2025-01-01", "비고": "품셈·계약예규에 계약종별 규정 없음(전력비는 실비 계상) — 기본 = 불러오기 기본값" }
],
"중간": [
{ "이름": "손료", "식": "버림(EQ:{기계}.가격 * 1000 * EQ:{기계}.손료계수 / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4" },
{ "이름": "직종", "식": "EQ:{기계}.조종원직종", "출처": "건설품셈 공통 8-1-3 5" },
{ "이름": "시간환산", "식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 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", "아래": "전력계약종별" },
"손료": { "기호": "C", "아래": "손료" },
"직종": { "기호": "S", "아래": "직종" },
"시간환산": { "기호": "Q", "아래": "시간환산" }
}
},
{
"키": "GC001198",
"원문번호": "8",
"구분": "건설품셈 공통",
"상세구분": "08장 건설기계",
"결과단위": "원/시간",
"이름": "기계 시간당 기계경비(조종원 없는 기종 · 손료계수 한 칸)",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"부품": { "갈래": "기계경비식" },
"소유": "공용",
"비고": "원문 손료 표가 시간당 손료계수 하나만 준 기종(8-3-8 기타기계 등) 가운데 주연료가 있고 조종원이 없는 것",
"입력": [
{ "이름": "기계", "고르기": ["7994-0050"] },
{
"이름": "지역",
"고르기": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"],
"출처": "건설품셈 공통 8-1-7 5"
}
],
"중간": [
{ "이름": "손료", "식": "버림(EQ:{기계}.가격 * 1000 * EQ:{기계}.손료계수 / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4" },
{ "이름": "연료", "식": "EQ:{기계}.주연료종류", "출처": "건설품셈 공통 8-4 주①㉰" }
],
"호표": [{ "종류": "재료", "요소": "MO:{연료}:{지역}", "이름": "주연료", "단위": "ℓ", "수량": "EQ:{기계}.주연료", "비목": "재료비", "출처": "건설품셈 공통 8-4-8" }],
"덧줄": [{ "이름": "잡재료비", "식": "재료비 * EQ:{기계}.잡재료율 / 100", "비목": "재료비", "출처": "건설품셈 공통 8-4 주②" }, { "이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2" }],
"끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음",
"변수표": { "기계": { "기호": "S", "아래": "기계" }, "지역": { "기호": "S", "아래": "지역" }, "손료": { "기호": "C", "아래": "손료" }, "연료": { "기호": "S", "아래": "연료" } }
},
{
"키": "GC001199",
"원문번호": "8",
"구분": "건설품셈 공통",
"상세구분": "08장 건설기계",
"결과단위": "원/시간",
"이름": "기계 시간당 기계경비(직접 값)",
"출처": "자체",
"부품": { "갈래": "기계경비식" },
"소유": "공용",
"비고": "자체 — 구성 없는 기계 · 구성(손료 · 연료 · 조종원) 없이 시간당 노무비 · 재료비 · 경비를 바로 둔 기계(내 기계 「시간당 값 직접」) — 기계 줄 값 그대로",
"입력": [{ "이름": "기계", "고르기": [] }],
"중간": [],
"호표": [],
"덧줄": [
{ "이름": "시간당 노무비", "식": "EQ:{기계}.노무비", "비목": "노무비", "출처": "자체" },
{ "이름": "시간당 재료비", "식": "EQ:{기계}.재료비", "비목": "재료비", "출처": "자체" },
{ "이름": "시간당 경비", "식": "EQ:{기계}.경비", "비목": "경비", "출처": "자체" }
],
"끝수": 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", "아래": "시간환산" }
}
}
]
}
@@ -62,7 +62,7 @@
"갈래": "기계경비식"
},
"변수": [
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["1730-0015", "7204-0055", "0602-0150", "2702-0020", "0101-0007", "0101-0010", "0101-0012", "0101-0019", "0101-0032", "2105-0005", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "4504-0032", "4504-0036", "4504-0041", "4504-0043", "4504-0047", "4504-0052", "4205-0010", "4205-0017", "4205-0020", "4205-0030", "4205-0040", "4205-0045", "2105-0002", "2105-0003", "2105-0010", "2105-0015", "2105-0018", "2502-0025", "0602-0025", "2502-0050", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "7204-0018", "7204-0160", "5205-0210", "5405-0110", "7505-0450", "5205-0255", "7505-0150", "5205-0103", "6701-0147", "6701-0184", "5205-0170", "7505-0350", "0302-0134", "7505-0100", "7505-0050", "5205-0071", "7505-0025", "0502-0036", "1306-0100", "1506-0032", "0121-0004", "1305-0007", "7204-0038", "7204-0065", "0302-0057", "5701-0020", "0302-0095", "3201-0003", "1106-0012", "1406-0015", "1206-0008", "3601-0102", "1306-0025", "0211-0018", "0602-0045", "0602-0060", "0602-0105", "1630-0080", "3901-0300", "7505-0250", "2502-0035", "1306-0120", "1106-0010", "3201-0001", "1406-0008", "3601-0202", "3601-0402", "3611-0142", "2502-0020", "2502-0075", "5205-0035", "2702-0030", "2702-0060", "0211-0060", "0211-0080", "0211-0100", "0221-0040", "0221-0070", "0302-0025", "0302-0172", "0302-0229", "0302-0287", "0302-0350", "0302-0500", "0406-0054", "0406-0115", "0406-0161", "0406-0206", "0503-0036", "0602-0200", "0602-0240", "0602-0320", "1106-0015", "1206-0010", "1206-0014", "1209-0001", "1209-0002", "1209-0004", "1209-0006", "1209-0007", "1209-0008", "1209-0013", "1306-0044", "1306-0060", "1406-0025", "1506-0011", "1506-0012", "1506-0015", "1506-0019", "1506-0025", "1506-0030", "1506-0037", "2602-0015", "2602-0025", "2602-0035", "2602-0045", "2702-0040", "3530-0015", "3530-0036", "3601-0204", "3601-0302", "3701-0200", "3805-0120", "4504-0021", "4504-0028", "5405-0150", "5701-0010", "7106-0035", "7505-0125", "7505-0200", "7505-0500", "7505-0700", "9010-0003", "9010-0006", "9010-0010", "9010-0012", "9010-0020", "9010-0022", "9010-0033", "9010-0040", "9010-0044", "9010-0060", "9010-0080", "9020-0010", "9020-0015", "9020-0016", "9020-0022", "9020-0035", "9020-0050", "9020-0072", "9020-0160", "9020-0180", "9020-0200", "9050-0075", "9050-0150", "9050-0450", "9050-0750", "9050-0850", "9090-0800", "9090-1000", "9090-1200", "9090-1300", "9090-1400", "9090-1500", "9090-1600"], "값": "1730-0015"}},
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["1730-0015", "7204-0055", "0602-0150", "2702-0020", "0101-0007", "0101-0010", "0101-0012", "0101-0019", "0101-0032", "2105-0005", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "4504-0032", "4504-0036", "4504-0041", "4504-0043", "4504-0047", "4504-0052", "4205-0010", "4205-0017", "4205-0020", "4205-0030", "4205-0040", "4205-0045", "2105-0002", "2105-0003", "2105-0010", "2105-0015", "2105-0018", "2502-0025", "0602-0025", "2502-0050", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "7204-0018", "7204-0160", "5205-0210", "5405-0110", "7505-0450", "5205-0255", "7505-0150", "5205-0103", "6701-0147", "6701-0184", "5205-0170", "7505-0350", "0302-0134", "7505-0100", "7505-0050", "5205-0071", "7505-0025", "0502-0036", "1306-0100", "1506-0032", "0121-0004", "1305-0007", "7204-0038", "7204-0065", "0302-0057", "5701-0020", "0302-0095", "3201-0003", "1106-0012", "1406-0015", "1206-0008", "3601-0102", "1306-0025", "0211-0018", "0602-0045", "0602-0060", "0602-0105", "1630-0080", "3901-0300", "7505-0250", "2502-0035", "1306-0120", "1106-0010", "3201-0001", "1406-0008", "3601-0202", "3601-0402", "3611-0142", "2502-0020", "2502-0075", "5205-0035", "2702-0030", "2702-0060", "0211-0060", "0211-0080", "0211-0100", "0221-0040", "0221-0070", "0302-0025", "0302-0172", "0302-0229", "0302-0287", "0302-0350", "0302-0500", "0406-0054", "0406-0115", "0406-0161", "0406-0206", "0503-0036", "0602-0200", "0602-0240", "0602-0320", "1106-0015", "1206-0010", "1206-0014", "1209-0001", "1209-0002", "1209-0004", "1209-0006", "1209-0007", "1209-0008", "1209-0013", "1306-0044", "1306-0060", "1406-0025", "1506-0011", "1506-0012", "1506-0015", "1506-0019", "1506-0025", "1506-0030", "1506-0037", "2602-0015", "2602-0025", "2602-0035", "2602-0045", "2702-0040", "3530-0015", "3530-0036", "3601-0204", "3601-0302", "3701-0200", "3805-0120", "4504-0021", "4504-0028", "5405-0150", "5701-0010", "7106-0035", "7505-0125", "7505-0200", "7505-0500", "7505-0700", "9010-0003", "9010-0006", "9010-0010", "9010-0012", "9010-0020", "9010-0022", "9010-0033", "9010-0040", "9010-0044", "9010-0060", "9010-0080", "9020-0010", "9020-0015", "9020-0016", "9020-0022", "9020-0035", "9020-0050", "9020-0072", "9020-0160", "9020-0180", "9020-0200", "9050-0075", "9050-0150", "9050-0450", "9050-0750", "9050-0850", "9090-0800", "9090-1000", "9090-1200", "9090-1300", "9090-1400", "9090-1500", "9090-1600", "3108-0060", "3108-0080", "3108-0100", "3108-0120", "9010-0120", "9010-0200"], "값": "1730-0015"}},
{"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류"}},
@@ -306,7 +306,7 @@
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"변수": [
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["7620-0003", "7621-0100", "7995-0050", "5210-0013", "5210-0027", "5220-0015", "7730-0150", "6516-0060", "6516-0120", "6516-0250", "6516-0300", "6105-0190", "6202-0060", "0230-0002", "6410-0080", "6410-0100", "6410-0120", "6410-0150", "6410-0200", "6901-0010", "6530-0090", "6530-0120", "6517-0100", "6517-0150", "6517-0200", "6517-0250", "6517-0300", "6510-0100", "6510-0150", "6510-0200", "6510-0250", "6510-0300", "6518-0100", "6518-0150", "6518-0200", "6518-0250", "6518-0300", "5501-0080", "7750-0016", "7750-0025", "7730-0050", "5401-0017", "5202-0127", "5202-0240", "5202-0300", "7611-0200", "7612-0200", "5204-0200", "5204-0300", "5204-0400", "5204-0500", "5204-0600", "5702-0095", "3430-0400", "7431-1300", "7740-0080", "7740-0150", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "2208-5008", "2208-5010", "2208-5012", "2208-5016", "2208-5020", "2105-0002", "2105-0003", "2105-0005", "2105-0010", "2105-0015", "2105-0018", "0201-0012", "0201-0020", "0201-0040", "0201-0060", "0201-0070", "0201-0080", "0201-0100", "0201-0120", "0201-0200", "6105-0390", "6202-0125", "7830-0081", "7950-0149", "7951-0066", "7952-0246", "7953-3400", "7954-1119", "7611-0300", "7431-1100", "7431-2300", "7431-2500", "7611-0400", "7611-0500", "7614-0300", "0250-0100", "5210-0010", "5210-0019", "0103-0016", "0103-0019", "0103-0023", "0103-0027", "0103-0032", "0230-0004", "0230-0006", "0230-0007", "0230-0008", "0230-0010", "0240-0007", "0250-0080", "0407-0054", "0407-0092", "0407-0107", "0407-0161", "0407-0206", "0610-0150", "0610-0200", "0610-0240", "2115-0024", "2115-0031", "2115-0036", "2116-0031", "2116-0036", "2117-0022", "2117-0027", "2118-0010", "2210-0145", "3430-0300", "3450-0642", "4115-0100", "4115-0150", "4115-0200", "4115-0300", "4505-0015", "4505-0026", "4611-0075", "5105-0050", "5105-0100", "5105-0150", "5105-0200", "5111-0040", "5111-0050", "5111-0060", "5111-0076", "5111-0091", "5112-0001", "5112-0002", "5112-0003", "5112-0004", "5112-0005", "5113-0001", "5113-0002", "5113-0003", "5113-0004", "5113-0005", "5113-0006", "5113-0007", "5113-0008", "5113-0009", "5113-0010", "5113-0011", "5114-0001", "5114-0002", "5114-0003", "5114-0004", "5114-0005", "5114-0006", "5114-0007", "5114-0008", "5115-0030", "5115-0055", "5115-0075", "5115-0095", "5116-0001", "5116-0002", "5116-0003", "5116-0004", "5116-0005", "5116-0006", "5116-0007", "5116-0008", "5117-0001", "5117-0002", "5117-0003", "5117-0004", "5117-0005", "5117-0006", "5117-0007", "5117-0008", "5118-0001", "5118-0002", "5118-0003", "5118-0004", "5118-0005", "5118-0006", "5118-0007", "5119-0625", "5203-1800", "5203-2200", "5203-2700", "5203-3500", "5203-4500", "5330-0074", "5805-0002", "5805-0003", "5901-0006", "5901-0010", "5901-0016", "6202-0200", "6408-0015", "6408-0020", "6408-0030", "6408-0040", "6408-0050", "6408-0085", "6408-0100", "6515-0090", "6530-0030", "6530-0040", "6530-0045", "6530-0060", "6532-0220", "6550-0130", "7103-0010", "7104-0010", "7202-0008", "7202-0010", "7205-0125", "7206-0020", "7206-0070", "7206-0100", "7430-1100", "7430-1300", "7430-1500", "7430-2300", "7430-2500", "7612-0300", "7612-0400", "7613-0075", "7613-0150", "7613-0300", "7613-0400", "7613-0600", "7613-0900", "7620-0002", "7730-0080", "7730-0100", "7730-0125", "7740-0100", "7770-0004", "7930-0001", "7930-0002", "7930-0003", "7930-0005", "7930-0007", "7930-0010", "7930-0015", "7930-0020", "7930-0025", "7930-0030", "7930-0040", "7930-0050", "7930-0075", "7930-0100", "7935-0180", "7991-0050", "7991-0100", "7991-0500", "7993-0020", "8201-0100", "8202-0100", "8203-0019", "8204-0100", "8205-0100", "9060-0060", "9060-0100", "9060-0200", "9060-0300", "9060-0500", "9060-0600", "9080-0050", "9080-0080", "9080-0100", "9080-0120", "9080-0150", "9080-0200", "9080-0300", "9080-0500", "9080-0700", "9080-1000", "9080-1100", "9080-1400", "9080-1500", "9080-1750", "9080-2000", "9080-3000"], "값": "7620-0003"}},
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["7620-0003", "7621-0100", "7995-0050", "5210-0013", "5210-0027", "5220-0015", "7730-0150", "6516-0060", "6516-0120", "6516-0250", "6516-0300", "6105-0190", "6202-0060", "0230-0002", "6410-0080", "6410-0100", "6410-0120", "6410-0150", "6410-0200", "6901-0010", "6530-0090", "6530-0120", "6517-0100", "6517-0150", "6517-0200", "6517-0250", "6517-0300", "6510-0100", "6510-0150", "6510-0200", "6510-0250", "6510-0300", "6518-0100", "6518-0150", "6518-0200", "6518-0250", "6518-0300", "5501-0080", "7750-0016", "7750-0025", "7730-0050", "5401-0017", "5202-0127", "5202-0240", "5202-0300", "7611-0200", "7612-0200", "5204-0200", "5204-0300", "5204-0400", "5204-0500", "5204-0600", "5702-0095", "3430-0400", "7431-1300", "7740-0080", "7740-0150", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "2105-0002", "2105-0003", "2105-0005", "2105-0010", "2105-0015", "2105-0018", "0201-0012", "0201-0020", "0201-0040", "0201-0060", "0201-0070", "0201-0080", "0201-0100", "0201-0120", "0201-0200", "6105-0390", "6202-0125", "7830-0081", "7950-0149", "7951-0066", "7952-0246", "7953-3400", "7954-1119", "7611-0300", "7431-1100", "7431-2300", "7431-2500", "7611-0400", "7611-0500", "7614-0300", "0250-0100", "5210-0010", "5210-0019", "0103-0016", "0103-0019", "0103-0023", "0103-0027", "0103-0032", "0230-0004", "0230-0006", "0230-0007", "0230-0008", "0230-0010", "0240-0007", "0250-0080", "0407-0054", "0407-0092", "0407-0107", "0407-0161", "0407-0206", "0610-0150", "0610-0200", "0610-0240", "2115-0024", "2115-0031", "2115-0036", "2116-0031", "2116-0036", "2117-0022", "2117-0027", "2118-0010", "2210-0145", "3430-0300", "4115-0100", "4115-0150", "4115-0200", "4115-0300", "4505-0015", "4505-0026", "4611-0075", "5111-0040", "5111-0050", "5111-0060", "5111-0076", "5111-0091", "5112-0001", "5112-0002", "5112-0003", "5112-0004", "5112-0005", "5113-0001", "5113-0002", "5113-0003", "5113-0004", "5113-0005", "5113-0006", "5113-0007", "5113-0008", "5113-0009", "5113-0010", "5113-0011", "5114-0001", "5114-0002", "5114-0003", "5114-0004", "5114-0005", "5114-0006", "5114-0007", "5114-0008", "5115-0030", "5115-0055", "5115-0075", "5115-0095", "5116-0001", "5116-0002", "5116-0003", "5116-0004", "5116-0005", "5116-0006", "5116-0007", "5116-0008", "5117-0001", "5117-0002", "5117-0003", "5117-0004", "5117-0005", "5117-0006", "5117-0007", "5117-0008", "5118-0001", "5118-0002", "5118-0003", "5118-0004", "5118-0005", "5118-0006", "5118-0007", "5203-1800", "5203-2200", "5203-2700", "5203-3500", "5203-4500", "5330-0074", "5901-0006", "5901-0010", "5901-0016", "6202-0200", "6408-0015", "6408-0020", "6408-0030", "6408-0040", "6408-0050", "6408-0085", "6408-0100", "6515-0090", "6530-0030", "6530-0040", "6530-0045", "6530-0060", "6532-0220", "6550-0130", "7103-0010", "7104-0010", "7206-0020", "7206-0070", "7206-0100", "7430-1100", "7430-1300", "7430-1500", "7430-2300", "7430-2500", "7612-0300", "7612-0400", "7613-0075", "7613-0150", "7613-0300", "7613-0400", "7613-0600", "7613-0900", "7620-0002", "7730-0080", "7730-0100", "7730-0125", "7740-0100", "7770-0004", "7930-0001", "7930-0002", "7930-0003", "7930-0005", "7930-0007", "7930-0010", "7930-0015", "7930-0020", "7930-0025", "7930-0030", "7930-0040", "7930-0050", "7930-0075", "7930-0100", "7935-0180", "8201-0100", "8202-0100", "8203-0019", "8204-0100", "8205-0100", "9060-0060", "9060-0100", "9060-0200", "9060-0300", "9060-0500", "9060-0600", "9080-0050", "9080-0080", "9080-0100", "9080-0120", "9080-0150", "9080-0200", "9080-0300", "9080-0500", "9080-0700", "9080-1000", "9080-1100", "9080-1400", "9080-1500", "9080-1750", "9080-2000", "9080-3000"], "값": "7620-0003"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
{"열쇠": "k_기계손료", "기호": "k", "아래": "기계손료", "위": "", "이름": "EQ:{기계}.손료계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "손료계수"}}
@@ -412,7 +412,7 @@
"갈래": "기계경비식"
},
"변수": [
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["5401-0017", "4108-0060", "4108-0090", "4108-0120", "4108-0150", "4108-0180", "4108-0210", "5401-0015"], "값": "5401-0017"}},
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["5401-0017", "4108-0060", "4108-0090", "4108-0120", "4108-0150", "4108-0180", "4108-0210", "5401-0015", "2208-5008", "2208-5010", "2208-5012", "2208-5016", "2208-5020", "5105-0050", "5105-0100", "5105-0150", "5105-0200", "5119-0625", "7205-0125"], "값": "5401-0017"}},
{"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "직종", "기호": "S", "아래": "직종", "위": "", "이름": "직종", "단위": "", "출처": "건설품셈 공통 8-1-3 5", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원직종"}},
@@ -2213,6 +2213,231 @@
"비목": {"방법": "버림", "자리": 0},
"끝전": "비목버림"
}
},
{
"꼴": "수식문서",
"판": 1,
"키": "GC001196",
"이름": "기계 시간당 기계경비(전력 기종)",
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6 · 8-1-6 3 가",
"부품": {
"갈래": "기계경비식"
},
"변수": [
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["7991-0050", "7202-0008", "7202-0010", "7991-0100", "7991-0500", "7992-0001", "7993-0020"], "값": "7991-0050"}},
{"열쇠": "전력 계약종별", "기호": "S", "아래": "전력계약종별", "위": "", "이름": "전력 계약종별", "단위": "", "출처": "유가전력 한전 전기요금표 2025-01-01", "연결": {"갈래": "선택", "목록": ["일반용(갑)Ⅰ 저압", "주택용 저압"], "값": "일반용(갑)Ⅰ 저압"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격", "계약종별": "S_전력계약종별"}},
{"열쇠": "k_기계손료", "기호": "k", "아래": "기계손료", "위": "", "이름": "EQ:{기계}.손료계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "손료계수", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_기계전력", "기호": "P", "아래": "기계전력", "위": "", "이름": "EQ:{기계}.전력", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "전력", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_전력", "기호": "P", "아래": "전력", "위": "", "이름": "전력단가", "단위": "", "연결": {"갈래": "요소", "재료": {"이름": "전력량요금", "계약종별": "입력"}, "단위": "㎾h", "계약종별": "S_전력계약종별"}},
{"열쇠": "r_기계잡재료", "기호": "r", "아래": "기계잡재료", "위": "", "이름": "EQ:{기계}.잡재료율", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "잡재료율", "계약종별": "S_전력계약종별"}}
],
"식": [
{"기호": "C_손료", "식": "버림(P_기계 × 1000 × k_기계손료 ÷ 10000000, 0)"}
],
"금액": [
{"이름": "전력", "단위": "㎾h", "비목": "재료비", "수량": "P_기계전력", "단가": "P_전력", "출처": "건설품셈 공통 8-1-6 3 가"},
{"이름": "잡재료비", "단위": "식", "비목": "재료비", "단가": "재료비 × r_기계잡재료 ÷ 100", "출처": "건설품셈 공통 8-4 주②"},
{"이름": "기계손료", "단위": "식", "비목": "경비", "단가": "C_손료", "출처": "건설품셈 공통 8-1-6 2"}
],
"끝수": {
"줄": {"방법": "버림", "자리": 1},
"비목": {"방법": "버림", "자리": 0},
"끝전": "비목버림"
}
},
{
"꼴": "수식문서",
"판": 1,
"키": "GC001197",
"이름": "기계 시간당 기계경비(전력 · 조종원 기종)",
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6 · 8-1-6 3 가",
"부품": {
"갈래": "기계경비식"
},
"변수": [
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["5805-0002", "5805-0003"], "값": "5805-0002"}},
{"열쇠": "전력 계약종별", "기호": "S", "아래": "전력계약종별", "위": "", "이름": "전력 계약종별", "단위": "", "출처": "유가전력 한전 전기요금표 2025-01-01", "연결": {"갈래": "선택", "목록": ["일반용(갑)Ⅰ 저압", "주택용 저압"], "값": "일반용(갑)Ⅰ 저압"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "직종", "기호": "S", "아래": "직종", "위": "", "이름": "직종", "단위": "", "출처": "건설품셈 공통 8-1-3 5", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원직종", "계약종별": "S_전력계약종별"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)", "연결": {"갈래": "식"}},
{"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격", "계약종별": "S_전력계약종별"}},
{"열쇠": "k_기계손료", "기호": "k", "아래": "기계손료", "위": "", "이름": "EQ:{기계}.손료계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "손료계수", "계약종별": "S_전력계약종별"}},
{"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}},
{"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}},
{"열쇠": "P_기계전력", "기호": "P", "아래": "기계전력", "위": "", "이름": "EQ:{기계}.전력", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "전력", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_전력", "기호": "P", "아래": "전력", "위": "", "이름": "전력단가", "단위": "", "연결": {"갈래": "요소", "재료": {"이름": "전력량요금", "계약종별": "입력"}, "단위": "㎾h", "계약종별": "S_전력계약종별"}},
{"열쇠": "Q_기계조종원", "기호": "Q", "아래": "기계조종원", "위": "", "이름": "EQ:{기계}.조종원", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원", "계약종별": "S_전력계약종별"}},
{"열쇠": "w_기계운전사", "기호": "w", "아래": "기계운전사", "위": "", "이름": "건설기계운전사노임", "단위": "", "연결": {"갈래": "요소", "키": "LB:{S_직종}", "계약종별": "S_전력계약종별"}},
{"열쇠": "r_기계잡재료", "기호": "r", "아래": "기계잡재료", "위": "", "이름": "EQ:{기계}.잡재료율", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "잡재료율", "계약종별": "S_전력계약종별"}}
],
"식": [
{"기호": "C_손료", "식": "버림(P_기계 × 1000 × k_기계손료 ÷ 10000000, 0)"},
{"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표"}
],
"금액": [
{"이름": "전력", "단위": "㎾h", "비목": "재료비", "수량": "P_기계전력", "단가": "P_전력", "출처": "건설품셈 공통 8-1-6 3 가"},
{"이름": "조종원", "단위": "인", "비목": "노무비", "수량": "Q_기계조종원 × Q_시간환산", "단가": "w_기계운전사", "출처": "건설품셈 공통 8-1-3 6"},
{"이름": "잡재료비", "단위": "식", "비목": "재료비", "단가": "재료비 × r_기계잡재료 ÷ 100", "출처": "건설품셈 공통 8-4 주②"},
{"이름": "기계손료", "단위": "식", "비목": "경비", "단가": "C_손료", "출처": "건설품셈 공통 8-1-6 2"}
],
"끝수": {
"줄": {"방법": "버림", "자리": 1},
"비목": {"방법": "버림", "자리": 0},
"끝전": "비목버림"
}
},
{
"꼴": "수식문서",
"판": 1,
"키": "GC001198",
"이름": "기계 시간당 기계경비(조종원 없는 기종 · 손료계수 한 칸)",
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"부품": {
"갈래": "기계경비식"
},
"변수": [
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["7994-0050"], "값": "7994-0050"}},
{"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류"}},
{"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
{"열쇠": "k_기계손료", "기호": "k", "아래": "기계손료", "위": "", "이름": "EQ:{기계}.손료계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "손료계수"}},
{"열쇠": "P_기계주연료", "기호": "P", "아래": "기계주연료", "위": "", "이름": "EQ:{기계}.주연료", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료"}},
{"열쇠": "P_휘발유", "기호": "P", "아래": "휘발유", "위": "", "이름": "휘발유단가", "단위": "", "연결": {"갈래": "요소", "키": "MO:{S_연료}:{S_지역}"}},
{"열쇠": "r_기계잡재료", "기호": "r", "아래": "기계잡재료", "위": "", "이름": "EQ:{기계}.잡재료율", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "잡재료율"}}
],
"식": [
{"기호": "C_손료", "식": "버림(P_기계 × 1000 × k_기계손료 ÷ 10000000, 0)"}
],
"금액": [
{"이름": "주연료", "단위": "ℓ", "비목": "재료비", "수량": "P_기계주연료", "단가": "P_휘발유", "출처": "건설품셈 공통 8-4-8"},
{"이름": "잡재료비", "단위": "식", "비목": "재료비", "단가": "재료비 × r_기계잡재료 ÷ 100", "출처": "건설품셈 공통 8-4 주②"},
{"이름": "기계손료", "단위": "식", "비목": "경비", "단가": "C_손료", "출처": "건설품셈 공통 8-1-6 2"}
],
"끝수": {
"줄": {"방법": "버림", "자리": 1},
"비목": {"방법": "버림", "자리": 0},
"끝전": "비목버림"
}
},
{
"꼴": "수식문서",
"판": 1,
"키": "GC001199",
"이름": "기계 시간당 기계경비(직접 값)",
"결과단위": "원/시간",
"출처": "자체",
"부품": {
"갈래": "기계경비식"
},
"변수": [
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": [], "값": 0}},
{"열쇠": "P_EQ기계", "기호": "P", "아래": "EQ기계", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "노무비"}},
{"열쇠": "P_EQ기계2", "기호": "P", "아래": "EQ기계2", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "재료비"}},
{"열쇠": "P_EQ기계3", "기호": "P", "아래": "EQ기계3", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "경비"}}
],
"식": [],
"금액": [
{"이름": "시간당 노무비", "단위": "식", "비목": "노무비", "단가": "P_EQ기계", "출처": "자체"},
{"이름": "시간당 재료비", "단위": "식", "비목": "재료비", "단가": "P_EQ기계2", "출처": "자체"},
{"이름": "시간당 경비", "단위": "식", "비목": "경비", "단가": "P_EQ기계3", "출처": "자체"}
],
"끝수": {
"줄": {"방법": "버림", "자리": 1},
"비목": {"방법": "버림", "자리": 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},
"끝전": "비목버림"
}
}
]
}
@@ -0,0 +1,12 @@
{
"format": 6,
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
+5
View File
@@ -9,12 +9,14 @@ for (const file of [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-row-match.js",
"native-escalation.js",
"native-progress.js",
"native-annual-load.js",
"native-business.js",
"native-review.js",
"native-resources.js",
@@ -32,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 });
}
+3 -1
View File
@@ -116,7 +116,9 @@ def _hourly_inputs(logic_key: str) -> dict:
def _kind(row: dict) -> tuple:
return (*mh._rank(row)[:3], any(x.get("이름") == "조종원" for x in row.get("호표") or []))
"""갈래 = 부착 · 손료만 · 보정 · 조종원 줄 · 전력 줄(822-2 전력 기종 식 — 손료만 식과 갈림)."""
lines = {x.get("이름") for x in row.get("호표") or []}
return (*mh._rank(row)[:3], "조종원" in lines, "전력" in lines)
# 기계표 조종원 1 인 · 「조종원 없는 기종」 GC000272 목록에도 겹친 기계(815-3) — 조종원 든 식
@@ -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
@@ -0,0 +1,276 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""822-2 기계 시간당 경비 식 완성 — 지킴 시험(정본 고침은 임시 사본 폴더 · 임시 storage 만).
① 모든 기계 = 식 있음 또는 까닭 글(「셀 자료가 없음」 0) ② 식 고름 = 목록 › 꼴(채운 칸) 한 함수
③ M01 [새 기계] 저장 = 꼴 식 목록 등록 · 지우면 목록에서 뺌 · 로직 파일 글 그대로(임시 마스터)
④ 직접 값 식(GC001199) — 내 기계 「직접」 시간당 · 줄 쌓기 식 ⑤ 전력 줄 — 점보드릴 135㎾ × 전력 단가 +
잡재료 6 % + 손료 + 조종원 · 계약종별 바꿈 · 내 기계 전력 · 연료 + 전력(822-2b GC001200 · + 조종원 GC001201) ⑥ 옮긴 기계 견본(아스팔트 믹싱플랜트 · 타워크레인 ·
직종 입력 = 아스팔트 스프레어) ⑦ 미리보기 = 서버 값 ⑧ 안 옮긴 기계 금액 그대로 ⑨ POWER TROWEL 이름.
"""
from __future__ import annotations
import json
import shutil
import sys
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from common_util import common_util_auth as auth
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Machine_Hourly as mh
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "resources" / "master_data" / "scripts"))
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}
def _rows() -> dict[str, dict]:
return {r["키"]: r for r in m01.read(MACHINE)[0]["줄"]}
def _wage(job: str) -> Decimal:
return Decimal(str(m01.loaded()[1].get(f"LB:{job}")["값"]))
def _codes(key: str) -> list[str]:
row = next(r for r in m01.read(LOGIC)[0]["줄"] if r["키"] == key)
return [str(c) for c in next(i for i in row["입력"] if i["이름"] == "기계").get("고르기", [])]
# ① · ② ───────────────────────────────────────────────────────────────
def test_every_machine_has_formula_or_reason():
"""모든 기계 — 식을 고르고 값이 다 있음 · 아니면 까닭 글(빈 답 0)."""
silent = []
for key, row in _rows().items():
row = m01.loaded()[1].get(key)
logic = mh.pick(row)
if not logic and not mh.why(row, logic):
silent.append(key)
assert silent == []
assert mh.hourly("EQ000343")["reason"] == "가격 없음" # TBM 3.0m
assert "일 단위" in mh.hourly("EQ000621")["reason"] # 산림품셈 예취기(1일 1대당)
assert mh.hourly("EQ000202")["reason"] == "주연료 「73.7+」 — 수 아님" # 표층재생기 · 안 풂
assert "범위" in mh.hourly("EQ000552")["reason"] # 펌프 준설선 잡재료 13∼18
assert (
"중유" in mh.hourly("EQ000187")["reason"]
) # 아스팔트 믹싱플랜트 40t — 유가 표에 중유 없음
def test_pick_listed_then_shape():
"""목록에 있으면 그 식(짝 차례) · 없으면 꼴 — 내 기계와 같은 규칙."""
assert mh.logic_of("0101-0007")["키"] in {"GC000268", "GC000269"} # 목록
shape = mym.logic_key
full = {"가격": 1, "손료계수": 3, "상각비계수": 1, "정비비계수": 1, "관리비계수": 1}
assert shape({"값": {**full, "주연료": 1, "조종원": 1, "조종원직종": "1048"}}) == "GC000268"
assert shape({"값": {**full, "주연료": 1, "조종원": 1}}) == "GC000275"
assert shape({"값": {**full, "주연료": 1}}) == "GC000272"
assert shape({"값": {"가격": 1, "손료계수": 3, "주연료": 1}}) == ONE_FUEL
assert shape({"값": {**full, "조종원": 1, "조종원직종": "1048"}}) == "GC000276"
assert shape({"값": {**full, "전력": 5}}) == POWER
assert shape({"값": {**full, "전력": 5, "조종원": 1, "조종원직종": "1048"}}) == POWER_CREW
assert shape({"값": full}) == "GC000273"
assert shape({"방식": "직접", "값": {}}) == DIRECT
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
# ③ M01 [새 기계] 식 등록 ──────────────────────────────────────────────
@pytest.fixture
def copy(tmp_path: Path, monkeypatch) -> Path:
for path in m01.FOLDER.glob("*.json"):
if not path.name.startswith("_") or path.name == m01.mk.BOOK.name:
shutil.copy(path, tmp_path / path.name)
monkeypatch.setattr(m01, "FOLDER", tmp_path)
return tmp_path
def _app() -> TestClient:
app = FastAPI()
app.dependency_overrides[auth.verify_session] = lambda: {
"user_id": 1, "name": "시험", "role": "SYSTEM_ADMIN", "company_id": 1,
} # fmt: skip
deps = [Depends(auth.verify_session), Depends(auth.require_system_admin)]
app.include_router(router_module.router, dependencies=deps)
return TestClient(app)
def _save(client: TestClient, changes: list[dict]) -> None:
version = client.get("/api/m01/machine/row", params={"key": "EQ000022"}).json()["version"]
body = {"files": [{"file": MACHINE, "version": version, "changes": changes}]}
res = client.post("/api/m01/save", json=body)
assert res.status_code == 200, res.text
def test_new_machine_registers_and_unregisters(copy: Path):
client = _app()
logic_text = (copy / LOGIC).read_text(encoding="utf-8")
seed = json.loads(json.dumps(_rows()["EQ000022"], default=float))
row = {**seed, "키": "", "원문번호": "9999-0822", "이름": NAME, "출처": {}, "단위": {}}
del row["별칭"]
draft = client.post("/api/m01/machine/formula", json=row).json()
assert draft["키"] == "GC000268" and draft["방법"] == "꼴" and not draft["까닭"]
_save(client, [{"op": "add", "row": row}])
assert "9999-0822" in _codes("GC000268")
key = next(k for k, r in _rows().items() if r["원문번호"] == "9999-0822")
hour = client.get("/api/m01/machine/hourly", params={"keys": key}).json()[key]
assert hour["로직"] == "GC000268" and hour["계"] == mh.hourly("EQ000022")["계"]
saved = json.loads(json.dumps(_rows()[key], default=float))
got = client.post("/api/m01/machine/formula", json=saved).json()
assert got["방법"] == "목록" and got["키"] == "GC000268"
_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…」 받을 키
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}])
assert (copy / LOGIC).read_text(encoding="utf-8") == logic_text
# ④ 직접 값 식 ─────────────────────────────────────────────────────────
def _at(folder, fn, *args):
with store.at(folder):
return fn(*args)
def test_direct_formula_my_machine(world):
me = world["personal"]
body = {
"이름": NAME,
"규격": "직접",
"방식": "직접",
"시간당": {"노무비": 10, "재료비": 20, "경비": 30},
}
made = _at(me, mym.create, body)
assert made["시간당"]["로직"] == DIRECT and made["시간당"]["계"] == 60
no = made["줄"]["원문번호"]
whole = _at(me, mym.whole)
run = m01.cm.mf.run(whole, DIRECT, {"기계": no}) # 직접 값 식 — 내 기계 원문번호를 받음
assert (run["노무비"], run["재료비"], run["경비"]) == (10, 20, 30)
line = {"종류": "로직", "로직": DIRECT, "입력값": {"기계": no}, "수량": "2"}
name = _at(
me, lambda: refs.create("sangeun", {"이름": NAME, "단위": "시간", "더한줄": [line]})["이름"]
)
doc = _at(me, lambda: store.read("sangeun", name)["문서"])
got = _at(me, calc.calc_doc, doc, None, False)["기준"]
assert (got["노무비"], got["재료비"], got["경비"]) == (20, 40, 60)
# ⑤ 전력 줄 ────────────────────────────────────────────────────────────
def _preview(key: str) -> dict:
v = json.loads(json.dumps(_rows()[key]["값"], default=float))
fuel = m01.loaded()[1].numbers["MO"].get(f"{v.get('주연료종류')}:전국평균")
wage = float(_wage(v["조종원직종"])) if v.get("조종원직종") else 0
return mc.preview(v, float((fuel or {}).get("값") or 0), wage, 114.4)
def test_power_line_jumbo_drill():
h = mh.hourly("EQ000347") # 점보드릴 2붐 135㎾ · 잡재료 6 % · 조종원 1(건설기계운전사)
assert h["로직"] == POWER_CREW
loss = 605472 * 1000 * 2515 // 10**7
elec = Decimal("135") * Decimal("114.4")
misc = int(elec * 6) / Decimal(100)
assert h["경비"] == loss and h["재료비"] == int(elec + misc)
assert h["노무비"] == int(_wage("1048") / 8)
p = _preview("EQ000347")
assert (p["노무비"], p["재료비"], p["경비"], p["계"]) == (
h["노무비"],
h["재료비"],
h["경비"],
h["계"],
)
home = m01.calc(POWER_CREW, {"기계": "5805-0002", "전력 계약종별": "주택용 저압"})
assert home["ok"] and float(home["sums"]["재료비"]) > h["재료비"] # 120 원/kWh > 114.4
def test_power_my_machine(world):
me = world["personal"]
body = {
"이름": NAME,
"규격": "전력",
"방식": "바로",
"값": {"가격": 1000, "손료계수": 2000, "전력": 10, "잡재료율": 5},
}
hour = _at(me, mym.create, body)["시간당"]
assert hour["로직"] == POWER
want = mc.preview({"가격": 1000, "손료계수": 2000, "전력": 10, "잡재료율": 5}, 0, 0, 114.4)
assert (hour["재료비"], hour["경비"]) == (want["재료비"], want["경비"]) == (1201, 200)
# 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["계"])
# ⑥ · ⑦ 옮긴 기계 견본 ─────────────────────────────────────────────────
def test_moved_samples():
plant = mh.hourly("EQ000188") # 아스팔트 믹싱플랜트 60t — 연료 + 조종원 2 인
assert plant["로직"] == "GC000268" and plant["노무비"] == int(_wage("1048") * 2 / 8)
assert plant["재료비"] > 0
tower = mh.hourly("EQ000165") # 타워크레인 — 건설기계운전사 조종원 줄(요구 799)
assert tower["로직"] == "GC000276" and tower["노무비"] == int(_wage("1048") / 8)
spray = m01.loaded()[1].get("EQ000198") # 아스팔트 스프레어 — 직종 빈 칸 = 직종 입력 식
assert mh.pick(spray)["키"] == "GC000275" and "조종원직종" in json.dumps(
mh.pick(spray)["입력"], ensure_ascii=False
)
assert "가격" in mh.hourly("EQ000198")["reason"]
assert "3450-0642" not in _codes("GC000273") # 손료만 식에서 뺌
for key, logic in (
("EQ000529", POWER),
("EQ000534", ONE_FUEL),
("EQ000532", POWER),
("EQ000426", POWER),
):
h = mh.hourly(key)
p = _preview(key)
assert h["로직"] == logic and (p["재료비"], p["경비"], p["계"]) == (
h["재료비"],
h["경비"],
h["계"],
), key
for key in ("EQ000188", "EQ000165"):
h, p = mh.hourly(key), _preview(key)
assert (p["노무비"], p["재료비"], p["경비"]) == (h["노무비"], h["재료비"], h["경비"]), key
# ⑧ · ⑨ ────────────────────────────────────────────────────────────────
def test_unmoved_same_and_name():
for key, total in KEEP.items():
assert mh.hourly(key)["계"] == total
row = _rows()["EQ000534"]
assert (row["이름"], row["옛이름"], row["별칭"]) == (
"POWER TROWEL",
"POWER TLOWEL",
"파워트로웰",
)
+208
View File
@@ -0,0 +1,208 @@
"""826-1 층 목록 요약 색인 — 답 = 다시 셈한 것 · 자국 같으면 항목 파일 안 엶 · 고침 · 지움 · 더함 ·
참조 고침 · M01 값 바뀜 · 색인 깨짐 뒤에도 맞음 · 판 충돌(409) 그대로."""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Layer_Index as index
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_labor_ref import COMPANY, world # noqa: F401 — 같은 임시 층
PCT = "GC001109" # 원/㎡ · 인력 둘 + 덧줄 %
WAGE = "LB000001"
LIB = ("sangeun", "precombo", "labor")
@pytest.fixture()
def fresh(world, monkeypatch): # noqa: F811
"""색인 기억 비움 — 시험끼리 안 섞임."""
monkeypatch.setattr(index, "_MEM", {})
monkeypatch.setattr(index, "_IDS_LOADED", [])
monkeypatch.setattr(refs, "_IDS", {})
return world
def _forget():
index._MEM.clear()
index._IDS_LOADED.clear()
refs._IDS.clear()
def _sk(folder, name, extra=None):
doc = {"이름": name, "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": extra or []}
with store.at(folder):
return refs.create("sangeun", doc)["이름"]
def _wage_line(qty=1):
return {"종류": "인력", "요소": WAGE, "이름": "보통인부", "단위": "인", "수량": qty}
def _old(folder):
rows = layers.list_templates(folder)
refs.summaries(folder, rows)
return rows
def _new(folder):
rows = index.list_rows(folder)
index.summaries(folder, rows)
return rows
def _same(folder):
got = _new(folder)
assert json.dumps(got, ensure_ascii=False) == json.dumps(_old(folder), ensure_ascii=False)
return got
@pytest.fixture()
def reads(monkeypatch):
"""항목 파일(서고 · 일위대가) 읽기 수."""
seen: list[str] = []
real = Path.read_bytes
def counting(self):
if self.parent.name in LIB:
seen.append(f"{self.parent.name}/{self.stem}")
return real(self)
monkeypatch.setattr(Path, "read_bytes", counting)
return seen
def _shelf(w):
up = _sk(w["company"], "회사 단가산출")
mine = w["personal"]
a = _sk(mine, "가", [_wage_line()])
b = _sk(mine, "나", [{"참조": {**COMPANY, "키": up}, "수량": 2}])
with store.at(mine):
sc = refs.create(
"precombo",
{
"이름": "일위대가",
"단위": "㎡",
"담은로직": [{"참조": {**COMPANY, "키": up}, "수량": {"손": 3}}],
},
)["이름"]
return up, a, b, sc
def test_same_and_no_reads_when_stamps_same(fresh, reads):
up, a, b, sc = _shelf(fresh)
mine = fresh["personal"]
first = _same(mine)
assert {r["이름"] for r in first} >= {a, b, sc} and first[0]["기준금액"] is not None
reads.clear()
assert _new(mine) == first and reads == [] # 메모리 기억
_forget()
reads.clear()
assert _new(mine) == first and reads == [] # 디스크 파생 저장본(서버 다시 뜸)
# 쓰는곳수 — 회사 SK 를 개인 SK 「나」 가 씀(사전조합은 사본으로 펼쳐 담음 · 740-1)
company = _same(fresh["company"])
assert company[0]["쓰는곳수"] == 1
def _unfreeze(folder, key):
"""옛 꼴(사본 뗌) — M01 을 지금 값으로 셈(사본 있으면 만든 때 값 그대로)."""
doc = json.loads(layers.template_path(folder, "sangeun", key).read_bytes())
old = {k: v for k, v in doc.items() if k not in ("사본", "수식", "M01판", "바탕판")}
layers.write_template(folder, "sangeun", key, old)
def test_edit_delete_add_and_dep_change(fresh, reads):
up, a, b, sc = _shelf(fresh)
mine = fresh["personal"]
gone = _sk(mine, "지울것")
_same(mine)
with store.at(mine): # 고침 · 지움
got = store.read("sangeun", a)
store.write("sangeun", a, got["판"], {**got["문서"], "더한줄": [_wage_line(3)]})
store.delete("sangeun", gone)
c = _sk(mine, "다") # 더함
reads.clear()
rows = _new(mine)
assert set(reads) == {f"sangeun/{a}", f"sangeun/{c}"} # 바뀐 항목만 다시 읽음
assert rows == _old(mine)
titles = {r["이름"]: r.get("제목") for r in rows if r["종류"] in index.layers.LIBRARY_KINDS}
assert gone == c and titles == {
a: "가",
b: "나",
c: "다",
sc: "일위대가",
} # 지운 키를 새 것이 받음
reads.clear()
was = {r["이름"]: r.get("기준금액") for r in _new(mine)}
assert reads == []
with store.at(fresh["company"]): # 사전조합이 담은 위 층 SK 고침 → 그 사전조합만 다시
got = store.read("sangeun", up)
store.write("sangeun", up, got["판"], {**got["문서"], "더한줄": [_wage_line(5)]})
reads.clear()
now = {r["이름"]: r.get("기준금액") for r in _new(mine)}
assert set(reads) == {f"precombo/{sc}", f"sangeun/{up}"}
assert now[sc] != was[sc] and {k: v for k, v in now.items() if k != sc} == {
k: v for k, v in was.items() if k != sc
}
assert _new(mine) == _old(mine)
def test_m01_change_recounts_amount(fresh, tmp_path, monkeypatch):
copy = tmp_path / "m01"
copy.mkdir()
for path in Path(m01.FOLDER).glob("*.json"):
shutil.copyfile(path, copy / path.name)
monkeypatch.setattr(m01, "FOLDER", copy)
mine = fresh["personal"]
a = _sk(mine, "가", [_wage_line()])
_unfreeze(mine, a)
one = next(r for r in _same(mine) if r["이름"] == a)["기준금액"]
assert one is not None
wages = json.loads((copy / "인력.json").read_text(encoding="utf-8"))
for row in wages["줄"]:
if row.get("키") == WAGE:
row["값"] = float(row["값"]) * 2
(copy / "인력.json").write_text(json.dumps(wages, ensure_ascii=False), encoding="utf-8")
two = next(r for r in _same(mine) if r["이름"] == a)["기준금액"]
assert two != one
def test_broken_index_rebuilds(fresh):
_up, _a, _b, _sc = _shelf(fresh)
mine = fresh["personal"]
first = _same(mine)
folder = index.derived_dir(layers.storage_root(), index.KIND)
for path in folder.glob("*.json"):
path.write_text("{깨짐", encoding="utf-8")
_forget()
assert _same(mine) == first
for path in folder.glob("*.json"):
path.write_text(json.dumps({"key": path.stem, "value": {"sangeun/x": 1}}), encoding="utf-8")
_forget()
assert _same(mine) == first
shutil.rmtree(folder)
_forget()
assert _same(mine) == first
def test_version_conflict_still_409(fresh):
mine = fresh["personal"]
a = _sk(mine, "가")
listed = next(r for r in index.list_rows(mine) if r["이름"] == a)["판"]
path = layers.template_path(mine, "sangeun", a)
assert listed == layers.version_of(path)
doc = json.loads(path.read_bytes())
layers.write_template(
mine, "sangeun", a, {**doc, "단위": "m"}, version=listed, check_version=True
)
with pytest.raises(layers.StaleTemplate): # 옛 판으로 또 저장
layers.write_template(mine, "sangeun", a, doc, version=listed, check_version=True)
fresh_ver = next(r for r in index.list_rows(mine) if r["이름"] == a)["판"]
assert fresh_ver == layers.version_of(path) != listed
+86
View File
@@ -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('<p')[0]);};
const plain=head({});
const custom=head({d:{code:false,gnZero:false},a:{level:true,sajung:false,gubun:true,gungu:true}});
return {plain:plain.h,custom:custom.h,jun:custom.rows[0][3]};
})()""",
)
assert result["plain"][0] == "코드" and "사정금액" in result["plain"]
assert result["custom"] == [
"레벨",
"공종",
"도급수량",
"전회수량",
"금회 입력",
"입력방법",
"금회금액",
"금회 노무",
"금회 재료",
"금회 경비",
"누계수량",
"누계금액",
"비고",
]
assert result["jun"] == ""
def test_origin_load_method_uses_original_fields():
result = run(
"math",
"""(()=>{
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,
}
+86
View File
@@ -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)=>`<button data-action="${a}">${t}</button>`,
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 수량",
"선시공 수량",
"적용제외",
"적용수량",
]
+1
View File
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
+1
View File
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
+1
View File
@@ -14,6 +14,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
+1
View File
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
+1
View File
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
+36
View File
@@ -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
+1
View File
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
+1
View File
@@ -12,6 +12,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-jungsang.js",
+1
View File
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
+1
View File
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
+61
View File
@@ -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
+1
View File
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
+34
View File
@@ -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]
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
@@ -202,3 +202,21 @@ def test_progress_preserves_prior_costs_for_multiple_blank_codes():
assert result["errors"] == []
assert [row["JunCost"] for row in result["costs"]] == [200, 100]
assert [row["TotalCost"] for row in result["costs"]] == [500, 250]
def test_annual_loaded_rows_replace_formula():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100},{PkID:2,Code:'S',GN:4,NU:50},{LinkIndex:54}];
const loaded=[{PkID:1,Code:'R',GN:3,GU:100,GC:300,TC:300},{PkID:2,Code:'S',GN:2,NU:50,NC:100,TC:100}];
p.annual=[{name:'1차',formula:'다른공사',rows:loaded}];
const out=EstBusiness.annual(EstNative.calculate(p));
const rows=out.results[0].project.tables.EstMainDat;
return {gn:rows.slice(0,2).map(r=>r.GN), tc:out.results[0].project.calculation.direct.TC, errors:out.errors};
})()""",
)
assert result["gn"] == [3, 2]
assert result["tc"] == 400
assert result["errors"] == []
@@ -0,0 +1,71 @@
"""EST 테스트 앱 워드형 수량산출서 표(원본 WPT <table> 보존 · 방향 셀 합 · 수량 표지 · 세로 병합)."""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
MAIN = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
def node(code: str, *args: str):
return json.loads(
subprocess.check_output(["node", "-e", code, *args], cwd=ROOT).decode("utf-8")
)
def test_table_parse_sum_values_merge():
out = node(r"""
const W = require(process.cwd() + '/tools/est-test/native-quantity-wpt.cjs');
const wpt = '<begin Tag=1 x=[[Name:"N000001"]]/><begin Tag=2 x=[[Name:"G00000203"]]/>' +
'<div>단가 <begin Tag=2/>9<end Tag=2/></div>' +
'<table><tr><td>공종</td><td>수량</td></tr>' +
'<tr><td><begin Tag=1/>절단<end Tag=1/></td><td><begin Tag=2/>1,000.5<end Tag=2/></td></tr>' +
'<tr><td>깨기</td><td>2</td></tr><tr><td>계</td><td></td></tr></table>';
const s = W.fromWpt('N1', '시험', wpt, [], {});
const t = s.rows[1].table;
const div = s.rows[0].LineData;
t[3][1].formula = W.sumFormula(t, 3, 1, 'down');
const values = Object.fromEntries(W.values(s));
const right = W.sumFormula(t, 3, 1, 'right');
W.vmerge(t, 1, 2, 0);
const merged = [t[1][0].text, t[2][0].vmerge, t[1][0].vtop];
W.vsplit(t, 1, 0);
process.stdout.write(JSON.stringify({ rows: s.rows.length, div, cell: t[1][1], name: t[1][0].name,
formula: t[3][1].formula, sum: t[3][1].text, values, right, merged, split: t[2][0].vmerge ?? null,
created: W.create(2, 3).map((r) => r.length) }));
""")
assert out["rows"] == 2
assert out["div"] == "단가 9" # 문단의 G 표지는 표지로 보지 않음
assert out["cell"] == {"text": "1,000.5", "mark": "G00000203"}
assert out["name"] is True
assert out["formula"] == "SUM(B2:B3)" and out["sum"] == "1,002.5"
assert out["values"] == {"G00000203": 1000.5}
assert out["right"] == "SUM(A4:A4)"
assert out["merged"] == ["절단 깨기", True, 2]
assert out["split"] is None
assert out["created"] == [3, 3]
def test_installed_main_tables_survive_import():
if not MAIN.exists():
pytest.skip("installed EST main sample unavailable")
out = node(
r"""
const { importEstx } = require(process.cwd() + '/tools/est-test/import-estx.cjs');
const p = importEstx(require('fs').readFileSync(process.argv[1]));
const tables = p.quantitySheets.filter((s) => s.rows.some((r) => r.table))
.map((s) => { const t = s.rows.find((r) => r.table).table; return [s.Code, t.length, t[0].length]; });
process.stdout.write(JSON.stringify(tables));
""",
str(MAIN),
)
assert out == [
["N0000000097", 9, 13],
["N0000000108", 7, 5],
["N0000000109", 5, 5],
["N0000000110", 7, 5],
["N0000000111", 8, 5],
]
@@ -10,6 +10,7 @@ FILES = [
"native-formula.js",
"native-text.js",
"native-quantity-total.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
]
SCRIPT = r"""(() => {
+1
View File
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-sources.js",
+1
View File
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
+1
View File
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
+1
View File
@@ -11,6 +11,7 @@ FILES = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
+23
View File
@@ -489,6 +489,29 @@ 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 <table> 을 통째로 버리던 결함 고침: 표를 한 줄 { table: [[칸]] }(글·수량 표지 r/L/G·품명/규격 표지·수식·세로병합)로 보존(설치 main.estx 표 5개 9×13~5×5). 문서 읽기는 `native-quantity-wpt.cjs` 로 분리. 표 화면: 칸 고르기(Shift 범위)·글/수식 고치기·방향 셀 합(우·좌·위·아래, 원본 범위 규칙·1,234.5 꼴)·수량으로 지정/해제·셀수직병합/분리·표 모양(테두리)·아래 표 만들기. 줄 도구: 문단(정렬·들여쓰기)·그림 넣기·한페이지 삽입/삭제(웹 한 쪽 45줄)·품명지정/규격지정(한 줄 하나·40자·제목 위 금지·원본 문구)·테두리선·산출근거 양식(화면 표시)·공종명칭 입력(머리 제목 줄 굵게 큰 글자). 수량 불러오기(Main Data 산출서 75개에서 새 산출서로), 수량목록보기(모든 산출서·수량 항목·원문 미리보기·검색·선택수량복사), 내역서 수량붙여넣기(연결)(고른 줄부터 아래로 수량·연결, 품명,규격입력 체크 시 이름도). 표·서식을 고친 산출서는 ESTX 저장 거부(JSON), 고치지 않은 표 산출서는 ESTX 저장이 표 줄을 건너 문단만 맞춤.
- 검증완료: `test_est_quantity_table.py` 2개(표 읽기·문단 G 표지 무시·아래 합 SUM(B2:B3)=1,002.5·수량 값·병합/분리·설치 표 5개) 및 수량·ESTX 내보내기 회귀 49개 통과. ORCA 브라우저에서 보차도경계석 보수 표 35칸, 수량 1.0→2.5 항목 수량 반영, 아래 합 SUM(D2:D6)=3.923, 수량 불러오기 75개·문단 가운데·산출근거 양식·쪽 삽입 45줄·삭제, 공종명칭 「보차도경계석 보수(L=1m)」, 품명지정 ⟦품명⟧, 수량목록 복사 → 내역 붙여넣기 1·0.19·연결 id:36, 샘플 복구.
- 남음: 글자모양(글꼴·색), 수량산출서 원본 모양 인쇄(쪽·간지), 엑셀형 시트 수량붙여넣기(연결).
- [x] 연차 내역서·상단·하단데이타 불러오기(원본 TFRMESTMULTIEDIT popLoadEst·popUpDataLoad·popDownLoad 0xedb784 → 0xf11a60 — spec_part §7) — 차수 줄의 세 단추: 고른 .estx 내역서 줄을 차수에 짝지어 옮기고(행 수는 현재 내역), 수식칸에 파일 이름. 상단·하단은 설계변경 내역만(당초 「_」 칸 있는 것), 상단 = 당초값·하단 = 변경값. 그 차수의 상세 줄은 수식 대신 불러온 값(금액 고정, 다시 셈 없음). 서버 계산도 같은 코드(`native-annual-load.js`).
- 검증완료: 기성·연차 시험 13개(불러온 줄 수량 3·2·직접공사비 400, 수식 무시) 통과. 당초/변경 고르기·설계변경 아닌 파일 거부 확인. ORCA 브라우저에서 차수 줄에 불러오기 단추 3개 표시(파일 선택은 수동 조작 필요).
- [x] 현재데이타 갱신(원본 TFRMDATAUPDATE btnApply 0x10bc3d4 → 엔진 0x10e12c8 — spec_update) — 원천: Main Data(원본 설치 main.estx = BufDat\Main\Main_1, 서버 `/api/main-data`, `EST_MAIN_DATA` 로 경로 변경) 또는 고른 .estx. 항목: 노임·자재(유류 제외)·유류(이름 8종)·표준(코드 일치)·기계(손료계수·환율 표 교체, 기계 호표 이름·규격 일치, 산출근거 줄 교체 뒤 자재·노임 다시 잇기)·환율(36·89·69·77·70·76). 맞춤 규칙·복사 칸·단가미갱신 표시·안내 문구 원본대로. 행 추가·삭제 없음. 갱신 뒤 재계산. 하지 않음: 최신(웹접속 — 원본 회사 서버·유지보수 인증), 자재 단가열 출처 재배치(OtherTables — 같은 열 순서 가정), 원본 결함(환율 끔 때 파운드를 76 값으로 덮음).
- 검증완료: `test_est_data_update.py`(노임 갱신·당초 칸 유지·못 찾음 표시와 문구, 자재 규격·단위 정리 맞춤·쪽수, 자재만 갱신 때 유류·비율 제외, 표준 코드 맞춤·이름 교체, 손료 계수 표 교체·당초 칸 유지, 환율 끔 유지) 통과. 샘플을 원본 main.estx 로 갱신하면 보통인부 172,068→172,698(2026 하반기)·못 찾음 0(145ms). ORCA 브라우저에서 같은 결과, 샘플 복구.
- [x] 견적서 엑셀변환(원본 TFRMEXCELCONVERTESTIPRN btnConvert) — 견적서 창 「엑셀변환」: 머리(제목·공사명·귀하·견적일자·합계금액 한글·공급자)와 레벨에 맞춘 내역 줄(품명·규격·단위·수량·재료/노무/경비/합계 단가·금액·비고)을 .xlsx 로 내려받기. 원본의 인쇄비율·셀 수식은 하지 않음(값만).
+43 -10
View File
@@ -211,7 +211,12 @@ function editDocument(text, oldSheet, sheet) {
);
});
if (rowIndex !== oldSheet.rows.length) throw Error("수량산출서 행 수 불일치");
if (!same(quantity.fromWpt(sheet.Code, sheet.Name, result, sheet.items, sheet.options), sheet))
if (
!same(
withoutTables(quantity.fromWpt(sheet.Code, sheet.Name, result, sheet.items, sheet.options)),
sheet,
)
)
throw Error("수량산출서 문서 재읽기 검산 실패");
return result;
}
@@ -414,10 +419,34 @@ function reorderRows(text, sheet, rows) {
return { text: document, rows: order.map((source) => sheet.rows[source]) };
}
// 워드형 산출서의 표 줄(row.table)은 문단 줄과 따로 다룸: 표는 고칠 수 없으므로(tableEdited 거부) 같은 자리에 그대로 있어야 하고,
// 문단 줄 처리 동안은 빼 두었다가 저장 결과에 다시 넣는다.
const tableRows = (sheet) => sheet.rows.flatMap((row, k) => (row.table ? [[k, row]] : []));
const withoutTables = (sheet) =>
sheet.rows.some((row) => row.table)
? { ...sheet, rows: sheet.rows.filter((row) => !row.table) }
: sheet;
function withTables(sheet, tables) {
if (!tables.length) return sheet;
const rows = sheet.rows.slice();
for (const [k, row] of tables) rows.splice(k, 0, row);
return { ...sheet, rows };
}
function updateQuantities(project, base, cp949, memory) {
const sheets = project.quantitySheets || [],
originals = base.quantitySheets || [];
const fullSheets = project.quantitySheets || [],
fullOriginals = base.quantitySheets || [];
fullSheets.forEach((sheet, i) => {
if (fullOriginals[i] && !same(tableRows(sheet), tableRows(fullOriginals[i])))
throw Error(
"표가 있는 산출서의 줄 추가·삭제·이동은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.",
);
});
const sheets = fullSheets.map(withoutTables),
originals = fullOriginals.map(withoutTables);
if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다.");
if (sheets.some((sheet) => sheet.tableEdited))
throw Error("워드형 산출서의 표 편집은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
if (sheets.some((sheet) => sheet.excelEdited))
throw Error("엑셀형 수량산출서 편집은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
if (sheets.some((sheet) => sheet.markerEdited))
@@ -570,12 +599,14 @@ function updateQuantities(project, base, cp949, memory) {
document = insertRows(document, sheets[i], inserts);
if (
!same(
quantity.fromWpt(
sheets[i].Code,
sheets[i].Name,
document,
sheets[i].items,
sheets[i].options,
withoutTables(
quantity.fromWpt(
sheets[i].Code,
sheets[i].Name,
document,
sheets[i].items,
sheets[i].options,
),
),
sheets[i],
)
@@ -667,7 +698,9 @@ function updateQuantities(project, base, cp949, memory) {
}
return {
...project,
quantitySheets: savedSheets,
quantitySheets: savedSheets.map((sheet, i) =>
sheet === sheets[i] ? fullSheets[i] : withTables(sheet, tableRows(fullSheets[i])),
),
tables: { ...project.tables, MulMainDat: rows, MulTitleDat: titles },
};
}
+30 -2
View File
@@ -13,7 +13,17 @@
<script defer src="native-excel-sheet.cjs"></script>
<script defer src="native-excel-edit.cjs"></script>
<script defer src="native-quantity-total.js"></script>
<script defer src="native-quantity-wpt.cjs"></script>
<script defer src="native-quantity.cjs"></script>
<script defer src="native-quantity-table.js"></script>
<script defer src="native-quantity-list.js"></script>
<script defer src="native-price-tools.js"></script>
<script defer src="native-misc-tools.js"></script>
<script defer src="native-excel-to-est.js"></script>
<script defer src="native-other-load.js"></script>
<script defer src="native-applied-cost.js"></script>
<script defer src="native-progress-view.js"></script>
<script defer src="native-actions-more.js"></script>
<script defer src="native-wonga.js"></script>
<script defer src="native-jungsang.js"></script>
<script defer src="native-sources.js"></script>
@@ -22,6 +32,7 @@
<script defer src="native-escalation.js"></script>
<script defer src="native-progress.js"></script>
<script defer src="native-accom-view.js"></script>
<script defer src="native-annual-load.js"></script>
<script defer src="native-business.js"></script>
<script defer src="native-review.js"></script>
<script defer src="native-resources.js"></script>
@@ -105,7 +116,9 @@
<hr />
<button data-action="csv">현재 자료 CSV 내보내기</button
><button data-action="print">인쇄</button>
<button data-action="print-batch">묶음 인쇄(설계서 일체·간지)</button>
<button data-action="print-batch">묶음 인쇄(설계서 일체·간지)</button
><button data-action="pfot-load">인쇄양식 불러오기(.pfot)</button
><button data-action="pfot-save">인쇄양식 저장하기(.pfot)</button>
<button data-action="export-xlsx">전체 자료 Excel 내보내기</button>
<button data-action="import-xlsx">Excel 표 불러오기</button>
<button data-action="excel-map-open">Excel 내역 열 맞춰 가져오기</button>
@@ -153,7 +166,12 @@
><button data-action="cost-part">비목별 단가 불러오기(일위대가)</button
><button data-action="global-var">전역변수 지정,해제</button
><button data-action="global-var-list">전역변수 목록</button
><button data-action="data-update">현재데이타 갱신</button>
><button data-action="data-update">현재데이타 갱신</button
><button data-action="jodal-fee">조달수수료 입력(Ctrl+J)</button
><button data-action="cost-to-wonga">원가계산서에 내역 금액 넣기</button
><button data-action="wonga-biul-mode">원가 구성비·요율 표시 전환</button
><button data-action="jun-load">전회기성 불러오기</button
><button data-action="excel-to-est">엑셀 데이타 =&gt; EST 데이타(7개 표)</button>
<hr />
<button data-action="find">찾기·다음찾기(Ctrl+F)</button>
<button data-action="multi-search">통합 검색(저장된 다른 작업)</button>
@@ -301,8 +319,18 @@
><button id="unit-calc-btn" data-action="unit-calc" hidden>단위환산</button
><button id="sources-btn" data-action="sources" hidden title="발췌근거 삭제,삽입 및 변경">발췌근거</button
><button id="uses-search-btn" data-action="uses-data-search">사용위치</button
><button data-action="uses-list">사용위치 검색</button
><button data-action="other-load">다른 공사 불러오기</button
><button data-action="book">품셈</button
><button data-action="book-paste">품셈자료 붙여넣기</button
><button data-action="mul-paste">수량붙여넣기(연결)</button
><button data-action="price-sum">SUM</button
><button data-action="price-change">바꾸기</button
><button data-action="price-ratio">할증</button
><button data-action="price-nachal">낙찰</button
><button data-action="price-restore">복원</button
><button data-action="price-samecheck">중복 항목 정리</button
><button data-action="price-gungu-excel">단가정의 엑셀변환</button
><button data-action="add-row">행 추가</button><button data-action="copy-row">복사</button
><button data-action="paste-row">붙여넣기</button
><button data-action="delete-row">삭제</button
+18
View File
@@ -0,0 +1,18 @@
// 동작 사슬 이어 붙이기(native-business-actions.js 가 700줄 한계라 새 모듈 처리기는 여기서 차례로 부른다).
const EstActionsMore = (() => {
const modules = () =>
[
typeof EstQuantityList !== "undefined" && EstQuantityList,
typeof EstPriceTools !== "undefined" && EstPriceTools,
typeof EstMiscTools !== "undefined" && EstMiscTools,
typeof EstExcelToEst !== "undefined" && EstExcelToEst,
typeof EstOtherLoad !== "undefined" && EstOtherLoad,
typeof EstAppliedCost !== "undefined" && EstAppliedCost,
typeof EstProgressView !== "undefined" && EstProgressView,
].filter(Boolean);
function handle(name, state, node) {
for (const module of modules()) if (module.handle(name, state, node)) return true;
return false;
}
return { handle };
})();
+4 -14
View File
@@ -333,7 +333,8 @@ const EstActions = (() => {
"REA 샘플 대조",
`<p><b>${result.checked}개 중 ${result.passed}개 일치 · 불일치 ${result.failed}개</b></p><p>보관된 EST 샘플의 저장값과 계산 결과를 비교했습니다. 원본 실행 비교는 포함하지 않습니다.</p>${result.errors.length ? `<p>${esc(result.errors.join(" / "))}</p>` : ""}<table><thead><tr><th>영역</th><th>검사 수</th><th>일치</th></tr></thead><tbody>${[...new Set(result.results.map((r) => r.group))].map((group) => `<tr><td>${esc(group)}</td><td>${result.results.filter((r) => r.group === group).length}</td><td>${result.results.filter((r) => r.group === group && r.pass).length}</td></tr>`).join("")}</tbody></table>`,
);
} else if (name === "settings") EstSettings.show();
} else if (name === "settings")
EstApp.state.view === "progress" ? EstProgressView.dialog(EstApp.state) : EstSettings.show();
else if (name === "raw-line") {
const row = activeRows()[Number(node.dataset.row)];
showDialog(
@@ -427,19 +428,8 @@ const EstActions = (() => {
const totals = EstQuantity.totals(project, state.quantitySheet);
if (confirm("연결된 내역 수량을 산출 결과로 바꾸시겠습니까?"))
EstApp.change(() => EstQuantity.applyTotals(project, totals));
} else if (name === "add-round") {
const next = {
id: EstApp.uid(),
name: EstBusiness.newProgressName(project.progress),
inputs: {},
approved: false,
locked: false,
};
EstApp.change(() => {
project.progress.push(next);
state.roundId = next.id;
}, false);
} else if (name === "lock-round") {
} else if (name === "add-round") EstProgressView.addRound(state);
else if (name === "lock-round") {
if (
confirm("이 회차를 확정하고 저장하시겠습니까? 전회 누계로 보존되고 직접 편집이 잠깁니다.")
) {
+74
View File
@@ -0,0 +1,74 @@
// 원본 연차(다차수) 「내역서데이타 불러오기」·「상단데이타 불러오기」·「하단데이타 불러오기」(TFRMESTMULTIEDIT popLoadEst 0xedb784 →
// 0xf11a60 — spec_part §7): 고른 .estx 의 내역서 줄을 차수 열에 짝지어 옮기고(행 수는 현재 내역 그대로), 차수 수식칸에 파일 이름을 적는다.
// 상단·하단은 설계변경 내역만 받으며 상단 = 당초값(「_」 짝 칸)·하단 = 변경값(원본 옵션 +0x37 = 0 기준). 금액은 파일 값 그대로(다시 셈 없음).
const EstAnnualLoad = (() => {
const num = (v) => Number(v) || 0;
const ORIGINAL = [
"Name",
"Size",
"Unite",
"GN",
"TU",
"TC",
"NU",
"NC",
"GU",
"GC",
"JU",
"JC",
"SU",
"SC",
"Bigo",
];
// 차수 계산에서 불러온 줄을 쓰면 true
function row(year, index, target) {
const loaded = year.rows?.[index];
if (!loaded) return false;
Object.assign(target, structuredClone(loaded));
target.CalcIndex = num(loaded.CalcIndex) >= 2 ? 3 : 1;
return true;
}
function rows(source, tag) {
const list = structuredClone(source.tables?.EstMainDat || []);
if (!list.length) throw Error("내역서 데이타를 찾을수 없습니다.");
if (tag) {
const changed = list.some((r) =>
Object.entries(r).some(([k, v]) => k.endsWith("_") && v != null && v !== ""),
);
if (!changed) throw Error("설계변경을 한 내역서가 아닙니다.");
if (tag === 1)
for (const r of list) for (const k of ORIGINAL) if (`${k}_` in r) r[k] = r[`${k}_`];
}
return list;
}
async function load(state, index, tag) {
const file = await new Promise((resolve) => {
const picker = document.createElement("input");
picker.type = "file";
picker.accept = ".estx";
picker.addEventListener("change", () => resolve(picker.files[0]));
picker.click();
});
if (!file) return;
const source = await EstApp.api(`/api/import?name=${encodeURIComponent(file.name)}`, {
method: "POST",
body: await file.arrayBuffer(),
});
const list = rows(source, tag);
EstApp.change((project) => {
const year = project.annual[index];
year.rows = list.slice(0, project.tables.EstMainDat.length);
year.formula = file.name.replace(/\.estx$/i, "");
}, false);
state.notice = `「${file.name}」 내역서 ${list.length}줄을 차수에 불러왔습니다.`;
}
function handle(name, state, node) {
if (name !== "annual-load") return false;
load(state, Number(node.dataset.row), Number(node.dataset.tag)).catch((error) => {
state.notice = error.message;
EstApp.render();
});
return true;
}
return { row, rows, handle };
})();
+236
View File
@@ -0,0 +1,236 @@
// 원본 「물가변동 적용대가 분류 내역서」(TfrmAppliedCost — spec_pb1 §5·§6) 화면: 품목조정 표(native-extra.js 에서 옮김),
// 「적용대가 보기 변경」(cmStyleToggle 0x10b5918 → e43b48: 단가 밴드를 숨기고 계약·구분별 수량 7종만 — 값 변화 없음),
// 「수량불러오기」(cmGNLoad 0x10b57a4 → 10b6630·10b5934·10b617c: 고른 내역서의 행 i 수량 GN 을 이 내역 행 i 의 구분 칸에,
// 그 파일 원가 행 i 의 Cost 를 원가 행 i 의 Es*Cost 에 — 위치 짝, 원본 행이 없으면 건너뜀),
// 「적용대가집계표」(mnuESWonga → TfrmESPWonga: 원가 행마다 계약금액·적용제외금액·적용대가금액 — 계산은 품목조정과 같은 값).
const EstAppliedCost = (() => {
const num = (v) => Number(v) || 0;
const QTY = ["EscConstruction", "EscPlanned", "EscActual", "EscProgress", "EscPS", "EscBefore"];
const QTY_LABELS = [
"시공중 수량",
"예정공정 수량",
"실행공정 수량",
"기성 수량",
"PS 수량",
"선시공 수량",
];
const KINDS = [
"계약분 수량",
"시공중인공사계약분 수량",
"예정공정율분 수량",
"실행공정율분 수량",
"기성율분 수량",
"PS분 수량",
"신시공부분 수량",
];
const COST = [
"EsContractCost",
"EsSiGongCost",
"EsSchduleCost",
"EsWorkCost",
"EsACCost",
"EsPSCost",
"EsBeforeCost",
];
function view(state, project, { box, button, table }) {
const { esc, number } = EstApp;
const output = EstBusiness.escalation(project);
state.business = output;
const comp = state.escalationComponent || "NU";
const choices = EstMath.units
.map(
(key, i) =>
`<option value="${key}" ${key === comp ? "selected" : ""}>${["노무", "재료", "경비", "실적"][i]}</option>`,
)
.join("");
const page = EstBlockActions.escalationPage(state, project.tables.EstMainDat);
const qty = state.escQtyView;
const rows = page.rows.map(({ row, index }) => {
const result = output.rows[index];
if (qty)
return [
esc(row.Code),
esc(row.Name),
number(num(result.EscApplied) + num(result.EscExcluded)),
...QTY.map((k) => EstViews.cell("EstMainDat", index, k, row[k] ?? 0)),
number(result.EscExcluded),
number(result.EscApplied),
];
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),
`<input type="checkbox" data-esc-auto="${index}" ${row.EscAuto !== false ? "checked" : ""} aria-label="적용제외 자동계산">`,
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),
];
});
return box(
"물가변동 품목조정",
`<select id="escalation-component">${choices}</select>` +
button("capture-bid", "현재 단가를 입찰단가로 기록") +
button("load-bid", "입찰단가 불러오기") +
button("load-contract", "계약 내역 불러오기") +
button("load-progress-exclusion", "기성 누계수량 불러오기") +
button(
"applied-style-toggle",
qty ? "적용대가 보기 변경(풀기)" : "적용대가 보기 변경",
qty ? 'aria-pressed="true"' : "",
) +
button("applied-gn-load", "수량불러오기") +
button("es-wonga", "적용대가집계표") +
button("reset-escalation", "물가변동 입력 초기화") +
button("capture-baseline", "계약 내역 확정") +
button("escalation-prev", "이전", state.escalationPage === 0 ? "disabled" : "") +
`<span>${state.escalationPage + 1} / ${page.pages}쪽 · ${page.total}행</span>` +
button(
"escalation-next",
"다음",
state.escalationPage + 1 >= page.pages ? "disabled" : "",
) +
`<label>조사 도급액(0=단가로 계산) <input type="number" step="any" data-option="EscDesignTotal" value="${project.options.EscDesignTotal ?? 0}"></label>`,
table(
qty
? ["코드", "품명", "계약 수량", ...QTY_LABELS, "적용제외", "적용수량"]
: [
"코드",
"품명",
"입찰 당시",
"계약 단가",
"변동 당시",
"등락률 %",
"등락폭",
"조정단가",
"예정공정수량",
"실행공정수량",
"기성수량",
"적용제외",
"적용수량",
"제외 자동",
"수동 제외수량",
"시공중 수량(참고)",
"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(" / "))}`,
);
}
// 수량불러오기 한 벌 적용: kind 0 = 계약 수량(계약 내역 확정본, 없으면 당초 칸 GN_), 1~6 = 구분 칸
function load(project, kind, source) {
const rows = project.tables.EstMainDat,
from = source.tables?.EstMainDat || [],
base = project.baseline?.tables?.EstMainDat;
let count = 0;
rows.forEach((row, i) => {
const src = from[i];
if (!src) return;
if (kind === 0) {
if (base?.[i]) base[i].GN = num(src.GN);
else row.GN_ = num(src.GN);
} else row[QTY[kind - 1]] = num(src.GN);
count++;
});
const wonga = project.tables.Wonga || [],
fromWonga = source.tables?.Wonga || [];
wonga.forEach((row, i) => fromWonga[i] && (row[COST[kind]] = num(fromWonga[i].Cost)));
return count;
}
function summary(project) {
const out = EstBusiness.escalation(project),
wonga = project.tables.Wonga || [];
return wonga.map((row, i) => {
const contract =
row.EsContractCost != null ? num(row.EsContractCost) : num(out.contractWonga?.[i]?.Cost),
except = num(out.excludedWonga?.[i]?.Cost);
return { row, contract, except, apply: contract - except };
});
}
function summaryDialog(state) {
const { esc, number } = EstApp,
list = summary(state.project);
if (!list.length) throw Error("원가계산서가 없습니다.");
EstApp.showDialog(
"적용대가집계표",
`<div style="max-height:480px;overflow:auto"><table class="sheet"><thead><tr><th>비목</th><th>약어</th><th>계약금액</th><th>적용제외금액</th><th>적용대가금액</th><th>산출근거</th></tr></thead><tbody>${list
.map(
({ row, contract, except, apply }) =>
`<tr><td style="text-align:left">${esc([row.Bimog1, row.Bimog2, row.Name].filter(Boolean).join(" "))}</td><td>${esc(row.SName ?? "")}</td><td>${number(contract)}</td><td>${number(except)}</td><td>${number(apply)}</td><td style="text-align:left">${esc(row.VSusic ?? "")}</td></tr>`,
)
.join("")}</tbody></table></div>`,
);
}
async function gnLoad(state) {
const kind = await new Promise((resolve) =>
EstActions.form(
"수량불러오기",
[
{
name: "kind",
label: "불러올 수량분",
value: "",
choices: [["", "선택"], ...KINDS.map((k, i) => [i, k])],
},
],
(values) => resolve(values.kind),
),
);
if (kind === "" || kind == null) throw Error("불러올 수량분을 선택하세요..");
const file = await new Promise((resolve) => {
const picker = document.createElement("input");
picker.type = "file";
picker.accept = ".estx,.json";
picker.addEventListener("change", () => resolve(picker.files[0]));
picker.click();
});
if (!file) return;
const name = KINDS[Number(kind)];
let source;
try {
source = /\.estx$/i.test(file.name)
? await EstApp.api(`/api/import?name=${encodeURIComponent(file.name)}`, {
method: "POST",
body: await file.arrayBuffer(),
})
: EstModel.legacy(JSON.parse(await file.text()));
} catch {
throw Error(`${name} 를 불러오지 못했습니다...`);
}
let count = 0;
EstApp.change((project) => (count = load(project, Number(kind), source)));
state.notice = `${name} ${count}행을 불러왔습니다.`;
EstApp.render();
}
function handle(name, state) {
if (name === "applied-style-toggle") {
state.escQtyView = !state.escQtyView;
EstApp.render();
} else if (name === "applied-gn-load")
gnLoad(state).catch((error) => {
state.notice = error.message;
EstApp.render();
});
else if (name === "es-wonga") summaryDialog(state);
else return false;
return true;
}
return { view, load, summary, handle, KINDS };
})();
+4 -1
View File
@@ -638,7 +638,10 @@ const EstBusinessActions = (() => {
(typeof EstGlobalVars !== "undefined" && EstGlobalVars.handle(name, state, node)) ||
(typeof EstNewEstimate !== "undefined" && EstNewEstimate.handle(name, state)) ||
(typeof EstBook !== "undefined" && EstBook.handle(name, state, node)) ||
(typeof EstDataUpdate !== "undefined" && EstDataUpdate.handle(name, state))
(typeof EstDataUpdate !== "undefined" && EstDataUpdate.handle(name, state)) ||
(typeof EstAnnualLoad !== "undefined" && EstAnnualLoad.handle(name, state, node)) ||
(typeof EstQuantityTable !== "undefined" && EstQuantityTable.handle(name, state, node)) ||
(typeof EstActionsMore !== "undefined" && EstActionsMore.handle(name, state, node))
) {
EstApp.render();
} else if (name === "insert-annual") {
+1
View File
@@ -632,6 +632,7 @@ const EstBusiness = (() => {
row.LinkIndex = 99;
}
if (num(row.Level) > 0 || [54, 55, 58, 59, 62, 67].includes(num(row.LinkIndex))) continue;
if (typeof EstAnnualLoad !== "undefined" && EstAnnualLoad.row(year, index, row)) continue;
try {
// f0f9b8/f1098c resolve the same column formula against each current field.
const value = (field) =>
+1 -1
View File
@@ -247,5 +247,5 @@ const EstDataUpdate = (() => {
else return false;
return true;
}
return { apply, isFuel, handle };
return { apply, isFuel, table, handle };
})();
+27
View File
@@ -63,6 +63,7 @@ const EstDocForms = (() => {
["docf-default", "기본양식으로 저장"],
["docf-init", kind === "gabgi" ? "갑지초기화" : "표지초기화"],
["docf-print", "인쇄"],
["docf-detail", "상세편집"],
]
.map(([a, l]) => `<button data-action="${a}" data-kind="${kind}">${l}</button>`)
.join("")}</div>`;
@@ -108,6 +109,29 @@ const EstDocForms = (() => {
if (project.docs) delete project.docs[kind];
}, false);
EstDocs.handle("doc-edit", state, { dataset: { kind } });
} else if (name === "docf-detail") {
// 상세편집: 인쇄 모양을 자유 편집(원본은 미리보기 ↔ 편집 전환). 저장하면 인쇄가 이 모양을 씀, 「상세편집 해제」로 칸 값 모양으로
const doc = state.project.docs?.[kind] || {};
const html = doc.detailHtml || EstDocs.html(kind === "gabgi" ? 8 : 9, state.project);
EstApp.showDialog(
`${kind === "gabgi" ? "갑지" : "표지"} 상세편집`,
`<div id="doc-detail" contenteditable="true" style="min-height:360px;max-height:480px;overflow:auto;border:1px solid #bbb;padding:8px;background:#fff">${EstDocEditor.clean(html)}</div><button data-action="docf-detail-save" data-kind="${kind}">저장</button> <button data-action="docf-detail-clear" data-kind="${kind}">상세편집 해제</button>`,
);
} else if (name === "docf-detail-save" || name === "docf-detail-clear") {
const html =
name === "docf-detail-save"
? EstDocEditor.clean(document.getElementById("doc-detail").innerHTML)
: null;
EstApp.byId("dialog").close();
EstApp.change((project) => {
project.docs ||= {};
project.docs[kind] = { ...(project.docs[kind] || {}) };
if (html) project.docs[kind].detailHtml = html;
else delete project.docs[kind].detailHtml;
}, false);
state.notice = html
? "상세편집 모양을 저장했습니다. 인쇄에 그대로 쓰입니다."
: "상세편집을 풀었습니다.";
} else if (name === "docf-print") {
EstApp.byId("dialog").close();
await EstReport.open();
@@ -117,6 +141,9 @@ const EstDocForms = (() => {
}
}
const names = new Set([
"docf-detail",
"docf-detail-save",
"docf-detail-clear",
"docf-list",
"docf-load",
"docf-delete",
+4
View File
@@ -98,6 +98,10 @@ const EstDocs = (() => {
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
const align = (pos) => ["left", "center", "right"][Number(pos)] || "center";
// 상세편집(popupDetailEdit)으로 고친 모양이 있으면 그대로
const detail = { 8: "gabgi", 9: "cover" }[form];
if (detail && project.docs?.[detail]?.detailHtml && typeof EstDocEditor !== "undefined")
return `<div class="document-${detail === "gabgi" ? "gabgi" : "cover"}">${EstDocEditor.clean(project.docs[detail].detailHtml)}</div>`;
if (form === 9) {
const c = doc(project, "cover");
return `<div class="document-cover" style="text-align:center;padding:40px 0">${c.YearShow ? `<p style="font-size:15pt">${esc(year(c.Date))}</p>` : ""}<h1 style="font-size:32pt;margin:80px 0 20px">${esc(c.Name)}</h1>${c.Name2 ? `<h2>${esc(c.Name2)}</h2>` : ""}<p style="font-size:15pt;margin-top:160px;text-align:${align(c.CompanyPos)}">${esc(c.CompanyName)}</p></div>`;
+370
View File
@@ -0,0 +1,370 @@
// 원본 「엑셀데이타를 EST로 변환」(mnuExcelToEst → TfrmExcelToEst btnOkClick 0x109c3bc — spec_pc §1) 7개 탭:
// 노임 → 자재 → 견적 → 표준시장 → 단가산출 → 일위대가 → 내역서 차례로 한 Excel 파일의 시트들을 새 공사로 옮긴다(금액은 Excel 값 그대로,
// 다시 셈 끔). 셀 규칙: 문자 Trim·줄바꿈 제거·품명/규격 앞 「.」 제거·단위 M2→㎡ M3→㎥·산출근거 「*」→「×」, 수치는 쉼표 빼고,
// 노무비율 ≤ 1 이면 ×100, 열 「B:D」 는 이어 붙임. 단가산출·일위대가는 머리 줄 꼴(#.산근 · #| · 제n호표 · 산근 n 호표 : … (… 당) ·
// (산근 · No n 호표 · No.n)을 찾아 호표를 만든다(원본의 글자 굵기 판정은 웹 Excel 읽기가 굵기를 몰라 하지 않음).
const EstExcelToEst = (() => {
const TABS = [
["LaborcDat", "노임단가", ["Code", "Name", "Size", "Unite", "Cost1", "Bigo"]],
[
"StuffcDat",
"자재단가",
[
"Code",
"Name",
"Size",
"Unite",
"Cost1",
"Page1",
"Cost2",
"Page2",
"Cost3",
"Page3",
"Cost4",
"Page4",
"Cost5",
"Page5",
"Cost6",
"Page6",
"TC",
"Bigo",
],
],
[
"EstiMateDat",
"견적단가",
["Code", "Name", "Size", "GN", "Unite", "TC", "NC", "GC", "JC", "Bigo"],
],
[
"SilJukDat",
"표준시장단가",
["Code", "Name", "Size", "Unite", "TC", "NC", "GC", "JC", "NCRatio", "EXPLEAN", "Bigo"],
],
["CostMainDat", "단가산출", ["Code", "LineData", "TC", "NC", "GC", "JC"]],
[
"HeadMainDat",
"일위대가",
[
"Code",
"Name",
"Size",
"GN",
"Unite",
"TU",
"TC",
"NU",
"NC",
"GU",
"GC",
"JU",
"JC",
"Bigo",
],
],
[
"EstMainDat",
"내역서",
[
"Code",
"Pr",
"Name",
"Size",
"GN",
"Unite",
"TU",
"TC",
"NU",
"NC",
"GU",
"GC",
"JU",
"JC",
"Bigo",
],
],
];
const TEXT = new Set([
"Code",
"Pr",
"Name",
"Size",
"Unite",
"Bigo",
"LineData",
"EXPLEAN",
"Page1",
"Page2",
"Page3",
"Page4",
"Page5",
"Page6",
]);
const num = (v) => Number(v) || 0;
const letter = (n) => {
let s = "";
for (n += 1; n > 0; n = Math.floor((n - 1) / 26))
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
return s;
};
const col = (s) =>
[...String(s).toUpperCase()].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1;
const unit = (s) =>
String(s)
.replace(/^[Mm]2$/, "㎡")
.replace(/^[Mm]3$/, "㎥");
function cell(table, r, spec) {
const [a, b] = String(spec).toUpperCase().split(":");
if (!b) return table[r]?.[col(a)];
let out = "";
for (let c = col(a); c <= col(b); c++) {
const v = table[r]?.[c];
if (v != null && String(v) !== "") out += String(v);
else if (out.length <= 1) out += " ";
}
return out;
}
function value(field, raw) {
if (TEXT.has(field)) {
let t = String(raw ?? "").replace(/\r?\n/g, "");
t = field === "LineData" ? t.replaceAll("*", "×") : t.trim();
if (field === "Unite") t = unit(t);
if (field === "Name" || field === "Size") t = t.replace(/^\.+/, "");
return t;
}
let v = typeof raw === "number" ? raw : Number(String(raw ?? "").replaceAll(",", "")) || 0;
if (field === "NCRatio" && v <= 1) v *= 100;
return v;
}
function readRows(table, tab, fields, columns) {
const rows = [];
for (let r = tab.start - 1; r <= tab.end - 1; r++) {
const row = { Index: 0 };
fields.forEach((f, i) => {
if (columns[i]) row[f] = value(f, cell(table, r, columns[i]));
});
rows.push(row);
}
return rows;
}
// 머리 줄 꼴 → { Name, Size, Unite, skipNext }
function costHead(text, next) {
const t = String(text ?? "").trim(),
split = (s) => {
const parts = s.trim().split(/\s+/);
return {
Name: parts[0] || "",
Size: parts.slice(1, -1).join(" "),
Unite: parts.length > 1 ? parts.at(-1) : "",
};
};
if (/^#\./.test(t) && t.includes("산근")) {
if (t.includes("|")) {
const [, n = "", s = "", u = ""] = t.split("|");
return { Name: n.trim(), Size: s.trim(), Unite: u.trim() };
}
return split(t.replace(/^.*?산근\s*\d*/, "").replace(/\//g, " "));
}
if (/^#/.test(t) && t.includes("|")) {
const [, n = "", s = "", u = ""] = t.split("|");
return { Name: n.trim(), Size: s.trim(), Unite: u.trim() };
}
if (/산근.*호표/.test(t) && t.includes("당")) {
const body = t.split(/[::]/).slice(1).join(":"),
u = /\(\s*[\d.]*\s*([^()]*?)\s*당\s*\)/.exec(body)?.[1] || "",
rest = body
.replace(/\([^()]*당\s*\)/, "")
.trim()
.split(/\s+/);
return { Name: rest[0] || "", Size: rest.slice(1).join(" "), Unite: u };
}
if (/제\s*\d+\s*호표/.test(t) && next != null)
return { ...split(String(next)), skipNext: true };
if (t.replace(/\s/g, "").includes("(산근")) return split(t.replace(/\(산근[^)]*\)/, ""));
return null;
}
function headHead(name) {
const t = String(name ?? "").trim();
if (/No/.test(t) && t.includes("호표"))
return { Name: t.split("호표").slice(1).join("호표").trim(), Size: "", Unite: "" };
if (/^No\.\s*[1-9]/.test(t))
return { Name: t.replace(/^No\.\s*\d+\s*/, ""), Size: "", Unite: "" };
if (/\(호표/.test(t)) {
const parts = t
.replace(/\(호표[^)]*\)/, "")
.trim()
.split(/\s+/);
return { Name: parts[0] || "", Size: parts.slice(1).join(" "), Unite: "" };
}
return null;
}
function groups(rows, titles, kind, headOf) {
let pk = 0;
const out = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (kind === 103 && String(row.Name ?? "").replace(/\s/g, "") === "[합계]") continue;
const h = headOf(row, rows[i + 1]);
if (!h) {
out.push(row);
continue;
}
const index = titles.length + 1,
code = `N${String(++pk).padStart(10, "0")}`;
titles.push({
Index: index,
Code: code,
Name: h.Name,
Size: h.Size,
Unite: unit(h.Unite),
TC: num(row.TC),
NC: num(row.NC),
GC: num(row.GC),
JC: num(row.JC),
});
Object.assign(row, { Code: code, TitleCode: code, Index: index });
if (kind === 104)
row.LineData = `\x0e${h.Name}\x0e${h.Size ? `\x13${h.Size}\x13` : ""}${h.Unite ? `\x15${unit(h.Unite)}\x15` : ""}`;
out.push(row);
if (h.skipNext) i++;
}
return out;
}
function convert(sheets, tabs, template) {
const project = EstNative.empty();
project.name = template.name;
project.options = { ...project.options, AutoCalc: false, SourceRefresh: false };
project.tables.Wonga = structuredClone(template.wonga || []);
const failed = [];
for (const [table, label, fields] of TABS) {
const tab = tabs[table];
if (!tab?.sheet || !(tab.start > 0) || !(tab.end > 0)) continue;
const data = sheets[tab.sheet];
if (!data) {
failed.push(`${tab.sheet || label} 시트가 없습니다.`);
continue;
}
let rows = readRows(data, tab, fields, tab.columns);
if (table === "LaborcDat") rows.forEach((r) => (r.TC = r.Cost1));
if (table === "SilJukDat" || table === "EstiMateDat")
rows.forEach((r) =>
Object.assign(r, {
OTC: r.TC,
ONC: r.NC,
OGC: r.GC,
OJC: r.JC,
...(table === "SilJukDat" && { Cost1: r.TC }),
}),
);
if (table === "CostMainDat") {
project.tables.CostTitleDat = [];
rows = groups(rows, project.tables.CostTitleDat, 104, (r, n) =>
costHead(r.LineData, n?.LineData),
);
} else if (table === "HeadMainDat") {
project.tables.HeadTitleDat = [];
rows = groups(rows, project.tables.HeadTitleDat, 103, (r) => headHead(r.Name));
}
project.tables[table] = rows;
}
while (project.tables.EstMainDat.length < 36) project.tables.EstMainDat.push({ Index: 0 });
let pk = 0;
for (const t of Object.keys(project.tables))
for (const r of project.tables[t])
if (r && typeof r === "object" && r.PkID == null) r.PkID = ++pk;
for (const t of ["LaborcDat", "StuffcDat", "SilJukDat", "EstiMateDat"])
for (const r of project.tables[t] || [])
if (!String(r.Code ?? "").trim()) r.Code = `N${String(r.PkID).padStart(10, "0")}`;
return { project, failed };
}
// 화면
function form(state) {
const x = (state.e2e ||= {
tab: "LaborcDat",
tabs: Object.fromEntries(
TABS.map(([t, label, fields]) => [
t,
{ sheet: label, start: 0, end: 0, columns: fields.map((_, i) => letter(i)) },
]),
),
});
const { esc } = EstApp,
[, , fields] = TABS.find(([t]) => t === x.tab),
tab = x.tabs[x.tab];
EstApp.showDialog(
"엑셀데이타를 EST로 변환",
`<div class="business-tools">${TABS.map(([t, l]) => `<button data-action="e2e-tab" data-tab="${t}"${t === x.tab ? ' style="font-weight:bold"' : ""}>${l}</button>`).join("")}</div><p><button data-action="e2e-open">엑셀파일열기</button> ${esc(x.file || "")} ${x.sheets ? `(시트 ${Object.keys(x.sheets).length}개)` : ""}</p><div class="form-fields"><label>시트명<input name="e2e-sheet" value="${esc(tab.sheet)}"></label><label>시작행<input name="e2e-start" type="number" value="${tab.start}"></label><label>끝행<input name="e2e-end" type="number" value="${tab.end}"></label></div><p>해당 칼럼명칭 입력(엑셀 열 글자, 비우면 건너뜀, 「B:D」 는 이어 붙임)</p><div class="form-fields">${fields.map((f, i) => `<label>${f}<input name="e2e-col" data-i="${i}" value="${esc(tab.columns[i] || "")}" size="4"></label>`).join("")}</div><button data-action="e2e-run"${x.sheets ? "" : " disabled"}>변 환</button>`,
);
}
function read(state) {
const x = state.e2e,
tab = x.tabs[x.tab],
q = (n) => document.querySelector(`#dialog-content [name="${n}"]`);
if (!q("e2e-sheet")) return;
tab.sheet = q("e2e-sheet").value.trim();
tab.start = Number(q("e2e-start").value) || 0;
tab.end = Number(q("e2e-end").value) || 0;
document
.querySelectorAll('#dialog-content [name="e2e-col"]')
.forEach((n) => (tab.columns[Number(n.dataset.i)] = n.value.trim().toUpperCase()));
}
async function run(name, state, node) {
if (name === "excel-to-est") return form(state);
read(state);
const x = state.e2e;
if (name === "e2e-tab") x.tab = node.dataset.tab;
else if (name === "e2e-open") {
const file = await new Promise((resolve) => {
const picker = document.createElement("input");
picker.type = "file";
picker.accept = ".xlsx,.xls";
picker.addEventListener("change", () => resolve(picker.files[0]));
picker.click();
});
if (!file) return;
const body = await file.arrayBuffer();
x.sheets = /\.xls$/i.test(file.name)
? EstJdlCompare.xlsTables(await EstApp.api("/api/read-xls", { method: "POST", body }))
: EstExcelMap.sheetTables(await EstApp.api("/api/import-xlsx", { method: "POST", body }));
x.file = file.name;
} else if (name === "e2e-run") {
const tabs = Object.values(x.tabs);
if (
tabs.every((t) => !t.start) ||
tabs.every((t) => !t.end) ||
tabs.some((t) => t.end < t.start)
)
throw Error("변환할 엑셀자료의 범위설정이 잘못되었습니다.");
if (!confirm("현재 작업을 엑셀 자료로 만든 새 공사로 바꾸시겠습니까?")) return;
const out = convert(x.sheets, x.tabs, {
name: x.file.replace(/\.xlsx?$/i, ""),
wonga: (state.project.tables.Wonga || []).map(({ Cost, ...rest }) => rest),
});
if (
out.failed.length &&
!confirm(`${out.failed.join("\n")}\n변환을 못한 시트가 있습니다.\n\n종료하시겠습니까?`)
)
return;
EstApp.byId("dialog").close();
EstApp.setProject(out.project);
state.notice =
"엑셀 역변환 작업이 완료되었습니다. 금액은 엑셀 값 그대로이며 자동계산은 꺼 두었습니다.";
EstApp.render();
return;
}
form(state);
}
const names = new Set(["excel-to-est", "e2e-tab", "e2e-open", "e2e-run"]);
function handle(name, state, node) {
if (!names.has(name)) return false;
run(name, state, node).catch((error) => {
state.notice = error.message;
EstApp.render();
});
return true;
}
return { convert, costHead, headHead, handle };
})();
+33 -1
View File
@@ -46,6 +46,11 @@ const EstExcelView = (() => {
btn("excel-style", "오른쪽", 'data-name="align" data-value="right"'),
btn("excel-merge", "병합"),
btn("excel-split", "분리"),
btn("excel-style", "테두리", 'data-name="border" data-value="1"'),
btn("excel-style", "테두리 없음", 'data-name="border" data-value=""'),
btn("excel-style", "음영", 'data-name="shade" data-value="#eeeeee"'),
btn("excel-style", "음영 없음", 'data-name="shade" data-value=""'),
btn("excel-mul-paste", "수량붙여넣기(연결)"),
`<select data-excel-digits aria-label="셀 서식 소수자리"><option value="">셀 서식(소수자리)</option>${[0, 1, 2, 3, 4].map((d) => `<option value="${d}">소수 ${d}자리</option>`).join("")}<option value="-">서식 해제</option></select>`,
btn("excel-style", "왼쪽대각선", 'data-name="diag" data-value="left"'),
btn("excel-style", "오른쪽대각선", 'data-name="diag" data-value="right"'),
@@ -119,6 +124,8 @@ const EstExcelView = (() => {
s.u ? "text-decoration:underline" : "",
s.align ? `text-align:${s.align}` : "",
s.diag ? `background-image:${DIAG[s.diag]}` : "",
s.border ? "border:2px solid #222" : "",
s.shade && !link && !chosen ? `background-color:${s.shade}` : "",
link ? "background-color:#e6f4ea" : chosen ? "background-color:#e8f0fb" : "",
here && sel.row === r && sel.col === c ? "outline:2px solid #316ea5" : "",
]
@@ -236,6 +243,7 @@ const EstExcelView = (() => {
"excel-style",
"excel-merge",
"excel-split",
"excel-mul-paste",
"excel-print",
]);
function handle(name, state, node) {
@@ -286,7 +294,31 @@ const EstExcelView = (() => {
edit(() => EstExcelEdit.style(page, box, node.dataset.name, node.dataset.value));
else if (name === "excel-merge") edit(() => EstExcelEdit.merge(page, box));
else if (name === "excel-split") edit(() => EstExcelEdit.split(page, box));
else if (name === "excel-print") {
else if (name === "excel-mul-paste") {
// 수량붙여넣기(연결)(actMulLangPaste 0xd31000): 수량 목록에서 담은 항목을 고른 셀부터 가로/세로로, 명칭·규격·단위·수량 중 하나
const clip = state.mulClip;
if (!clip?.items.length) throw Error("수량 목록에서 선택수량복사를 먼저 하세요.");
if (clip.code === sheet.Code)
throw Error("자기 자신의 데이타는 불러올수없습니다.셀수식을 사용하세요.");
const across = clip.items.length > 1 ? confirm("가로로 넣을까요? (취소 = 세로)") : true;
const kind =
clip.items.length > 1
? Number(prompt("입력항목(0 명칭 · 1 규격 · 2 단위 · 3 수량)", "3"))
: 3;
if (![0, 1, 2, 3].includes(kind)) throw Error("입력항목은 0~3 입니다.");
edit(() => {
clip.items.forEach((item, k) => {
const r = box.r1 + (across ? 0 : k),
c = box.c1 + (across ? k : 0),
v = [item.Name, item.Size, item.Unite, Number(item.GN) || 0][kind];
page.cells[`${r},${c}`] = {
value: v ?? "",
...(kind === 3 && { qlink: { sheet: clip.code, code: item.Code } }),
};
});
return EstExcelSheet.recalc(page.cells);
});
} else if (name === "excel-print") {
// 미리보기·인쇄(tbsPreView·tbsPrint): 고른 시트 격자만 새 창에
const html = render({ ...sheet, excel: [page], excelLinks: {} }, { excelCell: null })
.replace(/<div class="business-tools">[\s\S]*?<\/div>/, "")
+11 -177
View File
@@ -126,184 +126,9 @@ const EstExtra = (() => {
: "현재 작업을 저장한 뒤 당초 내역을 확정하세요.",
);
} else if (view === "progress") {
state.roundId ||= project.progress[0]?.id;
const round = project.progress.find((r) => r.id === state.roundId);
let body = '<p class="empty">회차를 추가하세요.</p>',
tools =
button("add-round", "기성 회차 추가") +
'<label>원본 기성 표 확인 <input type="file" accept=".estx" data-accom-inspect></label>' +
(project.progress.length ? button("clear-progress", "기성 전체 초기화") : "");
if (round) {
tools +=
`<select id="round-choice">${project.progress.map((r) => `<option value="${esc(r.id)}" ${r.id === round.id ? "selected" : ""}>${esc(r.name)}${r.locked ? " (확정)" : ""}</option>`).join("")}</select>` +
button(
round.locked ? "unlock-round" : "lock-round",
round.locked ? "확정 해제(이후 포함)" : "회차 확정·저장",
) +
button("fill-progress", "전량·비율 일괄 입력") +
button("rename-round", "회차 이름") +
button("delete-round", "회차 삭제") +
button("reset-round-inputs", "회차 입력 초기화") +
`<label><input id="progress-components" type="checkbox" ${round.separateComponents !== false ? "checked" : ""} ${round.locked ? "disabled" : ""}> 비목별 계산</label><label>금액 소수자리 <input id="progress-digits" type="number" min="0" max="4" value="${round.amountDigits ?? 0}" ${round.locked ? "disabled" : ""}></label>` +
`<label><input id="use-approved" type="checkbox" ${round.approved ? "checked" : ""} ${round.locked ? "disabled" : ""}> 누계에 사정 사용</label>`;
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" : "") +
`<span>${state.progressPage + 1} / ${page.pages}쪽 · ${page.total}행</span>` +
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 data-progress="${prefix}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix] ?? row[prefix + "GN"] ?? 0}" ${round.locked || !editable || input[prefix + "Mode"] === "components" ? "disabled" : ""}>` +
(input[prefix + "Mode"] === "components"
? EstMath.components
.map(
(field, i) =>
`<label>${["노무", "재료", "경비", "실적"][i]}<input data-progress="${prefix + field}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix + field] ?? 0}" ${round.locked ? "disabled" : ""}></label>`,
)
.join("")
: "");
const method = (prefix) =>
!editable
? "자동계산"
: `<select data-progress="${prefix}Mode" data-key="${esc(key)}" ${round.locked ? "disabled" : ""}>${[
["quantity", "수량"],
["amount", "금액"],
["ratio", "비율 %"],
["components", "비목 금액"],
]
.map(
([v, label]) =>
`<option value="${v}" ${v === (input[`${prefix}Mode`] || "quantity") ? "selected" : ""}>${label}</option>`,
)
.join("")}</select>`;
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),
];
}),
) +
`<p class="business-note">${esc((output.errors || []).join(" / "))} 금회 도급액 ${number(output.total)} · 기성비율 ${number(output.ratio)}%</p>`;
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) =>
`<option value="${key}" ${key === comp ? "selected" : ""}>${["노무", "재료", "경비", "실적"][i]}</option>`,
)
.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),
`<input type="checkbox" data-esc-auto="${index}" ${row.EscAuto !== false ? "checked" : ""} aria-label="적용제외 자동계산">`,
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(
"물가변동 품목조정",
`<select id="escalation-component">${choices}</select>` +
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" : "") +
`<span>${state.escalationPage + 1} / ${page.pages}쪽 · ${page.total}행</span>` +
button(
"escalation-next",
"다음",
state.escalationPage + 1 >= page.pages ? "disabled" : "",
) +
`<label>조사 도급액(0=단가로 계산) <input type="number" step="any" data-option="EscDesignTotal" value="${project.options.EscDesignTotal ?? 0}"></label>`,
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;
@@ -329,6 +154,15 @@ const EstExtra = (() => {
`data-row="${index}" data-direction="1" ${index === project.annual.length - 1 ? "disabled" : ""}`,
) +
button("insert-annual", "위에 삽입", `data-row="${index}"`) +
["내역서", "상단", "하단"]
.map((l, t) =>
button(
"annual-load",
`${l}데이타 불러오기`,
`data-row="${index}" data-tag="${t}"`,
),
)
.join("") +
button("delete-extra", "삭제", `data-section="annual" data-row="${index}"`),
]),
),
+319
View File
@@ -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(
"데이터 사용위치 검색",
`<div class="business-tools"><input data-uses-word value="${esc(word || "")}" placeholder="품명·규격"><button data-action="uses-list-search">검 색</button><button data-action="uses-list-all">전체보기</button></div><div style="max-height:420px;overflow:auto"><table class="sheet"><tbody>${list.map(([r, i]) => `<tr><td>${esc(r.Code ?? "")}</td><td style="text-align:left">${esc(r.Name ?? "")}</td><td>${esc(r.Size ?? "")}</td><td><button data-action="uses-list-pick" data-i="${i}">사용위치</button></td></tr>`).join("")}</tbody></table></div>`,
);
}
function handle(name, state, node) {
if (name === "uses-list" || name === "uses-list-all") {
usesList(state, "");
return true;
}
if (name === "uses-list-search") {
usesList(state, document.querySelector("[data-uses-word]")?.value || "");
return true;
}
if (name === "uses-list-pick") {
EstExtra.showUsesSearchDialog(state.project.tables[state.view][Number(node.dataset.i)]);
return true;
}
if (name === "pfot-load" || name === "pfot-save") {
pfot(name, state).catch((error) => {
state.notice = error.message;
EstApp.render();
});
return true;
}
if (name === "jodal-fee") jodal(state);
else if (name === "cost-to-wonga") costToWonga(state);
else if (name === "jun-load") junLoad(state);
else if (name === "wonga-biul-mode")
EstApp.change(
(project) => (project.options.WongaBiulMode = !project.options.WongaBiulMode),
false,
);
else return false;
return true;
}
if (typeof document !== "undefined" && document.addEventListener)
document.addEventListener("keydown", (event) => {
if (
event.ctrlKey &&
!event.altKey &&
event.key.toLowerCase() === "j" &&
EstApp.state.view === "EstMainDat"
) {
event.preventDefault();
try {
jodal(EstApp.state);
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
}
}
});
return { biul, handle };
})();
+113
View File
@@ -0,0 +1,113 @@
// 원본 「다른 공사 불러오기」 창(barbtnSelfLoad → f135b8 → TfrmOtherLoad — spec_pb2 §5.2·§11): 저장된 다른 작업의 같은 표를 띄워
// 찾기(Ctrl+F: 품명·규격 빈칸·|·괄호 무시, 「A&B」 는 둘 다)·고른 줄을 하위 자료와 함께 이 공사로 가져오기(EstTransfer, 같은 코드는 이 공사 것).
// 내역서는 고른 줄 아래에 줄을 넣고, 자재·노임·표준·견적·일위·단산·기계는 자료(호표)를 합친다.
const EstOtherLoad = (() => {
const KIND = {
StuffcDat: 5,
LaborcDat: 6,
SilJukDat: 21,
EstiMateDat: 101,
HeadMainDat: 103,
CostMainDat: 104,
MachMainDat: 105,
};
const norm = (s) => String(s ?? "").replace(/[\s|()]/g, "");
const match = (row, word) => {
const k = norm(row.Name || EstText.clean(row.LineData || "")) + norm(row.Size);
return norm(word)
.split("&")
.filter(Boolean)
.every((p) => k.includes(p));
};
async function open(state) {
if (!state.project.tables[state.view]) throw Error("자료 화면에서 쓰세요.");
const o = (state.otherLoad ||= { id: "", word: "", picked: new Set() });
o.projects = await EstApp.api("/api/projects");
draw(state);
}
function rowsOf(o, view) {
const rows = o.source?.tables?.[view] || [];
return rows
.map((r, i) => [r, i])
.filter(([r]) => {
if (["HeadMainDat", "CostMainDat", "MachMainDat"].includes(view) && !(Number(r.Index) > 0))
return false;
return (r.Name || r.Code || r.LineData) && (!o.word || match(r, o.word));
});
}
function draw(state) {
const o = state.otherLoad,
{ esc, number } = EstApp,
list = rowsOf(o, state.view).slice(0, 400);
EstApp.showDialog(
"다른 공사 불러오기",
`<div class="business-tools"><select data-ol-project><option value="">공사 고르기</option>${o.projects.map((p) => `<option value="${esc(p.id)}"${p.id === o.id ? " selected" : ""}>${esc(p.name || p.id)}</option>`).join("")}</select><input data-ol-word value="${esc(o.word)}" placeholder="찾기(Ctrl+F) A&B"><button data-action="ol-search">찾기</button><button data-action="ol-take">고른 줄 가져오기</button></div><div style="max-height:440px;overflow:auto"><table class="sheet"><tbody>${list.map(([r, i]) => `<tr><td><input type="checkbox" data-ol-pick="${i}"${o.picked.has(i) ? " checked" : ""}></td><td>${esc(r.Code ?? "")}</td><td style="text-align:left">${esc(r.Name || EstText.clean(r.LineData || ""))}</td><td>${esc(r.Size ?? "")}</td><td>${esc(r.Unite ?? "")}</td><td>${number(r.TC)}</td></tr>`).join("")}</tbody></table></div>`,
);
}
function take(state) {
const o = state.otherLoad,
view = state.view,
picked = [...o.picked].sort((a, b) => a - b).map((i) => o.source.tables[view][i]);
if (!picked.length) throw Error("가져올 줄을 고르세요.");
let added = 0,
kept = 0;
EstApp.change((project) => {
if (view === "EstMainDat") {
const deps = EstTransfer.collect(o.source, picked);
const r = EstTransfer.merge(project, deps);
added += r.added.length;
kept += r.kept.length;
const at = Math.max(-1, ...state.selection) + 1 || project.tables.EstMainDat.length;
added += EstTransfer.paste(project, picked, at);
} else {
const deps = EstTransfer.collect(
o.source,
picked.map((r) => ({ LinkIndex: KIND[view], Code: r.Code })),
);
const r = EstTransfer.merge(project, deps);
added += r.added.length;
kept += r.kept.length;
}
});
EstApp.byId("dialog").close();
state.notice = `${added}건을 가져왔습니다${kept ? `, 같은 코드 ${kept}건은 이 공사 자료를 그대로 씀` : ""}.`;
o.picked = new Set();
}
function install() {
document.addEventListener("change", async (event) => {
const t = event.target,
o = EstApp.state.otherLoad;
if (!o) return;
try {
if (t.dataset.olProject !== undefined) {
o.id = t.value;
o.source = t.value ? await EstApp.api(`/api/projects/${t.value}`) : null;
o.picked = new Set();
draw(EstApp.state);
} else if (t.dataset.olPick !== undefined) {
const i = Number(t.dataset.olPick);
if (t.checked) o.picked.add(i);
else o.picked.delete(i);
}
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
}
});
}
if (typeof document !== "undefined" && document.addEventListener) install();
function handle(name, state) {
if (name === "other-load")
open(state).catch((e) => ((state.notice = e.message), EstApp.render()));
else if (name === "ol-search") {
state.otherLoad.word = document.querySelector("[data-ol-word]")?.value || "";
draw(state);
} else if (name === "ol-take") take(state);
else return false;
return true;
}
return { match, handle };
})();
+211
View File
@@ -0,0 +1,211 @@
// 단가 목록 도구(spec_pb2): 중복 항목 정리(TfrmStuffc popupSamecheck 0xf61d94), 단가정의 엑셀변환(TfrmSiljuk popupGunguToExcel),
// 할증·낙찰·복원(TfrmSetDiget 모드 51·52, btnRestor 0x110919c), 바꾸기(barbtnChange → TfrmChangeValue: Main Data 로 이 표만 갱신),
// SUM(barbtnSum → 항목선택계산 LinkIndex 62 줄, TfrmSetGabBiIndex SUM 모드).
const EstPriceTools = (() => {
const num = (v) => Number(v) || 0;
const key = (s) => String(s ?? "").replace(/[ |]/g, "");
const TABLE_KIND = { StuffcDat: 5, LaborcDat: 6, SilJukDat: 21, EstiMateDat: 101 };
// 같은 품명·규격·단위(빈칸·| 무시) 묶음에서 TC 가장 큰 행 하나만(같으면 뒤 행) 남김. 묻지 않음, 다시 셈 없음.
function samecheck(rows) {
const keep = new Map();
rows.forEach((row, i) => {
const k = `${key(row.Name)}\u0000${key(row.Size)}\u0000${key(row.Unite)}`,
prev = keep.get(k);
if (prev == null || !(num(rows[prev].TC) > num(row.TC))) keep.set(k, i);
});
const kept = new Set(keep.values());
const removed = rows.length - kept.size;
return { rows: rows.filter((_, i) => kept.has(i)), removed };
}
// 표준시장단가 EXPLEAN 이 있는 행만 「코드 / 번호. 품명 (규격) [단위]」 + 설명 줄
function gungu(rows) {
const out = [];
for (const r of rows) {
const text = String(r.EXPLEAN ?? "").trim();
if (!text) continue;
out.push([
String(r.Code ?? ""),
`${r.Index ?? ""}. ${r.Name ?? ""} (${r.Size ?? ""}) [${r.Unite ?? ""}]`,
]);
for (const line of text.split(/\r?\n/)) out.push(["", line]);
out.push(["", ""]);
}
return out;
}
// 할증(Ratio)·낙찰(NachalRatio) 율 쓰기. v = 100 이면 원래값(그 율 칸 100, 자리 해제), 복원 = 0.
function setRate(rows, indices, field, v) {
const list = indices.length >= 2 ? indices : rows.map((_, i) => i);
for (const i of list) {
const row = rows[i];
if (!num(row.TC) && !String(row.Name ?? "").trim()) continue;
if (!num(row.OTC)) row.OTC = row.TC;
for (const f of [field].flat()) row[f] = v;
if (v === 100) Object.assign(row, { Diget: 0, DigetIndex: 0 });
}
return list.length;
}
// 항목선택계산(62) 줄
function sumRow(view, row, o) {
const banned =
view === "EstMainDat"
? [
5, 6, 21, 51, 52, 53, 54, 56, 57, 58, 59, 61, 101, 103, 104, 105, 211, 212, 213, 214,
215, 217, 218,
]
: [5, 6, 21, 51, 52, 53, 61, 101, 103, 104, 105, 211, 212, 213, 214, 215, 217, 218];
if (
(view === "EstMainDat" && num(row.Level) > 0) ||
(view === "HeadMainDat" && num(row.Index) > 0) ||
(banned.includes(num(row.LinkIndex)) &&
(String(row.Name ?? "").trim() || String(row.Size ?? "").trim()))
)
throw Error("현재 라인에는 합계(SUM)를 할수가 없습니다.");
if ((o.base === 0) !== (o.target === 0)) throw Error("비목별항목은 비목별적용만 가능합니다.");
if (!o.picked.length) throw Error("더할항목의 색인들을 선택하세요");
if (o.picked.length > 5) throw Error("더할항목의 색인들은 6개이상 선택할수없습니다");
Object.assign(row, {
Name: o.name,
Size: o.size,
LinkIndex: 62,
Code: `A${o.picked.map((n) => String(n).padStart(2, "0")).join("")}`,
GN: o.rate,
Biul: o.rate / 100,
Unite: "%",
OCheck: o.base,
SCheck: o.target,
});
}
function nextSKind(rows, at, view) {
let start = at,
end = at;
const head = (r) => (view === "EstMainDat" ? num(r.Level) > 0 : num(r.Index) > 0);
while (start > 0 && !head(rows[start - 1])) start--;
while (end + 1 < rows.length && !head(rows[end + 1])) end++;
return Math.max(0, ...rows.slice(start, end + 1).map((r) => num(r.SKind))) + 1;
}
async function run(name, state) {
const view = state.view,
rows = state.project.tables[view],
picked = [...state.selection].filter((i) => rows?.[i]).sort((a, b) => a - b);
if (name === "price-samecheck") {
if (view !== "StuffcDat") throw Error("자재단가 화면에서 쓰세요.");
let removed = 0;
EstApp.change((project) => {
const out = samecheck(project.tables.StuffcDat);
project.tables.StuffcDat = out.rows;
removed = out.removed;
});
state.notice = `중복 항목 ${removed}개를 지웠습니다.`;
} else if (name === "price-gungu-excel") {
const list = gungu(state.project.tables.SilJukDat || []);
if (!list.length) throw Error("단가정의(설명)가 있는 표준시장단가가 없습니다.");
const response = await fetch("/api/export-report-xlsx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "단가정의",
columns: ["코드", "단가정의"],
rows: list,
errors: [],
}),
});
if (!response.ok) throw Error((await response.json()).error);
EstApp.download(
`${state.project.name || "공사"}_단가정의.xlsx`,
await response.blob(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
} else if (name === "price-ratio" || name === "price-nachal" || name === "price-restore") {
if (!TABLE_KIND[view]) throw Error("자재·노임·표준시장·견적 단가 화면에서 쓰세요.");
// 복원(원본은 할증·낙찰 창 안 단추): 두 율 모두 0
const field =
name === "price-nachal"
? "NachalRatio"
: name === "price-ratio"
? "Ratio"
: ["Ratio", "NachalRatio"];
let v = 0;
if (name !== "price-restore") {
const input = prompt(
name === "price-nachal" ? "낙찰율을 입력하세요(%)" : "할증율을 입력하세요(%)",
"",
);
if (input == null) return;
v = Number(input);
if (!Number.isFinite(v)) throw Error("율은 수치로 입력하세요.");
}
if (
!confirm(
v === 100 || v === 0 ? "원래값으로 복원하시겠습니까?" : "설정값을 적용하시겠습니까?",
)
)
return;
let n = 0;
EstApp.change((project) => (n = setRate(project.tables[view], picked, field, v)));
state.notice = `${n}개 행에 적용했습니다.`;
} else if (name === "price-change") {
const kind = TABLE_KIND[view];
if (!kind) throw Error("자재·노임·표준시장·견적 단가 화면에서 쓰세요.");
if (
!confirm(
"선택한 Main데이타의 단가로 바꾸시겠습니까?\n바꾸기후 전체재계산을 해야 금액이 바뀝니다.",
)
)
return;
const source = await EstApp.api("/api/main-data");
let missing = 0;
EstApp.change((project) => {
missing = EstDataUpdate.table(project.tables[view], source.tables[view] || [], kind);
});
state.notice = missing
? `${missing} 개의 찾지못한 데이타가 있습니다. 확인란에 체크된 데이타를 검토하세요.`
: "Main 데이타의 단가로 바꿨습니다.";
} else if (name === "price-sum") {
if (!["EstMainDat", "HeadMainDat"].includes(view) || !picked.length)
throw Error("내역서·일위대가에서 SUM 줄로 바꿀 행을 고르세요.");
const row = rows[picked[0]];
const letters = prompt("더할 색인(A~Z, 최대 5개, 예 AC)", "")?.toUpperCase();
if (letters == null) return;
const rate = Number(prompt("적용율(%)", "100"));
const o = {
name: prompt("명칭", row.Name || "잡재료비") ?? "",
size: row.Size ?? "",
rate: Number.isFinite(rate) ? rate : 0,
base: num(row.OCheck),
target: num(row.SCheck),
picked: [...new Set(letters.replace(/[^A-Z]/g, ""))].map((c) => c.charCodeAt(0) - 64),
};
sumRow(view, structuredClone(row), o);
EstApp.change((project) => {
const list = project.tables[view];
sumRow(view, list[picked[0]], o);
list[picked[0]].SKind = nextSKind(list, picked[0], view);
});
} else return false;
EstApp.render();
return true;
}
const names = new Set([
"price-samecheck",
"price-gungu-excel",
"price-ratio",
"price-nachal",
"price-restore",
"price-change",
"price-sum",
]);
function handle(name, state) {
if (!names.has(name)) return false;
run(name, state).catch((error) => {
state.notice = error.message;
EstApp.render();
});
return true;
}
return { samecheck, gungu, setRate, sumRow, handle };
})();
+333
View File
@@ -0,0 +1,333 @@
// 기성고 화면(native-extra.js 에서 옮김)과 원본 기성 「환경설정」(TfrmAccomOption — spec_pb1 §4: 주 메뉴 환경 설정 0x10ccddc 가
// 기성 문서일 때 · 기성 원가계산서 팝업 popupOptision 0xd479f4 Ctrl+U). 칸을 누르면 바로 옵션에 쓰고 「취소」면 연 때 값으로 되돌림(d45e1c).
// 공통적용(코드·수량 0·금액 0·비목순서·품명정렬·품명 두줄·Enter 이동·바탕색)은 다른 표와 같은 계산 환경 Display,
// 기성고(레벨·비고란 발췌근거·집계공종 진하게·사정금액·노재경 구분·실적 비목)는 AccomOption. 원본도 처리기가 없는
// 자동저장 분·가로세로 계산 확인·가로 계산방법·재료비 처리·이윤 사사오입은 값만 담는다(화면·계산 불변).
const EstProgressView = (() => {
const num = (v) => Number(v) || 0;
const COMMON = {
code: true,
gnZero: true,
costZero: true,
order: 0,
align: 0,
twoLine: false,
enter: 0,
color: "",
autoSave: false,
saveTime: 10,
};
const ACCOM = {
level: false,
gungu: false,
sumBold: false,
calcCheck: false,
horCalc: 0,
sajung: true,
gubun: false,
siljuk: false,
gcTo: 0,
eyunRound: false,
};
const get = (options) => ({
...COMMON,
...(options?.Display || {}),
...ACCOM,
...(options?.AccomOption || {}),
});
const ORDERS = ["TNGJ", "TGNJ", "NGJT", "GNJT"];
const PART = { T: "합계", N: "노무", G: "재료", J: "경비", S: "실적" };
// 비목 금액 열(금회): 비목순서대로, 실적은 실적 비목 표시 때만 경비 뒤에
function parts(o) {
const seq = [...(ORDERS[o.order] || ORDERS[0])];
if (o.siljuk) seq.splice(seq.indexOf("J") + 1, 0, "S");
return seq;
}
function view(state, project, { box, button, table }) {
const { esc } = EstApp,
o = get(project.options);
const shown = (v, zero) => (!zero && !num(v) ? "" : EstApp.number(v));
const qty = (v) => shown(v, o.gnZero),
cost = (v) => shown(v, o.costZero);
state.roundId ||= project.progress[0]?.id;
const round = project.progress.find((r) => r.id === state.roundId);
let body = '<p class="empty">회차를 추가하세요.</p>',
tools =
button("add-round", "기성 회차 추가") +
'<label>원본 기성 표 확인 <input type="file" accept=".estx" data-accom-inspect></label>' +
(project.progress.length ? button("clear-progress", "기성 전체 초기화") : "") +
button("accom-option", "환경 설정");
if (round) {
tools +=
`<select id="round-choice">${project.progress.map((r) => `<option value="${esc(r.id)}" ${r.id === round.id ? "selected" : ""}>${esc(r.name)}${r.locked ? " (확정)" : ""}</option>`).join("")}</select>` +
button(
round.locked ? "unlock-round" : "lock-round",
round.locked ? "확정 해제(이후 포함)" : "회차 확정·저장",
) +
button("fill-progress", "전량·비율 일괄 입력") +
button("rename-round", "회차 이름") +
button("delete-round", "회차 삭제") +
button("reset-round-inputs", "회차 입력 초기화") +
`<label><input id="progress-components" type="checkbox" ${round.separateComponents !== false ? "checked" : ""} ${round.locked ? "disabled" : ""}> 비목별 계산</label><label>금액 소수자리 <input id="progress-digits" type="number" min="0" max="4" value="${round.amountDigits ?? 0}" ${round.locked ? "disabled" : ""}></label>` +
`<label><input id="use-approved" type="checkbox" ${round.approved ? "checked" : ""} ${round.locked ? "disabled" : ""}> 누계에 사정 사용</label>`;
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" : "") +
`<span>${state.progressPage + 1} / ${page.pages}쪽 · ${page.total}행</span>` +
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 data-progress="${prefix}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix] ?? row[prefix + "GN"] ?? 0}" ${round.locked || !editable || input[prefix + "Mode"] === "components" ? "disabled" : ""}>` +
(input[prefix + "Mode"] === "components"
? EstMath.components
.map(
(field, i) =>
`<label>${["노무", "재료", "경비", "실적"][i]}<input data-progress="${prefix + field}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix + field] ?? 0}" ${round.locked ? "disabled" : ""}></label>`,
)
.join("")
: "");
const method = (prefix) =>
!editable
? "자동계산"
: `<select data-progress="${prefix}Mode" data-key="${esc(key)}" ${round.locked ? "disabled" : ""}>${[
["quantity", "수량"],
["amount", "금액"],
["ratio", "비율 %"],
["components", "비목 금액"],
]
.map(
([v, label]) =>
`<option value="${v}" ${v === (input[`${prefix}Mode`] || "quantity") ? "selected" : ""}>${label}</option>`,
)
.join("")}</select>`;
const bold = o.sumBold && num(row.Level) > 0 ? ";font-weight:bold" : "";
return [
...(o.code ? [esc(row.Code)] : []),
...(o.level ? [num(row.Level) || ""] : []),
`<span style="display:block;${nameStyle}${bold}">${esc(row.Name)}</span>`,
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 ?? "")] : []),
];
}),
) +
`<p class="business-note">${esc((output.errors || []).join(" / "))} 금회 도급액 ${EstApp.number(output.total)} · 기성비율 ${EstApp.number(output.ratio)}%</p>`;
body += EstProgressWonga.render(state, round, output);
}
const html = box(
"기성고",
tools,
body,
"전회는 직전 확정 회차 누계를 읽습니다. 금회·사정은 수량·금액·비율 중 하나를 입력해 계산합니다.",
);
return o.color ? `<div style="background:${esc(o.color)}">${html}</div>` : html;
}
// 환경설정 창: 칸을 바꾸면 바로 옵션에 쓰고(화면에 즉시), 「취소」 는 연 때 값으로 되돌림
let opened = null;
function dialog(state) {
const { esc } = EstApp,
o = get(state.project.options);
opened = {
Display: structuredClone(state.project.options.Display),
AccomOption: structuredClone(state.project.options.AccomOption),
};
const check = (name, label, extra = "") =>
`<label><input type="checkbox" data-ao="${name}" ${o[name] ? "checked" : ""} ${extra}> ${esc(label)}</label>`;
const pick = (name, label, choices, extra = "") =>
`<label>${esc(label)} <select data-ao="${name}" ${extra}>${choices.map((c, i) => `<option value="${i}" ${num(o[name]) === i ? "selected" : ""}>${esc(c)}</option>`).join("")}</select></label>`;
EstApp.showDialog(
"환경설정",
`<h4>공통적용</h4><div class="form-fields">${check("code", "코드표시")}${check("gnZero", '수량 "0" 표시')}${check("costZero", '금액 "0" 표시')}${check("autoSave", "데이타 자동저장")}<label>데이타 입력이 없으면 <input type="number" min="5" max="30" step="5" data-ao="saveTime" value="${num(o.saveTime)}" ${o.autoSave ? "" : "disabled"}> 분후 자동저장</label>${pick("order", "비목순서", ["합/노/재/경", "합/재/노/경", "노/재/경/합", "재/노/경/합"], o.gubun ? "" : "disabled")}${pick("align", "품명정렬", ["좌정렬", "우정렬", "중앙정렬"])}${pick("twoLine", "품명표기", ["한줄로 표시", "두줄로 표시"])}${pick("enter", "Enter 뒤 셀 이동", ["이동안함", "좌", "우", "위", "아래"])}<label>바탕색 <input type="color" data-ao="color" value="${esc(o.color || "#ffffff")}"></label></div>` +
`<h4>기성고</h4><div class="form-fields">${check("level", "레벨 표시")}${check("gungu", "비고란에 발췌근거 표시")}${check("sumBold", "집계공종 진하게 표시")}${check("calcCheck", "가로,세로 계산 확인")}${pick("horCalc", "데이타(가로) 계산방법", ["절하", "절상"])}${check("sajung", "사정금액 표시")}${check("gubun", "노,재,경 구분 표시")}${check("siljuk", "실적 비목 표시", o.gubun ? "" : "disabled")}</div>` +
`<h4>원가계산</h4><div class="form-fields">${pick("gcTo", "순직접공사비의 재료비 처리", ["간접재료비", "직접재료비"])}${check("eyunRound", "이윤 비율 사사오입")}</div>` +
`<button data-action="accom-option-ok">적 용</button> <button data-action="accom-option-cancel">취 소</button>`,
);
}
function set(name, value) {
const options = EstApp.state.project.options,
bucket = name in COMMON ? "Display" : "AccomOption";
const flag = typeof COMMON[name] === "boolean" || typeof ACCOM[name] === "boolean";
const typed = flag
? typeof value === "string"
? value === "1"
: Boolean(value)
: name === "color"
? value
: num(value);
options[bucket] = { ...(options[bucket] || {}), [name]: typed };
const box = document.getElementById("dialog-content");
if (name === "autoSave") box.querySelector('[data-ao="saveTime"]').disabled = !typed;
if (name === "gubun") {
box.querySelector('[data-ao="order"]').disabled = !typed;
box.querySelector('[data-ao="siljuk"]').disabled = !typed;
}
EstApp.render();
}
function close(apply) {
const options = EstApp.state.project.options;
if (opened) {
const now = { Display: options.Display, AccomOption: options.AccomOption };
for (const k of ["Display", "AccomOption"])
if (opened[k] === undefined) delete options[k];
else options[k] = opened[k];
// 적용: 연 때 값으로 돌린 뒤 바뀐 값을 한 번에 기록(되돌리기 한 단계)
if (apply) EstApp.change((project) => Object.assign(project.options, now), false);
}
opened = null;
EstApp.byId("dialog").close();
EstApp.render();
}
// Enter 뒤 셀 이동(ComboBoxEnter 62c+0x60): 표 안 입력칸에서 Enter 면 좌·우·위·아래 칸으로
function move(input, direction) {
const cell = input.closest("td"),
tr = cell?.parentElement;
if (!tr) return null;
const col = [...tr.children].indexOf(cell);
let target = null;
if (direction === 1 || direction === 2) {
const cells = [...tr.children];
for (
let c = col + (direction === 1 ? -1 : 1);
c >= 0 && c < cells.length;
c += direction === 1 ? -1 : 1
)
if ((target = cells[c].querySelector("input:not([disabled]),select:not([disabled])")))
break;
} else if (direction === 3 || direction === 4) {
for (
let r = direction === 3 ? tr.previousElementSibling : tr.nextElementSibling;
r;
r = direction === 3 ? r.previousElementSibling : r.nextElementSibling
)
if (
(target = r.children[col]?.querySelector("input:not([disabled]),select:not([disabled])"))
)
break;
}
return target;
}
function install() {
document.addEventListener("change", (event) => {
const t = event.target;
if (t.dataset?.ao === undefined) return;
set(t.dataset.ao, t.type === "checkbox" ? t.checked : t.value);
});
document.addEventListener("keydown", (event) => {
const state = EstApp.state;
if (state?.view !== "progress") return;
if (event.ctrlKey && event.key.toLowerCase() === "u") {
event.preventDefault();
dialog(state);
return;
}
const t = event.target;
if (event.key !== "Enter" || !t.matches?.("table.sheet input, table.sheet select")) return;
const direction = num(get(state.project.options).enter);
if (!direction) return;
const target = move(t, direction);
if (target) {
event.preventDefault();
t.dispatchEvent(new Event("change", { bubbles: true }));
setTimeout(() =>
(
document.querySelector(
`[data-key="${CSS.escape(target.dataset.key || "")}"][data-progress="${target.dataset.progress}"]`,
) || target
).focus(),
);
}
});
}
if (typeof document !== "undefined" && document.addEventListener) install();
// 기성 회차 추가: 설계변경 내역서면 「도급내역을 선택하세요.」(당초/변경 — 닫기 없음, 고른 값으로 회차를 만듦)
function addRound(state) {
const project = state.project,
push = (loadMethod) => {
const next = {
id: EstApp.uid(),
name: EstBusiness.newProgressName(project.progress),
inputs: {},
approved: false,
locked: false,
...(loadMethod && { loadMethod }),
};
EstApp.change(() => {
project.progress.push(next);
state.roundId = next.id;
}, false);
};
if (project.options.Mode !== "설계변경") return push();
EstActions.form(
"도급내역을 선택하세요.",
[
{
name: "method",
label: "설계변경한 내역서입니다. 도급내역으로 불러올 데이타를 선택하세요.",
value: "origin",
choices: [
["origin", "당초 내역서"],
["change", "변경 내역서"],
],
},
],
(values) => push(values.method),
);
}
function handle(name, state) {
if (name === "accom-option") dialog(state);
else if (name === "accom-option-ok") close(true);
else if (name === "accom-option-cancel") close(false);
else return false;
return true;
}
return { view, get, parts, move, handle, dialog, addRound };
})();
+1
View File
@@ -119,6 +119,7 @@ const EstProgressWonga = (() => {
b("pw-total", "공사비역산 자리수 맞추기"),
b("pw-init", "원가계산서 초기화"),
b("pw-undo", "되살리기"),
'<button data-action="accom-option" title="Ctrl+U">환경 설정</button>',
`<label><input type="checkbox" data-pw-sname ${lockFormula ? "checked" : ""}> 산출근거 약어로 표시</label>`,
round.wonga ? "<span>회차 서식 사용 중</span>" : "",
round.wongaTotalDigits ? `<span>역산 ${round.wongaTotalDigits}자리</span>` : "",
+28 -6
View File
@@ -26,11 +26,28 @@ const EstProgress = (() => {
}
return output;
}
// 도급내역 선택(TfrmAccomLoadMethodEst 0xc8cd50 · 덮어쓰기 c8e5e8): 설계변경 내역서로 회차를 처음 만들 때 당초를 고르면
// 행마다 당초 칸(_)으로 — Index, 품명·규격·단위(당초가 비면 지금 값), 수량·단가·금액. 원본은 GC 를 빠뜨리지만 웹은 함께 옮김.
function originRows(rows) {
return rows.map((row) => {
const out = { ...row };
if (row.Index_ != null) out.Index = row.Index_;
for (const k of ["Name", "Size", "Unite"])
if (String(row[k + "_"] ?? "").trim()) out[k] = row[k + "_"];
for (const k of ["GN", "TU", "TC", ...units, ...components])
if (row[k + "_"] != null) out[k] = row[k + "_"];
return out;
});
}
const contractRows = (project, round) =>
round.loadMethod === "origin"
? originRows(project.tables.EstMainDat)
: project.tables.EstMainDat;
function calculate(project, roundId) {
const round = project.progress.find((item) => item.id === roundId);
if (!round) throw Error("기성 회차 없음");
const source = round.contract || {
rows: project.tables.EstMainDat,
rows: contractRows(project, round),
wonga: project.tables.Wonga,
options: project.options,
};
@@ -42,12 +59,17 @@ const EstProgress = (() => {
const ambiguous = ambiguousKeys(rows);
const earlier = project.progress.slice(0, position);
if (earlier.some((item) => !item.locked)) errors.push("전회 기성 회차를 먼저 확정하세요.");
const previous = earlier.at(-1)?.result,
// 전회기성 불러오기(TfrmAccomJunLoad): 고른 완료 회차가 있으면 그 누계를 전회로
const chosen =
round.junFrom &&
project.progress.find((r) => r.id === round.junFrom && r.locked && r !== round),
prior = chosen || earlier.at(-1);
const previous = prior?.result,
oldRows = new Map((previous?.rows || []).map((row, index) => [key(row, index), row]));
if (earlier.at(-1)?.locked && !Array.isArray(previous?.rows))
if (prior?.locked && !Array.isArray(previous?.rows))
errors.push("확정된 전회 기성 결과가 없습니다. 전회 결과를 확인하세요.");
if (
earlier.at(-1)?.locked &&
prior?.locked &&
source.wonga?.length &&
!Array.isArray(previous?.costs) &&
!Array.isArray(previous?.wonga)
@@ -311,7 +333,7 @@ const EstProgress = (() => {
if (!round) throw Error("기성 회차 없음");
if (round.locked) throw Error("이미 확정한 회차입니다.");
round.contract = {
rows: structuredClone(project.tables.EstMainDat),
rows: structuredClone(contractRows(project, round)),
wonga: structuredClone(project.tables.Wonga),
options: { ...project.options },
};
@@ -341,5 +363,5 @@ const EstProgress = (() => {
}
return project;
}
return { calculate, lock, unlock, rebuild, ambiguousKeys };
return { calculate, lock, unlock, rebuild, ambiguousKeys, originRows };
})();
+157
View File
@@ -0,0 +1,157 @@
// 원본 수량 목록(TFRMMULLANGLIST — spec_pa §2·3)·수량붙여넣기(연결)(TFRMEST ActionMullangInput §8.2)·
// 품명·규격 지정(TFRMMULNAMEINPUT btnSetName·btnSetSize §4)·공종명칭 입력(TFRMMULTITLENAMEINPUT §5).
// 수량 목록: 왼쪽 모든 산출서(검색 = 명칭·규격에서 빈칸·|·괄호 뺀 부분일치, 전체보기) · 가운데 고른 산출서 수량 항목 · 오른쪽 원문.
// 선택수량복사: 고른 수량 항목을 앱 안에 담아 둠(클립보드 아님). 내역서 「수량붙여넣기(연결)」: 고른 줄부터 아래로 차례로
// 수량을 넣고 그 항목을 내역 줄에 연결(「품명,규격입력」 체크면 품명·규격·단위도). 행은 더하지 않음.
const EstQuantityList = (() => {
const norm = (s) => String(s ?? "").replace(/[\s|()]/g, "");
const RESULT_NAMES = { name: ["\x0e", "품명"], size: ["\x13", "규격"] };
function open(state) {
const l = (state.qList ||= { word: "", code: null, nameSize: false, picked: new Set() });
const { esc, number } = EstApp,
sheets = (state.project.quantitySheets || []).filter(
(s) => !l.word || norm(s.Name).includes(l.word) || norm(s.Size).includes(l.word),
),
sheet = state.project.quantitySheets?.find((s) => s.Code === l.code) || sheets[0];
l.code = sheet?.Code;
const items = sheet ? EstQuantity.calculate(sheet).items : [];
const preview = sheet
? sheet.rows
.map((r) =>
r.table
? `<table class="sheet">${r.table.map((tr) => `<tr>${tr.map((c) => `<td>${esc(c.text)}</td>`).join("")}</tr>`).join("")}</table>`
: `<div style="white-space:pre-wrap">${esc(EstText.clean(r.LineData || ""))}</div>`,
)
.join("")
: "";
EstApp.showDialog(
"수량 목록",
`<div class="business-tools"><input data-ql-word value="${esc(l.word)}" placeholder="검색어"><button data-action="ql-search">검 색</button><button data-action="ql-all">전체보기</button><button data-action="ql-copy">선택수량복사</button><label><input type="checkbox" data-ql-namesize ${l.nameSize ? "checked" : ""}> 품명,규격입력</label></div><div style="display:flex;gap:8px;height:56vh"><div style="flex:0 0 26%;overflow:auto;border:1px solid #ccc">${sheets.map((s) => `<div><a href="#" data-ql-sheet="${esc(s.Code)}"${s.Code === l.code ? ' style="font-weight:bold"' : ""}>${esc(s.Name)} ${esc(s.Size || "")}</a></div>`).join("")}</div><div style="flex:0 0 34%;overflow:auto;border:1px solid #ccc"><table class="sheet"><thead><tr><th></th><th>명칭</th><th>규격</th><th>단위</th><th>수량</th></tr></thead><tbody>${items.map((i, k) => `<tr><td><input type="checkbox" data-ql-item="${k}" ${l.picked.has(k) ? "checked" : ""}></td><td>${esc(i.Name)}</td><td>${esc(i.Size)}</td><td>${esc(i.Unite)}</td><td>${number(i.GN)}</td></tr>`).join("")}</tbody></table></div><div style="flex:1;overflow:auto;border:1px solid #ccc;padding:4px;font-size:12px">${preview}</div></div>`,
);
}
function paste(state) {
const clip = state.mulClip;
if (!clip?.items.length) throw Error("수량 목록에서 선택수량복사를 먼저 하세요.");
if (state.view !== "EstMainDat") throw Error("내역서에서 붙여넣을 줄을 고르세요.");
const rows = state.project.tables.EstMainDat,
start = [...state.selection].sort((a, b) => a - b)[0];
if (!Number.isInteger(start)) throw Error("내역서에서 붙여넣을 줄을 고르세요.");
EstApp.change((project) => {
const sheet = project.quantitySheets.find((s) => s.Code === clip.code);
clip.items.forEach((src, k) => {
const row = project.tables.EstMainDat[Math.min(start + k, rows.length - 1)],
item = sheet?.items.find((i) => i.Code === src.Code);
if (clip.nameSize) Object.assign(row, { Name: src.Name, Size: src.Size, Unite: src.Unite });
row.GN = Number(src.GN) || 0;
row.MullangIndex = 9999;
if (item && row.PkID != null)
Object.assign(item, { TargetKey: `id:${row.PkID}`, TargetCode: row.Code || "" });
});
});
state.notice = `수량 ${clip.items.length}개를 붙여넣고 연결했습니다.`;
}
// 품명·규격 지정: 산출 줄 글에서 고른 글자를 ⟦품명⟧·⟦규격⟧ 표지로(한 줄에 하나, 40자 이하, 제목 표지 위 금지)
function nameSize(state, kind, index) {
const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet),
editor = document.querySelector(`[data-quantity-line="${index}"]`);
if (!sheet?.rows[index] || !editor) throw Error("산출 줄을 고르세요.");
const [mark, label] = RESULT_NAMES[kind],
text = editor.value;
let a = editor.selectionStart ?? 0,
b = editor.selectionEnd ?? a;
if (a === b) {
while (a > 0 && /[^\s⟦⟧]/.test(text[a - 1])) a--;
while (b < text.length && /[^\s⟦⟧]/.test(text[b])) b++;
}
const picked = text.slice(a, b);
if (EstText.encode(text).includes(mark))
throw Error(`한라인에 ${label}을두번이상 지정할수 없습니다.`);
if (sheet.rows[index].title) throw Error("제목표시에는 지정할수 없습니다.");
if (picked.length > 40) throw Error("선택한 범위가 너무 큽니다.");
if (!picked.trim())
throw Error(
`${label}으로 지정할 문자를 드래그하여 선택하던지커서를 선택할위치로 옮겨 놓으세요.`,
);
const token = `⟦${label}⟧`;
EstApp.change(() => {
sheet.rows[index].LineData = EstText.encode(
text.slice(0, a) + token + picked + token + text.slice(b),
);
sheet.markerEdited = true;
}, false);
}
// 공종명칭 입력: 목록 명칭·규격을 바꾸고 문서 머리에 「공백5 + 명칭(규격) + 공백5」 제목 줄(굵게 큰 글자)을 넣거나 바꿈
function title(state) {
const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
if (!sheet) throw Error("수량산출서를 고르세요.");
const head = sheet.rows[0]?.title ? sheet.rows[0] : null;
const name = prompt("명칭", sheet.Name ?? "");
if (name == null) return;
const size = prompt("규격", sheet.Size ?? "") ?? "";
EstApp.change(() => {
sheet.Name = name.trim();
sheet.Size = size.trim();
const text = ` ${name.trim()}${size.trim() ? `(${size.trim()})` : ""} `;
if (head) head.LineData = text;
else
sheet.rows.unshift({ LineData: text, markers: [], title: true, para: { align: "center" } });
sheet.tableEdited = true;
}, false);
}
function install() {
document.addEventListener("click", (event) => {
const a = event.target.closest("[data-ql-sheet]");
if (!a) return;
event.preventDefault();
const l = EstApp.state.qList;
l.code = a.dataset.qlSheet;
l.picked = new Set();
open(EstApp.state);
});
document.addEventListener("change", (event) => {
const t = event.target,
l = EstApp.state.qList;
if (!l) return;
if (t.dataset.qlItem !== undefined) {
const k = Number(t.dataset.qlItem);
if (t.checked) l.picked.add(k);
else l.picked.delete(k);
} else if (t.dataset.qlNamesize !== undefined) l.nameSize = t.checked;
else if (t.dataset.qlWord !== undefined) {
l.word = norm(t.value);
open(EstApp.state);
}
});
}
if (typeof document !== "undefined" && document.addEventListener) install();
function handle(name, state, node) {
if (name === "quantity-list") open(state);
else if (name === "ql-search") {
state.qList.word = norm(document.querySelector("[data-ql-word]")?.value);
open(state);
} else if (name === "ql-all") {
state.qList.word = "";
open(state);
} else if (name === "ql-copy") {
const l = state.qList,
sheet = state.project.quantitySheets.find((s) => s.Code === l.code),
items = EstQuantity.calculate(sheet).items.filter((_, k) => l.picked.has(k));
if (!items.length) throw Error("복사할 수량 항목을 고르세요.");
state.mulClip = { code: sheet.Code, items, nameSize: l.nameSize };
state.notice = `수량 ${items.length}개를 담았습니다. 내역서에서 수량붙여넣기(연결)를 누르세요.`;
EstApp.render();
} else if (name === "mul-paste") paste(state);
else if (name === "quantity-name" || name === "quantity-size")
nameSize(state, name === "quantity-name" ? "name" : "size", Number(node.dataset.index));
else if (name === "quantity-title") title(state);
else return false;
return true;
}
return { handle };
})();
+2 -2
View File
@@ -129,7 +129,7 @@ const EstQuantitySheets = (() => {
`<option value="${esc(sheet.Code)}" ${state.quantitySheet === sheet.Code ? "selected" : ""}>${esc(sheet.Name || sheet.Code)}</option>`,
)
.join("");
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${options}</select></label><input data-quantity-query value="${esc(state.quantityQuery || "")}" placeholder="명칭·규격" aria-label="산출서 검색"><button data-action="quantity-sheet-search">검색</button><button data-action="quantity-sheet-all">전체보기</button><button data-quantity-new>새 산출서</button><button data-action="quantity-sheet-insert">삽입</button><button data-action="quantity-sheet-multi-delete">선택공종 삭제</button><button data-quantity-excel>Excel 셀 수량 연결</button><button data-quantity-excel-links>Excel 연결 관리</button></div>`;
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${options}</select></label><input data-quantity-query value="${esc(state.quantityQuery || "")}" placeholder="명칭·규격" aria-label="산출서 검색"><button data-action="quantity-sheet-search">검색</button><button data-action="quantity-sheet-all">전체보기</button><button data-quantity-new>새 산출서</button><button data-action="quantity-sheet-insert">삽입</button><button data-action="quantity-sheet-multi-delete">선택공종 삭제</button><button data-action="qt-mul-load">불러오기</button><button data-action="quantity-list">수량목록보기</button><button data-action="quantity-title">공종명칭 입력</button><button data-action="qt-print">수량산출서 인쇄</button><button data-action="qt-outline">테두리선</button><button data-action="qt-gungu-form">산출근거 양식</button><button data-quantity-excel>Excel 셀 수량 연결</button><button data-quantity-excel-links>Excel 연결 관리</button></div>`;
}
function handle(name, state, node) {
@@ -274,7 +274,7 @@ const EstQuantitySheets = (() => {
function excelView(sheet) {
if (typeof EstExcelView === "undefined") return "";
const on = sheet.excel?.length && !sheet.excelOff;
return `<p><button data-action="excel-mode">${on ? "일반형식으로" : "엑셀형식으로"}</button></p>${on ? EstExcelView.render(sheet, EstApp.state) : ""}`;
return `${typeof EstQuantityTable !== "undefined" ? EstQuantityTable.banner(sheet) : ""}<p><button data-action="excel-mode">${on ? "일반형식으로" : "엑셀형식으로"}</button></p>${on ? EstExcelView.render(sheet, EstApp.state) : ""}`;
}
function excelMode(state) {
const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
+320
View File
@@ -0,0 +1,320 @@
// 워드형 수량산출서 표 화면·편집 — 칸 고르기(Shift 로 범위), 칸 글·수식(=…) 고치기, 방향 셀 합(ActionAddRight·Left·Up·Down),
// 수량으로 지정·지정 해제(칸 수량 표지 → 수량 항목), 셀수직병합·셀분리(btnCellMergeVer·btnCellSplitV), 표 만들기(mnuTableCreate).
// 계산·표 자료는 native-quantity-wpt.cjs. 표를 고친 산출서는 원본 WPT 로 다시 쓰지 못하므로 ESTX 저장을 막는다(tableEdited).
const EstQuantityTable = (() => {
const W = () => EstQuantityWpt;
function sheetOf(state) {
return state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
}
const PAGE = 45; // 웹 산출서 한 쪽 줄 수(원본은 편집기 쪽 배치)
function paraStyle(row) {
const p = row.para || {};
return [
p.align && `text-align:${p.align}`,
p.indent && `padding-left:${p.indent * 8}px`,
row.title && "font-weight:bold;font-size:20px;color:#800000",
p.bold && "font-weight:bold",
p.size && `font-size:${p.size}px`,
p.color && `color:${p.color}`,
]
.filter(Boolean)
.join(";");
}
// 테두리선(popFormatOutLine)·산출근거 양식(popFormatGungu): 화면에만 그림(원본도 글로 넣지 않음)
function banner(sheet) {
return sheet.gunguForm
? `<table class="sheet" style="${sheet.outline ? "border:2px solid #333;" : ""}margin:4px 0"><tr><th>공 종</th><th>산 출 내 역</th><th>수 량</th></tr></table>`
: sheet.outline
? '<p class="business-note">테두리선 켬(인쇄 쪽 둘레)</p>'
: "";
}
function html(sheet, index, state) {
const { esc } = EstApp;
if (sheet.rows[index].image)
return `<img src="${esc(sheet.rows[index].image)}" alt="산출서 그림" style="max-width:600px;max-height:400px">`;
const table = sheet.rows[index].table,
sel = state.qtCell?.sheet === sheet.Code && state.qtCell.row === index ? state.qtCell : null;
W().recalc(table);
const inBox = (r, c) =>
sel &&
r >= Math.min(sel.r, sel.r2 ?? sel.r) &&
r <= Math.max(sel.r, sel.r2 ?? sel.r) &&
c >= Math.min(sel.c, sel.c2 ?? sel.c) &&
c <= Math.max(sel.c, sel.c2 ?? sel.c);
const cur = sel ? table[sel.r]?.[sel.c] : null;
const tools = sel
? `<div class="business-tools">${W().letters(sel.c)}${sel.r + 1} <input data-qt-bar value="${esc(cur?.formula ? `=${cur.formula}` : (cur?.text ?? ""))}" aria-label="칸 글·수식" style="width:30%">${[
["right", "우측 합"],
["left", "좌측 합"],
["up", "위 합"],
["down", "아래 합"],
]
.map(([d, l]) => `<button data-action="qt-sum" data-dir="${d}">${l}</button>`)
.join(
"",
)}<button data-action="qt-mark">수량으로 지정</button><button data-action="qt-free">지정 해제</button><button data-action="qt-vmerge">셀수직병합</button><button data-action="qt-vsplit">셀분리</button><button data-action="qt-style">표 모양</button></div>`
: "";
const body = table
.map(
(row, r) =>
`<tr>${row
.map((cell, c) =>
cell.vmerge
? ""
: `<td data-qt="${index},${r},${c}"${cell.vtop ? ` rowspan="${cell.vtop}"` : ""} style="${[
cell.mark ? "background:#e6f4ea" : "",
inBox(r, c) ? "outline:2px solid #316ea5" : "",
cell.formula ? "font-weight:bold" : "",
cell.name ? "color:#1d4ed8" : "",
]
.filter(Boolean)
.join(
";",
)}"${cell.mark || cell.formula ? ` title="${esc([cell.mark && `수량: ${cell.mark}`, cell.formula && `=${cell.formula}`].filter(Boolean).join(" · "))}"` : ""}>${esc(cell.text) || "&nbsp;"}</td>`,
)
.join("")}</tr>`,
)
.join("");
const border = sheet.rows[index].tableBorder ?? 1;
return `${tools}<table class="sheet" style="font-size:12px;${border === 1 ? "" : `border:${border}px solid #000`}">${body}</table>`;
}
function edit(state, fn) {
const sheet = sheetOf(state),
sel = state.qtCell;
if (!sheet || !sel || sel.sheet !== sheet.Code) throw Error("표에서 칸을 고르세요.");
const table = sheet.rows[sel.row]?.table;
if (!table) throw Error("표에서 칸을 고르세요.");
EstApp.change(() => {
fn(table, sel, sheet);
W().recalc(table);
sheet.tableEdited = true;
}, false);
}
function handle(name, state, node) {
if (name === "qt-sum")
edit(state, (t, s) => {
t[s.r][s.c].formula = W().sumFormula(t, s.r, s.c, node.dataset.dir);
});
else if (name === "qt-mark")
edit(state, (t, s, sheet) => {
const cell = t[s.r][s.c];
if (cell.mark) throw Error("이미 수량으로 지정된 칸입니다.");
const code = EstQuantity.nextItemCode(sheet);
cell.mark = code;
const name = t[s.r].find((c) => c.name)?.text ?? "";
sheet.items.push({
Code: code,
Name: name,
Size: t[s.r].find((c) => c.size)?.text ?? "",
GN: 0,
});
});
else if (name === "qt-free")
edit(state, (t, s, sheet) => {
const cell = t[s.r][s.c];
if (!cell.mark) throw Error("수량으로 지정된 칸이 아닙니다.");
const item = sheet.items.find((i) => i.Code === cell.mark);
if (item?.TargetCode || item?.TargetKey)
throw Error("내역에 연결된 수량 항목입니다. 연결 내역코드를 먼저 비우세요.");
sheet.items = sheet.items.filter((i) => i.Code !== cell.mark);
delete cell.mark;
});
else if (name === "qt-vmerge")
edit(state, (t, s) =>
W().vmerge(t, Math.min(s.r, s.r2 ?? s.r), Math.max(s.r, s.r2 ?? s.r), s.c),
);
else if (name === "qt-vsplit") edit(state, (t, s) => W().vsplit(t, s.r, s.c));
else if (name === "qt-create") {
const sheet = sheetOf(state);
if (!sheet) throw Error("수량산출서를 고르세요.");
const rows = Number(prompt("줄 수", "3")),
cols = Number(prompt("칸 수", "5"));
if (!rows || !cols) return true;
const at = Number(node.dataset.index);
EstApp.change(() => {
sheet.rows.splice(Number.isInteger(at) ? at + 1 : sheet.rows.length, 0, {
LineData: "",
markers: [],
table: W().create(rows, cols),
});
sheet.tableEdited = true;
}, false);
} else if (name === "qt-style")
edit(state, (t, s, sheet) => {
const w = Number(prompt("표 테두리 굵기(0~5)", String(sheet.rows[s.row].tableBorder ?? 1)));
if (!(w >= 0 && w <= 5)) throw Error("테두리 굵기는 0~5 입니다.");
sheet.rows[s.row].tableBorder = w;
});
else if (name.startsWith("qt-page") || name === "qt-para" || name === "qt-image")
lineTool(name, state, Number(node.dataset.index));
else if (name === "qt-outline" || name === "qt-gungu-form") {
const sheet = sheetOf(state);
if (!sheet) throw Error("수량산출서를 고르세요.");
const key = name === "qt-outline" ? "outline" : "gunguForm";
EstApp.change(() => {
sheet[key] = !sheet[key];
sheet.tableEdited = true;
}, false);
} else if (name === "qt-mul-load" || name === "qt-mul-take") mulLoad(name, state, node);
else if (name === "qt-print") print(state);
else return false;
return true;
}
// 문단(btnsetPara·mnuFormatPara)·그림 넣기(btnJPGInput)·한페이지 삽입/삭제(popPageInsert·popPageDel)
function lineTool(name, state, index) {
const sheet = sheetOf(state);
if (!sheet?.rows?.[index]) throw Error("산출 줄을 고르세요.");
const change = (fn) =>
EstApp.change(() => {
fn();
sheet.tableEdited = true;
}, false);
if (name === "qt-para") {
const align = prompt("정렬(left·center·right)", sheet.rows[index].para?.align || "left");
if (align == null) return;
if (!["left", "center", "right"].includes(align))
throw Error("정렬은 left·center·right 입니다.");
const indent = Number(prompt("들여쓰기(칸)", String(sheet.rows[index].para?.indent ?? 0)));
if (!(indent >= 0 && indent <= 40)) throw Error("들여쓰기는 0~40 칸입니다.");
// 글자모양(mnuFormatFont): 크기·굵게·색
const size =
Number(prompt("글자 크기(px, 0 = 기본)", String(sheet.rows[index].para?.size ?? 0))) || 0,
bold = confirm("굵게 할까요?"),
color =
prompt("글자색(#RRGGBB, 비우면 기본)", sheet.rows[index].para?.color || "")?.trim() || "";
if (color && !/^#[0-9a-f]{6}$/i.test(color)) throw Error("글자색은 #RRGGBB 꼴입니다.");
change(
() =>
(sheet.rows[index].para = {
align,
indent,
...(size && { size }),
...(bold && { bold }),
...(color && { color }),
}),
);
} else if (name === "qt-image") {
const picker = document.createElement("input");
picker.type = "file";
picker.accept = "image/*";
picker.addEventListener("change", () => {
const file = picker.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () =>
change(() =>
sheet.rows.splice(index + 1, 0, { LineData: "", markers: [], image: reader.result }),
);
reader.readAsDataURL(file);
});
picker.click();
} else if (name === "qt-page-insert")
change(() =>
sheet.rows.splice(
index + 1,
0,
...Array.from({ length: PAGE }, () => ({ LineData: " ", markers: [] })),
),
);
else if (name === "qt-page-delete") {
const start = Math.floor(index / PAGE) * PAGE,
rows = sheet.rows.slice(start, start + PAGE);
if (rows.some((r) => r.markers?.length || r.table?.some((x) => x.some((c) => c.mark))))
throw Error("수량으로 지정된 줄이 있는 쪽은 지울 수 없습니다. 먼저 지정 해제하세요.");
if (rows.some((r) => r.image) && !confirm("그림이 있는 쪽입니다. 지우시겠습니까?")) return;
if (!confirm(`${start + 1}~${start + rows.length}줄(한 쪽)을 지우시겠습니까?`)) return;
change(() => sheet.rows.splice(start, PAGE));
}
}
// 수량 불러오기(btnMulLoad): Main Data(원본 main.estx) 수량산출서 목록에서 골라 새 산출서로(새 코드, 본문 그대로)
async function mulLoad(name, state, node) {
const { esc } = EstApp;
if (name === "qt-mul-load") {
state.mulLoad ||= (await EstApp.api("/api/main-data?part=quantity")).quantitySheets;
EstApp.showDialog(
"수량 불러오기",
`<div style="max-height:420px;overflow:auto">${state.mulLoad.map((s, i) => `<div><button data-action="qt-mul-take" data-i="${i}">가져오기</button> ${esc(s.Code)} ${esc(s.Name)}${s.excel ? " (엑셀형)" : ""}</div>`).join("")}</div>`,
);
return;
}
const src = state.mulLoad[Number(node.dataset.i)];
EstApp.change((project) => {
const copy = structuredClone(src);
copy.Code = `Q${EstApp.uid()}`;
for (const item of copy.items || []) delete item.TargetCode;
(project.quantitySheets ||= []).push(copy);
state.quantitySheet = copy.Code;
}, false);
EstApp.byId("dialog").close();
state.notice = `「${src.Name}」 산출서를 불러왔습니다.`;
}
// 수량산출서 인쇄(btnPrint → TfrmMulPrintRange): 현재 산출서 또는 모두(산출서마다 간지 고르기), 산출 줄·표 그대로
function print(state) {
const sheets = state.project.quantitySheets || [];
if (!sheets.length) throw Error("수량산출서가 없습니다.");
const all = confirm("모든 수량산출서를 인쇄할까요? (취소 = 현재 산출서만)"),
gangi = all && confirm("산출서마다 간지를 넣을까요?"),
list = all ? sheets : [sheetOf(state)].filter(Boolean),
{ esc } = EstApp;
const body = list
.map((sheet) => {
const rows = sheet.rows
.map((r) =>
r.image
? `<img src="${esc(r.image)}" style="max-width:100%">`
: r.table
? `<table>${r.table.map((tr) => `<tr>${tr.map((c) => (c.vmerge ? "" : `<td${c.vtop ? ` rowspan="${c.vtop}"` : ""}>${esc(c.text)}</td>`)).join("")}</tr>`).join("")}</table>`
: `<div style="white-space:pre-wrap;${paraStyle(r)}">${esc(EstText.clean(r.LineData || ""))}</div>`,
)
.join("");
return `${gangi ? `<section class="gangi"><div>${esc(sheet.Name)}</div></section>` : ""}<section class="sheet"><h2>${esc(sheet.Name)} ${esc(sheet.Size || "")}</h2>${rows}</section>`;
})
.join("");
const win = open("", "_blank");
if (!win) throw Error("새 창을 열 수 없습니다. 팝업 차단을 확인하세요.");
win.document.write(
`<!doctype html><meta charset="utf-8"><title>수량산출서</title><style>body{font:10pt "Malgun Gothic",sans-serif}table{border-collapse:collapse}td{border:1px solid #777;padding:2px 4px}.sheet{break-after:page}.gangi{break-after:page;height:250mm;display:flex;align-items:center;justify-content:center;font-size:28pt;font-weight:bold}</style>${body}<script>print()<\/script>`,
);
win.document.close();
}
function install() {
document.addEventListener("click", (event) => {
const td = event.target.closest("[data-qt]");
if (!td) return;
const [row, r, c] = td.dataset.qt.split(",").map(Number),
state = EstApp.state,
old = state.qtCell;
state.qtCell =
event.shiftKey && old && old.row === row && old.sheet === state.quantitySheet
? { ...old, r2: r, c2: c }
: { sheet: state.quantitySheet, row, r, c };
EstApp.render();
});
document.addEventListener("change", (event) => {
if (event.target.dataset.qtBar === undefined) return;
const v = event.target.value,
state = EstApp.state;
try {
edit(state, (t, s) => {
const cell = t[s.r][s.c];
if (v.trim().startsWith("=")) cell.formula = v.trim().slice(1);
else {
delete cell.formula;
cell.text = v;
}
});
} catch (error) {
state.notice = error.message;
EstApp.render();
}
});
}
if (typeof document !== "undefined" && document.addEventListener) install();
return { html, banner, paraStyle, handle };
})();
+190
View File
@@ -0,0 +1,190 @@
// 워드형 수량산출서(TFRMMULWORD, WPTools 문서) 읽기와 표 — spec_word.
// 문단(<div>) 은 산출 줄, 표(<table>) 는 한 줄 { table: [[칸]] } 로 둔다. 칸 = { text, mark(수량 표지 이름 r·L·G), name·size(품명·규격 표지),
// formula(=SUM(B3:B5) — 원본 PAR_COMMAND), vmerge(세로 병합으로 가려짐) }.
// 방향 셀 합(ActionAddRight·Left·Up·Down 0xd3c5f0): 옆 칸부터 밖으로 — 빈칸·숫자 칸은 범위를 늘리고 글자 칸은 건너뛰며,
// 품명·규격 표지 칸이나 표 끝에서 멈춤. 칸에 SUM 수식을 두고 바로 계산(값 글자 = 1,234.5 꼴, 소수 9자리까지, 끝수 처리 없음).
// 계산 때마다 수식 칸을 왼쪽 위부터 한 번씩 다시 셈. 수량 표지 칸의 수는 그 항목 수량.
const EstQuantityWpt = (() => {
function entities(text) {
return String(text)
.replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => {
const code = n[0].toLowerCase() === "x" ? parseInt(n.slice(1), 16) : Number(n);
return code <= 0x10ffff ? String.fromCodePoint(code) : "";
})
.replace(/&quot;/g, '"')
.replace(/&apos;|&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&");
}
const tag = (name) =>
/^[rLG]\d+$/.test(name)
? "\x12"
: /^[Fa]\d+$/.test(name)
? "\x16"
: /^N\d+$/.test(name)
? "\x0e"
: /^S\d+$/.test(name)
? "\x13"
: "";
function fromWpt(code, name, text, items, options) {
const tags = new Map(),
rows = [];
const superscripts = new Set(
[...text.matchAll(/<cs\b[^>]*\bnr=(\d+)[^>]*>/gi)]
.filter((m) => (Number(m[0].match(/CharStyleON:(\d+)/)?.[1] || 0) & 16) !== 0)
.map((m) => m[1]),
);
for (const match of text.matchAll(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi)) {
const name = entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1];
if (name) tags.set(match[1], name);
}
// 문단·칸 글 → 표지 글자(\x12 결과·\x16 누계·\x0e 품명·\x13 규격)
function convert(html, cell = false) {
// 문단의 G 표지는 원본처럼 표지로 보지 않음(표 칸에서만 수량)
const tagOf = (n) => (!cell && /^G/.test(n) ? "" : tag(n));
const markers = [],
totals = [],
names = [];
let raised = false;
let body = html.replace(/<c\s+nr=(\d+)\s*\/>/gi, (_, id) => {
const next = superscripts.has(id),
value = next && !raised ? "^(" : !next && raised ? ")" : "";
raised = next;
return value;
});
if (raised) body += ")";
body = body
.replace(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi, (_, id) => {
const name = tags.get(id) || "";
names.push(name);
if (tagOf(name) === "\x12" && !/^G/.test(name)) markers.push(name);
if (tagOf(name) === "\x16") totals.push(name);
return tagOf(name);
})
.replace(/<end\b[^>]*\bTag=(\d+)[^>]*>/gi, (_, id) => tagOf(tags.get(id) || ""));
return {
body: entities(body.replace(/<[^>]*>/g, "").replace(/\r?\n/g, "")),
markers,
totals,
names,
};
}
for (const match of text.matchAll(
/<table\b[\s\S]*?<\/table>|<div\b[^>]*>([\s\S]*?)<\/div>/gi,
)) {
if (/^<table/i.test(match[0])) {
const table = [...match[0].matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map((tr) =>
[...tr[1].matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)].map((td) => {
const c = convert(td[1], true),
mark = c.names.find((n) => tag(n) === "\x12");
return {
text: c.body.replace(/[\x0e\x12\x13\x16]/g, "").trim(),
...(mark && { mark }),
...(c.names.some((n) => /^N\d+$/.test(n)) && { name: true }),
...(c.names.some((n) => /^S\d+$/.test(n)) && { size: true }),
};
}),
);
if (table.length) rows.push({ LineData: "", markers: [], table });
continue;
}
const c = convert(match[1]);
if (c.body.trim())
rows.push({
LineData: c.body,
markers: c.markers,
...(c.totals.length && { totals: c.totals }),
});
}
return { Code: code, Name: name, rows, items, options };
}
// ── 표 계산 ──
const L = (n) => {
let s = "";
for (n += 1; n > 0; n = Math.floor((n - 1) / 26))
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
return s;
};
const numeric = (t) => /^[+-]?[\d,]*\.?\d+$/.test(String(t).trim());
const value = (t) => (numeric(t) ? Number(String(t).replaceAll(",", "")) : 0);
const show = (v) => v.toLocaleString("en-US", { maximumFractionDigits: 9 });
function recalc(table) {
const errors = [];
table.forEach((row) =>
row.forEach((cell) => {
if (!cell.formula) return;
try {
const X =
typeof EstExcelSheet !== "undefined"
? EstExcelSheet
: require("./native-excel-sheet.cjs");
const v = X.evaluate(cell.formula, (r, c) => value(table[r]?.[c]?.text ?? ""));
cell.text = show(typeof v === "number" ? v : Number(v) || 0);
} catch (error) {
errors.push(error.message);
}
}),
);
return errors;
}
// 항목 수량: 수량 표지 칸의 수
function values(sheet) {
const out = new Map();
for (const row of sheet.rows || []) {
if (!row.table) continue;
recalc(row.table);
for (const r of row.table)
for (const c of r) if (c.mark && !c.vmerge) out.set(c.mark, value(c.text));
}
return out;
}
function sumFormula(table, r, c, dir) {
const [dr, dc] = { right: [0, -1], left: [0, 1], up: [1, 0], down: [-1, 0] }[dir] || [];
if (dr == null) throw Error("방향을 고르세요.");
// 방향 = 결과 칸이 있는 쪽(원본 Ctrl+← 는 오른쪽 칸들을 더함)
let rr = r + dr,
cc = c + dc,
last = null;
const first = [rr, cc];
while (table[rr]?.[cc]) {
const cell = table[rr][cc];
if (cell.name || cell.size) break;
if (!cell.text.trim() || numeric(cell.text)) last = [rr, cc];
rr += dr;
cc += dc;
}
if (!table[first[0]]?.[first[1]]) throw Error("더할 셀이 없습니다.");
if (!last) last = dir === "right" ? [r, 0] : first;
const a = [Math.min(first[0], last[0]), Math.min(first[1], last[1])],
b = [Math.max(first[0], last[0]), Math.max(first[1], last[1])];
return `SUM(${L(a[1])}${a[0] + 1}:${L(b[1])}${b[0] + 1})`;
}
function vmerge(table, r1, r2, c) {
if (r2 <= r1) throw Error("세로로 여러 칸을 고르세요.");
const top = table[r1][c];
for (let r = r1 + 1; r <= r2; r++) {
const cell = table[r][c];
if (cell.text.trim()) top.text = `${top.text} ${cell.text}`.trim();
cell.text = "";
cell.vmerge = true;
}
top.vtop = r2 - r1 + 1;
}
function vsplit(table, r, c) {
const top = table[r][c];
for (let i = r + 1; i < r + (top.vtop || 1); i++) if (table[i]?.[c]) delete table[i][c].vmerge;
delete top.vtop;
}
function create(rows, cols) {
if (!(rows >= 1 && rows <= 100 && cols >= 1 && cols <= 30))
throw Error("줄 1~100, 칸 1~30 입니다.");
return Array.from({ length: rows }, () => Array.from({ length: cols }, () => ({ text: "" })));
}
return { entities, fromWpt, recalc, values, sumFormula, vmerge, vsplit, create, letters: L };
})();
if (typeof module !== "undefined" && module.exports) module.exports = EstQuantityWpt;
+15 -49
View File
@@ -13,52 +13,10 @@ const EstQuantity = (() => {
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&");
}
function fromWpt(code, name, text, items, options) {
const tags = new Map(),
rows = [];
const superscripts = new Set(
[...text.matchAll(/<cs\b[^>]*\bnr=(\d+)[^>]*>/gi)]
.filter((m) => (Number(m[0].match(/CharStyleON:(\d+)/)?.[1] || 0) & 16) !== 0)
.map((m) => m[1]),
);
for (const match of text.matchAll(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi)) {
const name = entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1];
if (name) tags.set(match[1], name);
}
for (const match of text.matchAll(/<div\b[^>]*>([\s\S]*?)<\/div>/gi)) {
const markers = [],
totals = [];
let raised = false;
const tag = (name) =>
/^r\d+$/.test(name) || /^L\d+$/.test(name)
? "\x12"
: /^[Fa]\d+$/.test(name)
? "\x16"
: /^N\d+$/.test(name)
? "\x0e"
: /^S\d+$/.test(name)
? "\x13"
: "";
let body = match[1].replace(/<c\s+nr=(\d+)\s*\/>/gi, (_, id) => {
const next = superscripts.has(id),
value = next && !raised ? "^(" : !next && raised ? ")" : "";
raised = next;
return value;
});
if (raised) body += ")";
body = body
.replace(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi, (_, id) => {
const name = tags.get(id) || "";
if (tag(name) === "\x12") markers.push(name);
if (tag(name) === "\x16") totals.push(name);
return tag(name);
})
.replace(/<end\b[^>]*\bTag=(\d+)[^>]*>/gi, (_, id) => tag(tags.get(id) || ""));
body = entities(body.replace(/<[^>]*>/g, "").replace(/\r?\n/g, ""));
if (body.trim()) rows.push({ LineData: body, markers, ...(totals.length && { totals }) });
}
return { Code: code, Name: name, rows, items, options };
}
// 워드형 문서 읽기·표는 native-quantity-wpt.cjs
const wpt = () =>
typeof EstQuantityWpt !== "undefined" ? EstQuantityWpt : require("./native-quantity-wpt.cjs");
const fromWpt = (...args) => wpt().fromWpt(...args);
function calculate(sheet) {
const markerErrors = sheet.rows.flatMap((row, index) => {
const count = [...String(row.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)].length;
@@ -82,7 +40,10 @@ const EstQuantity = (() => {
};
return {
...row,
LineData: row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴|\x16[^\x16]*\x16/g, ""),
LineData:
row.table || row.image
? ""
: row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴|\x16[^\x16]*\x16/g, ""),
Index: 0,
SCheck: 0,
LinkIndex: 0,
@@ -118,13 +79,18 @@ const EstQuantity = (() => {
});
if (typeof EstQuantityTotal !== "undefined")
EstQuantityTotal.apply(sheet, result.rows.slice(1));
const tableValues = sheet.rows.some((row) => row.table) ? wpt().values(sheet) : null;
const cellValues =
sheet.excel && typeof EstExcelSheet !== "undefined" ? EstExcelSheet.itemValues(sheet) : null;
return {
rows: result.rows.slice(1),
items: sheet.items.map((item) => ({
...item,
GN: values.get(item.Code) ?? cellValues?.get(item.Code) ?? item.GN,
GN:
values.get(item.Code) ??
tableValues?.get(item.Code) ??
cellValues?.get(item.Code) ??
item.GN,
})),
checks,
errors: [...result.errors, ...markerErrors],
@@ -325,7 +291,7 @@ const EstQuantity = (() => {
{ esc, number, byId } = EstApp;
byId("work-content").innerHTML =
selector(state) +
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-action="quantity-sheet-copy">산출서 복사</button><button data-action="quantity-sheet-up">산출서 위로</button><button data-action="quantity-sheet-down">산출서 아래로</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>끝수 처리</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td style="white-space:normal"><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td>${formatSelect(sheet.items[index], index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table>${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}<p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-action="quantity-mark" data-index="${index}">수량 지정</button><button data-action="quantity-free" data-index="${index}">지정 해제</button><button data-action="quantity-total" data-index="${index}">누계</button><button data-action="quantity-row-copy" data-index="${index}">복사</button><button data-action="quantity-row-cut" data-index="${index}">잘라내기</button><button data-action="quantity-row-paste" data-index="${index}">아래 붙여넣기</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-action="quantity-sheet-copy">산출서 복사</button><button data-action="quantity-sheet-up">산출서 위로</button><button data-action="quantity-sheet-down">산출서 아래로</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>끝수 처리</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td style="white-space:normal"><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td>${formatSelect(sheet.items[index], index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table>${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}<p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td>${(sheet.rows[index].table || sheet.rows[index].image) && typeof EstQuantityTable !== "undefined" ? EstQuantityTable.html(sheet, index, state) : `<textarea data-quantity-line="${index}" rows="1" style="min-width:750px;${typeof EstQuantityTable !== "undefined" ? EstQuantityTable.paraStyle(sheet.rows[index]) : ""}" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea>`}</td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-action="quantity-mark" data-index="${index}">수량 지정</button><button data-action="quantity-free" data-index="${index}">지정 해제</button><button data-action="quantity-total" data-index="${index}">누계</button><button data-action="quantity-row-copy" data-index="${index}">복사</button><button data-action="quantity-row-cut" data-index="${index}">잘라내기</button><button data-action="quantity-row-paste" data-index="${index}">아래 붙여넣기</button><button data-action="qt-create" data-index="${index}">아래 표 만들기</button><button data-action="qt-para" data-index="${index}">문단</button><button data-action="quantity-name" data-index="${index}">품명지정</button><button data-action="quantity-size" data-index="${index}">규격지정</button><button data-action="qt-image" data-index="${index}">그림</button><button data-action="qt-page-insert" data-index="${index}">한페이지 삽입</button><button data-action="qt-page-delete" data-index="${index}">한페이지 삭제</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
}
// 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수.
function resultFormat(code) {
+8 -3
View File
@@ -13,13 +13,18 @@ const EstRows = (() => {
}
function percent(row, preceding, digits = 0) {
if ([1, 3].includes(num(row.CalcIndex))) return row;
// f1a8c4 matches Chr(64 + source.SKind) against the percentage row's Code.
const selectedGroups = String(row.Code || "").toUpperCase();
// 항목선택계산(62) Code = 'A' + 두 자리 색인 번호들(원본 SUM effc54, 예 A0103 = A·C); 그 밖 꼴은 글자 Chr(64+SKind) 로.
const selectedGroups = String(row.Code || "").toUpperCase(),
numbered = /^A((?:\d\d)+)$/.exec(selectedGroups),
groups = numbered ? new Set(numbered[1].match(/\d\d/g).map(Number)) : null;
const eligible =
num(row.LinkIndex) === 62
? preceding.filter(
(r) =>
num(r.SKind) > 0 && selectedGroups.includes(String.fromCharCode(64 + num(r.SKind))),
num(r.SKind) > 0 &&
(groups
? groups.has(num(r.SKind))
: selectedGroups.includes(String.fromCharCode(64 + num(r.SKind)))),
)
: preceding;
const base = baseKind(row),
+7 -1
View File
@@ -80,13 +80,19 @@ const EstViews = (() => {
}
function grid(table, list, columns) {
const { esc, number, state } = EstApp;
const biul =
table === "Wonga" &&
state.project.options.WongaBiulMode &&
typeof EstMiscTools !== "undefined"
? EstMiscTools.biul(state.project.tables.Wonga || [])
: null;
const abbr =
table === "Wonga" &&
state.project.options.WongaSNameShow &&
typeof EstWongaTools !== "undefined"
? EstWongaTools.abbreviate(state.project.tables.Wonga || [], state.project.options)
: null;
return `<table class="sheet" data-grid="${table}"><thead><tr><th class="row-number">선택</th>${columns.map((key) => `<th class="col-${key}">${esc((table === "Wonga" && typeof EstJungSang !== "undefined" && EstJungSang.label(state.project, key)) || (typeof EstSources !== "undefined" && EstSources.label(state.project.options, table, key)) || EstModel.labels[key] || key)}</th>`).join("")}</tr></thead><tbody>${list.map(({ row, index }) => `<tr data-index="${index}" class="${state.selection.has(index) ? "selected " : ""}${Number(row.Index) > 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}"><th class="row-number"><label><input type="checkbox" data-select="${index}" ${state.selection.has(index) ? "checked" : ""}>${index + 1}</label></th>${columns.map((key) => `<td class="col-${key}"${key === "Name" && Number(row.LeftSpace) > 0 ? ` style="padding-left:${Number(row.LeftSpace) * 6}px"` : ""}>${abbr && key === "VSusic" ? `<span title="산출근거 약어 표시 중(편집 잠김)">${esc(abbr[index])}</span>` : cell(table, index, key, row[key])}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
return `<table class="sheet" data-grid="${table}"><thead><tr><th class="row-number">선택</th>${columns.map((key) => `<th class="col-${key}">${esc((biul && key === "Youl" && "비율") || (table === "Wonga" && typeof EstJungSang !== "undefined" && EstJungSang.label(state.project, key)) || (typeof EstSources !== "undefined" && EstSources.label(state.project.options, table, key)) || EstModel.labels[key] || key)}</th>`).join("")}</tr></thead><tbody>${list.map(({ row, index }) => `<tr data-index="${index}" class="${state.selection.has(index) ? "selected " : ""}${Number(row.Index) > 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}"><th class="row-number"><label><input type="checkbox" data-select="${index}" ${state.selection.has(index) ? "checked" : ""}>${index + 1}</label></th>${columns.map((key) => `<td class="col-${key}"${key === "Name" && Number(row.LeftSpace) > 0 ? ` style="padding-left:${Number(row.LeftSpace) * 6}px"` : ""}>${biul && key === "Youl" ? `<span title="구성비(표시 전용)">${esc(biul[index])}</span>` : abbr && key === "VSusic" ? `<span title="산출근거 약어 표시 중(편집 잠김)">${esc(abbr[index])}</span>` : cell(table, index, key, row[key])}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
}
function render(state) {
const { project, view } = state,
+139
View File
@@ -0,0 +1,139 @@
// 원본 인쇄양식 파일(.pfot — mnuPrintFormatSave 0x10ed000 · Load 0x10ed318): 인쇄정보표(DatPrintOption) MIDAS 이진 한 벌.
// 읽기는 import-print.cjs 와 같은 꼴. 쓰기는 같은 꼴의 바탕 파일(필드 설명자)을 받아 행 부분만 새로 만든다.
// .gfot(화면 격자 열 배치)는 웹 격자와 호환되지 않아 다루지 않는다.
const { readPrintOptions } = require("./import-print.cjs");
function layout(buffer) {
if (buffer.length < 24 || buffer.readUInt32LE(0) !== 0xbde01996)
throw Error("인쇄양식 파일 헤더 오류");
const count = buffer.readUInt16LE(12),
start = buffer.readUInt16LE(22);
let at = 24;
const fields = [];
for (let i = 0; i < count; i++) {
const length = buffer[at];
const name = buffer.subarray(at + 1, at + 1 + length).toString("ascii");
at += 1 + length;
const width = buffer.readUInt16LE(at),
kind = buffer.readUInt16LE(at + 2),
attributes = buffer.readUInt16LE(at + 6);
at += 8;
for (let j = 0; j < attributes; j++) {
at += 1 + buffer[at];
const size = buffer.readUInt16LE(at),
type = buffer.readUInt16LE(at + 2);
at += 4;
at += type === 73 ? 2 + buffer.readUInt16LE(at) : size;
}
fields.push({ name, width, kind });
}
return { count, start, fields, flag: buffer[start] };
}
// rows: 인쇄정보표 행들(읽은 값). BLOB(75) 칸은 비워 씀.
function write(template, rows) {
const { count, start, fields, flag } = layout(template);
const encoder = (s) => {
const bytes = [];
for (const ch of String(s)) {
const code = ch.codePointAt(0);
if (code < 0x80) bytes.push(code);
else {
const found = cp949Table().get(ch);
if (found == null) throw Error(`인쇄양식 글자를 cp949 로 바꿀 수 없습니다: ${ch}`);
bytes.push(found >> 8, found & 0xff);
}
}
return Buffer.from(bytes);
};
const parts = [Buffer.from(template.subarray(0, start))];
parts[0].writeUInt16LE(rows.length, 14);
for (const row of rows) {
const status = Buffer.alloc(Math.ceil(count / 4)),
values = [];
fields.forEach(({ name, width, kind }, i) => {
const v = row[name];
if (kind === 75 && v?.binary != null) {
const data = Buffer.from(v.binary, "base64"),
length = Buffer.alloc(4);
length.writeUInt32LE(data.length);
values.push(length, data);
return;
}
if (v == null || kind === 75) {
status[i >> 2] |= (row.__nulls?.[name] || 1) << ((i % 4) * 2);
return;
}
if (kind === 1 && width === 4) {
const b = Buffer.alloc(4);
b.writeInt32LE(Number(v) | 0);
values.push(b);
} else if (kind === 3 && width === 2) {
const b = Buffer.alloc(2);
b.writeInt16LE(Number(v) | 0);
values.push(b);
} else if (kind === 4 && width === 8) {
const b = Buffer.alloc(8);
b.writeDoubleLE(Number(v));
values.push(b);
} else if (kind === 73 && width === 1) {
const text = encoder(v);
if (text.length > 255) throw Error(`인쇄양식 글이 너무 깁니다: ${name}`);
values.push(Buffer.from([text.length]), text);
} else throw Error(`인쇄양식 필드형 오류: ${name}/${kind}/${width}`);
});
parts.push(Buffer.from([flag]), status, ...values);
}
return Buffer.concat(parts);
}
let table = null;
function cp949Table() {
if (table) return table;
table = new Map();
const decoder = new TextDecoder("euc-kr");
for (let hi = 0x81; hi <= 0xfe; hi++)
for (let lo = 0x41; lo <= 0xfe; lo++) {
const ch = decoder.decode(Buffer.from([hi, lo]));
if (ch.length === 1 && ch !== "�" && !table.has(ch)) table.set(ch, (hi << 8) | lo);
}
return table;
}
// BLOB(설명 등)까지 담아 읽기 — 그대로 다시 쓰면 원본과 같은 바이트
function readFull(buffer) {
const { count, start, fields } = layout(buffer),
rows = buffer.readUInt16LE(14),
decoder = new TextDecoder("euc-kr");
let at = start;
const out = [];
for (let r = 0; r < rows; r++) {
at += 1;
const status = buffer.subarray(at, at + Math.ceil(count / 4));
at += status.length;
const row = {};
fields.forEach(({ name, width, kind }, i) => {
const state = (status[i >> 2] >> ((i % 4) * 2)) & 3;
if (state) {
if (state !== 1) (row.__nulls ||= {})[name] = state; // 빈 칸 표지(1·2)도 그대로 되살리게
return;
}
if (kind === 1 && width === 4) ((row[name] = buffer.readInt32LE(at)), (at += 4));
else if (kind === 3 && width === 2) ((row[name] = buffer.readInt16LE(at)), (at += 2));
else if (kind === 4 && width === 8) ((row[name] = buffer.readDoubleLE(at)), (at += 8));
else if (kind === 73 && width === 1) {
const n = buffer[at];
row[name] = decoder.decode(buffer.subarray(at + 1, at + 1 + n));
at += 1 + n;
} else if (kind === 75) {
const n = buffer.readUInt32LE(at);
row[name] = { binary: buffer.subarray(at + 4, at + 4 + n).toString("base64") };
at += 4 + n;
} else throw Error(`인쇄양식 필드형 오류: ${name}/${kind}/${width}`);
});
out.push(row);
}
return out;
}
module.exports = { read: (buffer) => readPrintOptions(buffer), readFull, write, layout };
+39 -1
View File
@@ -7,6 +7,9 @@ const http = require("node:http"),
const { importEstx, table: dbisamTable } = require("./import-estx.cjs");
const { readXls } = require("./xls-read.cjs");
const bookStore = require("./book-store.cjs");
const printFormat = require("./print-format.cjs");
const pfotTemplate =
process.env.EST_PFOT_TEMPLATE || "C:/Dasansoft/Est Plus 2/System/EstFormat/main.pfot";
const mainDataPath =
process.env.EST_MAIN_DATA || "C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx";
let mainData = null;
@@ -28,6 +31,7 @@ const scripts = [
"native-text.js",
"native-excel-sheet.cjs",
"native-quantity-total.js",
"native-quantity-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-jungsang.js",
@@ -36,6 +40,7 @@ const scripts = [
"native-row-match.js",
"native-escalation.js",
"native-progress.js",
"native-annual-load.js",
"native-business.js",
"native-review.js",
"native-resources.js",
@@ -93,6 +98,15 @@ const allowed = new Set([
"native-new-estimate.js",
"native-book.js",
"native-data-update.js",
"native-quantity-table.js",
"native-quantity-list.js",
"native-price-tools.js",
"native-misc-tools.js",
"native-excel-to-est.js",
"native-other-load.js",
"native-applied-cost.js",
"native-progress-view.js",
"native-actions-more.js",
"native-wonga-tools.js",
"native-estimate-print.js",
"native-xml-check.js",
@@ -270,9 +284,17 @@ const server = http.createServer(async (req, res) => {
mainData = {
name: "Main",
tables: Object.fromEntries(keys.map((k) => [k, project.tables[k] || []])),
quantitySheets: project.quantitySheets || [],
};
}
reply(res, 200, mainData);
// 수량 불러오기(TFRMMULLANG btnMulLoad)는 수량산출서만
reply(
res,
200,
url.searchParams.get("part") === "quantity"
? { quantitySheets: mainData.quantitySheets }
: { name: mainData.name, tables: mainData.tables },
);
return;
}
if (req.method === "GET" && url.pathname.startsWith("/api/book")) {
@@ -295,6 +317,22 @@ const server = http.createServer(async (req, res) => {
);
return;
}
if (req.method === "POST" && url.pathname === "/api/pfot-read") {
reply(res, 200, printFormat.readFull(await body(req)));
return;
}
if (req.method === "POST" && url.pathname === "/api/pfot-write") {
// 인쇄양식(.pfot) 쓰기: 원본 설치 인쇄양식의 필드 설명자를 바탕으로 행만 새로
const input = JSON.parse((await body(req)).toString("utf8"));
if (!Array.isArray(input.rows)) throw Error("인쇄양식 행 형식 오류");
reply(
res,
200,
printFormat.write(fs.readFileSync(pfotTemplate), input.rows),
"application/octet-stream",
);
return;
}
if (req.method === "POST" && url.pathname === "/api/read-xls") {
reply(res, 200, readXls(await body(req)));
return;
+9 -6
View File
@@ -4,7 +4,7 @@
* 키보드 — 찾기 칸에서 ↑ ↓ 로 옮기고 Enter 로 고름. 창 겉틀은 부르는 쪽 몫.
*
* const find = createFindList({ placeholder, filters: [sub.root, detail.root] });
* find.setRows([{ key, tag, name, sub, unit, onPick }]);
* find.setRows([{ key, tag, name, sub, unit, onPick }]); // 둘째 인자 = 처음 짚을 줄 key(가운데로 보임)
* ========================================================================== */
import "./ui_template_find_list.css";
@@ -27,7 +27,8 @@ export interface FindList {
root: HTMLElement;
search: HTMLInputElement;
hint: HTMLElement;
setRows: (rows: FindRow[]) => void;
/** active = 처음 짚을 줄 key(없으면 첫 줄) — 그 줄이 가운데로 보임 */
setRows: (rows: FindRow[], active?: string) => void;
}
export function createFindList(opts: {
@@ -42,12 +43,12 @@ export function createFindList(opts: {
let rows: FindRow[] = [];
let at = -1;
const mark = (i: number): void => {
const mark = (i: number, block: ScrollLogicalPosition = "nearest"): void => {
list.children[at]?.classList.remove("is-active");
at = i;
const row = list.children[at];
row?.classList.add("is-active");
row?.scrollIntoView({ block: "nearest" });
row?.scrollIntoView({ block });
};
field.input.addEventListener("keydown", (ev) => {
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
@@ -61,7 +62,7 @@ export function createFindList(opts: {
}
});
const setRows = (next: FindRow[]): void => {
const setRows = (next: FindRow[], active?: string): void => {
rows = next;
at = -1;
list.replaceChildren(
@@ -89,7 +90,9 @@ export function createFindList(opts: {
return row;
}),
);
if (rows.length) mark(0);
const pin = active === undefined ? -1 : rows.findIndex((r) => r.key === active);
if (pin >= 0) mark(pin, "center");
else if (rows.length) mark(0);
};
const filters = opts.filters?.length
@@ -40,6 +40,7 @@ export const ui_locales_machine = {
"National average · instant · server recalculates on save",
],
MF_NoPrice: ["단가 없음", "no price"],
MF_PickFind: ["글자로 찾기", "Type to filter"],
MF_Similar: ["비슷한 기계에서 시작", "Start from a similar machine"],
MF_SimilarFind: ["기계 이름 · 규격으로 찾기", "Find by name or spec"],
MF_SimilarNone: ["찾은 기계 없음", "No machines found"],
@@ -51,6 +52,14 @@ export const ui_locales_machine = {
M01_MachineMeta: ["원문 · 구분", "Source · group"],
M01_MachineUses: ["쓰는 곳", "Used by"],
M01_MachineNoUses: ["쓰는 곳 없음", "Not used"],
M01_MachineFormula: ["쓰는 계산식", "Hourly formula"],
M01_MachineFormulaListed: ["목록에 있음", "listed"],
M01_MachineFormulaShape: [
"꼴로 고름(채운 칸 · 새 기계는 저장 때 그 식 목록에 등록)",
"picked by filled fields (new machines are listed on save)",
],
M01_MachineFormulaOwn: ["그 기계만의 식", "machine's own formula"],
M01_MachineFormulaNone: ["식 없음", "no formula"],
M01_MachineSame: [
"같은 이름 · 규격 기계가 있음 — {value}",
"A machine with the same name and spec exists — {value}",
+4 -2
View File
@@ -20,6 +20,7 @@ import {
import { createCalcFormula, createCalcFormulaRow } from "./ui_template_calc_row";
import { el, createInputField, createSelectField } from "./ui_template_elements";
import { createFindList } from "./ui_template_find_list";
import { attachPickSearch } from "./ui_template_pick_search";
import { t as L } from "./ui_template_locale";
import { createSegment } from "./ui_template_segment";
import { createText } from "./ui_template_text";
@@ -237,6 +238,7 @@ export function createMachineForm(opts: MachineFormOptions): MachineFormHandle {
const field = createSelectField({ label: tr(label), options, value: now, size: 28 });
field.select.dataset.key = key;
field.select.disabled = Boolean(opts.readOnly);
attachPickSearch(field.select, { placeholder: L("MF_PickFind") }); // 항목 많은 고르기(직종) = 찾기 판(801-2)
const mark = (): void => {
field.root.classList.toggle("is-changed", !same(values[key] ?? null, init[key] ?? null));
};
@@ -448,11 +450,11 @@ export function createMachineForm(opts: MachineFormOptions): MachineFormHandle {
const rows = [
line(L("MF_Loss"), `${num("가격")}천원 × 1,000 × ${num("손료계수")} × 10^-7^`, p.손료),
line(L("MF_Fuel"), `${num("주연료")}ℓ × ${fmt(fuel)}원`, p.주연료),
line(L("MF_Misc"), `${fmt(p.주연료)} × ${num("잡재료율")}%`, p.잡품),
line(L("MF_Crew"), `${num("조종원")}인 × ${fmt(wage)}원 × ${fmt(hour)}`, p.조종원),
...(values["전력"]
? [line(L("MF_Power"), `${num("전력")}kWh × ${fmt(opts.power ?? 0)}원`, p.전력)]
: []),
line(L("MF_Misc"), `${fmt(p.주연료 + p.전력)} × ${num("잡재료율")}%`, p.잡품),
line(L("MF_Crew"), `${num("조종원")}인 × ${fmt(wage)}원 × ${fmt(hour)}`, p.조종원),
];
side.replaceChildren(
...head,

Some files were not shown because too many files have changed in this diff Show More