Merge remote-tracking branch 'origin/dev' into sub_laptop_6
This commit is contained in:
@@ -54,6 +54,7 @@ from M02_MasterTemplete.M02_Item_Source import (
|
||||
labor_doc_rows,
|
||||
price_bundle,
|
||||
read_json,
|
||||
version_of,
|
||||
)
|
||||
|
||||
LIBRARY = "library"
|
||||
@@ -100,6 +101,12 @@ def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def source_version(files: dict[str, Any]) -> str:
|
||||
"""서고 사본 원본판(719-1) = 일위대가.json 지문 — 굳힌 SC · SK 문서 + 풀어 굳힌 SK 줄(바탕
|
||||
M01 줄 · 바탕판) · 단가는 뺌(단가 바뀜은 [업데이트] 단가 굳힘 몫) · 값 덮기 전에 셈."""
|
||||
return version_of(files.get(LABOR_FILE))
|
||||
|
||||
|
||||
def _build(kind: str, layer: str, owner: str | None, key: str) -> tuple[dict, dict]:
|
||||
"""서고 원본 → (사본 파일들, 원본 표지)."""
|
||||
if kind not in KINDS:
|
||||
@@ -247,7 +254,7 @@ def import_copy(
|
||||
if isinstance(read_json(library_dir(project_root) / name / MANIFEST_NAME), dict):
|
||||
return {"폴더": name, "만듦": False}
|
||||
manifest = {"종류": kind, "원본": origin, "가져온날": now_text(), "가져온이": who}
|
||||
_write(project_root, name, files, manifest, values)
|
||||
_write(project_root, name, files, {**manifest, "원본판": source_version(files)}, values)
|
||||
return {"폴더": name, "만듦": True}
|
||||
|
||||
|
||||
@@ -293,7 +300,8 @@ def resolve(project_root: str | Path, ref: dict[str, Any], dist: Any) -> str | N
|
||||
got = _build(kind, layer, owner, key)
|
||||
except (LookupError, ValueError):
|
||||
return None
|
||||
got = (got[0], {"종류": kind, "원본": got[1], "가져온날": now_text(), "가져온이": None})
|
||||
made = {"종류": kind, "원본": got[1], "가져온날": now_text(), "가져온이": None}
|
||||
got = (got[0], {**made, "원본판": source_version(got[0])})
|
||||
files, manifest = got
|
||||
values = haul_values(lines_of(files), ref.get("given"), ref.get("own"), dist)
|
||||
name = base + (f"~{tag(values)}" if values else "")
|
||||
|
||||
@@ -15,6 +15,7 @@ from pydantic import BaseModel
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M01_MasterData import M01_MasterData_Store_Flow as flow
|
||||
from M01_MasterData import M01_MasterData_Store_List as store_list
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
from M01_MasterData import M01_MasterData_Store_Revise as revise
|
||||
|
||||
@@ -193,7 +194,8 @@ def get_sections(
|
||||
def get_logics(
|
||||
sub: str = "", detail: str = "", q: str = "", blocked: int | None = None, owner: str = ""
|
||||
) -> dict:
|
||||
return {"logics": _call(store.logics, sub, detail, q, blocked, owner)}
|
||||
"""줄마다 덧칸(묶음 · 작업량 · 부르는/불리는 수 · 관련 · 713-3) — 읽기만."""
|
||||
return {"logics": store_list.attach(_call(store.logics, sub, detail, q, blocked, owner))}
|
||||
|
||||
|
||||
@router.get("/logic")
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""M01 로직 목록 덧칸 (PLAN 713-3 · 요구서 내역12 4장 7) — 정본은 그대로 · 목록에서만 묶음.
|
||||
|
||||
`/logics` 줄마다 붙이는 읽기 칸(파일에 안 씀):
|
||||
- `묶음` = 같은 파일 · 같은 원문번호 로직이 둘 넘으면 그 번호 · `묶음수` = 몇 개.
|
||||
- `작업량` = 돈 아닌 로직(`결과` 칸) · `부모` = 그 작업량을 부르는 로직(목록에서 그 밑으로 접음).
|
||||
- `부르는수` · `불리는수` = 로직끼리 부르기(기계경비 포함 · `Store_Cache.counted`).
|
||||
- `관련` = 비고 글의 절 번호(「1-5-6(주③)」 · 「별도」 참조)가 가리키는 로직 — 계산 밖 링크.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Cache as cache
|
||||
|
||||
_REF = re.compile(
|
||||
r"(?:(산림품셈|건설품셈)\s*(공통|토목|건축|기계설비|유지관리)?\s*)?(?<![\d.-])(\d+-\d+(?:-\d+)*)"
|
||||
)
|
||||
|
||||
|
||||
# ponytail: 넓은 절 거름은 수 하나 — 잘못 걸리면 절 깊이 규칙으로
|
||||
_WIDE = 12
|
||||
|
||||
|
||||
def _book(data: dict) -> tuple[str, str]:
|
||||
return str(data.get("원문") or ""), str(data.get("부문") or "")
|
||||
|
||||
|
||||
def _related(row: dict, book: tuple[str, str], by_section: dict) -> list[str]:
|
||||
"""비고 글 절 번호 → 같은 원문(책 이름이 앞에 있으면 그 책) 로직 키 · 자기 빼고 · 차례대로."""
|
||||
out: dict[str, None] = {}
|
||||
clauses = [c for c in str(row.get("비고") or "").split(" · ") if not c.startswith("해석")]
|
||||
for name, division, number in _REF.findall(" · ".join(clauses)):
|
||||
where = (name, division or ("" if name == "산림품셈" else book[1])) if name else book
|
||||
keys = by_section.get((*where, number), [])
|
||||
if len(keys) > _WIDE: # 장 · 큰 절 통째(「1-1」)는 링크 아님
|
||||
continue
|
||||
out |= dict.fromkeys(k for k in keys if k != str(row.get("키")))
|
||||
return list(out)
|
||||
|
||||
|
||||
def extras() -> dict[str, dict]:
|
||||
"""로직 키 → 덧칸 — 목록 전체로 셈(거른 목록이어도 묶음 수 · 부르는 수는 전체 기준)."""
|
||||
files, _ = store.loaded()
|
||||
rows = [
|
||||
(name, data, row)
|
||||
for name, data in files.items()
|
||||
if data.get("그룹") == "로직"
|
||||
for row in data.get("줄") or []
|
||||
]
|
||||
calls = {key: got[1] for key, got in cache.counted(store.FOLDER).items()}
|
||||
called = Counter(k for got in calls.values() for k in got)
|
||||
parents: dict[str, list[str]] = defaultdict(list)
|
||||
for key, got in calls.items():
|
||||
for k in got:
|
||||
parents[k].append(key)
|
||||
groups = Counter((name, str(row.get("원문번호") or "")) for name, _, row in rows)
|
||||
by_section: dict[tuple, list[str]] = defaultdict(list)
|
||||
for _, data, row in rows:
|
||||
number = str(row.get("원문번호") or "")
|
||||
for at in range(1, number.count("-") + 1): # 「1-5-6」 은 「1-5」 로도 찾힘
|
||||
by_section[(*_book(data), "-".join(number.split("-")[: at + 1]))].append(
|
||||
str(row.get("키"))
|
||||
)
|
||||
out = {}
|
||||
for name, data, row in rows:
|
||||
key, number = str(row.get("키")), str(row.get("원문번호") or "")
|
||||
many = groups[(name, number)] if number else 1
|
||||
work = "결과" in row
|
||||
out[key] = {
|
||||
"묶음": number if many > 1 else "",
|
||||
"묶음수": many if many > 1 else 0,
|
||||
"작업량": work,
|
||||
"부모": sorted(parents.get(key, [])) if work else [],
|
||||
"부르는수": len(calls.get(key, ())),
|
||||
"불리는수": called.get(key, 0),
|
||||
"관련": _related(row, _book(data), by_section),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def attach(logics: list[dict]) -> list[dict]:
|
||||
"""`/logics` 줄에 덧칸을 붙임(없는 키 = 저장 안 한 초안 — 빈 칸)."""
|
||||
more = extras()
|
||||
blank = {
|
||||
"묶음": "",
|
||||
"묶음수": 0,
|
||||
"작업량": False,
|
||||
"부모": [],
|
||||
"부르는수": 0,
|
||||
"불리는수": 0,
|
||||
"관련": [],
|
||||
}
|
||||
return [{**one, **more.get(str(one.get("키")), blank)} for one in logics]
|
||||
@@ -22,6 +22,7 @@ import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Ed
|
||||
import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick";
|
||||
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
|
||||
import {
|
||||
condText,
|
||||
@@ -281,7 +282,7 @@ function entries(ctx: DetailContext): Entry[] {
|
||||
open: (self) =>
|
||||
openMaterialModal({
|
||||
unitFrag: self.unitFrag,
|
||||
unitRef: self.unitRef,
|
||||
unitRef: line?.품목?.키 ?? self.unitRef,
|
||||
unitText: self.unitText,
|
||||
qtyFrag: self.piecesFrag,
|
||||
title: item.이름 ?? (cond ? condLabel(cond) : item.요소),
|
||||
@@ -290,6 +291,12 @@ function entries(ctx: DetailContext): Entry[] {
|
||||
prices: ctx.prices,
|
||||
middles,
|
||||
values: ctx.values,
|
||||
// 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜)
|
||||
kind: item.종류,
|
||||
usedSlot: line?.품목?.값칸,
|
||||
...(item.종류 === "재료"
|
||||
? { onPick: (it) => pickMaterial(ctx.calcHost, ctx.values, i, it) }
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
return spread(entry, line);
|
||||
|
||||
@@ -3,18 +3,23 @@
|
||||
* 자료 모달 둘 —
|
||||
* ① 줄 모달(`openMaterialModal`) — 줄을 누르면 그 줄의 단가(재료·인력·기계 또는 다른 로직)를
|
||||
* 바로 보임(알약 목록 없이) · 그 아래 한 줄 풀이 + 원문 수량 식
|
||||
* 재료 줄 = 위 물가 넷 가로(빈 칸 「-」 · 시험 계산이 쓴 값 굵게) + 후보 목록(찾기 · `onPick` 이면 누르면 고름)
|
||||
* — 상세 표 줄 · 시험 계산 자재 칸이 같은 모달(711-2) · 기계 고르기는 `Logic_PickTable` 그대로
|
||||
* ② 표 모달(`openTableModal`) — 수량 칸 안 알약(찾기(...) 표 참조)을 누르면 그 표만 보임
|
||||
* 표는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처
|
||||
* 표 읽기 = `/elements` → `/table`(`Logic_Note.loadTable`) · 단가 = 로직 화면이 이미 받은 `prices`
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { createButton, createInputField, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
fetchMaterials,
|
||||
searchElements,
|
||||
type ElementBrief,
|
||||
type NamedFormula,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch";
|
||||
import { createStat, statGrid } from "@ui/ui_template_stat";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { explain, loadTable, type TableRow } from "./M01_MasterData_UI_Logic_Note";
|
||||
@@ -81,6 +86,12 @@ export interface ModalTarget {
|
||||
unitText?: string;
|
||||
/** 서버 수량 조각 — 한 줄 풀이를 조각으로(알약 포함) · 없으면 식 풀이 글 */
|
||||
qtyFrag?: Fragment[];
|
||||
/** 호표 줄 종류 — 「재료」면 후보 목록(고르기 조건 줄이 아니어도) */
|
||||
kind?: string;
|
||||
/** 시험 계산이 쓴 값 칸(계산 답 `품목.값칸`) — 위 물가 넷 가운데 굵게 */
|
||||
usedSlot?: string;
|
||||
/** 있으면 후보 줄을 눌러 고름(시험 입력 · 저장 안 함) — 고르면 모달 닫힘 */
|
||||
onPick?: (item: { ref: string; label: string }) => void;
|
||||
}
|
||||
|
||||
/** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */
|
||||
@@ -176,7 +187,71 @@ function goButton(target: MasterJump): HTMLElement {
|
||||
});
|
||||
}
|
||||
|
||||
function priceView(ref: string, brief: ElementBrief): HTMLElement {
|
||||
/** 자재품목 값 칸 넷 — 위 가로 줄(관급은 뺌) */
|
||||
const SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격"];
|
||||
const MARKET_FILE = "재료_자재품목.json";
|
||||
const marketRows = new Map<string, Promise<Row | null>>();
|
||||
/** 자재품목 줄 하나(값 칸 넷) — 키로 찾음 · 한 번만 물음 */
|
||||
const marketRow = (ref: string): Promise<Row | null> => {
|
||||
if (!marketRows.has(ref))
|
||||
marketRows.set(
|
||||
ref,
|
||||
fetchRows(MARKET_FILE, 1, 5, ref)
|
||||
.then((got) => got.rows.find((r) => r["키"] === ref) ?? null)
|
||||
.catch(() => null),
|
||||
);
|
||||
return marketRows.get(ref)!;
|
||||
};
|
||||
|
||||
/** 자재품목 줄 단가 — 이름 · 키 · 규격 + 물가 넷 가로(빈 칸 「-」 · 시험 계산이 쓴 값 굵게 · 모르면 낮은 값) */
|
||||
function marketView(
|
||||
ref: string,
|
||||
brief: ElementBrief | null,
|
||||
usedSlot?: string,
|
||||
onValue?: (value: unknown) => void,
|
||||
): HTMLElement {
|
||||
const head = el("strong");
|
||||
const spec = el("p", { className: "ui-text ui-text--muted" });
|
||||
const slots = el("div");
|
||||
const paint = (name: unknown, size: unknown, values: Record<string, unknown>): void => {
|
||||
head.textContent = `${String(name ?? ref)} · ${ref}`;
|
||||
spec.textContent = size ? `${tl("Modal_Spec")} ${String(size)}` : "";
|
||||
spec.hidden = !size;
|
||||
const nums = SLOTS.map((s) => values[s]).filter((v): v is number => typeof v === "number");
|
||||
const used = SLOTS.includes(usedSlot ?? "")
|
||||
? usedSlot
|
||||
: SLOTS.find((s) => nums.length && values[s] === Math.min(...nums));
|
||||
slots.replaceChildren(
|
||||
statGrid(
|
||||
SLOTS.map((s) => {
|
||||
const card = createStat({
|
||||
label: s,
|
||||
value: values[s] === null || values[s] === undefined ? "-" : formatPrice(values[s]),
|
||||
compact: true,
|
||||
});
|
||||
card.classList.toggle("is-used", s === used);
|
||||
return card;
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (used) onValue?.(values[used]);
|
||||
};
|
||||
paint(brief?.이름, brief?.규격, brief?.값들 ?? {});
|
||||
if (!brief?.값들) void marketRow(ref).then((row) => row && paint(row["이름"], row["규격"], row));
|
||||
return el("div", {
|
||||
className: "m01lab__data m01lab__slots",
|
||||
attrs: { "data-price": ref },
|
||||
children: [
|
||||
head,
|
||||
spec,
|
||||
slots,
|
||||
goButton({ kind: "element", group: "재료", ref, file: brief?.file ?? MARKET_FILE }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function priceView(ref: string, brief: ElementBrief, usedSlot?: string): HTMLElement {
|
||||
if (ref.startsWith("MT")) return marketView(ref, brief, usedSlot);
|
||||
const cols = Object.entries(brief.값칸 ?? {});
|
||||
const slots = Object.entries(brief.값들 ?? {}).filter(([, v]) => v !== null && v !== undefined);
|
||||
return el("div", {
|
||||
@@ -207,8 +282,8 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement {
|
||||
});
|
||||
}
|
||||
|
||||
/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움 */
|
||||
function openDialog(title: string, mount: (body: HTMLElement) => void): void {
|
||||
/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움(닫기 함수도 받음) */
|
||||
function openDialog(title: string, mount: (body: HTMLElement, close: () => void) => void): void {
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: tl("Modal_Close"),
|
||||
@@ -220,58 +295,122 @@ function openDialog(title: string, mount: (body: HTMLElement) => void): void {
|
||||
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--muted", text });
|
||||
|
||||
/** 재료 고르기 줄 후보 목록 — 잡힌 품목(`pickedRef`)을 노란 줄로 · 후보가 100건을 넘으면 앞 100건만 옴 */
|
||||
function candidateView(
|
||||
cond: PickCond,
|
||||
pickedRef: string,
|
||||
onPicked: (item: ElementBrief | null) => void,
|
||||
): HTMLElement {
|
||||
const box = el("div", { className: "m01lab__data", attrs: { "data-candidates": "1" } });
|
||||
box.append(muted("…"));
|
||||
void searchElements("재료", seedOf(cond))
|
||||
const free = (v: unknown): string => {
|
||||
const text = String(v ?? "");
|
||||
return text.includes("{") ? "" : text; // `{입력}` 자리는 계산 때 정해짐 — 거름에 안 씀
|
||||
};
|
||||
const squash = (v: unknown): string =>
|
||||
String(v ?? "")
|
||||
.replace(/\s+/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
/** 재료 줄 후보 목록 — 고르기 조건 안 후보(`/materials` · 단위 거름) · 조건이 없으면 이름 찾기 ·
|
||||
* 찾기 칸 = 받은 후보 안에서 거름(없으면 재료 전체 이름 찾기) · 잡힌 품목(`pickedRef`) 노란 줄 맨 위 ·
|
||||
* `onPick` 이면 줄을 누르면 고름 · 값은 위 가로 칸이 보여 목록엔 값 열 없음 */
|
||||
function candidateView(target: ModalTarget, pickedRef: string, close: () => void): HTMLElement {
|
||||
const cond = target.cond;
|
||||
const sub = free(cond?.구분);
|
||||
const list = el("div");
|
||||
const count = el("strong");
|
||||
const field = createInputField({ type: "search", placeholder: tl("Modal_Find") });
|
||||
const box = el("div", {
|
||||
className: "m01lab__data",
|
||||
attrs: { "data-candidates": "1" },
|
||||
children: [count, field.root, list],
|
||||
});
|
||||
list.append(muted("…"));
|
||||
let base: ElementBrief[] = [];
|
||||
const draw = (items: ElementBrief[], total: number): void => {
|
||||
const sorted = [
|
||||
...items.filter((it) => it.ref === pickedRef),
|
||||
...items.filter((it) => it.ref !== pickedRef),
|
||||
];
|
||||
const more = total > items.length ? ` (${items.length})` : "";
|
||||
count.textContent = `${tl(target.onPick ? "Modal_PickList" : "Modal_Candidates")} ${total}${more}`;
|
||||
const rows = sorted.map((it) => {
|
||||
const hit = it.ref === pickedRef;
|
||||
const tr = el("tr", {
|
||||
className: `${hit ? "m01lab__hit" : ""}${target.onPick ? " m01lab__pickrow" : ""}`,
|
||||
attrs: { ...(hit ? { "data-hit": "1" } : {}), "data-ref": it.ref },
|
||||
children: [
|
||||
el("td", { text: it.이름 ?? it.ref }),
|
||||
el("td", { text: it.규격 ?? "" }),
|
||||
el("td", { text: String(it.단위 ?? "") }),
|
||||
],
|
||||
});
|
||||
const onPick = target.onPick;
|
||||
if (onPick)
|
||||
tr.addEventListener("click", () => {
|
||||
close();
|
||||
onPick({ ref: it.ref, label: `${it.이름 ?? ""} ${it.규격 ?? ""}`.trim() || it.ref });
|
||||
});
|
||||
return tr;
|
||||
});
|
||||
list.replaceChildren(
|
||||
rows.length
|
||||
? el("div", {
|
||||
className: "m01-logic__scroll m01lab__cands",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-table ui-table--data m01-logic__grid m01lab__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [tl("Col_Name"), tl("Modal_Spec"), tl("Col_Unit")].map((h) =>
|
||||
el("th", { text: h }),
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
: muted(tl("Pill_NoPick")),
|
||||
);
|
||||
};
|
||||
// 조건 없는 재료 줄 = 그 요소 이름 첫 낱말로 찾음(「모르타르, 서울 1:1」 → 「모르타르」)
|
||||
const known = target.element ? target.prices[target.element] : null;
|
||||
const seed = cond ? seedOf(cond) : headWord(known?.이름 ?? target.title).replace(/[,,].*$/, "");
|
||||
const first = sub
|
||||
? fetchMaterials({
|
||||
sub,
|
||||
detail: free(cond?.상세구분),
|
||||
spec: free(cond?.규격),
|
||||
unit: target.unit ?? "",
|
||||
})
|
||||
: searchElements("재료", seed);
|
||||
let total = 0;
|
||||
let timer = 0;
|
||||
let seq = 0;
|
||||
/** 찾기 칸 글로 거름 — 받은 후보 안에서 · 하나도 없으면 재료 전체 이름 찾기 */
|
||||
const apply = (): void => {
|
||||
const mine = ++seq;
|
||||
const q = field.input.value.trim();
|
||||
const words = q.split(/\s+/).map(squash).filter(Boolean);
|
||||
if (!words.length) return draw(base, total);
|
||||
const hits = base.filter((it) =>
|
||||
words.every((w) => squash(`${it.이름 ?? ""} ${it.규격 ?? ""} ${it.ref}`).includes(w)),
|
||||
);
|
||||
if (hits.length) return draw(hits, hits.length);
|
||||
void searchElements("재료", q)
|
||||
.then((got) => mine === seq && draw(got.items, got.total))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
void first
|
||||
.then((got) => {
|
||||
onPicked(got.items.find((it) => it.ref === pickedRef) ?? null);
|
||||
const rows = got.items.map((it) =>
|
||||
el("tr", {
|
||||
className: it.ref === pickedRef ? "m01lab__hit" : "",
|
||||
attrs: it.ref === pickedRef ? { "data-hit": "1" } : {},
|
||||
children: [
|
||||
el("td", { text: it.이름 ?? it.ref }),
|
||||
el("td", { text: it.규격 ?? "" }),
|
||||
el("td", { text: String(it.단위 ?? "") }),
|
||||
el("td", { className: "m01-logic__money", text: formatPrice(it.값) }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const more = got.total > got.items.length ? ` (${got.items.length})` : "";
|
||||
box.replaceChildren(
|
||||
el("strong", { text: `${tl("Modal_Candidates")} ${got.total}${more}` }),
|
||||
el("div", {
|
||||
className: "m01-logic__scroll",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-table ui-table--data m01-logic__grid m01lab__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
tl("Col_Name"),
|
||||
tl("Modal_Spec"),
|
||||
tl("Col_Unit"),
|
||||
tl("Modal_Value"),
|
||||
].map((h) => el("th", { text: h })),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
base = got.items;
|
||||
total = got.total;
|
||||
field.input.value = sub ? free(cond?.["검색어"]) : ""; // 엔진이 고르는 검색어로 먼저 거름 · 지우면 조건 안 전부
|
||||
apply();
|
||||
})
|
||||
.catch(() => box.replaceChildren(muted(tl("Pill_NoPick"))));
|
||||
.catch(() => list.replaceChildren(muted(tl("Pill_NoPick"))));
|
||||
field.input.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(apply, 300);
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -285,7 +424,7 @@ function pickedOf(cond: PickCond, values: Record<string, string>, fallback: stri
|
||||
/** 줄을 눌렀을 때 — 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 바로 보임(알약 없이) ·
|
||||
* 재료 고르기 줄(`cond`)은 잡힌 품목 단가 + 후보 목록 */
|
||||
export function openMaterialModal(target: ModalTarget): void {
|
||||
openDialog(target.title, (body) => {
|
||||
openDialog(target.title, (body, close) => {
|
||||
const cond = target.cond;
|
||||
const element = target.element ?? "";
|
||||
const base = element.split(".")[0];
|
||||
@@ -312,7 +451,7 @@ export function openMaterialModal(target: ModalTarget): void {
|
||||
setSum(known);
|
||||
const rate = el("div", { className: "m01lab__modal-body" });
|
||||
if (known && priceRef) {
|
||||
rate.append(priceView(priceRef, known));
|
||||
rate.append(priceView(priceRef, known, target.usedSlot));
|
||||
} else if (logicKey) {
|
||||
rate.append(
|
||||
el("p", {
|
||||
@@ -327,7 +466,12 @@ export function openMaterialModal(target: ModalTarget): void {
|
||||
}),
|
||||
);
|
||||
} else if (cond && priceRef) {
|
||||
rate.append(muted("…")); // 후보 목록이 오면 그 줄로 채움 — 100건 밖이면 키로 따로 찾아 옴
|
||||
// 서버가 잡은 품목 · 고른 품목 — 자재품목 줄을 키로 읽어 물가 넷
|
||||
rate.append(
|
||||
marketView(priceRef, null, target.usedSlot, (value) =>
|
||||
setSum({ ref: priceRef, 값: value } as ElementBrief),
|
||||
),
|
||||
);
|
||||
} else if (cond) {
|
||||
rate.append(muted(tl("Pill_NoPick")));
|
||||
} else if (element.includes("{")) {
|
||||
@@ -358,23 +502,7 @@ export function openMaterialModal(target: ModalTarget): void {
|
||||
}
|
||||
body.replaceChildren(
|
||||
rate,
|
||||
...(cond
|
||||
? [
|
||||
candidateView(cond, priceRef, (item) => {
|
||||
if (known || !priceRef) return;
|
||||
const show = (brief: ElementBrief | null): void => {
|
||||
rate.replaceChildren(
|
||||
brief ? priceView(priceRef, brief) : muted(`${priceRef} — ${tl("Pill_NoValue")}`),
|
||||
);
|
||||
setSum(brief);
|
||||
};
|
||||
if (item) return show(item);
|
||||
void searchElements("재료", priceRef)
|
||||
.then((got) => show(got.items.find((it) => it.ref === priceRef) ?? null))
|
||||
.catch(() => show(null));
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(cond || target.kind === "재료" ? [candidateView(target, priceRef, close)] : []),
|
||||
muted(tl("Modal_Sum")),
|
||||
sum,
|
||||
el("details", {
|
||||
|
||||
@@ -215,3 +215,13 @@
|
||||
.m01lab__now {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 재료 모달(711-2) — 위 물가 넷 가로 · 시험 계산이 쓴 값만 굵게 · 후보 목록은 모달 안 스크롤 */
|
||||
.m01lab__slots .ui-stat:not(.is-used) .ui-stat__value {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.m01lab__cands {
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,11 @@ const TEXT = {
|
||||
"후보 목록 (노란 줄 = 시험 계산이 쓰는 품목)",
|
||||
"Candidates (yellow = used in the trial)",
|
||||
],
|
||||
Modal_PickList: [
|
||||
"후보 목록 — 줄을 누르면 그 품목으로 시험 계산 (노란 줄 = 지금 쓰는 품목)",
|
||||
"Candidates — click a row to use it in the trial (yellow = in use)",
|
||||
],
|
||||
Modal_Find: ["이름 · 규격으로 찾기", "Search by name or spec"],
|
||||
Modal_Logic: ["다른 로직을 부름", "Calls another logic"],
|
||||
Modal_NoData: ["표나 단가 자료가 없는 줄 — 수량 식만 있음", "No table or price data"],
|
||||
Modal_Hit: ["노란 줄 = 지금 넣은 값으로 걸린 줄", "Highlighted row matches the trial values"],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_CalcPick.ts
|
||||
* 시험 계산의 재료 · 기계 고르기 — 재료 칸마다 잡힌 품목 이름(누르면 고르기) · 기계 입력은 단추 하나
|
||||
* 누르면 마스터 표 모달(`PickTable`) · 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함
|
||||
* 재료 = 상세 표 줄과 같은 재료 모달(`openMaterialModal` · 711-2) · 기계 = 마스터 표 모달(`PickTable`)
|
||||
* 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
@@ -9,7 +10,7 @@ import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch";
|
||||
import type { CalcLine, ElementBrief, LogicInput, LogicRow } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { openPickTable } from "./M01_MasterData_UI_Logic_PickTable";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { condText, headWord, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
import { condText, openMaterialModal, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
|
||||
interface Chosen {
|
||||
ref: string;
|
||||
@@ -24,6 +25,19 @@ const picksOf = (values: Record<string, string>): Map<number, Chosen> => {
|
||||
return mine;
|
||||
};
|
||||
|
||||
/** 재료 고름(시험 입력 · 저장 안 함) — 시험 계산 칸(`host`)의 자재 글을 바꾸고 다시 셈 · 상세 표 줄 모달이 씀 */
|
||||
export function pickMaterial(
|
||||
host: HTMLElement,
|
||||
values: Record<string, string>,
|
||||
i: number,
|
||||
it: Chosen,
|
||||
): void {
|
||||
picksOf(values).set(i, it);
|
||||
const link = host.querySelector<HTMLElement>(`[data-mat-idx="${i}"]`);
|
||||
if (link) link.textContent = it.label;
|
||||
host.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
|
||||
}
|
||||
|
||||
/** 계산 요청 몸의 `고름` — 고른 것이 없으면 안 실음 */
|
||||
export function pickBody(values: Record<string, string>): { 고름?: Record<string, string> } {
|
||||
const mine = picksOf(values);
|
||||
@@ -78,9 +92,6 @@ export function materialRows(
|
||||
const held = mine.get(i);
|
||||
const nowName =
|
||||
held?.label ?? (brief ? `${brief.이름 ?? ""} ${brief.규격 ?? ""}`.trim() : "") ?? "";
|
||||
const seeds = [cond?.["검색어"], cond?.이름, cond?.상세구분, cond?.구분, label]
|
||||
.map(headWord)
|
||||
.filter((w, k, all) => w && all.indexOf(w) === k);
|
||||
// 잡힌 품목 이름 = 링크 단추 — 누르면 고르기 모달([품목 고르기] 단추 걷음)
|
||||
const current = createButton({
|
||||
label: nowName || tx("Mat_NoPrice"),
|
||||
@@ -90,20 +101,26 @@ export function materialRows(
|
||||
current.dataset.matIdx = String(i);
|
||||
current.dataset.ref = brief?.ref ?? "";
|
||||
current.title = tx("Pick_Item");
|
||||
const title = `${label}${item.규격 ? ` ${item.규격}` : ""}`;
|
||||
const pick = (): void =>
|
||||
openPickTable({
|
||||
openMaterialModal({
|
||||
title,
|
||||
expr: item.수량,
|
||||
...(cond ? { cond } : { element: item.요소 }),
|
||||
unit: item.단위,
|
||||
kind: "재료",
|
||||
title: `${tx("Pick_MatTitle")} · ${label}`,
|
||||
seed: seeds[0] ?? "",
|
||||
alt: seeds.slice(1),
|
||||
isCurrent: (r) => String(r["키"]) === (mine.get(i)?.ref ?? current.dataset.ref),
|
||||
onPick: (r) => {
|
||||
mine.set(i, { ref: String(r["키"]), label: nameOf(r) });
|
||||
current.textContent = nameOf(r);
|
||||
prices,
|
||||
middles: row.중간 ?? [],
|
||||
values,
|
||||
unitRef: mine.get(i)?.ref || current.dataset.ref || undefined,
|
||||
usedSlot: current.dataset.slot || undefined,
|
||||
onPick: (it) => {
|
||||
mine.set(i, it);
|
||||
current.textContent = it.label;
|
||||
again();
|
||||
},
|
||||
});
|
||||
return line(`${label}${item.규격 ? ` ${item.규격}` : ""}`, current);
|
||||
return line(title, current);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,6 +134,7 @@ export function showItems(
|
||||
host.querySelectorAll<HTMLElement>("[data-mat-idx]").forEach((span) => {
|
||||
const i = Number(span.dataset.matIdx);
|
||||
const item = lines[i]?.품목;
|
||||
span.dataset.slot = item?.값칸 ?? ""; // 시험 계산이 쓴 값 칸 — 모달 위 가로 칸 굵게
|
||||
if (!item || mine.has(i)) return;
|
||||
span.dataset.ref = item.키;
|
||||
span.textContent = `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() || item.키;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""M02 일위대가 불러오기 → 단가산출(PLAN 718-1 · 요구서 내역12 4장 22) —
|
||||
산근(단산) 블록 = 바탕 없는 단가산출.
|
||||
|
||||
블록마다 SK 하나 — 그 블록을 부르는 첫 엑셀 줄(「단산N참조」)의 1 단위 단가
|
||||
(노무비 · 재료비 · 경비)를 고정 더한줄 하나로(`M02_Hopyo_Fixed` 꼴 · 엑셀 숫자 그대로 ·
|
||||
서버가 파일에서 읽음) · 이름 = 블록 이름(없으면 줄 품명) · 단위 = 줄 단위(없으면 블록 · 「식」) ·
|
||||
구분 「실무자료」 · 상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 단산N」, 파일, 원본,
|
||||
확인} · 같은 본뜬이 이 층에 있으면 그대로(겹침 없음) · 만드는 층 = 부르는 층(마법사에서 고른 층).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_time import utc_now_text
|
||||
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
ORIGIN = "실무자료"
|
||||
KIND = "sangeun"
|
||||
|
||||
|
||||
def mark(key: str, no: int) -> str:
|
||||
return f"{key} 단산{no}"
|
||||
|
||||
|
||||
def made(folder: str | Path, key: str | None = None) -> dict[str, str]:
|
||||
"""이 층 실무자료 단가산출 `{본뜬 키: SK 키}`(`key` = 그 HP 것만)."""
|
||||
out = {}
|
||||
for path in sorted((Path(folder) / KIND).glob("SK-*.json")):
|
||||
try:
|
||||
got = json.loads(path.read_bytes()).get("본뜬")
|
||||
except (OSError, ValueError, AttributeError):
|
||||
continue
|
||||
if isinstance(got, dict) and got.get("층") == ORIGIN:
|
||||
if key is None or str(got.get("키") or "").startswith(f"{key} "):
|
||||
out[str(got.get("키"))] = path.stem
|
||||
return out
|
||||
|
||||
|
||||
def _doc(line: dict, block: dict, no: int, hp: dict, stamp: str, who: Any) -> dict[str, Any]:
|
||||
name = str(block.get("이름") or line.get("이름") or "").strip() or f"단산{no}"
|
||||
unit = str(line.get("단위") or block.get("단위") or "").strip() or "식"
|
||||
spec = str(line.get("규격") or "").strip()
|
||||
price = {k: hfix._plain(v) for k, v in hfix._parts(line.get("단가")).items()}
|
||||
fx = {
|
||||
"이름": name,
|
||||
"규격": spec,
|
||||
"단위": unit,
|
||||
"단가": price,
|
||||
"이음": dict.fromkeys(fixed.LINKS),
|
||||
"출처": {"파일": hp.get("파일"), "호표": line.get("_호표"), "줄": int(line.get("차례", 0))},
|
||||
}
|
||||
return {
|
||||
"종류": "단가산출",
|
||||
"이름": name,
|
||||
"단위": unit,
|
||||
"구분": ORIGIN,
|
||||
"상세구분": str(hp.get("파일") or ""),
|
||||
"본뜬": {
|
||||
"층": ORIGIN,
|
||||
"키": mark(hp["키"], no),
|
||||
"파일": hp.get("파일"),
|
||||
"원본": list(refs.here()),
|
||||
"확인": {"누가": who, "때": stamp},
|
||||
},
|
||||
"바탕": None,
|
||||
"입력값": {},
|
||||
"열린입력": [],
|
||||
"고름": {},
|
||||
"자재표시": {},
|
||||
"덮개": {},
|
||||
"더한줄": [{"고정": fx, "이름": name, "단위": unit, "수량": 1}],
|
||||
"뺀줄": [],
|
||||
}
|
||||
|
||||
|
||||
def build(folder: Path, key: str, who: Any = None) -> dict[str, Any]:
|
||||
"""[단가산출 만들기] — 산근 블록마다 바탕 없는 단가산출 →
|
||||
`{블록, 만듦 [{번, 이름, 키}], 그대로}`."""
|
||||
hp = hopyo_store.read(folder, key)
|
||||
blocks = {int(b["번"]): b for b in hp.get("단산") or [] if b.get("번") is not None}
|
||||
calls: dict[int, dict] = {}
|
||||
for h in hp.get("호표") or []:
|
||||
for line in h.get("줄") or []:
|
||||
ref = line.get("참조") or {}
|
||||
no = int(ref.get("번") or 0) if ref.get("종류") == "단산" else 0
|
||||
if no in blocks and no not in calls:
|
||||
calls[no] = {**line, "_호표": h.get("번")}
|
||||
have, stamp = made(folder, key), utc_now_text()
|
||||
out: dict[str, Any] = {"블록": len(calls), "만듦": [], "그대로": 0}
|
||||
for no in sorted(calls):
|
||||
if mark(key, no) in have:
|
||||
out["그대로"] += 1
|
||||
continue
|
||||
doc = _doc(calls[no], blocks[no], no, hp, stamp, who)
|
||||
got = refs.create(KIND, doc)
|
||||
out["만듦"].append({"번": no, "이름": doc["이름"], "키": got["이름"]})
|
||||
return out
|
||||
@@ -44,7 +44,7 @@ def _texts(row: dict) -> list[str]:
|
||||
out.append(row["결과"]["식"])
|
||||
for item in row.get("호표", []):
|
||||
out.append(item["수량"])
|
||||
if not isinstance(item.get("요소"), dict):
|
||||
if isinstance(item.get("요소"), str): # 단가산출 참조 · 고정 더한줄 = 요소 없음(719-1)
|
||||
out.append(item["요소"])
|
||||
out += [extra["식"] for extra in row.get("덧줄", [])]
|
||||
return out
|
||||
@@ -200,6 +200,10 @@ class _Collector:
|
||||
return
|
||||
self.logics.add(key)
|
||||
self.keys.add(key)
|
||||
self.row(row)
|
||||
|
||||
def row(self, row: dict) -> None:
|
||||
"""로직 줄 하나가 닿는 것 — 마스터 로직 · 굳힌 단가산출 줄(719-1 · 마스터 밖 줄)."""
|
||||
if isinstance(row.get("흐름"), dict): # 흐름 로직 — 옛 칸 대신 흐름이 쓰는 것을 모음
|
||||
self.flow(row)
|
||||
return
|
||||
@@ -221,12 +225,17 @@ class _Collector:
|
||||
self.pick(row, item["요소"], item.get("단위"))
|
||||
|
||||
|
||||
def collect(logic_keys: list[str], materials: list[str] | None = None) -> dict[str, Any]:
|
||||
"""담은 로직 + 자재(MT)의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}`."""
|
||||
def collect(
|
||||
logic_keys: list[str], materials: list[str] | None = None, extra: list[dict] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""담은 로직 + 자재(MT)의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}` ·
|
||||
`extra` = 마스터 밖 로직 줄(굳힌 단가산출 719-1) — 그 줄이 닿는 것만 모음(줄은 안 담음)."""
|
||||
files, whole = master()
|
||||
collector = _Collector(whole)
|
||||
for key in logic_keys:
|
||||
collector.logic(key)
|
||||
for row in extra or []:
|
||||
collector.row(row)
|
||||
for code in materials or []:
|
||||
collector.add(code)
|
||||
out: dict[str, Any] = {}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""프로젝트 사본 — M02 단가산출(SK) 풀어 굳힘(PLAN 719-1 · 요구서 내역12 원칙 3 · 4장 14).
|
||||
|
||||
사본을 만들 때(구조물 사본 `M02_Item_Source.build_source` · B09 서고 사본 `Bill_Library`) 굳힌
|
||||
참조사본 안 SK 문서마다 고친 줄(`M02_Sangeun_Calc.resolve` = 바탕 사슬 + 덮개 · 더한줄 · 뺀줄)을
|
||||
풀어 사본 `단가.json` 로직 줄 하나로 굳힘 — 사본 SK 문서는 그 줄만 바탕으로 가리킴(덮개 0 ·
|
||||
연결 끊김). 셈은 그대로 `M02_Sangeun_Calc` 한 벌(사본 색인 = 사본 `단가.json`).
|
||||
|
||||
굳힌 키 = SK 참조 이름표(`ref_id` = 층 · 주인 · 키)에서 정해지는 `GX9#####`(해시 · 겹치면
|
||||
이름표 차례로 다음 번호) — 같은 SK = 같은 굳힌 키([업데이트] 때 B09 덮개 cover · cid · 입력값 ·
|
||||
고른자재가 짝을 안 잃음). 원본 층 문서는 읽기만.
|
||||
사본 SK 문서 = {종류 · 키 · 이름 · 단위 · 구분 · 상세구분, 바탕 {로직: 굳힌 키}, 입력값(사슬 합),
|
||||
열린입력, 켜쌓기율, 고름 · 자재표시(굳힌 줄 cid), 덮개 {}, 더한줄 [], 뺀줄 [],
|
||||
굳힌줄(= 단가.json 줄), 원본(원래 SK 문서 · 「M02 에서 고침」 근거), 바탕판, 짝잃음}.
|
||||
단가산출 속 일위대가(SC) · SK 더한줄 = 참조 줄 그대로 → 사본 셈이 굳힌 참조사본으로 펼침.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
#: 사본 `단가.json` 안 굳힌 SK 줄 파일(`master_formula.Master` 가 그룹 「로직」 으로 읽음)
|
||||
FILE = "로직_단가산출.json"
|
||||
ROW = "굳힌줄"
|
||||
_HEAD, _LOW, _SPAN = "GX", 900000, 99999 # GX900000 ~ GX999998(999999 = M01 저장 전 초안)
|
||||
|
||||
|
||||
def frozen_key(rid: str, taken: set[str]) -> str:
|
||||
"""SK 이름표 → 굳힌 로직 키 — 해시 자리 · 이미 쓴 키면 다음 번호(제자리 `taken` 에 더함)."""
|
||||
at = int(hashlib.sha256(rid.encode("utf-8")).hexdigest(), 16) % _SPAN
|
||||
while (key := f"{_HEAD}{_LOW + at:06d}") in taken:
|
||||
at = (at + 1) % _SPAN
|
||||
taken.add(key)
|
||||
return key
|
||||
|
||||
|
||||
def is_sangeun(doc: Any) -> bool:
|
||||
return isinstance(doc, dict) and doc.get("종류") == sd.TITLE
|
||||
|
||||
|
||||
def freeze(copies: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""참조사본 `{ref_id: 문서}` → SK 를 풀어 굳힌 참조사본(새 묶음 · 받은 것 안 고침) ·
|
||||
못 푼 SK(없는 바탕 · 순환) = 그대로(사본 셈이 까닭을 냄)."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
|
||||
sks = sorted(rid for rid, doc in copies.items() if is_sangeun(doc))
|
||||
if not sks:
|
||||
return copies
|
||||
whole = m01.loaded()[1]
|
||||
|
||||
def read(ref: dict) -> dict | None:
|
||||
return copies.get(refs.ref_id(ref))
|
||||
|
||||
out, taken = dict(copies), set()
|
||||
for rid in sks:
|
||||
doc = copies[rid]
|
||||
try:
|
||||
got = calc.resolve(doc, whole, read, (rid,))
|
||||
except sd.DocError:
|
||||
continue
|
||||
key = frozen_key(rid, taken)
|
||||
row = prices.to_json({**got["줄"], "키": key})
|
||||
ids = sd.line_ids(row)
|
||||
frozen = {k: doc.get(k) for k in ("종류", "키", "이름", "단위", "구분", "상세구분")}
|
||||
frozen |= {
|
||||
"바탕": {"로직": key},
|
||||
"입력값": prices.to_json(got["입력값"]),
|
||||
"열린입력": list(doc.get("열린입력") or []),
|
||||
"고름": {ids[int(n)]: mt for n, mt in got["고름"].items()},
|
||||
"자재표시": {ids[int(n)]: one for n, one in got["자재표시"].items()},
|
||||
"덮개": {},
|
||||
"더한줄": [],
|
||||
"뺀줄": [],
|
||||
ROW: row,
|
||||
"원본": doc,
|
||||
"바탕판": got["바탕판"],
|
||||
"짝잃음": got["짝잃음"],
|
||||
}
|
||||
if got["켜쌓기율"] is not None:
|
||||
frozen["켜쌓기율"] = prices.to_json(got["켜쌓기율"])
|
||||
out[rid] = frozen
|
||||
return out
|
||||
|
||||
|
||||
def rows(copies: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""굳힌 SK 줄 — 사본 `단가.json` `FILE` 에 넣음(이름표 차례)."""
|
||||
return [doc[ROW] for _, doc in sorted(copies.items()) if is_sangeun(doc) and ROW in doc]
|
||||
|
||||
|
||||
def materials(copies: dict[str, Any]) -> list[str]:
|
||||
"""굳힌 SK 고른 자재 키 — 단가 묶음에 담음."""
|
||||
return [
|
||||
str(mt)
|
||||
for doc in copies.values()
|
||||
if is_sangeun(doc)
|
||||
for mt in (doc.get("고름") or {}).values()
|
||||
]
|
||||
@@ -5,7 +5,7 @@
|
||||
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄 ·
|
||||
규격 열 = summary_item `spec` 열 수량을 손으로 굳힘 · 704-4)
|
||||
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) +
|
||||
참조사본(단가승격 · 사전조합을 값으로 굳힘) ·
|
||||
참조사본(단가승격 · 사전조합을 값으로 굳힘 · 단가산출 SK = 풀어 굳힌 로직 줄 719-1) ·
|
||||
`단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT)
|
||||
`원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎.
|
||||
프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음.
|
||||
@@ -93,11 +93,12 @@ def labor_rows(ua_key: str) -> dict[str, Any] | None:
|
||||
def labor_doc_rows(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""일위대가 문서(39-1a) + 담은 로직 줄 + 알약(`M02_Labor_Doc.pills` · 가져온 때 그대로 —
|
||||
복사본 계산이 마스터를 안 읽게) + 참조사본 `{ref_id: 문서}`(50-3 · 원본이 바뀌어도 그대로)."""
|
||||
from M02_MasterTemplete import M02_Item_Sangeun as sk
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
rows = {**logic_rows(doc), "알약": labor_doc.pills(doc)}
|
||||
copies = refs.frozen(doc)
|
||||
copies = sk.freeze(refs.frozen(doc)) # 단가산출(SK) = 풀어 굳힌 로직 줄(719-1)
|
||||
return {**rows, "참조사본": copies} if copies else rows
|
||||
|
||||
|
||||
@@ -162,11 +163,14 @@ def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]:
|
||||
"""사본 단가 묶음(`단가.json`) — 담은 로직 · 굳힌 참조 로직 · 자재 · 고른 자재 · 사전조합 자재 ·
|
||||
개인 자재(`root` = 원본 층 · B09 설계내역서 서고 사본도 이 길 · 204-2)."""
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Item_Sangeun as sk
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
copies = (labor or {}).get("참조사본") or {}
|
||||
sk_rows = sk.rows(copies) # 굳힌 단가산출 줄(719-1) — 그 줄이 닿는 마스터 줄도 모음
|
||||
logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])]
|
||||
logic_keys += refs.frozen_logics((labor or {}).get("참조사본") or {}) # 굳힌 단가승격 로직
|
||||
logic_keys += refs.frozen_logics(copies) # 굳힌 단가승격 로직
|
||||
doc = (labor or {}).get("줄") or {}
|
||||
materials = [str(m.get("코드")) for m in doc.get("자재") or []]
|
||||
materials += [ # 고른 로직 자재(103-1) 품목 줄도 — 복사본 계산이 그 단가를 씀
|
||||
@@ -174,8 +178,13 @@ def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]:
|
||||
]
|
||||
materials += refs.frozen_materials((labor or {}).get("참조사본") or {}) # 175-2 사전조합 자재
|
||||
materials += fixed.link_keys(doc) # 705-1 고정 줄이 잇는 노임 · 자재 · 기계
|
||||
materials += sk.materials(copies)
|
||||
wanted = logic_keys + [code for code in materials if code]
|
||||
bundle = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {}
|
||||
bundle = (
|
||||
prices.to_json(prices.collect(logic_keys, materials, sk_rows)) if wanted or sk_rows else {}
|
||||
)
|
||||
if sk_rows:
|
||||
bundle[sk.FILE] = {"그룹": "로직", "원문": "자체", "줄": sk_rows}
|
||||
if mine := own.bundle(
|
||||
[c for c in materials if own.is_own(c)], root
|
||||
): # 개인 자재(148-3 · 사전조합 것도 175-2) — 원본 목록이 바뀌어도 사본 그대로
|
||||
|
||||
@@ -33,6 +33,7 @@ from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste
|
||||
from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink
|
||||
from M02_MasterTemplete import M02_Hopyo_Sangeun as hopyo_sangeun
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
@@ -229,7 +230,8 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
|
||||
when = str(old.get("끝낸때") or "")[:10]
|
||||
raise store.StoreError(
|
||||
409,
|
||||
f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 일위대가 {made}) — 다시 만들지 않음",
|
||||
f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 만든 일위대가 · 단가산출 {made})"
|
||||
" — 다시 만들지 않음",
|
||||
)
|
||||
if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None):
|
||||
return {**same, "이어서": True}
|
||||
@@ -488,6 +490,7 @@ def _finish(folder: Path, key: str) -> dict[str, Any]:
|
||||
made = sorted(
|
||||
{sc for mark, sc in hopyo_build.confirmed(folder).items() if mark.startswith(head)}
|
||||
)
|
||||
made += sorted(hopyo_sangeun.made(folder, key).values()) # 단가산출(SK)
|
||||
if (cache_path := _later("M02_Hopyo_Fit", "cache_path")) is not None:
|
||||
try:
|
||||
got = cache_path(folder, key)
|
||||
@@ -500,6 +503,16 @@ def _finish(folder: Path, key: str) -> dict[str, Any]:
|
||||
return {"지움": key, "만든": len(made)}
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/hopyo/{key}/sangeun")
|
||||
def hopyo_make_sangeun(
|
||||
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
"""불러오기 결과 「단가산출」(718-1) — 산근 블록마다 바탕 없는 단가산출(파일 층 = 이 층) →
|
||||
`{블록, 만듦, 그대로}`."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
return _call(folder, hopyo_sangeun.build, folder, key, _who(session))
|
||||
|
||||
|
||||
class PasteBody(_Strict):
|
||||
"""엑셀 붙여넣기(705-2) — `글` = 붙인 글 그대로 · `열` = 기억한 열 짝(없으면 머리 글 · 짐작 + 물음) ·
|
||||
`줄만` = 열린 문서에 줄로 · `있음` = 문서의 고정 줄 가름(이름 겹침 피함)."""
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Wizard.ts
|
||||
* 일위대가 불러오기 = 한시 마법사(PLAN 718-1 · 요구서 내역12 4장 22) — 본 칸 한 화면 · 걸음 셋:
|
||||
* ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 · 단가산출 = 715-2 뒤 켬) · 엑셀 고르기 → 올림
|
||||
* ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 늘 · 단가산출 체크) · 엑셀 고르기 → 올림
|
||||
* (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 서버 409 안내) · 작업 중 파일 이어서
|
||||
* ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] →
|
||||
* 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층
|
||||
* ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 별칭 장부 남음 · 지문 기록
|
||||
* 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 ·
|
||||
* 단가산출 체크면 [단가산출 만들기] = 산근 블록마다 바탕 없는 단가산출(서버 `M02_Hopyo_Sangeun` · 겹침 없음)
|
||||
* ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 단가산출 · 별칭 장부 남음 · 지문 기록
|
||||
* 작업 중 = 서버에 보관(떠났다 와도 ① 목록에서 이어서) · 옛 HP(지문 없음)는 서버 목록에서 숨김.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
type HopyoFile,
|
||||
type HopyoScope,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { call } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer";
|
||||
|
||||
const LAYERS: { layer: ManageLayer; text: string }[] = [
|
||||
@@ -35,9 +37,6 @@ const STEPS = [
|
||||
{ value: "2", label: "② 보기 · 확인 · 만들기" },
|
||||
{ value: "3", label: "③ 끝내기" },
|
||||
];
|
||||
/** 단가산출 결과 — M02 단가산출 서고(715-2)가 들어오면 켬 */
|
||||
export const SANGEUN_READY = false;
|
||||
const SANGEUN_WHY = "M02 단가산출 칸(715-2)이 들어온 뒤 켬";
|
||||
|
||||
type Got = HopyoFile & { 이어서?: boolean };
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
@@ -70,6 +69,7 @@ export function buildHopyoWizard(): HTMLElement {
|
||||
});
|
||||
let layers: ManageLayer[] = [];
|
||||
let pick: ManageLayer = "personal";
|
||||
let wantSangeun = false; // 결과 「단가산출」 체크
|
||||
const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null });
|
||||
|
||||
async function first(): Promise<void> {
|
||||
@@ -112,15 +112,17 @@ export function buildHopyoWizard(): HTMLElement {
|
||||
});
|
||||
seg.root.dataset.hopyoLayer = "";
|
||||
const ilwi = createCheckbox("일위대가(고정 줄)", { checked: true, disabled: true });
|
||||
const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", { disabled: !SANGEUN_READY });
|
||||
sangeun.root.title = SANGEUN_READY ? "" : SANGEUN_WHY;
|
||||
const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", {
|
||||
checked: wantSangeun,
|
||||
onChange: (on) => (wantSangeun = on),
|
||||
});
|
||||
sangeun.root.dataset.hopyoSangeun = "";
|
||||
const busy = el("div", { className: "m02-hopyo" });
|
||||
body.replaceChildren(
|
||||
createText("결과 층", "muted", 14),
|
||||
el("div", { className: "ui-toolbar", children: [seg.root] }),
|
||||
createText("결과", "muted", 14),
|
||||
el("div", { className: "ui-toolbar", children: [ilwi.root, sangeun.root] }),
|
||||
...(SANGEUN_READY ? [] : [createText(`단가산출 — ${SANGEUN_WHY}`, "hint")]),
|
||||
el("div", { className: "ui-toolbar", children: [upload, input] }),
|
||||
createPanelHead({ title: "작업 중", level: 4, divider: true }).root,
|
||||
busy,
|
||||
@@ -169,6 +171,14 @@ export function buildHopyoWizard(): HTMLElement {
|
||||
)
|
||||
.catch((error: unknown) => showToast(why(error), "error")),
|
||||
});
|
||||
const sangeun = createButton({
|
||||
label: "단가산출 만들기",
|
||||
variant: "filled",
|
||||
onClick: () => void makeSangeun(),
|
||||
});
|
||||
sangeun.title =
|
||||
"엑셀 산근 블록마다 바탕 없는 단가산출(엑셀 단가 그대로 · 이미 만든 블록은 그대로)";
|
||||
sangeun.dataset.hopyoSangeunMake = "";
|
||||
const finish = createButton({ label: "끝내기", variant: "ghost", onClick: () => void end() });
|
||||
finish.dataset.hopyoFinish = "";
|
||||
const back = createButton({ label: "처음으로", variant: "link", onClick: () => void first() });
|
||||
@@ -177,22 +187,44 @@ export function buildHopyoWizard(): HTMLElement {
|
||||
body.replaceChildren(
|
||||
createText(`${file.파일} · ${textOf(layer)} 층 · 호표 ${file.호표수}${wrong}`, "muted", 14),
|
||||
createText(
|
||||
"엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가는 고른 층에 들어감",
|
||||
"엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가 · 단가산출은 고른 층에 들어감",
|
||||
"hint",
|
||||
),
|
||||
el("div", { className: "ui-toolbar", children: [view, finish, back] }),
|
||||
el("div", {
|
||||
className: "ui-toolbar",
|
||||
children: [view, ...(wantSangeun ? [sangeun] : []), finish, back],
|
||||
}),
|
||||
);
|
||||
|
||||
async function makeSangeun(): Promise<void> {
|
||||
sangeun.disabled = true;
|
||||
try {
|
||||
const got = await call<{ 블록: number; 만듦: unknown[]; 그대로: number }>(
|
||||
`/layers/${layer}/hopyo/${encodeURIComponent(file.키)}/sangeun`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const kind = got.블록 ? "success" : "warning";
|
||||
showToast(
|
||||
`단가산출 ${got.만듦.length} 만듦 · 그대로 ${got.그대로} · 산근 블록 ${got.블록}`,
|
||||
kind,
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(why(error), "error");
|
||||
} finally {
|
||||
sangeun.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function end(): Promise<void> {
|
||||
steps.set("3");
|
||||
const ok = await showConfirmDialog(
|
||||
`「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가와 별칭 장부는 남음 · 일위대가를 만들었으면 같은 엑셀은 다시 올리지 못함`,
|
||||
`「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출과 별칭 장부는 남음 · 하나라도 만들었으면 같은 엑셀은 다시 올리지 못함`,
|
||||
"끝내기",
|
||||
);
|
||||
if (!ok) return steps.set("2");
|
||||
try {
|
||||
const got = (await deleteHopyo(scope, file.키)) as { 만든?: number };
|
||||
showToast(`끝냄 — 만든 일위대가 ${got.만든 ?? 0} · 올린 파일 지움`, "success");
|
||||
showToast(`끝냄 — 만든 일위대가 · 단가산출 ${got.만든 ?? 0} · 올린 파일 지움`, "success");
|
||||
} catch (error) {
|
||||
showToast(why(error), "error");
|
||||
return steps.set("2");
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""713-3 지킴 — 로직 목록 덧칸(묶음 · 작업량 부모 · 부르는/불리는 수 · 관련) · 정본 그대로 · 줄 수 그대로."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from M01_MasterData import M01_MasterData_Router as router
|
||||
from M01_MasterData import M01_MasterData_Store_List as store_list
|
||||
|
||||
COLS = {"묶음", "묶음수", "작업량", "부모", "부르는수", "불리는수", "관련"}
|
||||
|
||||
|
||||
def test_목록_줄_수_그대로_덧칸_다_있음() -> None:
|
||||
got = router.get_logics()["logics"]
|
||||
assert len(got) == 1469 # 묶음은 화면 접기만 — 목록 줄은 그대로
|
||||
assert all(COLS <= set(x) for x in got)
|
||||
|
||||
|
||||
def test_묶음_작업량_부르기() -> None:
|
||||
more = store_list.extras()
|
||||
assert sum(1 for v in more.values() if v["묶음"]) == 323 # 한 절에 로직 둘 넘음(조사 12-B ㉯)
|
||||
work = {k for k, v in more.items() if v["작업량"]}
|
||||
assert len(work) == 27
|
||||
assert more["GF000102"]["부모"] == ["GF000103"] # 구조물터파기 작업량 → 단가 로직 밑
|
||||
assert more["GC000268"]["불리는수"] >= 300 # 기계경비 로직
|
||||
assert sum(1 for v in more.values() if v["부르는수"]) == 506
|
||||
assert more["GC000294"]["관련"] == ["GC000292"] # 비고 「1-5-6(주③)」
|
||||
|
||||
|
||||
def test_관련은_해석_글과_넓은_절을_뺌() -> None:
|
||||
more = store_list.extras()
|
||||
assert all(len(v["관련"]) <= store_list._WIDE for v in more.values())
|
||||
assert not any(k in v["관련"] for k, v in more.items()) # 자기 자신 없음
|
||||
@@ -45,7 +45,7 @@ def test_이어서_끝내기_지움_겹침_막음(client):
|
||||
|
||||
block = _post(client) # 끝낸 같은 엑셀 = 막음 · 안내
|
||||
assert block.status_code == 409 and "이미 불러온 엑셀" in block.json()["detail"]
|
||||
assert "일위대가 68" in block.json()["detail"] and _keys(client) == []
|
||||
assert "단가산출 68" in block.json()["detail"] and _keys(client) == []
|
||||
assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before
|
||||
|
||||
|
||||
@@ -65,3 +65,34 @@ def test_옛_HP_목록에서만_숨김(client):
|
||||
assert client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"] == []
|
||||
assert _post(client).json()["키"] == "HP-000008" # 옛 번 뒤로
|
||||
assert old.is_file() and _keys(client) == ["HP-000008"]
|
||||
|
||||
|
||||
def test_결과_단가산출_산근_블록_바탕_없음_겹침_없음(client):
|
||||
folder = layers.personal_dir(7, 42)
|
||||
assert _post(client).json()["키"] == "HP-000001"
|
||||
url = URL.format("personal") + "/HP-000001/sangeun"
|
||||
got = client.post(url)
|
||||
assert got.status_code == 200, got.text
|
||||
got = got.json()
|
||||
assert got["블록"] > 0 and len(got["만듦"]) == got["블록"] and got["그대로"] == 0
|
||||
docs = [json.loads(p.read_bytes()) for p in sorted((folder / "sangeun").glob("SK-*.json"))]
|
||||
assert len(docs) == got["블록"]
|
||||
one = docs[0]
|
||||
assert one["바탕"] is None and one["본뜬"]["키"].startswith("HP-000001 단산")
|
||||
assert one["구분"] == "실무자료" and len(one["더한줄"]) == 1 and one["단위"]
|
||||
hp = hs.read(folder, "HP-000001")
|
||||
no = int(one["본뜬"]["키"].rsplit("단산", 1)[1])
|
||||
line = next( # 그 블록을 부르는 첫 엑셀 줄 = 엑셀 단가 그대로
|
||||
x
|
||||
for h in hp["호표"]
|
||||
for x in h["줄"]
|
||||
if (x.get("참조") or {}).get("종류") == "단산" and x["참조"]["번"] == no
|
||||
)
|
||||
price = one["더한줄"][0]["고정"]["단가"]
|
||||
assert sum(price.values()) == sum((line["단가"] or {}).get(k) or 0 for k in price)
|
||||
again = client.post(url).json() # 다시 = 그대로(겹침 없음)
|
||||
assert again["만듦"] == [] and again["그대로"] == got["블록"]
|
||||
res = client.delete(URL.format("personal") + "/HP-000001").json()
|
||||
assert res["만든"] == got["블록"] # 단가산출만 만들어도 지문 기록
|
||||
assert len(list((folder / "sangeun").glob("SK-*.json"))) == got["블록"] # 남음
|
||||
assert _post(client).status_code == 409
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""719-1 프로젝트 사본 — M02 단가산출(SK) 풀어 굳힘(`M02_Item_Sangeun` · `M02_Item_Source` ·
|
||||
`M02_Item_Prices` · `B09_Estimation_Bill_Library`) — 임시 storage(견본 층 · 실제 M01 읽기만).
|
||||
|
||||
구조물 사본 · B09 서고 사본 셈 = M02 층 SK 셈 · 굳힌 줄은 사본 `단가.json` 로직 줄 · 원본 SK 를
|
||||
지워도 같은 값 · 같은 SK = 같은 굳힌 키(다시 복사 · SK 고친 뒤도) · 원본 층 지문 그대로 ·
|
||||
서고 manifest 원본판 = SK 바뀌면 다름.
|
||||
"""
|
||||
|
||||
# ruff: noqa: F811 — 임시 층 fixture `world` 를 들여 씀
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Item_Sangeun as sk
|
||||
from M02_MasterTemplete import M02_Item_Source as source
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from resources.tester.test_715_sangeun_store import PCT, _sk
|
||||
from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층
|
||||
|
||||
COVER = {"labor:LB000036#0": {"qty": 0.5}}
|
||||
ADD = [{"종류": "인력", "요소": "LB000002", "수량": 0.1}]
|
||||
|
||||
|
||||
def _print(folder):
|
||||
"""층 폴더 지문 — 파일 이름 · 바이트."""
|
||||
out = hashlib.sha256()
|
||||
for path in sorted(p for p in folder.rglob("*") if p.is_file()):
|
||||
out.update(str(path.relative_to(folder)).encode() + path.read_bytes())
|
||||
return out.hexdigest()
|
||||
|
||||
|
||||
def _top(ref, qty=2):
|
||||
with store.at(ref["folder"]):
|
||||
doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref["ref"], "수량": {"손": qty}}]})
|
||||
return doc, labor.calc(doc, {})["줄"][0]
|
||||
|
||||
|
||||
def _mine(world):
|
||||
key = _sk(world["personal"], 바탕={"로직": PCT}, 덮개=COVER, 더한줄=ADD)
|
||||
return key, {"folder": world["personal"], "ref": {**MINE, "키": key}}
|
||||
|
||||
|
||||
def test_구조물_사본_굳힌_줄_값_같음_원본_지워도(world):
|
||||
key, ref = _mine(world)
|
||||
doc, live = _top(ref)
|
||||
assert not live["까닭"] and live["기준"]["계"] > 0
|
||||
before = _print(world["personal"])
|
||||
rows = source.labor_doc_rows(doc)
|
||||
bundle = source.price_bundle(rows, world["personal"])
|
||||
assert _print(world["personal"]) == before # 원본 층 안 건드림
|
||||
rid = refs.ref_id(ref["ref"])
|
||||
frozen = rows["참조사본"][rid]
|
||||
fk = frozen["바탕"]["로직"]
|
||||
assert fk == sk.frozen_key(rid, set()) and fk.startswith("GX9")
|
||||
assert frozen["덮개"] == {} and frozen["더한줄"] == [] and frozen["뺀줄"] == []
|
||||
assert frozen["원본"]["덮개"]["labor:LB000036#0"]["qty"] == 0.5 # 고친 근거
|
||||
assert [r["키"] for r in bundle[sk.FILE]["줄"]] == [fk]
|
||||
json.dumps(rows["참조사본"], ensure_ascii=False) # 사본 파일로 쓸 수 있는 꼴(Decimal 없음)
|
||||
(world["personal"] / "sangeun" / f"{key}.json").unlink() # 원본이 사라져도 사본만 봄
|
||||
got = prices.calc(rows, json.loads(json.dumps(bundle, ensure_ascii=False)), {}, {}, None)
|
||||
line = got["줄"][0]
|
||||
assert not line["까닭"]
|
||||
for part in ("노무비", "재료비", "경비", "계"):
|
||||
assert line["기준"][part] == pytest.approx(live["기준"][part], rel=1e-9)
|
||||
assert line["반영"]["계"] == pytest.approx(live["반영"]["계"], rel=1e-9)
|
||||
|
||||
|
||||
def test_같은_SK_같은_굳힌_키_다른_SK_다른_키(world):
|
||||
key, ref = _mine(world)
|
||||
doc, _ = _top(ref)
|
||||
rid = refs.ref_id(ref["ref"])
|
||||
first = source.labor_doc_rows(doc)["참조사본"][rid]["바탕"]["로직"]
|
||||
with store.at(world["personal"]): # SK 를 고쳐도 굳힌 키 그대로(B09 덮개 짝 지킴)
|
||||
now = store.read("sangeun", key)
|
||||
store.write("sangeun", key, now["판"], {**now["문서"], "덮개": {}})
|
||||
again = source.labor_doc_rows(doc)["참조사본"][rid]
|
||||
assert again["바탕"]["로직"] == first and again["원본"]["덮개"] == {}
|
||||
other = refs.ref_id({"층": "company", "주인": "7", "키": key}) # 같은 번호 다른 층
|
||||
assert sk.frozen_key(other, set()) != first
|
||||
taken = {first}
|
||||
assert sk.frozen_key(rid, taken) != first and len(taken) == 2 # 겹치면 다음 번호
|
||||
|
||||
|
||||
def test_B09_서고_사본_값_같음_원본판(world, tmp_path):
|
||||
_, ref = _mine(world)
|
||||
with store.at(world["personal"]):
|
||||
sc = refs.create(
|
||||
"precombo",
|
||||
{"이름": "조합", "단위": "㎡", "담은로직": [{"참조": ref["ref"], "수량": {"손": 2}}]},
|
||||
)["이름"]
|
||||
_, live = _top({"folder": world["personal"], "ref": {**MINE, "키": sc}}, 1)
|
||||
before = _print(world["personal"])
|
||||
root = tmp_path / "proj"
|
||||
made = library.import_copy(root, "precombo", "personal", "7/42", sc)
|
||||
assert made["만듦"] and _print(world["personal"]) == before
|
||||
folder = library.library_dir(root) / made["폴더"]
|
||||
read = {p.name: json.loads(p.read_text(encoding="utf-8")) for p in folder.glob("*.json")}
|
||||
got = prices.calc(read[source.LABOR_FILE], read[source.PRICE_FILE], {}, {}, None)["줄"][0]
|
||||
assert not got["까닭"]
|
||||
assert got["기준"]["계"] == pytest.approx(live["기준"]["계"], rel=1e-9)
|
||||
mark = read["manifest.json"]["원본판"]
|
||||
built = library._build("precombo", "personal", "7/42", sc)[0]
|
||||
assert library.source_version(built) == mark # 다시 모아도 같은 판
|
||||
again = library.import_copy(root, "precombo", "personal", "7/42", sc)
|
||||
assert again == {"폴더": made["폴더"], "만듦": False}
|
||||
with store.at(world["personal"]): # SK 고침 → 원본판 다름
|
||||
now = store.read("sangeun", ref["ref"]["키"])
|
||||
store.write("sangeun", ref["ref"]["키"], now["판"], {**now["문서"], "덮개": {}})
|
||||
assert library.source_version(library._build("precombo", "personal", "7/42", sc)[0]) != mark
|
||||
|
||||
|
||||
def test_SK_없는_사본_그대로():
|
||||
"""SK 없는 참조사본 = 받은 묶음 그대로(기존 사본 · 공사 금액 전후 같음)."""
|
||||
copies = {"x": {"종류": "사전조합", "담은로직": []}}
|
||||
assert sk.freeze(copies) is copies and sk.rows(copies) == [] and sk.materials(copies) == []
|
||||
@@ -338,9 +338,10 @@ def test_좌측_판_자리와_권한():
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side
|
||||
host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1]
|
||||
# 716-3 차례 — 일위대가 › 구조물 조합 › 구분선 › 불러오기
|
||||
# 716-3 차례 — 일위대가 › 구조물 조합 › 구분선 › 일위대가 불러오기 › 도면 양식
|
||||
order = ("precomboSec,", "summary,", "createDivider(),", "section(hopyo.title, hopyo.body),")
|
||||
assert [host.index(k) for k in order] == sorted(host.index(k) for k in order)
|
||||
assert host.index("section(hopyo.title, hopyo.body),") < host.index("drawing ?")
|
||||
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
|
||||
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
|
||||
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
@@ -355,7 +356,7 @@ def test_좌측_판_자리와_권한():
|
||||
assert 'accept: ".xls,.xlsx"' in wizard and "input.hidden = true;" in wizard
|
||||
assert "accessFor(layer, null, user).canWrite" in wizard # 결과 층 = 쓸 수 있는 층만
|
||||
assert 'let pick: ManageLayer = "personal";' in wizard # 기본 개인
|
||||
assert "export const SANGEUN_READY = false;" in wizard # 단가산출 결과 = 715-2 뒤 켬
|
||||
assert "let wantSangeun = false;" in wizard and "/sangeun`" in wizard # 결과 단가산출 체크
|
||||
|
||||
|
||||
def test_로직_추가_한_곳과_파일_규칙():
|
||||
|
||||
Reference in New Issue
Block a user