Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_3
This commit is contained in:
@@ -23,6 +23,13 @@ export interface FileInfo {
|
||||
version: string;
|
||||
}
|
||||
|
||||
/** 하위 거름 한 갈래 — 인력 「구분」 · 기계 「세부분류」(머리에 등록된 것). */
|
||||
export interface SubInfo {
|
||||
name: string;
|
||||
book: string | null;
|
||||
details: string[];
|
||||
}
|
||||
|
||||
export interface RowsPage {
|
||||
file: string;
|
||||
version: string;
|
||||
@@ -93,8 +100,12 @@ export const fetchRows = (
|
||||
q: string,
|
||||
unlinked = false,
|
||||
sub = "",
|
||||
detail = "",
|
||||
): Promise<RowsPage> =>
|
||||
get<RowsPage>("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub });
|
||||
get<RowsPage>("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail });
|
||||
|
||||
export const fetchSubs = async (file: string): Promise<SubInfo[]> =>
|
||||
(await get<{ subs: SubInfo[] }>("/subs", { file })).subs;
|
||||
|
||||
export const fetchTables = (
|
||||
file: string,
|
||||
|
||||
@@ -68,11 +68,22 @@ def get_files(group: str) -> dict:
|
||||
return {"files": _call(store.files_of, group)}
|
||||
|
||||
|
||||
@router.get("/subs")
|
||||
def get_subs(file: str) -> dict:
|
||||
return _call(store.subs, file)
|
||||
|
||||
|
||||
@router.get("/rows")
|
||||
def get_rows(
|
||||
file: str, page: int = 1, size: int = 50, q: str = "", unlinked: bool = False, sub: str = ""
|
||||
file: str,
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
q: str = "",
|
||||
unlinked: bool = False,
|
||||
sub: str = "",
|
||||
detail: str = "",
|
||||
) -> dict:
|
||||
return _call(store.rows, file, page, size, q, unlinked, sub)
|
||||
return _call(store.rows, file, page, size, q, unlinked, sub, detail)
|
||||
|
||||
|
||||
@router.get("/tables")
|
||||
|
||||
@@ -134,11 +134,36 @@ def refs_of(rows_: list[dict]) -> dict[str, str]:
|
||||
return out
|
||||
|
||||
|
||||
def rows(file: str, page: int, size: int, q: str, unlinked: bool = False, sub: str = "") -> dict:
|
||||
def subs(file: str) -> dict:
|
||||
"""하위 거름 목록 — 인력 「구분」 · 기계 「세부분류」 묶음(머리에 등록된 것) · 그 밖은 빈 목록."""
|
||||
data, version = read(file)
|
||||
slot = next((s for s in ("구분", "세부분류") if isinstance(data.get(s), dict)), "")
|
||||
return {
|
||||
"file": file,
|
||||
"version": version,
|
||||
"slot": slot,
|
||||
"subs": [
|
||||
{"name": name, "book": head.get("원문"), "details": head.get("상세구분") or []}
|
||||
for name, head in (data.get(slot) or {}).items()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def rows(
|
||||
file: str,
|
||||
page: int,
|
||||
size: int,
|
||||
q: str,
|
||||
unlinked: bool = False,
|
||||
sub: str = "",
|
||||
detail: str = "",
|
||||
) -> 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("구분"), r.get("세부분류"))]
|
||||
if detail: # 인력 상세구분
|
||||
hits = [r for r in hits if r.get("상세구분") == detail]
|
||||
if unlinked: # 품셈재료 — 아직 못 이은 줄
|
||||
hits = [r for r in hits if not r.get("연결")]
|
||||
page, size = max(page, 1), min(max(size, 1), 500)
|
||||
@@ -298,13 +323,13 @@ def _lowest(value) -> object:
|
||||
|
||||
def pick(kind: str, q: str, limit: int) -> dict:
|
||||
"""고르기 창 — `procure` 나라장터자재(ref = 나라장터:<키>) · `price` 시중물가(관급 조달 줄 포함 · ref = 키)
|
||||
· `job` 값 있는 공표 노임 직종(준용대상 빼고 모든 조사)(ref = 키) · 이름·규격 낱말이 모두 들어간 줄."""
|
||||
· `job` 값 있는 공표 노임 직종(미확보 빼고 모든 구분)(ref = 키) · 이름·규격 낱말이 모두 들어간 줄."""
|
||||
if kind not in _PICK:
|
||||
raise StoreError(404, f"없는 고르기 「{kind}」")
|
||||
words = [_squash(w) for w in q.split()]
|
||||
hits = []
|
||||
for row in read(_PICK[kind])[0].get("줄") or []:
|
||||
if kind == "job" and (row.get("값") is None or row.get("조사") == "준용대상"):
|
||||
if kind == "job" and (row.get("값") is None or row.get("구분") == "미확보"):
|
||||
continue
|
||||
text = _squash(f"{row.get('이름')} {row.get('규격')} {' '.join(row.get('옛이름') or [])}")
|
||||
if all(w in text for w in words):
|
||||
@@ -312,7 +337,7 @@ def pick(kind: str, q: str, limit: int) -> dict:
|
||||
hits.append(
|
||||
{
|
||||
"ref": f"나라장터:{key}" if kind == "procure" else key,
|
||||
**{k: row.get(k) for k in ("원문번호", "조사", "이름", "규격", "단위")},
|
||||
**{k: row.get(k) for k in ("원문번호", "구분", "이름", "규격", "단위")},
|
||||
"값": _lowest(row.get("값")),
|
||||
"관급": kind == "price" and row["값"]["조달"].get("값") is not None,
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ function buildPage(): HTMLElement {
|
||||
const groupTitle = pick.group === pick.label ? [pick.group] : [pick.group, pick.label];
|
||||
title.textContent = groupTitle.join(" · ");
|
||||
const view = TABLE_GROUPS.includes(pick.group) ? renderTables : renderRows;
|
||||
dispose = view(body, pick.file.file, query, pick.sub);
|
||||
dispose = view(body, pick.file.file, query, pick.sub, pick.detail);
|
||||
showNotice(isStale(pick.file.file, pick.file.version) ? [L("M01_FileStale")] : []);
|
||||
};
|
||||
|
||||
|
||||
@@ -65,9 +65,10 @@ function hidden(file: string, col: string): boolean {
|
||||
return JOB.includes(file) && col === "준용";
|
||||
}
|
||||
|
||||
/** 키(한 번 주면 안 바뀜) · 빌더가 원문에서 채운 칸(옛이름 · 산정 · 후보) — 읽기 전용. */
|
||||
/** 키(한 번 주면 안 바뀜) · 빌더가 원문에서 채운 칸(구분 · 상세구분 · 옛이름) — 읽기 전용. */
|
||||
const fixed = (file: string, col: string): boolean =>
|
||||
col === "키" || (JOB.includes(file) && ["옛이름", "산정", "후보"].includes(col.split(SEP)[0]));
|
||||
col === "키" ||
|
||||
(JOB.includes(file) && ["구분", "상세구분", "옛이름"].includes(col.split(SEP)[0]));
|
||||
|
||||
const HEADS: Record<string, string[]> = {
|
||||
[MARKET]: ["조달 값", "조달 기준", "M01_ProcureCol"],
|
||||
@@ -93,7 +94,13 @@ const remember = (ref: string | null, name?: string): void => {
|
||||
};
|
||||
|
||||
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
|
||||
export function renderRows(host: HTMLElement, file: string, q: string, sub = ""): () => void {
|
||||
export function renderRows(
|
||||
host: HTMLElement,
|
||||
file: string,
|
||||
q: string,
|
||||
sub = "",
|
||||
detail = "",
|
||||
): () => void {
|
||||
let page = 1;
|
||||
let data: RowsPage | null = null;
|
||||
let unlinked = false;
|
||||
@@ -201,7 +208,7 @@ export function renderRows(host: HTMLElement, file: string, q: string, sub = "")
|
||||
const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" });
|
||||
add.addEventListener("click", () => {
|
||||
const fresh = blank(d.rows[0] ?? FALLBACK);
|
||||
const col = ["조사", "세부분류"].find((c) => c in fresh);
|
||||
const col = ["구분", "세부분류"].find((c) => c in fresh);
|
||||
addRow(file, d.version, sub && col ? { ...fresh, [col]: sub } : fresh);
|
||||
});
|
||||
const empty =
|
||||
@@ -233,7 +240,7 @@ export function renderRows(host: HTMLElement, file: string, q: string, sub = "")
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
data = await fetchRows(file, page, SIZE, q, unlinked, sub);
|
||||
data = await fetchRows(file, page, SIZE, q, unlinked, sub, detail);
|
||||
for (const [key, name] of Object.entries(data.refs ?? {})) names.set(key, name);
|
||||
paint();
|
||||
} catch (error) {
|
||||
@@ -317,7 +324,7 @@ function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
|
||||
return td;
|
||||
}
|
||||
|
||||
/** 값 없는 인력 줄(건설업 미공표 · 준용대상)의 준용 = 공표 직종 키 고르기. */
|
||||
/** 인력 줄의 준용 = 관리자가 고르는 공표 직종 키(값이 있어도 돌릴 수 있음). */
|
||||
function wageCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
|
||||
const text = show(cur["준용"]);
|
||||
const td = buildCell(named(text), { changed: text !== show(row["준용"]) });
|
||||
|
||||
@@ -8,14 +8,21 @@
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { fetchFiles, fetchRows, type FileInfo } from "./M01_MasterData_Api_Fetch";
|
||||
import {
|
||||
fetchFiles,
|
||||
fetchRows,
|
||||
fetchSubs,
|
||||
type FileInfo,
|
||||
type SubInfo,
|
||||
} from "./M01_MasterData_Api_Fetch";
|
||||
import { bookTree, renderTree, type MakeRow, type TreeNode } from "./M01_MasterData_UI_Tree";
|
||||
|
||||
/** 요소 화면에서 열 것 — 파일 · 하위 거름(조사·세부분류) · 제목 */
|
||||
/** 요소 화면에서 열 것 — 파일 · 하위 거름(구분·세부분류) · 상세구분 · 제목 */
|
||||
export interface Pick {
|
||||
group: string;
|
||||
file: FileInfo;
|
||||
sub: string;
|
||||
detail: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@@ -28,17 +35,6 @@ export interface SideHandle {
|
||||
setActive: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const LABOR_SURVEYS = [
|
||||
"건설업",
|
||||
"제조업",
|
||||
"엔지니어링",
|
||||
"측량",
|
||||
"건설사업관리",
|
||||
"SW",
|
||||
"산림",
|
||||
"준용대상",
|
||||
];
|
||||
const MACHINE_KINDS = ["건설품셈", "산림품셈"];
|
||||
const MATERIAL_NAMES: Record<string, string> = { 나라장터자재: "나라장터", 오피넷유가: "유가" };
|
||||
|
||||
/** 파일 이름 → 목록 글자 — 「소요량_건설품셈_10장_창호…」 → 「건설품셈_10장_창호…」 */
|
||||
@@ -64,6 +60,8 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
|
||||
const bodies = new Map<Group, HTMLElement>();
|
||||
const buttons = new Map<string, HTMLElement>();
|
||||
const counts = new Map<string, number>();
|
||||
// 하위 거름은 서버가 준 목록(파일 머리에 등록된 구분·세부분류) — 새 조사가 늘어도 화면은 그대로
|
||||
const kinds = new Map<Group, SubInfo[]>();
|
||||
|
||||
const setActive = (id: string | null): void => {
|
||||
for (const [key, button] of buttons) button.classList.toggle("is-active", key === id);
|
||||
@@ -104,11 +102,11 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
|
||||
const files = lists.get(group) ?? [];
|
||||
const body = bodies.get(group);
|
||||
if (!body) return;
|
||||
const open = (file: FileInfo, sub: string, label: string): void =>
|
||||
onOpen({ group, file, sub, label });
|
||||
const open = (file: FileInfo, sub: string, label: string, detail = ""): void =>
|
||||
onOpen({ group, file, sub, detail, label });
|
||||
const one = files[0];
|
||||
if (group === "인력" || group === "기계") {
|
||||
const kinds = group === "인력" ? LABOR_SURVEYS : MACHINE_KINDS;
|
||||
const list = kinds.get(group) ?? [];
|
||||
const all: TreeNode[] = one
|
||||
? [
|
||||
{
|
||||
@@ -117,11 +115,17 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
|
||||
count: one.rows,
|
||||
open: true,
|
||||
run: () => open(one, "", L("M01_All")),
|
||||
children: kinds.map((k) => ({
|
||||
id: `${group}\n${k}`,
|
||||
label: k,
|
||||
count: counts.get(`${group}\n${k}`) ?? null,
|
||||
run: () => open(one, k, k),
|
||||
children: list.map((k) => ({
|
||||
id: `${group}\n${k.name}`,
|
||||
label: k.name,
|
||||
count: counts.get(`${group}\n${k.name}`) ?? null,
|
||||
run: () => open(one, k.name, k.name),
|
||||
children: k.details.map((d) => ({
|
||||
id: `${group}\n${k.name}\n${d}`,
|
||||
label: d,
|
||||
count: null,
|
||||
run: () => open(one, k.name, `${k.name} · ${d}`, d),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
]
|
||||
@@ -161,12 +165,13 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
|
||||
|
||||
const refresh = async (group: string): Promise<FileInfo[]> => {
|
||||
const list = await fetchFiles(group);
|
||||
const kinds = group === "인력" ? LABOR_SURVEYS : group === "기계" ? MACHINE_KINDS : [];
|
||||
if (list[0] && kinds.length) {
|
||||
if (list[0] && (group === "인력" || group === "기계")) {
|
||||
const file = list[0].file;
|
||||
const subs = await fetchSubs(file);
|
||||
kinds.set(group, subs);
|
||||
await Promise.all(
|
||||
kinds.map(async (k) =>
|
||||
counts.set(`${group}\n${k}`, (await fetchRows(file, 1, 1, "", false, k)).total),
|
||||
subs.map(async (k) =>
|
||||
counts.set(`${group}\n${k.name}`, (await fetchRows(file, 1, 1, "", false, k.name)).total),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -195,7 +200,7 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
|
||||
const one = lists.get(group)?.[0];
|
||||
if (!one) return;
|
||||
setActive(`${group}\n`);
|
||||
onOpen({ group, file: one, sub: "", label: L(GROUP_TITLE[group]) });
|
||||
onOpen({ group, file: one, sub: "", detail: "", label: L(GROUP_TITLE[group]) });
|
||||
});
|
||||
}
|
||||
return root;
|
||||
|
||||
@@ -26,7 +26,13 @@ import {
|
||||
const LIST_SIZE = 30;
|
||||
|
||||
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
|
||||
export function renderTables(host: HTMLElement, file: string, q: string, _sub = ""): () => void {
|
||||
export function renderTables(
|
||||
host: HTMLElement,
|
||||
file: string,
|
||||
q: string,
|
||||
_sub = "",
|
||||
_detail = "",
|
||||
): () => void {
|
||||
let stop = (): void => {};
|
||||
const list = (page = 1): void => {
|
||||
stop();
|
||||
|
||||
@@ -54,24 +54,29 @@
|
||||
```json
|
||||
{
|
||||
"그룹": "인력",
|
||||
"조사": {
|
||||
"건설업": { "원문": "건설노임", "판": "2026-01-01", "비고": ["…"] }
|
||||
"비고": ["열 한 벌 · 상태 · 준용 · 새 조사 등록 …"],
|
||||
"구분": {
|
||||
"건설업": { "원문": "건설노임", "기관": "대한건설협회", "판": "2026-01-01", "공표일": "2025-12-31", "원천": [{ "경로": "resources/knowledge/original/원가계산/…", "해시": "…", "역할": "primary" }], "상세구분": [], "비고": ["…"] }
|
||||
},
|
||||
"줄": [
|
||||
{ "키": "LB000018", "원문번호": "1018", "조사": "건설업", "이름": "포설공", "규격": "", "단위": "원/일", "값": 0, "출처": "건설노임 1018" }
|
||||
{ "키": "LB000018", "원문번호": "1018", "구분": "건설업", "상세구분": null, "이름": "포설공", "규격": "", "단위": "원/일", "값": 221015, "출처": "건설노임 1018", "일시간": 8, "상태": "공표", "옛이름": [], "비고": null, "준용": null }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 인력 `조사` = 건설업 · 제조업 · 엔지니어링 · 측량 · 건설사업관리 · SW · 산림 · 준용대상 · 조사별 원문·판·비고는 머리 `조사` 묶음.
|
||||
- 인력은 **열 한 벌** — 키 · 원문번호 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 값 · 출처 · 일시간 · 상태 · 옛이름 · 비고 · 준용 · 모든 줄이 모든 칸(없으면 `null` · 빈 목록).
|
||||
- 인력 `구분` = 건설업 · 제조업 · 엔지니어링 · 측량 · 건설사업관리 · SW · 산림 · 미확보 · 구분별 원문·기관·판·공표일·원천·상세구분·비고는 머리 `구분` 묶음.
|
||||
- 새 조사 = 머리 `구분` 에 한 항목 더하고 줄을 붙이면 끝 · 키는 LB 일련번호 이어 받음.
|
||||
- `상세구분` = 제조업 업종 · 엔지니어링 부문 · 측량 기술계/기능계 분야 — 없으면 `null` · 목록은 머리에.
|
||||
- `상태` = 공표 · 산정(미공표 직종의 원문 4-라 값을 `값` 에 넣음 · 근거는 `비고` 한 줄) · 미공표(원문에 값 없음) · 미확보(공표 노임에 같은 직종 없음).
|
||||
- `일시간` = 1일 노임 기준 시간 — 8 · 잠수부만 6(원문 4-가).
|
||||
- 기계 `세부분류` = 건설품셈 · 산림품셈 · 세부분류별 원문·판·주는 머리 `세부분류` 묶음.
|
||||
- 값이 여럿인 요소(기계)는 `값` 을 묶음으로 — `{ "가격": …, "상각비계수": …, "정비비계수": …, "관리비계수": …, "손료계수": …, "주연료": …, "주연료종류": …, "잡재료율": …, "조종원": …, "조종원직종": … }` · `손료계수` = 원문 「계」.
|
||||
- 묶음 요소는 `단위` · `출처` 도 같은 모양의 묶음.
|
||||
- 원문에 없는 값을 지어 넣지 않음. 원문 「-」 = 칸을 비움(`null`).
|
||||
- `준용` = 값 없는 요소에 관리자가 고르는 유사 요소 키 — 준용 값을 미리 채우지 않음. 공표 노임에 이름이 온전히 같은 직종이 없으면 조사 「준용대상」.
|
||||
- `옛이름` = 품셈이 쓰는 옛 직종명 목록(읽기 전용) · 이름 바뀜·통합 근거가 선 것은 준용이 아니라 현행 줄에 바로 적음 · 이력은 그 줄 `비고` · 근거는 `노임단가_직종대응_근거.md`. `후보` = 비슷한 이름 글(「키 이름」 · 값 안 씀).
|
||||
- `산정` = 건설노임 미공표 직종의 원문 4-라 값 `{ 값, 근거, 마지막공표[, 비고] }` — 원 미만 처리 안 함.
|
||||
- 엔진 차례 = 값 → 준용 → 산정 · 호표 출처에 「준용: <이름>」 · 「산정: …」.
|
||||
- `준용` = 모든 줄의 공통 칸 — 관리자가 고르는 다른 요소 키 · 준용 값을 미리 채우지 않음. 공표 노임에 이름이 온전히 같은 직종이 없으면 구분 「미확보」.
|
||||
- `옛이름` = 품셈이 쓰는 옛 직종명 목록(읽기 전용) · 이름 바뀜·통합 근거가 선 것은 준용이 아니라 현행 줄에 바로 적음 · 이력은 그 줄 `비고` · 근거는 `노임단가_직종대응_근거.md`.
|
||||
- 엔진 차례 = 준용이 있으면 그 요소 값 · 없으면 제 값 · 호표 출처에 「준용: <이름>」.
|
||||
- 원문 이름 대신 번호로 적은 값(`조종원직종` = 노임 원문번호 「1048」)은 원문 이름을 `비고` 칸에.
|
||||
|
||||
재료 값:
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
|---|---|---|
|
||||
| `GET /groups` | | `{groups: [{group, files, rows}]}` — 그룹 차례는 `_틀.md` 2장 |
|
||||
| `GET /groups/{group}/files` | | `{files: [{file, book, chapter, edition, rows, version, order}]}` — 머리 `차례` 순 |
|
||||
| `GET /rows` | `file` · `page`(1) · `size`(50) · `q` | `{file, version, total, page, size, rows: [줄…]}` |
|
||||
| `GET /subs` | `file` | `{file, version, slot, subs: [{name, book, details}]}` — 인력 `구분` · 기계 `세부분류`(머리에 등록된 것) · 그 밖은 빈 목록 |
|
||||
| `GET /rows` | `file` · `page`(1) · `size`(50) · `q` · `sub` · `detail` | `{file, version, total, page, size, rows: [줄…]}` |
|
||||
| `GET /tables` | `file` · `q` | `{file, version, tables: [{키, 원문번호, 이름, 기준, 출처, 조건, 값칸, count}]}` |
|
||||
| `GET /table` | `file` · `key` | `{file, version, table: {표 하나 통째}}` |
|
||||
| `GET /logics` | `book` · `chapter` · `q` · `blocked`(0/1) | `{logics: [{file, book, chapter, 키, 원문번호, 이름, 결과단위, 출처, blocked, reasons}]}` |
|
||||
@@ -26,12 +27,13 @@
|
||||
| `GET /elements` | `group` · `q` · `limit`(50 · 200까지) | `{total, items: [{ref, file, 원문번호, 이름, 규격, 단위, 값, 값칸}]}` |
|
||||
|
||||
- `rows` 는 요소·로직 파일은 `줄`, 표형 파일(소요량·계수)은 `표` 를 줄로 봄.
|
||||
- `q` = 찾기 — `키`·`원문번호`·`이름` 에 든 글(대소문자 무시).
|
||||
- `q` = 찾기 — `키`·`원문번호`·`이름`·`옛이름` 에 든 글(대소문자 무시).
|
||||
- `sub` = 인력 `구분`·기계 `세부분류` 거름 · `detail` = 인력 `상세구분` 거름 · 빈 글 = 전체.
|
||||
- `chapter` = 「부문 NN장 장 제목」(`공통 03장 토공사` · `13장 구조물`) · 장 파일 아니면 "".
|
||||
- `blocked` = 로직 검사(`check_master.py 로직`)에 걸림 · `reasons` = 걸린 까닭 글.
|
||||
- `prices` = 호표 `요소` → `{ref, 이름, 규격, 단위, 값}`(단가 자동 칸) · 없는 요소는 `null` · `{이름}` 이 낀 원문번호·하위 로직 줄은 빠짐(계산 때 정해짐). 품셈재료는 `값` = 연결을 따라간 낮은 값 · `출처` = 그 값의 칸(`<시중물가 키>.시중.<칸>` · `….조달` · `<나라장터자재 키>`) · 자재지역 후보는 `값` null(계산 때 정해짐).
|
||||
- `elements` = 요소 찾기 창 — 그룹 전체에서 `q` 찾기 · `ref` = 식에 넣는 키 · 표형 그룹은 `값칸` 이 옴.
|
||||
- `pick` = 고르기 창 — `procure` ref `나라장터:<키>` · `price` ref 키 · `job` = `인력.json` 값 있는 공표 직종(준용대상 빼고) ref 키.
|
||||
- `pick` = 고르기 창 — `procure` ref `나라장터:<키>` · `price` ref 키 · `job` = `인력.json` 값 있는 공표 직종(구분 「미확보」 빼고) ref 키.
|
||||
|
||||
## 3. 시험 계산
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"""노임 공표 원문 md → 인력 요소 파일 다섯 · 준용대상 짝짓기 · 건설노임 미공표 산정.
|
||||
"""노임 공표 원문 md → 인력 요소 파일 다섯 · 미확보 짝짓기 · 건설노임 미공표 산정.
|
||||
|
||||
원문 = resources/knowledge/original/원가계산/노임단가_* (우리 md · ESTX 값 쓰지 않음).
|
||||
- 모든 줄이 같은 열 한 벌(`_틀.md` 인력) · 제조업 업종·엔지니어링 부문·측량 분야는 「상세구분」.
|
||||
- 인력_엔지니어링노임 · 측량노임 · 건설사업관리노임 · SW노임 · 산림노임 — md 표 그대로 · 줄 수·값을 md 와 기계 대조.
|
||||
- 인력_준용대상 — 새 노임 직종과 이름이 온전히 같은 줄은 옮김(로직 참조도) · 비슷한 이름은 비고에 후보.
|
||||
건설노임 원문 「통합·명칭변경 직종」 목록의 옛 직종은 현행 줄 `옛이름` 으로 옮겼음(`_틀.md` 인력 값).
|
||||
- 인력_건설노임 미공표 — `산정` = {값(원 미만 처리 안 함) · 근거 · 마지막공표} · 원문 4-라.
|
||||
옛이름·비고·준용은 다시 지어도 그대로 이어받음(`carry`).
|
||||
- 인력_준용대상(구분 「미확보」) — 새 노임 직종과 이름이 온전히 같은 줄은 옮김(로직 참조도).
|
||||
건설노임 원문 「통합·명칭변경 직종」 목록의 옛 직종은 현행 줄 `옛이름` 으로 옮겼음.
|
||||
- 인력_건설노임 미공표 — 원문 4-라 산정값을 「값」 에 넣고 상태 「산정」 · 근거는 비고 한 줄.
|
||||
이음 = 원문 회차의 전체직종 평균을 그대로 이음(미공표직종_추적.md 4절) · 산정값은 같은 md 5절과 대조.
|
||||
|
||||
쓰기: ./venv/Scripts/python.exe resources/master_data/scripts/build_인력_노임.py
|
||||
옛 모양(원문별 파일 · 열쇠)으로 쓰고 끝에 `master_keys.adopt` 가 인력.json(키 · 조사)으로 받음.
|
||||
옛 모양(원문별 파일 · 열쇠)으로 쓰고 끝에 `master_keys.adopt` 가 인력.json(키 · 구분)으로 받음.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -62,18 +64,34 @@ def origin(path: Path) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def row(name: str, value, source: str, **more) -> dict:
|
||||
def row(name: str, value, source: str, detail=None, **more) -> dict:
|
||||
"""열 한 벌(`_틀.md` 인력) — 키·원문번호·구분은 `master_keys.adopt` 가 앞에 붙임."""
|
||||
return {
|
||||
"열쇠": name,
|
||||
"상세구분": detail,
|
||||
"이름": name,
|
||||
"규격": "",
|
||||
"단위": WAGE,
|
||||
"값": value,
|
||||
"출처": source,
|
||||
"일시간": 6 if name == "잠수부" else 8,
|
||||
"상태": "공표",
|
||||
"옛이름": [],
|
||||
"비고": None,
|
||||
"준용": None,
|
||||
**more,
|
||||
}
|
||||
|
||||
|
||||
def details(rows: list[dict]) -> list[str]:
|
||||
"""구분 묶음의 상세구분 목록 — 줄에 나온 차례대로."""
|
||||
out = []
|
||||
for r in rows:
|
||||
if r["상세구분"] and r["상세구분"] not in out:
|
||||
out.append(r["상세구분"])
|
||||
return out
|
||||
|
||||
|
||||
# ── 갈래별 노임 ──────────────────────────────────────────────────────
|
||||
def engineering():
|
||||
d = COST / "노임단가_엔지니어링_한국엔지니어링협회"
|
||||
@@ -81,7 +99,7 @@ def engineering():
|
||||
t = table_after(src.read_text(encoding="utf-8"), "| 구분 |")
|
||||
parts = [p.replace(r"\*", "") for p in t[0][1:]] # 「기타**」 주석 기호
|
||||
rows = [
|
||||
row(f"{r[0]}({p})", num(v), f"엔지니어링노임 {r[0]} {p}", 부문=p)
|
||||
row(f"{r[0]}({p})", num(v), f"엔지니어링노임 {r[0]} {p}", detail=p)
|
||||
for r in t[1:]
|
||||
for p, v in zip(parts, r[1:])
|
||||
]
|
||||
@@ -90,13 +108,15 @@ def engineering():
|
||||
(d / "노임단가_연도별_엔지니어링(2025).md").read_text(encoding="utf-8"), "| 년도 |"
|
||||
)
|
||||
xls = {(r[0], p): num(v) for r in y[2:10] for p, v in zip(y[1][-7:], r[-7:])}
|
||||
assert {(r["이름"].split("(")[0], r["부문"]): r["값"] for r in rows} == xls
|
||||
assert {(r["이름"].split("(")[0], r["상세구분"]): r["값"] for r in rows} == xls
|
||||
head = {
|
||||
"그룹": "인력",
|
||||
"원문": "엔지니어링노임",
|
||||
"기관": "한국엔지니어링협회",
|
||||
"판": "2026-01-01",
|
||||
"공표일": "2025-12-02",
|
||||
"원천": origin(src),
|
||||
"상세구분": details(rows),
|
||||
"비고": [
|
||||
"한국엔지니어링협회 2025 엔지니어링업체 임금실태조사결과 공표(기획홍보팀-1515호 · 국가승인통계 제372001호) · 7 기술부문 × 8 기술등급 · 1인 1일 평균임금 · 월평균 근무일수 20.3일",
|
||||
"이름 = 기술등급(기술부문) · 기타 = 선박·항공우주·금속·화학·광업·농림·산업·해양·수산",
|
||||
@@ -130,14 +150,17 @@ def survey():
|
||||
name,
|
||||
num(r[3]),
|
||||
f"측량노임 {' '.join(x for x in (top, field if top == '기능계' else '', grade) if x)}",
|
||||
detail=f"{top} {field}" if top == "기능계" else top,
|
||||
)
|
||||
)
|
||||
head = {
|
||||
"그룹": "인력",
|
||||
"원문": "측량노임",
|
||||
"기관": "한국공간정보산업협회",
|
||||
"판": "2026-01-01",
|
||||
"공표일": "2025-12-22",
|
||||
"원천": origin(src),
|
||||
"상세구분": details(rows),
|
||||
"비고": [
|
||||
"한국공간정보산업협회 2025년도 측량업체 임금실태조사 결과 공표(국가승인통계 제377001호) · 20 직종 · 1인 1일 · 월평균 근무일수 20.3일",
|
||||
"이름 = 기술계 「측량<등급>기술자」·측량기술사 · 기능계 「<분야><등급>기능사」 · 기타 원문 그대로 — 원문 칸은 출처에",
|
||||
@@ -155,20 +178,15 @@ def cm():
|
||||
rows = []
|
||||
for r in t[1:]:
|
||||
grade = r[0].replace(" ", "")
|
||||
rows.append(
|
||||
row(
|
||||
f"{grade}건설사업관리기술인",
|
||||
num(r[1]),
|
||||
f"건설사업관리노임 {grade}",
|
||||
환산비=float(r[2]),
|
||||
)
|
||||
)
|
||||
rows.append(row(f"{grade}건설사업관리기술인", num(r[1]), f"건설사업관리노임 {grade}"))
|
||||
head = {
|
||||
"그룹": "인력",
|
||||
"원문": "건설사업관리노임",
|
||||
"기관": "한국건설엔지니어링협회",
|
||||
"판": "2026-01-01",
|
||||
"공표일": "2025-12-29",
|
||||
"원천": origin(src),
|
||||
"상세구분": [],
|
||||
"비고": [
|
||||
"한국건설엔지니어링협회 2026년도 건설사업관리기술인 노임가격(정책381-3804호 · 통계청승인 제378001호) · 일임금액",
|
||||
"환산비 = 원문 Si(고급 = 1.000 기준 등급별 비율 · 소수 넷째 자리 반올림)",
|
||||
@@ -187,16 +205,15 @@ def sw():
|
||||
for r in t[1:]:
|
||||
mark, name = r[0].split(" ", 1)
|
||||
n = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰".index(mark) + 1
|
||||
more = {"월평균임금": num(r[1]), "시간평균임금": num(r[3])}
|
||||
if r[4]:
|
||||
more["포함직무"] = r[4]
|
||||
rows.append(row(name, num(r[2]), f"SW노임 {n}", **more))
|
||||
rows.append(row(name, num(r[2]), f"SW노임 {n}"))
|
||||
head = {
|
||||
"그룹": "인력",
|
||||
"원문": "SW노임",
|
||||
"기관": "한국인공지능·소프트웨어산업협회",
|
||||
"판": "2026-01-01",
|
||||
"공표일": "2025-12-19",
|
||||
"원천": origin(src),
|
||||
"상세구분": [],
|
||||
"비고": [
|
||||
"한국인공지능·소프트웨어산업협회 2026년 적용 SW기술자 평균임금(통계승인 제375001호) · 값 = 일평균임금(M/D) · 적용 2026-01-01 ~ 2026-12-31",
|
||||
"⚠ 기본급·제수당·상여금·퇴직급여충당금·법인부담금(4대보험) 모두 포함 — 건설노임(기본급 일급)과 성격 다름",
|
||||
@@ -216,9 +233,11 @@ def forest():
|
||||
head = {
|
||||
"그룹": "인력",
|
||||
"원문": "산림노임",
|
||||
"기관": "산림청",
|
||||
"판": "2026-01-01",
|
||||
"공표일": "2026-01-06",
|
||||
"원천": origin(src),
|
||||
"상세구분": [],
|
||||
"비고": [
|
||||
"산림청 2026년 나무의사·수목치료기술자 노임단가 알림(수목진료 표준품셈 예정가격 작성용) · 2025년 소비자물가상승률 2.1% 반영(원문) · 통계 승인 노임 아님",
|
||||
"판 = 2026년 적용(원문에 적용 시작일 없음 · 1월 1일로 적음)",
|
||||
@@ -238,29 +257,17 @@ def check_md(src: Path, rows: list[dict], table_head: str | None = None) -> None
|
||||
print(f" {src.parent.name[5:]}: {len(rows)} 줄 · 값 합 {sum(got.elements()):,} · md 대조 같음")
|
||||
|
||||
|
||||
# ── 준용대상 짝짓기 · 통합 ───────────────────────────────────────────
|
||||
def merge_list() -> dict[str, list[str]]:
|
||||
"""건설노임 원문 「통합직종」 · 「직종명칭 변경」 표 — 옛 이름 → 새 이름(여럿이면 갈림)."""
|
||||
text = (CAK / "2026-09-01/2. 임금적용요령.md").read_text(encoding="utf-8")
|
||||
out: dict[str, list[str]] = {}
|
||||
lines = [cells(x) for x in text.splitlines() if x.startswith("| ") and "---" not in x]
|
||||
for r in lines:
|
||||
if len(r) != 6 or not r[0].isdigit():
|
||||
# ── 미확보 짝짓기 ────────────────────────────────────────────────────
|
||||
def carry(rows: list[dict], kind: str, old: list[dict]) -> None:
|
||||
"""관리자·근거가 채운 칸(옛이름 · 비고 · 준용)은 다시 지어도 그대로 — 이름으로 맞댐."""
|
||||
had = {r["이름"]: r for r in old if r.get("구분") == kind}
|
||||
for r in rows:
|
||||
keep = had.get(r["이름"])
|
||||
if not keep:
|
||||
continue
|
||||
for no, old, new in ((r[0], r[1], r[2]), (r[3], r[4], r[5])):
|
||||
if not no.isdigit() or not new:
|
||||
continue
|
||||
for name in re.split(r"\+", old.replace("<br>", "").replace(" ", "")):
|
||||
if name and name != new:
|
||||
out.setdefault(name, []).append(new)
|
||||
return out
|
||||
|
||||
|
||||
def candidates(name: str, pool: dict[str, str]) -> list[str]:
|
||||
"""비슷한 이름 — 새 이름이 옛 이름 뿌리(괄호 앞)와 괄호 안 말을 모두 품음."""
|
||||
m = re.fullmatch(r"(.+?)\((.+)\)", name)
|
||||
base, qual = (m.group(1), m.group(2)) if m else (name, "")
|
||||
return [ref for n, ref in pool.items() if n != name and base in n and qual in n]
|
||||
for col in ("옛이름", "비고", "준용"):
|
||||
if keep.get(col) not in (None, [], ""):
|
||||
r[col] = keep[col]
|
||||
|
||||
|
||||
def main():
|
||||
@@ -295,12 +302,12 @@ def main():
|
||||
make = mk.old_view("인력_제조노임.json")
|
||||
pool = {r["이름"]: f"인력:{b}:{r['열쇠']}" for b, (_, _, rows) in books.items() for r in rows}
|
||||
old_pool = {r["이름"]: f"인력:{d['원문']}:{r['열쇠']}" for d in (wage, make) for r in d["줄"]}
|
||||
merges = merge_list()
|
||||
|
||||
now = json.loads((MD / "인력.json").read_text(encoding="utf-8"))["줄"]
|
||||
applied = { # 현행 줄이 이미 품은 옛 직종명(`_틀.md` 인력 값 · 옛이름)
|
||||
name
|
||||
for r in json.loads((MD / "인력.json").read_text(encoding="utf-8"))["줄"]
|
||||
for name in (r.get("옛이름") or [])
|
||||
name for r in now for name in (r.get("옛이름") or [])
|
||||
} - { # 갈린 직종(절단공)은 옛이름이면서 미확보로도 남음 — 지우지 않음
|
||||
r["이름"] for r in now if r.get("구분") == "미확보"
|
||||
}
|
||||
alias_path = MD / "인력_준용대상.json"
|
||||
alias = mk.old_view(alias_path.name)
|
||||
@@ -311,25 +318,18 @@ def main():
|
||||
moved[f"인력:준용대상:{r['열쇠']}"] = pool[name]
|
||||
continue
|
||||
assert name not in old_pool, name
|
||||
if name in applied: # 이미 현행 줄 「옛이름」 으로 옮긴 직종 — 준용대상에 다시 만들지 않음
|
||||
if name in applied: # 이미 현행 줄 「옛이름」 으로 옮긴 직종 — 미확보로 다시 만들지 않음
|
||||
continue
|
||||
r = {k: v for k, v in r.items() if k not in ("통합", "후보")}
|
||||
old = name.replace("스레이트", "슬레이트") # 원문 표기 「슬레이트공」
|
||||
new = merges.get(old, [])
|
||||
if [old_pool[n] for n in new if n in old_pool]:
|
||||
r["후보"] = "재료 갈림: " + " · ".join(new) + " — 관리자가 준용으로 고름"
|
||||
near = candidates(name, {**pool, **old_pool})
|
||||
if near and "후보" not in r:
|
||||
r["후보"] = "비슷한 이름: " + " · ".join(near)
|
||||
kept.append(r)
|
||||
alias["비고"] = (
|
||||
"값을 확인 못 한 직종(공표 노임에 이름이 온전히 같은 직종 없음) — 이름·단위만 · 준용 = 관리자가 고르는 공표 직종 키(`LB000123`)"
|
||||
" · 후보 = 비슷한 이름(값 안 씀) · 비고 = 이 직종을 요구한 품셈 절"
|
||||
)
|
||||
alias["비고"] = [
|
||||
"값을 확인 못 한 직종(공표 노임에 이름이 온전히 같은 직종 없음) — 이름·단위만"
|
||||
" · 비고 = 이 직종을 요구한 품셈 절 · 비슷한 이름도 비고에"
|
||||
]
|
||||
head = {k: v for k, v in alias.items() if k != "줄"}
|
||||
dump_master(alias_path, head, kept)
|
||||
|
||||
for book, (src, head, rows) in books.items():
|
||||
carry(rows, mk.LABOR[book], now)
|
||||
dump_master(MD / f"인력_{book}.json", head, rows)
|
||||
|
||||
# 로직 참조 옮김 — 긴 열쇠부터
|
||||
@@ -343,10 +343,7 @@ def main():
|
||||
if new != text:
|
||||
path.write_bytes(new.encode("utf-8"))
|
||||
print(
|
||||
f"준용대상 {len(alias['줄'])} → 옮김 {len(moved)} {sorted(moved.values())} · 남김 {len(kept)} · 로직 참조 {n_ref} 곳"
|
||||
)
|
||||
print(
|
||||
f" 재료 갈림 {sum(1 for r in kept if '재료 갈림' in str(r.get('후보')))} · 비슷한 이름 {sum(1 for r in kept if '비슷한' in str(r.get('후보')))}"
|
||||
f"미확보 {len(alias['줄'])} → 옮김 {len(moved)} {sorted(moved.values())} · 남김 {len(kept)} · 로직 참조 {n_ref} 곳"
|
||||
)
|
||||
|
||||
estimate(wage)
|
||||
@@ -366,56 +363,27 @@ def estimate(wage: dict) -> None:
|
||||
until = "2026.1.1"
|
||||
n = 0
|
||||
for r in wage["줄"]:
|
||||
if r.get("상태") != "미공표":
|
||||
if r.get("상태") not in ("미공표", "산정"):
|
||||
continue
|
||||
vals = hist[r["열쇠"]]
|
||||
last = max(i for i, v in enumerate(vals) if v != "-" and dates[i] != "2026.9.1")
|
||||
at, base = dates[last], num(vals[last])
|
||||
value, used, start = Decimal(base), [], at
|
||||
value, start = Decimal(base), at
|
||||
while start != until:
|
||||
a, b, kinds, top, bottom = next(s for s in steps if s[0] == start)
|
||||
_, b, _, top, bottom = next(s for s in steps if s[0] == start)
|
||||
value = value * top / bottom
|
||||
used.append(f"{top:,}/{bottom:,}({kinds}종)")
|
||||
start = b
|
||||
value = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
want = shown[r["열쇠"]]
|
||||
assert str(value) == want[4].replace(",", ""), (r["열쇠"], value, want[4])
|
||||
note = []
|
||||
if want[5] != want[6]:
|
||||
note.append(
|
||||
f"다산소프트(ESTX) {want[5]} — 2020.1(123종) → 2020.9 를 127종 평균으로 이음 · 우리는 원문 회차 평균 그대로(123종끼리)"
|
||||
)
|
||||
after = vals[dates.index("2026.9.1")]
|
||||
if after != "-":
|
||||
note.append(f"2026.9.1 공표 {after} 있음(뒤 시기 · 판 밖)")
|
||||
r["산정"] = {
|
||||
"값": value,
|
||||
"근거": f"원문 4-라 · {at} 공표 {base:,} × 전체직종 평균 증가율 " + " × ".join(used),
|
||||
"마지막공표": at,
|
||||
**({"비고": " · ".join(note)} if note else {}),
|
||||
}
|
||||
r["값"] = float(value)
|
||||
r["상태"] = "산정"
|
||||
r["비고"] = f"4-라 산정: {at.rsplit('.', 1)[0]} {base:,}원 × 증가율"
|
||||
n += 1
|
||||
notes = wage["비고"]
|
||||
notes[0] = notes[0].replace("(값은 관리자 판단 — 마스터에 짓지 않음)", "(산정 칸)")
|
||||
notes[:] = [x for x in notes if not x.startswith(("산정 =", "2026-09-01"))]
|
||||
notes.append(
|
||||
"산정 = 미공표 직종의 원문 4-라 산정값(원 미만 처리 안 함 · 소수 둘째 자리) — 엔진 차례 값 → 준용(관리자가 골랐으면) → 통합 → 산정 · 호표 출처에 「산정: …」"
|
||||
)
|
||||
notes.append(
|
||||
"2026-09-01 적용분 확보됨(옛공표 md `2026-09-01/`) — 마스터 판은 2026-01-01 그대로"
|
||||
)
|
||||
head = {k: v for k, v in wage.items() if k != "줄"}
|
||||
dump_master(MD / "인력_건설노임.json", head, [_plain(r) for r in wage["줄"]])
|
||||
dump_master(MD / "인력_건설노임.json", head, wage["줄"])
|
||||
print(f"건설노임 미공표 산정 {n} 줄 · 미공표직종_추적.md 5절 값과 같음")
|
||||
|
||||
|
||||
def _plain(r: dict) -> dict:
|
||||
"""Decimal → JSON 수(소수 둘째 자리 그대로)."""
|
||||
out = dict(r)
|
||||
if "산정" in out:
|
||||
out["산정"] = {k: float(v) if isinstance(v, Decimal) else v for k, v in out["산정"].items()}
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -70,7 +70,25 @@ ROW_KEYS = {
|
||||
"품셈재료": ("키", "원문번호", "이름", "규격", "단위", "요구절", "연결"),
|
||||
}
|
||||
# 한 테이블 파일 — 줄의 갈래 칸 · 머리의 갈래 묶음
|
||||
MERGED = {"인력": "조사", "기계": "세부분류"}
|
||||
MERGED = {"인력": "구분", "기계": "세부분류"}
|
||||
# 인력 줄 — 모든 줄이 같은 열 한 벌 · 상태 값
|
||||
LABOR_COLS = (
|
||||
"키",
|
||||
"원문번호",
|
||||
"구분",
|
||||
"상세구분",
|
||||
"이름",
|
||||
"규격",
|
||||
"단위",
|
||||
"값",
|
||||
"출처",
|
||||
"일시간",
|
||||
"상태",
|
||||
"옛이름",
|
||||
"비고",
|
||||
"준용",
|
||||
)
|
||||
LABOR_STATUS = ("공표", "산정", "미공표", "미확보")
|
||||
|
||||
|
||||
def load(names: list[str] | None = None, folder: Path = MASTER) -> dict[str, dict]:
|
||||
@@ -168,6 +186,8 @@ def check_form(name: str, data: dict) -> list[str]:
|
||||
out.append(f"{where} · 칸 「{need}」 없음")
|
||||
if "출처" in row and not _source_ok(row["출처"]):
|
||||
out.append(f"{where} · 출처 모양 「{row['출처']}」")
|
||||
if data.get("그룹") == "인력":
|
||||
out += _check_labor(where, row)
|
||||
if kind == "품셈재료":
|
||||
out += _check_linked(where, row)
|
||||
if kind == "요소" and isinstance(row.get("값"), dict):
|
||||
@@ -181,6 +201,18 @@ def check_form(name: str, data: dict) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _check_labor(where: str, row: dict) -> list[str]:
|
||||
"""인력 줄 — 열 한 벌(차례까지 같음) · 상태 값 · 옛이름 목록."""
|
||||
out = []
|
||||
if tuple(row) != LABOR_COLS:
|
||||
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(LABOR_COLS)}」")
|
||||
if row.get("상태") not in LABOR_STATUS:
|
||||
out.append(f"{where} · 상태 「{row.get('상태')}」 — {' · '.join(LABOR_STATUS)} 아님")
|
||||
if not isinstance(row.get("옛이름"), list):
|
||||
out.append(f"{where} · 옛이름 목록 아님 「{row.get('옛이름')}」")
|
||||
return out
|
||||
|
||||
|
||||
def _check_linked(where: str, row: dict) -> list[str]:
|
||||
"""품셈재료 — 값 칸 없음 · 요구절은 출처 모양 · 연결 = 시중물가 키 · 후보 조건 · null."""
|
||||
out = [f"{where} · 값 칸을 두지 않음"] if "값" in row else []
|
||||
@@ -200,7 +232,7 @@ def _check_linked(where: str, row: dict) -> list[str]:
|
||||
|
||||
|
||||
def check_links(whole: mf.Master) -> list[str]:
|
||||
"""끊긴 키 — 품셈재료 연결 · 시중물가 조달 연결 · 인력 준용·후보가 가리키는 줄."""
|
||||
"""끊긴 키 — 품셈재료 연결 · 시중물가 조달 연결 · 인력 준용이 가리키는 줄."""
|
||||
out = []
|
||||
for key, row in whole.index.get("MP", {}).items():
|
||||
link = row.get("연결")
|
||||
@@ -211,9 +243,6 @@ def check_links(whole: mf.Master) -> list[str]:
|
||||
ref = row.get(col)
|
||||
if ref not in (None, "") and str(ref) not in whole.index.get("LB", {}):
|
||||
out.append(f"{whole.label(key)} · {col} 「{ref}」 없는 인력 키")
|
||||
for ref in mk.KEY.findall(str(row.get("후보") or "")):
|
||||
if ref not in whole.index.get("LB", {}):
|
||||
out.append(f"{whole.label(key)} · 후보 「{ref}」 없는 인력 키")
|
||||
nara = whole.index.get("MN", {})
|
||||
for key, row in whole.index.get("MT", {}).items():
|
||||
source = str(row["값"]["조달"].get("출처") or "")
|
||||
|
||||
@@ -124,7 +124,7 @@ def labor(estx: Estx, files: dict):
|
||||
ours = {}
|
||||
for survey, tag in (("건설업", "건설"), ("제조업", "제조")):
|
||||
for r in files["인력.json"]["줄"]:
|
||||
if r["조사"] == survey:
|
||||
if r["구분"] == survey:
|
||||
ours.setdefault((tag, norm(r["이름"])), r)
|
||||
theirs = {}
|
||||
for r in rows:
|
||||
|
||||
@@ -260,18 +260,13 @@ class Master:
|
||||
return f"{row.get('키')} {row.get('이름') or row.get('원문번호')}"
|
||||
|
||||
def value(self, ref: str):
|
||||
"""요소 값 · 출처 글 — 값이 비면 준용(관리자가 고른 키) → 산정(원문 4-라) 차례."""
|
||||
"""요소 값 · 출처 글 — 준용(관리자가 고른 키)이 있으면 그 요소 값 · 없으면 제 값."""
|
||||
row = self.get(ref)
|
||||
if row.get("값") is not None:
|
||||
return row.get("값"), None
|
||||
alias = row.get("준용")
|
||||
if alias not in (None, ""):
|
||||
target = self.get(str(alias))
|
||||
return target.get("값"), f"준용: {target.get('이름') or alias}"
|
||||
guess = row.get("산정")
|
||||
if isinstance(guess, dict) and guess.get("값") is not None:
|
||||
return guess["값"], f"산정: 원문 4-라 · 마지막 공표 {guess.get('마지막공표')}"
|
||||
return None, None
|
||||
return row.get("값"), None
|
||||
|
||||
def logic(self, ref: str) -> dict:
|
||||
if mk.GROUP_OF.get(mk.id_of(ref)) != "로직":
|
||||
|
||||
@@ -115,10 +115,10 @@ LABOR = {
|
||||
"건설사업관리노임": "건설사업관리",
|
||||
"SW노임": "SW",
|
||||
"산림노임": "산림",
|
||||
"준용대상": "준용대상",
|
||||
"준용대상": "미확보",
|
||||
}
|
||||
MERGED = {
|
||||
"인력": ("인력.json", "조사", LABOR),
|
||||
"인력": ("인력.json", "구분", LABOR),
|
||||
"기계": ("기계.json", "세부분류", {"건설품셈": "건설품셈", "산림품셈": "산림품셈"}),
|
||||
}
|
||||
DIVISIONS = ("공통", "토목", "건축", "기계설비", "유지관리")
|
||||
|
||||
+501
-466
File diff suppressed because it is too large
Load Diff
@@ -288,11 +288,26 @@ def test_고르기_나라장터_시중물가_직종(client: TestClient) -> None:
|
||||
"LB:기술사(건설)"
|
||||
)
|
||||
assert (
|
||||
not _get(client, "/api/m01/pick", kind="job", q="중급기술자(기계")["items"][0].get("조사")
|
||||
== "준용대상"
|
||||
not _get(client, "/api/m01/pick", kind="job", q="중급기술자(기계")["items"][0].get("구분")
|
||||
== "미확보"
|
||||
)
|
||||
|
||||
|
||||
def test_구분_목록(client: TestClient) -> None:
|
||||
"""구분 목록 API — 파일 머리에 등록된 갈래 그대로(화면이 목록을 안 품음)."""
|
||||
got = _get(client, "/api/m01/subs", file=LABOR)
|
||||
names = [s["name"] for s in got["subs"]]
|
||||
assert got["slot"] == "구분" and names[0] == "건설업" and names[-1] == "미확보"
|
||||
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 _get(client, "/api/m01/subs", file="재료_시중물가.json")["subs"] == []
|
||||
|
||||
|
||||
def test_못_이은_줄만_거름(client: TestClient) -> None:
|
||||
every = _get(client, "/api/m01/rows", file="재료_품셈재료.json", size=500)
|
||||
cut = _get(client, "/api/m01/rows", file="재료_품셈재료.json", size=500, unlinked=1)
|
||||
@@ -309,7 +324,10 @@ def test_품셈재료_단가는_연결의_낮은_값과_출처(client: TestClien
|
||||
def test_하위_거름과_참조_이름(client: TestClient) -> None:
|
||||
every = _get(client, "/api/m01/rows", file=LABOR, size=500)
|
||||
survey = _get(client, "/api/m01/rows", file=LABOR, size=500, sub="측량")
|
||||
assert 0 < survey["total"] < every["total"] and {r["조사"] for r in survey["rows"]} == {"측량"}
|
||||
assert 0 < survey["total"] < every["total"] and {r["구분"] for r in survey["rows"]} == {"측량"}
|
||||
field = _get(client, "/api/m01/rows", file=LABOR, size=500, sub="측량", detail="기능계 도화")
|
||||
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"]} == {"산림품셈"}
|
||||
linked = _get(client, "/api/m01/rows", file="재료_품셈재료.json", size=500)
|
||||
|
||||
@@ -52,7 +52,7 @@ def whole():
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def faked():
|
||||
"""준용대상 인력 값이 빈 칸 — 시험용 가짜 값(파일엔 안 씀)."""
|
||||
"""값 없는 인력 줄(미확보·미공표) — 시험용 가짜 값(파일엔 안 씀)."""
|
||||
files = cm.load()
|
||||
for row in files["인력.json"]["줄"]:
|
||||
if row["값"] is None:
|
||||
@@ -601,7 +601,7 @@ def test_범위규칙_이상이하():
|
||||
|
||||
|
||||
def test_미공표_준용():
|
||||
"""값 없는 직종 — 차례 값 → 준용 → 산정 · 호표 출처에 「준용: 이름」·「산정: …」 · 다 없으면 멈춤."""
|
||||
"""준용이 있으면 그 직종 값 · 없으면 제 값(미공표 직종은 4-라 산정값) · 둘 다 비면 멈춤."""
|
||||
files = cm.load()
|
||||
lines = [
|
||||
{
|
||||
@@ -615,20 +615,17 @@ def test_미공표_준용():
|
||||
files["_시험"] = {"그룹": "로직", "원문": "자체", "줄": [{"키": "GX999999", "호표": lines}]}
|
||||
row = mf.Master(files).get("LB:1032")
|
||||
common = mf.Master(files).get("LB:1002")["키"] # 보통인부
|
||||
assert row["값"] is None and "준용" in row and row["산정"]["값"] == Decimal("211645.83")
|
||||
assert row["상태"] == "산정" and row["값"] == Decimal("211645.83")
|
||||
assert row["비고"].startswith("4-라 산정: 2025.9 210,772원")
|
||||
got = mf.run(mf.Master(files), "GX999999", {})
|
||||
assert got["줄"][0]["출처"] == "산정: 원문 4-라 · 마지막 공표 2025.9.1"
|
||||
guess = row.pop("산정")
|
||||
with pytest.raises(mf.FormulaError):
|
||||
mf.run(mf.Master(files), "GX999999", {})
|
||||
row["산정"] = guess
|
||||
row["준용"] = common # 산정보다 앞
|
||||
assert "출처" not in got["줄"][0] and got["노무비"] == 2 * Decimal("211645.83")
|
||||
row["준용"] = common # 값이 있어도 준용이 앞섬
|
||||
got = mf.run(mf.Master(files), "GX999999", {})
|
||||
assert got["줄"][0]["출처"] == "준용: 보통인부"
|
||||
assert got["노무비"] == 2 * Decimal(172068)
|
||||
lines[0]["요소"] = "LB:기술사" # 공표 노임에 없는 직종(준용대상) — 같은 방식
|
||||
lines[0]["요소"] = "LB:기술사" # 공표 노임에 없는 직종(미확보) — 준용을 골라야 값이 섬
|
||||
alias = mf.Master(files).get("LB:기술사")
|
||||
assert alias["값"] is None and alias["준용"] is None and alias["조사"] == "준용대상"
|
||||
assert alias["값"] is None and alias["준용"] is None and alias["구분"] == "미확보"
|
||||
with pytest.raises(mf.FormulaError):
|
||||
mf.run(mf.Master(files), "GX999999", {})
|
||||
alias["준용"] = common
|
||||
@@ -640,10 +637,11 @@ def test_미공표_준용():
|
||||
|
||||
|
||||
def test_노임_갈래_파일_원문_대조():
|
||||
"""인력 한 테이블 — 조사별 줄 수 · 이름 표본 · 옛이름 옮김(준용대상 → 현행 줄)."""
|
||||
"""인력 한 테이블 — 구분별 줄 수 · 이름 표본 · 열 한 벌 · 상세구분 · 일시간."""
|
||||
files = cm.load()
|
||||
rows = files["인력.json"]["줄"]
|
||||
count = {b: sum(r["조사"] == b for r in rows) for b in files["인력.json"]["조사"]}
|
||||
data = files["인력.json"]
|
||||
rows = data["줄"]
|
||||
count = {b: sum(r["구분"] == b for r in rows) for b in data["구분"]}
|
||||
assert count == {
|
||||
"건설업": 132,
|
||||
"제조업": 129,
|
||||
@@ -652,15 +650,22 @@ def test_노임_갈래_파일_원문_대조():
|
||||
"건설사업관리": 4,
|
||||
"SW": 17,
|
||||
"산림": 2,
|
||||
"준용대상": 32,
|
||||
"미확보": 32,
|
||||
}
|
||||
assert {tuple(r) for r in rows} == {cm.LABOR_COLS}
|
||||
assert {r["상태"] for r in rows} == set(cm.LABOR_STATUS)
|
||||
whole = mf.Master(files)
|
||||
assert whole.value("LB:기술사(건설)")[0] == 467217
|
||||
assert whole.value("LB:측량중급기술자")[0] == 251596
|
||||
assert whole.value("LB:특급건설사업관리기술인")[0] == 399247
|
||||
assert whole.value("LB:IT PM")[0] == 492039
|
||||
assert whole.value("LB:나무의사")[0] == 326359
|
||||
assert whole.get("LB:측량중급기술자")["조사"] == "측량"
|
||||
assert whole.get("LB:측량중급기술자")["구분"] == "측량"
|
||||
assert whole.get("LB:기술사(건설)")["상세구분"] == "건설"
|
||||
assert whole.get("LB:도화중급기능사")["상세구분"] == "기능계 도화"
|
||||
assert data["구분"]["측량"]["상세구분"][0] == "기술계"
|
||||
assert whole.get("LB:1020")["일시간"] == 6 # 잠수부(원문 4-가)
|
||||
assert {r["일시간"] for r in rows} == {6, 8}
|
||||
|
||||
|
||||
def test_품셈재료_연결_낮은_값과_출처(whole):
|
||||
|
||||
Reference in New Issue
Block a user