Merge remote-tracking branch 'origin/dev' into sub_laptop_6

This commit is contained in:
2026-10-31 15:24:17 +09:00
42 changed files with 2497 additions and 41 deletions
@@ -1,12 +1,14 @@
"""B07 구조물 도면(376 · 요구 241-1) — B05 · B06 배치에 연결된 구조물 조합 도면 목록 · 단건.
조합 키 = 항목 사본 폴더 이름(`snapshot_folder_name`) — 같은 조합을 여러 배치가 써도 한 장 ·
조합 키 = 항목 사본 폴더 이름(`snapshot_folder_name`) + 규격 열(704-4 · `-s해시`) — 같은 조합 ·
같은 열을 여러 배치가 써도 한 장 · 열이 다르면 따로(표제 = 항목 이름 「… (H=2.0)」) ·
더한장은 장마다 · id `structure_{키}[_{장id}]` · 차례 = 도번 · 도면 = 프로젝트 사본 `도면.json` +
얹는 도각(M02 `structure_frame` · 도번 · 도면명). 고치기는 M02 · B08 — 여기는 읽기만.
"""
from __future__ import annotations
import hashlib
import logging
import re
from pathlib import Path
@@ -38,6 +40,8 @@ def structure_sheets(project_root: Path) -> list[dict[str, Any]]:
if "도면" not in copy["부분"] or not item.get("key"):
continue
key = snapshot_folder_name(item)
if item.get("spec"): # 규격 열(704-4) — 열 이름은 아무 글이라 id 에는 해시만
key += f"-s{hashlib.sha1(str(item['spec']).encode()).hexdigest()[:8]}"
if key in docs:
continue
doc = read_json(snapshot.structures_dir(project_root) / copy["폴더"] / "도면.json")
+139
View File
@@ -0,0 +1,139 @@
"""B09 재료 창고 「지난 공사」(PLAN 703-1) — 같은 회사 다른 공사 일위대가 → 이 공사 서고 사본.
목록 = 그 공사 구조물 사본(`S:` · 일위대가 있는 것) · 서고 사본(`L:` · 사전조합 · 구조물조합) ·
고친 칸 수 = 그 공사 저장 덮개(블록 cover — 조합 `S:`/`L:폴더` · 사전조합 `C:참조` · 짝
`Labor_Source.Builder`) · 같은 원본 · 같은 값 · 같은 덮개 = 한 줄
가져옴 = 사본 폴더 통째 → 이 공사 `library/{종류}@{층}@{주인}@{키}~{공사 · 사본 해시}`(구조물 사본 =
서고 구조물조합과 한 꼴) · manifest `가져온곳` = 그 공사 · 사본 · 다시 고르면 같은 사본 ·
덮개 = 그 공사 저장 칸 + 이 공사 블록 cover(조합 = `L:새 폴더` · 사전조합 = 같은 `C:참조`) →
화면 캐시가 그 cover 가 비었을 때만 얹음([저장] 때 남음) ·
단가 = 이 공사 단가 책(셈이 이 공사 것) · 권한 = 부르는 쪽(같은 회사).
"""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation import B09_Estimation_Store as store
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete.M02_Item_Snapshot import (
FOLDER_PATTERN,
MANIFEST_NAME,
PICKS_FILE,
VALUES_FILE,
linked_snapshots,
now_text,
structures_dir,
)
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, read_json
def _covers(root: Path) -> dict[str, Any]:
return store.read_user(root).get("daega_cover") or {}
def _cells(cover: Any) -> dict[str, Any]:
"""덮개 칸만(복사본 표지 `@copy` 뺌)."""
return {k: v for k, v in (cover or {}).items() if not k.startswith("@")}
def cover_of(head: str, folder: Path, kind: str) -> str:
"""사본 폴더의 대가 블록 cover — 사전조합 = 참조(공사 안 같은 사전조합 한 덮개)."""
if kind != "precombo":
return f"{head}:{folder.name}"
ref = ((read_json(folder / MANIFEST_NAME) or {}).get("원본") or {}).get("참조") or {}
return f"C:{refs.ref_id(ref)}"
def _row(folder: Path, key: str, kind: str, cover: dict[str, Any]) -> dict[str, Any]:
manifest = read_json(folder / MANIFEST_NAME) or {}
origin = manifest.get("원본") or {}
path = origin.get("경로") or []
if kind == "precombo":
name, spec, unit = origin.get("name") or folder.name, "", origin.get("단위") or ""
else: # 짝 `Labor_Source.Builder.combo` 이름 · 규격 · 단위
labor = read_json(folder / LABOR_FILE) or {}
name = origin.get("name") or " ".join(path) or folder.name
spec, unit = " › ".join(path), (labor.get("줄") or {}).get("단위") or ""
cells = _cells(cover.get(cover_of(key[0], folder, kind)))
sig = library.tag(
{
"원본": {k: origin.get(k) for k in ("layer", "owner", "key", "참조")},
"값": [read_json(folder / f) for f in (VALUES_FILE, PICKS_FILE)],
"덮개": cells,
}
)
kind_text = "사전조합" if kind == "precombo" else "구조물 조합"
return {"cover": key, "name": name, "spec": spec, "unit": unit, "kind": kind_text,
"covers": len(cells), "sig": sig} # fmt: skip
def rows_of(root: str | Path) -> list[dict[str, Any]]:
"""한 공사 가져올 일위대가 — 구조물 사본 → 서고 사본 · 같은 것 한 줄."""
root = Path(root)
cover = _covers(root)
out: list[dict[str, Any]] = []
for info in sorted(linked_snapshots(root).values(), key=lambda i: i["폴더"]):
if "일위대가" in info.get("부분", []):
folder = structures_dir(root) / info["폴더"]
out.append(_row(folder, f"S:{folder.name}", "combo", cover))
for folder, manifest in library.copies(root):
out.append(_row(folder, f"L:{folder.name}", str(manifest.get("종류")), cover))
seen: set[str] = set()
return [r for r in out if not (r["sig"] in seen or seen.add(r["sig"]))]
def _source(other: Path, cover_key: str) -> tuple[Path, str, str, str | None, str]:
"""덮개 열쇠 → (사본 폴더, 종류, 층, 주인, 키) · 틀린 모양 ValueError · 없음 LookupError."""
head, _, rest = cover_key.partition(":")
if head == "S" and FOLDER_PATTERN.match(rest):
folder = structures_dir(other) / rest
origin = (read_json(folder / MANIFEST_NAME) or {}).get("원본") or {}
kind, layer, owner, key = (
"combo",
origin.get("layer"),
origin.get("owner"),
origin.get("key"),
)
elif head == "L" and library.NAME.match(rest):
folder = library.library_dir(other) / rest
kind, layer, owner, key = rest.split("~")[0].split("@")
owner = None if owner == "-" else owner.replace("-", "/")
else:
raise ValueError(f"가져올 일위대가 모양이 틀림 「{cover_key}」")
if not isinstance(read_json(folder / MANIFEST_NAME), dict) or not layer or not key:
raise LookupError(f"지난 공사에 그 일위대가가 없음 「{cover_key}」")
return folder, kind, str(layer), owner, str(key)
def import_past(
root: str | Path,
other: str | Path,
other_ref: dict[str, Any],
cover_key: str,
who: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""지난 공사 사본 하나 → 이 공사 서고 사본 — `{폴더, 만듦, 덮개, 덮개자리}` ·
덮개 = 그 공사 저장 칸 · 덮개자리 = 이 공사 블록 cover."""
src, kind, layer, owner, key = _source(Path(other), cover_key)
tag = {"공사": str(other_ref.get("project_id")), "사본": cover_key}
name = library.folder_name(kind, layer, owner, key, tag)
target = library.library_dir(root) / name
cells = _cells(_covers(Path(other)).get(cover_of(cover_key[0], src, kind)))
mine = cover_of("L", src, kind) if kind == "precombo" else f"L:{name}"
out = {"폴더": name, "덮개": cells, "덮개자리": mine}
if isinstance(read_json(target / MANIFEST_NAME), dict):
return {**out, "만듦": False}
if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리
shutil.rmtree(target)
shutil.copytree(src, target, ignore=shutil.ignore_patterns(MANIFEST_NAME))
old = read_json(src / MANIFEST_NAME) or {}
manifest = {k: old[k] for k in ("원본", "값", "원값") if k in old}
manifest.update({"종류": kind, "가져온날": now_text(), "가져온이": who})
manifest["가져온곳"] = {**other_ref, "사본": cover_key}
atomic_write_json(target / MANIFEST_NAME, manifest) # 마지막 — 있으면 다 쓴 폴더
return {**out, "만듦": True}
+66
View File
@@ -35,6 +35,12 @@
「내 자료집에 저장」(703-2) — 펼친 일위대가 → 개인 층 사전조합
(`B09_Estimation_Daega_Personal`) · user = 화면 캐시 덮개 ·
같은 이름 = 409 {같은이름} → 덮어쓰기(overwrite = 키) · 새 이름
GET /api/projects/{id}/estimation/past
재료 창고 「지난 공사」(703-1) — 같은 회사 다른 공사의 일위대가 목록(`B09_Estimation_Past`)
POST /api/projects/{id}/estimation/past {project_id, cover}
지난 공사 일위대가 → 이 공사 서고 사본(덮개째) → {폴더, 만듦, key, cover, 덮개, payload} ·
덮개 = 그 공사 저장 칸(화면 캐시 cover 가 비었을 때만 얹음 · 같은 꼴 블록이 먼저 있으면 빈 것) ·
같은 회사만(남의 회사 403)
운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`).
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
@@ -66,6 +72,7 @@ from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milesto
from B08_Quantity.B08_Quantity_Router_Milestone import site_info
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation import B09_Estimation_Daega_Personal as personal
from B09_Estimation import B09_Estimation_Past as past
from B09_Estimation import B09_Estimation_Prices as prices
from B09_Estimation import B09_Estimation_Refresh as refresh
from B09_Estimation import B09_Estimation_Sangeun as sangeun
@@ -82,10 +89,12 @@ from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials
from common_util.common_util_auth import verify_session
from common_util.common_util_project_settings import reset_section
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow_state import mark_stage
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete.M02_Item_Snapshot import importer
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import _call, _root
from M02_MasterTemplete.M02_MasterTemplete_Router_Layers import _company_projects, _project
try: # 252 공기 초안(draft 폴더 · 지우면 공기 탭 원천 없이)
from B09_Estimation.draft import B09_Draft_Schedule_Source as schedule_draft
@@ -488,6 +497,63 @@ async def save_personal(
}
def _past_rows(projects: list[dict[str, Any]], here: str) -> list[dict[str, Any]]:
out = []
for other in projects:
if other["project_id"] == here or not other.get("storage_path"):
continue
try:
rows = past.rows_of(Path(resolve_stored_project_path(other["storage_path"])))
except (ValueError, OSError): # 못 읽는 공사 = 건너뜀
continue
ref = {"project_id": other["project_id"], "project": other["name"]}
out += [{**ref, **row} for row in rows]
return out
@router.get("/{project_id}/estimation/past")
async def past_list(
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
here = await _project(session, str(project_id))
projects = await _company_projects(int(here["company_id"]))
return {"rows": await asyncio.to_thread(_past_rows, projects, str(project_id))}
class PastBody(BaseModel):
project_id: str = Field(min_length=1, max_length=64)
cover: str = Field(min_length=3, max_length=200)
@router.post("/{project_id}/estimation/past")
async def past_import(
project_id: UUID, body: PastBody, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
here = await _project(session, str(project_id))
if body.project_id == str(project_id):
raise HTTPException(status_code=400, detail="이 공사 것은 「이 공사」 탭에서 고름")
other = await _project(session, body.project_id)
if other["company_id"] != here["company_id"]:
raise HTTPException(status_code=403, detail="같은 회사 공사에서만 가져옴")
ref = {"project_id": body.project_id, "프로젝트": other.get("name")}
try:
made = await asyncio.to_thread(
past.import_past, here["root"], other["root"], ref, body.cover, importer(session)
)
except LookupError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
out = await payload(project_id, here["root"])
daega = out["inputs"].get("daega") or {}
key = (daega.get("libs") or {}).get(made["폴더"])
# 같은 꼴 블록이 이 공사에 먼저 있으면 그 블록(다른 cover) — 덮개 안 옮김(그 블록 덮개 그대로)
cover = next((b["cover"] for b in daega.get("blocks") or [] if b["id"] == key), None)
if cover != made.pop("덮개자리"):
made["덮개"] = {}
return {**made, "key": key, "cover": cover, "payload": out}
# 166-4 견적서 파일 길 — 같은 머리 · 같은 권한
router.include_router(quotes_router)
+27
View File
@@ -274,3 +274,30 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child {
.b09-popup .ui-data-table {
max-height: none;
}
/* 703-1 재료 창고 — 오른쪽 서랍 몸(탭 · 찾기 목록 · 안내) 세로 쌓음 · 목록만 스크롤 */
.b09-store {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
height: 100%;
min-height: 0;
}
.b09-store .ui-find {
flex: 1 1 auto;
min-height: 0;
}
/* 703-1 표 칸 + 오른쪽 창고 — 창고가 DOM 앞(표 높이 셈) · 보이는 차례는 표 먼저 */
.b09-estimation__work {
display: flex;
flex: 1 1 auto;
flex-direction: column;
order: -1;
min-width: 0;
}
.ui-side-panels-row > .ui-side-panels.b09-store__side {
flex: none;
}
+7 -1
View File
@@ -19,6 +19,7 @@ import { createText } from "@ui/ui_template_text";
import { createBanner } from "@ui/ui_template_banner";
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
@@ -290,7 +291,12 @@ export function renderB09Estimation(root: HTMLElement): void {
const fold = bill?.fold?.(table.root); // 702-1 줄 펼침 = 표 아래
// 701-3 띠(원가계산서 「빈 줄 n · 미완성」) = 표 위
const banner = spec.banner ? [createBanner({ tone: "warning", text: spec.banner })] : [];
panel.replaceChildren(...banner, table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : []));
const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])];
const side = bill?.side?.(table.root); // 703-1 재료 창고 = 표 오른쪽 서랍
const main = side ? el("div", { className: "b09-estimation__work", children: body }) : null;
// 서랍 = DOM 앞(표 높이 셈 `spaceBelow` 가 뒤 형제만 봄) · 보이는 차례 = CSS order
const row = main && el("div", { className: "ui-side-panels-row", children: [side!, main] });
panel.replaceChildren(...banner, ...(row ? [row] : body));
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
};
+370
View File
@@ -0,0 +1,370 @@
/* =============================================================================
* B09_Estimation_UI_Store.ts
* B09 설계내역서 오른쪽 재료 창고(PLAN 703-1) — 찾기 하나 + 출처 탭(이 공사 · 내 것 · 회사 · 품셈 · 지난 공사).
*
* 두 번 누름 · Enter = 고른 내역 줄(`…_Bill_Level` 고름)에 단가연결 · 끌어 놓기 = 놓은 줄에 단가연결 ·
* 우클릭 = [고른 줄에 연결] · 품셈 = [계산서 보기](704-2 `Labor_Sheet` · [담기] = 연결)
* 연결 = 단가연결 모달과 같은 길(`…_Bill_Link` · 702-3 즉시 금액) — 호표 = 연결 · 조합 · 사전조합 = 사본 들여옴 ·
* 품셈 = 넣을 값 창(대표 규격 채움) · 지난 공사 = 서버가 이 공사 서고 사본으로(`…_Past` · 덮개째 ·
* 단가 = 이 공사 단가 책) → 받은 덮개를 그 cover 가 비었을 때만 화면 캐시에 얹음
* 틀 = 공용 오른쪽 서랍(`ui_template_side_panels` · 접고 펴기 · 폭 끌기 · 좁은 화면 = 아래로)
* 창고 하나 = 페이지 한 벌(다시 그려도 찾기 글 · 탭 · 받은 목록 남음) · 이 공사 = 그릴 때마다 새로
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { DataRow } from "@ui/ui_template_data_table";
import { el, showToast } from "@ui/ui_template_elements";
import { createFindList, type FindRow } from "@ui/ui_template_find_list";
import { loadRows } from "@ui/ui_template_item_picker";
import { itemName } from "@ui/ui_template_item_picker_data";
import { matchLogic } from "@ui/ui_template_logic_find";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { createTabs } from "@ui/ui_template_tabs";
import { createText } from "@ui/ui_template_text";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import {
fetchLibrary,
fetchStandard,
fetchStandardSheet,
type StandardRow,
} from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import { openStandardSheet } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet";
import { ownOwner } from "../M02_MasterTemplete/M02_MasterTemplete_Layer";
import type { Row } from "./B09_Estimation_Calc_Base";
import type { TabContext } from "./B09_Estimation_UI_Tabs";
import { applyAt, openMenuAt } from "./B09_Estimation_UI_Tabs_Bill_Edit";
import { askInputs, importLib, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link";
import { pickedKey } from "./B09_Estimation_UI_Tabs_Bill_Level";
type Source = "here" | "mine" | "company" | "poomsem" | "past";
const SOURCES: Array<[Source, string]> = [
["here", "이 공사"],
["mine", "내 것"],
["company", "회사"],
["poomsem", "품셈"],
["past", "지난 공사"],
];
/** 한 번에 그리는 줄 — 품셈 1만 줄 · 찾기로 좁힘 */
const SHOWN = 200;
const DRAG = "text/x-b09-store";
interface Item {
tag: string;
name: string;
sub: string;
unit: string;
hit: (q: string) => boolean;
go: (ctx: TabContext, apply: ApplyRef) => void;
sheet?: (put: () => void) => void;
}
const words = (q: string): string[] => q.toLowerCase().split(/\s+/).filter(Boolean);
const has = (text: string) => (q: string) => words(q).every((w) => text.toLowerCase().includes(w));
const onPayload = (ctx: TabContext) => (p: Row) => Object.assign(ctx.payload, p);
function hereItems(ctx: TabContext): Item[] {
const of = (tab: "dg" | "sg", list: Row[] | undefined): Item[] =>
(list || []).map((r) => {
const tag = tab === "dg" ? `일위대가 ${r.no}호` : `단가산출 ${r.no}호`;
const name = String(r.name || "");
return {
tag,
name,
sub: String(r.spec || ""),
unit: String(r.unit || ""),
hit: has(`${tag} ${name} ${r.spec || ""}`),
go: (_c, apply) => apply({ tab, key: r.key }, r),
};
});
return [...of("dg", ctx.sheets.daega), ...of("sg", ctx.sheets.sg_list)];
}
/** 내 것 · 회사 — 구조물 조합(그 층) + 사전조합(그 층 일위대가) */
async function layerItems(layer: "personal" | "company"): Promise<Item[]> {
const owner = ownOwner(layer, await fetchSessionUser().catch(() => null));
const combos = (await loadRows().catch(() => [])).filter((r) => r.layer === layer);
const pres = owner ? await fetchLibrary({ 층: layer, 주인: owner }, "precombo") : [];
return [
...combos.map((r) => {
const name = itemName(r);
const pick = { kind: "combo" as const, layer, owner: r.owner ?? null, key: r.key };
return {
tag: "구조물 조합",
name,
sub: "",
unit: String(r.단위 || ""),
hit: has(`구조물 조합 ${name}`),
go: (ctx: TabContext, apply: ApplyRef) =>
void importLib({ apply, onPayload: onPayload(ctx) }, pick),
};
}),
...pres.map((r) => {
const name = r.제목 || r.이름;
const pick = { kind: "precombo" as const, layer, owner, key: r.이름 };
return {
tag: "일위대가",
name,
sub: [r.구분, r.상세구분].filter(Boolean).join(" "),
unit: String(r.단위 || ""),
hit: has(`일위대가 ${name} ${r.구분 ?? ""} ${r.상세구분 ?? ""}`),
go: (ctx: TabContext, apply: ApplyRef) =>
void importLib({ apply, onPayload: onPayload(ctx) }, pick),
};
}),
];
}
/** 넣을 값 창 — 대표 규격 = 계산서(704-2)가 셈에 쓴 입력 전부(목록 `바꿈` 은 기본과 다른 것만 ·
* 기본을 다르게 보는 칸이 빠짐 — 석축헐기 뒷길이 60 · 0) · 못 받으면 `바꿈` */
function askStandard(apply: ApplyRef, r: StandardRow): void {
const open = (given: Row): void => askInputs(apply, r.키, r.이름, { given });
fetchStandardSheet(r).then(
(sheet) => open((sheet as { 입력?: Row }).입력 ?? r.바꿈),
() => open(r.바꿈),
);
}
/** 품셈 = 704-1 기준 목록(품명 · 규격 · 금액 · 실무 낱말 찾기) · 연결 = 넣을 값 창(대표 규격 채움) */
async function poomsemItems(): Promise<Item[]> {
const list = await fetchStandard();
return list.줄.map((r) => ({
tag: r.공종 || "품셈",
name: r.이름,
sub: [r.규격, r.금액 === null ? "" : `${Math.round(r.금액).toLocaleString("ko-KR")}원`]
.filter(Boolean)
.join(" · "),
unit: r.단위,
hit: (q) => !q || matchLogic(q, { ...r, 이름: `${r.이름} ${r.규격}` }).ok,
go: (_c, apply) => askStandard(apply, r),
sheet: (put) => openStandardSheet(r, put),
}));
}
async function call(path: string, body?: Row): Promise<Row> {
const projectId = currentProjectId();
if (!projectId) throw new Error("프로젝트 없음");
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/${path}`, {
method: body ? "POST" : "GET",
credentials: "include",
headers: { "Content-Type": "application/json" },
...(body && { body: JSON.stringify(body) }),
});
const got = (await response.json().catch(() => null)) as Row | null;
if (!response.ok || !got) {
const detail = got?.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
return got;
}
/** 지난 공사 가져옴 — 이 공사 서고 사본 · 덮개 = 그 cover 가 비었을 때만 얹음 */
async function importPast(ctx: TabContext, apply: ApplyRef, r: Row): Promise<void> {
showToast("지난 공사에서 가져오는 중…", "info");
try {
const got = await call("past", { project_id: r.project_id, cover: r.cover });
if (!got.key) throw new Error("사본은 들여왔으나 일위대가를 못 만듦");
onPayload(ctx)(got.payload);
const from = ((got.payload.sheets?.daega as Row[]) || []).find((x) => x.key === got.key);
const cells: Row = got.덮개 || {};
const n = Object.keys(cells).length;
let note = "";
if (n && got.cover) {
const all: Row = { ...(ctx.user.daega_cover || {}) };
if (Object.keys(all[got.cover] || {}).length) note = " · 이 공사에 고친 칸이 있어 그대로 둠";
else {
all[got.cover] = cells;
ctx.user = { ...ctx.user, daega_cover: all }; // 아래 연결이 같은 캐시에 씀
note = ` · 고친 칸 ${n} 함께 옮김`;
}
}
apply({ tab: "dg", key: got.key, lib: got.폴더 }, from);
showToast(`「${r.project}」 일위대가로 연결함${note} — [저장]해야 남음`, "success");
} catch (error) {
showToast(`못 가져옴 — ${error instanceof Error ? error.message : error}`, "error");
}
}
async function pastItems(): Promise<Item[]> {
const got = await call("past");
return ((got.rows as Row[]) || []).map((r) => ({
tag: String(r.project || ""),
name: String(r.name || ""),
sub: [r.kind, r.covers ? `고친 칸 ${r.covers}` : ""].filter(Boolean).join(" · "),
unit: String(r.unit || ""),
hit: has(`${r.project} ${r.name} ${r.spec} ${r.kind}`),
go: (ctx, apply) => void importPast(ctx, apply, r),
}));
}
const LOAD: Record<Exclude<Source, "here">, () => Promise<Item[]>> = {
mine: () => layerItems("personal"),
company: () => layerItems("company"),
poomsem: poomsemItems,
past: pastItems,
};
/** 페이지 한 벌 창고 */
let store: {
root: HTMLElement;
bind: (ctx: TabContext, raw: DataRow[], table: HTMLElement) => void;
} | null = null;
function makeStore(): NonNullable<typeof store> {
let ctx: TabContext | null = null;
let raw: DataRow[] = [];
let source: Source = "here";
const got: Partial<Record<Source, Item[] | "loading" | Error>> = {};
let shown: Item[] = [];
const finder = createFindList({ placeholder: "품명 · 규격 · 실무 낱말로 찾기" });
const tabs = createTabs({
tabs: SOURCES.map(([key, label]) => ({ key, label })),
value: source,
kind: "pill",
onChange: (key) => {
source = key as Source;
draw();
},
});
const guide = createText(
"두 번 누름 = 고른 내역 줄에 단가연결 · 끌어서 내역 줄에 놓기 = 그 줄에 연결 · 우클릭 = 계산서 보기(품셈)",
);
/** 그 줄에 연결 — 줄 = 고른 내역 줄(index 없음) · 연결 못 하는 줄이면 알림 */
const linkTo = (item: Item, at?: number): void => {
if (!ctx) return;
const key = pickedKey();
const i = at ?? raw.findIndex((r) => String(r.key) === key);
const apply = i >= 0 ? applyAt(ctx, raw, i) : null;
if (!apply)
return void showToast("내역 항목 줄을 먼저 누름(자재 · 묶음 줄은 연결 없음)", "info");
item.go(ctx, apply);
};
const draw = (): void => {
const now = source === "here" ? (ctx ? hereItems(ctx) : []) : got[source];
if (source !== "here" && now === undefined) void load(source);
if (!Array.isArray(now)) {
finder.hint.textContent = now instanceof Error ? `못 읽음 — ${now.message}` : "읽는 중…";
return finder.setRows([]);
}
const q = finder.search.value.trim();
const found = now.filter((x) => x.hit(q));
shown = found.slice(0, SHOWN);
const more = found.length > SHOWN ? ` · 앞 ${SHOWN} 줄만 — 찾기로 좁힘` : "";
finder.hint.textContent = found.length
? `${found.length} / ${now.length}${more}`
: now.length
? "맞는 것 없음"
: source === "past"
? "가져올 지난 공사 일위대가 없음(같은 회사 공사만)"
: "비었음";
finder.setRows(
shown.map<FindRow>((x, i) => ({
key: String(i),
tag: x.tag,
name: x.name,
sub: x.sub,
unit: x.unit,
onPick: () => linkTo(x),
})),
);
for (const row of finder.root.querySelectorAll<HTMLElement>("[data-pick]")) {
row.draggable = true;
row.addEventListener("dragstart", (ev) => ev.dataTransfer?.setData(DRAG, row.dataset.pick!));
}
for (const [key] of SOURCES) {
const list = key === "here" ? (ctx ? hereItems(ctx) : []) : got[key];
tabs.setBadge(key, Array.isArray(list) ? list.length : 0);
}
};
const load = async (key: Exclude<Source, "here">): Promise<void> => {
got[key] = "loading";
try {
got[key] = await LOAD[key]();
} catch (error) {
got[key] = error instanceof Error ? error : new Error(String(error));
}
draw();
};
finder.search.addEventListener("input", draw);
// 한 번 누름 = 고름만(두 번째 누름 · 키보드 = 연결)
finder.root.addEventListener(
"click",
(ev) => {
if ((ev.target as Element).closest("[data-pick]") && (ev as MouseEvent).detail === 1)
ev.stopPropagation();
},
true,
);
finder.root.addEventListener("contextmenu", (ev) => {
const row = (ev.target as Element).closest<HTMLElement>("[data-pick]");
const item = row ? shown[Number(row.dataset.pick)] : undefined;
if (!item) return;
ev.preventDefault();
openMenuAt(ev.clientX, ev.clientY, [
["고른 내역 줄에 연결", () => linkTo(item)],
...(item.sheet
? [["계산서 보기", () => item.sheet?.(() => linkTo(item))] as [string, () => void]]
: []),
]);
});
const body = el("div", {
className: "b09-store",
attrs: { "data-b09-store": "" },
children: [tabs.root, finder.root, guide],
});
const side = createSidePanels({
prefKey: "b09-store",
panels: [{ id: "store", title: "재료 창고", body }],
defaultWidth: 360,
});
/** 내역 표 줄에 놓기 — 표를 다시 그리면 새 표에 다시 붙임 */
const dropOn = (table: HTMLElement): void => {
let over: HTMLElement | null = null;
const mark = (tr: HTMLElement | null): void => {
over?.classList.remove("is-focus");
over = tr;
over?.classList.add("is-focus");
};
table.addEventListener("dragover", (ev) => {
if (!ev.dataTransfer?.types.includes(DRAG)) return;
ev.preventDefault();
mark((ev.target as Element).closest<HTMLElement>("tr[data-i]"));
});
table.addEventListener("dragleave", (ev) => {
if (!table.contains(ev.relatedTarget as Node)) mark(null);
});
table.addEventListener("drop", (ev) => {
const at = ev.dataTransfer?.getData(DRAG);
const tr = (ev.target as Element).closest<HTMLElement>("tr[data-i]");
mark(null);
if (!at || !tr) return;
ev.preventDefault();
const item = shown[Number(at)];
if (item) linkTo(item, Number(tr.dataset.i));
});
};
side.root.classList.add("b09-store__side");
return {
root: side.root,
bind: (next, rows, table) => {
ctx = next;
raw = rows;
dropOn(table);
draw();
},
};
}
/** 설계내역서 표 오른쪽 창고 — 표(root)에 놓기 붙이고 창고 서랍을 돌려줌 */
export function attachBillStore(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement {
store ??= makeStore();
store.bind(ctx, raw, table);
return store.root;
}
@@ -11,6 +11,7 @@
* 단가연결 칸(램프) = 항목 줄(자재 · 조달수수료 빼고) · 램프 누름 = 우클릭 [연결] 과 같은 메뉴
* 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) ·
* 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력
* 오른쪽 재료 창고(703-1) = `…_UI_Store`(두 번 누름 · 끌어 놓기 = 단가연결)
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
@@ -25,6 +26,7 @@ import {
type BillTable,
} from "./B09_Estimation_UI_Tabs_Bill_Edit";
import { attachBillFold } from "./B09_Estimation_UI_Tabs_Bill_Fold";
import { attachBillStore } from "./B09_Estimation_UI_Store";
const TEXT_KEYS = ["name", "spec", "unit"];
/** 폴더 줄 수식 칸 — 자손 합(셈) */
@@ -128,6 +130,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
menu: (i) => billMenu(ctx, raw, i),
drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before),
fold: (table) => attachBillFold(ctx, raw, table), // 702-1 줄 펼침 · 고친 칸 덮개
side: (table) => attachBillStore(ctx, raw, table), // 703-1 오른쪽 재료 창고
onCell: (i, key) => {
if (key === "connect") openLinkMenu(ctx, raw, i);
},
@@ -34,6 +34,8 @@ export type BillTable = TabTable & {
drop?: (index: number, parent: string | null, before: string | null) => (() => void) | null;
/** 줄 펼침(702-1) — 표 root 를 받아 표 아래 자리를 돌려줌 */
fold?: (table: HTMLElement) => HTMLElement;
/** 재료 창고(703-1) — 표 root 를 받아(놓기) 오른쪽 서랍을 돌려줌 */
side?: (table: HTMLElement) => HTMLElement;
};
const NEW_NAME = "새 줄";
@@ -222,6 +224,10 @@ function applyRef(ctx: TabContext, rows: DataRow[], i: number): ApplyRef {
};
}
/** 재료 창고(703-1) — 그 줄에 연결 적용 · 연결 없는 줄(자재 · 묶음 · 조달수수료) = null */
export const applyAt = (ctx: TabContext, rows: DataRow[], i: number): ApplyRef | null =>
linkable(rows[i]) ? applyRef(ctx, rows, i) : null;
/** 줄 연결 메뉴 — 지금 연결 = 덮개(로직 입력값 등) 있으면 덮개 · 없으면 셈 줄 */
function linkFor(ctx: TabContext, rows: DataRow[], i: number) {
const row = rows[i];
@@ -45,6 +45,9 @@ let live: {
} | null = null;
let keysOn = false;
/** 고른 줄 키(703-1 재료 창고 두 번 누름 = 이 줄에 연결) */
export const pickedKey = (): string | null => picked;
/** 고른 줄 class — Shell 줄 class 곁값 */
export const pickedClass = (row: DataRow | undefined): string | null =>
row && picked !== null && String(row.key) === picked ? "is-focus" : null;
+5 -1
View File
@@ -86,10 +86,11 @@ def now_text() -> str:
def same_item(manifest: Any, item: dict[str, Any]) -> bool:
"""같은 항목 · 같은 규격 열(704-4 · 열이 바뀌면 다른 사본 · 덮개는 폴더 열쇠라 그대로 남음)."""
origin = manifest.get("원본") if isinstance(manifest, dict) else None
if not isinstance(origin, dict):
return False
return all(origin.get(k) == item.get(k) for k in ("layer", "owner", "key"))
return all(origin.get(k) == item.get(k) for k in ("layer", "owner", "key", "spec"))
def origin_of(item: dict[str, Any]) -> dict[str, Any]:
@@ -142,6 +143,9 @@ def write_snapshot(target: Path, item: dict[str, Any], owner_id: str, extra: dic
atomic_write_json(target / name, content)
stamp = now_text()
source = version_of(source_files(files))
spec = ((files.get(LABOR_FILE) or {}).get("줄") or {}).get("규격") # 굳힌 규격 열(704-4)
if isinstance(spec, dict):
extra = {**extra, "규격": {"이름": spec.get("이름"), "열": spec.get("기본")}}
manifest = {
"원본": origin_of(item),
"판": source,
+5 -2
View File
@@ -2,7 +2,8 @@
모으는 것 = `{파일 이름: 내용}`:
`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA ·
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄)
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄 ·
규격 열 = summary_item `spec` 열 수량을 손으로 굳힘 · 704-4)
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) +
참조사본(단가승격 · 사전조합을 값으로 굳힘) ·
`단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT)
@@ -146,7 +147,9 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
files[name] = part
doc = read_json(root / LABOR_KIND / f"{column_id}.json") if column_id else None
if isinstance(doc, dict):
labor = labor_doc_rows(doc)
from M02_MasterTemplete import M02_Labor_Spec as labor_spec
labor = labor_doc_rows(labor_spec.freeze(doc, item.get("spec"))) # 고른 규격 열(704-4)
else:
labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None
if labor is not None:
+18 -4
View File
@@ -16,6 +16,7 @@
분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만.
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
규격 열(704-4) = 문서 `규격?` 칸 · 열마다 줄 수량(`M02_Labor_Spec` · settle `col`) · 계산값 `규격합`.
로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) ·
고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와
같은 품목 두 번은 [저장] 때 400.
@@ -42,6 +43,7 @@ from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Labor_Spec as spec
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Material_Own as own
@@ -192,7 +194,11 @@ def clean(doc: Any) -> dict[str, Any]:
}
if doc.get("일위대가번"):
out["일위대가번"] = doc["일위대가번"]
return out
try: # 규격 열(704-4 · `M02_Labor_Spec`) — 없으면 칸 없음(옛 문서)
cols = spec.clean(doc.get("규격"), {spec.line_key(x) for x in [*lines, *mats]})
except spec.SpecError as e:
raise _bad(str(e)) from e
return {**out, "규격": cols} if cols else out
def _alias(alias: Any, where: str) -> str | None:
@@ -424,9 +430,10 @@ def line_id(one: dict) -> Any:
return refs.ref_id(one["참조"]) if _is_ref(one) else one.get("로직")
def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]:
def settle(result: dict, doc: dict, given: dict[str, Any], col: Any = None) -> dict[str, Any]:
"""기준이 선 계산 답에 수량 · 반영 · 합을 채움 — 수량만 바뀌면 이것만 다시
(짝: `Labor_Calc.ts` `settle` · 자재 = 재료비)."""
(짝: `Labor_Calc.ts` `settle` · 자재 = 재료비 · `col` = 규격 열 · 없으면 기본 · 704-4)."""
doc = spec.at(doc, col)
have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None}
hands = {line_id(one): one.get("수량") for one in doc.get("담은로직") or []}
mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []}
@@ -659,7 +666,14 @@ def server(doc: Any) -> dict[str, Any]:
got = clean(doc)
out = calc(got)
no_twice(got, out)
return {**got, "계산값": out}
return {**got, "계산값": {**out, **spec_sums(got, out)}}
def spec_sums(doc: dict, out: dict) -> dict[str, Any]:
"""규격 열마다 반영합(704-4 · 고르기 창 열 단추) `{규격합: {열: 합}}` — 규격 없음 = 빈 것."""
cols = (doc.get("규격") or {}).get("열") or []
have = spec.given_of(out)
return {"규격합": {c: settle(out, doc, have, c)["반영합"] for c in cols}} if cols else {}
def no_twice(got: dict, out: dict) -> None:
+184
View File
@@ -0,0 +1,184 @@
"""일위대가 규격 열(PLAN 704-4) — 문서 `규격` 칸 검사 · 열 끼우기 · 사본 굳히기 · 쓰는 사본 찾기.
규격 = `{이름: "H", 열: ["1.5", "2.0"], 기본: "2.0", 수량: {줄 id: {열: 수}}}` —
줄 id = 로직 키 · 참조 `ref_id`(`M02_Labor_Doc.line_id`) · 자재 코드.
규격 수량이 있는 줄 = 고른 열 값이 `{손}` 자리 · 없는 줄 · 빈 열 = 문서 `수량` 그대로(공통).
규격 없음 = 옛 문서(열 하나 · 셈 그대로 · 옮김 없음).
짝(CLAUDE.md 5장 ①): `at` ↔ `M02_MasterTemplete_Labor_Calc.ts` `atSpec` —
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
사본(`M02_Item_Source.build_source`) = 설계가 고른 열(summary_item `spec`)을 `{손}` 으로 굳히고
`규격` 은 그 열 하나만 남김(`freeze`).
"""
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any
from M02_MasterTemplete import M02_Labor_Ref as refs
NAME_MAX = 20 # 규격 이름 · 열 이름 글자 수 상한 — 화면 칸과 같음
COLS_MAX = 20
class SpecError(ValueError):
"""규격 칸 꼴이 틀림 — 부르는 쪽이 400 으로."""
def _num(raw: Any) -> int | float | None:
if raw is None or isinstance(raw, bool):
return None
try:
got = Decimal(str(raw).replace(",", "").strip())
except InvalidOperation:
return None
if not got.is_finite():
return None
return int(got) if got == got.to_integral_value() else float(got)
def _text(raw: Any, what: str) -> str:
text = str(raw).strip() if isinstance(raw, (str, int, float)) and raw is not True else ""
if not text or len(text) > NAME_MAX:
raise SpecError(f"규격 {what}은 1~{NAME_MAX}자 글")
return text
def line_key(one: dict) -> Any:
"""줄 id — 참조 줄 = `ref_id` · 로직 줄 = 로직 키 · 자재 줄 = 코드."""
if isinstance(one.get("참조"), dict):
return refs.ref_id(one["참조"])
return one.get("로직") or one.get("코드")
def clean(raw: Any, ids: set[Any]) -> dict[str, Any] | None:
"""받은 `규격` → 저장 꼴 — 열이 없으면 None(규격 없음) · 이름 · 열 이름이 비거나 겹치면 ·
수량이 수 아니면 SpecError · 없는 줄 · 없는 열 수량은 버림(지운 줄)."""
if raw is None:
return None
if not isinstance(raw, dict):
raise SpecError("규격은 묶음")
cols: list[str] = []
for one in raw.get("열") or []:
col = _text(one, "열 이름")
if col in cols:
raise SpecError(f"같은 규격 열 두 번 「{col}」")
cols.append(col)
if not cols:
return None
if len(cols) > COLS_MAX:
raise SpecError(f"규격 열은 {COLS_MAX}개까지")
qty: dict[str, dict[str, int | float]] = {}
for lid, row in (raw.get("수량") or {}).items():
if lid not in ids or not isinstance(row, dict):
continue
kept = {}
for col, value in row.items():
if col not in cols or value in (None, ""):
continue
got = _num(value)
if got is None:
raise SpecError(f"규격 수량 「{lid} · {col}」 은 수")
kept[col] = got
if kept:
qty[str(lid)] = kept
return {
"이름": _text(raw.get("이름"), "이름"),
"열": cols,
"기본": raw.get("기본") if raw.get("기본") in cols else cols[0],
"수량": qty,
}
def column(doc: Any, col: Any = None) -> str | None:
"""쓸 열 — 고른 열이 규격에 있으면 그것 · 없으면 기본 · 규격 없음 = None."""
spec = doc.get("규격") if isinstance(doc, dict) else None
if not isinstance(spec, dict) or not spec.get("열"):
return None
return col if col in spec["열"] else spec.get("기본") or spec["열"][0]
def at(doc: dict, col: Any = None) -> dict:
"""열 끼우기 — 그 열 규격 수량이 있는 줄 · 자재의 `수량` 을 `{손: 값}` 으로
(규격 없음 = 그대로)."""
use = column(doc, col)
if use is None:
return doc
qty = doc["규격"].get("수량") or {}
def put(one: Any) -> Any:
if not isinstance(one, dict):
return one
value = (qty.get(line_key(one)) or {}).get(use)
return {**one, "수량": {"손": value}} if value is not None else one
return {
**doc,
"담은로직": [put(one) for one in doc.get("담은로직") or []],
"자재": [put(one) for one in doc.get("자재") or []],
}
def given_of(result: dict) -> dict[str, Any]:
"""셈 답에서 칸 값 되살림 `{알약: 수량}` — 열마다 다시 셀 때 면적 칸 값
(문서 · 층을 다시 안 읽음)."""
rows = [*(result.get("줄") or []), *(result.get("자재") or [])]
return {r["알약"]: r["수량"] for r in rows if r.get("수량출처") == "칸" and r.get("알약")}
def freeze(doc: dict, col: Any = None) -> dict:
"""사본 굳히기 — 고른 열(없거나 지운 열 = 기본)을 `{손}` 으로 끼우고 규격은 그 열 하나만."""
use = column(doc, col)
if use is None:
return doc
got = {k: v for k, v in at(doc, use).items() if k != "계산값"} # 기본 열 계산값은 버림
return {**got, "규격": {"이름": doc["규격"].get("이름"), "열": [use], "기본": use, "수량": {}}}
def users(layer_root: Path, keys: set[str], cols: set[str]) -> list[str]:
"""그 층 항목(`keys`) · 그 규격 열(`cols`)을 쓰는 프로젝트 사본 —
`프로젝트 폴더/사본 폴더` 목록.
ponytail: 저장소 프로젝트 사본 manifest 를 통째로 훑음 — 열 지우기 · 이름 바꾸기 [저장] 때만 ·
느리면 사본 색인을 둠."""
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_Item_Source import layer_root as root_of
from M02_MasterTemplete.M02_Item_Source import read_json
found = []
here = Path(layer_root).resolve()
for path in layers.storage_root().glob("*/*/*/templates/structures/*/manifest.json"):
origin = (read_json(path) or {}).get("원본")
if not isinstance(origin, dict) or origin.get("key") not in keys:
continue
if origin.get("spec") not in cols:
continue
try:
same = root_of(origin).resolve() == here
except (KeyError, ValueError, TypeError):
same = False
if same:
found.append(f"{path.parents[3].name}/{path.parent.name}")
return found
def guard(path: Path, new: Any, column: str) -> None:
"""[저장] 때 빠진 규격 열(지움 · 이름 바꿈)을 쓰는 프로젝트 사본이 있으면 409 —
지금 층(`store.root`) 그 열 항목(SS 키 · 옛 키) 기준."""
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_Item_Source import read_json
old = (read_json(path) or {}).get("규격") if path.is_file() else None
gone = set((old or {}).get("열") or []) - set(((new or {}).get("규격") or {}).get("열") or [])
if not gone:
return
rows = [r for r in store.read_combos().get("줄", []) if str(r.get("열")) == column]
keys = {str(k) for r in rows for k in [r.get("키"), *(r.get("옛키") or [])] if k}
used = users(store.root(), keys, gone)
if used:
raise store.StoreError(
409,
f"규격 열 「{', '.join(sorted(gone))}」 을 쓰는 프로젝트 사본 {len(used)}개 — 지울 수 없음 "
f"({', '.join(used[:3])}{' …' if len(used) > 3 else ''})",
)
@@ -119,6 +119,14 @@ def _list_extras(folder: Path, col: str) -> dict[str, Any]:
"기준": _number(calc.get("기준합")), # 수량 없는 일위대가 = 단가 합(39-8 B2)
"단위": doc.get("단위"), # 일위대가 문서 단위(구조물 열 단위 아님 · B3)
}
spec = doc.get("규격") if isinstance(doc.get("규격"), dict) else None
if spec and spec.get("열"): # 규격 열(704-4) — 고르기 창 열 단추 · 열마다 합계
sums = calc.get("규격합") or {}
labor["규격"] = {
"이름": spec.get("이름"),
"열": [{"이름": c, "합계": _number(sums.get(c))} for c in spec["열"]],
"기본": spec.get("기본"),
}
return {"수정일": stamps, "labor": labor}
@@ -11,7 +11,7 @@ import {
type LogicSummary,
type SubBrief,
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { LaborCalc, LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import type { LaborCalc, LaborRef, LaborSpec } from "./M02_MasterTemplete_Labor_Calc";
import {
call,
getActiveLayer,
@@ -238,6 +238,8 @@ export interface LaborDoc {
담은로직: LaborRow[];
자재: LaborMat[];
계산값?: unknown;
/** 규격 열(704-4 · `Labor_Spec`) — 없으면 옛 문서(열 하나) */
규격?: LaborSpec | null;
}
/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */
export function normalizeLabor(doc: unknown, column: string): LaborDoc {
@@ -6,7 +6,8 @@
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만.
* 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`).
* 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`) ·
* 고정 줄(705-1)은 「고정|이름|규격|단위」(짝 `M02_Labor_Fixed.fixed_id`) · 기준 = 서버가 낸 줄 단가. */
* 고정 줄(705-1)은 「고정|이름|규격|단위」(짝 `M02_Labor_Fixed.fixed_id`) · 기준 = 서버가 낸 줄 단가.
* 규격 열(704-4) = 문서 `규격` · 그 열 수량이 있는 줄 = `{손}` 자리(`atSpec` · 짝 `M02_Labor_Spec.at`). */
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { LogicMat } from "./M02_Labor_LogicMats_Model";
@@ -94,10 +95,19 @@ export interface LaborCalc {
반영합?: Money;
}
/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */
/** 규격 열(704-4) — 이름(H · Φ) · 열(화면 차례) · 기본 · 줄 id 마다 열 수량 */
export interface LaborSpec {
이름: string;
열: string[];
기본: string;
수량: Record<string, Record<string, number>>;
}
/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 · 규격 열 */
export interface LaborQtyDoc {
담은로직?: { 로직?: string; 참조?: LaborRef; 고정?: LaborFixed; 수량?: Qty }[];
자재?: { 코드: string; 수량?: Qty; 별도계상?: string }[];
규격?: LaborSpec | null;
}
function num(raw: unknown): number | null {
@@ -119,6 +129,37 @@ export function lineId(one: {
return one.로직;
}
/** 쓸 규격 열 — 고른 열이 있으면 그것 · 없으면 기본 · 규격 없음 = null(짝 `M02_Labor_Spec.column`) */
export function specColumn(doc: LaborQtyDoc, col?: string | null): string | null {
const cols = doc.규격?.열 ?? [];
if (!cols.length) return null;
return col && cols.includes(col) ? col : doc.규격?.기본 || cols[0];
}
/** 열 끼우기 — 그 열 규격 수량이 있는 줄 · 자재의 수량을 `{손}` 으로(짝 `M02_Labor_Spec.at`) */
export function atSpec<D extends LaborQtyDoc>(doc: D, col?: string | null): D {
const use = specColumn(doc, col);
if (use === null) return doc;
const qty = doc.규격?.수량 ?? {};
const put = <T extends { 수량?: Qty }>(one: T, id: string | undefined): T => {
const v = id !== undefined ? qty[id]?.[use] : undefined;
return v !== undefined && v !== null ? { ...one, 수량: { 손: v } } : one;
};
return {
...doc,
담은로직: (doc.담은로직 ?? []).map((one) => put(one, lineId(one))),
자재: (doc.자재 ?? []).map((one) => put(one, one.코드)),
};
}
/** 셈 답에서 칸 값 되살림 `{알약: 수량}` — 열마다 다시 셀 때(짝 `M02_Labor_Spec.given_of`) */
export function givenOf(result: LaborCalc): Record<string, number> {
const out: Record<string, number> = {};
for (const r of [...result.줄, ...result.자재])
if (r.수량출처 === "칸" && r.알약 && r.수량 != null) out[r.알약] = r.수량;
return out;
}
function zero(): Money {
return { 노무비: 0, 재료비: 0, 경비: 0, 계: 0 };
}
@@ -142,12 +183,15 @@ export function reflect(base: Money, qty: number | null, times: number): Money |
return out;
}
/** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 */
/** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 ·
* `col` = 규격 열(없으면 기본 · 704-4) */
export function settle(
result: LaborCalc,
doc: LaborQtyDoc,
whole: LaborQtyDoc,
given: Record<string, unknown>,
col?: string | null,
): LaborCalc {
const doc = atSpec(whole, col);
const have: Record<string, number> = {};
for (const [k, raw] of Object.entries(given)) {
const v = num(raw);
@@ -13,7 +13,7 @@
* · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 자재목록(175-1) · 원문(면적 · 다이어그램 없음) ·
* 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1)
* · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음)
* · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만
* · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 · 규격 표(704-4 · `Labor_Spec`)
* ========================================================================== */
import {
@@ -218,7 +218,7 @@ export function mountLaborDetail(
),
);
const draw = (): void => {
gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics));
gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && !lib && changed));
drawLogic();
matHost.replaceChildren(
matTable(
@@ -6,6 +6,7 @@
* 로직 줄 = 이름(별칭 앞섬) · 규격(옵션 값 · 켜쌓기) · 수량 · 분모 · 단가 = 기준 ÷ 배 · 금액 = 반영 · 꼬리표 품셈
* 자재 줄 = 이름 · 규격 · 수량 · 단위 · 재료비 단가 = 기준단가 · 금액 = 반영 · 별도계상은 비고 「합계 밖」
* 빈 줄(금액 모름 · 수량 없음) = 합계 줄 「빈 줄 n」 붉게 — 701-4 경고와 같은 줄
* 규격 열(704-4 · `Labor_Spec`) = 수량 칸 열마다 · 고칠 때 격자 위 규격 줄 + 수량 칸 고침
* ========================================================================== */
import {
@@ -20,6 +21,7 @@ import type { LaborCalc, LaborCost, LaborDoc } from "./M02_MasterTemplete_Labor_
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { valueLine } from "./M02_Labor_Values_Model";
import { specBar, specCells, withSpecs, type SpecEdit } from "./M02_MasterTemplete_Labor_Spec";
const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney =>
m
@@ -77,7 +79,7 @@ export function laborBlock(
};
});
const lines = [...logicLines, ...matLines];
return {
const block: HopyoBlock = {
key: doc.열,
name: doc.일위대가번 || doc.열,
unit: doc.단위 ?? "",
@@ -85,11 +87,20 @@ export function laborBlock(
lines,
...(calc ? {} : { blank: 0 }),
};
return withSpecs(block, doc, calc);
}
/** 격자 하나(읽기 전용) */
/** 격자 하나 — `edit` 있으면 위에 규격 줄 · 규격 수량 칸 고침(704-4) */
export const laborGrid = (
doc: LaborDoc,
calc: LaborCalc | null,
logics: ReadonlyMap<string, ItemLogic>,
): HTMLElement => createHopyoGrid([laborBlock(doc, calc, logics)]).root;
edit?: SpecEdit | false,
): HTMLElement => {
const block = laborBlock(doc, calc, logics);
if (!edit) return createHopyoGrid([block]).root;
const grid = createHopyoGrid([block], specCells(block, doc, edit)).root;
const box = document.createElement("div");
box.append(specBar(doc, calc, edit), grid);
return box;
};
@@ -0,0 +1,191 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Spec.ts
* 일위대가 규격 표(PLAN 704-4) — 704-3 격자에 규격 열(H=1.5 | 2.0 …)을 폄 · 고칠 때 격자 위 규격 줄.
* 문서 `규격` = {이름, 열, 기본, 수량: {줄 id: {열: 수}}}(짝 `M02_Labor_Spec.py`) · 화면 캐시만(저장은 [저장])
* 규격 줄 = 규격 이름 · 열마다 이름 칸 · [기본] · [×] · [+ 열](새 열 수량 = 지금 기본 열 값 그대로)
* 격자 수량 칸(열마다) 고침 = 그 줄 · 그 열 수량(빈 글 = 공통 수량을 따름)
* 열마다 수량 · 1 단위 합계 = 짝 셈 `settle(…, 열)`(서버 안 부름) · 금액 칸 = 기본 열
* 쓰는 사본이 있는 열 지움 · 이름 바꿈은 [저장] 때 서버가 막음(409 까닭 그대로)
* ========================================================================== */
import { createButton, createInputField, el } from "@ui/ui_template_elements";
import type { DataTableOptions } from "@ui/ui_template_data_table";
import { hopyoGrid, hopyoSpecKey, type HopyoBlock } from "@ui/ui_template_hopyo_grid";
import { createToolbar } from "@ui/ui_template_toolbar";
import { createText } from "@ui/ui_template_text";
import type { LaborCalc, LaborDoc } from "./M02_MasterTemplete_Labor_Api";
import { givenOf, lineId, settle, specColumn } from "./M02_MasterTemplete_Labor_Calc";
/** 고칠 때 — 규격이 바뀌면 `true`(수량만 · 기준 그대로 · 짝 셈으로 바로) */
export type SpecEdit = (qtyOnly: boolean) => void;
const TEXT = {
none: "규격 열 없음 — [+ 열] = H 같은 규격마다 줄 수량만 다른 열",
add: "+ 열",
name: "규격 이름",
base: "기본",
remove: "열 지움",
first: "1",
next: "새 열",
};
/** 줄 id 차례 — 격자 줄 차례(로직 줄 → 자재 줄)와 같음 */
const lineIds = (doc: LaborDoc): string[] => [
...doc.담은로직.map((r) => lineId(r) ?? ""),
...doc.자재.map((m) => m.코드),
];
/** 열마다 셈 → 블록 `specs` · 줄 `qtys`(규격 없음 = 그대로) */
export function withSpecs(block: HopyoBlock, doc: LaborDoc, calc: LaborCalc | null): HopyoBlock {
const spec = doc.규격;
if (!spec?.열.length) return block;
const per = calc ? spec.열.map((c) => settle(calc, doc, givenOf(calc), c)) : [];
const ids = lineIds(doc);
const logics = doc.담은로직.length;
const qtyOf = (p: LaborCalc, n: number): number | null =>
(n < logics
? p.줄.find((l) => lineId(l) === ids[n])?.수량
: p.자재.find((m) => m.코드 === ids[n])?.수량) ?? null;
return {
...block,
specs: {
name: spec.이름,
cols: spec.열,
pick: specColumn(doc),
totals: per.map((p) => p.반영합?.계 ?? null),
},
lines: block.lines.map((ln, n) => ({ ...ln, qtys: per.map((p) => qtyOf(p, n)) })),
};
}
/** 격자 수량 칸 고침(열마다) — 공용 표 옵션 덧 */
export function specCells(
block: HopyoBlock,
doc: LaborDoc,
edit: SpecEdit,
): Partial<DataTableOptions> {
const spec = doc.규격;
if (!spec?.열.length) return {};
const g = hopyoGrid([block]);
const ids = lineIds(doc);
const col = (key: string): string | undefined => spec.열.find((_, n) => hopyoSpecKey(n) === key);
return {
editable: (i, key) => g.at.has(i) && col(key) !== undefined,
onEdit: (i, key, text) => {
const id = ids[g.at.get(i)?.[1] ?? -1];
const name = col(key);
if (id === undefined || name === undefined) return;
const v = Number(text.replace(/,/g, "").trim());
const row = { ...(spec.수량[id] ?? {}) };
if (text.trim() === "" || !Number.isFinite(v)) delete row[name];
else row[name] = v;
if (Object.keys(row).length) spec.수량[id] = row;
else delete spec.수량[id];
edit(true);
},
};
}
/** 새 열 이름 — 겹치면 뒤에 번호 */
const fresh = (cols: string[], want: string): string => {
let out = want;
for (let n = 2; cols.includes(out); n++) out = `${want} ${n}`;
return out;
};
/** [+ 열] — 규격이 없으면 지금 수량 열(1) + 새 열 · 새 열 수량 = 기본 열 손 값 그대로 */
function addColumn(doc: LaborDoc, calc: LaborCalc | null): void {
const spec = (doc.규격 ??= { 이름: "H", 열: [TEXT.first], 기본: TEXT.first, 수량: {} });
const col = fresh(spec.열, TEXT.next);
const base = specColumn(doc) ?? spec.열[0];
spec.열.push(col);
const ids = lineIds(doc);
const logics = doc.담은로직.length;
ids.forEach((id, n) => {
const own = spec.수량[id]?.[base];
const line =
n < logics ? calc?.줄.find((l) => lineId(l) === id) : calc?.자재.find((m) => m.코드 === id);
const v = own ?? (line?.수량출처 === "손" ? line.수량 : null);
if (v != null) spec.수량[id] = { ...(spec.수량[id] ?? {}), [col]: v };
});
}
function renameColumn(doc: LaborDoc, from: string, to: string): boolean {
const spec = doc.규격;
const name = to.trim();
if (!spec || !name || name.length > 20 || name === from || spec.열.includes(name)) return false;
spec.열 = spec.열.map((c) => (c === from ? name : c));
if (spec.기본 === from) spec.기본 = name;
for (const row of Object.values(spec.수량))
if (from in row) {
row[name] = row[from];
delete row[from];
}
return true;
}
function removeColumn(doc: LaborDoc, col: string): void {
const spec = doc.규격;
if (!spec) return;
spec.열 = spec.열.filter((c) => c !== col);
for (const [id, row] of Object.entries(spec.수량)) {
delete row[col];
if (!Object.keys(row).length) delete spec.수량[id];
}
if (!spec.열.length)
doc.규격 = null; // 마지막 열 = 규격 없음(옛 문서 꼴)
else if (spec.기본 === col) spec.기본 = spec.열[0];
}
/** 격자 위 규격 줄(고칠 때만) */
export function specBar(doc: LaborDoc, calc: LaborCalc | null, edit: SpecEdit): HTMLElement {
const add = createButton({
label: TEXT.add,
variant: "ghost",
onClick: () => {
addColumn(doc, calc);
edit(true);
},
});
const spec = doc.규격;
if (!spec?.열.length)
return createToolbar({ items: [createText(TEXT.none, "muted"), add], label: TEXT.name });
const name = createInputField({ value: spec.이름, compact: true, placeholder: TEXT.name });
name.input.title = TEXT.name;
name.root.style.width = "6em"; // 짧은 글 칸 — 열 이름 칸도 같은 폭(카드 한 줄에)
name.input.addEventListener("change", () => {
const v = name.input.value.trim();
if (v && v.length <= 20) spec.이름 = v;
edit(true);
});
const cols = spec.열.flatMap((col) => {
const box = createInputField({ value: col, compact: true });
box.input.setAttribute("data-spec-col", col);
box.root.style.width = "6em";
box.input.addEventListener("change", () => {
renameColumn(doc, col, box.input.value);
edit(true);
});
const base = createButton({
label: TEXT.base,
variant: col === spec.기본 ? "filled" : "ghost",
size: "sm",
onClick: () => {
spec.기본 = col;
edit(true);
},
});
const drop = createButton({
label: "×",
variant: "ghost",
size: "sm",
onClick: () => {
removeColumn(doc, col);
edit(true);
},
});
drop.title = TEXT.remove;
return [el("span", { children: [box.root] }), base, drop, "divider" as const];
});
return createToolbar({ items: [name.root, "divider", ...cols, add], label: TEXT.name });
}
@@ -0,0 +1,260 @@
/* =============================================================================
* M02_MasterTemplete_ShareRequest.ts
* 회사에 올리기 요청 · 승인(PLAN 706-1) — 서버 `M02_Share_Request.py` 짝(장부 = 회사 층 파일 하나).
* 일반 사용자 = 자기 개인 층 구조물 조합 · 사전조합 나무 오른쪽 클릭 「회사에 올리기 요청」 → 글(선택) ·
* 같은 것 요청이 남아 있으면 「요청 중」(흐림) · 결과는 다음에 M02 열 때 위 띠(닫으면 봄)
* 회사 관리자 = 회사 층 위 띠 「올리기 요청 n」 → 목록(요청자 · 항목 · 날짜 · 글) · [미리 보기] ·
* [승인](폴더 고름 = 「회사로 복사…」와 같은 길 · 원본 그대로) · [거절](사유 한 줄)
* 관리자(회사 층을 고칠 수 있는 사람)는 「회사로 복사…」가 있어 요청 메뉴를 안 둠.
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { createListRow } from "@ui/ui_template_list_row";
import type { TreeMenuItem } from "@ui/ui_template_tree";
import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { call, fetchPrecomboTree, fetchTree, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import { accessFor, ownOwner } from "./M02_MasterTemplete_Layer";
import { askCopyFolder } from "./M02_MasterTemplete_TreePanel_CopyTo";
import { askFolder, askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
import type { Target } from "./M02_MasterTemplete_TreePanel_Pick";
type ShareKind = "item" | "precombo";
export interface ShareRow {
id: string;
종류: ShareKind;
키: string;
이름: string;
경로: string[];
주인: string;
요청자: number;
요청자이름: string;
글: string;
날짜: string;
상태: "요청" | "승인" | "거절";
처리자?: string;
사유?: string;
사본?: string;
봄?: boolean;
}
const KIND_NAME: Record<ShareKind, string> = { item: "구조물 조합", precombo: "사전조합" };
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
const post = <T>(path: string, body: object = {}): Promise<T> =>
call<T>(path, { method: "POST", body: JSON.stringify(body) });
export interface ShareOptions {
user: SessionUser | null;
/** 볼 회사(시스템 관리자는 고른 회사) */
company: string | null;
/** 지금 페이지 층 — 회사 층이면 관리자 띠 */
layer: Layer;
owner: string | null;
showView: (label: string, node: HTMLElement) => void;
/** 사전조합 미리 보기 — 편집 칸에 읽기만으로 */
openPrecombo: (row: ShareRow) => void;
/** 구조물 조합 미리 보기 — 요청자 개인 층으로 */
openItem: (row: ShareRow) => void;
/** 승인 뒤 — 나무 · 좌측 다시 읽기 */
changed: () => void;
}
export interface ShareHandle {
/** 편집 칸 맨 위 띠 자리(관리자 「올리기 요청 n」 · 요청자 결과) */
top: HTMLElement;
/** 구조물 조합 나무 줄 덧메뉴 — 자기 개인 층 항목만 */
itemMenu: (target: Target) => TreeMenuItem[];
/** 사전조합 나무 줄 덧메뉴 — 자기 개인 층만(`mine`) */
precomboMenu: (mine: boolean, key: string, name: string) => TreeMenuItem[];
}
export function createShare(opt: ShareOptions): ShareHandle {
const top = el("div", { attrs: { "data-share": "" } });
const companyOwner = opt.company ?? ownOwner("company", opt.user);
// 요청할 사람 = 회사가 있고 회사 층을 못 고침(고칠 수 있으면 「회사로 복사…」)
const asker =
!!ownOwner("personal", opt.user) && !accessFor("company", companyOwner, opt.user).canWrite;
const boss =
opt.layer === "company" &&
!!companyOwner &&
accessFor("company", companyOwner, opt.user).canWrite;
const q = opt.company ? `?company=${encodeURIComponent(opt.company)}` : "";
let rows: ShareRow[] = [];
let listHost: HTMLElement | null = null;
const pending = (kind: ShareKind, key: string): boolean =>
rows.some((r) => r.종류 === kind && r.키 === key && r.상태 === "요청");
const refresh = async (): Promise<void> => {
if (!asker && !boss) return;
rows = (await call<{ 줄: ShareRow[] }>(`/share-requests${q}`).catch(() => ({ 줄: [] }))).줄;
paintTop();
if (listHost?.isConnected) paintList(listHost);
};
function paintTop(): void {
if (boss) {
const n = rows.filter((r) => r.상태 === "요청").length;
top.replaceChildren(
...(rows.length
? [
createBanner({
tone: n ? "warning" : "info",
text: n
? `회사에 올리기 요청 ${n} 건 — 승인하면 회사 층에 사본`
: "올리기 요청 없음",
actions: [
createButton({ label: `올리기 요청 ${n}`, size: "sm", onClick: openList }),
],
}),
]
: []),
);
return;
}
const done = rows.filter((r) => r.봄 === false);
top.replaceChildren(
...(done.length
? [
createBanner({
tone: done.some((r) => r.상태 === "거절") ? "warning" : "success",
title: "회사에 올리기 결과",
text: done
.map((r) => `「${r.이름}」 ${r.상태}${r.사유 ? `(${r.사유})` : ""}`)
.join(" · "),
onClose: () => void post(`/share-requests/seen`).catch(() => undefined),
}),
]
: []),
);
}
async function ask(kind: ShareKind, key: string, name: string): Promise<void> {
const got = await askText(
`회사에 올리기 요청 — ${name}`,
[{ key: "글", label: "요청 글(선택)", optional: true }],
"요청",
);
if (!got) return;
try {
await post("/share-requests", { 종류: kind, 키: key, 글: got.글 });
showToast("요청함 — 회사 관리자가 승인하면 회사 층에 올라감", "success");
} catch (error) {
showToast(`요청 못 함 — ${why(error)}`, "error");
}
await refresh();
}
const menu = (kind: ShareKind, key: string, name: string): TreeMenuItem[] =>
pending(kind, key)
? [
{
label: "회사에 올리기 요청 중",
run: () => {},
disabledReason: "회사 관리자가 아직 안 봄",
},
]
: [{ label: "회사에 올리기 요청", run: () => void ask(kind, key, name) }];
async function approve(row: ShareRow): Promise<void> {
const title = `회사에 올리기 승인 — ${row.이름}`;
const dest =
row.종류 === "item"
? await askCopyFolder(fetchTree, { layer: "company", owner: companyOwner, label: title }, 1)
: await askFolder(
title,
(await fetchPrecomboTree("company", companyOwner).catch(() => null))?.폴더 ?? [],
() => false,
[],
"넣을 폴더",
);
if (!dest) return;
try {
await post(`/share-requests/${row.id}/approve${q}`, { 경로: dest });
showToast(`「${row.이름}」 회사 층에 올림 — ${dest.join(" / ") || "맨 위"}`, "success");
opt.changed();
} catch (error) {
showToast(`승인 못 함 — ${why(error)}`, "error");
}
await refresh();
}
async function reject(row: ShareRow): Promise<void> {
const got = await askText(
`거절 — ${row.이름}`,
[{ key: "사유", label: "사유(선택)", optional: true }],
"거절",
);
if (!got) return;
try {
await post(`/share-requests/${row.id}/reject${q}`, { 사유: got.사유 });
} catch (error) {
showToast(`거절 못 함 — ${why(error)}`, "error");
}
await refresh();
}
function paintList(host: HTMLElement): void {
const line = (r: ShareRow): HTMLElement => {
const tools =
r.상태 === "요청"
? el("span", {
className: "ui-toolbar",
children: [
createButton({
label: "미리 보기",
variant: "ghost",
size: "sm",
onClick: () => (r.종류 === "item" ? opt.openItem(r) : opt.openPrecombo(r)),
}),
createButton({ label: "승인", size: "sm", onClick: () => void approve(r) }),
createButton({
label: "거절",
variant: "danger-ghost",
size: "sm",
onClick: () => void reject(r),
}),
],
})
: createBadge(
`${r.상태}${r.사유 ? ` · ${r.사유}` : ""}`,
r.상태 === "승인" ? "success" : "danger",
);
const sub = [KIND_NAME[r.종류], r.요청자이름, r.날짜.slice(0, 10).replace(/-/g, "."), r.글];
return createListRow({
label: r.이름,
sub: sub.filter(Boolean).join(" · "),
trailing: tools,
size: 36,
});
};
// 기다리는 것 먼저 · 그다음 최근 처리
const order = [...rows].sort(
(a, b) => Number(b.상태 === "요청") - Number(a.상태 === "요청") || (a.날짜 < b.날짜 ? 1 : -1),
);
host.replaceChildren(
...(order.length
? order.map(line)
: [createEmpty({ text: "올리기 요청 없음", small: true })]),
);
}
function openList(): void {
listHost = el("div", { className: "ui-sidebar-list", attrs: { "data-share-list": "" } });
paintList(listHost);
opt.showView("회사에 올리기 요청", listHost);
}
void refresh();
return {
top,
itemMenu: (t) =>
asker && opt.layer === "personal" && !opt.owner && t.kind === "item"
? menu("item", t.item.key, t.item.경로[t.item.경로.length - 1] ?? t.item.key)
: [],
precomboMenu: (mine, key, name) => (asker && mine ? menu("precombo", key, name) : []),
};
}
@@ -166,6 +166,10 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
if kind == "table" and name == STRUCTURE_TABLE:
doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기)
doc = _server(kind, doc)
if kind == "labor": # 쓰는 규격 열 지움 막음(704-4)
from M02_MasterTemplete import M02_Labor_Spec as labor_spec
labor_spec.guard(path, doc, name)
fresh: list[dict[str, Any]] = []
with _LOCK:
have = version_of(path.read_bytes()) if path.is_file() else ""
@@ -85,6 +85,7 @@ export interface TreePanelOptions {
};
/** 「〇〇로 복사…」 — 그 층 폴더를 골라 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로 · 39-6) */
copyTo?: CopyTarget[];
more?: (target: Target) => TreeMenuItem[]; // 줄 하나 덧메뉴(706-1 회사에 올리기 요청)
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
canEdit: boolean;
/** 항목을 누름 — 표에서 그 열을 고름 */
@@ -226,6 +227,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
return [
...(opt.canEdit ? menuOf(target) : []),
...copyItem([target]),
...(opt.more?.(target) ?? []),
...(target.kind === "folder" ? pasteItem(target.path) : []),
];
};
@@ -40,6 +40,7 @@ import {
STRUCTURE_TABLE,
type StructureColumn,
} from "./M02_MasterTemplete_Structure";
import { createShare } from "./M02_MasterTemplete_ShareRequest";
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
import {
companyOwner,
@@ -91,10 +92,32 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal";
return [{ layer: to, owner: toOwner, label: L(`M02_TreeCopyTo${key}`) }];
});
// 회사에 올리기 요청 · 승인(706-1) — 요청 = 개인 층 나무 덧메뉴 · 승인 = 회사 층 위 띠 · 결과 띠
const share = createShare({
user,
company,
layer,
owner,
showView: (label, node) =>
void main.confirmLeave().then((ok) => ok && main.showView(label, node)),
openPrecombo: (r) =>
void main.open({
layer: "personal",
owner: r.주인,
canWrite: false,
projectId: null,
kind: "precombo",
name: r.키,
label: r.이름,
}),
openItem: (r) => void go("personal", r.주인, "구조물조합"),
changed: () => void Promise.all([side.refresh(), tree.refresh()]),
});
const tree = createTreePanel({
layer,
owner,
copyTo,
more: share.itemMenu,
canEdit: canEdit(layer, isAdmin),
onPick: (item) => void main.openTable(layer, item.열),
settle: () => main.settle(),
@@ -138,7 +161,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
() => void Promise.all([side.refresh(), tree.refresh()]),
() => side.paint(),
{
top: readOnlyBar?.root,
top: el("div", { children: [...(readOnlyBar ? [readOnlyBar.root] : []), share.top] }),
// 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5)
...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {}),
onColumn: (column) => {
@@ -222,7 +245,11 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
peopleTree("구조물조합", tree.root),
],
precombo: { scope: () => pre, bar: preBar },
precombo: {
scope: () => pre,
bar: preBar,
more: (key, name) => share.precomboMenu(pre.root === "personal" && !pre.owner, key, name),
},
});
const layout = el("div", { className: "ui-workflow-layout m02-master" });
@@ -69,7 +69,7 @@ export interface SideOptions {
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
tree?: HTMLElement[];
/** 「일위대가 사전조합」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */
precombo: Pick<PrecomboOptions, "scope" | "bar">;
precombo: Pick<PrecomboOptions, "scope" | "bar" | "more">;
}
export interface SideHandle {
@@ -86,6 +86,8 @@ export interface PrecomboOptions {
confirmLeave: () => Promise<boolean>;
onOpen: (sel: Selection | null) => void;
getOpen: () => Selection | null;
/** 문서 줄 덧메뉴(706-1 회사에 올리기 요청) — 없거나 빈 목록이면 메뉴 없음 */
more?: (key: string, name: string) => TreeMenuItem[];
}
export interface PrecomboHandle {
@@ -237,7 +239,8 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
const label = row.제목 || row.이름;
const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label };
targets.set(id, { kind: "item", item });
return { id, label, tip: row.이름, count: null, run: () => void open(row) };
const menu = () => opt.more?.(row.이름, label) ?? [];
return { id, label, tip: row.이름, count: null, run: () => void open(row), menu };
};
const folderNode = (node: PrecomboFolder): TreeNode => {
const id = folderIdOf(node.path);
+243
View File
@@ -0,0 +1,243 @@
"""회사에 올리기 요청 · 승인(PLAN 706-1 · 요구서 4장 6) — 일반 사용자가 개인 층 항목 ·
사전조합을 회사 층에 올려 달라고 요청 · 회사 관리자가 승인(= 「회사로 복사…」와 같은 길 ·
원본은 그대로) · 거절.
장부 = 회사 층 폴더 `올리기요청.json` 하나(DB 안 씀) · `{줄: [요청 …]}`.
요청 = `{id, 종류(item · precombo), 키, 이름, 경로(항목 폴더), 주인 C/U, 요청자, 요청자이름,
글, 날짜, 상태(요청 · 승인 · 거절), 처리자, 처리날짜, 사유, 사본, 봄}` · `봄` = 요청자가 결과
알림을 닫음. 권한 = `M02_Layer_Access` 한 벌 — 요청 = 자기 개인 층 · 목록 = 회사 관리자는
그 회사 전부 · 일반은 자기 것 ·
승인 · 거절 = 회사 층 쓰기(일반 403) · 다른 회사 장부는 안 봄(시스템 관리자만 `company`).
같은 항목(주인 · 종류 · 키)이 「요청」 상태면 다시 요청 = 409 「요청 중」.
⚠ 등록은 `main.py`(로그인만).
"""
from __future__ import annotations
import json
import threading
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_Item_Paste as paste_store
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Precombo_Folders as pre_folders
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import (
NotedRoute,
Session,
_call,
_root,
)
router = APIRouter(prefix="/api/m02", tags=["M02 Share Request"], route_class=NotedRoute)
FILE = "올리기요청.json"
# ponytail: 한 프로세스 잠금 — 서버를 여럿 띄우면 파일 잠금으로
_LOCK = threading.Lock()
class AskBody(BaseModel):
종류: Literal["item", "precombo"]
키: str
글: str = ""
class ApproveBody(BaseModel):
경로: list[str] = []
class RejectBody(BaseModel):
사유: str = ""
def _company(session: Session, company: str | None) -> str:
"""장부 회사 — 시스템 관리자만 `company` 로 남의 회사 · 그 밖은 자기 회사(없으면 409)."""
if company and access.is_system_admin(session):
return str(company)
own = session.get("company_id")
if own is None:
raise HTTPException(status_code=409, detail=access.NO_COMPANY)
return str(own)
def _path(company: str) -> Path:
return access.folder(access.Grant("company", company, False, True)) / FILE
def _read(company: str) -> list[dict[str, Any]]:
path = _path(company)
if not path.is_file():
return []
return list(json.loads(path.read_bytes()).get("줄") or [])
def _write(company: str, rows: list[dict[str, Any]]) -> None:
atomic_write_json(_path(company), {"줄": rows})
def _now() -> str:
return datetime.now().isoformat(timespec="seconds")
def _source(session: Session, kind: str, key: str) -> dict[str, Any]:
"""자기 개인 층 원본 — `{이름, 경로}`(없으면 404)."""
folder = _root(session, "personal")
if kind == "item":
found = next((r for r in items_store.items(folder) if r.get("key") == key), None)
if found is None:
raise HTTPException(status_code=404, detail=f"없는 항목 「{key}」")
path = list(found.get("경로") or [])
return {"이름": path[-1] if path else key, "경로": path[:-1]}
owner = access.own_owner(session, "personal")
doc = (
refs.read_ref({"층": "personal", "주인": owner, "키": key})
if refs._KEY.match(key)
else None
)
if doc is None or refs.kind_of(key) != "precombo":
raise HTTPException(status_code=404, detail=f"없는 사전조합 「{key}」")
return {"이름": str(doc.get("이름") or key), "경로": []}
@router.post("/share-requests")
def ask(body: AskBody, session: Session = Depends(verify_session)) -> dict[str, Any]:
"""요청 — 자기 개인 층 것만 · 같은 것이 「요청」 이면 409 「요청 중」."""
owner = access.own_owner(session, "personal")
if owner is None:
raise HTTPException(status_code=409, detail=access.NO_COMPANY)
src = _source(session, body.종류, body.키)
company = owner.split("/")[0]
row = {
"id": uuid.uuid4().hex[:12],
"종류": body.종류,
"키": body.키,
**src,
"주인": owner,
"요청자": session.get("user_id"),
"요청자이름": session.get("name") or "",
"글": body.글.strip()[:200],
"날짜": _now(),
"상태": "요청",
}
with _LOCK:
rows = _read(company)
same = (owner, body.종류, body.키)
if any((r["주인"], r["종류"], r["키"]) == same and r["상태"] == "요청" for r in rows):
raise HTTPException(status_code=409, detail="요청 중 — 회사 관리자가 아직 안 봄")
_write(company, [*rows, row])
return row
@router.get("/share-requests")
def listing(
company: str | None = None, session: Session = Depends(verify_session)
) -> dict[str, Any]:
"""목록 — 회사를 맡은 사람 = 그 회사 전부 · 그 밖 = 자기 요청만 · `관리` = 승인할 수 있나."""
at = _company(session, company)
rows = _read(at)
boss = access.manages(session, at)
if not boss:
me = session.get("user_id")
rows = [r for r in rows if r.get("요청자") == me]
return {"회사": at, "관리": boss, "줄": rows}
def _settle(session: Session, company: str | None, rid: str, done: dict[str, Any]) -> dict:
"""요청 한 줄을 끝냄 — 회사 층 쓰기 권한(일반 403) · 「요청」 상태만(아니면 409)."""
at = _company(session, company)
access.require(session, "company", at, write=True)
with _LOCK:
rows = _read(at)
row = next((r for r in rows if r.get("id") == rid), None)
if row is None:
raise HTTPException(status_code=404, detail="없는 요청")
if row["상태"] != "요청":
raise HTTPException(status_code=409, detail=f"이미 {row['상태']}")
extra = done.pop("run", None)
if extra:
done["사본"] = extra(row, at)
row.update({**done, "처리자": session.get("name") or "", "처리날짜": _now(), "봄": False})
_write(at, rows)
return row
def _copy(session: Session, dest: list[str]):
"""승인 = 「회사로 복사…」와 같은 길 — 구조물 항목 = 붙여넣기 ·
사전조합 = 서고 복사(+ 폴더 자리)."""
def run(row: dict[str, Any], company: str) -> str:
target = _root(session, "company", company, write=True)
source = _root(session, "personal", row["주인"])
if row["종류"] == "item":
ref = [{"경로": row.get("경로") or [], "키": row["키"]}]
origin = {"layer": "personal", "owner": row["주인"]}
try:
note = paste_store.paste(source, target, ref, dest, origin)
except store.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
return str(note["열"][0])
ref = {"층": "personal", "주인": row["주인"], "키": row["키"]}
doc = refs.read_ref(ref)
if doc is None:
raise HTTPException(status_code=404, detail=f"원본이 없음 「{row['키']}」")
def make() -> dict[
str, Any
]: # 못 읽는 참조 같이 복사 = 받는 층 안에서(`new_library` 와 같음)
return refs.create("precombo", refs.carry(refs.origin_mark(doc, ref), here))
here = ("personal", row["주인"])
made = _call(target, make)
if dest:
view = _call(target, pre_folders.view)
move = {"op": "move", "키": made["이름"], "새경로": dest, "판": view["판"]}
_call(target, pre_folders.edit, move)
return str(made["이름"])
return run
@router.post("/share-requests/{rid}/approve")
def approve(
rid: str,
body: ApproveBody,
company: str | None = None,
session: Session = Depends(verify_session),
) -> dict[str, Any]:
"""승인 — 회사 층 `경로` 폴더에 사본(새 키 · 같은 이름 「(1)」) · 원본 개인 것은 그대로."""
return _settle(session, company, rid, {"상태": "승인", "run": _copy(session, body.경로)})
@router.post("/share-requests/{rid}/reject")
def reject(
rid: str,
body: RejectBody,
company: str | None = None,
session: Session = Depends(verify_session),
) -> dict[str, Any]:
return _settle(session, company, rid, {"상태": "거절", "사유": body.사유.strip()[:200]})
@router.post("/share-requests/seen")
def seen(session: Session = Depends(verify_session)) -> dict[str, int]:
"""요청자가 결과 알림 줄을 닫음 — 자기 요청 가운데 끝난 것 `봄` = true."""
at = _company(session, None)
me = session.get("user_id")
with _LOCK:
rows = _read(at)
hit = [r for r in rows if r.get("요청자") == me and r.get("봄") is False]
for r in hit:
r["봄"] = True
if hit:
_write(at, rows)
return {"봄": len(hit)}
+13 -4
View File
@@ -1,7 +1,10 @@
"""구조물 집계표 항목 참조 `summary_item` — 모양 검사 · 스냅샷 폴더 이름 (PLAN 21-1).
summary_item = {layer: "system"|"company"|"personal",
owner: null|"{회사}"|"{회사}/{사용자}", key: "SS000004", name: "…"}
owner: null|"{회사}"|"{회사}/{사용자}", key: "SS000004", name: "…",
spec?: "2.0"}
`spec`(PLAN 704-4) = 그 항목 일위대가 규격 열 이름 — 없으면 기본 열(옛 기록 · 규격 없는 항목).
항목 키(PLAN 26-4a) — 마스터 `SS000004` · 회사 `SC{회사}-000001` · 개인 `SP{사용자}-000001`
(머리의 번호 = owner 의 회사 · 사용자). 옛 `SS` 키는 어느 층이든 그대로 받음 — 회사 · 개인 층이 새
@@ -28,15 +31,17 @@ _OWNER = {
"personal": re.compile(r"^\d+/\d+$"),
}
_NAME_MAX = 200
_SPEC_MAX = 20 # 규격 열 이름 상한(`M02_Labor_Spec.NAME_MAX`)
def parse_summary_item(value: Any) -> dict[str, Any] | None:
"""모양이 맞으면 `{layer, owner, key, name}` 새 dict · 빈 값은 None · 틀리면 ValueError."""
"""모양이 맞으면 `{layer, owner, key, name, spec?}` 새 dict · 빈 값은 None ·
틀리면 ValueError."""
if value is None:
return None
if not isinstance(value, dict):
raise ValueError("집계 항목(summary_item)은 객체여야 합니다.")
extra = set(value) - {"layer", "owner", "key", "name"}
extra = set(value) - {"layer", "owner", "key", "name", "spec"}
if extra:
raise ValueError(f"집계 항목에 모르는 칸이 있습니다: {', '.join(sorted(extra))}")
layer = value.get("layer")
@@ -59,7 +64,11 @@ def parse_summary_item(value: Any) -> dict[str, Any] | None:
name = value.get("name")
if not isinstance(name, str) or not name.strip() or len(name) > _NAME_MAX:
raise ValueError(f"집계 항목 name 은 1~{_NAME_MAX}자 글입니다.")
return {"layer": layer, "owner": owner, "key": key, "name": name}
spec = value.get("spec")
if spec is not None and (not isinstance(spec, str) or not 0 < len(spec.strip()) <= _SPEC_MAX):
raise ValueError(f"집계 항목 spec(규격 열)은 1~{_SPEC_MAX}자 글입니다.")
out = {"layer": layer, "owner": owner, "key": key, "name": name}
return {**out, "spec": spec.strip()} if spec is not None else out
#: 옵션 그룹 id(PLAN 46 · `M02_SO_Groups.GROUP_ID` 와 같은 규칙)
+7 -2
View File
@@ -3,7 +3,9 @@
* 구조물 집계표 항목 참조(PLAN 21-1) — 구조물 · 관 지점이 「어느 집계표 항목」인지 가리키는 값.
*
* summary_item: { layer: "system"|"company"|"personal", owner: null|"{회사}"|"{회사}/{사용자}",
* key: "SS000004", name: "돌기슭막이 메쌓기 1.2m" }
* key: "SS000004", name: "돌기슭막이 메쌓기 1.2m", spec?: "2.0" }
*
* spec(PLAN 704-4) = 그 항목 일위대가 규격 열 이름 — 없으면 기본 열(서버 `parse_summary_item` 과 짝).
*
* 없으면 null(= 미지정 · 집계표에 안 들어감). 옵션(options) 밖의 칸이다.
* import 없음 — 시험이 이 파일만 옮겨 돌림.
@@ -16,6 +18,8 @@ export interface SummaryItem {
owner: string | null;
key: string;
name: string;
/** 규격 열 이름(704-4) — 없으면 기본 열 */
spec?: string;
}
const LAYERS: readonly string[] = ["system", "company", "personal"];
@@ -30,7 +34,8 @@ export function isSummaryItem(value: unknown): value is SummaryItem {
(item.owner === null || typeof item.owner === "string") &&
typeof item.key === "string" &&
item.key !== "" &&
typeof item.name === "string"
typeof item.name === "string" &&
(item.spec === undefined || typeof item.spec === "string")
);
}
@@ -0,0 +1,91 @@
# 513 산림청 주제도 — 받는 길 · 이용 조건 조사 (2026-10-31 · 웹 공개 페이지만)
## 결론
- 산림청 다섯 주제도 중 공개 **지도 API(WMS·WFS)** = 산사태위험지도 하나(행안부 생활안전지도 WMS) · 그마저 **공공누리 4유형(상업 이용·변경 금지)**.
- 나머지 넷 = **파일 내려받기**(SHP·GDB)가 길 · 공공데이터포털(data.go.kr) 표기는 모두 「이용허락범위 제한 없음」.
- 같은 자료가 다른 창구에서 **상충** — 브이월드 국가중점 = CC BY-NC-ND(비영리·변경 금지) · FGIS 자료신청 = 서약(해당 업무에만 · 외부 유출 금지) · 산림청 누리집 약관 = 가공·판매 등 상업 이용 금지.
- 임도망도 1:5,000 · 산사태위험지도 1:25,000 = FGIS 에서 **공공·연구기관 전용**(회사 신청 불가).
- map.forest.go.kr 자체 지도 서버(WMS 등) 공개 안내 = **없음**.
- 상용 탑재 가능 여부 = **확인 못 함** — 산림청 서면 확인 필요(산림디지털담당관실 042-481-4285 · 산림빅데이터팀 042-481-8809).
## 1. 요약 표
| 주제도 | API | 파일 | 형식 · 좌표계 | 축척 | 갱신 · 기준 | 이용 조건 | 상용 탑재 | 출처 |
|---|---|---|---|---|---|---|---|---|
| 임상도(나무지도 대축척) | 없음 | ① FGIS 무료신청(일반인) ② 브이월드 국가중점 전국 ③ 산림빅데이터플랫폼 전국 ④ data.go.kr = FGIS 경로 안내만 | ① SHP · UTM-K(EPSG:5179) ② GDB 3,182 MB · EPSG:5179 ③ SHP zip 7개(각 약 2 GB) · EPSG:5179 | 1:5,000 | 2차 현행화 2020~2024 완료 · 브이월드 연간 · 2026-10-06 · 빅데이터 2022년 기준 | data.go.kr 제한 없음 · 브이월드 **CC BY-NC-ND** · FGIS 서약 · 빅데이터 확인 못 함 | 확인 못 함(상충) | [D1](https://www.data.go.kr/data/15093362/fileData.do) [V1](https://www.vworld.kr/dtmk/dtmk_ntads_s002.do?svcCde=MK&dsId=30136) [B1](https://www.data.go.kr/data/95990/linkedData.do) [F2](https://forest.go.kr/newkfsweb/html/HtmlPage.do?pg=%2Ffgis%2FUI_KFS_5002_020100.html&mn=KFS_02_04_03_04_01) |
| 임상도(소축척) | 없음 | data.go.kr 파일 · FGIS 일반 제공 | SHP · 5차 파일 TM중부(GRS80) | 1:25,000 | 5차 2006~2010 · 수정 2025-11-20 | data.go.kr 제한 없음 | 확인 못 함 | [D2](https://www.data.go.kr/data/3045619/fileData.do) [F5](https://www.forest.go.kr/kfsweb/opda/dataMng/pblicDataPrusContView.do?pblicDataId=PBD0000059&mn=NKFS_01_01&subTitle=%EC%9E%84%EC%83%81%EB%8F%84) |
| 임도망도(산길) | 없음 | ① data.go.kr 파일 ② 산림빅데이터플랫폼 전국 ③ FGIS = **공공·연구기관 전용** | ① SHP ② SHP(polyline) · EPSG:5179 | ① 미표기 ③ 1:5,000 | ① 수정 2025-11-20 ② 2019년 기준 | data.go.kr 제한 없음 · 빅데이터 확인 못 함 · FGIS 비영리 공공·학술 | 확인 못 함 | [D3](https://www.data.go.kr/data/3045621/fileData.do) [B2](https://www.data.go.kr/data/95799/linkedData.do) [F1](https://www.forest.go.kr/newkfsweb/kfi/kfs/fgis/selectAvailMapList.do?mn=KFS_02_04_02_02&orgId=fgis) |
| 산림입지토양도(산림토양도) | 토양도 자체 없음 · 참고: 브이월드 WMS `lt_c_fsdifrsts`(산림입지도) | ① data.go.kr 1:25,000 파일 ② 브이월드 국가중점 1:5,000 ③ 산림빅데이터플랫폼 1:5,000 ④ FGIS 일반 제공(1:25,000 · 1:5,000) ⑤ data.go.kr 1:5,000 = PDF 안내 | ① SHP ② GDB 4,599 MB · TM중부(EPSG:5186) ③ SHP(polygon) · EPSG:5179 | 1:25,000 · 1:5,000 | ① 수정 2025-11-20 ② 변경 시 · 2024-08-28 ③ 2020년 기준 · 제주 미구축 | data.go.kr 제한 없음 · 브이월드 「라이선스 미지정」 · 빅데이터 확인 못 함 | 확인 못 함 | [D4](https://www.data.go.kr/data/3058167/fileData.do) [D5](https://www.data.go.kr/data/15093364/fileData.do) [V2](https://www.vworld.kr/dtmk/dtmk_ntads_s002.do?svcCde=MK&dsId=30133) [B3](https://www.data.go.kr/data/95578/linkedData.do) [VW](https://www.vworld.kr/dev/v4dv_wmsguide2_s001.do) |
| 산사태위험지도 | **생활안전지도 WMS** — `safemap.go.kr/openApi2/IF_0046_WMS` · 레이어 `A2SM_SANSATAI` · 예제 EPSG:4326 · 3857 · 생활안전지도 인증키 | ① data.go.kr 파일 ② FGIS = **공공·연구기관 전용** · 브이월드 국가중점 = 목록에 없음 | ① XML · 10 m 격자 | ② 1:25,000 | WMS 갱신 1년 · 수정 2025-05-30 · 파일 수정 2026-09-03 | WMS **공공누리 4유형**(출처표시 · 상업 금지 · 변경 금지) — data.go.kr 같은 WMS 는 「제한 없음」 표기(상충) · 파일 제한 없음 | WMS 불가(4유형) · 파일 확인 못 함 | [S1](https://www.safemap.go.kr/opna/data/dataViewRenew.do?objtId=125) [D6](https://www.data.go.kr/data/15149602/openapi.do) [D7](https://www.data.go.kr/data/15074817/fileData.do) |
| 경제림육성단지 | data.go.kr `산림청_경제림육성단지구역도_GW` — REST · XML · `apis.data.go.kr/1400000/fsInfoService` · 공공데이터포털 인증키 · 도형 포함 여부 확인 못 함 | ① 브이월드 국가중점 국유림(30126) · 사유림(30127) ② FGIS 일반 제공 | ① POLYGON zip 10 MB · TM중부(EPSG:5186) | 1:25,000 | ① 변경 시 · 2023-07-26 · API 실시간 | API 제한 없음 · 브이월드 **CC BY-NC-ND**(30126 확인) | 확인 못 함 | [D8](https://www.data.go.kr/data/15158975/openapi.do) [V3](https://www.vworld.kr/dtmk/dtmk_ntads_s002.do?svcCde=MK&dsId=30126) [F1](https://www.forest.go.kr/newkfsweb/kfi/kfs/fgis/selectAvailMapList.do?mn=KFS_02_04_02_02&orgId=fgis) |
- 「화면 보기만」 = map.forest.go.kr 지도 안의 모든 주제도 + 나무지도 GeoPDF 검색 · 1:5,000 임상 · 영급 · 경급 · 임종 · 소나무 분포도 PDF([M1](https://map.forest.go.kr/forest/) · [F2](https://forest.go.kr/newkfsweb/html/HtmlPage.do?pg=%2Ffgis%2FUI_KFS_5002_020100.html&mn=KFS_02_04_03_04_01)).
- 산림청 GW 오픈API(fsInfoService 계열) 목록에 임상도 · 임도망도 · 토양도 · 산사태위험지도 없음([data.go.kr 검색 「산림공간정보」 오픈API 19건](https://www.data.go.kr/tcs/dss/selectDataSetList.do?keyword=%EC%82%B0%EB%A6%BC%EA%B3%B5%EA%B0%84%EC%A0%95%EB%B3%B4&dType=API)).
- 브이월드 WMS 목록에 산사태 · 경제림 · 임도 레이어 없음([VW](https://www.vworld.kr/dev/v4dv_wmsguide2_s001.do)).
## 2. 속성 칸
| 주제도 | 속성(공개 문서 기준) | 출처 |
|---|---|---|
| 임상도 | 임종(천연림·인공림) · 임상(침엽수·활엽수 등) · 주요 수종(51종) · 경급 · 영급 · 수관밀도 · 5차 1:25,000 칸 이름 예 `FIFTH_FRTP_CD`(도엽) · `DMCLS_CD`(경급) · `AGCLS_CD`(영급) · `DNST_CD`(밀도) · `SMBL_CD`(심볼) · 1:5,000 칸 이름 확인 못 함 | D1 · D2 · B1 · F5 |
| 임도망도 | 임도명 · 임도시설기간 · 임도시설거리 · 임도개설기간(빅데이터 판) · 노선 종류(간선·작업 등) 칸 확인 못 함 | B2 |
| 산림입지토양도 | 모암 · 기후 · 지형 · 입지표고 · 경사도 · 사면 · 경사형 · 토심 · 토성 · 토양형 · 칸 이름 확인 못 함 | D4 · D5 |
| 산사태위험지도 | 등급 1~5(1 = 가장 위험) · 10 m 격자 | D7 · S1 |
| 경제림육성단지 | 단지명 · 국유림/사유림 구분 · 관리소 · 시도·시군 행정구역(API 설명 기준) · 응답 칸 이름 확인 못 함 | D8 |
## 3. 이용 조건 — 창구별 상충
| 창구 | 표기 | 상용 의미 | 출처 |
|---|---|---|---|
| 공공데이터포털 | 「이용허락범위 제한 없음」 · 무료 | 공공데이터법 제3조 ④ 「영리적 이용인 경우에도 이를 금지 또는 제한하여서는 아니 된다」(다른 법률 특별 규정 · 제28조 ① 예외) · 제3자 권리 포함 데이터는 권리자 허락 필요 | [P1](https://www.data.go.kr/ugs/selectPortalPolicyView.do) [L1](https://ko.wikisource.org/wiki/%EA%B3%B5%EA%B3%B5%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%9D%98_%EC%A0%9C%EA%B3%B5_%EB%B0%8F_%EC%9D%B4%EC%9A%A9_%ED%99%9C%EC%84%B1%ED%99%94%EC%97%90_%EA%B4%80%ED%95%9C_%EB%B2%95%EB%A5%A0) [P2](https://www.data.go.kr/odmc/trublMdat/faq/index.do) |
| 브이월드 국가중점데이터 | 임상도 1:5,000 · 경제림 = CC BY-NC-ND · 토양도 1:5,000 = 미지정 | 영리 · 변경 불가 | V1 · V3 · V2 |
| 생활안전지도 WMS | 공공누리 4유형 · 위치정보 사업화 시 허가·신고 안내 | 상업 · 변경 불가 | S1 |
| FGIS 자료신청 | 서약 — 「업무상 취득한 … 자료등을 외부에 누설 또는 유출하지 않을 것」 · 해당 업무에만 사용 · 공공·연구기관 = 비영리 공공사업·학술연구 · 활용 뒤 성과물 제출 · 로그인(Any-ID) | 상용 재배포와 충돌 가능 | [F3](https://forest.go.kr/newkfsweb/kfi/kfs/fgis/fgisTermsAgreePage.do?mn=KFS_02_04_02_03&orgId=fgis) [M1](https://map.forest.go.kr/forest/) |
| 산림청 누리집 이용약관 | 제15조 「정보를 가공, 판매하는 행위 등 … 상업적으로 사용할 수 없습니다」 · 제16조 승인 없이 수정·판매·배포 불가 | 상업 불가(누리집 서비스 자료) | [F4](https://www.forest.go.kr/kfsweb/kfi/kfs/cms/cmsView.do?mn=NKFS_07_02&cmsId=FC_000131) |
| 산림빅데이터플랫폼(한국임업진흥원) | 무료 · 이용 조건 확인 못 함(사이트 접속 거부) | 확인 못 함 | B1 · B2 · B3 |
- 보안 등급 · 공개 제한 표기 = 확인 못 함(FGIS 서약 · 공공·연구기관 전용 구분만 확인).
## 4. map.forest.go.kr 지도 서버 주소
- 공개 안내 **없음** — map.forest.go.kr 메뉴(공간자료 다운로드 · 내 산 위치 찾기 · 통합검색 · SHP 중첩 등)와 forest.go.kr 산림공간정보 메뉴(지도서비스 · 자료유통서비스 · 산림공간정보소개 · 열린마당)에 오픈API · WMS 안내 없음([M1](https://map.forest.go.kr/forest/) · [F6](https://forest.go.kr/newkfsweb/kfs/idx/SubIndex.do?orgId=fgis&mn=KFS_03_08_01)).
- 화면 뒤 주소 캐내기 · 호출 시도 안 함.
## 5. 확인 못 함
- 상용 프로그램 탑재 허용 여부(모든 주제도) — 창구별 상충 · 산림청 서면 답 필요.
- data.go.kr 파일(3045619 · 3045621 · 3058167 · 15074817)의 첨부 파일 유무 · 크기 · 좌표계 — 페이지 글에 없음 · 1:5,000 임상도(15093362)는 FGIS 무료신청 경로 안내뿐.
- 임도망도 data.go.kr 판 축척 · 범위 · 노선 종류 칸.
- 경제림육성단지 GW API 응답에 도형(좌표) 포함 여부 · 심의 유형 · 트래픽.
- 산림빅데이터플랫폼 이용 조건(공공누리 유형).
- 브이월드 WMS 산림입지도(`lt_c_fsdifrsts`)가 산림입지토양도 1:25,000 과 같은 자료인지 · 브이월드 API 상용 조건(이전 조사 참고 · 이번 미확인).
## 출처 목록
- D1 https://www.data.go.kr/data/15093362/fileData.do — 임상도 1:5,000(FGIS 무료신청 안내)
- D2 https://www.data.go.kr/data/3045619/fileData.do — 임상도 1:25,000
- D3 https://www.data.go.kr/data/3045621/fileData.do — 임도망도
- D4 https://www.data.go.kr/data/3058167/fileData.do — 산림입지토양도 1:25,000
- D5 https://www.data.go.kr/data/15093364/fileData.do — 산림입지토양도 1:5,000(PDF)
- D6 https://www.data.go.kr/data/15149602/openapi.do — 행안부 산사태위험지도 WMS
- D7 https://www.data.go.kr/data/15074817/fileData.do — 산사태위험지도 파일
- D8 https://www.data.go.kr/data/15158975/openapi.do — 경제림육성단지구역도 GW
- V1 https://www.vworld.kr/dtmk/dtmk_ntads_s002.do?svcCde=MK&dsId=30136 — 임상도 1:5,000
- V2 https://www.vworld.kr/dtmk/dtmk_ntads_s002.do?svcCde=MK&dsId=30133 — 산림입지토양도 1:5,000
- V3 https://www.vworld.kr/dtmk/dtmk_ntads_s002.do?svcCde=MK&dsId=30126 — 경제림육성단지 국유림
- VW https://www.vworld.kr/dev/v4dv_wmsguide2_s001.do — 브이월드 WMS 레이어 목록
- B1 https://www.data.go.kr/data/95990/linkedData.do → https://www.bigdata-forest.kr/product/FRT001001
- B2 https://www.data.go.kr/data/95799/linkedData.do → https://www.bigdata-forest.kr/product/FRT002601
- B3 https://www.data.go.kr/data/95578/linkedData.do → https://www.bigdata-forest.kr/product/FRT001301
- S1 https://www.safemap.go.kr/opna/data/dataViewRenew.do?objtId=125 — 생활안전지도 산사태위험지도 WMS
- F1 https://www.forest.go.kr/newkfsweb/kfi/kfs/fgis/selectAvailMapList.do?mn=KFS_02_04_02_02&orgId=fgis — FGIS 자료유통현황
- F2 https://forest.go.kr/newkfsweb/html/HtmlPage.do?pg=%2Ffgis%2FUI_KFS_5002_020100.html&mn=KFS_02_04_03_04_01 — 임상도 현황
- F3 https://forest.go.kr/newkfsweb/kfi/kfs/fgis/fgisTermsAgreePage.do?mn=KFS_02_04_02_03&orgId=fgis — FGIS 자료신청 서약
- F4 https://www.forest.go.kr/kfsweb/kfi/kfs/cms/cmsView.do?mn=NKFS_07_02&cmsId=FC_000131 — 산림청 이용약관
- F5 https://www.forest.go.kr/kfsweb/opda/dataMng/pblicDataPrusContView.do?pblicDataId=PBD0000059 — 임상도 활용정보
- F6 https://forest.go.kr/newkfsweb/kfs/idx/SubIndex.do?orgId=fgis&mn=KFS_03_08_01 — 산림공간정보 메뉴
- M1 https://map.forest.go.kr/forest/ — 산림공간정보서비스
- P1 https://www.data.go.kr/ugs/selectPortalPolicyView.do — 공공데이터포털 이용정책
- P2 https://www.data.go.kr/odmc/trublMdat/faq/index.do — 분쟁조정 FAQ(영리 이용)
- L1 위키문헌 공공데이터법(법률 제13723호 판) 제3조 — 현행 조문은 law.go.kr 대조 필요
@@ -1017,3 +1017,7 @@ CPU는 더늘리기는 힘들어보이고 GPU 사용을 위해 부품사용에
## 요구 512 (10-31 · 사용자)
하는 동안에 나와대화해보자. 브이월드나 공공데이터포털에 정보가 있을것같은데.임상도라고 알아?
## 요구 513 (10-31 · 사용자)
내가 조사를 해봤는데 산림청에 데이터가 있는데 API 있는지는 모르겠어. (산림청 map.forest.go.kr — 산길(임도망도) · 나무지도(대축적 임상도) · 산림토양도 · 산사태위험지도 · 경제림육성단지) 이런 정보들이 있는데 확인만 해봐.
+5
View File
@@ -547,6 +547,11 @@ app.include_router(m02_items_router, dependencies=protected)
app.include_router(m02_combos_router, dependencies=protected)
app.include_router(m02_drawing_router, dependencies=protected)
app.include_router(m02_labor_router, dependencies=protected)
# 회사에 올리기 요청 · 승인(PLAN 706-1) — 로그인만 · 층 권한은 길 안에서
app.include_router(
__import__("M02_MasterTemplete.M02_Share_Request", fromlist=["router"]).router,
dependencies=protected,
)
# 프로젝트 집계 항목 복사본(PLAN 25-2b) — 로그인만 · 프로젝트 권한은 길 안에서.
app.include_router(m02_project_items_router, dependencies=protected)
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
+2 -2
View File
@@ -1,5 +1,5 @@
/* M02 일위대가 수량 · 반영 · 합(`M02_MasterTemplete_Labor_Calc.ts` `settle`) 거울 헬퍼 —
* 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {답, 문서, 값} · stdout = settle 결과 JSON. */
* 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {답, 문서, 값, 열?} · stdout = settle 결과 JSON. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
@@ -18,4 +18,4 @@ const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const input = JSON.parse(fs.readFileSync(0, "utf8"));
process.stdout.write(JSON.stringify(box.exports.settle(input.답, input.문서, input.값)));
process.stdout.write(JSON.stringify(box.exports.settle(input.답, input.문서, input.값, input.열)));
@@ -202,9 +202,9 @@ def test_셸_배선():
) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나)
assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄
assert (
"panel.replaceChildren(...banner, table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : []))"
in src
) # 203 레벨 안내 + 탭 안내 = 한 영역
"const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])];" in src
and "panel.replaceChildren(...banner, ...(row ? [row] : body));" in src
) # 203 레벨 안내 + 탭 안내 = 한 영역 · 703-1 창고 있으면 표 칸 + 오른쪽 서랍
lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8")
assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수
assert "b09-estimation__level" not in lvl
@@ -106,7 +106,9 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에():
def test_잇기_쉘_안내_줄_아래_도구_비움():
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert (
"panel.replaceChildren(...banner, table.root, ...(foot ? [foot] : []))" in shell # 701-3 띠 = 표 위
"const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])];" in shell
and "panel.replaceChildren(...banner, ...(row ? [row] : body));"
in shell # 701-3 띠 = 표 위
) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 203 레벨 안내 + 탭 안내 = 한 영역(맨 아래)
assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음
assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell)
+147
View File
@@ -0,0 +1,147 @@
"""703-1 B09 재료 창고 「지난 공사」 — 같은 회사 다른 공사 일위대가 → 이 공사 서고 사본(덮개째).
- 목록 = 그 공사 서고 사본 · 구조물 사본 · 고친 칸 수(그 공사 저장 덮개) · 같은 것 한 줄
- 가져옴 = 사본 폴더 통째 → `library/…~해시` · 가져온곳 · 덮개 = 그 공사 칸 · 다시 = 같은 사본
- 새 호표(`libs`) · 같은 꼴 블록이 이 공사에 먼저 있으면 그 블록 cover
- 모양 틀림 400 · 없음 404 · 다른 회사 403 · 이 공사 400
임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀.
"""
import asyncio
import json
import sys
from pathlib import Path
import pytest
from fastapi import HTTPException
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402
from B09_Estimation import B09_Estimation_Past as past # noqa: E402
from B09_Estimation import B09_Estimation_Router as router # noqa: E402
from B09_Estimation import B09_Estimation_Store as store_b09 # noqa: E402
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402
from config import config_system # noqa: E402
from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402
from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402
from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402
from resources.tester.helper_m02_master_sample import copy_master_sample # noqa: E402
STONE = "GF000219" # 돌쌓기 메쌓기 원/㎡
@pytest.fixture()
def world(tmp_path, monkeypatch):
system = copy_master_sample(tmp_path / "master")
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
with store.at(system):
key = refs.create(
"precombo",
{"이름": "지난 조합", "단위": "㎡", "담은로직": [{"로직": STONE, "수량": {"손": 2}}]},
)["이름"]
a, b = (tmp_path / "storage" / "7" / "1" / n for n in ("past", "here"))
a.mkdir(parents=True)
b.mkdir(parents=True)
folder = library.import_copy(a, "precombo", "system", None, key)["폴더"]
ref = {"층": "system", "주인": None, "키": key}
cover = {
f"C:{refs.ref_id(ref)}": {"sangeun:GF#0": {"qty": 5}},
"K:x": {"@copy": {"from": "S:y"}},
}
store_b09.write_user(a, {"daega_cover": cover})
return {"key": key, "a": a, "b": b, "folder": folder}
def test_목록_서고_사본_고친_칸_수_같은_것_한_줄(world):
a = world["a"]
rows = past.rows_of(a)
assert [(r["cover"], r["name"], r["unit"], r["kind"], r["covers"]) for r in rows] == [
(f"L:{world['folder']}", "지난 조합", "㎡", "사전조합", 1)
]
# 같은 원본 · 같은 값 · 덮개 없음 = 한 줄로 셈(값 해시 다른 사본)
library.import_copy(a, "precombo", "system", None, world["key"], haul={"given": {}})
assert len(past.rows_of(a)) == 1
def test_가져옴_사본_통째_덮개째_새_호표_다시는_같은_사본(world):
a, b, folder = world["a"], world["b"], world["folder"]
ref = {"project_id": "P-A", "프로젝트": "지난"}
made = past.import_past(b, a, ref, f"L:{folder}", {"이름": "나"})
name = made["폴더"]
assert made["만듦"] and made["덮개"] == {"sangeun:GF#0": {"qty": 5}}
assert name.startswith(f"precombo@system@-@{world['key']}~") and library.NAME.match(name)
copy = library.library_dir(b) / name
src = library.library_dir(a) / folder
for f in ("일위대가.json", "단가.json"):
assert (copy / f).read_bytes() == (src / f).read_bytes()
manifest = json.loads((copy / "manifest.json").read_text("utf-8"))
assert manifest["가져온곳"] == {**ref, "사본": f"L:{folder}"} and manifest["종류"] == "precombo"
got = daega_inputs(b)
bid = got["libs"][name]
block = next(x for x in got["blocks"] if x["id"] == bid)
# 사전조합 덮개 = 참조(공사 안 같은 사전조합 한 덮개) — 덮개자리 = 그 cover
assert (block["name"], block["cover"]) == ("지난 조합", made["덮개자리"])
assert made["덮개자리"] == "C:참조|system||" + world["key"]
again = past.import_past(b, a, ref, f"L:{folder}")
assert (again["폴더"], again["만듦"]) == (name, False)
def test_같은_꼴_블록이_먼저_있으면_그_cover(world):
a, b = world["a"], world["b"]
mine = library.import_copy(b, "precombo", "system", None, world["key"])["폴더"]
name = past.import_past(b, a, {"project_id": "P-A"}, f"L:{world['folder']}")["폴더"]
got = daega_inputs(b)
assert got["libs"][name] == got["libs"][mine]
block = next(x for x in got["blocks"] if x["id"] == got["libs"][name])
assert block["cover"].startswith("C:") # 사전조합 = 같은 참조 한 덮개(화면이 빈 때만 얹음)
def test_구조물조합_사본_덮개자리_새_폴더(world):
a, b = world["a"], world["b"]
folder = library.import_copy(a, "combo", "system", None, "SS000011")["폴더"]
store_b09.write_user(a, {"daega_cover": {f"L:{folder}": {"x#0": {"qty": 2}}}})
row = next(r for r in past.rows_of(a) if r["cover"] == f"L:{folder}")
assert (row["kind"], row["covers"]) == ("구조물 조합", 1)
made = past.import_past(b, a, {"project_id": "P-A"}, f"L:{folder}")
assert made["덮개자리"] == f"L:{made['폴더']}" and made["덮개"] == {"x#0": {"qty": 2}}
got = daega_inputs(b)
block = next(x for x in got["blocks"] if x["id"] == got["libs"][made["폴더"]])
assert block["cover"] == made["덮개자리"]
def test_모양_틀림_없음(world):
a, b = world["a"], world["b"]
for bad in ("L:../x", "X:y", "S:../../z"):
with pytest.raises(ValueError):
past.import_past(b, a, {}, bad)
with pytest.raises(LookupError):
past.import_past(b, a, {}, "S:nope")
with pytest.raises(LookupError):
past.import_past(b, a, {}, "L:precombo@system@-@SC-000999")
def test_길_다른_회사_403_이_공사_400(world, monkeypatch):
rows = {
"here": {"company_id": 7, "root": world["b"]},
"far": {"company_id": 8, "root": world["a"]},
}
async def project(_session, pid):
return {"name": pid, **rows["here" if pid.startswith("0") else "far"]}
monkeypatch.setattr(router, "_project", project)
pid = router.UUID("00000000-0000-0000-0000-000000000001")
body = router.PastBody(project_id="far", cover=f"L:{world['folder']}")
with pytest.raises(HTTPException) as e:
asyncio.run(router.past_import(pid, body, {"user_id": 1}))
assert e.value.status_code == 403
same = router.PastBody(project_id=str(pid), cover="L:x")
with pytest.raises(HTTPException) as e:
asyncio.run(router.past_import(pid, same, {"user_id": 1}))
assert e.value.status_code == 400
assert not library.library_dir(world["b"]).exists() # 아무것도 안 씀
+2 -1
View File
@@ -95,6 +95,7 @@ CALC = {"줄": [{"로직": "GF1", "분모": "㎡", "배": 10, "수량": 2.09,
def test_M02_문서_calc_를_격자_줄로():
files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
logics = [
["GF1", {"이름": "돌쌓기 찰쌓기", "입력": [], "결과단위": "㎡"}],
@@ -134,7 +135,7 @@ def test_M02_문서_calc_를_격자_줄로():
def test_세_곳_같은_부품():
detail = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8")
assert "laborGrid(cur, calc ?? last, logics)" in detail # 카드 · 상세 = 같은 Labor_Detail
assert "laborGrid(cur, calc ?? last, logics, " in detail # 카드 · 상세 = 같은 Labor_Detail
m02 = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts").read_text("utf-8")
b09 = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8")
assert '"@ui/ui_template_hopyo_grid"' in m02 and '"@ui/ui_template_hopyo_grid"' in b09
+249
View File
@@ -0,0 +1,249 @@
"""704-4 규격 열 — 설계 고르기 = 열(summary_item `spec`) · 사본이 고른 열을 굳힘 ·
열 바꿈 뒤 B09 덮개 남음 · B07 한 장 판정 = 항목 + 열 · 열 지움 막음(쓰는 사본).
임시 저장소(실제 마스터 · storage 안 씀 · M01 읽기만).
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
import pytest
from B07_DesignDetail.B07_DesignDetail_Router_Support_Structure import structure_sheets
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
from common_util.common_util_summary_item import parse_summary_item
from config import config_system
from M02_MasterTemplete import M02_Item_Snapshot as snap
from M02_MasterTemplete import M02_Labor_Spec as spec
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.helper_m02_master_sample import SAMPLE
MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"}
SPEC = {
"이름": "H",
"열": ["1.5", "2.5"],
"기본": "1.5",
"수량": {"GF000219": {"1.5": 2, "2.5": 3}},
}
LABOR = {
"종류": "일위대가",
"열": "ms",
"단위": "m",
"담은로직": [
{"로직": "GF000219", "수량": {"손": 2}}, # 메쌓기 — 열마다 수량
{"로직": "GF000119", "수량": {"손": 0.5}}, # 노체포설 — 공통
],
"자재": [],
"규격": SPEC,
}
# ── summary_item spec ──────────────────────────────────────────────────────
def test_summary_item_spec_있음_없음():
assert parse_summary_item({**MS, "spec": " 2.5 "}) == {**MS, "spec": "2.5"}
assert parse_summary_item(MS) == MS # 옛 기록 = 칸 없음 그대로
with pytest.raises(ValueError, match="모르는 칸"):
parse_summary_item({**MS, "열": "2.5"})
for bad in ("", "x" * 21, 2.5):
with pytest.raises(ValueError, match="spec"):
parse_summary_item({**MS, "spec": bad})
# ── 굳히기 ─────────────────────────────────────────────────────────────────
def test_굳히기_고른_열_지운_열_옛_문서():
got = spec.freeze({**LABOR, "계산값": {"x": 1}}, "2.5")
assert got["담은로직"][0]["수량"] == {"손": 3} and got["담은로직"][1]["수량"] == {"손": 0.5}
assert got["규격"] == {"이름": "H", "열": ["2.5"], "기본": "2.5", "수량": {}}
assert "계산값" not in got
assert spec.freeze(LABOR, "지운열")["담은로직"][0]["수량"] == {"손": 2} # = 기본
old = {k: v for k, v in LABOR.items() if k != "규격"}
assert spec.freeze(old, "2.5") is old
# ── 임시 저장소 사본 ───────────────────────────────────────────────────────
@pytest.fixture
def world(tmp_path: Path, monkeypatch) -> dict[str, Any]:
system = tmp_path / "master"
system.mkdir()
shutil.copy2(SAMPLE / layers.COMBO_FILE, system / layers.COMBO_FILE)
for kind in ("table", "structure", "area"):
shutil.copytree(SAMPLE / kind, system / kind)
(system / "labor").mkdir()
(system / "labor" / "ms.json").write_text(json.dumps(LABOR), encoding="utf-8")
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
storage = tmp_path / "storage"
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
root = storage / "3" / "12" / "proj"
(root / "B05_Profile/route").mkdir(parents=True)
(root / "B04_PreProcess/drainage/edits").mkdir(parents=True)
return {"system": system, "root": root}
def _save(root: Path, specs: list[str | None]) -> dict[str, list[str]]:
rows = [
{
"structure_id": f"st{n}",
"type_id": "masonry_dry",
"placement": "interval",
"chainage_m": 85 + n * 100,
"start_m": 80 + n * 100,
"end_m": 90 + n * 100,
"options": {"height_m": 2.0, "length_m": 10},
"summary_item": {**MS, **({"spec": s} if s else {})},
}
for n, s in enumerate(specs)
]
(root / "B05_Profile/route/structures.json").write_text(
json.dumps({"revision": 1, "structures": rows}, ensure_ascii=False), encoding="utf-8"
)
(root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text(
'{"points": []}', encoding="utf-8"
)
return snap.sync_snapshots(root)
def _block(root: Path, owner: str) -> tuple[dict, dict]:
got = daega_inputs(root)
return got, next(b for b in got["blocks"] if b["id"] == got["owners"][owner])
def test_사본이_고른_열을_굳힘_manifest_규격(world):
root = world["root"]
assert _save(root, ["2.5"])["만듦"] == ["st0"]
folder = snap.structures_dir(root) / "st0"
doc = json.loads((folder / "일위대가.json").read_text(encoding="utf-8"))["줄"]
assert [x["수량"] for x in doc["담은로직"]] == [{"손": 3}, {"손": 0.5}]
manifest = json.loads((folder / snap.MANIFEST_NAME).read_text(encoding="utf-8"))
assert manifest["규격"] == {"이름": "H", "열": "2.5"} and manifest["원본"]["spec"] == "2.5"
assert _save(root, ["2.5"])["만듦"] == [] # 같은 항목 · 같은 열 = 그대로
def test_열_바꿈_뒤_덮개_남음(world):
root = world["root"]
_save(root, ["1.5"])
_, before = _block(root, "st0")
cids = [ln["cid"] for ln in before["lines"]]
assert before["cover"] == "S:st0" and [ln["qty"] for ln in before["lines"]] == [2, 0.5]
cover = {before["cover"]: {cids[1]: {"qty": 0.7}}} # 공통 줄 품을 고침
got = _save(root, ["2.5"])
assert got["지움"] == ["st0"] and got["만듦"] == ["st0"] # 열 바꿈 = 새 사본
after_all, after = _block(root, "st0")
assert after["cover"] == before["cover"] and [ln["cid"] for ln in after["lines"]] == cids
sheets = {"labor": [], "material": [], "machine": [], "gaps": []}
out = compute_labor(after_all, sheets, {}, cover)
lines = next(b for b in out["daega_blocks"] if b["id"] == after["id"])["lines"]
assert lines[0]["qty"] == 3 # 새 열 수량
assert lines[1]["qty"] == 0.7 and lines[1]["covered"] == ["qty"] # 덮개 그대로
assert out["daega_orphans"] == []
def test_B07_한_장_판정_항목_더하기_열(world):
root = world["root"]
_save(root, ["1.5", "1.5", "2.5"])
ids = [s["id"] for s in structure_sheets(root) if s["id"].startswith("structure_system")]
assert len(set(ids)) == 2 # 같은 열 둘 = 한 장 · 다른 열 = 따로
def test_열_지움_쓰는_사본_찾기(world):
root = world["root"]
_save(root, ["2.5", None])
assert spec.users(world["system"], {"SS000022"}, {"2.5"}) == ["proj/st0"]
assert spec.users(world["system"], {"SS000022"}, {"1.5"}) == []
assert spec.users(world["system"], {"SS000001"}, {"2.5"}) == []
def test_쓰는_열_지움_409_안_쓰는_열_됨(world):
from M02_MasterTemplete import M02_MasterTemplete_Store as store
root = world["root"]
_save(root, ["2.5"])
path = world["system"] / "labor" / "ms.json"
with store.at(world["system"]):
with pytest.raises(store.StoreError) as got:
spec.guard(path, {"규격": {**SPEC, "열": ["1.5"]}}, "ms")
assert got.value.status == 409 and "proj/st0" in str(got.value.detail)
spec.guard(path, {"규격": {**SPEC, "열": ["2.5", "3.0"]}}, "ms") # 1.5 = 안 씀
spec.guard(path, {"규격": SPEC}, "ms")
# ── 화면 자료(TS · Node) — 격자 규격 열 · 고르기 창 spec ─────────────────────
def test_격자_규격_열_수량_칸_열마다():
from resources.tester import test_704_3_hopyo_grid as g
files = [g.GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
doc = {**g.DOC, "규격": {"이름": "H", "열": ["1.5", "2.0"], "기본": "2.0",
"수량": {"GF1": {"1.5": 1.5, "2.0": 2.09}, "MT1": {"1.5": 4}}}} # fmt: skip
doc["자재"] = [{**doc["자재"][0], "수량": {"손": 5}}, {**doc["자재"][1], "수량": {"손": 2}}]
out = g._node(files, (
f"\nconst b = laborBlock({json.dumps(doc, ensure_ascii=False)}, {json.dumps(g.CALC, ensure_ascii=False)}, new Map());"
"\nconst grid = hopyoGrid([b]);"
"\nconsole.log(JSON.stringify({ cols: grid.columns.map((c) => [c.key, c.label, c.group]),"
" rows: grid.rows, specs: b.specs }));"
), g.STUBS) # fmt: skip
cols = [c for c in out["cols"] if c[0].startswith("qty")]
assert cols == [["qty_0", "1.5", ["H 열"]], ["qty_1", "2.0 (기본)", ["H 열"]]]
line = out["rows"][1] # 첫 구성 줄
assert line["qty_0"] == 1.5 and line["qty_1"] == 2.09
mt1 = next(r for r in out["rows"] if r["name"] == "ㄴ 큰돌")
assert mt1["qty_0"] == 4 and mt1["qty_1"] == 5 # 2.0 빈 열 = 공통 수량
t15, t20 = out["specs"]["totals"]
assert t20 == pytest.approx(112559.55) # 기본 열 = 금액 칸 합
assert t15 == pytest.approx(529950 * 1.5 / 10 + 1800) # 열 수량 · 별도계상 MT1 = 합 밖
def test_고르기_창_spec_돌려줌():
import subprocess
data = Path(__file__).resolve().parents[2] / "ui_template/ui_template_item_picker_data.ts"
body = (
data.read_text("utf-8")
+ """
const row = { key: "SS000022", 열: "ms", 경로: ["석축", "메쌓기"],
labor: { 규격: { 이름: "H", 기본: "2.0", 열: [{ 이름: "1.5", 합계: 1 }, { 이름: "2.0", 합계: 2 }] } } };
const one = { ...row, labor: { 규격: { 이름: "H", 기본: "1.5", 열: [{ 이름: "1.5", 합계: 1 }] } } };
const plain = { key: "SS000001", 열: "rv" };
const cur = { layer: "system", owner: null, key: "SS000022", name: "x", spec: "1.5" };
console.log(JSON.stringify([toSummary(row, "1.5"), toSummary(one, "1.5"), toSummary(plain, "1.5"),
startSpec(row, cur), startSpec(row, null), startSpec(row, { ...cur, spec: "지운" }), startSpec(plain, cur)]));
"""
)
got = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
input=body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert got.returncode == 0, got.stderr
two, single, plain, *starts = json.loads(got.stdout)
assert two == {"layer": "system", "owner": None, "key": "SS000022",
"name": "석축 메쌓기 (H=1.5)", "spec": "1.5"} # fmt: skip
assert single["name"] == "석축 메쌓기" and single["spec"] == "1.5" # 열 하나 = 이름 그대로
assert "spec" not in plain # 규격 없는 옛 항목
assert starts == ["1.5", "2.0", "2.0", None]
def test_B08_B09_금액_열_수량_곱하기_단가(world):
"""B08 = 사본 1 단위 값(사본 셈 반영합) = 층 문서 그 열 셈 · B09 = 대가 줄 금액 = 열 수량 × 단가."""
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Labor_Doc as labor
root = world["root"]
_save(root, ["2.5"])
folder = snap.structures_dir(root) / "st0"
read = lambda name: json.loads((folder / name).read_text(encoding="utf-8")) # noqa: E731
copy = item_prices.calc(read("일위대가.json"), read("단가.json"), read("입력값.json"), {}, None)
layer = labor.clean(LABOR)
want = labor.settle(labor.calc(layer, {}), layer, {}, "2.5")["반영합"]
assert copy["반영합"]["계"] == pytest.approx(want["계"]) # B08 1 단위 값 = 2.5 열
first = labor.settle(labor.calc(layer, {}), layer, {}, "1.5")["반영합"]
assert want["계"] != pytest.approx(first["계"])
got, block = _block(root, "st0")
price = {ln["key"]: {"labor": 1000.0, "material": 0.0, "expense": 0.0} for ln in block["lines"]}
sheets = {"labor": [], "material": [], "machine": [], "gaps": []}
out = compute_labor(got, sheets, price, {})
lines = next(b for b in out["daega_blocks"] if b["id"] == block["id"])["lines"]
assert [ln["amounts"]["labor"] for ln in lines] == [3000, 500] # 3 × 1000 · 공통 0.5 × 1000
@@ -0,0 +1,129 @@
"""회사에 올리기 요청 · 승인(PLAN 706-1 · `M02_Share_Request`) — 임시 storage.
① 일반 사용자 요청(구조물 항목 · 사전조합) → 장부 한 줄 · 같은 것 다시 = 409 「요청 중」.
② 회사 관리자 승인 = 회사 층 고른 폴더에 사본(원본 개인 것 그대로) · 거절 = 사유 · 요청자 알림 → 봄.
③ 권한 — 일반 승인 403 · 다른 회사 관리자는 그 요청을 못 봄(404) · 남의 회사 장부 안 봄 ·
목록은 자기 것만.
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Labor_Router as labor_router
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
from M02_MasterTemplete import M02_Share_Request as share
from resources.tester import test_m02_tree as base
from resources.tester.test_m02_tree import Tree
world = base.world # 같은 임시 storage 판
URL = "/api/m02/share-requests"
def _client(world: dict[str, Any]) -> TestClient:
app = FastAPI()
for one in (layers_router, items_router, labor_router, share):
app.include_router(one.router)
app.dependency_overrides[verify_session] = lambda: world["session"]
return TestClient(app)
def _as(world: dict[str, Any], role: str = "USER", user: int = 42, company: int = 7) -> None:
world["session"].update(role=role, user_id=user, company_id=company, name=f"사람{user}")
def _mine(world: dict[str, Any], client: TestClient) -> tuple[str, str]:
"""일반 사용자 42 개인 층 — 항목 「배수/sub7임시」 · 사전조합 하나 → (항목 키, 사전조합 키)."""
_as(world)
t = Tree(client, "personal")
t.do(op="add-folder", 경로=[], 이름="배수")
t.do(op="add-item", 경로=["배수"], 이름="sub7임시", 단위="m")
doc = {
"문서": {"이름": "sub7임시 사전", "구분": "C 사면안정", "상세구분": "옹벽", "담은로직": []}
}
pre = client.post("/api/m02/layers/personal/library/precombo", json=doc).json()["이름"]
return t.item(["배수", "sub7임시"])["key"], pre
def test_요청_승인_거절_알림(world):
client = _client(world)
item, pre = _mine(world, client)
got = client.post(URL, json={"종류": "item", "키": item, "글": " 회사 공용으로 "})
assert got.status_code == 200, got.text
one = got.json()
assert (one["이름"], one["경로"], one["주인"], one["글"]) == (
"sub7임시",
["배수"],
"7/42",
"회사 공용으로",
)
assert client.post(URL, json={"종류": "item", "키": item}).status_code == 409 # 요청 중
two = client.post(URL, json={"종류": "precombo", "키": pre}).json()
assert client.post(URL, json={"종류": "item", "키": "SP42-999999"}).status_code == 404
assert client.post(URL, json={"종류": "precombo", "키": "SC-999999"}).status_code == 404
mine = client.get(URL).json()
assert mine["관리"] is False and [r["id"] for r in mine["줄"]] == [one["id"], two["id"]]
_as(world, "ADMIN", user=50) # 회사 7 관리자
company = Tree(client, "company")
company.do(op="add-folder", 경로=[], 이름="공용")
boss = client.get(URL).json()
assert boss["관리"] is True and len(boss["줄"]) == 2
ok = client.post(f"{URL}/{one['id']}/approve", json={"경로": ["공용"]})
assert ok.status_code == 200, ok.text
assert ok.json()["상태"] == "승인" and ok.json()["봄"] is False
company = Tree(client, "company")
assert ["공용", "sub7임시"] in company.paths() # 고른 폴더에 사본
again = client.post(f"{URL}/{one['id']}/approve", json={"경로": []})
assert again.status_code == 409 # 이미 승인
no = client.post(f"{URL}/{two['id']}/reject", json={"사유": "이름 다시"})
assert no.json()["상태"] == "거절" and no.json()["사유"] == "이름 다시"
_as(world) # 요청자 — 원본 그대로 · 결과 둘 · 봄 뒤 0
assert ["배수", "sub7임시"] in Tree(client, "personal").paths()
rows = client.get(URL).json()["줄"]
assert [(r["상태"], r["봄"]) for r in rows] == [("승인", False), ("거절", False)]
assert client.post(f"{URL}/seen").json() == {"봄": 2}
assert client.post(f"{URL}/seen").json() == {"봄": 0}
# 거절된 것은 다시 요청할 수 있음
assert client.post(URL, json={"종류": "precombo", "키": pre}).status_code == 200
def test_사전조합_승인_폴더_자리(world):
client = _client(world)
_, pre = _mine(world, client)
rid = client.post(URL, json={"종류": "precombo", "키": pre}).json()["id"]
_as(world, "ADMIN", user=50)
tree_url = "/api/m02/layers/company/precombo-tree"
now = client.get(tree_url).json()
client.post(tree_url, json={"op": "add-folder", "경로": [], "이름": "옹벽", "판": now["판"]})
made = client.post(f"{URL}/{rid}/approve", json={"경로": ["옹벽"]}).json()["사본"]
assert client.get(tree_url).json()["자리"] == {made: ["옹벽"]}
doc = client.get(f"/api/m02/layers/company/templates/precombo/{made}").json()["문서"]
assert doc["이름"] == "sub7임시 사전"
assert doc["본뜬"]["주인"] == "7/42" # 원본 표시
def test_권한_일반_승인_403_다른_회사(world):
client = _client(world)
item, _ = _mine(world, client)
rid = client.post(URL, json={"종류": "item", "키": item}).json()["id"]
assert client.post(f"{URL}/{rid}/approve", json={}).status_code == 403 # 일반 = 승인 못 함
assert client.post(f"{URL}/{rid}/reject", json={}).status_code == 403
_as(world, "USER", user=43) # 같은 회사 다른 일반 — 남의 요청 안 보임
assert client.get(URL).json()["줄"] == []
_as(world, "ADMIN", user=90, company=9) # 다른 회사 관리자
assert client.get(URL).json()["줄"] == []
assert client.post(f"{URL}/{rid}/approve", json={}).status_code == 404 # 자기 장부에 없음
assert client.get(URL, params={"company": "7"}).json()["회사"] == "9" # 남의 회사 고르기 무시
_as(world, "SYSTEM_ADMIN", user=1, company=None)
assert len(client.get(URL, params={"company": "7"}).json()["줄"]) == 1
ledger = world["storage"] / "7" / "templates" / share.FILE
assert json.loads(ledger.read_bytes())["줄"][0]["상태"] == "요청"
@@ -69,3 +69,84 @@ def test_파이썬과_TS_가_같은_답(values):
py = labor.settle(answer, doc, values)
_same(py, _ts(answer, doc, values))
assert py == labor.calc(doc, values) # settle 만 다시 = 처음부터 다시
# ── 규격 열(704-4) — 열마다 줄 수량 · 열 없는 옛 문서는 위 시험 그대로 ──
SPEC_DOC = {
"열": "rv",
"담은로직": [
{"로직": TEN, "수량": None},
{"로직": THOUSAND, "수량": {"손": 4}},
{"로직": PLAIN, "수량": None},
],
"자재": [{"코드": MAT, "수량": {"손": 1}, "별도계상": "직접계산"}],
"규격": {
"이름": "H",
"열": ["1.5", "2.0", "2.5"],
"기본": "2.0",
"수량": {
THOUSAND: {"1.5": 3, "2.0": "4.5", "2.5": 6},
MAT: {"1.5": 0.8, "2.5": 1.4}, # 2.0 빈 열 = 공통 손 1
"없는줄": {"1.5": 9}, # 버림
},
},
}
@pytest.mark.parametrize("col", [None, "1.5", "2.0", "2.5", "모름"])
def test_규격_열_파이썬과_TS_가_같은_답(col):
doc = labor.clean(SPEC_DOC)
assert doc["규격"]["수량"] == {
THOUSAND: {"1.5": 3, "2.0": 4.5, "2.5": 6},
MAT: {"1.5": 0.8, "2.5": 1.4},
}
answer = labor.calc(doc, {})
values = {"사석 수상고르기|㎡": 2.5}
py = labor.settle(answer, doc, values, col)
_same(py, _ts(answer, doc, values) if col is None else _ts_col(answer, doc, values, col))
use = col if col in ("1.5", "2.0", "2.5") else "2.0" # 모름 = 기본
line = next(x for x in py["줄"] if x["로직"] == THOUSAND)
assert line["수량"] == {"1.5": 3, "2.0": 4.5, "2.5": 6}[use] and line["수량출처"] == "손"
mat = py["자재"][0]
assert mat["수량"] == {"1.5": 0.8, "2.0": 1, "2.5": 1.4}[use]
def _ts_col(answer: dict, doc: dict, values: dict, col: str) -> dict:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")],
input=json.dumps({"답": answer, "문서": doc, "값": values, "열": col}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(proc.stdout)
def test_규격합_은_열마다_다시_센_반영합():
doc = labor.clean(SPEC_DOC)
out = labor.calc(doc, {"사석 수상고르기|㎡": 2.5})
sums = labor.spec_sums(doc, out)["규격합"]
assert list(sums) == ["1.5", "2.0", "2.5"]
for col, total in sums.items():
assert total == labor.settle(out, doc, {"사석 수상고르기|㎡": 2.5}, col)["반영합"]
assert sums["1.5"]["계"] < sums["2.0"]["계"] < sums["2.5"]["계"]
@pytest.mark.parametrize(
"bad",
[
{"이름": "", "열": ["1.5"]},
{"이름": "H", "열": ["1.5", "1.5"]},
{"이름": "H", "열": ["1.5"], "수량": {THOUSAND: {"1.5": "글"}}},
],
)
def test_규격_칸_꼴이_틀리면_400(bad):
with pytest.raises(labor.store.StoreError) as got:
labor.clean({**SPEC_DOC, "규격": bad})
assert got.value.status == 400
def test_열_없는_규격_은_옛_문서():
assert "규격" not in labor.clean({**SPEC_DOC, "규격": {"이름": "H", "열": []}})
assert "규격" not in labor.clean({k: v for k, v in SPEC_DOC.items() if k != "규격"})
+41 -1
View File
@@ -8,6 +8,8 @@
* 구성 줄 = 「ㄴ 품명」 · 규격 · 품/수량 · 단위 · 합계 · 노무비 · 재료비 · 경비(단가 / 금액 쌍) · 비고 · 구분(줄 꼬리표)
* 합계 줄 = 비목 금액 합 · 빈 줄(금액 모름 · 수량 없음) 있으면 비고 「빈 줄 n」 붉게 — 합계는 아는 줄만
* 줄 꼬리표 = 「품셈」(우리 로직이 품을 셈) · 「고정」 갈래는 705 몫(자리만 · 같은 칸)
* 규격 열(704-4 · 첫 호표 `specs`) = 수량 칸이 열 수만큼(`qty_n` · 머리 「H 열」) · 합계 줄 그 칸 = 열마다 1 단위 합계 ·
* 금액 칸 = 기본 열 값
* 값은 부르는 쪽이 준 것 그대로(서버 계산 값) — 여기서 셈하지 않음
*
* const g = createHopyoGrid([block]); host.append(g.root); g.set([block2]);
@@ -44,6 +46,16 @@ export interface HopyoLine {
note?: string | null;
/** 줄 꼬리표 — 「품셈」 · 「고정」(705) · 빈 값 = 없음 */
tag?: string | null;
/** 규격 열마다 품 · 수량(704-4 · 블록 `specs.cols` 차례) — 없으면 `qty` */
qtys?: (number | null)[];
}
/** 규격 열(704-4) — 이름(H) · 열 이름 · 기본 열 · 열마다 1 단위 합계 */
export interface HopyoSpecs {
name: string;
cols: string[];
pick?: string | null;
totals: (number | null)[];
}
export interface HopyoBlock {
@@ -63,6 +75,8 @@ export interface HopyoBlock {
/** 빈 줄 수 · 이름 — 없으면 금액 모르는 줄에서 셈 */
blank?: number;
blank_names?: string[];
/** 규격 열(704-4) — 첫 호표 것만 열로 폄 */
specs?: HopyoSpecs | null;
}
/** 줄 꼬리표 낱말 */
@@ -106,6 +120,23 @@ export const HOPYO_COLUMNS: DataColumn[] = [
hopyoText("tag", "구분", 56),
];
/** 규격 열 수량 칸 열쇠(704-4) */
export const hopyoSpecKey = (n: number): string => `qty_${n}`;
/** 열 — 첫 호표에 규격 열이 있으면 수량 칸을 열마다 폄(기본 열 = 「(기본)」) */
export function hopyoColumns(blocks: HopyoBlock[]): DataColumn[] {
const specs = blocks[0]?.specs;
if (!specs?.cols.length) return HOPYO_COLUMNS;
const qty = HOPYO_COLUMNS.find((c) => c.key === "qty") as DataColumn;
const cols = specs.cols.map((col, n): DataColumn => ({
...qty,
key: hopyoSpecKey(n),
label: col === specs.pick ? `${col} (기본)` : col,
group: [`${specs.name} 열`],
}));
return HOPYO_COLUMNS.flatMap((c) => (c.key === "qty" ? cols : [c]));
}
export interface HopyoGrid {
columns: DataColumn[];
rows: DataRow[];
@@ -126,6 +157,7 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid {
const rows: DataRow[] = [];
const titles = new Set<number>();
const at = new Map<number, [number, number]>();
const specCount = blocks[0]?.specs?.cols.length ?? 0;
blocks.forEach((b, bi) => {
titles.add(rows.length);
rows.push({
@@ -153,6 +185,7 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid {
note: ln.note ?? "",
tag: ln.tag ?? "",
};
for (let k = 0; k < specCount; k++) row[hopyoSpecKey(k)] = ln.qtys?.[k] ?? ln.qty;
for (const [key] of HOPYO_COSTS) {
row[`${key}_price`] = ln.prices[key] ?? null;
row[key] = ln.amounts[key] ?? null;
@@ -161,7 +194,14 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid {
});
const blank = hopyoBlank(b);
titles.add(rows.length);
const sums = Object.fromEntries(
Array.from({ length: specCount }, (_, k) => {
const sum = b.specs?.totals[k];
return [hopyoSpecKey(k), sum == null ? null : Math.round(sum * 10) / 10]; // 금액 칸처럼 한 자리
}),
);
rows.push({
...sums,
key: `${b.key}|합계`,
name: "합 계",
sum: b.total.sum ?? null,
@@ -173,7 +213,7 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid {
blank_names: blank.names,
});
});
return { columns: HOPYO_COLUMNS, rows, titles, at };
return { columns: hopyoColumns(blocks), rows, titles, at };
}
/** 칸 꼴 — 꼬리표 · 빈 줄 알림 */
+42 -2
View File
@@ -11,6 +11,8 @@
* 224-1 — 맨 위 = M02 구조물조합 상단 표(구조물집계표 · 보기 전용 · 고른 항목의 열이 골라짐).
* 173-2 — 나무 = M02 나무 꼴: 층 단추 셋(하나만 보임 · 뿌리 줄 없음) · 개인 맨 위 「새 항목」 = 빈 자리 오른쪽 클릭.
* 280-2 — 위 표 · 나무 · 오른쪽 카드 = 한 상태: 위 표 = 보이는 층 표 · 표에서 열을 고르면 나무(펼쳐 굴림) · 카드도 그 항목.
* 704-4 — 고른 항목에 규격 열이 둘 이상이면 표 아래 열 단추(열마다 1 단위 합계) · [적용] = `spec` 붙은 참조 ·
* 처음 = 지금 지정 열(같은 항목) · 아니면 기본 · 열 하나 = 단추 없음(그 열로).
* ========================================================================== */
import "./ui_template_item_picker.css";
@@ -25,6 +27,8 @@ import {
ownerOf,
pathIds,
rowId,
specOf,
startSpec,
tableKey,
toSummary,
type PickNode,
@@ -47,6 +51,8 @@ import { projectItems, type CreatedItem } from "./ui_template_item_picker_projec
import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail";
import { createEditView, type EditView } from "./ui_template_item_picker_edit";
import { createComboTable } from "./ui_template_item_picker_table";
import { createToolbar } from "./ui_template_toolbar";
import { createText } from "./ui_template_text";
export type { SummaryItem } from "./ui_template_item_picker_data";
@@ -166,6 +172,10 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
children: [comboTable.root],
});
const editHost = el("div", { className: "ui-item-picker__edit-host", attrs: { hidden: "" } });
/** 규격 열(704-4) — 고른 열 · 그 열을 정한 항목 */
let specPick: string | null = null;
let specFor = "";
const specHost = el("div", { className: "ui-item-picker__spec", attrs: { hidden: "" } });
let editView: EditView | null = null;
const applyButton = createButton({ label: t("M02_PickerApply"), variant: "filled" });
const clearButton = createButton({ label: t("M02_PickerClear"), variant: "ghost" });
@@ -194,9 +204,37 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
);
};
const paintSpec = (): void => {
if (chosen && rowId(chosen) !== specFor) {
specFor = rowId(chosen);
specPick = startSpec(chosen, opts.current);
}
const spec = chosen ? specOf(chosen) : null;
specHost.hidden = !spec || spec.열.length < 2;
if (!spec || specHost.hidden) return void specHost.replaceChildren();
const sum = (n: number | null): string =>
n === null ? "" : ` · ${n.toLocaleString("ko-KR", { maximumFractionDigits: 0 })}`;
const buttons = spec.열.map((col) => {
const button = createButton({
label: `${spec.이름}=${col.이름}${sum(col.합계)}`,
variant: col.이름 === specPick ? "filled" : "ghost",
size: "sm",
onClick: () => {
specPick = col.이름;
paintSpec();
},
});
button.setAttribute("aria-pressed", String(col.이름 === specPick));
return button;
});
const head = createText(`${spec.이름} 규격 열`, "muted");
specHost.replaceChildren(createToolbar({ items: [head, ...buttons], label: spec.이름 }));
};
const paintDetail = (): void => {
detailView.show(chosen);
paintTable();
paintSpec();
const layer = chosen ? layerOf(chosen) : null;
applyButton.disabled = !chosen;
clearButton.disabled = !opts.current;
@@ -337,7 +375,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
});
applyButton.addEventListener(
"click",
() => chosen && finish({ action: "apply", item: toSummary(chosen) }),
() => chosen && finish({ action: "apply", item: toSummary(chosen, specPick) }),
);
clearButton.addEventListener("click", () => finish({ action: "clear" }));
manageButton.addEventListener("click", () => {
@@ -407,6 +445,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
editHost.replaceChildren(view.root);
cols.hidden = true;
tableWrap.hidden = true;
specHost.hidden = true;
actions.hidden = true;
headButtons.hidden = true;
editHost.hidden = false;
@@ -419,6 +458,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
editHost.hidden = true;
cols.hidden = false;
tableWrap.hidden = false;
paintSpec();
actions.hidden = false;
headButtons.hidden = false;
if (saved) detailView.markEdited();
@@ -440,7 +480,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
return true;
},
mount: (body) => {
body.append(tableWrap, cols, editHost, ...(opts.projectId ? [actions] : []));
body.append(tableWrap, specHost, cols, editHost, ...(opts.projectId ? [actions] : []));
paintDetail();
void load(null);
},
+31 -2
View File
@@ -2,6 +2,7 @@
* ui_template_item_picker_data.ts
* 항목 고르기의 자료 쪽(화면 없음) — 항목 목록 → 나무 · 찾기 · 폴더 줄 · 돌려줄 `summary_item`.
* 모양은 PLAN.md 21-1 약속 그대로: 항목 목록 API 한 줄 = `PickerRow`, 참조 = `SummaryItem`.
* 규격 열(704-4) = 줄 `labor.규격` · 돌려줄 값에 `spec`(열 둘 이상이면 이름 뒤 「(H=2.0)」).
* ========================================================================== */
export type PickerLayer = "system" | "company" | "personal";
@@ -12,6 +13,15 @@ export interface SummaryItem {
owner: string | null;
key: string;
name: string;
/** 규격 열 이름(704-4) — 없으면 기본 열 */
spec?: string;
}
/** 규격 열(704-4 · 서버 목록 `labor.규격`) — 열마다 1 단위 합계 */
export interface PickerSpec {
이름: string;
열: { 이름: string; 합계: number | null }[];
기본: string;
}
/** 항목 줄이 가리키는 키 칸 — 서버가 `names` 로 이름을 함께 주면 이름을 보임 */
@@ -36,6 +46,8 @@ export interface PickerRow {
names?: Partial<Record<NameField, string | null>>;
layer?: PickerLayer;
owner?: string | null;
/** 일위대가 문서 요약(39-5) — 규격 열만 씀 */
labor?: { 규격?: PickerSpec } | null;
}
/** 그룹 차례 = 마스터 · 회사 · 개인 */
@@ -88,8 +100,25 @@ export const rowId = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row)
/** 상단 표(224-1)가 보이는 집계표 = 고른 항목이 속한 층(+ 주인) 것 — 같은 키면 표는 그대로 두고 열만 옮김 */
export const tableKey = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}`;
export function toSummary(row: PickerRow): SummaryItem {
return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) };
/** 그 항목 규격 열 — 없으면 null */
export const specOf = (row: PickerRow): PickerSpec | null =>
row.labor?.규격?.열?.length ? row.labor.규격 : null;
/** 처음 고른 열 — 지금 지정이 같은 항목이고 그 열이 있으면 그것 · 아니면 기본 */
export function startSpec(row: PickerRow, current: SummaryItem | null): string | null {
const spec = specOf(row);
if (!spec) return null;
const want = isCurrent(current, row) ? current?.spec : undefined;
return spec.열.some((c) => c.이름 === want) ? (want as string) : spec.기본;
}
/** 돌려줄 참조 — 규격 열이 있으면 `spec`(열 둘 이상 = 이름 뒤 「(H=열)」) */
export function toSummary(row: PickerRow, col: string | null = null): SummaryItem {
const out = { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) };
const spec = specOf(row);
if (!spec || col === null) return out;
const name = spec.열.length > 1 ? `${out.name} (${spec.이름}=${col})` : out.name;
return { ...out, name, spec: col };
}
export function isCurrent(current: SummaryItem | null, row: PickerRow): boolean {