From 4ca903ea054609d323dc79292cacad4580225262 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 20 Sep 2026 22:41:35 +0900 Subject: [PATCH] =?UTF-8?q?wip=20feat(M01):=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=C2=B7=EC=83=81=EC=84=B8=EA=B5=AC=EB=B6=84=20?= =?UTF-8?q?2=EB=8B=A8=20=EA=B1=B0=EB=A6=84=20=C2=B7=20=EC=86=8C=EC=9A=94?= =?UTF-8?q?=EB=9F=89/=EA=B3=84=EC=88=98=20=EC=9A=A9=EB=8F=84=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=C2=B7=EB=8C=80=EC=83=81=20=EA=B1=B0=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기계.json 머리 구분 딕셔너리 추가(세부분류 유지) · UI_Side 기계 2단 필터 Store/Router/Api_Fetch usage 쿼리 추가 · UI_Tables 용도 표시 + 대상 드롭다운 locale 문구 추가 · 관련 시험 수정·추가 --- M01_MasterData/M01_MasterData_Api_Fetch.ts | 10 +++- M01_MasterData/M01_MasterData_Router.py | 3 +- M01_MasterData/M01_MasterData_Store.py | 12 ++-- M01_MasterData/M01_MasterData_UI_Side.ts | 5 +- M01_MasterData/M01_MasterData_UI_Style.css | 3 +- M01_MasterData/M01_MasterData_UI_Tables.ts | 67 +++++++++++++++++++--- resources/master_data/기계.json | 46 +++++++++++++++ resources/tester/test_m01_api.py | 27 +++++++-- ui_template/ui_template_locale_m1.ts | 6 +- 9 files changed, 157 insertions(+), 22 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts index 9ce08993..54870e2c 100644 --- a/M01_MasterData/M01_MasterData_Api_Fetch.ts +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -41,6 +41,13 @@ export interface RowsPage { refs: Record; } +/** 소요량·계수 표의 용도 — 공종(원문 절 이름) · 대상(인력 · 기계 · 재료 · 할증 · 환산 · 작업량 조건) · 이 표를 쓰는 로직 키. */ +export interface TableUsage { + 공종: string; + 대상: string[]; + 로직키: string[]; +} + export interface TableHead { file: string; 키: string; @@ -133,7 +140,8 @@ export const fetchTables = ( q: string, page: number, size: number, -): Promise => get("/tables", { group, sub, detail, q, page, size }); + usage = "", +): Promise => get("/tables", { group, sub, detail, q, page, size, usage }); export const fetchTable = ( file: string, diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 0c9c1a5a..e14d61ff 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -95,8 +95,9 @@ def get_tables( q: str = "", page: int = 1, size: int = 30, + usage: str = "", ) -> dict: - return _call(store.tables, file, group, sub, detail, q, page, size) + return _call(store.tables, file, group, sub, detail, q, page, size, usage) @router.get("/table") diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 636774de..5232f968 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -130,7 +130,7 @@ _STATES = ("공표", "산정", "미공표", "미확보", "추정") # 인력 「 def subs(file: str = "", group: str = "") -> dict: - """하위 거름 목록 — 한 테이블 파일은 머리 묶음(인력 「구분」 · 기계 「세부분류」) · + """하위 거름 목록 — 한 테이블 파일은 머리 묶음(인력 · 기계 모두 「구분」 → 「상세구분」) · 장 그룹(소요량 · 계수 · 로직)은 장 파일에서 모음(구분 = 원문 + 부문 · 상세구분 = 장 · 차례대로).""" if group: if group not in cm.mf.GROUPS: @@ -184,9 +184,9 @@ def rows( ) -> dict: data, version = read(file) hits = [r for r in data.get(items_key(data)) or [] if _hit(r, q)] - if sub: # 인력 구분 · 기계 세부분류 + if sub: # 인력·기계 구분 (세부분류는 남은 옛 화면 호출 대비) hits = [r for r in hits if sub in (r.get("구분"), r.get("세부분류"))] - if detail: # 인력 상세구분 + if detail: # 인력 상세구분 · 기계 상세구분(기종) hits = [r for r in hits if r.get("상세구분") == detail] if state: # 인력 상태 hits = [r for r in hits if r.get("상태") == state] @@ -227,8 +227,10 @@ def tables( q: str = "", page: int = 1, size: int = 30, + usage: str = "", ) -> dict: - """표 목록 — 파일 하나 또는 그룹 전체(구분 · 상세구분 · 찾기로 거름 · 장 차례대로 · 쪽 나눔).""" + """표 목록 — 파일 하나 또는 그룹 전체(구분 · 상세구분 · 용도 대상 · 찾기로 거름 · 장 차례대로 · 쪽 나눔). + `usage` = 용도.대상(인력 · 기계 · 재료 · 할증 · 환산 · 작업량 조건) 하나.""" if file: names = [file] elif group in cm.mf.TABLE_GROUPS: @@ -245,6 +247,8 @@ def tables( continue if detail and t.get("상세구분") != detail: continue + if usage and usage not in ((t.get("용도") or {}).get("대상") or []): + continue if _hit(t, q): hits.append( { diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index f3361854..68d7e46e 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -11,10 +11,11 @@ import { t as L } from "@ui/ui_template_locale"; import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch"; import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree"; -/** 컨테이너마다 기억하는 화면 상태 — 찾기 글 · 쪽 (거름은 `m01.filter.*`) */ +/** 컨테이너마다 기억하는 화면 상태 — 찾기 글 · 쪽 · 소요량·계수 표의 용도 대상 거름 (거름은 `m01.filter.*`) */ export interface View { q: string; page: number; + usage?: string; } const viewKey = (group: string): string => `m01.view.${group}`; @@ -262,7 +263,7 @@ export function buildSide( const one = files[0]; const dropped: Partial> = { 인력: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") }, - 기계: { label: L("M01_MachineSub") }, + 기계: { label: L("M01_MachineSub"), detail: L("M01_MachineDetail") }, 소요량: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") }, 계수: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") }, }; diff --git a/M01_MasterData/M01_MasterData_UI_Style.css b/M01_MasterData/M01_MasterData_UI_Style.css index 1a518c44..53dd4bc5 100644 --- a/M01_MasterData/M01_MasterData_UI_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Style.css @@ -101,7 +101,8 @@ } .m01-master__head, -.m01-master__bar { +.m01-master__bar, +.m01-master__filter { display: flex; flex-wrap: wrap; align-items: center; diff --git a/M01_MasterData/M01_MasterData_UI_Tables.ts b/M01_MasterData/M01_MasterData_UI_Tables.ts index 394899a4..300af822 100644 --- a/M01_MasterData/M01_MasterData_UI_Tables.ts +++ b/M01_MasterData/M01_MasterData_UI_Tables.ts @@ -4,9 +4,15 @@ * → 표 하나(조건 칸 + 값 칸의 긴 줄) · 눌러 고침(노랑) · 줄 더하기·지우기 * ========================================================================== */ -import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; -import { fetchTable, fetchTables, type Row, type TableHead } from "./M01_MasterData_Api_Fetch"; +import { + fetchTable, + fetchTables, + type Row, + type TableHead, + type TableUsage, +} from "./M01_MasterData_Api_Fetch"; import { saveView, type Pick } from "./M01_MasterData_UI_Side"; import { onDraftChange, @@ -27,16 +33,31 @@ import { const LIST_SIZE = 30; const LIST_COLS = ["키", "원문번호", "구분", "상세구분", "이름", "기준", "출처", "비고"] as const; +/** 용도 대상 거름 드롭다운 후보 — PLAN 1-1 셋째 줄 그대로. */ +const USAGE_TARGETS = ["인력", "기계", "재료", "할증", "환산", "작업량 조건"]; /** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */ export function renderTables(host: HTMLElement, pick: Pick, q: string): () => void { let stop = (): void => {}; + let usage = pick.view?.usage ?? ""; const list = (page = pick.view?.page ?? 1): void => { stop(); stop = (): void => {}; - void showList(host, pick, q, page, (file, key) => { - stop = openTable(host, file, key, () => list(page)); - }); + void showList( + host, + pick, + q, + usage, + page, + (file, key) => { + stop = openTable(host, file, key, () => list(page)); + }, + (next) => { + usage = next; + saveView(pick.group, { usage, page: 1 }); + list(1); + }, + ); }; list(); const off = onDraftChange(() => host.querySelector(".m01-master__tables") && markChanged(host)); @@ -58,17 +79,19 @@ async function showList( host: HTMLElement, pick: Pick, q: string, + usage: string, page: number, open: (file: string, key: string) => void, + onUsageChange: (usage: string) => void, ): Promise { let got: { total: number; tables: TableHead[] }; try { - got = await fetchTables(pick.group, pick.sub, pick.detail, q, page, LIST_SIZE); + got = await fetchTables(pick.group, pick.sub, pick.detail, q, page, LIST_SIZE, usage); } catch (error) { showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); return; } - saveView(pick.group, { page }); + saveView(pick.group, { page, usage }); const rows = got.tables.map((t) => { const tr = el("tr", { className: "m01-master__table-row", @@ -81,8 +104,24 @@ async function showList( tr.addEventListener("click", () => open(t.file, t.키)); return tr; }); + const usageField = createSelectField({ + options: [ + { value: "", text: L("M01_All") }, + ...USAGE_TARGETS.map((v) => ({ value: v, text: v })), + ], + value: usage, + compact: true, + label: L("M01_UsageTarget"), + }); + usageField.select.addEventListener("change", () => onUsageChange(usageField.select.value)); host.replaceChildren( - buildPager(got.total, LIST_SIZE, page, (p) => void showList(host, pick, q, p, open)), + el("div", { className: "m01-master__filter", children: [usageField.root] }), + buildPager( + got.total, + LIST_SIZE, + page, + (p) => void showList(host, pick, q, usage, p, open, onUsageChange), + ), el("div", { className: "m01-master__grid-wrap m01-master__tables", children: [ @@ -169,6 +208,7 @@ function openTable(host: HTMLElement, file: string, key: string, back: () => voi const notes = ((table.주 ?? []) as string[]).map((n) => el("p", { className: "m01-master__muted", text: n }), ); + const usage = table.용도 as TableUsage | undefined; host.replaceChildren( el("div", { className: "m01-master__bar", @@ -179,6 +219,17 @@ function openTable(host: HTMLElement, file: string, key: string, back: () => voi className: "m01-master__muted", text: `${key} · ${table.기준 || "—"} · ${table.출처 || ""}`, }), + ...(usage + ? [ + el("span", { + className: "m01-master__muted", + attrs: { title: usage.로직키.join(", ") }, + text: `${L("M01_TableUsage")}: ${usage.공종} · ${usage.대상.join(", ")} · ${L( + "M01_TableUsageLogicCount", + ).replace("{value}", String(usage.로직키.length))}`, + }), + ] + : []), add, ], }), diff --git a/resources/master_data/기계.json b/resources/master_data/기계.json index e477ad64..effb970c 100644 --- a/resources/master_data/기계.json +++ b/resources/master_data/기계.json @@ -815,6 +815,52 @@ } } }, + "구분": { + "[00]토공기계": { + "원문": "건설품셈", + "상세구분": [] + }, + "[10]다짐기계": { + "원문": "건설품셈", + "상세구분": [] + }, + "[20]운반 및 하역기계": { + "원문": "건설품셈", + "상세구분": [] + }, + "[30]포장기계": { + "원문": "건설품셈", + "상세구분": [] + }, + "[40]콘크리트기계": { + "원문": "건설품셈", + "상세구분": [] + }, + "[50]골재생산기계 등": { + "원문": "건설품셈", + "상세구분": [] + }, + "[60]기초공사용 기계": { + "원문": "건설품셈", + "상세구분": [] + }, + "[70]기타기계": { + "원문": "건설품셈", + "상세구분": [] + }, + "[80]스마트 건설장비": { + "원문": "건설품셈", + "상세구분": [] + }, + "[90]해상장비": { + "원문": "건설품셈", + "상세구분": [] + }, + "임업기계": { + "원문": "산림품셈", + "상세구분": [] + } + }, "줄": [ { "키": "EQ000001", diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index 80411fd9..25f235da 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -113,6 +113,25 @@ def test_표_목록은_구분_상세구분으로_거름(client: TestClient) -> N assert page["page"] == 2 and len(page["tables"]) == 5 and page["total"] == every["total"] +def test_표_용도_대상으로_거름(client: TestClient) -> None: + """PLAN 1-1 셋째 줄 — 표마다 붙은 용도(공종·대상·로직키)로 거름.""" + one = _get(client, "/api/m01/tables", group="소요량", size=1)["tables"][0] + usage = _get(client, "/api/m01/table", file=one["file"], key=one["키"])["table"]["용도"] + assert usage["공종"] and usage["대상"] + target = usage["대상"][0] + narrowed = _get( + client, "/api/m01/tables", group="소요량", q=one["이름"], usage=target, size=500 + ) + assert one["키"] in {t["키"] for t in narrowed["tables"]} + ALL_TARGETS = ("인력", "기계", "재료", "할증", "환산", "작업량 조건") + other = next((v for v in ALL_TARGETS if v not in usage["대상"]), None) + if other: + excluded = _get( + client, "/api/m01/tables", group="소요량", q=one["이름"], usage=other, size=500 + ) + assert one["키"] not in {t["키"] for t in excluded["tables"]} + + def test_갈래_목록은_서버가_줌(client: TestClient) -> None: """소요량 · 계수 · 로직 컨테이너의 구분 · 상세구분 — 장 차례대로.""" for group in ("소요량", "계수", "로직"): @@ -332,10 +351,10 @@ 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"]] == [ - "건설품셈", - "산림품셈", - ] + names = [s["name"] for s in machine["subs"]] + assert machine["slot"] == "구분" and names[0] == "[00]토공기계" and names[-1] == "임업기계" + earthwork = next(s for s in machine["subs"] if s["name"] == "[00]토공기계") + assert earthwork["book"] == "건설품셈" and "불도저" in earthwork["details"] assert _get(client, "/api/m01/subs", file="재료_자재품목.json")["subs"] == [] diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index 98907892..9cbb1b7c 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -58,7 +58,11 @@ export const ui_locales_m1 = { M01_FuelSub: ["구분", "Category"], M01_FuelDetail: ["상세구분", "Detail"], M01_LaborState: ["상태", "Status"], - M01_MachineSub: ["세부분류", "Sub-category"], + M01_MachineSub: ["구분", "Category"], + M01_MachineDetail: ["상세구분", "Detail"], + M01_UsageTarget: ["대상", "Target"], + M01_TableUsage: ["용도", "Usage"], + M01_TableUsageLogicCount: ["로직 {value}개", "{value} logic rules"], M01_GroupLabor: ["인력", "Labor"], M01_GroupMaterial: ["재료", "Materials"], M01_GroupMachine: ["기계", "Machines"],