diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts index df3e436e..9ce08993 100644 --- a/M01_MasterData/M01_MasterData_Api_Fetch.ts +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -112,13 +112,19 @@ export const fetchRows = ( unlinked = false, sub = "", detail = "", + state = "", ): Promise => - get("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail }); + get("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail, state }); /** 한 테이블 파일은 `file` · 장 그룹(소요량 · 계수 · 로직)은 `group` 으로 부름. */ +export const fetchSubsAll = (where: { + file?: string; + group?: string; +}): Promise<{ subs: SubInfo[]; states?: string[] }> => + get("/subs", { file: where.file ?? "", group: where.group ?? "" }); + export const fetchSubs = async (where: { file?: string; group?: string }): Promise => - (await get<{ subs: SubInfo[] }>("/subs", { file: where.file ?? "", group: where.group ?? "" })) - .subs; + (await fetchSubsAll(where)).subs; export const fetchTables = ( group: string, diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 7880fab9..86e59128 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -81,8 +81,9 @@ def get_rows( unlinked: bool = False, sub: str = "", detail: str = "", + state: str = "", ) -> dict: - return _call(store.rows, file, page, size, q, unlinked, sub, detail) + return _call(store.rows, file, page, size, q, unlinked, sub, detail, state) @router.get("/tables") diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 3df73036..eacfe9f7 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -126,6 +126,9 @@ def refs_of(rows_: list[dict]) -> dict[str, str]: return out +_STATES = ("공표", "산정", "미공표", "미확보", "추정") # 인력 「상태」 거름 차례 + + def subs(file: str = "", group: str = "") -> dict: """하위 거름 목록 — 한 테이블 파일은 머리 묶음(인력 「구분」 · 기계 「세부분류」) · 장 그룹(소요량 · 계수 · 로직)은 장 파일에서 모음(구분 = 원문 + 부문 · 상세구분 = 장 · 차례대로).""" @@ -144,12 +147,26 @@ def subs(file: str = "", group: str = "") -> dict: return {"file": "", "version": "", "slot": "구분", "subs": list(found.values())} data, version = read(file) slot = next((s for s in ("구분", "세부분류") if isinstance(data.get(s), dict)), "") + lines = data.get(items_key(data)) or [] + has_state = any("상태" in r for r in lines) + + def details_of(name: str, head: dict) -> list: + """머리에 상세구분이 없으면 줄에서 모음(재료_유가전력 — 지역 · 계약종별).""" + if head.get("상세구분"): + return head["상세구분"] + return list( + dict.fromkeys( + r["상세구분"] for r in lines if r.get("구분") == name and r.get("상세구분") + ) + ) + return { "file": file, "version": version, "slot": slot, + "states": list(_STATES) if has_state else [], "subs": [ - {"name": name, "book": head.get("원문"), "details": head.get("상세구분") or []} + {"name": name, "book": head.get("원문"), "details": details_of(name, head)} for name, head in (data.get(slot) or {}).items() ], } @@ -163,6 +180,7 @@ def rows( unlinked: bool = False, sub: str = "", detail: str = "", + state: str = "", ) -> dict: data, version = read(file) hits = [r for r in data.get(items_key(data)) or [] if _hit(r, q)] @@ -170,6 +188,8 @@ def rows( 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 state: # 인력 상태 + hits = [r for r in hits if r.get("상태") == state] if unlinked: # 품셈재료 — 아직 못 이은 줄 hits = [r for r in hits if not r.get("연결")] page, size = max(page, 1), min(max(size, 1), 500) @@ -452,6 +472,17 @@ def _chapter_cols(file: str, data: dict) -> dict: } +def _follow(row: dict, items: list[dict], file: str) -> dict: + """인력 줄의 준용 = 그 직종의 값을 다시 읽어 값 칸에 채움(화면이 보낸 값을 믿지 않음).""" + ref = row.get("준용") + if file != "인력.json" or not isinstance(ref, str) or not _REF_KEY.match(ref): + return row + src = next((r for r in items if r.get("키") == ref), None) + if src is None or src.get("값") is None: + return row + return {**row, "값": src["값"]} + + def _apply(data: dict, changes: list[dict], file: str, book: dict) -> None: """고침 · 더함(키는 대장의 다음 번호 · 원문번호 없으면 빈 글) · 지움 — 키는 바꾸지 않음.""" items = data.setdefault(items_key(data), []) @@ -466,6 +497,8 @@ def _apply(data: dict, changes: list[dict], file: str, book: dict) -> None: raise StoreError(404, f"{file} · 없는 키 「{key}」") if op != "delete" and not isinstance(row, dict): raise StoreError(400, f"{file} · row 없음") + if op != "delete": + row = _follow(row, items, file) if op == "add": number = str(row.get("원문번호") or "") cols = _chapter_cols(file, data) diff --git a/M01_MasterData/M01_MasterData_UI_Pick.ts b/M01_MasterData/M01_MasterData_UI_Pick.ts index d41767e3..c3934e04 100644 --- a/M01_MasterData/M01_MasterData_UI_Pick.ts +++ b/M01_MasterData/M01_MasterData_UI_Pick.ts @@ -23,7 +23,7 @@ export interface PickOptions { /** 처음 찾을 글 — 그 재료 이름·규격 (없는 결과면 첫 낱말만으로 다시). */ seed: string; /** 고르면 ref · 연결 끊기 = null. */ - onPick: (ref: string | null, name?: string) => void; + onPick: (ref: string | null, name?: string, value?: unknown) => void; /** 있으면 「후보 조건으로 연결」 칸이 뜸. */ cond?: { 이름: string; 규격: string; onCond: (c: Cond) => void }; } @@ -93,7 +93,7 @@ export function openPickModal(opt: PickOptions): void { ], }); b.addEventListener("click", () => { - opt.onPick(it.ref, `${it.이름} ${it.규격}`.trim()); + opt.onPick(it.ref, `${it.이름} ${it.규격}`.trim(), it.값); close(); }); return b; diff --git a/M01_MasterData/M01_MasterData_UI_Rows.ts b/M01_MasterData/M01_MasterData_UI_Rows.ts index 858a6fcf..439f8f64 100644 --- a/M01_MasterData/M01_MasterData_UI_Rows.ts +++ b/M01_MasterData/M01_MasterData_UI_Rows.ts @@ -90,7 +90,7 @@ const remember = (ref: string | null, name?: string): void => { /** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void { - const { sub, detail } = pick; + const { sub, detail, state } = pick; const file = pick.file.file; let page = 1; let data: RowsPage | null = null; @@ -233,7 +233,7 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void const load = async (): Promise => { try { - data = await fetchRows(file, page, SIZE, q, unlinked, sub, detail); + data = await fetchRows(file, page, SIZE, q, unlinked, sub, detail, state); for (const [key, name] of Object.entries(data.refs ?? {})) names.set(key, name); paint(); } catch (error) { @@ -295,9 +295,10 @@ function wageCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement { title: `${L("M01_JobTitle")} · ${cur["이름"] ?? ""}`, current: text, seed: "", - onPick: (ref, name) => { + onPick: (ref, name, value) => { remember(ref, name); - put(withCell(cur, "준용", ref)); + // 준용을 고르면 값도 그 직종 값으로 · 비우면 원래 값 (저장 때 서버가 다시 읽음) + put(withCell(withCell(cur, "준용", ref), "값", ref ? value : row["값"])); }, }), ); diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index b3db4eba..ebf58894 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -8,7 +8,7 @@ import { createSelectField, 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, fetchSubs, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch"; +import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch"; import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree"; /** 요소 화면에서 열 것 — 파일 · 하위 거름(구분·세부분류) · 상세구분 · 제목 */ @@ -17,6 +17,8 @@ export interface Pick { file: FileInfo; sub: string; detail: string; + /** 인력 상태 거름 · 비면 전체 */ + state?: string; label: string; } @@ -39,13 +41,19 @@ export interface FilterArgs { subs: SubInfo[]; total: number; subLabel: string; + /** 「전체」 줄 글 · 없으면 「전체」 */ + title?: string; /** 없으면 한 단 */ detailLabel?: string; - onPick: (sub: string, detail: string, label: string) => void; + /** 있으면 「상태」 드롭다운 한 단 더 — 목록은 서버가 줌 */ + states?: string[]; + stateLabel?: string; + onPick: (sub: string, detail: string, label: string, state: string) => void; } const MATERIAL_NAMES: Record = { 유가전력: "유가·전력" }; /** 재료 컨테이너 차례 — 여기 없는 파일은 뒤에 */ +const FUEL = "유가전력"; const MATERIAL_ORDER = ["자재품목", "유가전력", "품셈재료"]; /** 파일 이름 → 목록 글자 — 「소요량_건설품셈_10장_창호…」 → 「건설품셈_10장_창호…」 */ @@ -72,6 +80,7 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si const buttons = new Map(); // 하위 거름은 서버가 준 목록(파일 머리에 등록된 구분·세부분류) — 새 조사가 늘어도 화면은 그대로 const kinds = new Map(); + const states = new Map(); const setActive = (id: string | null): void => { for (const [key, button] of buttons) button.classList.toggle("is-active", key === id); @@ -136,10 +145,19 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si disabled: !pickSub?.details.length, }) : null; + const state = a.states?.length + ? createSelectField({ + options: opts(a.states), + value: a.states.includes(saved[2]) ? saved[2] : "", + compact: true, + label: a.stateLabel ?? "", + }) + : null; const run = (): void => { const d = detail?.select.value ?? ""; + const s = state?.select.value ?? ""; try { - sessionStorage.setItem(a.store, JSON.stringify([sub.select.value, d])); + sessionStorage.setItem(a.store, JSON.stringify([sub.select.value, d, s])); } catch { /* 기억 못 해도 거름은 됨 */ } @@ -147,7 +165,8 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si a.onPick( sub.select.value, d, - [sub.select.value, d].filter(Boolean).join(" · ") || L("M01_All"), + [sub.select.value, d, s].filter(Boolean).join(" · ") || L("M01_All"), + s, ); }; sub.select.addEventListener("change", () => { @@ -159,16 +178,23 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si run(); }); detail?.select.addEventListener("change", run); - const all = make({ id: a.id, label: L("M01_All"), count: a.total, run }, "", 0, () => { - sub.select.value = ""; - sub.select.dispatchEvent(new Event("change")); - }); + state?.select.addEventListener("change", run); + const all = make( + { id: a.id, label: a.title ?? L("M01_All"), count: a.total, run }, + "", + 0, + () => { + if (state) state.select.value = ""; + sub.select.value = ""; + sub.select.dispatchEvent(new Event("change")); + }, + ); return el("div", { children: [ all, el("div", { className: "m01-side__filter", - children: [sub.root, ...(detail ? [detail.root] : [])], + children: [sub.root, ...(detail ? [detail.root] : []), ...(state ? [state.root] : [])], }), ], }); @@ -183,8 +209,8 @@ 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, detail = ""): void => - onOpen({ group, file, sub, detail, label }); + const open = (file: FileInfo, sub: string, label: string, detail = "", state = ""): void => + onOpen({ group, file, sub, detail, state, label }); const one = files[0]; const dropped: Partial> = { 인력: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") }, @@ -202,36 +228,59 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si total: files.reduce((n, f) => n + f.rows, 0), subLabel: drop.label, detailLabel: drop.detail, - onPick: (sub, detail, label) => open(one, sub, label, detail), + states: states.get(group), + stateLabel: L("M01_LaborState"), + onPick: (sub, detail, label, state) => open(one, sub, label, detail, state), }), ); return; } body.replaceChildren( - ...renderTree( - [...files] - .sort((a, b) => order(a.file) - order(b.file)) - .map((f) => { - const name = fileLabel(f.file); - const label = group === "재료" ? (MATERIAL_NAMES[name] ?? name) : name; - return { - id: `${group}\n${f.file}`, - label, - count: f.rows, - run: () => open(f, "", label), - }; - }), - make, - ), + ...[...files] + .sort((a, b) => order(a.file) - order(b.file)) + .flatMap((f) => { + const name = fileLabel(f.file); + const label = group === "재료" ? (MATERIAL_NAMES[name] ?? name) : name; + if (group === "재료" && name === FUEL) + return [ + filter({ + id: `${group}\n${f.file}`, + store: `m01.filter.${group}.${name}`, + subs: kinds.get(group) ?? [], + total: f.rows, + title: label, + subLabel: L("M01_FuelSub"), + detailLabel: L("M01_FuelDetail"), + onPick: (sub, detail, tail) => + open(f, sub, sub || detail ? `${label} · ${tail}` : label, detail), + }), + ]; + return renderTree( + [ + { + id: `${group}\n${f.file}`, + label, + count: f.rows, + run: () => open(f, "", label), + }, + ], + make, + ); + }), ); }; const refresh = async (group: string): Promise => { const list = await fetchFiles(group); if (list[0] && (group === "인력" || group === "기계")) { - kinds.set(group, await fetchSubs({ file: list[0].file })); + const got = await fetchSubsAll({ file: list[0].file }); + kinds.set(group, got.subs); + states.set(group as Group, got.states ?? []); + } else if (group === "재료") { + const fuel = list.find((f) => fileLabel(f.file) === FUEL); + if (fuel) kinds.set(group, (await fetchSubsAll({ file: fuel.file })).subs); } else if (group === "소요량" || group === "계수") { - kinds.set(group, await fetchSubs({ group })); + kinds.set(group, (await fetchSubsAll({ group })).subs); } lists.set(group as Group, list); drawGroup(group as Group); diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index 44d953bc..e41f8511 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -389,3 +389,41 @@ def test_자재품목_저장은_띄어쓰기를_안_바꿈(client: TestClient) - assert diff[0][1].replace('"물가자료": 1,', '"물가자료": X,').count("{ ") == diff[0][0].count( "{ " ) + + +def test_준용_저장은_직종_값을_다시_읽고_상태_거름(client: TestClient) -> None: + subs = _get(client, "/api/m01/subs", file=LABOR) + assert subs["states"] == ["공표", "산정", "미공표", "미확보", "추정"] + guess = _get(client, "/api/m01/rows", file=LABOR, size=500, state="추정") + assert 0 < guess["total"] < 30 and {r["상태"] for r in guess["rows"]} == {"추정"} + row = next(r for r in guess["rows"] if r["이름"] == "제관공") + other, version = _labor_row(client, "1016") # 화약취급공 + edited = {**row, "준용": other["키"], "값": 1} # 화면이 보낸 값은 믿지 않음 + body = { + "files": [ + { + "file": LABOR, + "version": guess["version"], + "changes": [{"op": "edit", "key": row["키"], "row": edited}], + } + ] + } + assert client.post("/api/m01/save", json=body).status_code == 200 + saved = next(r for r in _get(client, "/api/m01/rows", file=LABOR, q=row["키"])["rows"]) + assert saved["준용"] == other["키"] and saved["값"] == other["값"] + + +def test_유가전력_구분_상세구분_거름(client: TestClient) -> None: + subs = _get(client, "/api/m01/subs", file="재료_유가전력.json")["subs"] + assert {s["name"] for s in subs} == {"유류", "전력"} + power = next(s for s in subs if s["name"] == "전력") + assert "주택용 저압" in power["details"] + hit = _get( + client, + "/api/m01/rows", + file="재료_유가전력.json", + size=500, + sub="전력", + detail="주택용 저압", + ) + assert hit["total"] > 0 and {r["상세구분"] for r in hit["rows"]} == {"주택용 저압"} diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index c00c325e..33c82a98 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -55,6 +55,9 @@ export const ui_locales_m1 = { M01_All: ["전체", "All"], M01_LaborSub: ["구분", "Category"], M01_LaborDetail: ["상세구분", "Detail"], + M01_FuelSub: ["구분", "Category"], + M01_FuelDetail: ["상세구분", "Detail"], + M01_LaborState: ["상태", "Status"], M01_MachineSub: ["세부분류", "Sub-category"], M01_GroupLabor: ["인력", "Labor"], M01_GroupMaterial: ["재료", "Materials"],