Merge remote-tracking branches 'origin/sub_desktop_1' and 'origin/sub_laptop_1' into sub_laptop_2
This commit is contained in:
@@ -127,10 +127,16 @@ def get_pick(kind: str, q: str = "", limit: int = 50) -> dict:
|
||||
|
||||
@router.get("/materials")
|
||||
def get_materials(
|
||||
sub: str, detail: str = "", spec: str = "", region: str = "", limit: int = 50
|
||||
sub: str,
|
||||
detail: str = "",
|
||||
spec: str = "",
|
||||
region: str = "",
|
||||
unit: str = "",
|
||||
limit: int = 50,
|
||||
) -> dict:
|
||||
"""재료 고르기 조건 안 후보 — sub = 구분 · detail = 상세구분 · spec = 규격 · region = 자재지역."""
|
||||
return _call(store.materials, sub, detail, spec, region, limit)
|
||||
"""재료 고르기 조건 안 후보 — sub = 구분 · detail = 상세구분 · spec = 규격 · region = 자재지역
|
||||
· unit = 호표 줄 단위(주면 그 단위 줄만)."""
|
||||
return _call(store.materials, sub, detail, spec, region, unit, limit)
|
||||
|
||||
|
||||
@router.post("/calc")
|
||||
|
||||
@@ -343,15 +343,19 @@ def logic(key: str) -> dict:
|
||||
def _brief(ref: str, row: dict) -> dict:
|
||||
return {
|
||||
"ref": ref,
|
||||
**{k: row.get(k) for k in ("원문번호", "이름", "규격", "단위", "값", "값칸")},
|
||||
**{
|
||||
k: row.get(k)
|
||||
for k in ("원문번호", "구분", "상세구분", "이름", "규격", "단위", "값", "값칸")
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _pick_brief(whole, cond: dict) -> dict | None:
|
||||
"""고르기 조건 줄의 단가 미리보기 — 후보 수와 시험 계산이 쓸 줄(값 열 다섯 포함)."""
|
||||
def _pick_brief(whole, cond: dict, unit: str | None = None) -> dict | None:
|
||||
"""고르기 조건 줄의 단가 미리보기 — 후보 수와 시험 계산이 쓸 줄(값 열 다섯 포함).
|
||||
`unit` = 호표 줄 단위 — 후보를 같은 단위 줄로만 거름."""
|
||||
try:
|
||||
keys = cm.mf.candidates(whole, cond, {})
|
||||
brief = _market_brief(whole.get(cm.mf.pick(whole, cond, {})))
|
||||
keys = cm.mf.candidates(whole, cond, {}, unit)
|
||||
brief = _market_brief(whole.get(cm.mf.pick(whole, cond, {}, unit)))
|
||||
except (cm.mf.FormulaError, KeyError, TypeError):
|
||||
return None
|
||||
return {**brief, "조건": cond, "후보": len(keys)}
|
||||
@@ -364,7 +368,7 @@ def _prices(whole, row: dict) -> dict:
|
||||
out = {}
|
||||
for item in row.get("호표") or []:
|
||||
if isinstance(item.get("요소"), dict):
|
||||
out[cm.mf.cond_text(item["요소"])] = _pick_brief(whole, item["요소"])
|
||||
out[cm.mf.cond_text(item["요소"])] = _pick_brief(whole, item["요소"], item.get("단위"))
|
||||
continue
|
||||
ref = str(item.get("요소", ""))
|
||||
if item.get("종류") != "로직" and "{" not in ref:
|
||||
@@ -447,14 +451,15 @@ def _market_brief(row: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def materials(sub: str, detail: str, spec: str, region: str, limit: int) -> dict:
|
||||
def materials(sub: str, detail: str, spec: str, region: str, unit: str, limit: int) -> dict:
|
||||
"""재료 고르기 조건 안 후보 목록 — 화면·테스트 컨테이너가 같이 씀.
|
||||
`unit` = 호표 줄 단위 — 주면 그 단위 줄만(단위가 다르면 다른 물건).
|
||||
`기본` = 시험 계산이 쓸 줄(대표 줄이 없을 때의 첫 줄) · 그 줄을 `items` 맨 앞에 둠."""
|
||||
whole = cm.master(folder=FOLDER)
|
||||
cond = {"구분": sub, "상세구분": detail or None, "규격": spec or None}
|
||||
env = {cm.mf.REGION: region} if region else {}
|
||||
keys = cm.mf.candidates(whole, cond, env)
|
||||
first = cm.mf.pick(whole, cond, env) if keys else None
|
||||
keys = cm.mf.candidates(whole, cond, env, unit or None)
|
||||
first = cm.mf.pick(whole, cond, env, unit or None) if keys else None
|
||||
keys = [first] + [k for k in keys if k != first] if first else keys
|
||||
return {
|
||||
"total": len(keys),
|
||||
|
||||
@@ -23,7 +23,15 @@ import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import { saveFiles } from "./M01_MasterData_Api_Fetch";
|
||||
import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft";
|
||||
import { renderRows } from "./M01_MasterData_UI_Rows";
|
||||
import { buildSide, fileLabel, loadView, saveView, type Pick } from "./M01_MasterData_UI_Side";
|
||||
import {
|
||||
buildSide,
|
||||
fileLabel,
|
||||
loadView,
|
||||
saveView,
|
||||
setPendingLogicKey,
|
||||
takePendingLogicKey,
|
||||
type Pick,
|
||||
} from "./M01_MasterData_UI_Side";
|
||||
import { renderTables } from "./M01_MasterData_UI_Tables";
|
||||
import "./M01_MasterData_UI_Style.css";
|
||||
|
||||
@@ -95,8 +103,9 @@ function buildPage(): HTMLElement {
|
||||
current = { ...pick, view: undefined };
|
||||
const groupTitle = pick.group === pick.label ? [pick.group] : [pick.group, pick.label];
|
||||
title.textContent = groupTitle.join(" · ");
|
||||
const view = TABLE_GROUPS.includes(pick.group) ? renderTables : renderRows;
|
||||
dispose = view(body, pick, query);
|
||||
dispose = TABLE_GROUPS.includes(pick.group)
|
||||
? renderTables(body, pick, query, openLogicTab)
|
||||
: renderRows(body, pick, query);
|
||||
showNotice(isStale(pick.file.file, pick.file.version) ? [L("M01_FileStale")] : []);
|
||||
};
|
||||
|
||||
@@ -165,18 +174,35 @@ function buildPage(): HTMLElement {
|
||||
return [L("M01_Stale").replace("{value}", stale.map(fileLabel).join(", ")), again];
|
||||
};
|
||||
|
||||
/** 로직 탭으로 전환 — `key` 가 있으면 들고 가서 로직 목록 찾기 칸에 넣으라고 안내
|
||||
* (로직 목록이 직접 그 키를 열지는 못함 — 로직 부분은 sub1 담당 파일). */
|
||||
const notifyLogicKey = (key: string): void => {
|
||||
navigator.clipboard?.writeText(key).catch(() => undefined);
|
||||
showToast(L("M01_LogicKeyCopy").replace("{value}", key), "success");
|
||||
};
|
||||
|
||||
const openLogicTab = (key?: string): void => {
|
||||
if (key) setPendingLogicKey(key);
|
||||
dispose();
|
||||
dispose = (): void => {};
|
||||
current = null;
|
||||
showMode("logic");
|
||||
if (logicMounted) {
|
||||
if (key) notifyLogicKey(key);
|
||||
return;
|
||||
}
|
||||
logicMounted = true;
|
||||
void import("./M01_MasterData_UI_Logic_Page").then((m) => {
|
||||
void m.mountM01Logic(logicHost, side);
|
||||
const pending = takePendingLogicKey();
|
||||
if (pending) notifyLogicKey(pending);
|
||||
});
|
||||
};
|
||||
|
||||
/* --- 좌측: 컨테이너 아홉 --- */
|
||||
const side = buildSide(
|
||||
openFile,
|
||||
() => {
|
||||
dispose();
|
||||
dispose = (): void => {};
|
||||
current = null;
|
||||
showMode("logic");
|
||||
if (logicMounted) return;
|
||||
logicMounted = true;
|
||||
void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(logicHost, side));
|
||||
},
|
||||
() => openLogicTab(),
|
||||
() => {
|
||||
dispose();
|
||||
dispose = (): void => {};
|
||||
|
||||
@@ -36,6 +36,27 @@ export function saveView(group: string, patch: Partial<View>): void {
|
||||
}
|
||||
}
|
||||
|
||||
const PENDING_LOGIC_KEY = "m01.logic.pending";
|
||||
|
||||
/** 소요량·계수 표의 「쓰는 로직」 키를 눌러 로직 탭으로 넘어갈 때 들고 갈 키 — 로직 목록 화면이 직접 열지는 못함(로직 부분은 sub1). */
|
||||
export function setPendingLogicKey(key: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(PENDING_LOGIC_KEY, key);
|
||||
} catch {
|
||||
/* 기억 못 해도 로직 탭 전환은 됨 */
|
||||
}
|
||||
}
|
||||
|
||||
export function takePendingLogicKey(): string | null {
|
||||
try {
|
||||
const key = sessionStorage.getItem(PENDING_LOGIC_KEY);
|
||||
sessionStorage.removeItem(PENDING_LOGIC_KEY);
|
||||
return key;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 요소 화면에서 열 것 — 파일 · 하위 거름(구분·세부분류) · 상세구분 · 제목 */
|
||||
export interface Pick {
|
||||
group: string;
|
||||
|
||||
@@ -230,6 +230,16 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m01-master__logic-key {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-accent);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 조달 찾기 모달 */
|
||||
.m01-procure {
|
||||
position: fixed;
|
||||
|
||||
@@ -36,8 +36,13 @@ const LIST_COLS = ["키", "원문번호", "구분", "상세구분", "이름", "
|
||||
/** 용도 대상 거름 드롭다운 후보 — PLAN 1-1 셋째 줄 그대로. */
|
||||
const USAGE_TARGETS = ["인력", "기계", "재료", "할증", "환산", "작업량 조건"];
|
||||
|
||||
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
|
||||
export function renderTables(host: HTMLElement, pick: Pick, q: string): () => void {
|
||||
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. `openLogic` = 용도의 로직키 눌렀을 때 로직 탭으로. */
|
||||
export function renderTables(
|
||||
host: HTMLElement,
|
||||
pick: Pick,
|
||||
q: string,
|
||||
openLogic: (key: string) => void,
|
||||
): () => void {
|
||||
let stop = (): void => {};
|
||||
let usage = pick.view?.usage ?? "";
|
||||
const list = (page = pick.view?.page ?? 1): void => {
|
||||
@@ -50,7 +55,7 @@ export function renderTables(host: HTMLElement, pick: Pick, q: string): () => vo
|
||||
usage,
|
||||
page,
|
||||
(file, key) => {
|
||||
stop = openTable(host, file, key, () => list(page));
|
||||
stop = openTable(host, file, key, () => list(page), openLogic);
|
||||
},
|
||||
(next) => {
|
||||
usage = next;
|
||||
@@ -140,7 +145,13 @@ async function showList(
|
||||
}
|
||||
|
||||
/** 표 하나. 돌려받은 함수 = 해제. */
|
||||
function openTable(host: HTMLElement, file: string, key: string, back: () => void): () => void {
|
||||
function openTable(
|
||||
host: HTMLElement,
|
||||
file: string,
|
||||
key: string,
|
||||
back: () => void,
|
||||
openLogic: (key: string) => void,
|
||||
): () => void {
|
||||
let off = (): void => {};
|
||||
void (async () => {
|
||||
let version: string;
|
||||
@@ -209,6 +220,15 @@ function openTable(host: HTMLElement, file: string, key: string, back: () => voi
|
||||
el("p", { className: "m01-master__muted", text: n }),
|
||||
);
|
||||
const usage = table.용도 as TableUsage | undefined;
|
||||
const logicLinks = (usage?.로직키 ?? []).map((k, i) => {
|
||||
const btn = el("button", {
|
||||
className: "m01-master__logic-key",
|
||||
text: k,
|
||||
attrs: { type: "button", title: L("M01_OpenLogic") },
|
||||
});
|
||||
btn.addEventListener("click", () => openLogic(k));
|
||||
return i === 0 ? btn : el("span", { text: ", ", children: [btn] });
|
||||
});
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-master__bar",
|
||||
@@ -223,10 +243,8 @@ function openTable(host: HTMLElement, file: string, key: string, back: () => voi
|
||||
? [
|
||||
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))}`,
|
||||
text: `${L("M01_TableUsage")}: ${usage.공종} · ${usage.대상.join(", ")} · `,
|
||||
children: logicLinks,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -14,18 +14,18 @@
|
||||
|
||||
## 2. 읽기
|
||||
|
||||
| 길 | 받음 | 줌 |
|
||||
| --------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET /groups` | | `{groups: [{group, files, rows}]}` — 그룹 차례는 `_틀.md` 2장 |
|
||||
| `GET /groups/{group}/files` | | `{files: [{file, book, chapter, edition, rows, version, order}]}` — 머리 `차례` 순 |
|
||||
| `GET /subs` | `file` 또는 `group` | `{file, version, slot, subs: [{name, book, details}]}` — 인력 `구분` · 기계 `세부분류`(머리에 등록된 것) · `group`(소요량 · 계수 · 로직)은 장 파일에서 모음(구분 = 원문 + 부문 · 상세구분 = 장 · 차례대로) |
|
||||
| `GET /rows` | `file` · `page`(1) · `size`(50) · `q` · `sub` · `detail` | `{file, version, total, page, size, rows: [줄…]}` |
|
||||
| `GET /tables` | `file` 또는 `group` · `sub` · `detail` · `q` · `page`(1) · `size`(30) | `{file, version, total, page, size, tables: [{file, 키, 원문번호, 구분, 상세구분, 이름, 기준, 출처, 조건, 값칸, count}]}` — 파일을 가로지른 한 목록 |
|
||||
| `GET /table` | `file` · `key` | `{file, version, table: {표 하나 통째}}` |
|
||||
| `GET /logics` | `sub` · `detail` · `q` · `blocked`(0/1) | `{logics: [{file, 키, 원문번호, 구분, 상세구분, 이름, 결과단위, 출처, blocked, reasons}]}` — 장 차례대로 |
|
||||
| `GET /logic` | `key` | `{file, version, logic: {로직 한 줄 통째}, blocked, reasons, prices: {요소: 요약 \| null}}` |
|
||||
| `GET /elements` | `group` · `q` · `limit`(50 · 200까지) | `{total, items: [{ref, file, 원문번호, 이름, 규격, 단위, 값, 값칸}]}` |
|
||||
| `GET /materials` | `sub`(구분) · `detail` · `spec` · `region`(자재지역) · `limit`(50 · 200까지) | `{total, 기본, items: [{ref, 구분, 상세구분, 이름, 규격, 단위, 값, 관급}]}` — 재료 고르기 조건 안 후보 · `기본` = 시험 계산이 쓸 줄 |
|
||||
| 길 | 받음 | 줌 |
|
||||
| --------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET /groups` | | `{groups: [{group, files, rows}]}` — 그룹 차례는 `_틀.md` 2장 |
|
||||
| `GET /groups/{group}/files` | | `{files: [{file, book, chapter, edition, rows, version, order}]}` — 머리 `차례` 순 |
|
||||
| `GET /subs` | `file` 또는 `group` | `{file, version, slot, subs: [{name, book, details}]}` — 인력 `구분` · 기계 `세부분류`(머리에 등록된 것) · `group`(소요량 · 계수 · 로직)은 장 파일에서 모음(구분 = 원문 + 부문 · 상세구분 = 장 · 차례대로) |
|
||||
| `GET /rows` | `file` · `page`(1) · `size`(50) · `q` · `sub` · `detail` | `{file, version, total, page, size, rows: [줄…]}` |
|
||||
| `GET /tables` | `file` 또는 `group` · `sub` · `detail` · `q` · `page`(1) · `size`(30) | `{file, version, total, page, size, tables: [{file, 키, 원문번호, 구분, 상세구분, 이름, 기준, 출처, 조건, 값칸, count}]}` — 파일을 가로지른 한 목록 |
|
||||
| `GET /table` | `file` · `key` | `{file, version, table: {표 하나 통째}}` |
|
||||
| `GET /logics` | `sub` · `detail` · `q` · `blocked`(0/1) | `{logics: [{file, 키, 원문번호, 구분, 상세구분, 이름, 결과단위, 출처, blocked, reasons}]}` — 장 차례대로 |
|
||||
| `GET /logic` | `key` | `{file, version, logic: {로직 한 줄 통째}, blocked, reasons, prices: {요소: 요약 \| null}}` |
|
||||
| `GET /elements` | `group` · `q` · `limit`(50 · 200까지) | `{total, items: [{ref, file, 원문번호, 구분, 상세구분, 이름, 규격, 단위, 값, 값칸}]}` |
|
||||
| `GET /materials` | `sub`(구분) · `detail` · `spec` · `region`(자재지역) · `limit`(50 · 200까지) | `{total, 기본, items: [{ref, 구분, 상세구분, 이름, 규격, 단위, 값, 관급}]}` — 재료 고르기 조건 안 후보 · `기본` = 시험 계산이 쓸 줄 |
|
||||
|
||||
- `rows` 는 요소·로직 파일은 `줄`, 표형 파일(소요량·계수)은 `표` 를 줄로 봄.
|
||||
- `q` = 찾기 — `키`·`원문번호`·`이름`·`옛이름` 에 든 글(대소문자 무시).
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
담당: 소요량_*.json · 계수_*.json (읽기+쓰기) · 로직_*.json · 원문 md (읽기만).
|
||||
다시 돌리면 같은 결과 — 순수 함수 + 정렬.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import re
|
||||
|
||||
@@ -337,6 +337,25 @@ def check_links(whole: mf.Master) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def check_units(files: dict[str, dict], whole: mf.Master) -> list[str]:
|
||||
"""단위 경고 — 재료 고르기 조건의 후보 단위가 그 줄 단위와 다름(호표 줄 · 품셈재료 줄).
|
||||
데이터가 틀린 것이 아니라 조건을 더 좁혀야 하는 자리라 로직 검사와 따로 봄."""
|
||||
out = []
|
||||
for key, row in whole.index.get("MP", {}).items():
|
||||
cond = row.get("고르기")
|
||||
if isinstance(cond, dict):
|
||||
out += mf.check_unit(whole, whole.label(key) + " 고르기", cond, row.get("단위"))
|
||||
for name, data in files.items():
|
||||
if data.get("그룹") != "로직":
|
||||
continue
|
||||
for row in data.get("줄", []):
|
||||
for item in row.get("호표", []):
|
||||
if isinstance(item.get("요소"), dict):
|
||||
where = f"{name} · {row.get('키')} 호표 {item.get('이름', '')} 요소"
|
||||
out += mf.check_unit(whole, where, item["요소"], item.get("단위"))
|
||||
return out
|
||||
|
||||
|
||||
def _check_table(where: str, table: dict) -> list[str]:
|
||||
out, conds, cols = [], table.get("조건", {}), table.get("값칸", {})
|
||||
allowed = set(conds) | set(cols) | {"단위", "짝"} | {c + "원문" for c in cols}
|
||||
@@ -613,6 +632,8 @@ def main(argv: list[str]) -> int:
|
||||
report["본문"] = check_body(files)
|
||||
if mode in ("로직", "전부"):
|
||||
report["로직"] = check_logics(files, whole)
|
||||
if mode in ("단위", "전부"):
|
||||
report["단위"] = check_units(files, whole)
|
||||
for title, found in report.items():
|
||||
print(f"({title}) {len(found)}건")
|
||||
for line in found:
|
||||
|
||||
@@ -481,7 +481,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
# 고르기 조건 묶음은 그대로 넘김 — `element` 가 대표 줄·첫 줄로 풂
|
||||
ref = item["요소"] if isinstance(item["요소"], dict) else fill(item["요소"], env)
|
||||
try:
|
||||
price, source = element(master, ref, env)
|
||||
price, source = element(master, ref, env, item.get("단위"))
|
||||
except FormulaError:
|
||||
if qty != 0:
|
||||
raise
|
||||
@@ -703,6 +703,7 @@ from master_material import ( # noqa: E402, F401
|
||||
TARIFF,
|
||||
candidates,
|
||||
check_pick,
|
||||
check_unit,
|
||||
cond_text,
|
||||
element,
|
||||
pick,
|
||||
|
||||
@@ -111,35 +111,58 @@ def cond_text(cond: dict) -> str:
|
||||
return "|".join(str(cond.get(k) or "") for k in PICK_KEYS)
|
||||
|
||||
|
||||
def candidates(master: mf.Master, cond: dict, env: dict | None = None) -> list[str]:
|
||||
def candidates(
|
||||
master: mf.Master, cond: dict, env: dict | None = None, unit: str | None = None
|
||||
) -> list[str]:
|
||||
"""고르기 조건 안 자재품목 키 — 구분 · 상세구분 · 규격 낱말이 모두 든 줄 · 파일 차례대로.
|
||||
`unit` 을 주면 그 단위(호표 줄 단위)와 같은 줄만 — 단위가 다르면 다른 물건.
|
||||
입력 `자재지역` 이 있으면 이름에 그 지역이 든 줄만(그런 줄이 없으면 조건 안 전부)."""
|
||||
want = [plain_spec(w) for w in str(cond.get("규격") or "").split()]
|
||||
detail = cond.get("상세구분")
|
||||
need = plain_unit(unit) if unit else ""
|
||||
hits = [
|
||||
key
|
||||
for key, row in master.index.get(MARKET, {}).items()
|
||||
if row.get("구분") == cond.get("구분")
|
||||
and (not detail or row.get("상세구분") == detail)
|
||||
and all(w in plain_spec(row.get("규격")) for w in want)
|
||||
and (not need or plain_unit(row.get("단위")) == need)
|
||||
]
|
||||
region = plain((env or {}).get(REGION, ""))
|
||||
near = [k for k in hits if region in plain(master.get(k).get("이름"))] if region else []
|
||||
return near or hits
|
||||
|
||||
|
||||
def pick(master: mf.Master, cond: dict, env: dict) -> str:
|
||||
"""시험 계산이 쓸 줄 — 관리자가 정한 `대표`, 없으면 조건 안 값 있는 첫 줄."""
|
||||
def pick(master: mf.Master, cond: dict, env: dict, unit: str | None = None) -> str:
|
||||
"""시험 계산이 쓸 줄 — 관리자가 정한 `대표`, 없으면 조건 안 값 있는 첫 줄(단위 같은 줄만)."""
|
||||
if cond.get("대표"):
|
||||
return str(cond["대표"])
|
||||
keys = candidates(master, cond, env)
|
||||
keys = candidates(master, cond, env, unit)
|
||||
keys = [k for k in keys if _offers(master, k)] or keys
|
||||
if not keys:
|
||||
raise mf.FormulaError(f"재료 고르기 후보 없음 — {cond}")
|
||||
return keys[0]
|
||||
|
||||
|
||||
def check_pick(master: mf.Master, where: str, cond: dict) -> list[str]:
|
||||
def check_unit(master: mf.Master, where: str, cond: dict, unit: str | None) -> list[str]:
|
||||
"""단위 경고 — 호표 줄·품셈재료 줄 단위와 맞는 후보가 없거나 대표 줄 단위가 다름.
|
||||
틀린 데이터가 아니라 좁혀야 할 조건이라 `check_pick` 과 따로 봄(`check_master 단위`)."""
|
||||
rows = master.index.get(MARKET, {})
|
||||
rep = str(cond.get("대표") or "")
|
||||
if not unit or set(cond) - set(PICK_KEYS) or not cond.get("구분"):
|
||||
return []
|
||||
if rep in rows:
|
||||
if plain_unit(rows[rep].get("단위")) != plain_unit(unit):
|
||||
return [
|
||||
f"{where} · 대표 줄 단위 「{rows[rep].get('단위')}」 이 줄 단위 「{unit}」 과 다름"
|
||||
]
|
||||
return []
|
||||
if candidates(master, cond) and not candidates(master, cond, None, unit):
|
||||
return [f"{where} · 단위 「{unit}」 후보 0 — 조건 안 줄은 모두 다른 단위"]
|
||||
return []
|
||||
|
||||
|
||||
def check_pick(master: mf.Master, where: str, cond: dict, unit: str | None = None) -> list[str]:
|
||||
"""고르기 조건 검사 — 칸 이름 · 구분·상세구분이 자재품목에 있는지 · 대표 줄 · 후보 0."""
|
||||
if set(cond) - set(PICK_KEYS) or not cond.get("구분"):
|
||||
return [f"{where} · 재료 고르기 조건 모양 「{cond}」 — {' · '.join(PICK_KEYS)}"]
|
||||
@@ -157,11 +180,13 @@ def check_pick(master: mf.Master, where: str, cond: dict) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def element(master: mf.Master, ref, env: dict) -> tuple[object, str | None]:
|
||||
def element(
|
||||
master: mf.Master, ref, env: dict, unit: str | None = None
|
||||
) -> tuple[object, str | None]:
|
||||
"""요소 값과 출처 — 자재품목은 제 줄, 품셈재료는 연결을 따라가 낮은 값 · 그 밖은 줄의 `값`.
|
||||
`ref` 가 고르기 조건 묶음이면 대표 줄(없으면 조건 안 첫 줄)로 풂."""
|
||||
`ref` 가 고르기 조건 묶음이면 대표 줄(없으면 단위 같은 조건 안 첫 줄)로 풂."""
|
||||
if isinstance(ref, dict):
|
||||
ref = pick(master, ref, env)
|
||||
ref = pick(master, ref, env, unit)
|
||||
row = master.get(ref)
|
||||
if ref[:2] == MARKET: # 자재품목을 바로 가리키는 호표 줄
|
||||
return _lowest(master, ref)
|
||||
|
||||
@@ -63,7 +63,10 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000097",
|
||||
"요소": {
|
||||
"구분": "건축마감",
|
||||
"상세구분": "천장·바닥·마감재"
|
||||
},
|
||||
"이름": "외부PANEL(벽) 1,200×2,400㎜",
|
||||
"단위": "매",
|
||||
"수량": "찾기(QC000012, 구분='외부PANEL(벽)', 종류=주자재종류).수량 * 손율 / 100",
|
||||
@@ -71,7 +74,10 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000098",
|
||||
"요소": {
|
||||
"구분": "건축마감",
|
||||
"상세구분": "천장·바닥·마감재"
|
||||
},
|
||||
"이름": "외부PANEL (창문) 1,200×2,400㎜",
|
||||
"단위": "매",
|
||||
"수량": "찾기(QC000012, 구분='외부PANEL (창문)', 종류=주자재종류).수량 * 손율 / 100",
|
||||
@@ -79,7 +85,10 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000099",
|
||||
"요소": {
|
||||
"구분": "건축마감",
|
||||
"상세구분": "천장·바닥·마감재"
|
||||
},
|
||||
"이름": "외부PANEL (철재문) 1,200×2,400㎜",
|
||||
"단위": "매",
|
||||
"수량": "찾기(QC000012, 구분='외부PANEL (철재문)', 종류=주자재종류).수량 * 손율 / 100",
|
||||
@@ -87,7 +96,10 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000100",
|
||||
"요소": {
|
||||
"구분": "건축마감",
|
||||
"상세구분": "천장·바닥·마감재"
|
||||
},
|
||||
"이름": "내부PANEL(벽) 1,200×2,400㎜",
|
||||
"단위": "매",
|
||||
"수량": "만약(종류 == '사무실', 찾기(QC000012, 구분='내부PANEL(벽)', 종류=주자재종류).수량 * 손율 / 100, 0)",
|
||||
@@ -103,7 +115,10 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000102",
|
||||
"요소": {
|
||||
"구분": "건축마감",
|
||||
"상세구분": "천장·바닥·마감재"
|
||||
},
|
||||
"이름": "PANEL JOINT(AL-BAR) L=2,400㎜",
|
||||
"단위": "조",
|
||||
"수량": "찾기(QC000012, 구분='PANEL JOINT(AL-BAR)', 종류=주자재종류).수량 * 손율 / 100",
|
||||
@@ -111,7 +126,10 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000103",
|
||||
"요소": {
|
||||
"구분": "건축마감",
|
||||
"상세구분": "천장·바닥·마감재"
|
||||
},
|
||||
"이름": "CANOPY(출입구채양) 600×1,200㎜",
|
||||
"단위": "매",
|
||||
"수량": "찾기(QC000012, 구분='CANOPY(출입구채양)', 종류=주자재종류).수량 * 손율 / 100",
|
||||
@@ -119,7 +137,10 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000104",
|
||||
"요소": {
|
||||
"구분": "건축마감",
|
||||
"상세구분": "천장·바닥·마감재"
|
||||
},
|
||||
"이름": "박공PANEL",
|
||||
"단위": "매",
|
||||
"수량": "찾기(QC000012, 구분='박공PANEL', 종류=주자재종류).수량 * 손율 / 100",
|
||||
@@ -127,7 +148,11 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000105",
|
||||
"요소": {
|
||||
"구분": "건축마감",
|
||||
"상세구분": "천장·바닥·마감재",
|
||||
"대표": "MT007262"
|
||||
},
|
||||
"이름": "ROOFSHEET 0.5㎜COLORSHEET",
|
||||
"단위": "㎡",
|
||||
"수량": "찾기(QC000012, 구분='ROOFSHEET', 종류=주자재종류).수량 * 손율 / 100",
|
||||
@@ -3974,7 +3999,10 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000130",
|
||||
"요소": {
|
||||
"구분": "잡재료",
|
||||
"상세구분": "소모품"
|
||||
},
|
||||
"이름": "방진망",
|
||||
"단위": "㎡",
|
||||
"수량": "찾기(QC000133, 구분='방진망').수량",
|
||||
|
||||
@@ -140,7 +140,10 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000158",
|
||||
"요소": {
|
||||
"구분": "매트·토목섬유",
|
||||
"상세구분": "부직포·토목섬유"
|
||||
},
|
||||
"이름": "펠트",
|
||||
"단위": "㎡",
|
||||
"수량": "찾기(QC001251, 구분='펠트').수량",
|
||||
@@ -1106,7 +1109,11 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000168",
|
||||
"요소": {
|
||||
"구분": "건축마감",
|
||||
"상세구분": "천장·바닥·마감재",
|
||||
"대표": "MT008074"
|
||||
},
|
||||
"이름": "초배지",
|
||||
"단위": "㎡",
|
||||
"수량": "찾기(QC001269, 바탕면=바탕면).초배지",
|
||||
|
||||
@@ -407,7 +407,10 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000186",
|
||||
"요소": {
|
||||
"구분": "건축마감",
|
||||
"상세구분": "천장·바닥·마감재"
|
||||
},
|
||||
"이름": "폴리카보네이트",
|
||||
"단위": "㎡",
|
||||
"수량": "찾기(QC001319, 구분='폴리카보네이트').수량",
|
||||
|
||||
@@ -3205,7 +3205,10 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000251",
|
||||
"요소": {
|
||||
"구분": "잡재료",
|
||||
"상세구분": "소모품"
|
||||
},
|
||||
"이름": "Sheet Metal Screw",
|
||||
"단위": "개",
|
||||
"수량": "만약(보온두께 == '30mm이하', 찾기(QC001650, 'Pipe Size mm'=관경, 구분='직관의물량').'Sheet Metal Screw', 만약(보온두께 == '31mm∼40mm', 찾기(QC001651, 'Pipe Size mm'=관경, 구분='직관의물량').'Sheet Metal Screw', 만약(보온두께 == '41mm∼60mm', 찾기(QC001652, 'Pipe Size mm'=관경, 구분='직관의물량').'Sheet Metal Screw', 만약(보온두께 == '61mm∼75mm', 찾기(QC001653, 'Pipe Size mm'=관경, 구분='직관의물량').'Sheet Metal Screw', 찾기(QC001654, 'Pipe Size mm'=관경, 구분='직관의물량').'Sheet Metal Screw')))) * 만약(부위 == '관(m당)', 1, 0)",
|
||||
@@ -8709,7 +8712,11 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000289",
|
||||
"요소": {
|
||||
"구분": "잡재료",
|
||||
"상세구분": "고철·기타금속",
|
||||
"대표": "MT003499"
|
||||
},
|
||||
"이름": "함석",
|
||||
"단위": "매",
|
||||
"수량": "0.71",
|
||||
@@ -9009,7 +9016,11 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000289",
|
||||
"요소": {
|
||||
"구분": "잡재료",
|
||||
"상세구분": "고철·기타금속",
|
||||
"대표": "MT003499"
|
||||
},
|
||||
"이름": "함석",
|
||||
"단위": "매",
|
||||
"수량": "0.62",
|
||||
@@ -10252,7 +10263,11 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000292",
|
||||
"요소": {
|
||||
"구분": "잡재료",
|
||||
"상세구분": "고철·기타금속",
|
||||
"대표": "MT003499"
|
||||
},
|
||||
"이름": "함석",
|
||||
"단위": "대",
|
||||
"수량": "0.53",
|
||||
@@ -11595,7 +11610,11 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000307",
|
||||
"요소": {
|
||||
"구분": "잡재료",
|
||||
"상세구분": "고철·기타금속",
|
||||
"대표": "MT003499"
|
||||
},
|
||||
"이름": "함석",
|
||||
"단위": "매",
|
||||
"수량": "1.9",
|
||||
@@ -12095,7 +12114,11 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000307",
|
||||
"요소": {
|
||||
"구분": "잡재료",
|
||||
"상세구분": "고철·기타금속",
|
||||
"대표": "MT003499"
|
||||
},
|
||||
"이름": "함석",
|
||||
"단위": "매",
|
||||
"수량": "1.7",
|
||||
@@ -12516,7 +12539,11 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000312",
|
||||
"요소": {
|
||||
"구분": "잡재료",
|
||||
"상세구분": "고철·기타금속",
|
||||
"대표": "MT003499"
|
||||
},
|
||||
"이름": "함석(Template)",
|
||||
"단위": "매",
|
||||
"수량": "0.53",
|
||||
@@ -12552,7 +12579,10 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000316",
|
||||
"요소": {
|
||||
"구분": "기계·설비",
|
||||
"상세구분": "기계류"
|
||||
},
|
||||
"이름": "Bite",
|
||||
"단위": "개",
|
||||
"수량": "2.5",
|
||||
@@ -12570,7 +12600,10 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000316",
|
||||
"요소": {
|
||||
"구분": "기계·설비",
|
||||
"상세구분": "기계류"
|
||||
},
|
||||
"이름": "Bite",
|
||||
"단위": "개",
|
||||
"수량": "3.2",
|
||||
@@ -12797,7 +12830,11 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000307",
|
||||
"요소": {
|
||||
"구분": "잡재료",
|
||||
"상세구분": "고철·기타금속",
|
||||
"대표": "MT003499"
|
||||
},
|
||||
"이름": "함석",
|
||||
"단위": "매",
|
||||
"수량": "1.2",
|
||||
@@ -13044,7 +13081,11 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000318",
|
||||
"요소": {
|
||||
"구분": "잡재료",
|
||||
"상세구분": "고철·기타금속",
|
||||
"대표": "MT003499"
|
||||
},
|
||||
"이름": "함석",
|
||||
"단위": "매",
|
||||
"수량": "0.71",
|
||||
@@ -19220,7 +19261,11 @@
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": "MP000339",
|
||||
"요소": {
|
||||
"구분": "잡재료",
|
||||
"상세구분": "고철·기타금속",
|
||||
"대표": "MT003499"
|
||||
},
|
||||
"이름": "함석",
|
||||
"단위": "매",
|
||||
"수량": "(만약(공사 == '기기', 0.05, 만약(공사 == '철골', 0.07, 만약(공사 == '배관', 0.05, 만약(공사 == 'Belt & Conveyor', 0.05, 만약(공사 == 'Heater & Tank', 0.05, 만약(공사 == 'Pump & Fan', 0.05, 만약(공사 == 'Crane 류', 0.07, 0))))))))",
|
||||
|
||||
@@ -243,6 +243,13 @@ def test_더함_지움_로직_파일도_같은_길(client: TestClient) -> None:
|
||||
assert client.get("/api/m01/logic", params={"key": new}).status_code == 404
|
||||
|
||||
|
||||
def test_요소_찾기_기계는_구분_상세구분_실림(client: TestClient) -> None:
|
||||
"""테스트 컨테이너(sub3)가 기계 후보를 구분→상세구분으로 거르려면 필요."""
|
||||
found = _get(client, "/api/m01/elements", group="기계", q="불도저")
|
||||
assert found["items"]
|
||||
assert all(x["구분"] and x["상세구분"] for x in found["items"])
|
||||
|
||||
|
||||
def test_단가_자동_요소_찾기_저장전_시험계산(client: TestClient) -> None:
|
||||
one = _get(client, "/api/m01/logic", key=LOGIC_KEY)
|
||||
labor = one["prices"][key_of("LB:1016")]
|
||||
|
||||
@@ -913,8 +913,14 @@ def test_로직_일괄_시험계산():
|
||||
"""로직 전부를 시험 입력으로 돌림 — 멈춤은 데이터 결손(관리자 값·규격 미지정)만."""
|
||||
files = cm.load()
|
||||
passed, stuck = cm.calc_all(files, mf.Master(files))
|
||||
assert passed >= 1270
|
||||
남은_까닭 = ("관리자가 채울 값", "규격 선택 필요", "지역 값 없음", "맞는 줄")
|
||||
assert passed >= 1272
|
||||
남은_까닭 = (
|
||||
"관리자가 채울 값",
|
||||
"규격 선택 필요",
|
||||
"지역 값 없음",
|
||||
"맞는 줄",
|
||||
"고르기 후보 없음",
|
||||
)
|
||||
assert [x for x in stuck if not any(c in x for c in 남은_까닭)] == []
|
||||
|
||||
|
||||
@@ -926,3 +932,23 @@ def test_시험입력이_기본값을_이김():
|
||||
assert cm.시험입력(row) == {"지역": "전국평균", "깊이": 9}
|
||||
row["시험입력"] = {"지역": "포항"}
|
||||
assert cm.시험입력(row)["지역"] == "포항"
|
||||
|
||||
|
||||
def test_고르기_후보는_줄_단위와_같은_것만(whole):
|
||||
"""호표 줄 단위와 다른 자재품목 줄은 후보에서 뺌 — 우드칩(㎥)에 원목후로링(㎡)이 섞이지 않음."""
|
||||
cond = {"구분": "목재"}
|
||||
전부 = mm.candidates(whole, cond)
|
||||
한단위 = mm.candidates(whole, cond, None, "㎥")
|
||||
assert 0 < len(한단위) < len(전부)
|
||||
assert {mm.plain_unit(whole.get(k)["단위"]) for k in 한단위} == {mm.plain_unit("㎥")}
|
||||
쓸줄 = mm.pick(whole, cond, {}, "㎥")
|
||||
assert mm.plain_unit(whole.get(쓸줄)["단위"]) == mm.plain_unit("㎥")
|
||||
|
||||
|
||||
def test_단위_경고는_로직_검사와_따로():
|
||||
"""단위 경고는 조건을 좁히라는 알림 — 로직 검사(틀린 데이터)에 섞지 않음."""
|
||||
files = cm.load()
|
||||
whole = mf.Master(files)
|
||||
assert cm.check_logics(files, whole) == []
|
||||
warned = cm.check_units(files, whole)
|
||||
assert warned and all("단위" in x for x in warned)
|
||||
|
||||
@@ -63,6 +63,11 @@ export const ui_locales_m1 = {
|
||||
M01_UsageTarget: ["대상", "Target"],
|
||||
M01_TableUsage: ["용도", "Usage"],
|
||||
M01_TableUsageLogicCount: ["로직 {value}개", "{value} logic rules"],
|
||||
M01_OpenLogic: ["눌러 로직 탭으로", "Click to jump to the Logic tab"],
|
||||
M01_LogicKeyCopy: [
|
||||
"로직 탭으로 이동 — 키 {value} 복사함(찾기에 붙여넣을 것)",
|
||||
"Switched to Logic tab — copied key {value} (paste it into the search box)",
|
||||
],
|
||||
M01_GroupLabor: ["인력", "Labor"],
|
||||
M01_GroupMaterial: ["재료", "Materials"],
|
||||
M01_GroupMachine: ["기계", "Machines"],
|
||||
|
||||
Reference in New Issue
Block a user