feat(M01): 준용 고르면 값 따라옴 · 인력 상태 거름 · 유가전력 구분·상세구분 거름

- 인력 준용 고르기 — 값 칸이 고른 직종 값으로 바뀌어 노랑 · 비우면 원래 값
- 저장 때 서버가 준용 직종 값을 다시 읽어 값 칸에 채움(화면 값 불신)
- 인력 왼쪽 패널 「상태」 드롭다운(목록은 서버) · 재료 유가전력 2단 거름
- 시험 2개 더함

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
2026-09-20 20:03:59 +09:00
co-authored by Claude Sonnet 5
parent 572fb8e5ee
commit 4b1eeec524
8 changed files with 171 additions and 40 deletions
+9 -3
View File
@@ -112,13 +112,19 @@ export const fetchRows = (
unlinked = false,
sub = "",
detail = "",
state = "",
): Promise<RowsPage> =>
get<RowsPage>("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail });
get<RowsPage>("/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<SubInfo[]> =>
(await get<{ subs: SubInfo[] }>("/subs", { file: where.file ?? "", group: where.group ?? "" }))
.subs;
(await fetchSubsAll(where)).subs;
export const fetchTables = (
group: string,
+2 -1
View File
@@ -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")
+34 -1
View File
@@ -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)
+2 -2
View File
@@ -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;
+5 -4
View File
@@ -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<void> => {
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["값"]));
},
}),
);
+78 -29
View File
@@ -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<string, string> = { : "유가·전력" };
/** 재료 컨테이너 차례 — 여기 없는 파일은 뒤에 */
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<string, HTMLElement>();
// 하위 거름은 서버가 준 목록(파일 머리에 등록된 구분·세부분류) — 새 조사가 늘어도 화면은 그대로
const kinds = new Map<Group, SubInfo[]>();
const states = new Map<Group, string[]>();
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<Record<Group, { label: string; detail?: string }>> = {
: { 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<FileInfo[]> => {
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);
+38
View File
@@ -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"]} == {"주택용 저압"}
+3
View File
@@ -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"],