feat(M01): 조달 찾기 확정 모양 · 품셈재료 가격 연결 · 건설노임 준용 직종 고르기

- 고르기 API GET /api/m01/pick (나라장터자재 · 시중물가 · 공표 직종) — 조달 찾기 옛 API 대체
- 시중물가 조달›출처 「나라장터:<열쇠>」 연결 · 끊기 뒤 변화 없음 · 조달 값·기준 칸
- 품셈재료 「가격 연결」 모달(이름·규격 미리 찾기 · 관급 표시 · 후보 조건 · 끊기) · 못 이은 줄만 보기
- 건설노임 미공표 줄 「준용」 직종 고르기

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
2026-09-19 23:53:28 +09:00
co-authored by Claude Sonnet 5
parent 796b0f8efc
commit d2a6646fc4
10 changed files with 367 additions and 135 deletions
+15 -5
View File
@@ -83,8 +83,13 @@ export const fetchGroups = async (): Promise<GroupInfo[]> =>
export const fetchFiles = async (group: string): Promise<FileInfo[]> =>
(await get<{ files: FileInfo[] }>(`/groups/${encodeURIComponent(group)}/files`, {})).files;
export const fetchRows = (file: string, page: number, size: number, q: string): Promise<RowsPage> =>
get<RowsPage>("/rows", { file, page, size, q });
export const fetchRows = (
file: string,
page: number,
size: number,
q: string,
unlinked = false,
): Promise<RowsPage> => get<RowsPage>("/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<SaveResult> {
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 });
+5 -5
View File
@@ -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")
+43 -13
View File
@@ -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)]}
+10
View File
@@ -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<unknown>((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(" ~ ");
+133
View File
@@ -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<void> => {
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();
}
@@ -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<void> => {
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();
}
+129 -21
View File
@@ -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<string, string[]> = {
[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<void> => {
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;
}
@@ -261,3 +261,9 @@
justify-content: flex-end;
gap: 8px;
}
.m01-procure__cond {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
+14 -5
View File
@@ -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:
+12
View File
@@ -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;