Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_5' into sub_laptop_7
This commit is contained in:
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)); // 스크롤만(표시 = 빨강)
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 — 산길(임도망도) · 나무지도(대축적 임상도) · 산림토양도 · 산사태위험지도 · 경제림육성단지) 이런 정보들이 있는데 확인만 해봐.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() # 아무것도 안 씀
|
||||
Reference in New Issue
Block a user