Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-09-20 22:45:17 +09:00
15 changed files with 830 additions and 734 deletions
+11 -6
View File
@@ -70,6 +70,11 @@ def _hit(row: dict, q: str) -> bool:
)
def _book(data: dict) -> str | None:
"""머리 원문 이름 — 한 테이블 파일(기계)은 머리 「원문」 이 갈래 묶음이라 이름이 묶음 안에."""
return data.get("원문") if isinstance(data.get("원문"), str) else None
def groups() -> list[dict]:
out = {g: {"group": g, "files": 0, "rows": 0} for g in cm.mf.GROUPS}
for data in cm.load(folder=FOLDER).values():
@@ -89,7 +94,7 @@ def files_of(group: str) -> list[dict]:
out.append(
{
"file": path.name,
"book": data.get("원문"),
"book": _book(data),
"chapter": chapter_of(path.name, data),
"edition": data.get(""),
"rows": len(data.get(items_key(data)) or []),
@@ -130,7 +135,7 @@ _STATES = ("공표", "산정", "미공표", "미확보", "추정") # 인력 「
def subs(file: str = "", group: str = "") -> dict:
"""하위 거름 목록 — 한 테이블 파일은 머리 묶음(인력 「구분」 · 기계 「세부분류」) ·
"""하위 거름 목록 — 한 테이블 파일은 머리 묶음(인력 「구분」 · 기계 「원문」) ·
장 그룹(소요량 · 계수 · 로직)은 장 파일에서 모음(구분 = 원문 + 부문 · 상세구분 = 장 · 차례대로)."""
if group:
if group not in cm.mf.GROUPS:
@@ -146,7 +151,7 @@ def subs(file: str = "", group: str = "") -> dict:
slot["details"].append(mk.detail_of(info["file"]))
return {"file": "", "version": "", "slot": "구분", "subs": list(found.values())}
data, version = read(file)
slot = next((s for s in ("구분", "세부분류") if isinstance(data.get(s), dict)), "")
slot = next((s for s in cm.MERGED.values() if isinstance(data.get(s), dict)), "")
lines = data.get(items_key(data)) or []
has_state = any("상태" in r for r in lines)
@@ -184,8 +189,8 @@ def rows(
) -> dict:
data, version = read(file)
hits = [r for r in data.get(items_key(data)) or [] if _hit(r, q)]
if sub: # 인력 구분 · 기계 세부분류
hits = [r for r in hits if sub in (r.get("구분"), r.get("세부분류"))]
if sub: # 인력 구분 · 기계 원문
hits = [r for r in hits if sub in {r.get(s) for s in cm.MERGED.values()}]
if detail: # 인력 상세구분
hits = [r for r in hits if r.get("상세구분") == detail]
if state: # 인력 상태
@@ -527,7 +532,7 @@ def _follow(row: dict, items: list[dict], file: str) -> dict:
def _apply(data: dict, changes: list[dict], file: str, book: dict) -> None:
"""고침 · 더함(키는 대장의 다음 번호 · 원문번호 없으면 빈 글) · 지움 — 키는 바꾸지 않음."""
items = data.setdefault(items_key(data), [])
tid = mk.table_id(data.get("그룹"), data.get("원문"))
tid = mk.table_id(data.get("그룹"), _book(data))
for ch in changes:
op, key, row = ch.get("op"), ch.get("key"), _dec(ch.get("row"))
if op not in ("edit", "add", "delete"):
+3 -3
View File
@@ -15,7 +15,7 @@ import {
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { hasPickable, pickPanel, swappedRow } from "./M01_MasterData_UI_Test_Pick";
import { tt } from "./M01_MasterData_UI_Test_Text";
import { plainReason, tt } from "./M01_MasterData_UI_Test_Text";
type Step =
{ kind: "job" } | { kind: "ask"; spec: LogicInput } | { kind: "pick" } | { kind: "result" };
@@ -107,7 +107,7 @@ function wizard(host: HTMLElement, one: LogicOne): void {
} else {
const box = el("input", {
className: "m01-test__input",
attrs: { type: "text", inputmode: "decimal" },
attrs: { type: "text", inputmode: "decimal", placeholder: tt("Enter_Value") },
});
box.value = values[name] ?? "";
box.addEventListener("input", () => changed(box.value));
@@ -154,7 +154,7 @@ function resultView(answer: CalcAnswer): HTMLElement[] {
className: "m01-test__reasons",
children: [
el("strong", { text: tt("Result_Stopped") }),
el("div", { text: answer.reason }),
el("div", { text: plainReason(answer.reason) }),
],
}),
];
+6 -3
View File
@@ -27,7 +27,7 @@ import {
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Test_Pick";
import { tt } from "./M01_MasterData_UI_Test_Text";
import { plainReason, tt } from "./M01_MasterData_UI_Test_Text";
/* ── 원문 표(소요량·계수) 미리보기 — /elements 로 파일을 찾고 /table 로 통째 읽음 ── */
@@ -298,7 +298,7 @@ function paint(host: HTMLElement, state: State): void {
} else {
const box = el("input", {
className: "m01-logic__input",
attrs: { type: "text", inputmode: "decimal" },
attrs: { type: "text", inputmode: "decimal", placeholder: tt("Enter_Value") },
});
box.value = state.values[spec.] ?? "";
let timer: ReturnType<typeof setTimeout> | undefined;
@@ -337,7 +337,10 @@ function paint(host: HTMLElement, state: State): void {
main.append(
el("div", {
className: "m01-logic__reasons",
children: [el("strong", { text: "멈춤" }), el("div", { text: state.answer.reason })],
children: [
el("strong", { text: "멈춤" }),
el("div", { text: plainReason(state.answer.reason) }),
],
}),
);
}
+3 -3
View File
@@ -24,7 +24,7 @@ import {
type FlowKind,
} from "./M01_MasterData_UI_Test_C_Model";
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Test_Pick";
import { tt } from "./M01_MasterData_UI_Test_Text";
import { plainReason, tt } from "./M01_MasterData_UI_Test_Text";
import "./M01_MasterData_UI_Test_C_Style.css";
const TEXT = {
@@ -169,7 +169,7 @@ function mount(host: HTMLElement, one: LogicOne): void {
}
rest.replaceChildren(...nodes);
const reason = answer && !answer.ok ? answer.reason : "";
stopped.textContent = reason ? `${tc("Stopped")}${reason}` : "";
stopped.textContent = reason ? `${tc("Stopped")}${plainReason(reason)}` : "";
stopped.hidden = !reason;
};
@@ -255,7 +255,7 @@ function inputColumn(
className: "m01c__input",
attrs: { type: "text", inputmode: "decimal" },
});
if (spec.) box.placeholder = `${spec.[0]} ${spec.[1]}`;
box.placeholder = spec. ? `${spec.[0]} ${spec.[1]}` : tt("Enter_Value");
box.value = values[spec.] ?? "";
box.addEventListener("input", () => {
values[spec.] = box.value;
+14 -4
View File
@@ -37,14 +37,24 @@ export function swappedRow(logic: LogicRow, swaps: Swaps): LogicRow | undefined
};
}
type Brief = ElementBrief & { 구분?: string; 상세구분?: string };
type Brief = ElementBrief & {
구분?: string;
상세구분?: string;
값들?: Record<string, unknown>;
};
const distinct = (items: Brief[], key: "구분" | "상세구분"): string[] => [
...new Set(items.map((i) => i[key]).filter((v): v is string => !!v)),
];
const label = (it: Brief): string =>
[`${it. ?? ""} ${it. ?? ""}`.trim(), formatNumber(it.)].filter(Boolean).join(" · ");
/** 후보 글 — 값 열이 여럿이면 있는 열을 다 보임(계산은 그 가운데 낮은 값) */
const label = (it: Brief): string => {
const cols = Object.entries(it. ?? {})
.filter(([, v]) => v !== null && v !== undefined)
.map(([k, v]) => `${k} ${formatNumber(v)}`);
const prices = cols.length ? cols : [formatNumber(it.) || tt("Mat_NoPrice")];
return [`${it. ?? ""} ${it. ?? ""}`.trim(), ...prices].join(" · ");
};
function pickRow(one: LogicOne, line: HoLine, i: number, swaps: Swaps, onChange: () => void) {
const cond =
@@ -109,7 +119,7 @@ function pickRow(one: LogicOne, line: HoLine, i: number, swaps: Swaps, onChange:
region: "울진",
})
: line. === "재료"
? searchPrice((line. ?? "").split(/[\s(·]/)[0]) // 옛 줄 — 이름 첫 낱말로 자재품목(구분 붙음)
? searchPrice(one.prices[line. as string]?. ?? (line. ?? "").split(/[\s(·]/)[0]) // 옛 줄(고르기 조건 없음) — 품셈재료 이름 · 없으면 이름 첫 낱말
: searchElements(line., line. ?? "");
void found
.then((r) => {
+20 -2
View File
@@ -29,14 +29,24 @@ const TEXT = {
Ask_Range: ["범위 {min} {max}", "Range {min} {max}"],
Mat_Title: ["쓰이는 재료·기계", "Materials and machines"],
Mat_Note: [
"기본은 원문 그대로 — 바꿔 보면 결과가 달라짐(저장 안 함)",
"Default is the source as is — swapping only changes this trial",
"기본은 원문 그대로 — 바꿔 보면 결과가 달라짐(저장 안 함) · 값이 여럿이면 그중 낮은 값으로 셈",
"Default is the source as is — swapping only changes this trial; the lowest listed price is used",
],
Mat_None: ["이 공종은 재료·기계를 따로 고르지 않습니다", "No materials or machines to pick"],
Mat_Default: ["기본 (원문 그대로)", "Default (as in source)"],
Pick_All: ["(전체)", "(All)"],
Use_Of: ["이 표의 용도", "Used for"],
Mat_Price: ["단가", "Unit price"],
Mat_NoPrice: ["단가 없음", "No price"],
Stop_Missing: [
"「{name}」 값을 아직 안 넣었습니다 — 값을 넣으면 계산됩니다",
"「{name}」 is empty — enter it to calculate",
],
Stop_NoRow: [
"넣은 값에 맞는 표 줄이 없습니다 — 값을 바꿔 보세요",
"No table row fits these values — try other values",
],
Enter_Value: ["값을 넣어 주세요", "Enter a value"],
Result_Title: ["결과", "Result"],
Result_Stopped: ["계산이 멈춤", "Calculation stopped"],
Col_Name: ["이름", "Name"],
@@ -48,6 +58,14 @@ const TEXT = {
export type TestTextKey = keyof typeof TEXT;
/** 엔진이 멈춘 까닭을 설계자 말로 — 모르는 글은 그대로 */
export function plainReason(reason: string): string {
const missing = /입력 「([^」]+)」 없음/.exec(reason);
if (missing) return tt("Stop_Missing", { name: missing[1] });
if (/맞는 줄 0개/.test(reason)) return tt("Stop_NoRow");
return reason;
}
export function tt(key: TestTextKey, fill: Record<string, string | number> = {}): string {
const entry = TEXT[key];
const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0];
+2 -2
View File
@@ -68,9 +68,9 @@
| 빌더 | 빌더 없음 |
| 검사 | `check_master.py 로직` (로직 재료 줄 고르기 조건) · `pytest resources/tester/test_자재품목_분류.py -q` (품셈재료가 부르는 재료의 묶음) |
## 기계 (`기계.json` · 세부분류 건설품셈 · 산림품셈)
## 기계 (`기계.json` · 원문 건설품셈 · 산림품셈)
| 세부분류 | 출처 · 지식DB 자리 | 받는 법 | 주기 | 빌더 | 검사 |
| 원문 | 출처 · 지식DB 자리 | 받는 법 | 주기 | 빌더 | 검사 |
| --- | --- | --- | --- | --- | --- |
| 건설품셈 | 건설품셈 공통 8-3 손료 · 8-4 운전경비 · 8-5 기계가격 · `원가계산/건설공사_표준품셈/본문/01_공통부문/제08장_건설기계/` | 한국표준품셈정보원 PDF — `collect_cost_sources.py docs` → md | 연 1회(품셈 개정) | `scripts/build_기계_건설품셈.py [--dry]` | `check_master.py 틀 기계.json` |
| 산림품셈 | 산림품셈 2장 기계손료 · `원가계산/산림_표준품셈/본문/제02장_소요재료_및_기계손료/` | 산림청 고시 PDF · md | 고시 개정 때 | 빌더 없음 | 같음 |
+10 -5
View File
@@ -15,8 +15,8 @@
| 그룹 | 파일 이름 |
| -------------------- | --------------------------------------------------------------------------------------- |
| 인력 | `인력.json` 하나 — 줄마다 `조사` |
| 기계 | `기계.json` 하나 — 줄마다 `세부분류` |
| 인력 | `인력.json` 하나 — 줄마다 `구분` |
| 기계 | `기계.json` 하나 — 줄마다 `원문` |
| 재료 | `재료_<원문>.json` — 자재품목 · 유가전력 · 품셈재료 |
| 환율 · 요율 | `환율_한국은행환율.json` · `요율_조달청제비율.json` |
| 소요량 · 계수 · 로직 | `<그룹>_<원문>_<NN>장_<장 제목>.json` — 예 `소요량_건설품셈_10장_창호_및_유리공사.json` |
@@ -94,7 +94,9 @@
- `상세구분` = 제조업 업종 · 엔지니어링 부문 · 측량 기술계/기능계 분야 — 없으면 `null` · 목록은 머리에.
- `상태` = 공표 · 산정(미공표 직종의 원문 4-라 값을 `값` 에 넣음 · 근거는 `비고` 한 줄) · 미공표(원문에 값 없음) · 미확보(공표 노임에 같은 직종 없음) · 추정(원문이 이름을 맞대지 않아 작업 범위로 짐작한 대응 — `준용` 에 대응 직종 키 · `구분` 은 관리자가 정함 · `값`·`단위`·`일시간` 은 빌더가 준용 직종에서 채움 · 근거 문서는 `출처` 짧게).
- `일시간` = 1일 노임 기준 시간 — 8 · 잠수부만 6(원문 4-가).
- 기계 `세부분류` = 건설품셈 · 산림품셈 · 세부분류별 원문·판·주는 머리 `세부분류` 묶음.
- 기계 `원문` = 건설품셈 · 산림품셈 · 원문별 판·주는 머리 `원문` 묶음(한 테이블 파일은 머리 `원문` 이 갈래 묶음 · 이름은 묶음 열쇠).
- 기계 `구분`·`상세구분` = 원문 기계 분류 그대로(`[00]토공기계``임업기계` 열하나 · 상세구분은 장비 이름) · 목록은 `ref/_묶음_기계.md`.
- 기계 `값원문` = 원문 글이 수 하나로 안 떨어지는 값의 원문 글 묶음 — `{ "8-4-2 주연료": "휘발유0.7" }`(있는 줄만).
- 값이 여럿인 요소(기계)는 `값` 을 묶음으로 — `{ "가격": …, "상각비계수": …, "정비비계수": …, "관리비계수": …, "손료계수": …, "주연료": …, "주연료종류": …, "잡재료율": …, "조종원": …, "조종원직종": … }` · `손료계수` = 원문 「계」.
- 묶음 요소는 `단위` · `출처` 도 같은 모양의 묶음.
- 원문에 없는 값을 지어 넣지 않음. 원문 「-」 = 칸을 비움(`null`).
@@ -111,7 +113,7 @@
- 값 열 다섯 = 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · `관급`(조달) — 채택값·기본가·견적가 두지 않음.
- `관급` = 원문 「가격정보」 칸의 조달청 관급 단가(철근 · 레미콘 · 아스콘 · H형강 등) · 기간은 `비고` 「조달 26.7」 · 「조달 ~29.3.18」.
- 그 밖의 줄에 남은 「가격정보」 값은 출처가 조달인지 물가지인지 원문이 안 가름 — 어느 값 열에도 넣지 않고 `비고` 「가격정보 1,485 · 2026.7」 로만(67 줄).
- `구분` · `상세구분` = 원문(ESTX)에 대·중분류 칸이 없어 비움 — 지어내지 않음.
- `구분` · `상세구분` = 원문(ESTX)에 대·중분류 칸이 없어 품명 글자 규칙으로 채움(`scripts/build_자재품목_분류.py`) · 묶음 목록은 `ref/_묶음_자재품목.md` · 못 가른 줄은 「미분류」.
- `면수` = 물가지 값의 원문 면수 묶음 `{ 물가자료, 유통물가, 물가정보, 거래가격 }` · 없으면 `null` · 화면에는 접어 둠.
- **원문번호 칸 없음** — 원문(ESTX)이 준 품번 `M0005250505` 는 남의 번호라 이름 몫을 못 함 · 대조는 `_키대장.json` 에만 둠(조달청에서 새로 더한 줄의 조달청 번호도 같음). 로직·연결은 키로 적음.
- 유류·전력은 뗌 — 유가·전기요금은 `재료_유가전력`(유류는 값이 비고 글뿐 · 중유는 받지 않음).
@@ -136,6 +138,7 @@
"이름": "메쌓기(인력)",
"기준": "1㎡",
"출처": "산림품셈 13-4-1",
"용도": { "공종": "돌쌓기", "대상": ["인력"], "로직키": ["GF000123"] },
"조건": { "뒷길이": "수", "돌": "고르기", "쌓기": "고르기" },
"값칸": { "석공": "인", "보통인부": "인" },
"줄": [{ "뒷길이": 35, "돌": "견치돌", "쌓기": "골쌓기", "석공": 0.5, "보통인부": 0.4 }],
@@ -144,7 +147,7 @@
```
- 장 파일 줄은 갈래를 스스로 지님 — `구분` = 원문 + 부문(「산림품셈」 · 「건설품셈 공통」 · 「건설품셈 토목」 …) · `상세구분` = 「NN장 장 제목」(파일 이름 그대로 · 「13장 구조물」). 파일 머리의 `부문` · `차례` 는 목록 차례로 남김.
- 열 차례 — 표는 키 · 원문번호 · 구분 · 상세구분 · 이름 · 기준 · 출처 · 비고 · 조건 · 범위규칙 · 값칸 · 줄 · 주 · `그룹`(소요량 파일 안의 계수 표 표시 · 맨 뒤) · 로직은 키 · 원문번호 · 구분 · 상세구분 · 이름 · 결과단위 · 출처 · 소유 · 비고 · 입력 · 중간 · 호표 · 덧줄 · 결과 · 끝수.
- 열 차례 — 표는 키 · 원문번호 · 구분 · 상세구분 · 이름 · 기준 · 출처 · 비고 · 용도 · 조건 · 범위규칙 · 값칸 · 줄 · 주 · `그룹`(소요량 파일 안의 계수 표 표시 · 맨 뒤) · 로직은 키 · 원문번호 · 구분 · 상세구분 · 이름 · 결과단위 · 출처 · 소유 · 비고 · 입력 · 중간 · 호표 · 덧줄 · 결과 · 끝수.
- 갈래 목록은 서버가 장 파일에서 모아 줌(`GET /subs?group=…` · 장 차례대로) — 화면이 이름을 품지 않음.
- 건설품셈 부문은 `구분` 칸 — 「건설품셈 건축」 · 원문번호는 절 번호만(「1-1-1」).
- 조건 종류 — `수`(같은 값) · `고르기`(글자) · `범위`(줄에 `[아래, 위]` · 끝 열림은 `null` · 포함 여부는 `범위규칙` 에 「초과·이하」처럼).
@@ -157,6 +160,8 @@
- 분수·식으로 적힌 값(「L/V×2×60」)은 값 칸에 식 글자 그대로 · `<값 칸>원문` 칸에 원문.
- 원문에 짝 표시 없이 나란히 놓인 줄은 `짝` 칸으로 묶음.
- 수는 원문 그대로(쉼표만 뺌). 단위 환산하지 않음.
- `용도` = 그 표를 어디에 쓰는지 — `공종`(원문 항 제목) · `대상`(인력 · 기계 · 재료 · 작업량 조건 …) · `로직키`(그 표를 부르는 로직 키 목록 · 없으면 빈 목록).
채우기 = `scripts/build_용도.py`(로직 식에서 표 키를 거꾸로 모음 · 다시 돌리면 같은 결과) · 표 3,029개 모두 있음(로직이 부르는 것 1,852).
- `주` 는 원문 글 그대로 — 계산에 쓰는 수가 들어 있으면 따로 계수 표로도 올림.
## 7. 로직 파일
@@ -0,0 +1,57 @@
# 기계 묶음 (구분 · 상세구분)
`기계.json` 줄에서 뽑은 표 — 손으로 고치지 않음.
- 구분 11 · 상세구분 174 (전체 654 줄)
## 규칙
- 구분 = 원문 기계 분류 코드 그대로(건설품셈 8-3 `[00]`~`[90]` · 산림품셈은 「임업기계」).
- 상세구분 = 장비 이름 묶음 — 규격·톤수는 `규격` 칸으로 가름.
- 줄의 `원문`(건설품셈 · 산림품셈)은 갈래 칸 — 구분과 다름.
## 구분별 상세구분 (줄 수)
### [00]토공기계 (80)
로더 17 · 굴착기 15 · 불도저 10 · 스크레이퍼 9 · 덤프트럭 9 · 브레이커 6 · 유압식 리퍼 5 · 덤프트럭 자동덮개시설 3 · 압쇄기 2 · 모터그레이더 2 · 유압식 진동콤팩터 1 · 트랜처 1
### [10]다짐기계 (32)
탠덤롤러 10 · 양족식 롤러 8 · 진동롤러 6 · 머캐덤 롤러 3 · 타이어 롤러 3 · 래 머 1 · 플레이트 콤팩터 1
### [20]운반 및 하역기계 (74)
크레인 34 · 트럭탑재형 크레인 6 · 리더 5 · 타워크레인 5 · 지게차 5 · 트랙터 4 · 트럭 트랙터 및 평판트레일러 4 · 고소작업차 3 · 케이싱 2 · 디젤 기관차 2 · 터널용고소작업차 1 · 스킵버킷 1 · 건설용리프트 1 · 경운기 1
### [30]포장기계 (29)
콘크리트 피니셔 6 · 아스팔트 믹싱플랜트 5 · 아스팔트 디스트리뷰터 4 · 아스팔트 피니셔 2 · 아스팔트 스프레어 2 · 아스팔트 스프레이어 2 · 스테이빌라이저 2 · 콘크리트 조면 마무리기 2 · 현장가열 표층재생기 1 · 콘크리트 스프레더 1 · 콘크리트 롤러페이버 1 · 슬러리실 기계 1
### [40]콘크리트기계 (33)
콘크리트 펌프차 8 · 콘크리트 배치플랜트 6 · 콘크리트 믹서 6 · 사일로 4 · 콘크리트 믹서트럭 2 · 콘크리트 펌프 2 · 초고압펌프 2 · 콘크리트 진동기 2 · 커터 1
### [50]골재생산기계 등 (103)
스크린 16 · 죠 크러셔 11 · 롤 크러셔 8 · 파이프추진기 8 · 아그리게이트빈 7 · 공기압축기 6 · 벨트 컨베이어 5 · 에이프런 피더 5 · 유압잭 5 · 브레이커 5 · 크러셔 4 · 콘 크러셔 4 · 크롤러드릴 4 · 터널전단면 굴착기 4 · 노면파쇄기 3 · 코아드릴 3 · 점보드릴 2 · 골재세척설비 1 · 드릴웨곤 1 · 유압식할암기 1
### [60]기초공사용 기계 (67)
보링 기계 7 · 진동파일 해머 7 · 파일천공전용장비 6 · 오거 5 · 오실레이터, 로테이터 5 · 리버스서큘레이션드릴 5 · 전회전식천공기 5 · 유압 파일 해머 5 · 디젤 파일 해머 4 · 강연선인장기 4 · 그라우팅 펌프 3 · 그라우팅 믹서 2 · PBD천공기 2 · 다짐말뚝 전용장비 2 · 유압파워팩 1 · 워터젯트 1 · 유압식 압입 인발기 1 · 고압분사전용장비 1 · 자동화 믹서플랜트 1
### [70]기타기계 (117)
엔진 17 · 모터 15 · 발전기 11 · 윈치 9 · 용접기 7 · 용착기 6 · 물탱크 5 · 건설용펌프 5 · 이동식 임목파쇄기 3 · 부착용 집게 3 · 수중모터 펌프 3 · 모르타르 펌프 3 · 진공흡입 준설차 2 · 자동세륜기 2 · 차선 제거기 2 · 절단기 2 · 취부기 2 · 고성능 착정기 1 · 하수관 천공기 1 · 상수도관 천공기 1 · 골재 살포기 1 · 버킷식준설기 1 · 동력분무기 1 · 라인 마커 1 · 알곤 용접기 1 · 프라즈마 절단기 1 · 실사출기 1 · 우레탄폼 분사용기구 1 · 레일천공기 1 · 파워렌치 1 · 침목천공기 1 · 타이템퍼 1 · 양로기 1 · 모르타르 믹서 1 · 양수기 1 · POWER TLOWEL 1 · 배관파이프 1
### [80]스마트 건설장비 (5)
3D GNSS 머신 가이던스 2 · 3D GNSS 머신 컨트롤 2 · 지능형 다짐공 시스템 1
### [90]해상장비 (79)
대선 16 · 펌프 준설선 13 · 양묘선 11 · 그래브 준설선 10 · 예 선 9 · 하천골재채취선 7 · 토운선 6 · 기중기선 5 · 이우선 2
### 임업기계 (35)
굴착기 3 · 포워더 2 · 체인톱 1 · 예취기 1 · 아키아윈치 1 · 2드럼 케이블윈치 1 · 파르미윈치 1 · 타이푼윈치 1 · 우드피싱 1 · 임내차 1 · 스마트집재기 1 · HAM300 1 · 콜라타워야더 1 · 임업용 동력집재기 1 · HAM200, 춘천집재기, 스마트집재기 1 · 하베스터 1 · 동력상하차기, 임업용 굴착기 1 · 타워야더 1 · 스윙야더 1 · 트럭 1 · 배부식분무기 1 · 천공기 1 · 양수기 1 · 드론 무인 헬리콥터 1 · 드론 무인 멀티콥터 1 · 리튬폴리머 배터리 1 · 1톤 1 · 동력분무기 1 · 파쇄 1 · 부착용집게 1 · 트럭탑재형크레인 1 · 타이어형크레인 1
@@ -2,7 +2,7 @@
본문 md(확정) 표를 읽어 기계 한 줄로 합침. 값은 md 에서만 · 계산·추정 없음.
실행: python resources/master_data/scripts/build_기계_건설품셈.py [--dry]
옛 모양(열쇠)으로 쓰고 `master_keys.adopt` 가 기계.json(키 · 세부분류 건설품셈)으로 받음.
옛 모양(열쇠)으로 쓰고 `master_keys.adopt` 가 기계.json(키 · 원문 건설품셈)으로 받음.
"""
import json
@@ -504,7 +504,7 @@ def compose(rows):
org = dict(v.get("원문83") or {})
org.update({f"{v['sec84']} {a}": b for a, b in (v.get("원문84") or {}).items()})
if org:
row["원문"] = org
row["원문"] = org
out.append(row)
return out
@@ -512,7 +512,7 @@ def compose(rows):
def dumps_row(r):
j = lambda o: json.dumps(o, ensure_ascii=False)
head = f'{{ "열쇠": {j(r["열쇠"])}, "이름": {j(r["이름"])}, "규격": {j(r["규격"])},'
tail = [k for k in ("비고", "원문") if k in r]
tail = [k for k in ("비고", "원문") if k in r]
body = [f' "단위": {j(r["단위"])},', f' "": {j(r[""])},']
body.append(f' "출처": {j(r["출처"])}' + ("," if tail else " }"))
for n, k in enumerate(tail):
+8 -10
View File
@@ -94,8 +94,8 @@ ROW_KEYS = {
),
"품셈재료": ("", "원문번호", "이름", "규격", "단위", "요구절", "연결", "고르기"),
}
# 한 테이블 파일 — 줄의 갈래 칸 · 머리의 갈래 묶음
MERGED = {"인력": "구분", "기계": "세부분류"}
# 한 테이블 파일 — 줄의 갈래 칸 · 머리의 갈래 묶음 (칸 이름은 `master_keys.MERGED` 한 곳에서)
MERGED = {group: slot for group, (_, slot, _) in mk.MERGED.items()}
# 인력 줄 — 모든 줄이 같은 열 한 벌 · 상태 값
LABOR_COLS = (
"",
@@ -222,25 +222,23 @@ def check_form(name: str, data: dict) -> list[str]:
f"{name} · 파일 이름이 「그룹_원문[_NN장_장 제목].json」 · 「인력.json」 · 「기계.json」 아님"
]
out = _head(name, data, m)
# 한 테이블 파일(기계)은 머리 「원문」 이 갈래 묶음 — 원문 이름은 묶음 안에
book = data.get("원문") if isinstance(data.get("원문"), str) else None
try:
tid = mk.table_id(data.get("그룹"), data.get("원문"))
tid = mk.table_id(data.get("그룹"), book)
except KeyError:
return out + [f"{name} · 테이블ID 없는 그룹·원문"]
merged = MERGED.get(data.get("그룹"))
table = data.get("그룹") in mf.TABLE_GROUPS
kind = "" if table else "로직" if data.get("그룹") == "로직" else "요소"
if data.get("원문") == "품셈재료":
if book == "품셈재료":
kind = "품셈재료"
rows = data.get("" if table else "")
if not isinstance(rows, list):
return out + [f"{name} · 머리 「{'' if table else ''}」 목록 없음"]
division = data.get("부문") if kind != "요소" else None
# 장 파일 줄의 갈래 — 구분 = 원문 + 부문 · 상세구분 = 「NN장 장 제목」(파일 이름 그대로)
want = (
(" ".join(x for x in (data.get("원문"), division) if x), _detail(name))
if m.group(4)
else None
)
want = (" ".join(x for x in (book, division) if x), _detail(name)) if m.group(4) else None
for row in rows:
key, number = str(row.get("")), str(row.get("원문번호") or "")
where = f"{name} · {key} {row.get('이름') or number}"
@@ -257,7 +255,7 @@ def check_form(name: str, data: dict) -> list[str]:
f"{where} · 구분·상세구분 「{row.get('구분')} · {row.get('상세구분')}"
f" 이 파일 머리·이름 「{want[0]} · {want[1]}」 과 다름"
)
cols = COLS.get(data.get("원문"))
cols = COLS.get(book)
for need in cols or ROW_KEYS[kind]:
if need not in row:
out.append(f"{where} · 칸 「{need}」 없음")
@@ -269,7 +269,7 @@ def machine(estx: Estx, files: dict, master: mf.Master):
for r in estx.table("MachTitleDat"):
if r.get("Unite") == "hr" and r.get("TC"):
est.setdefault(mach_key(r.get("Name"), r.get("Size")), r)
ours = [m for m in files["기계.json"][""] if m["세부분류"] == "건설품셈"]
ours = [m for m in files["기계.json"][""] if m["원문"] == "건설품셈"]
pairs = [(m, est[k]) for m in ours if (k := mach_key(m["이름"], m["규격"])) in est]
print(f"## 기계 — 우리 {len(ours)} · ESTX 시간당 {len(est)} · 기종·규격 짝 {len(pairs)}")
out = []
+1 -1
View File
@@ -134,7 +134,7 @@ LABOR = {
}
MERGED = {
"인력": ("인력.json", "구분", LABOR),
"기계": ("기계.json", "세부분류", {"건설품셈": "건설품셈", "산림품셈": "산림품셈"}),
"기계": ("기계.json", "원문", {"건설품셈": "건설품셈", "산림품셈": "산림품셈"}),
}
DIVISIONS = ("공통", "토목", "건축", "기계설비", "유지관리")
# 장 파일 줄의 열 차례 — 앞 넷은 한 줄에 · 「그룹」(소요량 파일 안의 계수 표 표시)은 맨 뒤
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -332,7 +332,7 @@ def test_구분_목록(client: TestClient) -> None:
survey = next(s for s in got["subs"] if s["name"] == "측량")
assert survey["book"] == "측량노임" and "기능계 도화" in survey["details"]
machine = _get(client, "/api/m01/subs", file="기계.json")
assert machine["slot"] == "세부분류" and [s["name"] for s in machine["subs"]] == [
assert machine["slot"] == "원문" and [s["name"] for s in machine["subs"]] == [
"건설품셈",
"산림품셈",
]
@@ -368,7 +368,7 @@ def test_하위_거름과_참조_이름(client: TestClient) -> None:
assert 0 < field["total"] < survey["total"]
assert {r["상세구분"] for r in field["rows"]} == {"기능계 도화"}
machine = _get(client, "/api/m01/rows", file="기계.json", size=500, sub="산림품셈")
assert 0 < machine["total"] < 100 and {r["세부분류"] for r in machine["rows"]} == {"산림품셈"}
assert 0 < machine["total"] < 100 and {r["원문"] for r in machine["rows"]} == {"산림품셈"}
linked = _get(client, "/api/m01/rows", file="재료_품셈재료.json", size=500)
keyed = [r for r in linked["rows"] if isinstance(r["연결"], str)]
assert keyed and all(linked["refs"][r["연결"]] for r in keyed)