diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts index 89a2e6b9..07de6a1f 100644 --- a/M01_MasterData/M01_MasterData_Api_Fetch.ts +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -83,8 +83,13 @@ export const fetchGroups = async (): Promise => export const fetchFiles = async (group: string): Promise => (await get<{ files: FileInfo[] }>(`/groups/${encodeURIComponent(group)}/files`, {})).files; -export const fetchRows = (file: string, page: number, size: number, q: string): Promise => - get("/rows", { file, page, size, q }); +export const fetchRows = ( + file: string, + page: number, + size: number, + q: string, + unlinked = false, +): Promise => get("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0 }); export const fetchTables = ( file: string, @@ -108,13 +113,18 @@ export async function saveFiles(files: FileChanges[]): Promise { return { status, detail: typeof detail === "string" ? detail : JSON.stringify(detail) }; } -export interface ProcureItem { +export type PickKind = "procure" | "price" | "job"; + +export interface PickItem { ref: string; 이름: string; 규격: string; 단위: string; 값: unknown; + 관급: boolean; } -export const fetchProcurement = (q: string): Promise<{ total: number; items: ProcureItem[] }> => - get("/procurement", { q, limit: 50 }); +export const fetchPick = ( + kind: PickKind, + q: string, +): Promise<{ total: number; items: PickItem[] }> => get("/pick", { kind, q, limit: 50 }); diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 9089047d..8c211378 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -69,8 +69,8 @@ def get_files(group: str) -> dict: @router.get("/rows") -def get_rows(file: str, page: int = 1, size: int = 50, q: str = "") -> dict: - return _call(store.rows, file, page, size, q) +def get_rows(file: str, page: int = 1, size: int = 50, q: str = "", unlinked: bool = False) -> dict: + return _call(store.rows, file, page, size, q, unlinked) @router.get("/tables") @@ -98,9 +98,9 @@ def get_elements(group: str, q: str = "", limit: int = 50) -> dict: return _call(store.elements, group, q, limit) -@router.get("/procurement") -def get_procurement(q: str = "", limit: int = 50) -> dict: - return _call(store.procurement, q, limit) +@router.get("/pick") +def get_pick(kind: str, q: str = "", limit: int = 50) -> dict: + return _call(store.pick, kind, q, limit) @router.post("/calc") diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 88112604..c40430b2 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -92,9 +92,11 @@ def files_of(group: str) -> list[dict]: return out -def rows(file: str, page: int, size: int, q: str) -> dict: +def rows(file: str, page: int, size: int, q: str, unlinked: bool = False) -> dict: data, version = read(file) hits = [r for r in data.get(items_key(data)) or [] if _hit(r, q)] + if unlinked: # 품셈재료 — 아직 못 이은 줄 + hits = [r for r in hits if not r.get("연결")] page, size = max(page, 1), min(max(size, 1), 500) return { "file": file, @@ -227,19 +229,47 @@ def elements(group: str, q: str, limit: int) -> dict: return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]} -def procurement(q: str, limit: int) -> dict: - """조달 찾기 창 — 나라장터자재 + 시중물가 안의 조달 줄에서 이름·규격 찾기 · `ref` = 「나라장터:<열쇠>」.""" - words = q.lower().split() +_PICK = { + "procure": "재료_나라장터자재.json", + "price": "재료_시중물가.json", + "job": "인력_건설노임.json", +} + + +def _squash(text) -> str: + return "".join(str(text or "").lower().split()) + + +def _lowest(value) -> object: + """시중물가 줄 — 시중 다섯 칸 · 조달 값 가운데 낮은 값 · 그 밖은 `값` 그대로.""" + if not isinstance(value, dict): + return value + nums = [v for v in value["시중"].values() if isinstance(v, (int, float))] + supply = value["조달"].get("값") + return min(nums + ([supply] if supply is not None else []), default=None) + + +def pick(kind: str, q: str, limit: int) -> dict: + """고르기 창 — `procure` 나라장터자재(ref = 나라장터:<열쇠>) · `price` 시중물가(관급 조달 줄 포함 · ref = 열쇠) + · `job` 값 있는 건설노임 직종(ref = 열쇠) · 이름·규격 낱말이 모두 들어간 줄.""" + if kind not in _PICK: + raise StoreError(404, f"없는 고르기 「{kind}」") + words = [_squash(w) for w in q.split()] hits = [] - for book, only_procured in (("나라장터자재", False), ("시중물가", True)): - data, _ = read(f"재료_{book}.json") - for row in data.get("줄") or []: - if only_procured and "조달" not in row: - continue - text = f"{row.get('이름', '')} {row.get('규격', '')}".lower() - if all(w in text for w in words): - brief = {k: row.get(k) for k in ("이름", "규격", "단위", "값")} - hits.append({"ref": f"{book}:{row.get('열쇠')}", **brief}) + for row in read(_PICK[kind])[0].get("줄") or []: + if kind == "job" and row.get("값") is None: + continue + text = _squash(f"{row.get('이름')} {row.get('규격')}") + if all(w in text for w in words): + key = str(row.get("열쇠")) + hits.append( + { + "ref": f"나라장터:{key}" if kind == "procure" else key, + **{k: row.get(k) for k in ("이름", "규격", "단위")}, + "값": _lowest(row.get("값")), + "관급": kind == "price" and row["값"]["조달"].get("값") is not None, + } + ) return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]} diff --git a/M01_MasterData/M01_MasterData_UI_Cells.ts b/M01_MasterData/M01_MasterData_UI_Cells.ts index f1e4441b..567359d2 100644 --- a/M01_MasterData/M01_MasterData_UI_Cells.ts +++ b/M01_MasterData/M01_MasterData_UI_Cells.ts @@ -33,6 +33,16 @@ export function withCell(row: Row, path: string, value: unknown): Row { return { ...row, [head]: { ...(row[head] as Row), [sub]: value } }; } +/** 줄 안 깊은 자리 읽기 · 넣은 새 줄(중간 묶음 없으면 만듦). */ +export const deep = (row: Row, path: string[]): unknown => + path.reduce((o, k) => (o as Row | null | undefined)?.[k], row); + +export function withDeep(row: Row, path: string[], value: unknown): Row { + const [head, ...rest] = path; + if (!rest.length) return { ...row, [head]: value }; + return { ...row, [head]: withDeep((row[head] ?? {}) as Row, rest, value) }; +} + export function show(v: unknown): string { if (v === null || v === undefined) return ""; if (Array.isArray(v)) return v.map((x) => (x === null ? "" : String(x))).join(" ~ "); diff --git a/M01_MasterData/M01_MasterData_UI_Pick.ts b/M01_MasterData/M01_MasterData_UI_Pick.ts new file mode 100644 index 00000000..e749af58 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Pick.ts @@ -0,0 +1,133 @@ +/* ============================================================================= + * M01_MasterData_UI_Pick.ts + * 고르기 모달 — 조달 자료(나라장터) · 가격 연결(시중물가) · 준용 직종을 이름·규격으로 찾아 고르면 ref 를 돌려줌 + * ========================================================================== */ + +import { el, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { fetchPick, type PickItem, type PickKind } from "./M01_MasterData_Api_Fetch"; +import { show } from "./M01_MasterData_UI_Cells"; + +/** 지역이 갈리는 재료 — 이름·규격만 적어 두고 지역은 계산 때 입력. */ +export interface Cond { + 이름: string; + 규격: string; + 지역: "입력"; +} + +export interface PickOptions { + kind: PickKind; + title: string; + /** 지금 연결 글 — 비면 「연결 끊기」 꺼짐. */ + current: string; + /** 처음 찾을 글 — 그 재료 이름·규격 (없는 결과면 첫 낱말만으로 다시). */ + seed: string; + /** 고르면 ref · 연결 끊기 = null. */ + onPick: (ref: string | null) => void; + /** 있으면 「후보 조건으로 연결」 칸이 뜸. */ + cond?: { 이름: string; 규격: string; onCond: (c: Cond) => void }; +} + +const button = (text: string): HTMLButtonElement => + el("button", { className: "m01-master__row-btn", text, attrs: { type: "button" } }); + +const input = (value: string, placeholder: string): HTMLInputElement => { + const box = el("input", { + className: "m01-master__search", + attrs: { type: "search", placeholder }, + }); + box.value = value; + return box; +}; + +export function openPickModal(opt: PickOptions): void { + const close = (): void => back.remove(); + const search = input(opt.seed, L("M01_ProcureSearch")); + const list = el("div", { className: "m01-procure__list" }); + const cut = button(L("M01_ProcureCut")); + cut.disabled = !opt.current; + cut.addEventListener("click", () => { + opt.onPick(null); + close(); + }); + const shut = button(L("M01_ProcureClose")); + shut.addEventListener("click", close); + + const condRow: HTMLElement[] = []; + if (opt.cond) { + const { onCond } = opt.cond; + const name = input(opt.cond.이름, L("M01_PriceCondName")); + const spec = input(opt.cond.규격, L("M01_PriceCondSpec")); + const go = button(L("M01_PriceCond")); + go.addEventListener("click", () => { + if (!name.value.trim()) return; + onCond({ 이름: name.value.trim(), 규격: spec.value.trim(), 지역: "입력" }); + close(); + }); + condRow.push(el("div", { className: "m01-procure__cond", children: [name, spec, go] })); + } + + const box = el("div", { + className: "m01-procure__box", + children: [ + el("h3", { text: opt.title }), + el("p", { className: "m01-master__muted", text: opt.current }), + search, + list, + ...condRow, + el("div", { className: "m01-procure__foot", children: [cut, shut] }), + ], + }); + const back = el("div", { className: "m01-procure", children: [box] }); + back.addEventListener("click", (e) => { + if (e.target === back) close(); + }); + document.body.append(back); + + const paint = (items: PickItem[], total: number): void => { + list.replaceChildren( + ...(items.length + ? items.map((it) => { + const tag = it.관급 ? ` · ${L("M01_Govt")}` : ""; + const b = el("button", { + className: "m01-procure__item", + attrs: { type: "button" }, + children: [ + el("strong", { text: it.이름 }), + el("span", { text: `${it.규격} · ${it.단위} · ${show(it.값)}${tag}` }), + el("span", { className: "m01-master__muted", text: it.ref }), + ], + }); + b.addEventListener("click", () => { + opt.onPick(it.ref); + close(); + }); + return b; + }) + : [el("p", { className: "m01-master__empty", text: L("M01_NoRows") })]), + ...(total > items.length + ? [el("p", { className: "m01-master__muted", text: `${items.length} / ${total}` })] + : []), + ); + }; + let timer: number | undefined; + const run = async (fallback = false): Promise => { + try { + const q = search.value.trim(); + const r = await fetchPick(opt.kind, q); + if (!r.total && fallback && q.includes(" ")) { + search.value = q.split(/\s+/)[0]; + return run(); + } + paint(r.items, r.total); + } catch (error) { + showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); + } + }; + search.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void run(), 300); + }); + void run(true); + search.focus(); +} diff --git a/M01_MasterData/M01_MasterData_UI_Procure.ts b/M01_MasterData/M01_MasterData_UI_Procure.ts deleted file mode 100644 index 79212869..00000000 --- a/M01_MasterData/M01_MasterData_UI_Procure.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_Procure.ts - * 재료 › 시중물가 「조달 찾기」 모달 — 조달 자료를 이름·규격으로 찾아 고르면 ref 를 돌려줌 - * ========================================================================== */ - -import { el, showToast } from "@ui/ui_template_elements"; -import { t as L } from "@ui/ui_template_locale"; -import { fetchProcurement, type ProcureItem } from "./M01_MasterData_Api_Fetch"; -import { show } from "./M01_MasterData_UI_Cells"; - -const button = (text: string): HTMLButtonElement => - el("button", { className: "m01-master__row-btn", text, attrs: { type: "button" } }); - -/** 고르면 `onPick(ref)` · 연결 끊기 = `onPick(null)`. */ -export function openProcureModal(current: string, onPick: (ref: string | null) => void): void { - const close = (): void => back.remove(); - const input = el("input", { - className: "m01-master__search", - attrs: { type: "search", placeholder: L("M01_ProcureSearch") }, - }); - const list = el("div", { className: "m01-procure__list" }); - const cut = button(L("M01_ProcureCut")); - cut.disabled = !current; - cut.addEventListener("click", () => { - onPick(null); - close(); - }); - const shut = button(L("M01_ProcureClose")); - shut.addEventListener("click", close); - const box = el("div", { - className: "m01-procure__box", - children: [ - el("h3", { text: L("M01_ProcureTitle") }), - el("p", { className: "m01-master__muted", text: current }), - input, - list, - el("div", { className: "m01-procure__foot", children: [cut, shut] }), - ], - }); - const back = el("div", { className: "m01-procure", children: [box] }); - back.addEventListener("click", (e) => { - if (e.target === back) close(); - }); - document.body.append(back); - - const paint = (items: ProcureItem[], total: number): void => { - list.replaceChildren( - ...(items.length - ? items.map((it) => { - const b = el("button", { - className: "m01-procure__item", - attrs: { type: "button" }, - children: [ - el("strong", { text: it.이름 }), - el("span", { text: `${it.규격} · ${it.단위} · ${show(it.값)}` }), - el("span", { className: "m01-master__muted", text: it.ref }), - ], - }); - b.addEventListener("click", () => { - onPick(it.ref); - close(); - }); - return b; - }) - : [el("p", { className: "m01-master__empty", text: L("M01_NoRows") })]), - ...(total > items.length - ? [el("p", { className: "m01-master__muted", text: `${items.length} / ${total}` })] - : []), - ); - }; - let timer: number | undefined; - const run = async (): Promise => { - try { - const r = await fetchProcurement(input.value.trim()); - paint(r.items, r.total); - } catch (error) { - showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); - } - }; - input.addEventListener("input", () => { - window.clearTimeout(timer); - timer = window.setTimeout(() => void run(), 300); - }); - void run(); - input.focus(); -} diff --git a/M01_MasterData/M01_MasterData_UI_Rows.ts b/M01_MasterData/M01_MasterData_UI_Rows.ts index 1bcbb129..00f8df2b 100644 --- a/M01_MasterData/M01_MasterData_UI_Rows.ts +++ b/M01_MasterData/M01_MasterData_UI_Rows.ts @@ -5,7 +5,7 @@ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; -import { openProcureModal } from "./M01_MasterData_UI_Procure"; +import { openPickModal } from "./M01_MasterData_UI_Pick"; import { fetchRows, type Row, type RowsPage } from "./M01_MasterData_Api_Fetch"; import { addRow, @@ -21,16 +21,22 @@ import { buildCell, buildPager, coerce, + deep, flatten, isScalar, SEP, show, withCell, + withDeep, } from "./M01_MasterData_UI_Cells"; const SIZE = 50; const MARKET = "재료_시중물가.json"; -const LINK = `조달${SEP}연결`; +const LINKED = "재료_품셈재료.json"; +const WAGE = "인력_건설노임.json"; +const SUPPLY = ["값", "조달"]; +const SOURCE = [...SUPPLY, "출처"]; +const NARA = "나라장터:"; const FALLBACK: Row = { 열쇠: "", 이름: "", 규격: "", 단위: "", 값: null, 출처: "" }; /** 새 줄 바탕 — 본 줄과 같은 칸 · 글 칸은 "" · 수·값 칸은 null. */ @@ -43,10 +49,29 @@ function blank(sample: Row): Row { return out; } +/** 요소별 전용 칸으로 그리는 칸 — 일반 칸에서 뺌. */ +function hidden(file: string, col: string): boolean { + if (file === MARKET) return col === `값${SEP}조달`; + if (file === LINKED) return col.startsWith("연결"); + return file === WAGE && col === "준용"; +} + +const HEADS: Record = { + [MARKET]: ["조달 값", "조달 기준", "M01_ProcureCol"], + [LINKED]: ["M01_PriceCol"], + [WAGE]: ["M01_JobCol"], +}; + +const linkText = (link: unknown): string => + link && typeof link === "object" + ? `${L("M01_PriceCondUsed")}: ${(link as Row)["이름"]} ${(link as Row)["규격"] ?? ""}`.trim() + : String(link ?? ""); + /** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */ export function renderRows(host: HTMLElement, file: string, q: string): () => void { let page = 1; let data: RowsPage | null = null; + let unlinked = false; const paint = (): void => { if (!data) return; @@ -55,14 +80,16 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo const adds = draft?.adds ?? []; const cols: string[] = []; for (const row of [...adds, ...d.rows]) { - for (const k of Object.keys(flatten(row))) if (k !== LINK && !cols.includes(k)) cols.push(k); + for (const k of Object.keys(flatten(row))) + if (!hidden(file, k) && !cols.includes(k)) cols.push(k); } - const market = file === MARKET; const head = el("tr", { children: [ el("th"), ...cols.map((c) => el("th", { text: c })), - ...(market ? [el("th", { text: L("M01_ProcureCol") })] : []), + ...(HEADS[file] ?? []).map((h) => + el("th", { text: h.startsWith("M01_") ? L(h as "M01_PriceCol") : h }), + ), ], }); const body = el("tbody"); @@ -107,23 +134,29 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo }), ); } - if (market) { - const link = String(now[LINK] ?? ""); - const pick = (ref: string | null): void => - editRow(file, d.version, key, row, withCell(cur, LINK, ref)); - const btn = el("button", { - className: "m01-master__row-btn", - text: L("M01_ProcureFind"), - attrs: { type: "button" }, - }); - btn.addEventListener("click", () => openProcureModal(link, pick)); - const td = buildCell(link, { changed: link !== String(orig[LINK] ?? "") }); - td.append(btn); - tr.append(td); - } + const put = deleted + ? undefined + : (next: Row): void => editRow(file, d.version, key, row, next); + if (file === MARKET) tr.append(...marketCells(row, cur, put)); + if (file === LINKED) tr.append(linkedCell(row, cur, put)); + if (file === WAGE) tr.append(wageCell(row, cur, put)); body.append(tr); } + const only = + file === LINKED + ? [ + createButton({ + label: L("M01_OnlyUnlinked"), + variant: unlinked ? "filled" : "ghost", + onClick: () => { + unlinked = !unlinked; + page = 1; + void load(); + }, + }), + ] + : []; const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" }); add.addEventListener("click", () => addRow(file, d.version, blank(d.rows[0] ?? FALLBACK))); const empty = @@ -133,7 +166,7 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo host.replaceChildren( el("div", { className: "m01-master__bar", - children: [add, buildPager(d.total, SIZE, d.page, go)], + children: [add, ...only, buildPager(d.total, SIZE, d.page, go)], }), el("div", { className: "m01-master__grid-wrap", @@ -155,7 +188,7 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo const load = async (): Promise => { try { - data = await fetchRows(file, page, SIZE, q); + data = await fetchRows(file, page, SIZE, q, unlinked); paint(); } catch (error) { showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); @@ -176,3 +209,78 @@ function actionCell(label: string, onClick: () => void): HTMLTableCellElement { button.addEventListener("click", onClick); return el("td", { children: [button] }); } + +type Put = (row: Row) => void; + +const rowBtn = (text: string): HTMLButtonElement => + el("button", { className: "m01-master__row-btn", text, attrs: { type: "button" } }); + +/** 시중물가 — 조달 값 · 기준 · 조달 연결(「나라장터:<열쇠>」 · 「가격정보」 줄은 원천이라 그대로). */ +function marketCells(row: Row, cur: Row, put?: Put): HTMLTableCellElement[] { + const link = show(deep(cur, SOURCE)); + const td = buildCell(link, { changed: link !== show(deep(row, SOURCE)) }); + if (put && (!link || link.startsWith(NARA))) { + const btn = rowBtn(L("M01_ProcureFind")); + btn.addEventListener("click", () => + openPickModal({ + kind: "procure", + title: L("M01_ProcureTitle"), + current: link, + seed: `${cur["이름"] ?? ""} ${cur["규격"] ?? ""}`.trim(), + onPick: (ref) => put(withDeep(cur, SOURCE, ref)), + }), + ); + td.append(btn); + } + return [ + buildCell(show(deep(cur, [...SUPPLY, "값"])), { changed: false }), + buildCell(show(deep(cur, [...SUPPLY, "기준"])), { changed: false }), + td, + ]; +} + +/** 품셈재료 — 가격 연결(시중물가 열쇠 · 후보 조건 · 없음) · 고르기 모달. */ +function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement { + const link = cur["연결"]; + const td = buildCell(linkText(link), { + changed: JSON.stringify(link ?? null) !== JSON.stringify(row["연결"] ?? null), + }); + if (put) { + const cond = link && typeof link === "object" ? (link as Row) : null; + const name = String(cond?.["이름"] ?? cur["이름"] ?? ""); + const spec = String(cond?.["규격"] ?? cur["규격"] ?? ""); + const btn = rowBtn(L("M01_PriceFind")); + btn.addEventListener("click", () => + openPickModal({ + kind: "price", + title: `${L("M01_PriceTitle")} · ${cur["이름"] ?? ""}`, + current: linkText(link), + seed: `${name} ${spec}`.trim(), + onPick: (ref) => put(withCell(cur, "연결", ref)), + cond: { 이름: name, 규격: spec, onCond: (c) => put(withCell(cur, "연결", c)) }, + }), + ); + td.append(btn); + } + return td; +} + +/** 건설노임 — 미공표(값 없음) 줄의 준용 = 공표 직종 고르기. */ +function wageCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement { + const text = show(cur["준용"]); + const td = buildCell(text, { changed: text !== show(row["준용"]) }); + if (put && row["값"] === null) { + const btn = rowBtn(L("M01_JobFind")); + btn.addEventListener("click", () => + openPickModal({ + kind: "job", + title: `${L("M01_JobTitle")} · ${cur["이름"] ?? ""}`, + current: text, + seed: "", + onPick: (ref) => put(withCell(cur, "준용", ref)), + }), + ); + td.append(btn); + } + return td; +} diff --git a/M01_MasterData/M01_MasterData_UI_Style.css b/M01_MasterData/M01_MasterData_UI_Style.css index e4e0296f..35cc3d74 100644 --- a/M01_MasterData/M01_MasterData_UI_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Style.css @@ -261,3 +261,9 @@ justify-content: flex-end; gap: 8px; } + +.m01-procure__cond { + display: flex; + flex-wrap: wrap; + gap: 8px; +} diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index b26fea54..4928ef5b 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -242,11 +242,20 @@ def test_main_은_시스템관리자만() -> None: assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main -def test_조달_찾기는_나라장터자재를_이름_규격으로_찾음(client: TestClient) -> None: - body = _get(client, "/api/m01/procurement", q="육각볼트 M6*20") - assert body["total"] >= 1 - item = body["items"][0] - assert item["ref"].startswith("나라장터자재:") and "육각볼트" in item["이름"] +def test_고르기_나라장터_시중물가_직종(client: TestClient) -> None: + nara = _get(client, "/api/m01/pick", kind="procure", q="육각볼트 M6*20") + assert nara["total"] >= 1 and nara["items"][0]["ref"].startswith("나라장터:") + market = _get(client, "/api/m01/pick", kind="price", q="H형강 관급", limit=5) + assert market["items"] and market["items"][0]["관급"] and market["items"][0]["값"] > 0 + job = _get(client, "/api/m01/pick", kind="job", q="보통인부") + assert job["items"][0]["값"] > 0 + assert all(i["값"] is not None for i in _get(client, "/api/m01/pick", kind="job")["items"]) + + +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) + assert 0 < cut["total"] < every["total"] and not any(r["연결"] for r in cut["rows"]) def test_품셈재료_단가는_연결의_낮은_값과_출처(client: TestClient) -> None: diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index b421a263..acaa66ea 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -43,4 +43,16 @@ export const ui_locales_m1 = { M01_ProcureCut: ["연결 끊기", "Unlink"], M01_ProcureClose: ["닫기", "Close"], M01_ProcureCol: ["조달 연결", "Procurement link"], + M01_PriceFind: ["가격 연결", "Link price"], + M01_PriceTitle: ["가격 연결 고르기", "Pick price source"], + M01_PriceCol: ["가격 연결", "Price link"], + M01_PriceCond: ["후보 조건으로 연결 (지역은 입력)", "Link by condition (region as input)"], + M01_PriceCondName: ["이름", "Name"], + M01_PriceCondSpec: ["규격", "Spec"], + M01_PriceCondUsed: ["후보 조건", "Condition"], + M01_Govt: ["관급", "Govt"], + M01_OnlyUnlinked: ["못 이은 줄만 보기", "Unlinked only"], + M01_JobFind: ["직종 고르기", "Pick job"], + M01_JobTitle: ["준용 직종 고르기", "Pick substitute job"], + M01_JobCol: ["준용", "Substitute"], } as const;