feat(M02 사전조합 → 구조물 조합 한 단계): 실무자료 확인 창 [구조물 조합으로] · 규격 묶음 제안 (PLAN 704-4 뒷부분)

- 확인 창 몸 그대로(체크 = 품셈 줄 · 나머지 = 고정 줄)로 구조물 조합 항목(SU)을 바로 만듦 — 사전조합 SC 문서 안 씀 · 옛 [사전조합 생성] 길 그대로
- 규격 묶음 제안 — 같은 이름 · 단위 · 줄 구성 · 규격만 다른 호표 → 한 항목의 규격 열(H=1.5 → 이름 H · 열 1.5) · 열마다 같은 몸으로 만든 줄 수량 · 기본 열 = 연 호표
- 서버 M02_Hopyo_Item · /hopyo/{키}/{번}/siblings · /hopyo-item(이 층 쓰기 · 「실무자료」 폴더) · Builder.build(save=False)
- 규격 열 줄 id 가 고정 줄을 못 알아보던 흠 — line_key = fixed_id(TS lineId 와 같음)
- 만든 뒤 나무 · 옆 목록 다시
- 시험: 울진 대흥 돌기슭막이 H 1.5 / 2.0 / 2.5 = 한 항목 열 셋 · 열마다 = 그 호표 엑셀 합계 · 줄 구성 다른 호표 400 · 남의 층 403 · 사전조합 0 · 마스터 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 16:14:59 +09:00
co-authored by Claude Opus 5.5
parent 175857de81
commit f9ffd5fc77
9 changed files with 434 additions and 5 deletions
+9 -3
View File
@@ -221,7 +221,10 @@ class Builder:
"확인": sure,
}
def build(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> dict:
def build(
self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]], save: bool = True
) -> dict:
"""`save` 거짓 = 사전조합 안 씀 · `{문서, 빠진줄, 줄수}`(704-4 [구조물 조합으로])."""
stamp = utc_now_text()
hopyo, file = self.hopyo, self.doc.get("파일") or ""
lines, mats, missing, used, mat_lines = [], {}, [], set(), {}
@@ -329,12 +332,15 @@ class Builder:
if nation:
doc["본뜬"]["전국자재"] = list(nation.values())
doc["본뜬"]["품셈"] = poomsem
ref, again = _save(doc)
wrote = ledger.write(self.folder, book, self.who, file or None)
fixed_n = sum(1 for x in lines if "고정" in x)
links = sum(1 for x in lines if any(((x.get("고정") or {}).get("이음") or {}).values()))
count = {"품셈": len(poomsem), "고정": fixed_n, "호표": kid_n}
count |= {"빠짐": sum(1 for x in missing if x["까닭"] != TWICE), "이음": links}
if not save:
ledger.write(self.folder, book, self.who, file or None)
return {"문서": doc, "빠진줄": missing, "줄수": {**count, "이음후보": linker.near}}
ref, again = _save(doc)
wrote = ledger.write(self.folder, book, self.who, file or None)
out = {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}}
return {**out, "줄수": {**count, "이음후보": linker.near}}
+145
View File
@@ -0,0 +1,145 @@
"""실무 호표 → 구조물 조합 항목 한 단계(PLAN 704-4 뒷부분 · `_제안_704-4_규격표.md` 6장 끝) — 사전조합 안 거침.
[구조물 조합으로] = 확인 창 몸(체크한 줄 · 통째 · `M02_Hopyo_Build.check` 그대로)으로 줄을 만들어(`build(save=False)` ·
사전조합 SC 문서 안 씀 · 옛 [사전조합 생성] 길은 그대로) 이 층 구조물집계표에 새 항목(SU · `M02_Item_Personal.create_item`
· 경로 「실무자료」) + 그 일위대가 문서에 줄을 담음(수량 = 1 단위 몫 `{손}`).
규격 묶음 = 같은 파일에서 이름 · 단위 같고 줄 구성(줄 이름 · 단위 차례) 같은 · 규격만 다른 호표(5-A 「구성 같은 묶음」) →
한 항목의 규격 열(704-4) — 열 이름 = 규격 글에서 달라지는 칸(「H=1.5」 → 이름 H · 열 1.5) · 아니면 이름 「규격」 ·
열마다 같은 몸으로 만든 줄 수량(줄 차례 같음 · 다르면 400) · 기본 열 = 연 호표.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Build as hb
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Item_Personal as personal
from M02_MasterTemplete import M02_Labor_Spec as spec
from M02_MasterTemplete import M02_MasterTemplete_Store as store
PATH = ["실무자료"]
_TOKEN = re.compile(r"[\s,]+")
_PAIR = re.compile(r"^([A-Za-z가-힣Φφ]+)\s*[=:]?\s*([\d.]+)\D{0,3}$")
def _flat(text: Any) -> str:
return re.sub(r"\s+", "", str(text or ""))
def _shape(h: dict[str, Any]) -> tuple:
return tuple((_flat(x.get("이름")), _flat(x.get("단위"))) for x in h.get("줄") or [])
def columns(specs: list[str]) -> tuple[str, list[str]]:
"""규격 글들 → (규격 이름, 열 이름) — 처음 달라지는 칸이 모두 「H=1.5」 꼴이면 (H, [1.5 …]) ·
아니면 (「규격」, 그 칸 글) · 20 자 자름 · 겹치면 뒤에 차례."""
parts = [[t for t in _TOKEN.split(s.strip()) if t] for s in specs]
width = max((len(p) for p in parts), default=0)
at = next((i for i in range(width) if len({p[i] if i < len(p) else "" for p in parts}) > 1), 0)
cells = [p[at] if at < len(p) else "" for p in parts]
hits = [_PAIR.match(c) for c in cells]
if all(hits) and len({h.group(1) for h in hits}) == 1:
name, cols = hits[0].group(1), [h.group(2) for h in hits]
else:
name, cols = "규격", [c or s for c, s in zip(cells, specs, strict=True)]
out: list[str] = []
for c in cols:
c = (c.strip() or "열")[: spec.NAME_MAX]
while c in out:
c = f"{c[: spec.NAME_MAX - 2]}·{len(out) + 1}"
out.append(c)
return name[: spec.NAME_MAX], out
def siblings(source: Path, key: str, no: int) -> dict[str, Any]:
"""규격 묶음 제안 — 같은 이름 · 단위 · 줄 구성 · 규격 다른 호표(연 호표 먼저) →
`{묶음 [{번, 규격, 열}], 이름}` · 없으면 묶음 빈 것."""
units = hopyo_store.read(source, key).get("호표") or []
me = next((h for h in units if int(h["번"]) == int(no)), None)
if me is None:
raise store.StoreError(404, f"없는 호표 「제{no}호표」")
same = [
h
for h in units
if _flat(h.get("이름")) == _flat(me.get("이름"))
and _flat(h.get("단위")) == _flat(me.get("단위"))
and _shape(h) == _shape(me)
]
same.sort(key=lambda h: (int(h["번"]) != int(no), int(h["번"])))
specs = list(dict.fromkeys(_flat(h.get("규격")) for h in same))
if len(same) < 2 or len(specs) != len(same):
return {"묶음": [], "이름": None}
name, cols = columns([str(h.get("규격") or "") for h in same])
rows = [
{"번": int(h["번"]), "규격": h.get("규격") or "", "열": c}
for h, c in zip(same, cols, strict=True)
]
return {"묶음": rows, "이름": name}
def _doc(folder: Path, source: Path, key: str, no: int, lines: list, whole: Any, who: Any) -> dict:
maker = hb.Builder(folder, source, key, no, who)
checked, pairs = maker.check(list(lines or []), whole)
return {**maker.build(checked, pairs, save=False), "호표": maker.hopyo}
def make_item(
folder: Path,
source: Path,
key: str,
no: int,
lines: list[dict[str, Any]] | None = None,
whole: dict[str, Any] | None = None,
bundle: list[int] | None = None,
name: str | None = None,
who: Any = None,
) -> dict[str, Any]:
"""[구조물 조합으로] → `{key, 열, 이름, 줄수, 열수, 빠진줄}` — `bundle` = 규격 열로 묶을 호표 번(연 호표 빼고)."""
main = _doc(folder, source, key, no, lines, whole, who)
doc, hopyo = main["문서"], main["호표"]
title = (name or "").strip() or doc["이름"]
cols_doc = None
if bundle:
found = {r["번"]: r for r in siblings(source, key, no)["묶음"]}
want = [int(no), *(int(b) for b in bundle if int(b) != int(no))]
if any(b not in found for b in want):
raise store.StoreError(400, "규격 묶음은 이름 · 단위 · 줄 구성이 같은 호표만")
title = (name or "").strip() or str(hopyo.get("이름") or doc["이름"])
qty: dict[str, dict[str, Any]] = {}
for b in want:
got = (
main["문서"]
if b == int(no)
else _doc(folder, source, key, b, lines, whole, who)["문서"]
)
col = found[b]["열"]
for part in ("담은로직", "자재"):
mine, theirs = doc[part], got[part]
if len(mine) != len(theirs):
raise store.StoreError(400, f"제{b}호표 줄 구성이 다름 — 묶지 않고 따로 만듦")
for one, other in zip(mine, theirs, strict=True):
hand = (other.get("수량") or {}).get("손")
if hand is not None:
qty.setdefault(str(spec.line_key(one)), {})[col] = hand
cols_doc = {
"이름": siblings(source, key, no)["이름"],
"열": [found[b]["열"] for b in want],
"기본": found[int(no)]["열"],
"수량": qty,
}
with store.at(folder):
made = personal.create_item(folder, PATH, title, doc.get("단위"))
have = store.read("labor", made["열"])
body = {**have["문서"], "담은로직": doc["담은로직"], "자재": doc["자재"]}
body["단위"] = doc.get("단위") or body.get("단위")
if cols_doc:
body["규격"] = cols_doc
store.write("labor", made["열"], have["판"], body)
count = {
"줄수": len(doc["담은로직"]) + len(doc["자재"]),
"열수": len(cols_doc["열"]) if cols_doc else 1,
}
return {**made, **count, "빠진줄": main["빠진줄"]}
+38
View File
@@ -25,6 +25,7 @@ from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid
from M02_MasterTemplete import M02_Hopyo_Item as hopyo_item
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
@@ -505,3 +506,40 @@ def hopyo_relink_apply(
"""[바꿈] · [되돌리기] — 이 층 사전조합 이음 칸 고쳐 씀(층 쓰기) → `{바뀜, 문서, 되돌림}`."""
folder = _root(session, layer, owner, write=True)
return _call(folder, hopyo_relink.apply, body.바꿈)
@router.get("/layers/{layer}/hopyo/{key}/{no}/siblings")
def hopyo_siblings(
layer: Layer,
key: str,
no: int,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""규격 묶음 제안(704-4) — 이름 · 단위 · 줄 구성 같고 규격만 다른 호표 `{묶음 [{번, 규격, 열}], 이름}`."""
folder = _root(session, layer, owner)
return _call(folder, hopyo_item.siblings, folder, key, no)
class ItemBody(BuildBody):
"""[구조물 조합으로](704-4) — 확인 창 몸 그대로 + `묶음` = 규격 열로 묶을 호표 번 · `이름` = 항목 이름."""
묶음: list[int] = Field(default_factory=list, max_length=20)
이름: str | None = Field(None, max_length=120)
@router.post("/layers/{layer}/hopyo-item")
def hopyo_to_item(
layer: Layer,
body: ItemBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""실무 호표 → 이 층 구조물 조합 항목(사전조합 안 만듦 · 이 층 쓰기 · 파일 층 읽기) →
`{key, 열, 이름, 줄수, 열수, 빠진줄}`."""
folder = _root(session, layer, owner, write=True)
source = _root(session, body.층, body.주인)
lines = [one.model_dump(exclude_none=True) for one in body.줄]
whole = body.통째.model_dump(exclude_none=True) if body.통째 else None
args = (folder, source, body.키, body.번, lines, whole, body.묶음, body.이름, _who(session))
return _call(folder, hopyo_item.make_item, *args)
+5 -1
View File
@@ -16,6 +16,7 @@ from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any
from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_Labor_Ref as refs
NAME_MAX = 20 # 규격 이름 · 열 이름 글자 수 상한 — 화면 칸과 같음
@@ -46,7 +47,10 @@ def _text(raw: Any, what: str) -> str:
def line_key(one: dict) -> Any:
"""줄 id — 참조 줄 = `ref_id` · 로직 줄 = 로직 키 · 자재 줄 = 코드."""
"""줄 id — 참조 줄 = `ref_id` · 고정 줄 = `fixed_id`(705 · 짝 TS `lineId`) · 로직 줄 = 로직 키 ·
자재 줄 = 코드."""
if fixed.is_fixed(one):
return fixed.fixed_id(one[fixed.FIXED])
if isinstance(one.get("참조"), dict):
return refs.ref_id(one["참조"])
return one.get("로직") or one.get("코드")
@@ -492,3 +492,25 @@ export const relinkApply = (
문서: { 키: string; 이름: string; 전: number | null; 후: number | null }[];
되돌림: RelinkChange[];
}> => call(at(scope, "/hopyo-relink"), { method: "POST", body: JSON.stringify({ 바꿈 }) });
/** 규격 묶음 제안(704-4) — 이름 · 단위 · 줄 구성 같고 규격만 다른 호표(연 호표 먼저) */
export const hopyoSiblings = (
scope: HopyoScope,
key: string,
no: number,
): Promise<{ 묶음: { 번: number; 규격: string; 열: string }[]; 이름: string | null }> =>
call(at(scope, `/hopyo/${enc(key)}/${no}/siblings`));
/** [구조물 조합으로](704-4) — 확인 창 몸 그대로 → 이 층 구조물 조합 항목(사전조합 안 만듦) */
export const buildHopyoItem = (
target: HopyoScope,
from: { 층: Layer; 주인: string | null; 키: string; 번: number },
줄: BuildLine[],
통째: { 대상: BuildTarget[] } | null,
묶음: number[],
이름: string,
): Promise<{ key: string; 열: string; 이름: string; 줄수: number; 열수: number }> =>
call(at(target, "/hopyo-item"), {
method: "POST",
body: JSON.stringify({ ...from, ...(통째 ? { 통째 } : { 줄 }), 묶음, 이름 }),
});
@@ -12,6 +12,7 @@
* · 705-3 — 체크 안 한 줄 = 고정 줄(엑셀 숫자 그대로) · 만든 사전조합이 있으면 첫 체크 = 그 품셈 줄(`만든.품셈`) ·
* [품셈으로 바꾸기 (n)] = 맞음 · 별칭 줄 체크 + 생성 · [모두 고정으로] = 체크 모두 끔 + 생성(되돌리기) ·
* 생성 물음에 전후 금액(품셈 = 우리 미리 셈 · 고정 = 엑셀) · 품 다른 줄 수.
* · 704-4 — [구조물 조합으로] = 같은 몸으로 구조물 조합 항목 바로(`Hopyo_ToItem` · 규격 묶음 제안)
* 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build` · `/labor/{열}/calc`) — 화면은 보이기 · 체크 · 고름만.
* ========================================================================== */
@@ -49,6 +50,7 @@ import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
import { calcLabor, fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { draftDoc } from "./logic/M02_MasterTemplete_Hopyo_Draft";
import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem";
import "./M02_MasterTemplete_Hopyo.css";
export const POLL_MS = 3000;
@@ -500,6 +502,15 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
});
}
const toItemButton = (h: HopyoOne["호표"], whole: boolean): HTMLElement =>
createButton({
label: "구조물 조합으로",
size: "sm",
onClick: () =>
void toItem({ scope: opt.scope, target, key: opt.key, no: opt.no, hopyo: h,
lines: whole ? [] : bodyOf(h.줄, d), whole: whole ? { 대상: h.통째!.대상.map(toTarget) } : null }), // prettier-ignore
});
function draw(): void {
if (!got) return;
const h = got.호표;
@@ -545,7 +556,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
});
tools.push(poom, back);
}
tools.push(go);
tools.push(go, toItemButton(h, whole)); // 704-4 — 사전조합 안 거치고 구조물 조합
}
const head = el("div", {
className: "ui-toolbar m02-hopyo__bar",
@@ -0,0 +1,112 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_ToItem.ts
* [구조물 조합으로](PLAN 704-4 뒷부분) — 실무자료 확인 창 몸 그대로 구조물 조합 항목(SU)을 바로 만듦(사전조합 안 거침).
* 창 = 항목 이름(기본 = 호표 이름 · 규격) · 규격 묶음 제안(같은 이름 · 줄 구성 · 규격만 다른 호표 → 한 항목의 규격 열 ·
* 기본 켬 · 끄면 이 호표 하나) · [만들기] → 서버 `/hopyo-item`(이 층 쓰기 · 「실무자료」 폴더) → 나무 다시
* 숫자는 서버가 파일에서 — 화면은 체크 · 고름만
* ========================================================================== */
import {
createButton,
el,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createInputField } from "@ui/ui_template_input";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import {
buildHopyoItem,
hopyoSiblings,
type BuildLine,
type BuildTarget,
type HopyoOne,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
/** 항목을 만들었음(document 알림) — 구조물 조합 나무 · 옆 목록 다시 */
export const ITEM_BUILT = "m02-hopyo-item-built";
export interface ToItemOptions {
scope: HopyoScope;
target: HopyoScope;
key: string;
no: number;
hopyo: HopyoOne["호표"];
lines: BuildLine[];
whole: { 대상: BuildTarget[] } | null;
}
export async function toItem(o: ToItemOptions): Promise<void> {
let sib: Awaited<ReturnType<typeof hopyoSiblings>>;
try {
sib = await hopyoSiblings(o.scope, o.key, o.no);
} catch (error) {
showToast(
`묶음 제안 실패 — ${error instanceof Error ? error.message : String(error)}`,
"error",
);
return;
}
const h = o.hopyo;
const one = [h.이름, h.규격].filter(Boolean).join(" ");
const bundle = sib.묶음.length > 1;
const name = createInputField({ label: "구조물 조합 이름", value: bundle ? h.이름 : one });
const pack = bundle
? createCheckbox(
`규격 열로 묶음 — ${sib.이름}: ${sib.묶음.map((r) => `${r.열}(제${r.번}호표)`).join(" · ")}`,
{
checked: true,
onChange: (on) => (name.input.value = on ? h.이름 : one),
},
)
: null;
openModal({
title: `구조물 조합으로 — 제${o.no}호표`,
closeLabel: "취소",
width: "40rem",
mount: (body, close, actions) => {
const go = createButton({
label: "만들기",
onClick: async () => {
const label = name.input.value.trim();
if (!label) return name.setError("이름이 빔");
const from = { 층: o.scope.layer, 주인: o.scope.owner, 키: o.key, 번: o.no };
const with_ = pack?.input.checked
? sib.묶음.map((r) => r.번).filter((n) => n !== o.no)
: [];
showLoadingOverlay();
try {
const got = await buildHopyoItem(o.target, from, o.lines, o.whole, with_, label);
close();
document.dispatchEvent(new Event(ITEM_BUILT));
showToast(
`구조물 조합 「${got.이름}」 만듦 — 「실무자료」 폴더 · 줄 ${got.줄수}` +
(got.열수 > 1 ? ` · 규격 열 ${got.열수}` : ""),
"success",
6000,
);
} catch (error) {
showToast(
`못 만듦 — ${error instanceof Error ? error.message : String(error)}`,
"error",
);
} finally {
hideLoadingOverlay();
}
},
});
actions.append(go);
body.append(
createText(
"체크한 줄 = 품셈 줄 · 나머지 = 고정 줄(엑셀 숫자 그대로) — 사전조합을 거치지 않고 구조물 조합 항목을 바로 만듦",
"muted",
),
name.root,
...(pack ? [el("div", { children: [pack.root] })] : []),
);
},
});
}
@@ -50,6 +50,7 @@ import {
} from "./M02_MasterTemplete_TreePanel_Roots";
import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main";
import { buildSide } from "./M02_MasterTemplete_UI_Side";
import { ITEM_BUILT } from "./M02_MasterTemplete_Hopyo_ToItem";
import { precomboList, type PrecomboScope } from "./M02_MasterTemplete_UI_Side_Precombo";
import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick";
import "./M02_MasterTemplete_UI_Style.css";
@@ -156,6 +157,12 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
go: (next) => void go("personal", next, open),
})
: panel;
// 704-4 [구조물 조합으로] 만든 항목 → 나무 · 옆 목록 다시(페이지가 내려가면 듣기 끝)
const onItem = (): void =>
tree.root.isConnected
? void Promise.all([side.refresh(), tree.refresh()])
: document.removeEventListener(ITEM_BUILT, onItem);
document.addEventListener(ITEM_BUILT, onItem);
const main = createMain(
isAdmin,
() => void Promise.all([side.refresh(), tree.refresh()]),
@@ -273,3 +273,87 @@ def test_B09_붙인_줄_거울_정리_수요():
assert stone["prices"]["labor"] == 258935 and stone["qty"] == 0.1 # 이은 단가 · 덮개
pct = next(ln for ln in dg9["lines"] if ln["kind"] == "pct" and ln["cid"].startswith(ADD))
assert pct["amounts"]["expense"] > 0 and py["daega_orphans"] == []
# ── 704-4 뒷부분 — [구조물 조합으로] · 규격 묶음 ─────────────────────────────────
def _upload(client, tag):
from resources.tester.test_m02_hopyo_excel import _post
path = DOCS / FILES[tag][0]
res = _post(client, data=path.read_bytes(), name=path.name)
assert res.status_code == 200, res.text
return res.json()["키"]
def _item_doc(column):
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
with store.at(layers.personal_dir(7, 42)):
table = store.read("table", store.STRUCTURE_TABLE)["문서"]
col = next(c for c in table["열"] if c.get("id") == column)
return store.read("labor", column)["문서"], col
def _total(doc, col=None):
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Spec as spec
doc = {**doc, "규격": {**doc["규격"], "기본": col}} if col else doc # 셈 = 기본 열
return labor.calc(labor.clean(spec.at(doc, col)), {})["반영합"]["계"]
def layers_dir():
from M02_MasterTemplete import M02_Template_Layers as layers
return layers.personal_dir(7, 42)
def test_구조물_조합으로_규격_묶음_울진_돌기슭막이(client):
before = master_hash()
key = _upload(client, "대흥")
sib = client.get(f"/api/m02/layers/personal/hopyo/{key}/17/siblings").json()
assert sib["이름"] == "H" and [(r["번"], r["열"]) for r in sib["묶음"]] == [
(17, "1.5"), (24, "2.0"), (25, "2.5")
] # fmt: skip
body = {"층": "personal", "키": key, "번": 17, "묶음": [24, 25]}
res = client.post("/api/m02/layers/personal/hopyo-item", json=body)
assert res.status_code == 200, res.text
got = res.json()
assert got["열수"] == 3 and got["이름"].endswith("돌기슭막이(찰쌓기,기초무,구입)")
doc, col = _item_doc(got["열"])
assert col["경로"] == ["실무자료", "돌기슭막이(찰쌓기,기초무,구입)"] and doc["규격"]["열"] == [
"1.5",
"2.0",
"2.5",
]
assert doc["규격"]["기본"] == "1.5" and all("고정" in x for x in doc["담은로직"])
excel = {
h["번"]: h["금액"]["계"]
for h in client.get(f"/api/m02/layers/personal/hopyo/{key}").json()["호표"]
if h["번"] in (17, 24, 25)
}
for no, c in ((17, "1.5"), (24, "2.0"), (25, "2.5")):
assert abs(_total(doc, c) - excel[no]) <= excel[no] * 1e-4 # 열마다 = 그 호표 엑셀 합계(끝수 차)
one = client.post("/api/m02/layers/personal/hopyo-item", json={**body, "묶음": []}).json()
single, _ = _item_doc(one["열"])
assert not single.get("규격") and one["이름"].endswith("H=1.5 L3=45cm")
bad = client.post("/api/m02/layers/personal/hopyo-item", json={**body, "묶음": [3]})
assert bad.status_code == 400 # 줄 구성 다른 호표
precombo = layers_dir() / "precombo"
assert not list(precombo.glob("SC-*.json")) # 사전조합 문서 안 만듦
assert master_hash() == before
def test_구조물_조합으로_남의_층_403(client):
key = _upload(client, "대흥")
body = {"층": "personal", "키": key, "번": 17}
assert client.post("/api/m02/layers/company/hopyo-item", json=body).status_code == 403
def test_규격_열_이름():
from M02_MasterTemplete import M02_Hopyo_Item as item
assert item.columns(["H=1.5 L3=45cm", "H=2.0 L3=45cm"]) == ("H", ["1.5", "2.0"])
assert item.columns(["일반형", "ㄷ형"]) == ("규격", ["일반형", "ㄷ형"])
assert item.columns(["D800", "D1000"]) == ("D", ["800", "1000"])