Merge remote-tracking branch 'origin/dev' into sub_laptop_4
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* m_settings_nav.ts
|
||||
* 시스템 설정 바로가기(334 · 요구 225-2) — 대시보드 「시스템 설정」 카드 · M01 · M02 ☰ 가 같이 쓰는
|
||||
* 역할 규칙 한 벌(`settingLinks`) · ☰ 진행 단계 자리에 🏠 대시보드 + 카드(`mountSettingsDrawer`).
|
||||
* 시스템 설정 바로가기(334 · 요구 225-2 · 716-2) — 대시보드 설계 기본값 줄(`settingLinks` · 칸마다 한 줄 ·
|
||||
* `?open=`) / M01 · M02 ☰ 메뉴(`pageLinks` · 페이지 둘 · `mountSettingsDrawer`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES, type RoutePath } from "@config/config_frontend";
|
||||
@@ -16,52 +16,64 @@ interface SettingUser {
|
||||
company_id?: number | null;
|
||||
}
|
||||
|
||||
/** key = M01 은 「m01」 · 계산 자리 창은 「calcSites」(대시보드 카드만 · 504-2) · M02 는 진입 인자 `open` */
|
||||
/** key = 페이지 열쇠(☰ · 「m01」 「m02」) 또는 칸 열쇠(대시보드 줄 · 계산 자리 「calcSites」 = 창 504-2) ·
|
||||
* group = 대시보드 설계 기본값 묶음 제목 */
|
||||
export interface SettingLink {
|
||||
key: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
route: RoutePath;
|
||||
query?: Record<string, string>;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
const ICONS: Record<string, string> = { m01: "🗄️", 구조물조합: "🧱", 옵션: "🎛️", 도면: "📑" };
|
||||
const ICONS: Record<string, string> = { m01: "🗄️", m02: "🧱" };
|
||||
|
||||
/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */
|
||||
/** 설계 기본값 줄(대시보드) — 칸마다 한 줄 · 역할로 숨김 · M01 줄 `?open=`(칸 고름) · M02 줄 `?open=&root=`(27-1d).
|
||||
* 칸을 더할 때는 아래 표에 한 줄(m01 / m02 호출)만 더함. */
|
||||
export function settingLinks(user: SettingUser, companyId: number | string | null): SettingLink[] {
|
||||
// 시스템 관리자는 고른 회사를 해시에 실음(29-1)
|
||||
const company: Record<string, string> =
|
||||
user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {};
|
||||
const m01 = (key: string, title: string, desc: string, open?: string): SettingLink => ({
|
||||
key,
|
||||
title,
|
||||
desc,
|
||||
route: ROUTES.M01_MASTER_DATA,
|
||||
query: open ? { open } : undefined,
|
||||
group: "마스터 데이터",
|
||||
});
|
||||
const m02 = (title: string, open: string, root: string, desc: string): SettingLink => ({
|
||||
key: open,
|
||||
title,
|
||||
desc,
|
||||
route: ROUTES.M02_MASTER_TEMPLATE,
|
||||
query: { open, root, ...company },
|
||||
group: "마스터 템플릿",
|
||||
});
|
||||
// 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙
|
||||
const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback);
|
||||
if (user.role === "SYSTEM_ADMIN") {
|
||||
const desc = L("B01_Dashboard_PermMasterWrite");
|
||||
const read = L("B01_Dashboard_PermSourceRead");
|
||||
return [
|
||||
{
|
||||
key: "m01",
|
||||
title: L("B01_Dashboard_MasterElements"),
|
||||
desc: L("B01_Dashboard_PermSourceRead"),
|
||||
route: ROUTES.M01_MASTER_DATA,
|
||||
},
|
||||
m01("m01", L("M01_Title"), read),
|
||||
m01("로직", L("B01_Dashboard_CardLogic"), read, "로직"),
|
||||
m01("옵션", L("B01_Dashboard_CardStructOptionDef"), desc, "옵션"),
|
||||
m01("별칭", L("B01_Dashboard_CardAliasLedger"), desc, "별칭"),
|
||||
m02(L("B01_Dashboard_CardPrecombo"), "일위대가조합", comboRoot("master"), desc),
|
||||
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
|
||||
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
|
||||
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
|
||||
{
|
||||
key: "calcSites",
|
||||
title: L("B01_CalcSites_Title"),
|
||||
desc: L("B01_CalcSites_Desc"),
|
||||
route: ROUTES.B01_ACCOUNT,
|
||||
group: "계산",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -76,14 +88,30 @@ export function settingLinks(user: SettingUser, companyId: number | string | nul
|
||||
return [m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc)];
|
||||
}
|
||||
|
||||
/** ☰ 진행 단계 자리 — 🏠 대시보드 + 카드 · 지금 칸 강조 · 같은 길(M02 → M02)은 머리 가드가 안 물어 여기서 먼저 */
|
||||
/** ☰ 메뉴 — 페이지 둘만(M01 = 시스템 관리자 · M02 = 누구나) · 열쇠 「m01」 「m02」 */
|
||||
export function pageLinks(user: SettingUser, companyId: number | string | null): SettingLink[] {
|
||||
const combo = settingLinks(user, companyId).find((link) => link.key === "구조물조합");
|
||||
const links: SettingLink[] = [];
|
||||
if (user.role === "SYSTEM_ADMIN")
|
||||
links.push({
|
||||
key: "m01",
|
||||
title: L("B01_Dashboard_MasterElements"),
|
||||
desc: "",
|
||||
route: ROUTES.M01_MASTER_DATA,
|
||||
});
|
||||
if (combo)
|
||||
links.push({ ...combo, key: "m02", title: L("M02_Title"), desc: "", group: undefined });
|
||||
return links;
|
||||
}
|
||||
|
||||
/** ☰ 진행 단계 자리 — 🏠 대시보드 + 페이지 둘 · 지금 페이지 강조(`activeKey` = 「m01」 「m02」) ·
|
||||
* 같은 길(M02 → M02)은 머리 가드가 안 물어 여기서 먼저 */
|
||||
export function mountSettingsDrawer(
|
||||
user: SettingUser,
|
||||
activeKey: string,
|
||||
companyId: number | string | null,
|
||||
): void {
|
||||
// 계산 자리 = 대시보드 창(504-2) — 페이지가 아니라 ☰ 에 안 둠
|
||||
const links = settingLinks(user, companyId).filter((link) => link.key !== "calcSites");
|
||||
const links = pageLinks(user, companyId);
|
||||
const bar = createStepBar(
|
||||
links.map((link) => link.title),
|
||||
links.findIndex((link) => link.key === activeKey),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_View_SysDefaults.ts
|
||||
* 시스템 관리 › 설계 기본값(549-2) — 마스터 데이터 · 구조물 조합 · 구조물 옵션 · 도면 양식 · 계산 자리.
|
||||
* 카드 규칙은 `settingLinks` 한 벌(334) — 여기는 모양만(묶음 제목 + 둥근 상자 안 줄) · 계산 자리 = 창(504-2).
|
||||
* 시스템 관리 › 설계 기본값(549-2 · 716-2) — 마스터 데이터(마스터 요소 · 단가산출 로직 · 구조물 옵션 정의) ·
|
||||
* 마스터 템플릿(일위대가 · 구조물 조합 · 도면 양식) · 계산 자리. 칸마다 한 줄(누르면 그 칸을 고른 채 열림 · `?open=`).
|
||||
* 줄 · 묶음 규칙은 `settingLinks` 한 벌(334) — 여기는 모양만(묶음 제목 + 둥근 상자 안 줄) · 계산 자리 = 창(504-2).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
@@ -12,22 +13,13 @@ import { pageFrame, settingCard, settingRow } from "./B01_Dashboard_UI_PageKit";
|
||||
import { group } from "./B01_Dashboard_UI_View_Parts";
|
||||
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
|
||||
|
||||
/** 카드 열쇠 → 묶음 제목 — 모르는 열쇠는 「그 밖」 */
|
||||
const GROUP_OF: Record<string, string> = {
|
||||
m01: "마스터 데이터",
|
||||
구조물조합: "구조물 · 도면",
|
||||
옵션: "구조물 · 도면",
|
||||
도면: "구조물 · 도면",
|
||||
calcSites: "계산",
|
||||
};
|
||||
|
||||
const open = (link: SettingLink): void =>
|
||||
link.key === "calcSites" ? void openCalcSitesDialog() : navigateTo(link.route, link.query);
|
||||
|
||||
export function mountSysDefaults(host: HTMLElement, ctx: ViewCtx): void {
|
||||
const groups = new Map<string, HTMLElement[]>();
|
||||
for (const link of settingLinks(ctx.me, ctx.companyId)) {
|
||||
const title = GROUP_OF[link.key] ?? "그 밖";
|
||||
const title = link.group ?? "그 밖";
|
||||
const openBtn = createButton({ label: "열기", variant: "ghost", onClick: () => open(link) });
|
||||
groups.set(title, [...(groups.get(title) ?? []), settingRow(link.title, link.desc, openBtn)]);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ from B09_Estimation import B09_Estimation_Store as store
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as m02_store
|
||||
|
||||
router = APIRouter()
|
||||
PATH = "/{project_id}/estimation/link"
|
||||
@@ -51,8 +52,8 @@ def _folders(session: dict[str, Any]) -> dict[str, tuple[Path, bool]]:
|
||||
|
||||
|
||||
def recalled(session: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""맞춤 기억 — 회사 위에 개인을 덮음."""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
"""맞춤 기억 — 마스터(시스템 관리자 승인 · 718-2) 위에 회사 · 그 위에 개인을 덮음."""
|
||||
out: dict[str, dict[str, Any]] = dict(hopyo_store.memory(m02_store.FOLDER, linker.MEMORY))
|
||||
for folder, _write in _folders(session).values():
|
||||
out.update(hopyo_store.memory(folder, linker.MEMORY))
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""M01 별칭 장부 시스템 모으기(PLAN 718-2 · 요구서 내역12 4장 23 · 4-1 확정 6).
|
||||
|
||||
모든 회사 · 개인 층의 별칭 장부(`실무자료/맞춤.json` · `M02_Hopyo_Ledger`)와 B09 공사 내역 맞춤
|
||||
기억(`실무자료/내역맞춤.json` · 707-4)을 읽어 후보로 모음 — 읽기만(공유 storage 안 씀).
|
||||
후보 = (종류, 열쇠, 서명) · 서명 = 짝 내용 지문(같은 짝이면 같은 서명) · 회사 수 · 사람 수만
|
||||
(누가 · 처음파일 · 층 주인은 안 내보냄 = 익명) · 충돌 = 같은 열쇠 다른 서명 · 마스터와 다름.
|
||||
승인 = 시스템 관리자만 → 마스터 층에 씀(맞춤 = `ledger.put` 재검사 · 내역맞춤 = 기억 파일) ·
|
||||
반려 = 마스터 층 `실무자료/별칭반려.json` 에 서명 기억(같은 짝은 다시 안 보임) · 자동 반영 없음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_time import utc_now_text
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
ALIAS, BILL = "맞춤", "내역맞춤"
|
||||
KINDS = (ALIAS, BILL)
|
||||
REJECT = "별칭반려"
|
||||
|
||||
router = APIRouter(prefix="/api/m01/alias-ledger", tags=["M01 별칭 장부"])
|
||||
|
||||
|
||||
# ── 읽기 ─────────────────────────────────────────────────────────────
|
||||
def layer_folders() -> list[tuple[str, str | None, Path]]:
|
||||
"""(회사, 사용자 | None, 층 폴더) — storage 의 회사 · 개인 층(숫자 폴더만 · 두 단만 훑음)."""
|
||||
root = layers.storage_root()
|
||||
out: list[tuple[str, str | None, Path]] = []
|
||||
for c in sorted(root.iterdir() if root.is_dir() else []):
|
||||
if not (c.is_dir() and c.name.isdigit()):
|
||||
continue
|
||||
out.append((c.name, None, layers.company_dir(c.name)))
|
||||
for u in sorted(c.iterdir()):
|
||||
if u.is_dir() and u.name.isdigit():
|
||||
out.append((c.name, u.name, layers.personal_dir(c.name, u.name)))
|
||||
return out
|
||||
|
||||
|
||||
def _body(kind: str, row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""서명에 드는 내용 — 맞춤 = 갈래 · 짝 · 내역맞춤 = 확정 줄 키 · 이름."""
|
||||
if kind == ALIAS:
|
||||
return {"갈래": row.get("갈래"), "짝": row.get("짝") or []}
|
||||
return {"확정": row.get("확정"), "이름": row.get("이름")}
|
||||
|
||||
|
||||
def sign(kind: str, row: dict[str, Any]) -> str:
|
||||
raw = json.dumps(_body(kind, row), ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _book(kind: str, folder: Path) -> dict[str, dict[str, Any]]:
|
||||
if kind == ALIAS:
|
||||
return ledger.read(folder)
|
||||
got = hopyo_store.memory(folder, BILL)
|
||||
return {
|
||||
k: v
|
||||
for k, v in got.items()
|
||||
if isinstance(v, dict) and v.get("확정") and str(k).count("|") == 2
|
||||
} # 잊음 · 깨진 열쇠 뺌
|
||||
|
||||
|
||||
def _origin(full: str, row: dict[str, Any]) -> dict[str, str]:
|
||||
if isinstance(row.get("원래"), dict):
|
||||
return {k: str(row["원래"].get(k) or "") for k in ("품명", "규격", "단위")}
|
||||
return ledger._plain_of(full)
|
||||
|
||||
|
||||
def rejected() -> dict[str, dict[str, Any]]:
|
||||
return hopyo_store.memory(store.FOLDER, REJECT)
|
||||
|
||||
|
||||
def _reject_id(kind: str, full: str, sig: str) -> str:
|
||||
return f"{kind}|{sig}|{full}"
|
||||
|
||||
|
||||
def gather() -> dict[str, Any]:
|
||||
"""후보 모음 `{후보, 모은곳{회사, 사람}, 반려}` — 마스터와 같은 짝 · 반려한 짝은 뺌."""
|
||||
seen: dict[tuple[str, str, str], dict[str, Any]] = {}
|
||||
firms, people = set(), set()
|
||||
for company, user, folder in layer_folders():
|
||||
for kind in KINDS:
|
||||
for full, row in _book(kind, folder).items():
|
||||
sig = sign(kind, row)
|
||||
who = (
|
||||
(row.get("누가") or {}).get("id") if isinstance(row.get("누가"), dict) else None
|
||||
)
|
||||
person = f"{company}/{user}" if user else f"{company}/{who or '-'}"
|
||||
one = seen.setdefault(
|
||||
(kind, full, sig),
|
||||
{"종류": kind, "열쇠": full, "서명": sig, "원래": _origin(full, row),
|
||||
**_body(kind, row), "회사": set(), "사람": set(), "때": None},
|
||||
) # fmt: skip
|
||||
one["회사"].add(company)
|
||||
one["사람"].add(person)
|
||||
firms.add(company)
|
||||
people.add(person)
|
||||
stamp = row.get("때")
|
||||
if stamp and (one["때"] is None or str(stamp) > str(one["때"])):
|
||||
one["때"] = stamp
|
||||
master = {kind: _book(kind, Path(store.FOLDER)) for kind in KINDS}
|
||||
gone = rejected()
|
||||
live = []
|
||||
for (kind, full, sig), one in seen.items():
|
||||
mine = master[kind].get(full)
|
||||
if (mine and sign(kind, mine) == sig) or _reject_id(kind, full, sig) in gone:
|
||||
continue
|
||||
one["마스터"] = "다름" if mine else None
|
||||
live.append(one)
|
||||
many: dict[tuple[str, str], int] = {}
|
||||
for one in live:
|
||||
many[(one["종류"], one["열쇠"])] = many.get((one["종류"], one["열쇠"]), 0) + 1
|
||||
out = []
|
||||
for one in sorted(live, key=lambda x: (x["종류"], x["열쇠"], -len(x["사람"]), x["서명"])):
|
||||
clash = many[(one["종류"], one["열쇠"])] > 1 or one["마스터"] == "다름"
|
||||
out.append({**one, "회사": len(one["회사"]), "사람": len(one["사람"]), "충돌": clash})
|
||||
return {"후보": out, "모은곳": {"회사": len(firms), "사람": len(people)}, "반려": len(gone)}
|
||||
|
||||
|
||||
def _find(kind: str, full: str, sig: str) -> dict[str, Any]:
|
||||
if kind not in KINDS:
|
||||
raise store.StoreError(400, f"종류는 {' · '.join(KINDS)}")
|
||||
got = next(
|
||||
(x for x in gather()["후보"] if (x["종류"], x["열쇠"], x["서명"]) == (kind, full, sig)),
|
||||
None,
|
||||
)
|
||||
if got is None:
|
||||
raise store.StoreError(404, f"후보가 없음(이미 승인 · 반려 · 바뀜) 「{full}」")
|
||||
return got
|
||||
|
||||
|
||||
# ── 쓰기(마스터 층만) ─────────────────────────────────────────────────
|
||||
def approve(kind: str, full: str, sig: str, who: Any) -> dict[str, Any]:
|
||||
"""후보 하나 → 마스터 장부(서버가 다시 모아 찾음 · 맞춤은 `ledger.put` 재검사)."""
|
||||
one = _find(kind, full, sig)
|
||||
if kind == ALIAS:
|
||||
ledger.put(store.FOLDER, {full: {"원래": one["원래"], **_body(kind, one)}}, who)
|
||||
else:
|
||||
row = {**_body(kind, one), "누가": who, "때": utc_now_text()}
|
||||
hopyo_store.remember(store.FOLDER, {full: row}, BILL)
|
||||
return gather()
|
||||
|
||||
|
||||
def reject(kind: str, full: str, sig: str, who: Any) -> dict[str, Any]:
|
||||
_find(kind, full, sig)
|
||||
row = {"종류": kind, "열쇠": full, "서명": sig, "누가": who, "때": utc_now_text()}
|
||||
hopyo_store.remember(store.FOLDER, {_reject_id(kind, full, sig): row}, REJECT)
|
||||
return gather()
|
||||
|
||||
|
||||
def master_bill() -> list[dict[str, Any]]:
|
||||
"""마스터 내역맞춤 줄(보기 · 지움) — 맞춤 장부는 `/api/m02/layers/system/hopyo-ledger`."""
|
||||
book = _book(BILL, Path(store.FOLDER))
|
||||
return [
|
||||
{"열쇠": k, "원래": ledger._plain_of(k), **_body(BILL, v)} for k, v in sorted(book.items())
|
||||
]
|
||||
|
||||
|
||||
def forget_bill(full: str) -> list[dict[str, Any]]:
|
||||
if full not in _book(BILL, Path(store.FOLDER)):
|
||||
raise store.StoreError(404, f"마스터 내역맞춤에 없음 「{full}」")
|
||||
hopyo_store.remember(store.FOLDER, {full: None}, BILL)
|
||||
return master_bill()
|
||||
|
||||
|
||||
# ── 길(시스템 관리자만 — main.py 에서 system_admin_only) ───────────────
|
||||
def _who(session: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"id": session.get("user_id"), "이름": session.get("name")}
|
||||
|
||||
|
||||
def _call(fn, *args):
|
||||
try:
|
||||
return fn(*args)
|
||||
except store.StoreError as error:
|
||||
raise HTTPException(error.status, error.detail) from error
|
||||
|
||||
|
||||
class Pick(BaseModel):
|
||||
종류: str = Field(max_length=10)
|
||||
열쇠: str = Field(min_length=1, max_length=400)
|
||||
서명: str = Field(min_length=16, max_length=16)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def candidates() -> dict[str, Any]:
|
||||
return {**gather(), "마스터내역": master_bill()}
|
||||
|
||||
|
||||
@router.post("/approve")
|
||||
def approve_one(body: Pick, session: dict = Depends(verify_session)) -> dict[str, Any]:
|
||||
return _call(approve, body.종류, body.열쇠, body.서명, _who(session))
|
||||
|
||||
|
||||
@router.post("/reject")
|
||||
def reject_one(body: Pick, session: dict = Depends(verify_session)) -> dict[str, Any]:
|
||||
return _call(reject, body.종류, body.열쇠, body.서명, _who(session))
|
||||
|
||||
|
||||
class Forget(BaseModel):
|
||||
열쇠: str = Field(min_length=1, max_length=400)
|
||||
|
||||
|
||||
@router.post("/master-bill/forget")
|
||||
def forget_one(body: Forget) -> dict[str, Any]:
|
||||
return {"마스터내역": _call(forget_bill, body.열쇠)}
|
||||
@@ -0,0 +1,237 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Alias.ts
|
||||
* M01 별칭 장부(PLAN 718-3 · 요구서 내역12 4장 23 · 4-1 확정 6) — 본 칸 · 시스템 관리자만.
|
||||
* · 「모인 후보」 = 모든 회사 · 개인 층 별칭 장부(일위대가 맞춤) + 공사 내역 맞춤을 서버가 모음
|
||||
* (`/api/m01/alias-ledger` · 718-2) · 이름 · 짝 · 회사 수 · 사람 수(누군지 안 보임) · 충돌 ·
|
||||
* [승인] = 마스터 장부에 넣음(모든 사용자 제안에 쓰임) · [반려] = 다시 안 보임 · 둘 다 확인 창
|
||||
* · 「마스터 장부」 = M02 별칭 장부 부품(`buildHopyoLedger` · 마스터 층) 그대로 + 마스터 공사 내역 맞춤 줄 [지움]
|
||||
* · 자동 반영 없음 — 승인한 것만 모두에게 · 승인 · 반려 · 지움은 누를 때 바로 서버(확인 창 뒤)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { askConfirm, createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { createPanelHead } from "@ui/ui_template_panel_head";
|
||||
import { createTable, type Column } from "@ui/ui_template_table";
|
||||
import { createTabs } from "@ui/ui_template_tabs";
|
||||
import "@ui/ui_template_general_layout.css"; // 마스터 장부 부품(M02 Hopyo_Ledger) 표 꼴 — M01 엔 안 실려 있음
|
||||
import type { LedgerPair } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api";
|
||||
import {
|
||||
buildHopyoLedger,
|
||||
KIND_TEXT,
|
||||
LEDGER_HINT,
|
||||
pairText,
|
||||
} from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger";
|
||||
|
||||
export const ALIAS_TITLE = "별칭 장부";
|
||||
const KIND_NAME: Record<string, string> = { 맞춤: "일위대가", 내역맞춤: "공사 내역" };
|
||||
|
||||
interface Origin {
|
||||
품명: string;
|
||||
규격: string;
|
||||
단위: string;
|
||||
}
|
||||
export interface AliasCandidate {
|
||||
종류: "맞춤" | "내역맞춤";
|
||||
열쇠: string;
|
||||
서명: string;
|
||||
원래: Origin;
|
||||
갈래?: string;
|
||||
짝?: LedgerPair[];
|
||||
확정?: string;
|
||||
이름?: string;
|
||||
회사: number;
|
||||
사람: number;
|
||||
때: string | null;
|
||||
충돌: boolean;
|
||||
마스터: "다름" | null;
|
||||
}
|
||||
interface MasterBill {
|
||||
열쇠: string;
|
||||
원래: Origin;
|
||||
확정: string;
|
||||
이름?: string;
|
||||
}
|
||||
export interface AliasBoard {
|
||||
후보: AliasCandidate[];
|
||||
모은곳: { 회사: number; 사람: number };
|
||||
반려: number;
|
||||
마스터내역?: MasterBill[];
|
||||
}
|
||||
|
||||
async function call<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`${API_BASE_URL}/m01/alias-ledger${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
const got = (await res.json()) as T & { detail?: unknown };
|
||||
if (!res.ok) throw new Error(String(got.detail ?? `HTTP ${res.status}`));
|
||||
return got;
|
||||
}
|
||||
|
||||
/** 짝 글 — 일위대가 = 갈래 · 우리 로직 · 공사 내역 = 우리 공종 이름 */
|
||||
export const candidateText = (c: AliasCandidate): string =>
|
||||
c.종류 === "내역맞춤"
|
||||
? `${c.이름 ?? ""} (${c.확정 ?? ""})`
|
||||
: c.갈래 === "짝"
|
||||
? (c.짝 ?? []).map(pairText).join(" + ")
|
||||
: (KIND_TEXT[c.갈래 ?? ""] ?? c.갈래 ?? "");
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const pick = (c: AliasCandidate) => ({ 종류: c.종류, 열쇠: c.열쇠, 서명: c.서명 });
|
||||
|
||||
const CANDIDATE_COLS: Column[] = [
|
||||
{ label: "종류", width: "84px" },
|
||||
{ label: "엑셀 품명" },
|
||||
{ label: "규격" },
|
||||
{ label: "단위", width: "64px" },
|
||||
{ label: "짝" },
|
||||
{ label: "회사", width: "56px", align: "end" },
|
||||
{ label: "사람", width: "56px", align: "end" },
|
||||
{ label: "충돌", width: "96px" },
|
||||
{ label: "", width: "136px" },
|
||||
];
|
||||
const BILL_COLS: Column[] = [
|
||||
{ label: "엑셀 품명" },
|
||||
{ label: "규격" },
|
||||
{ label: "단위", width: "64px" },
|
||||
{ label: "우리 공종" },
|
||||
{ label: "", width: "72px" },
|
||||
];
|
||||
|
||||
export function buildAliasLedger(): HTMLElement {
|
||||
const root = el("div", { className: "m01-alias", attrs: { "data-alias-ledger": "" } });
|
||||
const body = el("div", { className: "m01-alias__body" });
|
||||
let view = "candidates";
|
||||
|
||||
async function act(c: AliasCandidate, ok: boolean): Promise<void> {
|
||||
const name = `${c.원래.품명} ${c.원래.규격}`.trim();
|
||||
const yes = await askConfirm({
|
||||
title: ok ? "마스터 장부에 승인" : "후보 반려",
|
||||
message: ok
|
||||
? `「${name}」 → ${candidateText(c)}\n마스터 장부에 넣으면 모든 사용자 제안에 쓰임(각자 장부가 먼저).`
|
||||
: `「${name}」 → ${candidateText(c)}\n반려하면 같은 짝은 후보에 다시 안 보임.`,
|
||||
okLabel: ok ? "승인" : "반려",
|
||||
danger: !ok,
|
||||
});
|
||||
if (!yes) return;
|
||||
try {
|
||||
const got = await call<AliasBoard>(ok ? "/approve" : "/reject", pick(c));
|
||||
showToast(ok ? "마스터 장부에 넣음" : "반려함", "success");
|
||||
paintCandidates(got);
|
||||
} catch (error) {
|
||||
showToast(`${ok ? "승인" : "반려"} 못 함 — ${why(error)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function candidateRow(c: AliasCandidate): (string | Node)[] {
|
||||
const clash = c.마스터 === "다름" ? "마스터와 다름" : c.충돌 ? "다른 짝 있음" : "";
|
||||
return [
|
||||
KIND_NAME[c.종류] ?? c.종류,
|
||||
c.원래.품명,
|
||||
c.원래.규격,
|
||||
c.원래.단위,
|
||||
candidateText(c),
|
||||
String(c.회사),
|
||||
String(c.사람),
|
||||
clash ? createBadge(clash, "warning") : "",
|
||||
el("div", {
|
||||
className: "ui-toolbar",
|
||||
children: [
|
||||
createButton({
|
||||
label: "승인",
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () => void act(c, true),
|
||||
}),
|
||||
createButton({
|
||||
label: "반려",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void act(c, false),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function paintCandidates(board: AliasBoard): void {
|
||||
const head = createPanelHead({
|
||||
title: "모인 후보",
|
||||
sub: `${LEDGER_HINT} · 회사 ${board.모은곳.회사} · 사람 ${board.모은곳.사람}의 장부에서 모음 · 반려 ${board.반려} · 승인한 것만 모두에게 · 누가 적었는지는 안 보임`,
|
||||
});
|
||||
body.replaceChildren(
|
||||
head.root,
|
||||
createTable(CANDIDATE_COLS, board.후보.map(candidateRow), {
|
||||
density: "data",
|
||||
empty: "모인 후보 없음",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function forget(b: MasterBill): Promise<void> {
|
||||
const yes = await askConfirm({
|
||||
title: "마스터 공사 내역 맞춤 지움",
|
||||
message: `「${b.원래.품명} ${b.원래.규격}」 → ${b.이름 ?? b.확정}\n지우면 모든 사용자 제안에서 빠짐.`,
|
||||
okLabel: "지움",
|
||||
danger: true,
|
||||
});
|
||||
if (!yes) return;
|
||||
try {
|
||||
const got = await call<{ 마스터내역: MasterBill[] }>("/master-bill/forget", { 열쇠: b.열쇠 });
|
||||
showToast("지움", "success");
|
||||
paintMaster(got.마스터내역);
|
||||
} catch (error) {
|
||||
showToast(`지우지 못 함 — ${why(error)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function paintMaster(bill: MasterBill[]): void {
|
||||
const rows = bill.map((b) => [
|
||||
b.원래.품명,
|
||||
b.원래.규격,
|
||||
b.원래.단위,
|
||||
`${b.이름 ?? ""} (${b.확정})`,
|
||||
createButton({ label: "지움", variant: "danger", size: "sm", onClick: () => void forget(b) }),
|
||||
]);
|
||||
body.replaceChildren(
|
||||
buildHopyoLedger({ scope: { layer: "system", owner: null }, canWrite: true }),
|
||||
createPanelHead({
|
||||
title: "마스터 공사 내역 맞춤",
|
||||
sub: "B09 내역 줄 ↔ 우리 공종(각자 기억이 먼저)",
|
||||
}).root,
|
||||
createTable(BILL_COLS, rows, { density: "data", empty: "마스터 공사 내역 맞춤 없음" }),
|
||||
);
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const stop = showLoading(body);
|
||||
try {
|
||||
const got = await call<AliasBoard>("");
|
||||
if (view === "candidates") paintCandidates(got);
|
||||
else paintMaster(got.마스터내역 ?? []);
|
||||
} catch (error) {
|
||||
body.replaceChildren(loadingError(why(error), () => void load()));
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = createTabs({
|
||||
tabs: [
|
||||
{ key: "candidates", label: "모인 후보" },
|
||||
{ key: "master", label: "마스터 장부" },
|
||||
],
|
||||
value: view,
|
||||
onChange: (key) => {
|
||||
view = key;
|
||||
void load();
|
||||
},
|
||||
});
|
||||
root.append(tabs.root, body); // 제목 = 본 칸 머리(ALIAS_TITLE · 왼쪽 판이 넘김)
|
||||
void load();
|
||||
return root;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Cells.ts
|
||||
* 칸 다루기 — 값 → 글 · 글 → 값 · 눌러 고치기 · 쪽 나눔 띠 (요소·표 화면 공용)
|
||||
* 칸 다루기 — 값 → 글 · 글 → 값 · 눌러 고치기 (요소·표 화면 공용) · 쪽 단추 옛 이름
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createPager } from "@ui/ui_template_pager";
|
||||
import type { Row } from "./M01_MasterData_Api_Fetch";
|
||||
|
||||
/** 묶음 값(`값 = {가격: …}`)을 「값›가격」 칸으로 펼칠 때의 구분자. */
|
||||
@@ -94,29 +94,8 @@ export function startEdit(td: HTMLElement, text: string, commit: (value: string)
|
||||
input.addEventListener("blur", () => finish(true));
|
||||
}
|
||||
|
||||
/** 쪽 나눔 띠 — 이전·다음 · 「n / N 쪽 · 전체 줄」. */
|
||||
export function buildPager(
|
||||
total: number,
|
||||
size: number,
|
||||
page: number,
|
||||
goto: (page: number) => void,
|
||||
): HTMLElement {
|
||||
const last = Math.max(1, Math.ceil(total / size));
|
||||
const prev = createButton({ label: L("M01_Prev"), variant: "ghost", disabled: page <= 1 });
|
||||
const next = createButton({ label: L("M01_Next"), variant: "ghost", disabled: page >= last });
|
||||
prev.addEventListener("click", () => goto(page - 1));
|
||||
next.addEventListener("click", () => goto(page + 1));
|
||||
return el("div", {
|
||||
className: "ui-toolbar m01-master__pager",
|
||||
children: [
|
||||
el("span", {
|
||||
text: `${page} / ${last} ${L("M01_Page")} · ${total.toLocaleString("ko-KR")} ${L("M01_Rows")}`,
|
||||
}),
|
||||
prev,
|
||||
next,
|
||||
],
|
||||
});
|
||||
}
|
||||
/** 쪽 나눔 띠 — 부품 `createPager` 를 부르는 옛 이름(PickTable · M02 목록이 씀). */
|
||||
export const buildPager = createPager;
|
||||
|
||||
/** 칸 하나 — 노랑(고침) · 눌러 고침. */
|
||||
export function buildCell(
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from "./M01_MasterData_UI_LogicLab_Flow";
|
||||
import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic";
|
||||
import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side";
|
||||
import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View";
|
||||
import { buildHeadBar } from "./M01_MasterData_UI_Head";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
|
||||
@@ -130,6 +131,7 @@ export async function mountM01LogicLab(
|
||||
const editor = el("div", { className: "m01-logic__editor" });
|
||||
// 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 둘(흐름도 · 원문 · 하나만 열림 · 폭 공유)
|
||||
const panes = buildLogicSide(editor);
|
||||
const views = createViewPicker(editor); // 보기 고르기 — 머리줄 [신규 로직] 왼쪽
|
||||
const errors = el("div", {
|
||||
className: "ui-banner ui-banner--danger m01-logic__reasons",
|
||||
attrs: { hidden: "" },
|
||||
@@ -236,7 +238,7 @@ export async function mountM01LogicLab(
|
||||
list.root.hidden = !listing || !loaded;
|
||||
waiting.hidden = loaded;
|
||||
panes.root.hidden = !opened;
|
||||
back.hidden = listing;
|
||||
back.hidden = views.root.hidden = listing;
|
||||
if (!opened) return;
|
||||
if (!opened.row) {
|
||||
editor.replaceChildren(createEmpty({ text: `● ${tx("List_Deleted")}`, small: true }));
|
||||
@@ -582,6 +584,7 @@ export async function mountM01LogicLab(
|
||||
title: tx("Title"),
|
||||
status: [pending],
|
||||
buttons: [
|
||||
views.root,
|
||||
createButton({ label: tf("New"), variant: "filled", onClick: () => void onNew() }),
|
||||
copyButton,
|
||||
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
|
||||
|
||||
@@ -17,7 +17,8 @@ import type {
|
||||
TextLine,
|
||||
TextPiece,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { buildEditor, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { pctView } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
@@ -36,6 +37,8 @@ import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import "./M01_MasterData_UI_LogicLab_Style.css";
|
||||
import "./M01_MasterData_UI_LogicLab_Pill.css";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View";
|
||||
|
||||
/** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */
|
||||
function toFragments(pieces: TextPiece[], ctx: DetailContext): Fragment[] {
|
||||
@@ -97,6 +100,8 @@ interface Entry {
|
||||
spec: string;
|
||||
unit: string;
|
||||
qty: string;
|
||||
/** 고른 수량 값(계산 답 `CalcLine.수량` · % 덧줄은 율) — 계산 전 「—」 */
|
||||
qtyValue: string;
|
||||
qtyTag: string;
|
||||
qtyFrag: Fragment[];
|
||||
/** 한 줄이 비목 몫으로 갈렸을 때 이 묶음의 몫 이름(예 「노무비」) */
|
||||
@@ -139,7 +144,7 @@ const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
|
||||
],
|
||||
});
|
||||
|
||||
function infoBox(ctx: DetailContext): HTMLElement {
|
||||
function infoBox(ctx: DetailContext): HTMLElement[] {
|
||||
const row = ctx.row;
|
||||
const own = isOwnLogic(row);
|
||||
const field = (label: string, key: "이름" | "원문번호" | "결과단위" | "출처"): HTMLElement => {
|
||||
@@ -186,34 +191,51 @@ function infoBox(ctx: DetailContext): HTMLElement {
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
return section(tl("Info_Title"), [
|
||||
el("p", {
|
||||
className: "ui-text ui-text--muted",
|
||||
text: tl(own ? "Info_Own" : ctx.admin ? "Info_Base" : "Info_ReadOnly"),
|
||||
}),
|
||||
...reasons,
|
||||
el("div", {
|
||||
className: "m01lab__info",
|
||||
children: [
|
||||
cell(tx("Head_File"), ctx.file),
|
||||
cell(tx("Head_Key"), row.키),
|
||||
field(tx("Head_Number"), "원문번호"),
|
||||
field(tx("Head_Name"), "이름"),
|
||||
field(tx("Head_Unit"), "결과단위"),
|
||||
field(tx("Head_Source"), "출처"),
|
||||
cell(tx("Head_Owner"), row.소유 ?? ""),
|
||||
el("label", {
|
||||
className: "m01lab__cell m01lab__cell--wide",
|
||||
children: [
|
||||
el("span", { className: "ui-text ui-text--muted", text: tx("Head_Note") }),
|
||||
note,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const grid = el("div", {
|
||||
className: "m01lab__info",
|
||||
children: [
|
||||
cell(tx("Head_File"), ctx.file),
|
||||
cell(tx("Head_Key"), row.키),
|
||||
field(tx("Head_Number"), "원문번호"),
|
||||
field(tx("Head_Name"), "이름"),
|
||||
field(tx("Head_Unit"), "결과단위"),
|
||||
field(tx("Head_Source"), "출처"),
|
||||
cell(tx("Head_Owner"), row.소유 ?? ""),
|
||||
el("label", {
|
||||
className: "m01lab__cell m01lab__cell--wide",
|
||||
children: [
|
||||
el("span", { className: "ui-text ui-text--muted", text: tx("Head_Note") }),
|
||||
note,
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
// 늘 맨 위 접힌 한 줄 — 펼침은 이 로직 안에서 기억(다시 그려도) · 막힘 까닭은 접어도 보임
|
||||
const head = [row.키, row.원문번호, row.이름, row.결과단위].filter(Boolean).join(" · ");
|
||||
const fold = el("details", {
|
||||
className: "m01lab__box m01lab__infofold",
|
||||
attrs: ctx.ownHost.dataset.info ? { open: "" } : {},
|
||||
children: [
|
||||
el("summary", {
|
||||
children: [
|
||||
el("strong", { text: tl("Info_Title") }),
|
||||
el("span", { className: "ui-text ui-text--muted", text: head }),
|
||||
],
|
||||
}),
|
||||
el("p", {
|
||||
className: "ui-text ui-text--muted",
|
||||
text: tl(own ? "Info_Own" : ctx.admin ? "Info_Base" : "Info_ReadOnly"),
|
||||
}),
|
||||
grid,
|
||||
],
|
||||
});
|
||||
fold.addEventListener("toggle", () => (ctx.ownHost.dataset.info = fold.open ? "1" : ""));
|
||||
return [fold, ...reasons];
|
||||
}
|
||||
|
||||
/** 수량 칸 = 계산 답의 고른 수량(% 덧줄은 율) · 계산 전 「—」 */
|
||||
const qtyOf = (line?: CalcLine): string => (line ? formatNumber(pctView(line).qty) : "—");
|
||||
|
||||
/** 계산된 줄은 서버가 준 비목별 몫으로 묶음에 나눠 넣음 — 로직을 부르는 줄도 노무비·재료비·경비로 갈림 */
|
||||
const spread = (e: Entry, line?: CalcLine): Entry[] => {
|
||||
const parts = Object.entries(line?.비목 ?? {}).filter(([, v]) => v !== 0);
|
||||
@@ -249,6 +271,7 @@ function entries(ctx: DetailContext): Entry[] {
|
||||
spec: item.규격 ?? brief?.규격 ?? "",
|
||||
unit: item.단위 ?? "",
|
||||
qty: item.수량,
|
||||
qtyValue: qtyOf(line),
|
||||
qtyTag: qtyKind(item.수량) === tx("Ho_QtyTable") ? tl("Tag_Table") : qtyKind(item.수량),
|
||||
qtyFrag: [{ text: item.수량 }],
|
||||
price: price === undefined || price === null ? "" : formatPrice(price),
|
||||
@@ -278,8 +301,9 @@ function entries(ctx: DetailContext): Entry[] {
|
||||
group: OF_COST[extra.비목 ?? ""] ?? "other",
|
||||
name: extra.이름,
|
||||
spec: "",
|
||||
unit: "",
|
||||
unit: line ? pctView(line).unit : "",
|
||||
qty: extra.식,
|
||||
qtyValue: qtyOf(line),
|
||||
qtyTag: tl("Extra"),
|
||||
qtyFrag: [{ text: extra.식 }],
|
||||
price: "",
|
||||
@@ -356,6 +380,7 @@ function lineRow(e: Entry): HTMLElement {
|
||||
...(e.warn ? [createBadge(`${tx("Calc_UnitWarn")} · ${e.warn}`, "warning")] : []),
|
||||
],
|
||||
}),
|
||||
cellOf(e.qtyValue, "m01-logic__money"),
|
||||
cellOf(e.unit),
|
||||
el("td", {
|
||||
className: "m01lab__qty-cell",
|
||||
@@ -375,7 +400,7 @@ function groupTable(group: Group, mine: Entry[], calculated: boolean): HTMLEleme
|
||||
const empty = el("tr", {
|
||||
children: [
|
||||
el("td", {
|
||||
attrs: { colspan: "5" },
|
||||
attrs: { colspan: "6" },
|
||||
className: "ui-text ui-text--muted",
|
||||
text: tl("Empty_Group"),
|
||||
}),
|
||||
@@ -385,14 +410,15 @@ function groupTable(group: Group, mine: Entry[], calculated: boolean): HTMLEleme
|
||||
className: "m01lab__subtotal",
|
||||
attrs: { "data-group": group, "data-subtotal": calculated ? String(sub) : "" },
|
||||
children: [
|
||||
el("td", { attrs: { colspan: "4" }, text: `${groupName(group)} ${tl("Subtotal")}` }),
|
||||
el("td", { attrs: { colspan: "5" }, text: `${groupName(group)} ${tl("Subtotal")}` }),
|
||||
cellOf(calculated ? formatMoney(sub) : "—", "m01-logic__money"),
|
||||
],
|
||||
});
|
||||
const headers = [
|
||||
tl("Col_Name"),
|
||||
tl("Col_Unit"),
|
||||
tl("Col_Qty"),
|
||||
tl("Col_Unit"),
|
||||
tl("Col_Logic"),
|
||||
tl("Col_Price"),
|
||||
tl("Col_Amount"),
|
||||
];
|
||||
@@ -443,10 +469,13 @@ function ownEditor(ctx: DetailContext): HTMLElement {
|
||||
function hoBox(ctx: DetailContext): HTMLElement {
|
||||
const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원");
|
||||
if (!money) {
|
||||
return section(tl("Ho_Title"), [
|
||||
el("p", { className: "ui-text ui-text--muted", text: tl("Not_Money") }),
|
||||
el("pre", { className: "m01lab__expr", text: ctx.row.결과?.식 ?? "" }),
|
||||
]);
|
||||
return block(
|
||||
section(tl("Ho_Title"), [
|
||||
el("p", { className: "ui-text ui-text--muted", text: tl("Not_Money") }),
|
||||
el("pre", { className: "m01lab__expr", text: ctx.row.결과?.식 ?? "" }),
|
||||
]),
|
||||
"ho",
|
||||
);
|
||||
}
|
||||
const all = entries(ctx);
|
||||
const calculated = ctx.lines !== null;
|
||||
@@ -469,7 +498,7 @@ function hoBox(ctx: DetailContext): HTMLElement {
|
||||
fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : ""));
|
||||
const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...tables, totalLine];
|
||||
const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`;
|
||||
return section(tl("Ho_Title"), blocks, hint);
|
||||
return block(section(tl("Ho_Title"), [nowInputs(ctx.values), ...blocks], hint), "ho");
|
||||
}
|
||||
|
||||
/** 상세 화면 넷을 `host` 에 쌓음 */
|
||||
@@ -482,10 +511,14 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
||||
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
|
||||
? { node: active, from: active.selectionStart, to: active.selectionEnd }
|
||||
: null;
|
||||
// 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 · 시험 계산은 숨어도 그려 둠
|
||||
host.replaceChildren(
|
||||
infoBox(ctx),
|
||||
section(tl("Calc_Title"), [ctx.calcHost]),
|
||||
...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]),
|
||||
...infoBox(ctx),
|
||||
block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"),
|
||||
block(section(tl("View_Est"), [createEmpty({ text: tl("View_Soon"), small: true })]), "est"),
|
||||
...(ctx.flowHost
|
||||
? [block(nowInputs(ctx.values), "ho"), ctx.flowHost]
|
||||
: [hoBox(ctx), block(section(tl("Formula_Title"), [formula]), "formula")]),
|
||||
);
|
||||
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
|
||||
const back = own ? host.querySelector<HTMLInputElement>(`[data-own="${own}"]`) : kept?.node;
|
||||
|
||||
@@ -159,9 +159,11 @@ export function buildFlowView(
|
||||
try {
|
||||
ev = opts.result ?? evalLogic(lg, values, { logics: opts.logics });
|
||||
} catch (e) {
|
||||
host.replaceChildren(
|
||||
section(tf("Detail"), [el("p", { className: "ui-text ui-text--muted", text: String(e) })]),
|
||||
);
|
||||
const failed = section(tf("Detail"), [
|
||||
el("p", { className: "ui-text ui-text--muted", text: String(e) }),
|
||||
]);
|
||||
failed.dataset.block = "ho";
|
||||
host.replaceChildren(failed);
|
||||
return;
|
||||
}
|
||||
const totalRound = totalRoundOf(lg, totalRoundNow());
|
||||
@@ -196,5 +198,8 @@ export function buildFlowView(
|
||||
tf("Formula"),
|
||||
bodies.length ? bodies : [el("p", { className: "ui-text ui-text--muted", text: tf("NoFunc") })],
|
||||
);
|
||||
// 보기 고르기 이름표(`LogicLab_View.ts`) — 고른 덩이만 보임
|
||||
detail.dataset.block = "ho";
|
||||
text.dataset.block = "formula";
|
||||
host.replaceChildren(detail, text);
|
||||
}
|
||||
|
||||
@@ -45,20 +45,25 @@
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
/* 열 = 이름 · 수량 · 단위 · 로직 · 단가 · 금액 */
|
||||
.m01lab__ho th:nth-child(1) {
|
||||
width: 26%;
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
.m01lab__ho th:nth-child(2) {
|
||||
width: 8%;
|
||||
width: 9%;
|
||||
}
|
||||
|
||||
.m01lab__ho th:nth-child(3) {
|
||||
width: 40%;
|
||||
width: 7%;
|
||||
}
|
||||
|
||||
.m01lab__ho th:nth-child(4),
|
||||
.m01lab__ho th:nth-child(5) {
|
||||
.m01lab__ho th:nth-child(4) {
|
||||
width: 36%;
|
||||
}
|
||||
|
||||
.m01lab__ho th:nth-child(5),
|
||||
.m01lab__ho th:nth-child(6) {
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
@@ -185,3 +190,28 @@
|
||||
.m01lab__pickrow.is-picked {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김(시험 계산은 숨어도 돎) */
|
||||
.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]),
|
||||
.m01-logic__editor[data-view="est"] [data-block]:not([data-block="est"]),
|
||||
.m01-logic__editor[data-view="calc"] [data-block]:not([data-block="calc"]),
|
||||
.m01-logic__editor[data-view="formula"] [data-block]:not([data-block="formula"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 기본정보 — 늘 맨 위 접힌 한 줄 */
|
||||
.m01lab__infofold > summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01lab__infofold[open] > summary {
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 상세 머리 「지금 입력」 한 줄 */
|
||||
.m01lab__now {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@ const TEXT = {
|
||||
Formula_Title: ["텍스트 수식", "Text formula"],
|
||||
Formula_Slot: ["시험 계산을 누르면 식이 글로 나옴", "Run the trial calc to see the formula"],
|
||||
Calc_Title: ["시험 계산", "Trial calculation"],
|
||||
View_Est: ["인력·재료·경비", "Labor·material·expense"],
|
||||
View_Label: ["보기", "View"],
|
||||
View_Soon: [
|
||||
"준비 중 — 인력 · 재료 · 경비 묶음 보기",
|
||||
"Coming soon — labor · material · expense view",
|
||||
],
|
||||
Now_Inputs: ["지금 입력", "Current inputs"],
|
||||
Now_None: ["없음", "none"],
|
||||
G_labor: ["인력", "Labor"],
|
||||
G_material: ["재료", "Material"],
|
||||
G_cost: ["경비", "Expense"],
|
||||
@@ -40,6 +48,7 @@ const TEXT = {
|
||||
Col_Name: ["이름", "Name"],
|
||||
Col_Unit: ["단위", "Unit"],
|
||||
Col_Qty: ["수량", "Quantity"],
|
||||
Col_Logic: ["로직", "Logic"],
|
||||
Col_Price: ["단가", "Price"],
|
||||
Col_Amount: ["금액", "Amount"],
|
||||
Empty_Group: ["줄 없음", "No lines"],
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_View.ts
|
||||
* 「단가산출 로직」 상세 보기 고르기 — 머리줄 [신규 로직] 왼쪽 「분할 고르기 | 세로 나눔선」
|
||||
* 보기 넷 = 단가산출 상세(기본) · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임
|
||||
* 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지
|
||||
* 시험 계산은 숨겨도 그려 둠(자동 계산이 숨은 칸에서도 돎) · 기본정보는 이름표 없음(늘 맨 위)
|
||||
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 단가산출 상세)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
|
||||
export type LabView = "ho" | "est" | "calc" | "formula";
|
||||
|
||||
/** 덩이에 보기 이름표를 붙여 돌려줌 */
|
||||
export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
|
||||
node.dataset.block = view;
|
||||
return node;
|
||||
};
|
||||
|
||||
/** 보기 고르기 — `host`(상세 그릇)에 `data-view` 를 둠 · `root` 는 머리줄에 놓음 */
|
||||
export function createViewPicker(host: HTMLElement): { root: HTMLElement } {
|
||||
host.dataset.view = "ho";
|
||||
const segment = createSegment({
|
||||
options: [
|
||||
{ value: "ho", label: tl("Ho_Title") },
|
||||
{ value: "est", label: tl("View_Est") },
|
||||
{ value: "calc", label: tl("Calc_Title") },
|
||||
{ value: "formula", label: tl("Formula_Title") },
|
||||
],
|
||||
value: "ho",
|
||||
size: 32,
|
||||
ariaLabel: tl("View_Label"),
|
||||
onChange: (v) => (host.dataset.view = v),
|
||||
});
|
||||
return { root: createToolbar({ items: [segment.root, "divider"], label: tl("View_Label") }) };
|
||||
}
|
||||
|
||||
/** 상세 머리 「지금 입력」 한 줄(읽기만) — 숨은 시험 계산 칸에 든 값 */
|
||||
export function nowInputs(values: Record<string, string>): HTMLElement {
|
||||
const filled = Object.entries(values).filter(([, v]) => String(v ?? "").trim() !== "");
|
||||
const text = filled.length ? filled.map(([k, v]) => `${k} ${v}`).join(" · ") : tl("Now_None");
|
||||
return el("p", {
|
||||
className: "ui-text ui-text--muted m01lab__now",
|
||||
attrs: { "data-now": "" },
|
||||
text: `${tl("Now_Inputs")} · ${text}`,
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createDivider } from "@ui/ui_template_divider";
|
||||
|
||||
/** 고르기 값이 마스터 키(자재·기계·직종)면 이름으로 바꿔 보임 — 값 칸은 키 그대로 보냄 */
|
||||
const KEY_RE = /^[A-Z]{2}\d{6}$/;
|
||||
@@ -67,6 +68,9 @@ export interface CalcContext {
|
||||
onText?: (answer: TextAnswer) => void;
|
||||
}
|
||||
|
||||
const grid = (children: HTMLElement[]): HTMLElement =>
|
||||
el("div", { className: "m01-logic__head", children });
|
||||
|
||||
const SUMS = ["노무비", "재료비", "경비", "계"];
|
||||
|
||||
/** 로직마다 자동값을 한 번만 받음(다시 그려도 안 부름) */
|
||||
@@ -122,7 +126,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
control = box;
|
||||
}
|
||||
const label = spec.단위 ? `${name} (${spec.단위})` : name;
|
||||
// 긴 이름 줄만 한 칸 다 씀 — 고르기 줄은 짧으면 그대로 2열에 맞춰 나란히
|
||||
// 긴 이름 줄만 두 칸 차지 — 짧으면 4 열에 맞춰 나란히
|
||||
const wide = label.length > 10;
|
||||
return el("label", {
|
||||
className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`,
|
||||
@@ -196,8 +200,13 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
runButton.setAttribute("data-calc-run", "");
|
||||
host.replaceChildren(
|
||||
el("h3", { text: tx("Calc_Title") }),
|
||||
// 옵션 고르기 · 자재 고르기 두 격자(기본 4 열) — 사이 수평 구분선(709-1 부품)
|
||||
...(fields.length || materials.length
|
||||
? [el("div", { className: "m01-logic__head", children: [...fields, ...materials] })] // 요소 표가 쓰는 auto-fill 그리드 재사용 — 2열
|
||||
? [
|
||||
...(fields.length ? [grid(fields)] : []),
|
||||
...(fields.length && materials.length ? [createDivider()] : []),
|
||||
...(materials.length ? [grid(materials)] : []),
|
||||
]
|
||||
: [el("p", { className: "ui-text ui-text--muted", text: tx("Calc_NoInputs") })]),
|
||||
runButton,
|
||||
out,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_CalcPick.ts
|
||||
* 시험 계산의 재료 · 기계 고르기 — 재료 줄마다 「잡힌 품목 + 품목 고르기」 · 기계 입력은 단추 하나
|
||||
* 시험 계산의 재료 · 기계 고르기 — 재료 칸마다 잡힌 품목 이름(누르면 고르기) · 기계 입력은 단추 하나
|
||||
* 누르면 마스터 표 모달(`PickTable`) · 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -49,10 +49,11 @@ const isMachineInput = (spec: LogicInput): boolean =>
|
||||
const pickButton = (label: string, onClick: () => void): HTMLButtonElement =>
|
||||
createButton({ label, variant: "ghost", onClick });
|
||||
|
||||
const line = (label: string, current: HTMLElement, button: HTMLElement): HTMLElement =>
|
||||
/** 자재 칸 — 이름 + 잡힌 품목(누르면 고르기 모달) · 긴 이름은 두 칸 */
|
||||
const line = (label: string, current: HTMLElement): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m01-logic__field m01-logic__field--wide",
|
||||
children: [el("span", { text: label }), el("div", { children: [current, button] })],
|
||||
className: `m01-logic__field${label.length > 10 ? " m01-logic__field--wide" : ""}`,
|
||||
children: [el("span", { text: label }), current],
|
||||
});
|
||||
|
||||
/** 재료 줄마다 한 칸 — 지금 잡힌 품목 + 품목 고르기 */
|
||||
@@ -80,12 +81,16 @@ export function materialRows(
|
||||
const seeds = [cond?.["검색어"], cond?.이름, cond?.상세구분, cond?.구분, label]
|
||||
.map(headWord)
|
||||
.filter((w, k, all) => w && all.indexOf(w) === k);
|
||||
const current = el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
text: `${nowName || tx("Mat_NoPrice")} `,
|
||||
attrs: { "data-mat-idx": String(i), "data-ref": brief?.ref ?? "" },
|
||||
// 잡힌 품목 이름 = 링크 단추 — 누르면 고르기 모달([품목 고르기] 단추 걷음)
|
||||
const current = createButton({
|
||||
label: nowName || tx("Mat_NoPrice"),
|
||||
variant: "link",
|
||||
onClick: () => pick(),
|
||||
});
|
||||
const button = pickButton(tx("Pick_Item"), () =>
|
||||
current.dataset.matIdx = String(i);
|
||||
current.dataset.ref = brief?.ref ?? "";
|
||||
current.title = tx("Pick_Item");
|
||||
const pick = (): void =>
|
||||
openPickTable({
|
||||
kind: "재료",
|
||||
title: `${tx("Pick_MatTitle")} · ${label}`,
|
||||
@@ -94,12 +99,11 @@ export function materialRows(
|
||||
isCurrent: (r) => String(r["키"]) === (mine.get(i)?.ref ?? current.dataset.ref),
|
||||
onPick: (r) => {
|
||||
mine.set(i, { ref: String(r["키"]), label: nameOf(r) });
|
||||
current.textContent = `${nameOf(r)} `;
|
||||
current.textContent = nameOf(r);
|
||||
again();
|
||||
},
|
||||
}),
|
||||
);
|
||||
return line(`${label}${item.규격 ? ` ${item.규격}` : ""}`, current, button);
|
||||
});
|
||||
return line(`${label}${item.규격 ? ` ${item.규격}` : ""}`, current);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,7 +119,7 @@ export function showItems(
|
||||
const item = lines[i]?.품목;
|
||||
if (!item || mine.has(i)) return;
|
||||
span.dataset.ref = item.키;
|
||||
span.textContent = `${`${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() || item.키} `;
|
||||
span.textContent = `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() || item.키;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { createInputField, el } from "@ui/ui_template_elements";
|
||||
import { aliasText, matchLogic } from "@ui/ui_template_logic_find";
|
||||
import { buildPager } from "./M01_MasterData_UI_Cells";
|
||||
import { pageKeepFirst, watchFit } from "@ui/ui_template_pager";
|
||||
import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import "./M01_MasterData_UI_Head.css";
|
||||
@@ -41,7 +42,7 @@ export interface ListHandle {
|
||||
|
||||
export const logicId = (sub: string, key: string): string => `${sub}\n${key}`;
|
||||
|
||||
const SIZE = 30;
|
||||
const DEFAULT_SIZE = 30; // 붙기 전 어림 — 붙은 뒤 화면 높이로 다시 셈
|
||||
const COLS = [
|
||||
"키",
|
||||
"원문번호",
|
||||
@@ -64,6 +65,7 @@ export function buildList(
|
||||
let aliases = new Map<string, string>();
|
||||
const aliasOf = (x: ListItem): string => aliases.get(x.id) ?? x.alias;
|
||||
let page = 1;
|
||||
let size = DEFAULT_SIZE;
|
||||
const pick = { sub: "", detail: "" };
|
||||
const searchField = createInputField({ type: "search", placeholder: tx("List_Search") });
|
||||
const search = searchField.input;
|
||||
@@ -84,9 +86,9 @@ export function buildList(
|
||||
별칭: aliasOf(x),
|
||||
}).ok,
|
||||
);
|
||||
page = Math.min(page, Math.max(1, Math.ceil(shown.length / SIZE)));
|
||||
const rows = shown.slice((page - 1) * SIZE, page * SIZE).map(line);
|
||||
pager.replaceWith((pager = buildPager(shown.length, SIZE, page, go)));
|
||||
page = Math.min(page, Math.max(1, Math.ceil(shown.length / size)));
|
||||
const rows = shown.slice((page - 1) * size, page * size).map(line);
|
||||
pager.replaceWith((pager = buildPager(shown.length, size, page, go)));
|
||||
grid.replaceChildren(
|
||||
el("table", {
|
||||
className: "ui-table m01-master__grid",
|
||||
@@ -144,18 +146,29 @@ export function buildList(
|
||||
};
|
||||
|
||||
search.addEventListener("input", () => go(1));
|
||||
let pager = buildPager(0, SIZE, 1, go);
|
||||
let pager = buildPager(0, size, 1, go);
|
||||
|
||||
const root = el("div", {
|
||||
className: "m01-logic__listview",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-head__filters",
|
||||
children: [searchField.root, pager],
|
||||
children: [searchField.root],
|
||||
}),
|
||||
grid,
|
||||
pager,
|
||||
],
|
||||
});
|
||||
watchFit(
|
||||
root,
|
||||
() => grid,
|
||||
(rows) => {
|
||||
if (rows === size) return;
|
||||
page = pageKeepFirst(page, size, rows);
|
||||
size = rows;
|
||||
draw();
|
||||
},
|
||||
);
|
||||
return {
|
||||
root,
|
||||
setItems: (logics) => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch";
|
||||
import { buildPager, flatten, show } from "./M01_MasterData_UI_Cells";
|
||||
import { fitRows, PAGER_ROOM, pageKeepFirst, watchFit } from "@ui/ui_template_pager";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
|
||||
@@ -29,7 +30,8 @@ const COLS: Record<PickTableKind, string[]> = {
|
||||
],
|
||||
기계: ["원문번호", "구분", "상세구분", "이름", "규격", "값›가격"],
|
||||
};
|
||||
const SIZE = 30;
|
||||
/** 모달 안 표 칸 + 쪽 단추가 쓸 높이 — 모달 최대 높이(80vh)에서 제목 · 찾기 줄을 뺌 */
|
||||
const room = (): number => window.innerHeight * 0.8 - 130;
|
||||
|
||||
export interface PickTableOptions {
|
||||
kind: PickTableKind;
|
||||
@@ -60,13 +62,14 @@ export function openPickTable(opt: PickTableOptions): void {
|
||||
});
|
||||
|
||||
let page = 1;
|
||||
let size = fitRows(room() - PAGER_ROOM); // 처음 어림 — 붙은 뒤 다시 재서 어긋나면 바로잡음
|
||||
const tried = new Set([opt.seed]);
|
||||
let seq = 0;
|
||||
const load = async (fallback = false): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
try {
|
||||
const q = search.value.trim();
|
||||
const got = await fetchRows(FILES[opt.kind], page, SIZE, q);
|
||||
const got = await fetchRows(FILES[opt.kind], page, size, q);
|
||||
if (mine !== seq) return; // 최신 응답만
|
||||
if (!got.total && fallback) {
|
||||
const next = [...(q.includes(" ") ? [q.split(/\s+/)[0]] : []), ...(opt.alt ?? [])].find(
|
||||
@@ -94,10 +97,6 @@ export function openPickTable(opt: PickTableOptions): void {
|
||||
return tr;
|
||||
});
|
||||
host.replaceChildren(
|
||||
buildPager(got.total, SIZE, got.page, (next) => {
|
||||
page = next;
|
||||
void load();
|
||||
}),
|
||||
el("div", {
|
||||
className: "ui-table-wrap m01-master__grid-wrap",
|
||||
children: [
|
||||
@@ -112,12 +111,28 @@ export function openPickTable(opt: PickTableOptions): void {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
buildPager(got.total, size, got.page, (next) => {
|
||||
page = next;
|
||||
void load();
|
||||
}),
|
||||
);
|
||||
fit.ask();
|
||||
host.querySelector(".is-picked")?.scrollIntoView({ block: "nearest" });
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
||||
}
|
||||
};
|
||||
const fit = watchFit(
|
||||
host,
|
||||
() => host.querySelector<HTMLElement>(".m01-master__grid-wrap"),
|
||||
(rows) => {
|
||||
if (rows === size) return;
|
||||
page = pageKeepFirst(page, size, rows);
|
||||
size = rows;
|
||||
void load();
|
||||
},
|
||||
{ cap: room, once: true },
|
||||
);
|
||||
let timer = 0;
|
||||
search.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
|
||||
@@ -73,11 +73,24 @@
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 시험 계산 입력만 2열 고정 — 요소 표 쪽(auto-fill)은 그대로 둠
|
||||
/* 시험 계산 입력만 4열 고정(옵션 · 자재 격자 둘) — 요소 표 쪽(auto-fill)은 그대로 둠 · 긴 이름 칸은 두 칸
|
||||
실제 그릇은 LogicLab 이 씌우는 `.m01lab__calc`(`.m01-logic__calc` 는 옛 테스트 컨테이너 몫이라 안 걸림) */
|
||||
.m01lab__calc .m01-logic__head,
|
||||
.m01-logic__calc .m01-logic__head {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.m01lab__calc .m01-logic__field--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
/* 자재 칸 잡힌 품목 링크 — 칸 폭 안에서 왼쪽 맞춤 · 넘치면 말줄임 */
|
||||
.m01lab__calc .m01-logic__field > .ui-btn--link {
|
||||
justify-content: flex-start;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-logic__field {
|
||||
@@ -211,6 +224,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 표 칸은 칸 그대로 — block 이면 이웃 칸이 한 칸에 위아래로 쌓임(단가 2 행) */
|
||||
td.m01-logic__money {
|
||||
display: table-cell;
|
||||
}
|
||||
|
||||
/* 목록 「별칭」 칸(208-1) — 칸 안 글 상자 · 테두리는 올렸을 때만 */
|
||||
.m01-logic__alias {
|
||||
width: 160px;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Page.ts
|
||||
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직)
|
||||
* · 일위대가 조합은 M02 마스터 템플릿(PLAN 27-2b)
|
||||
* / 우측 요소 표 · 표 목록 · 로직 편집
|
||||
* · 일위대가 조합은 M02 마스터 템플릿(PLAN 27-2b) · 구조물 옵션 정의(716-1 · 화면은 M02 `StructOptions/`)
|
||||
* / 우측 요소 표 · 표 목록 · 로직 편집 · 옵션 정의 · 별칭 장부(718-3) ·
|
||||
* 주소 `?open=로직|옵션|별칭` = 그 칸을 고른 채 열림(716-2)
|
||||
*
|
||||
* 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 —
|
||||
* 자동저장 없음.
|
||||
@@ -26,7 +27,7 @@ import { t as L } from "@ui/ui_template_locale";
|
||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { mountSettingsDrawer } from "../A00_Common/m_settings_nav";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { currentQuery, navigateTo, setLeaveGuard } from "../A00_Common/router";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch";
|
||||
import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft";
|
||||
@@ -89,6 +90,7 @@ function buildPage(): HTMLElement {
|
||||
children: [headBar.root, buildHeadFilters([searchField.root])],
|
||||
});
|
||||
const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
|
||||
const optionsHost = el("div", { className: "m02-sod__host", attrs: { hidden: "" } });
|
||||
const elementView = el("div", {
|
||||
className: "m01-master__elements",
|
||||
children: [
|
||||
@@ -112,12 +114,50 @@ function buildPage(): HTMLElement {
|
||||
onDraftChange(refreshBar);
|
||||
refreshBar();
|
||||
|
||||
const showMode = (mode: "elements" | "logic"): void => {
|
||||
const showMode = (mode: "elements" | "logic" | "options"): void => {
|
||||
logicHost.hidden = mode !== "logic";
|
||||
elementView.hidden = mode !== "elements";
|
||||
optionsHost.hidden = mode !== "options";
|
||||
if (mode !== "options") side.setOptionsMark(false);
|
||||
};
|
||||
|
||||
/* --- 구조물 옵션 정의(716-1) — 저장 안 한 고침은 칸을 바꿀 때 · 페이지를 떠날 때 물음 --- */
|
||||
let optionsDirty = false;
|
||||
const leaveOptions = async (): Promise<boolean> => {
|
||||
if (!optionsDirty || optionsHost.hidden) return true;
|
||||
if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) return false;
|
||||
optionsDirty = false;
|
||||
return true;
|
||||
};
|
||||
/** 옵션 정의에 고침이 있으면 물은 뒤에 `run` — 취소면 옵션 줄 표시를 되돌려 머묾 */
|
||||
const afterLeave = (run: () => void): boolean => {
|
||||
if (!optionsDirty || optionsHost.hidden) return true;
|
||||
void leaveOptions().then((ok) => {
|
||||
if (ok) return run();
|
||||
side.setActive(null);
|
||||
side.setOptionsMark(true);
|
||||
});
|
||||
return false;
|
||||
};
|
||||
const showOptions = (label: string, node: HTMLElement): ((dirty: boolean) => void) => {
|
||||
dispose();
|
||||
dispose = (): void => {};
|
||||
current = null;
|
||||
optionsDirty = false;
|
||||
optionsHost.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-master__headbox",
|
||||
children: [buildHeadBar({ title: label, buttons: [] }).root],
|
||||
}),
|
||||
node,
|
||||
);
|
||||
showMode("options");
|
||||
setLeaveGuard(leaveOptions);
|
||||
return (dirty) => (optionsDirty = dirty);
|
||||
};
|
||||
|
||||
const openFile = (pick: Pick): void => {
|
||||
if (!afterLeave(() => openFile(pick))) return;
|
||||
dispose();
|
||||
showMode("elements");
|
||||
if (pick.view) {
|
||||
@@ -203,6 +243,7 @@ function buildPage(): HTMLElement {
|
||||
/** 「단가산출 로직」 탭으로 전환 — `key` 가 있으면 그 로직을 바로 엶. */
|
||||
const openLogicTab = (key?: string): void => {
|
||||
if (key) setPendingLogicKey(key);
|
||||
if (!afterLeave(() => openLogicTab())) return;
|
||||
dispose();
|
||||
dispose = (): void => {};
|
||||
current = null;
|
||||
@@ -259,7 +300,11 @@ function buildPage(): HTMLElement {
|
||||
setMasterJump((target) => void jump(target));
|
||||
|
||||
/* --- 좌측: 컨테이너 --- */
|
||||
const side = buildSide(openFile, () => openLogicTab());
|
||||
const side = buildSide(openFile, () => openLogicTab(), {
|
||||
confirmLeave: leaveOptions,
|
||||
show: showOptions,
|
||||
onOpen: () => side.setActive(null),
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m01-master" });
|
||||
const main = el("main", {
|
||||
@@ -267,7 +312,7 @@ function buildPage(): HTMLElement {
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-master__panel",
|
||||
children: [elementView, logicHost],
|
||||
children: [elementView, logicHost, optionsHost],
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -287,5 +332,8 @@ function buildPage(): HTMLElement {
|
||||
);
|
||||
// 다른 화면(일위대가 조합 다이어그램 등)이 로직 키를 들고 왔으면 로직 탭으로 바로 엶
|
||||
if (hasPendingLogicKey()) openLogicTab();
|
||||
// 주소 `?open=` — 대시보드 설계 기본값 줄 · 옛 M02 `open=옵션` 즐겨찾기가 그 칸을 고른 채 엶
|
||||
const want = currentQuery().get("open");
|
||||
if (want === "로직" || want === "옵션" || want === "별칭") side.reveal(want);
|
||||
return layout;
|
||||
}
|
||||
|
||||
@@ -29,8 +29,10 @@ import {
|
||||
withCell,
|
||||
} from "./M01_MasterData_UI_Cells";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { guessRows, pageKeepFirst, watchFit } from "@ui/ui_template_pager";
|
||||
|
||||
const SIZE = 50;
|
||||
/** 표 칸 위에 오는 도구줄(단추 줄 + 간격) 높이 — 첫 줄 수 어림에만 씀 */
|
||||
const BAR_H = 52;
|
||||
const MARKET = "재료_자재품목.json";
|
||||
const LINKED = "재료_품셈재료.json";
|
||||
const JOB = ["인력.json"];
|
||||
@@ -94,6 +96,7 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void
|
||||
const { sub, detail, state } = pick;
|
||||
const file = pick.file.file;
|
||||
let page = pick.view?.page ?? 1;
|
||||
let size = guessRows(host, BAR_H); // 화면 높이로 셈 — 그린 뒤 다시 재서 어긋나면 바로잡음
|
||||
let data: RowsPage | null = null;
|
||||
let unlinked = false;
|
||||
|
||||
@@ -210,7 +213,7 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "ui-toolbar m01-master__bar",
|
||||
children: [add, ...only, buildPager(d.total, SIZE, d.page, go)],
|
||||
children: [add, ...only],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ui-table-wrap m01-master__grid-wrap",
|
||||
@@ -221,8 +224,10 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void
|
||||
}),
|
||||
],
|
||||
}),
|
||||
buildPager(d.total, size, d.page, go),
|
||||
...empty,
|
||||
);
|
||||
fit.ask();
|
||||
};
|
||||
|
||||
const go = (next: number): void => {
|
||||
@@ -232,7 +237,7 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
data = await fetchRows(file, page, SIZE, q, unlinked, sub, detail, state);
|
||||
data = await fetchRows(file, page, size, q, unlinked, sub, detail, state);
|
||||
for (const [key, name] of Object.entries(data.refs ?? {})) names.set(key, name);
|
||||
saveView(pick.group, { page: data.page });
|
||||
paint();
|
||||
@@ -241,9 +246,22 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void
|
||||
}
|
||||
};
|
||||
|
||||
const fit = watchFit(
|
||||
host,
|
||||
() => host.querySelector<HTMLElement>(".m01-master__grid-wrap"),
|
||||
(rows) => {
|
||||
if (rows === size) return;
|
||||
page = pageKeepFirst(data?.page ?? page, size, rows);
|
||||
size = rows;
|
||||
void load();
|
||||
},
|
||||
);
|
||||
const off = onDraftChange(paint);
|
||||
void load();
|
||||
return off;
|
||||
return () => {
|
||||
fit.stop();
|
||||
off();
|
||||
};
|
||||
}
|
||||
|
||||
function actionCell(label: string, onClick: () => void): HTMLTableCellElement {
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Side.ts
|
||||
* 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`) 아홉 개
|
||||
* 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`)
|
||||
* 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 단가산출 로직(일위대가 조합은 M02 · PLAN 27-2b)
|
||||
* ─ 구분선 ─ 구조물 옵션 정의(716-1 · 화면 코드는 M02 `StructOptions/` 그대로 · 입구만 여기)
|
||||
* · 별칭 장부(718-3 · 누르면 본 칸에 모인 후보 · 마스터 장부 · `M01_MasterData_UI_Alias`)
|
||||
* 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록
|
||||
* ========================================================================== */
|
||||
|
||||
import { createSelectField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { createDivider } from "@ui/ui_template_divider";
|
||||
import { currentLanguageIndex, t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
buildOptionsSection,
|
||||
type OptionsSectionOptions,
|
||||
} from "../M02_MasterTemplete/StructOptions/M02_SO_Options";
|
||||
import { ALIAS_TITLE, buildAliasLedger } from "./M01_MasterData_UI_Alias";
|
||||
import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree";
|
||||
@@ -103,6 +111,10 @@ export interface SideHandle {
|
||||
setOnPicked: (fn: () => void) => void;
|
||||
/** 「전체」 + 드롭다운 거름 한 벌 — 로직 컨테이너도 씀 */
|
||||
filter: (args: FilterArgs) => HTMLElement;
|
||||
/** 주소 `open` — 그 컨테이너를 펼치고 고름(로직 = 로직 목록 · 옵션 = 정의 화면까지) */
|
||||
reveal: (target: "로직" | "옵션" | "별칭") => void;
|
||||
/** 옵션 줄 고름 표시 지움(다른 화면을 열었음) · 켬(떠나기를 취소해 머묾) */
|
||||
setOptionsMark: (on: boolean) => void;
|
||||
}
|
||||
|
||||
export interface FilterArgs {
|
||||
@@ -156,8 +168,13 @@ const LEAF: Group[] = ["환율", "요율"];
|
||||
/** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */
|
||||
const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"];
|
||||
const LOGIC_ID = "로직|";
|
||||
const ALIAS_ID = "별칭|";
|
||||
|
||||
export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle {
|
||||
export function buildSide(
|
||||
onOpen: (pick: Pick) => void,
|
||||
onLogic: () => void,
|
||||
optionsOpt: Omit<OptionsSectionOptions, "isAdmin">,
|
||||
): SideHandle {
|
||||
const lists = new Map<Group, FileInfo[]>();
|
||||
const bodies = new Map<Group, HTMLElement>();
|
||||
const buttons = new Map<string, HTMLElement>();
|
||||
@@ -483,9 +500,25 @@ ${first.file}`
|
||||
onLogic();
|
||||
});
|
||||
|
||||
// 구조물 옵션 정의 — 로직 칸 아래 구분선 + 칸 하나(시스템 관리자 화면이라 고침 가능)
|
||||
const options = buildOptionsSection({ ...optionsOpt, isAdmin: true });
|
||||
const optionsSec = section(options.title, options.body);
|
||||
// 별칭 장부(718-3) — 옵션 칸 아래 칸 하나 · 누르면 본 칸(옵션 정의와 같은 자리)
|
||||
const aliasSec = section(ALIAS_TITLE, null);
|
||||
aliasSec.classList.add("m01-side__leaf");
|
||||
buttons.set(ALIAS_ID, aliasSec);
|
||||
const openAlias = async (): Promise<void> => {
|
||||
if (!(await optionsOpt.confirmLeave())) return;
|
||||
optionsOpt.show(ALIAS_TITLE, buildAliasLedger());
|
||||
options.clear();
|
||||
setActive(ALIAS_ID);
|
||||
};
|
||||
aliasSec
|
||||
.querySelector(".ui-collapsible__title")
|
||||
?.addEventListener("click", () => void openAlias());
|
||||
const root = el("div", {
|
||||
className: "m01-side ui-sidebar-panel",
|
||||
children: [...groups, logic],
|
||||
children: [...groups, logic, createDivider(), optionsSec, aliasSec],
|
||||
});
|
||||
attachCollapsible(root);
|
||||
void Promise.all(GROUPS.filter((g) => g !== FUEL_GROUP).map(refresh)).catch((error) =>
|
||||
@@ -497,5 +530,30 @@ ${first.file}`
|
||||
// 재료는 유가 · 전력 파일까지 다(알약 → 마스터 화면 길이 파일로 찾음) · 유가 · 전력은 그 파일만
|
||||
const files = (group: string): FileInfo[] =>
|
||||
group === FUEL_GROUP ? shown(FUEL_GROUP) : (lists.get(group as Group) ?? []);
|
||||
return { root, logicHost, refresh, files, setActive, setOnPicked, filter };
|
||||
const reveal = (target: "로직" | "옵션" | "별칭"): void => {
|
||||
if (target === "별칭") return void openAlias();
|
||||
if (target === "옵션") {
|
||||
optionsSec.classList.remove("is-collapsed");
|
||||
options.body.querySelector<HTMLElement>("button")?.click();
|
||||
return;
|
||||
}
|
||||
logic.classList.remove("is-collapsed");
|
||||
setActive(buttons.has(LOGIC_ID) ? LOGIC_ID : null);
|
||||
onLogic();
|
||||
};
|
||||
const setOptionsMark = (on: boolean): void => {
|
||||
if (!on) return options.clear();
|
||||
options.body.querySelector("button")?.classList.add("is-active");
|
||||
};
|
||||
return {
|
||||
root,
|
||||
logicHost,
|
||||
refresh,
|
||||
files,
|
||||
setActive,
|
||||
setOnPicked,
|
||||
filter,
|
||||
reveal,
|
||||
setOptionsMark,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,9 +84,9 @@ html.is-bm-route .m01-master__headbox {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 겉 = 표 부품(ui-table-wrap · ui-table) · 여기는 높이 · 폭 · 머리 고정 · 칸 상태만 */
|
||||
.m01-master__grid-wrap {
|
||||
max-height: calc(100dvh - 300px);
|
||||
/* 겉 = 표 부품(ui-table-wrap · ui-table) · 여기는 높이 · 폭 · 머리 고정 · 칸 상태만
|
||||
쪽 있는 표 = 높이 제한 없음(줄 수를 화면 높이로 셈 · 쪽 단추 부품 watchFit) · 쪽 없는 한 장 표(--sheet) = 화면 아래까지 안에서 스크롤 */
|
||||
.m01-master__grid-wrap--sheet {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -130,13 +130,9 @@ table.m01-master__grid {
|
||||
padding: 2px var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-master__pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-12);
|
||||
margin-left: auto;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
/* 쪽 단추(부품 ui-pager) — 간격은 부품이 줌(표 아래 한 칸) · 이미 칸 사이 간격이 있는 몸통은 겹치지 않게 뺌 */
|
||||
.m01-master__body > .ui-table-wrap + .ui-pager {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.m01-master__table-row {
|
||||
|
||||
@@ -30,8 +30,10 @@ import {
|
||||
toNumberish,
|
||||
toRange,
|
||||
} from "./M01_MasterData_UI_Cells";
|
||||
import { guessRows, pageKeepFirst, watchFit, watchHeight } from "@ui/ui_template_pager";
|
||||
|
||||
const LIST_SIZE = 30;
|
||||
/** 표 칸 위에 오는 거름 줄 높이 — 첫 줄 수 어림에만 씀 */
|
||||
const FILTER_H = 52;
|
||||
const LIST_COLS = ["키", "원문번호", "구분", "상세구분", "이름", "기준", "출처", "비고"] as const;
|
||||
/** 용도 대상 거름 드롭다운 후보 — PLAN 1-1 셋째 줄 그대로. */
|
||||
const USAGE_TARGETS = ["인력", "기계", "재료", "할증", "환산", "작업량 조건"];
|
||||
@@ -45,16 +47,22 @@ export function renderTables(
|
||||
): () => void {
|
||||
let stop = (): void => {};
|
||||
let usage = pick.view?.usage ?? "";
|
||||
let size = guessRows(host, FILTER_H); // 화면 높이로 셈 — 그린 뒤 다시 재서 어긋나면 바로잡음
|
||||
let at = 0; // 목록을 보고 있는 쪽(0 = 표 하나를 보는 중)
|
||||
const list = (page = pick.view?.page ?? 1): void => {
|
||||
stop();
|
||||
stop = (): void => {};
|
||||
at = page;
|
||||
void showList(
|
||||
host,
|
||||
pick,
|
||||
q,
|
||||
usage,
|
||||
page,
|
||||
size,
|
||||
list,
|
||||
(file, key) => {
|
||||
at = 0;
|
||||
stop = openTable(host, file, key, () => list(page), openLogic);
|
||||
},
|
||||
(next) => {
|
||||
@@ -62,11 +70,22 @@ export function renderTables(
|
||||
saveView(pick.group, { usage, page: 1 });
|
||||
list(1);
|
||||
},
|
||||
);
|
||||
).then(() => fit.ask());
|
||||
};
|
||||
const fit = watchFit(
|
||||
host,
|
||||
() => host.querySelector<HTMLElement>(".m01-master__tables"),
|
||||
(rows) => {
|
||||
if (!at || rows === size) return;
|
||||
const page = pageKeepFirst(at, size, rows);
|
||||
size = rows;
|
||||
list(page);
|
||||
},
|
||||
);
|
||||
list();
|
||||
const off = onDraftChange(() => host.querySelector(".m01-master__tables") && markChanged(host));
|
||||
return () => {
|
||||
fit.stop();
|
||||
stop();
|
||||
off();
|
||||
};
|
||||
@@ -86,12 +105,14 @@ async function showList(
|
||||
q: string,
|
||||
usage: string,
|
||||
page: number,
|
||||
size: number,
|
||||
goPage: (page: number) => void,
|
||||
open: (file: string, key: string) => void,
|
||||
onUsageChange: (usage: string) => void,
|
||||
): Promise<void> {
|
||||
let got: { total: number; tables: TableHead[] };
|
||||
try {
|
||||
got = await fetchTables(pick.group, pick.sub, pick.detail, q, page, LIST_SIZE, usage);
|
||||
got = await fetchTables(pick.group, pick.sub, pick.detail, q, page, size, usage);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
|
||||
return;
|
||||
@@ -121,12 +142,6 @@ async function showList(
|
||||
usageField.select.addEventListener("change", () => onUsageChange(usageField.select.value));
|
||||
host.replaceChildren(
|
||||
el("div", { className: "m01-master__filter", children: [usageField.root] }),
|
||||
buildPager(
|
||||
got.total,
|
||||
LIST_SIZE,
|
||||
page,
|
||||
(p) => void showList(host, pick, q, usage, p, open, onUsageChange),
|
||||
),
|
||||
el("div", {
|
||||
className: "ui-table-wrap m01-master__grid-wrap m01-master__tables",
|
||||
children: [
|
||||
@@ -141,6 +156,7 @@ async function showList(
|
||||
}),
|
||||
],
|
||||
}),
|
||||
buildPager(got.total, size, page, goPage),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,6 +169,7 @@ function openTable(
|
||||
openLogic: (key: string) => void,
|
||||
): () => void {
|
||||
let off = (): void => {};
|
||||
let unfit = (): void => {}; // 쪽 없는 한 장 표 — 화면 아래까지 높이 묶음
|
||||
void (async () => {
|
||||
let version: string;
|
||||
let table: Row & { 줄: Row[] };
|
||||
@@ -211,6 +228,7 @@ function openTable(
|
||||
const backButton = createButton({ label: L("M01_BackToTables"), variant: "ghost" });
|
||||
backButton.addEventListener("click", () => {
|
||||
off();
|
||||
unfit();
|
||||
back();
|
||||
});
|
||||
const notes = ((table.주 ?? []) as string[]).map((n) =>
|
||||
@@ -224,6 +242,24 @@ function openTable(
|
||||
btn.addEventListener("click", () => openLogic(k));
|
||||
return i === 0 ? btn : el("span", { text: ", ", children: [btn] });
|
||||
});
|
||||
const sheet = el("div", {
|
||||
className: "ui-table-wrap m01-master__grid-wrap m01-master__grid-wrap--sheet",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-table m01-master__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [el("th"), ...cols.map((c) => el("th", { text: headText(c) }))],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "ui-toolbar m01-master__bar",
|
||||
@@ -246,29 +282,17 @@ function openTable(
|
||||
add,
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ui-table-wrap m01-master__grid-wrap",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-table m01-master__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [el("th"), ...cols.map((c) => el("th", { text: headText(c) }))],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
sheet,
|
||||
...notes,
|
||||
);
|
||||
unfit();
|
||||
unfit = watchHeight(sheet);
|
||||
};
|
||||
off = onDraftChange(paint);
|
||||
paint();
|
||||
})();
|
||||
return () => off();
|
||||
return () => {
|
||||
off();
|
||||
unfit();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
파일 = 읽은 결과만(원본 엑셀 안 둠) `{키, 파일, 올린때, 올린이, 시트, 호표, 검산}` · 키 `HP-######`
|
||||
(층마다 다음 번호) · 맞춤 기억 = `{줄: {줄 열쇠: {로직?|자재?|빼기?, 입력값, 환산, 확정}}}`.
|
||||
층 종류(`layers.KINDS`) 밖 폴더 — 양식 목록 · 복사 · 프로젝트 씨앗에 안 섞임 · 권한은 길 몫.
|
||||
한시 불러오기(718-1) — 파일에 `지문`(올린 엑셀 sha256) · 목록 = 지문 있는 것(작업 중)만 · 옛 HP 는
|
||||
파일 그대로 목록에서만 숨김 · 끝낸 기록 `실무자료/불러옴.json`
|
||||
`{번, 지문 {지문: {파일, 키, 끝낸때, 만든}}}` · 번 = 낸 키 가장 큰 번(지운 뒤에도 키를 다시 안 씀).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
@@ -21,6 +25,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
DIRNAME = "실무자료"
|
||||
MEMORY = "맞춤"
|
||||
GRID = "격자"
|
||||
DONE = "불러옴"
|
||||
NO_GRID = "격자 없음 — 다시 올리면 보임"
|
||||
_KEY = re.compile(r"^HP-(\d{6})$")
|
||||
# ponytail: 프로세스 안 잠금 — 서버를 여럿 띄우면 파일 잠금으로
|
||||
@@ -58,16 +63,33 @@ def brief(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def fingerprint(data: bytes) -> str:
|
||||
"""올린 엑셀 지문 — 같은 파일 다시 올림을 알아봄."""
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def rows(folder: str | Path) -> list[dict[str, Any]]:
|
||||
"""층 실무자료 목록(키 차례)."""
|
||||
"""층 실무자료 목록(키 차례) — 작업 중(지문 있음)만 · 옛 HP 는 숨김."""
|
||||
out = []
|
||||
for path in sorted(_dir(folder).glob("HP-*.json")):
|
||||
doc = _load(path)
|
||||
if isinstance(doc, dict) and _KEY.match(path.stem):
|
||||
out.append(brief(doc))
|
||||
if isinstance(doc, dict) and _KEY.match(path.stem) and doc.get("지문"):
|
||||
out.append({**brief(doc), "지문": doc["지문"]})
|
||||
return out
|
||||
|
||||
|
||||
def done(folder: str | Path) -> dict[str, Any]:
|
||||
"""끝낸 기록 `{번, 지문 {지문: {파일, 키, 끝낸때, 만든}}}`."""
|
||||
doc = _load(_dir(folder) / f"{DONE}.json")
|
||||
doc = doc if isinstance(doc, dict) else {}
|
||||
marks = doc.get("지문")
|
||||
return {"번": int(doc.get("번") or 0), "지문": marks if isinstance(marks, dict) else {}}
|
||||
|
||||
|
||||
def _put_done(folder: str | Path, got: dict[str, Any]) -> None:
|
||||
atomic_write_json(_dir(folder) / f"{DONE}.json", got)
|
||||
|
||||
|
||||
def read(folder: str | Path, key: str) -> dict[str, Any]:
|
||||
doc = _load(_path(folder, key))
|
||||
if not isinstance(doc, dict):
|
||||
@@ -81,25 +103,30 @@ def save(
|
||||
parsed: dict[str, Any],
|
||||
who: Any,
|
||||
grid: dict[str, Any] | None = None,
|
||||
mark: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄."""
|
||||
"""읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄 · `mark` = 올린 엑셀 지문."""
|
||||
with _LOCK:
|
||||
have = [
|
||||
int(m.group(1)) for p in _dir(folder).glob("HP-*.json") if (m := _KEY.match(p.stem))
|
||||
]
|
||||
key = f"HP-{max(have, default=0) + 1:06d}"
|
||||
log = done(folder)
|
||||
log["번"] = max(*have, log["번"], 0) + 1
|
||||
key = f"HP-{log['번']:06d}"
|
||||
doc = {
|
||||
"키": key,
|
||||
"파일": name,
|
||||
"올린때": utc_now_text(),
|
||||
"올린이": who,
|
||||
**({"지문": mark} if mark else {}),
|
||||
**parsed,
|
||||
}
|
||||
if grid is not None:
|
||||
book = {**grid["책"], "열": key}
|
||||
atomic_write_json(_grid_path(folder, key), {**grid, "책": book})
|
||||
atomic_write_json(_path(folder, key), doc)
|
||||
return brief(doc)
|
||||
_put_done(folder, log)
|
||||
return {**brief(doc), **({"지문": mark} if mark else {})}
|
||||
|
||||
|
||||
def grid(folder: str | Path, key: str) -> dict[str, Any]:
|
||||
@@ -110,11 +137,22 @@ def grid(folder: str | Path, key: str) -> dict[str, Any]:
|
||||
return doc
|
||||
|
||||
|
||||
def delete(folder: str | Path, key: str) -> None:
|
||||
def delete(folder: str | Path, key: str, made: list[str] | None = None) -> None:
|
||||
"""HP · 격자 지움 · 만든 것(`made` SC 키)이 있으면 지문을 끝낸 기록에 남김(다시 올림 막음)."""
|
||||
path = _path(folder, key)
|
||||
with _LOCK:
|
||||
if not path.is_file():
|
||||
doc = _load(path)
|
||||
if not isinstance(doc, dict):
|
||||
raise store.StoreError(404, f"없는 실무자료 「{key}」")
|
||||
if made and doc.get("지문"):
|
||||
log = done(folder)
|
||||
log["지문"][doc["지문"]] = {
|
||||
"파일": doc.get("파일"),
|
||||
"키": key,
|
||||
"끝낸때": utc_now_text(),
|
||||
"만든": made,
|
||||
}
|
||||
_put_done(folder, log)
|
||||
path.unlink()
|
||||
_grid_path(folder, key).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""M02 서고 참조 펼침(계산) — `M02_Labor_Ref.Expand` 를 떼어 둠(PLAN 715-2 · 700 줄).
|
||||
|
||||
참조 → `{이름, 단위, 기준(1 단위), 까닭, 안?}` — 단가승격(옛 SP) · 사전조합(SC) · 단가산출(SK ·
|
||||
`M02_Sangeun_Calc.unit`). 이름은 `M02_Labor_Ref.Expand` 로 그대로 부름.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
|
||||
Ref = dict[str, Any]
|
||||
|
||||
|
||||
class Expand:
|
||||
"""참조 → `{이름, 단위, 기준(1 단위), 까닭, 안?}` — 한 번 셈 안에서 키별 캐시 ·
|
||||
깊이 상한 · 순환은 까닭(통째로 멈추지 않음). `read` = 참조 → 문서(프로젝트는 굳힌 사본)."""
|
||||
|
||||
def __init__(self, whole: Any, read: Callable[[Ref], dict | None] = refs.read_ref) -> None:
|
||||
self.whole, self.read, self.memo = whole, read, {}
|
||||
|
||||
def __call__(self, ref: Ref, trail: tuple[str, ...] = ()) -> dict[str, Any]:
|
||||
rid = refs.ref_id(ref)
|
||||
if rid in trail:
|
||||
keys = [t.rsplit("|", 1)[-1] for t in (*trail, rid)]
|
||||
return self._stop(ref, "순환 — " + " → ".join(keys))
|
||||
if len(trail) >= refs.DEPTH:
|
||||
return self._stop(ref, f"참조가 {refs.DEPTH}단을 넘음")
|
||||
if rid not in self.memo:
|
||||
doc = self.read(ref)
|
||||
if not isinstance(doc, dict):
|
||||
return self._stop(ref, f"없는 참조 「{ref['키']}」")
|
||||
if refs.kind_of(ref["키"]) == "promote":
|
||||
self.memo[rid] = self._promote(doc)
|
||||
elif refs.kind_of(ref["키"]) == "sangeun": # 단가산출(715-2) = 고친 줄 셈 1 단위
|
||||
self.memo[rid] = calc.unit(doc, self.whole, self, (*trail, rid))
|
||||
else:
|
||||
self.memo[rid] = self._combo(doc, (*trail, rid))
|
||||
return self.memo[rid]
|
||||
|
||||
@staticmethod
|
||||
def _stop(ref: Ref, why: str) -> dict[str, Any]:
|
||||
return {"이름": ref["키"], "단위": None, "기준": None, "까닭": why}
|
||||
|
||||
def _promote(self, doc: dict) -> dict[str, Any]:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
line = (doc.get("담은로직") or [{}])[0]
|
||||
code = str(line.get("로직") or "")
|
||||
got = labor.run_line(
|
||||
self.whole, code, line.get("입력값"), line.get("켜쌓기율"), line.get("고름")
|
||||
)
|
||||
times = labor._number(doc.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
why = got.get("까닭") or (None if base else "돈 아닌 로직")
|
||||
return {
|
||||
"이름": doc.get("이름"),
|
||||
"단위": doc.get("단위"),
|
||||
"로직": line.get("로직"),
|
||||
"기준": {k: Decimal(str(v)) / times for k, v in base.items()} if base else None,
|
||||
"까닭": why,
|
||||
# 원 로직 줄(줄 누름 하위 행 · 51-2) — 1 단위로 나눔(하위 합 = 기준)
|
||||
"줄": [
|
||||
{
|
||||
**x,
|
||||
"수량": Decimal(str(x["수량"])) / times,
|
||||
"금액": Decimal(str(x["금액"])) / times,
|
||||
"비목": {k: Decimal(str(v)) / times for k, v in (x.get("비목") or {}).items()},
|
||||
}
|
||||
for x in got.get("줄") or []
|
||||
],
|
||||
}
|
||||
|
||||
def _logic(self, one: dict, pill: dict | None) -> dict[str, Any]:
|
||||
"""사전조합 안 로직 줄 — 일위대가 로직 줄과 같은 `run_line` · 배는 물량 알약 것."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
pill = pill or {}
|
||||
got = labor.run_line(
|
||||
self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
|
||||
)
|
||||
got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1)
|
||||
times = labor._number(pill.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
return {
|
||||
"이름": pill.get("이름") or one["로직"],
|
||||
"단위": pill.get("단위"),
|
||||
"기준": {k: Decimal(str(v)) / times for k, v in base.items()} if base else None,
|
||||
"까닭": got.get("까닭") or (None if base else "돈 아닌 로직"),
|
||||
}
|
||||
|
||||
def _combo(self, doc: dict, trail: tuple[str, ...]) -> dict[str, Any]:
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
lines = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)]
|
||||
kept = doc.get("알약") # 프로젝트 참조사본 = 가져온 때 굳힌 알약(142-2) · 층 = 지금 M01
|
||||
pills = kept if isinstance(kept, list) else None
|
||||
by_logic = {
|
||||
code: pill
|
||||
for pill in pills or combo_store.combo_pills({"담은로직": lines})["물량"]
|
||||
for code in pill["로직"]
|
||||
}
|
||||
inner, rows, why = [], [], None
|
||||
for one in lines:
|
||||
ref = one.get("참조")
|
||||
if isinstance(ref, dict):
|
||||
head, sub = {"참조": ref}, self(ref, trail)
|
||||
elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음)
|
||||
head, sub = {"로직": one["로직"]}, self._logic(one, by_logic.get(one["로직"]))
|
||||
elif fixed.is_fixed(one): # 고정 줄(705-3) = 줄 단가 · 비율 줄 = 끝에(`ratio_row`)
|
||||
head, sub = {"고정": one["고정"]}, fixed.line(self.whole, one["고정"])
|
||||
else:
|
||||
continue
|
||||
qty = labor._number((one.get("수량") or {}).get("손"))
|
||||
qty = Decimal(1) if qty is None else qty
|
||||
keep = ("이름", "단위", "기준", "까닭")
|
||||
inner.append({**head, "수량": qty, **{k: sub.get(k) for k in keep}})
|
||||
if sub.get("기준") is None:
|
||||
why = why or f"「{sub.get('이름')}」 {sub.get('까닭')}"
|
||||
continue
|
||||
rows.append({k: v * qty for k, v in sub["기준"].items()})
|
||||
for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 손 · 빈칸 = 안 셈
|
||||
qty = labor._number((mat.get("수량") or {}).get("손"))
|
||||
if qty is None or labor.apart(mat): # 별도계상 = 안 셈 · 안 줄에도 없음(222-1)
|
||||
continue
|
||||
got = labor.material_price(self.whole, mat["코드"], mat.get("단위"))
|
||||
price = labor._number(got["기준단가"])
|
||||
zero = dict.fromkeys(labor.COSTS, Decimal(0))
|
||||
base = None if price is None else {**zero, "재료비": price, "계": price}
|
||||
name = mat.get("이름") or mat["코드"]
|
||||
inner.append(
|
||||
{
|
||||
"자재": mat["코드"],
|
||||
"수량": qty,
|
||||
"이름": name,
|
||||
"단위": mat.get("단위"),
|
||||
"기준": base,
|
||||
"까닭": got["까닭"],
|
||||
}
|
||||
)
|
||||
if base is None:
|
||||
why = why or f"「{name}」 {got['까닭']}"
|
||||
continue
|
||||
rows.append({k: v * qty for k, v in base.items()})
|
||||
under = labor._sum(rows)
|
||||
for one in inner: # 비율 고정 줄(705-3) — 밑 = 다른 줄 합
|
||||
if (one.get("고정") or {}).get("비율"):
|
||||
one["기준"], row = fixed.ratio_row(one["고정"]["비율"], one["수량"], under)
|
||||
rows.append(row)
|
||||
return {
|
||||
"이름": doc.get("이름"),
|
||||
"단위": doc.get("단위"),
|
||||
"기준": None if why else labor._sum(rows),
|
||||
"까닭": why,
|
||||
"안": inner,
|
||||
}
|
||||
@@ -10,7 +10,9 @@
|
||||
승격 · 사전조합 · 단가산출 로직(51-11) · 수량은 손(기본 1) ·
|
||||
자재 수량은 손 · 빈칸 = 안 셈(175) ·
|
||||
값 = Σ 안 줄 1 단위 값 × 수량(로직 줄 = 기준 ÷ 배) + Σ 자재 기준단가 × 손
|
||||
참조 = `{층, 주인, 키}`(마스터 주인 null · 회사 `C` · 개인 `C/U`) — 종류는 키 머리(SP · SC) ·
|
||||
sangeun `{층}/sangeun/SK-######.json` 단가산출(715-2) — 꼴 `M02_Sangeun_Doc` · 셈
|
||||
`M02_Sangeun_Calc` · 참조 자리 = `바탕.참조` · `더한줄[].참조`(`refs_in`)
|
||||
참조 = `{층, 주인, 키}`(마스터 주인 null · 회사 `C` · 개인 `C/U`) — 종류는 키 머리(SP · SC · SK) ·
|
||||
위 층만 가리킴(개인 → 그 개인 · 그 회사 · 마스터 / 회사 → 그 회사 · 마스터 / 마스터 → 마스터) ·
|
||||
같은 참조 두 번 · 순환(422)은 저장 때 막음 · 계산 때도 깊이 상한(`DEPTH`)에서 멈추고 까닭.
|
||||
지우기 = 쓰는 곳(모든 층 일위대가 · 사전조합)이 있으면 409 · 층 사이 복사 = 대상 층이 못 읽는
|
||||
@@ -22,19 +24,20 @@ from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
PREFIX = {"precombo": "SC"}
|
||||
TITLE = {"precombo": "사전조합"}
|
||||
if TYPE_CHECKING:
|
||||
from M02_MasterTemplete.M02_Labor_Expand import Expand
|
||||
|
||||
PREFIX = {"precombo": "SC", "sangeun": "SK"}
|
||||
TITLE = {"precombo": "사전조합", "sangeun": "단가산출"}
|
||||
DEPTH = 8 # 참조 깊이 상한(저장 · 계산 둘 다)
|
||||
REF_HEAD = "참조|"
|
||||
_KEY = re.compile(r"^(SP|SC)-(\d{6})$")
|
||||
_KEY = re.compile(r"^(SP|SC|SK)-(\d{6})$")
|
||||
_OWNER = {"company": re.compile(r"^\d+$"), "personal": re.compile(r"^\d+/\d+$")}
|
||||
|
||||
Ref = dict[str, Any]
|
||||
@@ -46,7 +49,19 @@ def _bad(text: str, status: int = 400) -> store.StoreError:
|
||||
|
||||
|
||||
def kind_of(key: str) -> str:
|
||||
return "promote" if str(key).startswith("SP") else "precombo"
|
||||
head = str(key)[:2]
|
||||
return {"SP": "promote", "SK": "sangeun"}.get(head, "precombo")
|
||||
|
||||
|
||||
def refs_in(doc: Any) -> list[Ref]:
|
||||
"""문서가 가리키는 참조 — 담은로직 줄 · 단가산출 바탕 · 더한줄(715-2)."""
|
||||
if not isinstance(doc, dict):
|
||||
return []
|
||||
base = doc.get("바탕")
|
||||
lines = [*(doc.get("담은로직") or []), *(doc.get("더한줄") or []), base]
|
||||
return [
|
||||
one["참조"] for one in lines if isinstance(one, dict) and isinstance(one.get("참조"), dict)
|
||||
]
|
||||
|
||||
|
||||
def ref_id(ref: Ref) -> str:
|
||||
@@ -118,7 +133,7 @@ def check_ref(raw: Any, at: str, here_: Here) -> Ref:
|
||||
elif layer not in _OWNER or not _OWNER[layer].match(str(owner or "")):
|
||||
raise _bad(f"{at} · 참조 층 · 주인 꼴이 틀림")
|
||||
if not _KEY.match(key):
|
||||
raise _bad(f"{at} · 참조 키는 SP-###### · SC-######")
|
||||
raise _bad(f"{at} · 참조 키는 SP · SC · SK-######")
|
||||
ref = {"층": layer, "주인": None if owner is None else str(owner), "키": key}
|
||||
if not readable(here_, ref):
|
||||
raise _bad(f"{at} · 위 층만 가리킴 — 「{key}」({layer} {owner or ''})")
|
||||
@@ -141,6 +156,10 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]:
|
||||
key = str(doc.get("키") or "")
|
||||
check_key(kind, key)
|
||||
name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip()
|
||||
if kind == "sangeun":
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
|
||||
return calc.clean_doc(doc, here())
|
||||
out: dict[str, Any] = {"종류": TITLE[kind], "키": key}
|
||||
if not name:
|
||||
raise _bad("사전조합 이름이 빔")
|
||||
@@ -240,9 +259,8 @@ def _no_loop(ref: Ref, trail: list[Ref]) -> None:
|
||||
"""저장 때 순환 · 깊이 — 아래로 따라가 처음 문서를 다시 만나면 422 「A → B → A」."""
|
||||
if len(trail) > DEPTH:
|
||||
raise _bad(f"참조가 {DEPTH}단을 넘음 — " + " → ".join(r["키"] for r in trail), 422)
|
||||
for one in (read_ref(ref) or {}).get("담은로직") or []:
|
||||
child = one.get("참조") if isinstance(one, dict) else None
|
||||
if not isinstance(child, dict) or not child.get("키"):
|
||||
for child in refs_in(read_ref(ref)):
|
||||
if not child.get("키"):
|
||||
continue
|
||||
if ref_id(child) == ref_id(trail[0]):
|
||||
raise _bad("순환 — " + " → ".join(r["키"] for r in [*trail, child]), 422)
|
||||
@@ -287,6 +305,7 @@ def summary(folder: Path, kind: str, key: str, expand: Expand | None = None) ->
|
||||
def summaries(folder: Path, rows: list[dict[str, Any]]) -> None:
|
||||
"""층 목록의 서고 줄마다 `summary`(값 포함) — 한 번 셈 캐시를 함께 씀."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete.M02_Labor_Expand import Expand
|
||||
|
||||
expand = Expand(m01.loaded()[1])
|
||||
for row in rows:
|
||||
@@ -296,21 +315,16 @@ def summaries(folder: Path, rows: list[dict[str, Any]]) -> None:
|
||||
|
||||
# ── 쓰는 곳 · 지우기 ──────────────────────────────────────────────────
|
||||
def users(ref: Ref) -> list[str]:
|
||||
"""`ref` 를 담은 일위대가 · 사전조합 — 「어디 이름」 목록(모든 층 훑음 · 프로젝트 뺌)."""
|
||||
"""`ref` 를 담은 일위대가 · 사전조합 · 단가산출(바탕 · 더한줄) — 「어디 이름」 목록(모든 층 훑음 ·
|
||||
프로젝트 뺌)."""
|
||||
rid, out = ref_id(ref), []
|
||||
for kind, what in (("labor", "일위대가"), ("precombo", "사전조합")):
|
||||
for kind, what in (("labor", "일위대가"), ("precombo", "사전조합"), ("sangeun", "단가산출")):
|
||||
for at, path in layers.layer_files(kind):
|
||||
try:
|
||||
doc = json.loads(path.read_bytes())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
lines = doc.get("담은로직") if isinstance(doc, dict) else None
|
||||
if any(
|
||||
isinstance(one, dict)
|
||||
and isinstance(one.get("참조"), dict)
|
||||
and ref_id(one["참조"]) == rid
|
||||
for one in lines or []
|
||||
):
|
||||
if any(ref_id(one) == rid for one in refs_in(doc)):
|
||||
name = doc.get("일위대가번") or doc.get("이름") or path.stem
|
||||
out.append(f"{what} {at} 「{name}」")
|
||||
return out
|
||||
@@ -350,12 +364,23 @@ def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]:
|
||||
got = read_ref(ref)
|
||||
if got is None:
|
||||
raise _bad(f"없는 참조 「{ref['키']}」")
|
||||
body = {**got, "담은로직": _moved(got.get("담은로직"), move, depth + 1)}
|
||||
body = _move_doc(got, move, depth + 1)
|
||||
made = create(kind_of(ref["키"]), origin_mark(_spec_moved(body, done), ref))
|
||||
done[ref_id(ref)] = {"층": target[0], "주인": target[1], "키": made["이름"]}
|
||||
return done[ref_id(ref)]
|
||||
|
||||
return _spec_moved({**doc, "담은로직": _moved(doc.get("담은로직"), move, 1)}, done)
|
||||
return _spec_moved(_move_doc(doc, move, 1), done)
|
||||
|
||||
|
||||
def _move_doc(doc: dict[str, Any], move: Callable[[Ref, int], Ref], depth: int) -> dict[str, Any]:
|
||||
"""문서의 참조 자리를 모두 `move` — 단가산출(715-2) = 바탕 · 더한줄 · 그 밖 = 담은로직."""
|
||||
if doc.get("종류") != TITLE["sangeun"]:
|
||||
return {**doc, "담은로직": _moved(doc.get("담은로직"), move, depth)}
|
||||
base = doc.get("바탕")
|
||||
out = {**doc, "더한줄": _moved(doc.get("더한줄"), move, depth)}
|
||||
if isinstance(base, dict) and isinstance(base.get("참조"), dict):
|
||||
out["바탕"] = {"참조": move(base["참조"], depth)}
|
||||
return out
|
||||
|
||||
|
||||
def _spec_moved(doc: dict[str, Any], done: dict[str, Ref]) -> dict[str, Any]:
|
||||
@@ -377,150 +402,6 @@ def _moved(lines: Any, move: Callable[[Ref, int], Ref], depth: int) -> list[Any]
|
||||
]
|
||||
|
||||
|
||||
# ── 펼침(계산) ────────────────────────────────────────────────────────
|
||||
class Expand:
|
||||
"""참조 → `{이름, 단위, 기준(1 단위), 까닭, 안?}` — 한 번 셈 안에서 키별 캐시 ·
|
||||
깊이 상한 · 순환은 까닭(통째로 멈추지 않음). `read` = 참조 → 문서(프로젝트는 굳힌 사본)."""
|
||||
|
||||
def __init__(self, whole: Any, read: Callable[[Ref], dict | None] = read_ref) -> None:
|
||||
self.whole, self.read, self.memo = whole, read, {}
|
||||
|
||||
def __call__(self, ref: Ref, trail: tuple[str, ...] = ()) -> dict[str, Any]:
|
||||
rid = ref_id(ref)
|
||||
if rid in trail:
|
||||
keys = [t.rsplit("|", 1)[-1] for t in (*trail, rid)]
|
||||
return self._stop(ref, "순환 — " + " → ".join(keys))
|
||||
if len(trail) >= DEPTH:
|
||||
return self._stop(ref, f"참조가 {DEPTH}단을 넘음")
|
||||
if rid not in self.memo:
|
||||
doc = self.read(ref)
|
||||
if not isinstance(doc, dict):
|
||||
return self._stop(ref, f"없는 참조 「{ref['키']}」")
|
||||
if kind_of(ref["키"]) == "promote":
|
||||
self.memo[rid] = self._promote(doc)
|
||||
else:
|
||||
self.memo[rid] = self._combo(doc, (*trail, rid))
|
||||
return self.memo[rid]
|
||||
|
||||
@staticmethod
|
||||
def _stop(ref: Ref, why: str) -> dict[str, Any]:
|
||||
return {"이름": ref["키"], "단위": None, "기준": None, "까닭": why}
|
||||
|
||||
def _promote(self, doc: dict) -> dict[str, Any]:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
line = (doc.get("담은로직") or [{}])[0]
|
||||
code = str(line.get("로직") or "")
|
||||
got = labor.run_line(
|
||||
self.whole, code, line.get("입력값"), line.get("켜쌓기율"), line.get("고름")
|
||||
)
|
||||
times = labor._number(doc.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
why = got.get("까닭") or (None if base else "돈 아닌 로직")
|
||||
return {
|
||||
"이름": doc.get("이름"),
|
||||
"단위": doc.get("단위"),
|
||||
"로직": line.get("로직"),
|
||||
"기준": {k: Decimal(str(v)) / times for k, v in base.items()} if base else None,
|
||||
"까닭": why,
|
||||
# 원 로직 줄(줄 누름 하위 행 · 51-2) — 1 단위로 나눔(하위 합 = 기준)
|
||||
"줄": [
|
||||
{
|
||||
**x,
|
||||
"수량": Decimal(str(x["수량"])) / times,
|
||||
"금액": Decimal(str(x["금액"])) / times,
|
||||
"비목": {k: Decimal(str(v)) / times for k, v in (x.get("비목") or {}).items()},
|
||||
}
|
||||
for x in got.get("줄") or []
|
||||
],
|
||||
}
|
||||
|
||||
def _logic(self, one: dict, pill: dict | None) -> dict[str, Any]:
|
||||
"""사전조합 안 로직 줄 — 일위대가 로직 줄과 같은 `run_line` · 배는 물량 알약 것."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
pill = pill or {}
|
||||
got = labor.run_line(
|
||||
self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
|
||||
)
|
||||
got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1)
|
||||
times = labor._number(pill.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
return {
|
||||
"이름": pill.get("이름") or one["로직"],
|
||||
"단위": pill.get("단위"),
|
||||
"기준": {k: Decimal(str(v)) / times for k, v in base.items()} if base else None,
|
||||
"까닭": got.get("까닭") or (None if base else "돈 아닌 로직"),
|
||||
}
|
||||
|
||||
def _combo(self, doc: dict, trail: tuple[str, ...]) -> dict[str, Any]:
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
lines = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)]
|
||||
kept = doc.get("알약") # 프로젝트 참조사본 = 가져온 때 굳힌 알약(142-2) · 층 = 지금 M01
|
||||
pills = kept if isinstance(kept, list) else None
|
||||
by_logic = {
|
||||
code: pill
|
||||
for pill in pills or combo_store.combo_pills({"담은로직": lines})["물량"]
|
||||
for code in pill["로직"]
|
||||
}
|
||||
inner, rows, why = [], [], None
|
||||
for one in lines:
|
||||
ref = one.get("참조")
|
||||
if isinstance(ref, dict):
|
||||
head, sub = {"참조": ref}, self(ref, trail)
|
||||
elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음)
|
||||
head, sub = {"로직": one["로직"]}, self._logic(one, by_logic.get(one["로직"]))
|
||||
elif fixed.is_fixed(one): # 고정 줄(705-3) = 줄 단가 · 비율 줄 = 끝에(`ratio_row`)
|
||||
head, sub = {"고정": one["고정"]}, fixed.line(self.whole, one["고정"])
|
||||
else:
|
||||
continue
|
||||
qty = labor._number((one.get("수량") or {}).get("손"))
|
||||
qty = Decimal(1) if qty is None else qty
|
||||
keep = ("이름", "단위", "기준", "까닭")
|
||||
inner.append({**head, "수량": qty, **{k: sub.get(k) for k in keep}})
|
||||
if sub.get("기준") is None:
|
||||
why = why or f"「{sub.get('이름')}」 {sub.get('까닭')}"
|
||||
continue
|
||||
rows.append({k: v * qty for k, v in sub["기준"].items()})
|
||||
for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 손 · 빈칸 = 안 셈
|
||||
qty = labor._number((mat.get("수량") or {}).get("손"))
|
||||
if qty is None or labor.apart(mat): # 별도계상 = 안 셈 · 안 줄에도 없음(222-1)
|
||||
continue
|
||||
got = labor.material_price(self.whole, mat["코드"], mat.get("단위"))
|
||||
price = labor._number(got["기준단가"])
|
||||
zero = dict.fromkeys(labor.COSTS, Decimal(0))
|
||||
base = None if price is None else {**zero, "재료비": price, "계": price}
|
||||
name = mat.get("이름") or mat["코드"]
|
||||
inner.append(
|
||||
{
|
||||
"자재": mat["코드"],
|
||||
"수량": qty,
|
||||
"이름": name,
|
||||
"단위": mat.get("단위"),
|
||||
"기준": base,
|
||||
"까닭": got["까닭"],
|
||||
}
|
||||
)
|
||||
if base is None:
|
||||
why = why or f"「{name}」 {got['까닭']}"
|
||||
continue
|
||||
rows.append({k: v * qty for k, v in base.items()})
|
||||
under = labor._sum(rows)
|
||||
for one in inner: # 비율 고정 줄(705-3) — 밑 = 다른 줄 합
|
||||
if (one.get("고정") or {}).get("비율"):
|
||||
one["기준"], row = fixed.ratio_row(one["고정"]["비율"], one["수량"], under)
|
||||
rows.append(row)
|
||||
return {
|
||||
"이름": doc.get("이름"),
|
||||
"단위": doc.get("단위"),
|
||||
"기준": None if why else labor._sum(rows),
|
||||
"까닭": why,
|
||||
"안": inner,
|
||||
}
|
||||
|
||||
|
||||
def pill(ref: Ref, read: Callable[[Ref], dict | None] = read_ref) -> dict[str, Any]:
|
||||
"""참조 줄 면적산출 알약 — 키 = `ref_id` · 이름 · 단위 = 가리킨 문서 것."""
|
||||
doc = read(ref) or {}
|
||||
@@ -539,17 +420,16 @@ def frozen(doc: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
(원본이 바뀌어도 프로젝트는 그대로)."""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def walk(lines: Any, depth: int) -> None:
|
||||
for one in lines or []:
|
||||
ref = one.get("참조") if isinstance(one, dict) else None
|
||||
if not isinstance(ref, dict) or ref_id(ref) in out or depth > DEPTH:
|
||||
def walk(found: list[Ref], depth: int) -> None:
|
||||
for ref in found:
|
||||
if ref_id(ref) in out or depth > DEPTH:
|
||||
continue
|
||||
got = read_ref(ref)
|
||||
if got is not None:
|
||||
out[ref_id(ref)] = with_pills(got)
|
||||
walk(got.get("담은로직"), depth + 1)
|
||||
walk(refs_in(got), depth + 1)
|
||||
|
||||
walk(doc.get("담은로직"), 1)
|
||||
walk(refs_in(doc), 1)
|
||||
return out
|
||||
|
||||
|
||||
@@ -582,3 +462,12 @@ def frozen_logics(copies: dict[str, Any]) -> list[str]:
|
||||
for line in doc.get("담은로직") or []
|
||||
if isinstance(line, dict) and line.get("로직")
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""`Expand` = 떼어 둔 `M02_Labor_Expand`(715-2) — 옛 이름 `refs.Expand` 그대로."""
|
||||
if name == "Expand":
|
||||
from M02_MasterTemplete.M02_Labor_Expand import Expand
|
||||
|
||||
return Expand
|
||||
raise AttributeError(name)
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) +
|
||||
일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) ·
|
||||
338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 ·
|
||||
올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`).
|
||||
올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`) ·
|
||||
718-1 한시 불러오기 = 올림 지문(끝낸 같은 엑셀 409 · 작업 중 같은 엑셀 = 그 파일 `이어서`) ·
|
||||
지우기 = [끝내기](HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가가 있으면 지문 기록 · 별칭 장부 남음).
|
||||
층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌).
|
||||
⚠ 등록은 `main.py`(로그인만).
|
||||
"""
|
||||
@@ -106,7 +108,7 @@ def _copy(kind: str, doc: dict[str, Any], source: refs.Here, ref: dict) -> dict[
|
||||
@router.post("/layers/{layer}/library/{kind}")
|
||||
def new_library(
|
||||
layer: Layer,
|
||||
kind: Literal["precombo"],
|
||||
kind: Literal["precombo", "sangeun"],
|
||||
body: LibraryBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
@@ -198,6 +200,16 @@ def _who(session: Session) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
|
||||
mark = hopyo_store.fingerprint(data)
|
||||
if old := hopyo_store.done(store.root())["지문"].get(mark):
|
||||
made = len(old.get("만든") or [])
|
||||
when = str(old.get("끝낸때") or "")[:10]
|
||||
raise store.StoreError(
|
||||
409,
|
||||
f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 일위대가 {made}) — 다시 만들지 않음",
|
||||
)
|
||||
if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None):
|
||||
return {**same, "이어서": True}
|
||||
try:
|
||||
parsed = hopyo_excel.read_book(name, data)
|
||||
except hopyo_excel.ExcelError as error:
|
||||
@@ -206,7 +218,7 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
|
||||
grid = hopyo_grid.read(name, data, parsed)
|
||||
except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404)
|
||||
grid = None
|
||||
row = hopyo_store.save(store.root(), name, parsed, who, grid)
|
||||
row = hopyo_store.save(store.root(), name, parsed, who, grid, mark)
|
||||
if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None:
|
||||
ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림)
|
||||
return row
|
||||
@@ -441,10 +453,28 @@ def hopyo_ledger_write(
|
||||
def hopyo_delete(
|
||||
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
"""실무자료 지우기(만든 사전조합 · 맞춤 기억은 그대로)."""
|
||||
"""실무자료 지우기 = 불러오기 [끝내기](718-1) — HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가
|
||||
(이 층 확인된 본뜬 「키 제N호표」)는 그대로 · 있으면 지문 기록(같은 엑셀 다시 올림 막음) ·
|
||||
별칭 장부 그대로 → `{지움, 만든}`."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
_call(folder, hopyo_store.delete, folder, key)
|
||||
return {"지움": key}
|
||||
return _call(folder, _finish, folder, key)
|
||||
|
||||
|
||||
def _finish(folder: Path, key: str) -> dict[str, Any]:
|
||||
head = f"{key} 제"
|
||||
made = sorted(
|
||||
{sc for mark, sc in hopyo_build.confirmed(folder).items() if mark.startswith(head)}
|
||||
)
|
||||
if (cache_path := _later("M02_Hopyo_Fit", "cache_path")) is not None:
|
||||
try:
|
||||
got = cache_path(folder, key)
|
||||
except (OSError, store.StoreError):
|
||||
got = None
|
||||
# ponytail: 뒤 셈이 도는 중이면 끝낸 뒤 캐시를 다시 쓸 수 있음 — tmp 캐시라 둠
|
||||
for old in got.parent.glob(f"{'_'.join(got.name.split('_')[:2])}_*.json") if got else []:
|
||||
old.unlink(missing_ok=True)
|
||||
hopyo_store.delete(folder, key, made)
|
||||
return {"지움": key, "만든": len(made)}
|
||||
|
||||
|
||||
class PasteBody(_Strict):
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Side.ts
|
||||
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 359-1 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
|
||||
* [파일 업로드](구조물 조합 층 단추 꼴 `m02-layers__btn` · 숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) +
|
||||
* 파일 줄 → 누르면 바로 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴 · 지우기 · 검산 · 셈 중은 창 머리) ·
|
||||
* 고름 표시 = 그 파일 창이 열린 동안 · 올린 직후도 엑셀 보기 창 ·
|
||||
* 파일 목록 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger`).
|
||||
* 좌측 「일위대가 불러오기」 컨테이너(PLAN 324-4 · 718-1 한시) — [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) →
|
||||
* 본 칸 마법사(`M02_MasterTemplete_Hopyo_Wizard` · 결과 층 고름 · 작업 중 이어서 · [끝내기] 때 올린 파일 지움) ·
|
||||
* 옛 파일 목록 없음(옛 HP 는 서버 목록에서 숨김) ·
|
||||
* 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger` · 층 = 구조물 조합 층).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { pageScope } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { buildHopyoLedger, LEDGER_HINT, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
|
||||
export const HOPYO_TITLE = "일위대가 실무자료";
|
||||
import { buildHopyoWizard } from "./M02_MasterTemplete_Hopyo_Wizard";
|
||||
|
||||
export interface HopyoSectionOptions {
|
||||
/** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 엑셀 보기 창 보기만 */
|
||||
/** 이 층 쓰기 권한 — 별칭 장부 고침 */
|
||||
canWrite: boolean;
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
@@ -30,122 +28,48 @@ export interface HopyoSection {
|
||||
body: HTMLElement;
|
||||
/** 다른 화면이 열렸으면 고름 표시를 지움 */
|
||||
paint: () => void;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const WIZARD = "불러오기";
|
||||
|
||||
export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
const title = L("M02_HopyoImport");
|
||||
const scope = pageScope();
|
||||
const list = el("div", { className: "m02-side__items ui-sidebar-list" });
|
||||
const input = el("input", {
|
||||
attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" },
|
||||
}) as HTMLInputElement;
|
||||
input.hidden = true;
|
||||
// 구조물 조합 층 단추(`TreePanel_Roots` [마스터])와 같은 꼴 — 보통 크기 · 같은 클래스
|
||||
const upload = createButton({
|
||||
label: "파일 업로드",
|
||||
const start = createButton({
|
||||
label: "불러오기",
|
||||
variant: "filled",
|
||||
disabled: !opt.canWrite,
|
||||
onClick: () => input.click(),
|
||||
onClick: () => void showView(WIZARD),
|
||||
});
|
||||
upload.classList.add("m02-layers__btn");
|
||||
upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음";
|
||||
start.classList.add("m02-layers__btn");
|
||||
start.title = "다른 프로그램 일위대가 엑셀 → 우리 일위대가(한시 · 끝내면 올린 파일 지움)";
|
||||
start.dataset.hopyoWizardOpen = "";
|
||||
const ledger = el("button", {
|
||||
className: "m02-side__item ui-sidebar-row",
|
||||
text: LEDGER_TITLE,
|
||||
attrs: { type: "button", title: LEDGER_HINT, "data-hopyo-ledger-open": "" },
|
||||
});
|
||||
ledger.addEventListener("click", () => void showLedger());
|
||||
ledger.addEventListener("click", () => void showView(LEDGER_TITLE));
|
||||
const body = el("div", {
|
||||
className: "m02-hopyo-side",
|
||||
children: [el("div", { className: "m02-side__row", children: [upload, input] }), list, ledger],
|
||||
children: [el("div", { className: "m02-side__row", children: [start] }), ledger],
|
||||
});
|
||||
let files: HopyoFile[] = [];
|
||||
let open: { key: string; view: HTMLElement } | null = null;
|
||||
let sheetKey: string | null = null; // 엑셀 보기 창이 열린 파일
|
||||
let seq = 0;
|
||||
|
||||
input.addEventListener("change", () => {
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
upload.disabled = true;
|
||||
uploadHopyo(scope, file)
|
||||
.then(async (got) => {
|
||||
const wrong = got.어긋남 ? ` · 검산 어긋남 ${got.어긋남}` : "";
|
||||
showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success");
|
||||
await refresh();
|
||||
await showSheet(got);
|
||||
})
|
||||
.catch((error) => showToast(why(error), "error"))
|
||||
.finally(() => (upload.disabled = !opt.canWrite));
|
||||
});
|
||||
|
||||
function paintList(): void {
|
||||
const paintList = (): void => {
|
||||
ledger.classList.toggle("is-active", open?.key === LEDGER_TITLE);
|
||||
list.replaceChildren(
|
||||
...(files.length
|
||||
? files.map((f) => {
|
||||
const row = el("button", {
|
||||
className: `m02-side__item ui-sidebar-row${sheetKey === f.키 ? " is-active" : ""}`,
|
||||
text: `${f.파일} · 호표 ${f.호표수}`,
|
||||
attrs: { type: "button", title: `${f.키} · ${f.올린때}`, "data-hopyo-key": f.키 },
|
||||
});
|
||||
row.addEventListener("click", () => void showSheet(f));
|
||||
return row;
|
||||
})
|
||||
: [createEmpty({ text: "올린 실무자료 없음", small: true })]),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** 파일 줄 누름 · 올린 직후 = 바로 엑셀 보기 창(본 칸은 그대로 · 열 때 불러옴) */
|
||||
async function showSheet(file: HopyoFile): Promise<void> {
|
||||
const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet").catch((error: unknown) => {
|
||||
showToast(why(error), "error");
|
||||
return null;
|
||||
});
|
||||
if (!sheet) return;
|
||||
sheetKey = file.키;
|
||||
paintList();
|
||||
sheet.openHopyoSheet({
|
||||
scope,
|
||||
key: file.키,
|
||||
파일: file.파일,
|
||||
// 사전조합은 어느 화면 층에서든 로그인한 사람 개인 층에 만듦 — 회사 · 마스터는 구조물 카드 [가져오기]
|
||||
target: { layer: "personal", owner: null },
|
||||
canWrite: true,
|
||||
onClose: () => {
|
||||
if (sheetKey === file.키) sheetKey = null;
|
||||
paintList();
|
||||
},
|
||||
// [삭제]는 실무자료 파일이 있는 화면 층 쓰기 권한 때만
|
||||
onDeleted: opt.canWrite ? () => void refresh() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function showLedger(): Promise<void> {
|
||||
if (open?.key === LEDGER_TITLE && open.view.isConnected) return;
|
||||
async function showView(key: string): Promise<void> {
|
||||
if (open?.key === key && open.view.isConnected) return;
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
const view = buildHopyoLedger({ scope, canWrite: opt.canWrite });
|
||||
opt.show(`${HOPYO_TITLE} — ${LEDGER_TITLE}`, view);
|
||||
const view =
|
||||
key === WIZARD ? buildHopyoWizard() : buildHopyoLedger({ scope, canWrite: opt.canWrite });
|
||||
opt.show(key === WIZARD ? title : `${title} — ${LEDGER_TITLE}`, view);
|
||||
opt.onOpen();
|
||||
open = { key: LEDGER_TITLE, view };
|
||||
open = { key, view };
|
||||
paintList();
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const mine = ++seq;
|
||||
try {
|
||||
const got = await listHopyo(scope);
|
||||
if (mine === seq) files = got;
|
||||
} catch (error) {
|
||||
if (mine === seq) files = [];
|
||||
showToast(why(error), "error");
|
||||
}
|
||||
if (mine === seq) paintList();
|
||||
}
|
||||
|
||||
const paint = (): void => {
|
||||
if (open && !open.view.isConnected) {
|
||||
open = null;
|
||||
@@ -153,6 +77,5 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
}
|
||||
};
|
||||
|
||||
void refresh();
|
||||
return { title: HOPYO_TITLE, body, paint, refresh };
|
||||
return { title, body, paint };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Wizard.ts
|
||||
* 일위대가 불러오기 = 한시 마법사(PLAN 718-1 · 요구서 내역12 4장 22) — 본 칸 한 화면 · 걸음 셋:
|
||||
* ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 · 단가산출 = 715-2 뒤 켬) · 엑셀 고르기 → 올림
|
||||
* (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 서버 409 안내) · 작업 중 파일 이어서
|
||||
* ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] →
|
||||
* 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층
|
||||
* ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 별칭 장부 남음 · 지문 기록
|
||||
* 작업 중 = 서버에 보관(떠났다 와도 ① 목록에서 이어서) · 옛 HP(지문 없음)는 서버 목록에서 숨김.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { createPanelHead } from "@ui/ui_template_panel_head";
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import {
|
||||
deleteHopyo,
|
||||
listHopyo,
|
||||
uploadHopyo,
|
||||
type HopyoFile,
|
||||
type HopyoScope,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer";
|
||||
|
||||
const LAYERS: { layer: ManageLayer; text: string }[] = [
|
||||
{ layer: "personal", text: "개인" },
|
||||
{ layer: "company", text: "회사" },
|
||||
{ layer: "system", text: "마스터" },
|
||||
];
|
||||
const STEPS = [
|
||||
{ value: "1", label: "① 층 · 엑셀" },
|
||||
{ value: "2", label: "② 보기 · 확인 · 만들기" },
|
||||
{ value: "3", label: "③ 끝내기" },
|
||||
];
|
||||
/** 단가산출 결과 — M02 단가산출 서고(715-2)가 들어오면 켬 */
|
||||
export const SANGEUN_READY = false;
|
||||
const SANGEUN_WHY = "M02 단가산출 칸(715-2)이 들어온 뒤 켬";
|
||||
|
||||
type Got = HopyoFile & { 이어서?: boolean };
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const textOf = (layer: string): string => LAYERS.find((x) => x.layer === layer)?.text ?? layer;
|
||||
|
||||
/** 쓸 수 있는 자기 층(개인 · 회사 · 마스터 차례) */
|
||||
export async function writableLayers(): Promise<ManageLayer[]> {
|
||||
const user = await fetchSessionUser().catch(() => null);
|
||||
return LAYERS.map((x) => x.layer).filter((layer) => accessFor(layer, null, user).canWrite);
|
||||
}
|
||||
|
||||
export function buildHopyoWizard(): HTMLElement {
|
||||
const steps = createSegment({
|
||||
options: STEPS.map((s) => ({ ...s, disabled: true })),
|
||||
value: "1",
|
||||
});
|
||||
steps.root.dataset.hopyoSteps = "";
|
||||
const body = el("div", { className: "m02-hopyo" });
|
||||
const root = el("div", {
|
||||
className: "m02-hopyo",
|
||||
attrs: { "data-hopyo-wizard": "" },
|
||||
children: [
|
||||
createText(
|
||||
"한시 — 다른 프로그램 일위대가 엑셀을 우리 일위대가로 옮김 · 끝내면 올린 파일 지움 · 별칭 장부는 남음",
|
||||
"hint",
|
||||
),
|
||||
el("div", { className: "ui-toolbar", children: [steps.root] }),
|
||||
body,
|
||||
],
|
||||
});
|
||||
let layers: ManageLayer[] = [];
|
||||
let pick: ManageLayer = "personal";
|
||||
const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null });
|
||||
|
||||
async function first(): Promise<void> {
|
||||
steps.set("1");
|
||||
layers = await writableLayers();
|
||||
if (!layers.includes(pick)) pick = layers[0] ?? "personal";
|
||||
const input = el("input", {
|
||||
attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" },
|
||||
}) as HTMLInputElement;
|
||||
input.hidden = true;
|
||||
const upload = createButton({
|
||||
label: "엑셀 고르기",
|
||||
variant: "filled",
|
||||
disabled: !layers.length,
|
||||
onClick: () => input.click(),
|
||||
});
|
||||
upload.title = layers.length ? "설계내역서 엑셀(일위대가 시트)" : "쓸 수 있는 층 없음";
|
||||
input.addEventListener("change", () => {
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
upload.disabled = true;
|
||||
uploadHopyo(scopeOf(pick), file)
|
||||
.then((got: Got) => {
|
||||
if (got.이어서) showToast(`같은 엑셀 작업 중 — ${got.키} 이어서`, "info");
|
||||
else showToast(`올림 — 호표 ${got.호표수}`, got.어긋남 ? "warning" : "success");
|
||||
second(pick, got);
|
||||
})
|
||||
.catch((error) => showToast(why(error), "error"))
|
||||
.finally(() => (upload.disabled = !layers.length));
|
||||
});
|
||||
const seg = createSegment({
|
||||
options: LAYERS.map((x) => ({
|
||||
value: x.layer,
|
||||
label: x.text,
|
||||
disabled: !layers.includes(x.layer),
|
||||
})),
|
||||
value: pick,
|
||||
onChange: (v) => (pick = v as ManageLayer),
|
||||
});
|
||||
seg.root.dataset.hopyoLayer = "";
|
||||
const ilwi = createCheckbox("일위대가(고정 줄)", { checked: true, disabled: true });
|
||||
const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", { disabled: !SANGEUN_READY });
|
||||
sangeun.root.title = SANGEUN_READY ? "" : SANGEUN_WHY;
|
||||
const busy = el("div", { className: "m02-hopyo" });
|
||||
body.replaceChildren(
|
||||
createText("결과 층", "muted", 14),
|
||||
el("div", { className: "ui-toolbar", children: [seg.root] }),
|
||||
createText("결과", "muted", 14),
|
||||
el("div", { className: "ui-toolbar", children: [ilwi.root, sangeun.root] }),
|
||||
...(SANGEUN_READY ? [] : [createText(`단가산출 — ${SANGEUN_WHY}`, "hint")]),
|
||||
el("div", { className: "ui-toolbar", children: [upload, input] }),
|
||||
createPanelHead({ title: "작업 중", level: 4, divider: true }).root,
|
||||
busy,
|
||||
);
|
||||
const rows = (
|
||||
await Promise.all(
|
||||
layers.map((layer) =>
|
||||
listHopyo(scopeOf(layer))
|
||||
.then((list) => list.map((f) => ({ layer, f })))
|
||||
.catch(() => []),
|
||||
),
|
||||
)
|
||||
).flat();
|
||||
busy.replaceChildren(
|
||||
...(rows.length
|
||||
? rows.map(({ layer, f }) => {
|
||||
const row = createButton({
|
||||
label: `${f.파일} · 호표 ${f.호표수} · ${textOf(layer)} — 이어서`,
|
||||
variant: "ghost",
|
||||
onClick: () => second(layer, f),
|
||||
});
|
||||
row.dataset.hopyoKey = f.키;
|
||||
return row;
|
||||
})
|
||||
: [createEmpty({ text: "작업 중인 불러오기 없음", small: true })]),
|
||||
);
|
||||
}
|
||||
|
||||
function second(layer: ManageLayer, file: HopyoFile): void {
|
||||
steps.set("2");
|
||||
const scope = scopeOf(layer);
|
||||
const view = createButton({
|
||||
label: "엑셀 보기 · 확인 · 만들기",
|
||||
variant: "filled",
|
||||
onClick: () =>
|
||||
void import("./M02_MasterTemplete_Hopyo_Sheet")
|
||||
.then((m) =>
|
||||
m.openHopyoSheet({
|
||||
scope,
|
||||
key: file.키,
|
||||
파일: file.파일,
|
||||
target: scope,
|
||||
canWrite: true,
|
||||
onDeleted: () => void first(),
|
||||
}),
|
||||
)
|
||||
.catch((error: unknown) => showToast(why(error), "error")),
|
||||
});
|
||||
const finish = createButton({ label: "끝내기", variant: "ghost", onClick: () => void end() });
|
||||
finish.dataset.hopyoFinish = "";
|
||||
const back = createButton({ label: "처음으로", variant: "link", onClick: () => void first() });
|
||||
back.title = "작업은 그대로 보관 — 처음 화면 「작업 중」에서 이어서";
|
||||
const wrong = file.어긋남 ? ` · 검산 어긋남 ${file.어긋남}` : "";
|
||||
body.replaceChildren(
|
||||
createText(`${file.파일} · ${textOf(layer)} 층 · 호표 ${file.호표수}${wrong}`, "muted", 14),
|
||||
createText(
|
||||
"엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가는 고른 층에 들어감",
|
||||
"hint",
|
||||
),
|
||||
el("div", { className: "ui-toolbar", children: [view, finish, back] }),
|
||||
);
|
||||
|
||||
async function end(): Promise<void> {
|
||||
steps.set("3");
|
||||
const ok = await showConfirmDialog(
|
||||
`「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가와 별칭 장부는 남음 · 일위대가를 만들었으면 같은 엑셀은 다시 올리지 못함`,
|
||||
"끝내기",
|
||||
);
|
||||
if (!ok) return steps.set("2");
|
||||
try {
|
||||
const got = (await deleteHopyo(scope, file.키)) as { 만든?: number };
|
||||
showToast(`끝냄 — 만든 일위대가 ${got.만든 ?? 0} · 올린 파일 지움`, "success");
|
||||
} catch (error) {
|
||||
showToast(why(error), "error");
|
||||
return steps.set("2");
|
||||
}
|
||||
await first();
|
||||
}
|
||||
}
|
||||
|
||||
void first();
|
||||
return root;
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells";
|
||||
import { PAGER_ROOM, pageKeepFirst, watchFit } from "@ui/ui_template_pager";
|
||||
import { dateText } from "../common_util/common_util_time";
|
||||
import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head";
|
||||
import "../M01_MasterData/M01_MasterData_UI_Style.css";
|
||||
@@ -160,6 +161,7 @@ export interface ItemListHandle {
|
||||
export function buildItemList(): ItemListHandle {
|
||||
let draw = (): void => {};
|
||||
let page = 1;
|
||||
let size = SIZE; // 붙은 뒤 화면 높이로 다시 셈
|
||||
const searchField = createInputField({ type: "search", placeholder: TEXT.search });
|
||||
const search = searchField.input;
|
||||
const grid = el("div", { className: "ui-table-wrap m01-master__grid-wrap" });
|
||||
@@ -176,9 +178,9 @@ export function buildItemList(): ItemListHandle {
|
||||
): void => {
|
||||
page = 1;
|
||||
draw = (): void => {
|
||||
const got = pageOf(rows, cols, search.value, page);
|
||||
const got = pageOf(rows, cols, search.value, page, size);
|
||||
page = got.page;
|
||||
const next = buildPager(got.total, SIZE, page, (p) => {
|
||||
const next = buildPager(got.total, size, page, (p) => {
|
||||
page = p;
|
||||
draw();
|
||||
});
|
||||
@@ -230,8 +232,23 @@ export function buildItemList(): ItemListHandle {
|
||||
});
|
||||
const root = el("div", {
|
||||
className: "m02-itemlist",
|
||||
children: [buildHeadFilters([searchField.root, pager]), grid],
|
||||
children: [buildHeadFilters([searchField.root]), grid, pager],
|
||||
});
|
||||
// 모달 안 목록 — 모달이 줄 수에 따라 움직이니 화면 위치 대신 높이 한도로 셈
|
||||
watchFit(
|
||||
root,
|
||||
() => grid,
|
||||
(rows) => {
|
||||
if (rows === size) return;
|
||||
page = pageKeepFirst(page, size, rows);
|
||||
size = rows;
|
||||
draw();
|
||||
},
|
||||
{
|
||||
cap: () => Math.min(window.innerHeight * 0.6, window.innerHeight - 300) + PAGER_ROOM,
|
||||
once: true,
|
||||
},
|
||||
);
|
||||
// 처음 = 빈 표 자리(줄이 오면 `show`)
|
||||
note(TEXT.loading);
|
||||
return { root, show, message: (text = TEXT.loading) => note(text) };
|
||||
|
||||
@@ -101,7 +101,7 @@ const TEXT = {
|
||||
Labor_LineApplied: ["반영(수량 곱함)", "Applied (× quantity)"],
|
||||
Labor_LineBase: ["기준", "Base"],
|
||||
Labor_FromLogic: ["원 로직", "Source logic"],
|
||||
Lib_Precombo: ["사전조합", "Pre-combination"],
|
||||
Lib_Precombo: ["일위대가", "Unit-cost item"],
|
||||
Lib_System: ["마스터", "Master"],
|
||||
Lib_Company: ["회사", "Company"],
|
||||
Lib_Personal: ["개인", "Personal"],
|
||||
@@ -156,13 +156,13 @@ const TEXT = {
|
||||
In_Close: ["닫기", "Close"],
|
||||
Lib_Name: ["이름", "Name"],
|
||||
// 서고 목록표(51-4)
|
||||
Lib_PrecomboAll: ["일위대가 사전조합", "Pre-combination"],
|
||||
Lib_None: ["사전조합 없음", "No pre-combination"],
|
||||
Lib_PrecomboAll: ["일위대가", "Unit-cost items"],
|
||||
Lib_None: ["일위대가 없음", "No unit-cost items"],
|
||||
Lib_Unsorted: ["미분류", "Unsorted"],
|
||||
Lib_NewHere: ["여기에 새 사전조합", "New pre-combination here"],
|
||||
Lib_NewHere: ["여기에 새 일위대가", "New unit-cost item here"],
|
||||
Lib_FolderFull: [
|
||||
"폴더 안에 사전조합 {n}개 — 먼저 옮기거나 지운 뒤 지움",
|
||||
"Folder holds {n} pre-combination(s) — move or delete them first",
|
||||
"폴더 안에 일위대가 {n}개 — 먼저 옮기거나 지운 뒤 지움",
|
||||
"Folder holds {n} unit-cost item(s) — move or delete them first",
|
||||
],
|
||||
Lib_FolderDelete: ["「{value}」 빈 폴더를 지움", "Delete empty folder “{value}”"],
|
||||
Lib_Create: ["생성", "Create"],
|
||||
|
||||
@@ -51,6 +51,14 @@ export function entryFromHash(user?: SessionUser | null): Entry {
|
||||
return { root, owner, open, company: query.get("company") || null, pre };
|
||||
}
|
||||
|
||||
/** 옛 주소 `open=옵션`(구조물 옵션 정의는 M01 로 옮김 · 716-1) — 시스템 관리자면 M01 그 칸으로 돌려보냄 ·
|
||||
* 돌려보냈으면 참(그 밖 역할은 읽기 화면이 없어져 구조물 조합으로 열림) */
|
||||
export function redirectOldOptions(user: SessionUser | null): boolean {
|
||||
if (user?.role !== "SYSTEM_ADMIN" || currentQuery().get("open") !== "옵션") return false;
|
||||
navigateTo(ROUTES.M01_MASTER_DATA, { open: "옵션" });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 진입 해시 인자 — `navigateTo(ROUTES.M02_MASTER_TEMPLATE, entryQuery(…))` */
|
||||
export const entryQuery = (entry: Entry): Record<string, string> => ({
|
||||
open: entry.open,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells";
|
||||
import { pageKeepFirst, watchFit } from "@ui/ui_template_pager";
|
||||
import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head";
|
||||
import "../M01_MasterData/M01_MasterData_UI_Style.css";
|
||||
import {
|
||||
@@ -103,6 +104,7 @@ export function mountLibraryList(
|
||||
let rows: Row[] = [];
|
||||
let picked: string | null = null;
|
||||
let page = 1;
|
||||
let size = SIZE; // 붙은 뒤 화면 높이로 다시 셈
|
||||
const searchField = createInputField({ type: "search", placeholder: tc("Search") });
|
||||
const body = el("tbody");
|
||||
let pager = buildPager(0, SIZE, 1, () => {});
|
||||
@@ -116,8 +118,8 @@ export function mountLibraryList(
|
||||
const draw = (): void => {
|
||||
const q = searchField.input.value.trim().toLowerCase();
|
||||
const shown = rows.filter((r) => !q || libraryLabel(r).toLowerCase().includes(q));
|
||||
page = Math.min(page, Math.max(1, Math.ceil(shown.length / SIZE)));
|
||||
const next = buildPager(shown.length, SIZE, page, (p) => {
|
||||
page = Math.min(page, Math.max(1, Math.ceil(shown.length / size)));
|
||||
const next = buildPager(shown.length, size, page, (p) => {
|
||||
page = p;
|
||||
draw();
|
||||
});
|
||||
@@ -125,7 +127,7 @@ export function mountLibraryList(
|
||||
pager = next;
|
||||
body.replaceChildren(
|
||||
...(shown.length
|
||||
? shown.slice((page - 1) * SIZE, page * SIZE).map((row) => {
|
||||
? shown.slice((page - 1) * size, page * size).map((row) => {
|
||||
const radio = el("input", {
|
||||
attrs: { type: "radio", name: `m02-lib-${opt.kind}`, "aria-label": row.이름 },
|
||||
}) as HTMLInputElement;
|
||||
@@ -252,25 +254,34 @@ export function mountLibraryList(
|
||||
"Lib_Base",
|
||||
"Lib_Date",
|
||||
] as const;
|
||||
host.replaceChildren(
|
||||
buildHeadFilters([searchField.root, pager]),
|
||||
el("div", {
|
||||
className: "ui-table-wrap m01-master__grid-wrap",
|
||||
attrs: { "data-lib-list": opt.kind },
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-table m01-master__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", { children: heads.map((h) => el("th", { text: h ? tc(h) : "" })) }),
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
const gridBox = el("div", {
|
||||
className: "ui-table-wrap m01-master__grid-wrap",
|
||||
attrs: { "data-lib-list": opt.kind },
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-table m01-master__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", { children: heads.map((h) => el("th", { text: h ? tc(h) : "" })) }),
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
host.replaceChildren(buildHeadFilters([searchField.root]), gridBox, pager);
|
||||
watchFit(
|
||||
host,
|
||||
() => gridBox,
|
||||
(rows) => {
|
||||
if (rows === size) return;
|
||||
page = pageKeepFirst(page, size, rows);
|
||||
size = rows;
|
||||
draw();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
if (opt.canEdit)
|
||||
tools.replaceChildren(
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
entryQuery,
|
||||
layerOfRoot,
|
||||
ownOwner,
|
||||
redirectOldOptions,
|
||||
viewCompany,
|
||||
type OpenTarget,
|
||||
type Root,
|
||||
@@ -61,6 +62,8 @@ const NEW_ASK =
|
||||
|
||||
export async function renderM02MasterTemplate(root: HTMLElement): Promise<MountedPage> {
|
||||
const user = await fetchSessionUser().catch(() => null);
|
||||
// 구조물 옵션 정의는 M01 로 옮김(716-1) — 옛 주소는 M01 그 칸으로
|
||||
if (redirectOldOptions(user)) return { covers: () => false, show: () => {}, dispose: () => {} };
|
||||
const entry = entryFromHash(user); // 134-4 — 해시에 층 없음 = 개인(회사 없으면 마스터)
|
||||
const company = viewCompany(entry, user); // 볼 회사(시스템 관리자는 고른 회사)
|
||||
let layer = layerOfRoot(entry.root);
|
||||
@@ -283,7 +286,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 ·
|
||||
// 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
|
||||
if (entry.open !== "구조물조합") side.reveal(entry.open);
|
||||
mountSettingsDrawer(user ?? {}, entry.open, entry.company); // ☰ 시스템 설정 바로가기(334)
|
||||
mountSettingsDrawer(user ?? {}, "m02", entry.company); // ☰ 시스템 설정 바로가기(334)
|
||||
void tree.refresh();
|
||||
// 이 화면을 떠나면 층 길을 시스템 · 자기 층으로 되돌림(다른 화면의 항목 고르기가 남의 층을 안 부르게)
|
||||
return { covers: () => false, show: () => {}, dispose: () => setActiveLayer("system") };
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「일위대가 사전조합」(층 단추 따로 · 2단 폴더 나무 ·
|
||||
* `UI_Side_Precombo` · 50-3 · 134-3) · 「일위대가 실무자료」(구조물 조합 층 · `Hopyo_Side` · 324-4) ·
|
||||
* 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 마스터 템플릿 좌측 패널 차례(716-3) — 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` ·
|
||||
* 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
|
||||
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기) › 실무문서. 「단가산출」 칸은 맨 위(717-2a).
|
||||
* 구조물 옵션 정의는 M01 로 옮김(716-1).
|
||||
* 서버 길은 계약 `6_계약.md`.
|
||||
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
|
||||
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
|
||||
@@ -30,17 +31,13 @@ import {
|
||||
type TemplateInfo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
|
||||
import { buildOptionsSection } from "./StructOptions/M02_SO_Options";
|
||||
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
|
||||
import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import {
|
||||
buildPrecomboSection,
|
||||
PRECOMBO_TITLE,
|
||||
type PrecomboOptions,
|
||||
} from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import { buildPrecomboSection, type PrecomboOptions } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { createDivider } from "@ui/ui_template_divider";
|
||||
|
||||
const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
{ kind: "table", label: () => L("M02_KindTable") },
|
||||
@@ -68,7 +65,7 @@ export interface SideOptions {
|
||||
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
|
||||
tree?: HTMLElement[];
|
||||
/** 「일위대가 사전조합」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */
|
||||
/** 「일위대가」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */
|
||||
precombo: Pick<PrecomboOptions, "scope" | "bar" | "more">;
|
||||
}
|
||||
|
||||
@@ -77,7 +74,7 @@ export interface SideHandle {
|
||||
refresh: () => Promise<void>;
|
||||
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
|
||||
paint: () => void;
|
||||
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */
|
||||
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침 · 도면은 시스템 층에만 · 옵션은 M01 로 옮겨 아무 일 없음 */
|
||||
reveal: (target: "옵션" | "도면" | "일위대가조합") => void;
|
||||
/** 사전조합 층이 바뀜(134-3) — 그 컨테이너만 다시 그림 */
|
||||
refreshPrecombo: () => Promise<void>;
|
||||
@@ -120,13 +117,6 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
del.addEventListener("click", () => void deleteCurrent());
|
||||
return [el("div", { className: "m02-side__row", children: [btnNew, copy, del] })];
|
||||
};
|
||||
// 구조물 옵션 보이기 — 구조물 집계 양식 아래 별도 줄 하나(누르면 메인 칸에 종류별 옵션 체크)
|
||||
const options = buildOptionsSection({
|
||||
isAdmin: canEdit(layer, opt.isAdmin),
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: () => (practice?.clear(), paint()),
|
||||
});
|
||||
/** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */
|
||||
const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => {
|
||||
sec.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
|
||||
@@ -143,27 +133,24 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
openOnExpand(summary, async () => {
|
||||
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
|
||||
});
|
||||
// 일위대가 사전조합 서고(50-3 · 134-3) — 구조물 조합 바로 아래 · 층은 페이지 층과 따로
|
||||
// 일위대가 서고(50-3 · 134-3) — 맨 위 칸 · 층은 페이지 층과 따로
|
||||
const precombo = buildPrecomboSection({
|
||||
...opt.precombo,
|
||||
confirmLeave: opt.confirmLeave,
|
||||
onOpen: opt.onOpen,
|
||||
getOpen: opt.getOpen,
|
||||
});
|
||||
const precomboSec = section(PRECOMBO_TITLE, precombo.body);
|
||||
// 실무자료로 사전조합을 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝
|
||||
const precomboSec = section(L("M02_PrecomboTitle"), precombo.body);
|
||||
// 실무자료로 일위대가를 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝
|
||||
const onBuilt = (): void =>
|
||||
precombo.body.isConnected
|
||||
? void precombo.refresh()
|
||||
: document.removeEventListener(HOPYO_BUILT, onBuilt);
|
||||
document.addEventListener(HOPYO_BUILT, onBuilt);
|
||||
// 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2)
|
||||
const extras =
|
||||
// 도면 양식은 시스템 층(마스터 템플릿)에만
|
||||
const drawing =
|
||||
layer === "system"
|
||||
? {
|
||||
옵션: section(options.title, options.body),
|
||||
도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
|
||||
}
|
||||
? section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing"))
|
||||
: null;
|
||||
// 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만
|
||||
const practice =
|
||||
@@ -171,23 +158,24 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
? buildPracticeSection({
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: () => (options.clear(), paint()),
|
||||
onOpen: paint,
|
||||
})
|
||||
: null;
|
||||
// 일위대가 실무자료(324-4) — 사전조합 아래 · 층 = 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐
|
||||
// 일위대가 불러오기(324-4 · 제목은 718-1) · 층 = 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐
|
||||
const hopyo = buildHopyoSection({
|
||||
canWrite: canEdit(layer, opt.isAdmin),
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: () => (options.clear(), practice?.clear(), paint()),
|
||||
onOpen: () => (practice?.clear(), paint()),
|
||||
});
|
||||
const listHost = el("div", {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
children: [
|
||||
summary,
|
||||
precomboSec,
|
||||
summary,
|
||||
createDivider(),
|
||||
section(hopyo.title, hopyo.body),
|
||||
...(extras ? [extras.옵션, extras.도면].filter((s): s is HTMLElement => !!s) : []),
|
||||
...(drawing ? [drawing] : []),
|
||||
...(practice ? [section(practice.title, practice.body)] : []),
|
||||
],
|
||||
});
|
||||
@@ -237,14 +225,13 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind;
|
||||
}
|
||||
|
||||
/** 고름 표시는 여기서만 — 목록 · 옵션 줄을 함께 그려야 두 줄이 동시에 안 남음
|
||||
* (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */
|
||||
/** 고름 표시는 여기서만 — 목록 · 실무문서 줄을 함께 그려야 두 줄이 동시에 안 남음
|
||||
* (양식이 열려 있으면 실무문서 고름은 지움) */
|
||||
function paint(): void {
|
||||
paintList();
|
||||
precombo.paint();
|
||||
hopyo.paint();
|
||||
paintButtons();
|
||||
if (opt.getOpen()) options.clear();
|
||||
if (opt.getOpen()) practice?.clear();
|
||||
}
|
||||
|
||||
@@ -347,10 +334,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
|
||||
const reveal = (target: "옵션" | "도면" | "일위대가조합"): void => {
|
||||
const sec = target === "일위대가조합" ? precomboSec : extras?.[target];
|
||||
if (!sec) return; // 옵션 보이기는 시스템 관리자만
|
||||
sec.classList.remove("is-collapsed");
|
||||
if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();
|
||||
const sec = target === "일위대가조합" ? precomboSec : target === "도면" ? drawing : null;
|
||||
sec?.classList.remove("is-collapsed");
|
||||
};
|
||||
|
||||
void refresh();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side_Precombo.ts
|
||||
* 좌측 「일위대가 사전조합」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 ·
|
||||
* 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 사전조합 나무.
|
||||
* 좌측 「일위대가」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 ·
|
||||
* 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 일위대가 나무.
|
||||
* 나무 = 자유 폴더(158-1 · 구조물 조합 탐색기와 같은 꼴 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) —
|
||||
* 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 사전조합] · [이름 바꾸기] · [지우기](빈 폴더만) · 빈 자리 오른쪽
|
||||
* 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 일위대가] · [이름 바꾸기] · [지우기](빈 폴더만) · 빈 자리 오른쪽
|
||||
* 클릭 = 뿌리 메뉴 · 문서 · 폴더 끌어 옮기기. 폴더 자리만 서버(`precombo-tree`)에 적음 — 문서 파일은 안 옮김.
|
||||
* 구분 · 상세구분은 문서 칸(상세 머리 · 고르기 창 거름 · `precomboClasses`) — 나무 폴더와 무관.
|
||||
* 문서 누름 = 편집 칸(UI_Main 종류 precombo = 일위대가 상세 모드) · 켜진 층 단추 다시 누름 = 목록표
|
||||
@@ -42,7 +42,6 @@ import { createEmpty } from "@ui/ui_template_empty";
|
||||
|
||||
export type PrecomboKind = Extract<Kind, "precombo">;
|
||||
|
||||
export const PRECOMBO_TITLE = "일위대가 사전조합";
|
||||
export const isPrecombo = (kind: Kind): kind is PrecomboKind => kind === "precombo";
|
||||
|
||||
/** 여러 구조물에 쓰는 묶음(콘크리트 · 거푸집 등) — 군마다 첫 상세구분(134-5) */
|
||||
@@ -183,7 +182,7 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
|
||||
}
|
||||
};
|
||||
|
||||
/** [여기에 새 사전조합] — 뿌리에 생성 → 폴더면 그 자리로 옮김 → 나무 다시 · 상세 엶 */
|
||||
/** [여기에 새 일위대가] — 뿌리에 생성 → 폴더면 그 자리로 옮김 → 나무 다시 · 상세 엶 */
|
||||
const make = (path: string[]): void => {
|
||||
const at = opt.scope();
|
||||
nameDialog(tc("Lib_NewHere"), "", async (name) => {
|
||||
@@ -303,7 +302,7 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
|
||||
paint();
|
||||
};
|
||||
|
||||
// 빈 자리 오른쪽 클릭 = 뿌리 메뉴(새 폴더 · 새 사전조합)
|
||||
// 빈 자리 오른쪽 클릭 = 뿌리 메뉴(새 폴더 · 새 일위대가)
|
||||
treeHost.addEventListener("contextmenu", (event) => {
|
||||
if (!view || (event.target as HTMLElement).closest("[data-node]")) return;
|
||||
event.preventDefault();
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""M02 단가산출(SK) 셈 — 바탕 사슬 → 고친 줄 → M01 엔진 한 벌(PLAN 715-1b · 715-2).
|
||||
|
||||
셈 = ① 바탕(M01 로직 · 바탕 SK 의 고친 줄 · 없음) ② `M02_Sangeun_Doc.patched` ③ 더한줄 참조 ·
|
||||
(SK · SC) · 고정 줄 = 먼저 셈한 1 단위 노 · 재 · 경 → `값묶음`
|
||||
④ M01 색인 위에 고친 줄을 SK 키로 얹어
|
||||
(M01 시험 계산 `_swap` 과 같은 꼴 · 메모리만 · 정본 · 캐시 안 고침) `labor.run_line` — 자동값 ·
|
||||
켜쌓기율 · 고름 · % 덧줄을 엔진이 다시 셈. 1 단위 = 기준 ÷ 배(바탕 로직 결과단위 앞 수).
|
||||
계산 자리(CLAUDE.md 5장 ③) = 서버 단독 · 화면은 셈 길(저장 안 함 · 최신 응답만).
|
||||
저장(`clean_doc`) = 꼴 검사 + 참조 위 층 · 있는 문서 · 순환 422 + 서버가 `원값` · `바탕판` 적음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
COSTS = labor.COSTS
|
||||
Ref = dict[str, Any]
|
||||
|
||||
|
||||
class _Over:
|
||||
"""M01 색인 위에 고친 줄 하나 — `logic(키)` 만 가로챔(그 밖 그대로 · 색인 안 고침)."""
|
||||
|
||||
def __init__(self, whole: Any, key: str, row: dict) -> None:
|
||||
self._whole, self._key, self._row = whole, key, row
|
||||
|
||||
def logic(self, ref: str) -> dict:
|
||||
return self._row if ref == self._key else self._whole.logic(ref)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._whole, name)
|
||||
|
||||
|
||||
def _fail(e: sd.DocError) -> store.StoreError:
|
||||
return store.StoreError(e.status, str(e))
|
||||
|
||||
|
||||
def times_of(row: dict) -> Decimal:
|
||||
"""결과단위 분모 앞 수(원/10㎡ → 10) — 없으면 1."""
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
|
||||
per = combo_store._PER.findall(str(row.get("결과단위") or ""))
|
||||
head = per[0][0].replace(",", "") if len(per) == 1 else ""
|
||||
return Decimal(head) if head else Decimal(1)
|
||||
|
||||
|
||||
def fingerprint(row: dict) -> str:
|
||||
raw = json.dumps(row, ensure_ascii=False, sort_keys=True, default=str)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
# ── 바탕 사슬 → 고친 줄 ─────────────────────────────────────────────────
|
||||
def _base_of(doc: dict, whole: Any, read: Any, trail: tuple[str, ...]) -> dict[str, Any]:
|
||||
"""이 SK 가 얹힐 바탕 — {줄, 고름, 자재표시, 입력값, 켜쌓기율, 짝잃음}."""
|
||||
base = doc.get("바탕")
|
||||
empty = {"고름": {}, "자재표시": {}, "입력값": {}, "켜쌓기율": None, "짝잃음": []}
|
||||
if not base:
|
||||
return {**empty, "줄": sd.empty_row(doc)}
|
||||
if "로직" in base:
|
||||
try:
|
||||
return {**empty, "줄": whole.logic(base["로직"])}
|
||||
except Exception as e: # noqa: BLE001 — 엔진 FormulaError
|
||||
raise sd.DocError(f"바탕 로직 · {e}") from e
|
||||
ref = base["참조"]
|
||||
rid = refs.ref_id(ref)
|
||||
if rid in trail:
|
||||
keys = [t.rsplit("|", 1)[-1] for t in (*trail, rid)]
|
||||
raise sd.DocError("순환 — " + " → ".join(keys), 422)
|
||||
if len(trail) >= refs.DEPTH:
|
||||
raise sd.DocError(f"바탕 사슬이 {refs.DEPTH}단을 넘음", 422)
|
||||
parent = read(ref)
|
||||
if not isinstance(parent, dict):
|
||||
raise sd.DocError(f"없는 바탕 「{ref.get('키')}」")
|
||||
up = resolve(parent, whole, read, (*trail, rid))
|
||||
lost = [{**x, "바탕": ref.get("키")} for x in up["짝잃음"]]
|
||||
return {**up, "짝잃음": lost}
|
||||
|
||||
|
||||
def resolve(doc: dict, whole: Any, read: Any = refs.read_ref, trail: tuple = ()) -> dict[str, Any]:
|
||||
"""SK 문서 → 고친 줄 `{줄, 고름, 자재표시, 정체, 짝잃음, 입력값, 켜쌓기율, 배, 바탕판}` ·
|
||||
`trail` = 지나온 참조 이름표(순환 · 깊이 422)."""
|
||||
base = _base_of(doc, whole, read, trail)
|
||||
got = sd.patched(base["줄"], doc, base["고름"], base["자재표시"])
|
||||
rate = doc.get("켜쌓기율", base["켜쌓기율"])
|
||||
return {
|
||||
**got,
|
||||
"짝잃음": base["짝잃음"] + got["짝잃음"],
|
||||
"입력값": {**base["입력값"], **(doc.get("입력값") or {})},
|
||||
"켜쌓기율": rate,
|
||||
"배": times_of(base["줄"]),
|
||||
"바탕판": fingerprint(base["줄"]),
|
||||
}
|
||||
|
||||
|
||||
def _bundles(row: dict, whole: Any, expand: Any, trail: tuple) -> str | None:
|
||||
"""더한줄 참조 · 고정 줄 `값묶음` = 1 단위 노 · 재 · 경 — 못 셈이면 까닭."""
|
||||
for item in row["호표"]:
|
||||
if item.get("값묶음") is not None or not ("참조" in item or "고정" in item):
|
||||
continue
|
||||
sub = expand(item["참조"], trail) if "참조" in item else fixed.line(whole, item["고정"])
|
||||
if sub.get("기준") is None:
|
||||
return f"「{sub.get('이름') or item.get('이름')}」 {sub.get('까닭')}"
|
||||
item["값묶음"] = {k: Decimal(str(sub["기준"][k])) for k in COSTS}
|
||||
item["이름"] = item.get("이름") or sub.get("이름") or ""
|
||||
item["단위"] = item.get("단위") or sub.get("단위") or ""
|
||||
return None
|
||||
|
||||
|
||||
def run(
|
||||
doc: dict,
|
||||
whole: Any,
|
||||
given: dict | None = None,
|
||||
read: Any = refs.read_ref,
|
||||
expand: Any = None,
|
||||
trail: tuple = (),
|
||||
) -> dict[str, Any]:
|
||||
"""SK 셈(저장 안 함) — `run_line` 답 + `{1단위, 배, 정체, 짝잃음, 바탕판}` · 줄마다 `cid` ·
|
||||
고친 칸 `덮개`(칸 이름) · `원값`. 틀린 문서 · 순환 = `sd.DocError`."""
|
||||
from M02_MasterTemplete.M02_Labor_Expand import Expand
|
||||
|
||||
got = resolve(doc, whole, read, trail)
|
||||
row = {**got["줄"], "호표": [dict(x) for x in got["줄"]["호표"]]}
|
||||
why = _bundles(row, whole, expand or Expand(whole, read), trail)
|
||||
head = {k: got[k] for k in ("정체", "짝잃음", "바탕판")} | {"배": got["배"]}
|
||||
if why:
|
||||
return {**head, "기준": None, "1단위": None, "줄": [], "까닭": why}
|
||||
key = str(doc.get("키") or "SK-000000")
|
||||
over = _Over(whole, key, {**row, "키": key})
|
||||
values = {**got["입력값"], **(given or {})}
|
||||
out = labor.run_line(over, key, values, got["켜쌓기율"], got["고름"] or None)
|
||||
covers = doc.get("덮개") or {}
|
||||
lines = []
|
||||
for cid, line in zip(got["정체"], out.get("줄") or []):
|
||||
mine = covers.get(cid) or {}
|
||||
mark = {"덮개": [k for k in sd.COVER_KEYS if k in mine]} if mine else {}
|
||||
lines.append(
|
||||
{**line, "cid": cid, **mark, **({"원값": mine["원값"]} if "원값" in mine else {})}
|
||||
)
|
||||
out = labor.drop_apart({**out, "줄": lines}, got["자재표시"])
|
||||
base = out.get("기준")
|
||||
unit = {k: Decimal(str(v)) / got["배"] for k, v in base.items()} if base else None
|
||||
return {**out, **head, "1단위": unit}
|
||||
|
||||
|
||||
def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]:
|
||||
"""펼침(`Expand`) 한 칸 — `{이름, 단위, 기준(1 단위), 까닭, 줄, 짝잃음}` · 못 셈 = 까닭."""
|
||||
head = {"이름": doc.get("이름"), "단위": doc.get("단위")}
|
||||
try:
|
||||
got = run(doc, whole, None, expand.read, expand, trail)
|
||||
except sd.DocError as e:
|
||||
return {**head, "기준": None, "까닭": str(e)}
|
||||
why = got.get("까닭") or (None if got.get("1단위") else "돈 아닌 로직")
|
||||
times = got["배"]
|
||||
lines = [
|
||||
{
|
||||
**x,
|
||||
"수량": None if x.get("수량") is None else Decimal(str(x["수량"])) / times,
|
||||
"금액": None if x.get("금액") is None else Decimal(str(x["금액"])) / times,
|
||||
}
|
||||
for x in got.get("줄") or []
|
||||
]
|
||||
return {**head, "기준": got["1단위"], "까닭": why, "줄": lines, "짝잃음": got["짝잃음"]}
|
||||
|
||||
|
||||
# ── 저장 ──────────────────────────────────────────────────────────────
|
||||
def _check_refs(doc: dict, here_: refs.Here) -> None:
|
||||
me = {"층": here_[0], "주인": here_[1], "키": doc["키"]}
|
||||
base = doc.get("바탕") or {}
|
||||
if "참조" in base:
|
||||
base["참조"] = refs.check_ref(base["참조"], "바탕", here_)
|
||||
if refs.kind_of(base["참조"]["키"]) != sd.KIND:
|
||||
raise store.StoreError(400, "바탕 참조는 단가산출(SK)만")
|
||||
for at, one in enumerate(doc.get("더한줄") or [], 1):
|
||||
if "참조" in one:
|
||||
one["참조"] = refs.check_ref(one["참조"], f"더한줄 {at}째", here_)
|
||||
for ref in refs.refs_in(doc):
|
||||
if refs.ref_id(ref) == refs.ref_id(me):
|
||||
raise store.StoreError(422, f"순환 — 자기 자신을 담음 「{doc['키']}」")
|
||||
refs._no_loop(ref, [me, ref])
|
||||
|
||||
|
||||
def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]:
|
||||
"""저장 꼴(`Labor_Ref.clean_doc` 가 부름) — 검사 · 참조 · 순환 · 서버가 `원값` · `바탕판`."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
|
||||
whole = m01.loaded()[1]
|
||||
try:
|
||||
rough = sd.clean(doc, None, whole)
|
||||
_check_refs(rough, here_)
|
||||
base = _base_of(rough, whole, refs.read_ref, ())
|
||||
out = sd.clean(doc, base["줄"], whole)
|
||||
out["바탕"], out["더한줄"] = rough["바탕"], rough["더한줄"]
|
||||
if out["덮개"]:
|
||||
bare = run({**out, "덮개": {}}, whole) # 덮기 전 값 = 원값(그때 M01)
|
||||
was = {x["cid"]: x for x in bare.get("줄") or []}
|
||||
for cid, one in out["덮개"].items():
|
||||
if cid in was:
|
||||
one["원값"] = _was(was[cid])
|
||||
out["바탕판"] = fingerprint(base["줄"])
|
||||
except sd.DocError as e:
|
||||
raise _fail(e) from e
|
||||
return out
|
||||
|
||||
|
||||
def _was(line: dict) -> dict[str, Any]:
|
||||
keep = {"qty": line.get("율", line.get("수량")), "단가": line.get("단가")}
|
||||
return {k: sd.plain(Decimal(str(v))) for k, v in keep.items() if v is not None}
|
||||
|
||||
|
||||
def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]:
|
||||
"""셈 길(`M02_Sangeun_Router`) — 지금 층 문서 · 저장 전 화면 문서 · 틀림 400 · 순환 422."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
|
||||
whole = m01.loaded()[1]
|
||||
try:
|
||||
clean = sd.clean(doc, None, whole)
|
||||
_check_refs(clean, refs.here()) # 저장 전 문서도 위 층만 · 있는 문서 · 순환 422
|
||||
me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]})
|
||||
got = run(clean, whole, given, trail=(me,))
|
||||
except sd.DocError as e:
|
||||
raise _fail(e) from e
|
||||
return labor._json(got)
|
||||
@@ -0,0 +1,387 @@
|
||||
"""M02 단가산출(SK) 문서 — 꼴 · 검사 · 줄 정체 · 고친 줄(PLAN 715-1a · 요구서 내역12 4장 12 · 13).
|
||||
|
||||
문서 `{층}/sangeun/SK-######.json` = M01 로직에 연결 + 바꾼 것만 —
|
||||
{종류: "단가산출", 키, 이름, 단위(필수), 구분, 상세구분, 본뜬,
|
||||
바탕: {로직: GF…} | {참조: {층, 주인, 키: SK-…}} | null(바탕 없음 = 더한줄만),
|
||||
입력값 {이름: 값}, 열린입력 [이름](설계 때 정함 · 그 SK 품 덮개 = 배율만), 켜쌓기율?,
|
||||
고름 {cid: MT키}, 자재표시 {cid: {세 칸}},
|
||||
덮개 {cid: {qty | 배율, labor, material, expense, 원값}},
|
||||
더한줄 [{종류: 인력|재료|기계, 요소, 이름, 단위, 수량, 비목?, 단가?}
|
||||
| {종류: 로직, 로직, 입력값, 수량} | {참조 | 고정, 이름?, 단위?, 수량}],
|
||||
뺀줄 [cid], 바탕판}
|
||||
줄 정체 cid = `kind:키#n`(B09 702 덮개 꼴) — kind = labor · material · machine · logic · pct(덧줄) ·
|
||||
키 = 요소 키(로직 줄 = 부르는 GC 키 · 조건 재료 = 이름 · 덧줄 = 이름 · 더한줄 참조 = 그 키) ·
|
||||
n = 같은 머리 차례 · 바탕 줄(M01 로직 줄 · 바탕 SK 의 고친 줄)에서 셈 — 뺀줄 · 더한줄 뒤 같은 줄.
|
||||
고름 · 자재표시도 cid 열쇠(호표 차례 열쇠면 뺀줄 뒤 엉뚱한 줄에 붙음) → `patched` 가 차례로 바꿈.
|
||||
덮개 = 숫자만(식 안 받음 · 4-1 확정 4) — qty = 품 수 · 배율 = 품 × 율 · labor · material · expense =
|
||||
줄 단가(로직 줄 = 노 · 재 · 경 묶음) · 덧줄 = qty(율 %) · 배율만 · `원값` · `바탕판` = 저장 때
|
||||
서버가 셈(브라우저 값 안 받음). 짝 잃은 덮개(M01 개정으로 줄이 사라짐) = 지우지 않고 `짝잃음` 알림.
|
||||
엔진 입력 덧칸(715-1b · `master_pick`) = 줄 `단가`(수) · 로직 줄 `값묶음{노무비 · 재료비 · 경비}`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
KIND = "sangeun"
|
||||
TITLE = "단가산출"
|
||||
PREFIX = "SK"
|
||||
KEY = re.compile(r"^SK-\d{6}$")
|
||||
PARTS = {"labor": "노무비", "material": "재료비", "expense": "경비"}
|
||||
COVER_KEYS = ("qty", "배율", *PARTS)
|
||||
KINDS = {"인력": "labor", "재료": "material", "기계": "machine", "로직": "logic"}
|
||||
ADD_PART = {"인력": "노무비", "재료": "재료비", "기계": "경비"}
|
||||
_CID = re.compile(r"^(labor|material|machine|logic|pct):.*#\d+$")
|
||||
_CALL = re.compile(r"^\s*로직\(\s*([A-Z]{2}\d{6})")
|
||||
_PCT = re.compile(r"^(?P<x>.+)\*(?P<y>[^*]+)/\s*100\s*$", re.S) # 「X * Y / 100」
|
||||
_NAME = re.compile(r"^[\w가-힣]+$")
|
||||
|
||||
|
||||
class DocError(ValueError):
|
||||
def __init__(self, text: str, status: int = 400) -> None:
|
||||
super().__init__(text)
|
||||
self.status = status
|
||||
|
||||
|
||||
def _num(raw: Any, where: str) -> Decimal:
|
||||
try:
|
||||
got = Decimal(str(raw).replace(",", "").strip())
|
||||
except (InvalidOperation, TypeError):
|
||||
got = None
|
||||
if raw is None or isinstance(raw, bool) or got is None or not got.is_finite():
|
||||
raise DocError(f"{where} · 수가 아님 「{raw}」")
|
||||
return got
|
||||
|
||||
|
||||
def plain(value: Decimal) -> int | float:
|
||||
return int(value) if value == value.to_integral_value() else float(value)
|
||||
|
||||
|
||||
def text(value: Any) -> str:
|
||||
"""식 안에 넣을 수 글 — 지수 꼴 없이."""
|
||||
got = value if isinstance(value, Decimal) else Decimal(str(value))
|
||||
return format(got.normalize(), "f")
|
||||
|
||||
|
||||
# ── 줄 정체 ───────────────────────────────────────────────────────────
|
||||
def _head(item: dict, extra: bool) -> str:
|
||||
if extra:
|
||||
return f"pct:{item.get('이름') or ''}"
|
||||
kind = KINDS.get(str(item.get("종류")), "logic")
|
||||
elem = item.get("요소")
|
||||
if isinstance(item.get("참조"), dict):
|
||||
key = str(item["참조"].get("키") or "")
|
||||
elif isinstance(item.get("고정"), dict):
|
||||
key = str(item["고정"].get("이름") or "")
|
||||
elif kind == "logic":
|
||||
found = _CALL.match(str(elem or ""))
|
||||
key = found.group(1) if found else str(item.get("이름") or "")
|
||||
else:
|
||||
key = elem if isinstance(elem, str) else str(item.get("이름") or "")
|
||||
return f"{kind}:{key}"
|
||||
|
||||
|
||||
def line_ids(row: dict) -> list[str]:
|
||||
"""로직 줄(호표 → 덧줄 차례)마다 cid — 같은 머리 n번째 = `#n`."""
|
||||
seen: dict[str, int] = {}
|
||||
out = []
|
||||
pairs = [(x, False) for x in row.get("호표") or []] + [(x, True) for x in row.get("덧줄") or []]
|
||||
for item, extra in pairs:
|
||||
head = _head(item, extra)
|
||||
seen[head] = seen.get(head, -1) + 1
|
||||
out.append(f"{head}#{seen[head]}")
|
||||
return out
|
||||
|
||||
|
||||
# ── 정리 · 검사 ───────────────────────────────────────────────────────
|
||||
def _cid(raw: Any, where: str) -> str:
|
||||
if not isinstance(raw, str) or not _CID.match(raw):
|
||||
raise DocError(f"{where} · 줄 정체는 kind:키#n 「{raw}」")
|
||||
return raw
|
||||
|
||||
|
||||
def _base(raw: Any) -> dict | None:
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, dict) and isinstance(raw.get("로직"), str) and raw["로직"].strip():
|
||||
return {"로직": raw["로직"].strip()}
|
||||
if isinstance(raw, dict) and isinstance(raw.get("참조"), dict):
|
||||
ref = raw["참조"]
|
||||
if not KEY.match(str(ref.get("키") or "")):
|
||||
raise DocError("바탕 참조 키는 SK-######")
|
||||
return {"참조": {"층": ref.get("층"), "주인": ref.get("주인"), "키": str(ref["키"])}}
|
||||
raise DocError("바탕은 {로직} · {참조} · null")
|
||||
|
||||
|
||||
def _given(raw: Any, where: str) -> dict:
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
raise DocError(f"{where} · 입력값이 묶음 아님")
|
||||
return {str(k): v for k, v in raw.items() if v not in (None, "")}
|
||||
|
||||
|
||||
def _cover(raw: Any, where: str, opened: bool) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
raise DocError(f"{where} · 덮개는 묶음")
|
||||
unknown = set(raw) - {*COVER_KEYS, "원값"}
|
||||
if unknown:
|
||||
raise DocError(f"{where} · 모르는 덮개 칸 {sorted(unknown)}")
|
||||
out = {k: plain(_num(raw[k], f"{where} {k}")) for k in COVER_KEYS if raw.get(k) is not None}
|
||||
if "qty" in out and "배율" in out:
|
||||
raise DocError(f"{where} · 품은 qty · 배율 중 하나")
|
||||
if opened and "qty" in out:
|
||||
raise DocError(f"{where} · 옵션을 열어 둔 단가산출은 품 배율만")
|
||||
if any(v < 0 for v in out.values()):
|
||||
raise DocError(f"{where} · 음수 덮개")
|
||||
if not out:
|
||||
raise DocError(f"{where} · 빈 덮개")
|
||||
return out
|
||||
|
||||
|
||||
def _added(raw: Any, where: str, whole: Any) -> dict:
|
||||
"""더한줄 하나 — 수량은 숫자(식 안 받음)."""
|
||||
if not isinstance(raw, dict):
|
||||
raise DocError(f"{where} · 묶음 아님")
|
||||
qty = plain(_num(raw.get("수량"), f"{where} 수량"))
|
||||
name, unit = str(raw.get("이름") or "").strip(), str(raw.get("단위") or "").strip()
|
||||
if isinstance(raw.get("참조"), dict):
|
||||
ref = raw["참조"]
|
||||
if not re.match(r"^(SK|SC)-\d{6}$", str(ref.get("키") or "")):
|
||||
raise DocError(f"{where} · 참조 키는 SK · SC")
|
||||
head: dict[str, Any] = {"참조": {k: ref.get(k) for k in ("층", "주인", "키")}}
|
||||
elif isinstance(raw.get("고정"), dict):
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
|
||||
try:
|
||||
head = {"고정": fixed.clean(raw["고정"], where, whole) if whole else raw["고정"]}
|
||||
except Exception as e: # noqa: BLE001 — 고정 줄 검사 = StoreError(400)
|
||||
raise DocError(str(getattr(e, "detail", e))) from e
|
||||
if head["고정"].get("비율"):
|
||||
raise DocError(f"{where} · 비율 고정 줄은 단가산출에 못 담음 — 덧줄 율로")
|
||||
elif raw.get("종류") == "로직":
|
||||
code = str(raw.get("로직") or "")
|
||||
row = _logic(whole, code, where)
|
||||
names = {str(s.get("이름")) for s in (row or {}).get("입력") or []}
|
||||
given = _given(raw.get("입력값"), where)
|
||||
head = {"종류": "로직", "로직": code, "입력값": given}
|
||||
if row is not None:
|
||||
head["입력값"] = {k: v for k, v in given.items() if k in names}
|
||||
name = name or str(row.get("이름") or code)
|
||||
elif raw.get("종류") in ADD_PART:
|
||||
key = str(raw.get("요소") or "")
|
||||
got = _element(whole, key, where)
|
||||
part = str(raw.get("비목") or ADD_PART[raw["종류"]])
|
||||
if part not in PARTS.values():
|
||||
raise DocError(f"{where} · 비목은 노무비 · 재료비 · 경비")
|
||||
head = {"종류": raw["종류"], "요소": key, "비목": part}
|
||||
if raw.get("단가") is not None:
|
||||
head["단가"] = plain(_num(raw["단가"], f"{where} 단가"))
|
||||
name = name or str((got or {}).get("이름") or key)
|
||||
unit = unit or str((got or {}).get("단위") or "")
|
||||
else:
|
||||
raise DocError(f"{where} · 종류는 인력 · 재료 · 기계 · 로직 · 참조 · 고정")
|
||||
return {**head, "이름": name, "단위": unit, "수량": qty}
|
||||
|
||||
|
||||
def _logic(whole: Any, code: str, where: str) -> dict | None:
|
||||
if whole is None:
|
||||
return None
|
||||
try:
|
||||
return whole.logic(code)
|
||||
except Exception as e: # noqa: BLE001 — 엔진 FormulaError(가져오기 둘레 피함)
|
||||
raise DocError(f"{where} · {e}") from e
|
||||
|
||||
|
||||
def _element(whole: Any, key: str, where: str) -> dict | None:
|
||||
if not key:
|
||||
raise DocError(f"{where} · 요소 키가 빔")
|
||||
if whole is None:
|
||||
return None
|
||||
try:
|
||||
return whole.get(key)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise DocError(f"{where} · 없는 요소 「{key}」") from e
|
||||
|
||||
|
||||
def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str, Any]:
|
||||
"""서버가 받는 꼴로 — 틀리면 `DocError`(400). `base_row` = 패치를 얹을 바탕 줄(입력 이름 ·
|
||||
열린입력 검사) · `whole` = M01(요소 · 로직 · 자재 키 검사). 짝 없는 cid 는 받음(셈 때 알림) ·
|
||||
`원값` · `바탕판` 은 서버 몫이라 버림."""
|
||||
if not isinstance(doc, dict):
|
||||
raise DocError("단가산출 문서가 묶음 아님")
|
||||
key = str(doc.get("키") or "")
|
||||
if not KEY.match(key):
|
||||
raise DocError("단가산출 키는 SK-######")
|
||||
name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip()
|
||||
if not name:
|
||||
raise DocError("단가산출 이름이 빔")
|
||||
if not unit:
|
||||
raise DocError("단가산출 단위가 빔 — 1 단위 값의 단위")
|
||||
base = _base(doc.get("바탕"))
|
||||
if base and "로직" in base and whole is not None and base_row is None:
|
||||
base_row = _logic(whole, base["로직"], "바탕")
|
||||
names = {str(s.get("이름")) for s in (base_row or {}).get("입력") or []}
|
||||
given = _given(doc.get("입력값"), "입력값")
|
||||
opened = [str(x) for x in doc.get("열린입력") or []]
|
||||
if base_row is not None:
|
||||
given = {k: v for k, v in given.items() if k in names}
|
||||
bad = [x for x in opened if x not in names or x in given]
|
||||
if bad:
|
||||
raise DocError(f"열린입력은 바탕 입력 중 값 안 넣은 것만 {bad}")
|
||||
out: dict[str, Any] = {"종류": TITLE, "키": key, "이름": name, "단위": unit, "바탕": base}
|
||||
out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")})
|
||||
out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None
|
||||
out["입력값"], out["열린입력"] = given, opened
|
||||
if doc.get("켜쌓기율") is not None:
|
||||
out["켜쌓기율"] = plain(_num(doc["켜쌓기율"], "켜쌓기율"))
|
||||
picks = {}
|
||||
for cid, mt in (doc.get("고름") or {}).items():
|
||||
mt = str(mt or "")
|
||||
if not mt.startswith("MT") or (whole is not None and _element(whole, mt, "고름") is None):
|
||||
raise DocError(f"고름 「{cid}」 · M01 자재품목 키 아님 「{mt}」")
|
||||
picks[_cid(cid, "고름")] = mt
|
||||
out["고름"] = picks
|
||||
out["자재표시"] = _marks(doc.get("자재표시"))
|
||||
out["덮개"] = {
|
||||
_cid(cid, "덮개"): _cover(one, f"덮개 「{cid}」", bool(opened))
|
||||
for cid, one in (doc.get("덮개") or {}).items()
|
||||
}
|
||||
out["더한줄"] = [
|
||||
_added(one, f"더한줄 {at}째", whole) for at, one in enumerate(doc.get("더한줄") or [], 1)
|
||||
]
|
||||
drop = [_cid(x, "뺀줄") for x in doc.get("뺀줄") or []]
|
||||
if len(set(drop)) != len(drop):
|
||||
raise DocError("뺀줄에 같은 줄 두 번")
|
||||
out["뺀줄"] = drop
|
||||
return out
|
||||
|
||||
|
||||
def _marks(raw: Any) -> dict[str, dict[str, str]]:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
out = {}
|
||||
for cid, one in (raw or {}).items() if isinstance(raw, dict) else ():
|
||||
got = labor.marks(one)
|
||||
if got:
|
||||
out[_cid(cid, "자재표시")] = got
|
||||
return out
|
||||
|
||||
|
||||
# ── 고친 줄 ───────────────────────────────────────────────────────────
|
||||
def _covered(item: dict, cover: dict, extra: bool) -> tuple[dict, str | None]:
|
||||
"""덮개 한 줄을 로직 줄에 — (고친 줄, 못 덮은 까닭)."""
|
||||
item = dict(item)
|
||||
if extra: # 덧줄 = 율(qty) · 배율만
|
||||
if any(k in cover for k in PARTS):
|
||||
return item, "덧줄은 율만 고침"
|
||||
formula = str(item.get("식") or "")
|
||||
if "배율" in cover:
|
||||
item["식"] = f"({formula}) * {text(cover['배율'])}"
|
||||
return item, None
|
||||
found = _PCT.match(formula)
|
||||
if not found:
|
||||
return item, "덧줄 식이 「X * 율 / 100」 꼴 아님"
|
||||
item["식"] = f"{found.group('x').strip()} * {text(cover['qty'])} / 100"
|
||||
return item, None
|
||||
if "qty" in cover:
|
||||
item["수량"] = text(cover["qty"])
|
||||
elif "배율" in cover:
|
||||
item["수량"] = f"({item.get('수량')}) * {text(cover['배율'])}"
|
||||
prices = {PARTS[k]: Decimal(str(cover[k])) for k in PARTS if k in cover}
|
||||
if not prices:
|
||||
return item, None
|
||||
if item.get("종류") == "로직":
|
||||
item["값묶음"] = {**dict(item.get("값묶음") or {}), **prices}
|
||||
return item, None
|
||||
part = str(item.get("비목") or "")
|
||||
if set(prices) != {part}:
|
||||
return item, f"비목 다름 — 이 줄은 {part or '비목 없음'}"
|
||||
item["단가"] = prices[part]
|
||||
return item, None
|
||||
|
||||
|
||||
def _add_item(one: dict) -> dict:
|
||||
"""더한줄 → 엔진 호표 줄 — 참조 · 고정 줄 `값묶음` 은 셈(`M02_Sangeun_Calc`)이 채움."""
|
||||
head = {"이름": one.get("이름") or "", "단위": one.get("단위") or ""}
|
||||
qty = text(one["수량"])
|
||||
if "참조" in one or "고정" in one:
|
||||
kept = {k: one[k] for k in ("참조", "고정") if k in one}
|
||||
return {**head, "종류": "로직", "요소": None, "수량": qty, **kept, "값묶음": None}
|
||||
if one.get("종류") == "로직":
|
||||
args = "".join(
|
||||
f", {k if _NAME.match(k) else repr(k)}="
|
||||
+ (text(v) if isinstance(v, (int, float, Decimal)) else f"'{v}'")
|
||||
for k, v in (one.get("입력값") or {}).items()
|
||||
)
|
||||
return {**head, "종류": "로직", "요소": f"로직({one['로직']}{args})", "수량": qty}
|
||||
item = {**head, "종류": one["종류"], "요소": one["요소"], "수량": qty, "비목": one["비목"]}
|
||||
return {**item, **({"단가": Decimal(str(one["단가"]))} if "단가" in one else {})}
|
||||
|
||||
|
||||
def patched(
|
||||
row: dict, doc: dict, picks: dict | None = None, shown: dict | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""바탕 줄(M01 로직 줄 · 바탕 SK 의 고친 줄) + SK 패치 → 고친 줄(순수 · 엔진 안 부름).
|
||||
`picks` · `shown` = 바탕 쪽 차례 열쇠 고름 · 자재표시(바탕 SK 사슬) → 새 차례로 옮김.
|
||||
돌려줌 = {줄, 고름 {차례: MT}, 자재표시 {차례: 칸}, 정체 [새 줄 cid · 더한줄 = `add:#k`],
|
||||
짝잃음 [{cid, 까닭}]}."""
|
||||
items, extras = list(row.get("호표") or []), list(row.get("덧줄") or [])
|
||||
ids = line_ids(row)
|
||||
covers, drop = doc.get("덮개") or {}, set(doc.get("뺀줄") or [])
|
||||
mine = doc.get("고름") or {}
|
||||
marks = doc.get("자재표시") or {}
|
||||
lost = [
|
||||
{"cid": c, "까닭": "뺀줄 — 바탕에 없는 줄"} for c in doc.get("뺀줄") or [] if c not in ids
|
||||
]
|
||||
new_items, new_extras, cids, out_picks, out_marks = [], [], [], {}, {}
|
||||
for at, cid in enumerate(ids):
|
||||
if cid in drop:
|
||||
continue
|
||||
extra = at >= len(items)
|
||||
item = extras[at - len(items)] if extra else items[at]
|
||||
if cid in covers:
|
||||
item, why = _covered(item, covers[cid], extra)
|
||||
if why:
|
||||
lost.append({"cid": cid, "까닭": why})
|
||||
if extra:
|
||||
new_extras.append(item)
|
||||
continue
|
||||
n = str(len(new_items))
|
||||
old = (picks or {}).get(str(at))
|
||||
if cid in mine and item.get("종류") != "재료":
|
||||
lost.append({"cid": cid, "까닭": "고름 — 재료 줄 아님"})
|
||||
elif cid in mine or old:
|
||||
out_picks[n] = mine.get(cid) or old
|
||||
if (shown or {}).get(str(at)) or cid in marks:
|
||||
out_marks[n] = marks.get(cid) or shown[str(at)]
|
||||
new_items.append(item)
|
||||
cids.append(cid)
|
||||
for k, one in enumerate(doc.get("더한줄") or []):
|
||||
new_items.append(_add_item(one))
|
||||
cids.append(f"add:#{k}")
|
||||
cids += [c for at, c in enumerate(ids) if at >= len(items) and c not in drop]
|
||||
for name, keys in (("덮개", covers), ("고름", mine), ("자재표시", marks)):
|
||||
lost += [{"cid": c, "까닭": f"{name} — 바탕에 없는 줄"} for c in keys if c not in ids]
|
||||
return {
|
||||
"줄": {**row, "호표": new_items, "덧줄": new_extras},
|
||||
"고름": out_picks,
|
||||
"자재표시": out_marks,
|
||||
"정체": cids,
|
||||
"짝잃음": lost,
|
||||
}
|
||||
|
||||
|
||||
def empty_row(doc: dict) -> dict[str, Any]:
|
||||
"""바탕 없는 단가산출의 바탕 줄 — 입력 · 줄 없음(더한줄만 셈)."""
|
||||
return {
|
||||
"키": doc.get("키"),
|
||||
"이름": doc.get("이름"),
|
||||
"결과단위": f"원/{doc.get('단위') or ''}",
|
||||
"입력": [],
|
||||
"중간": [],
|
||||
"호표": [],
|
||||
"덧줄": [],
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc`.
|
||||
|
||||
저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만.
|
||||
몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값).
|
||||
문서 읽기 · 쓰기 · 새로 · 복사 · 지우기는 양식 길 · 서고 길 그대로(종류 `sangeun`).
|
||||
⚠ 등록은 `main.py`(로그인만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Sangeun"])
|
||||
|
||||
|
||||
class SangeunCalcBody(BaseModel):
|
||||
문서: dict[str, Any] | None = None
|
||||
키: str | None = None
|
||||
입력값: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _calc(body: SangeunCalcBody) -> dict[str, Any]:
|
||||
doc = body.문서
|
||||
if doc is None:
|
||||
if not sd.KEY.match(str(body.키 or "")):
|
||||
raise store.StoreError(400, "문서 또는 키(SK-######)")
|
||||
doc = store.read(sd.KIND, str(body.키))["문서"]
|
||||
return calc.calc_doc(doc, body.입력값)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/sangeun/calc")
|
||||
def calc_sangeun(
|
||||
layer: Layer,
|
||||
body: SangeunCalcBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""단가산출 셈 — 줄(cid · 덮개 · 원값) · 기준 · 1단위 · 짝잃음(저장 안 함)."""
|
||||
if body.문서 is None and body.키 is None:
|
||||
raise HTTPException(status_code=400, detail="문서 또는 키")
|
||||
return _call(_root(session, layer, owner), _calc, body)
|
||||
@@ -29,7 +29,8 @@ LAYERS = ("system", "company", "personal", "project")
|
||||
# area = 면적산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`)
|
||||
# labor = 일위대가(구조물 열마다 · `M02_Labor_Doc`)
|
||||
# precombo = 사전조합(SC-) — 일위대가 사전조합 서고(`M02_Labor_Ref` · 옛 단가승격 promote 는 134-2 에서 뺌)
|
||||
LIBRARY_KINDS = ("precombo",)
|
||||
# sangeun = 단가산출(SK- · 715-2 · 꼴 `M02_Sangeun_Doc`)
|
||||
LIBRARY_KINDS = ("precombo", "sangeun")
|
||||
KINDS = ("table", "drawing", "structure", "labor", "area", *LIBRARY_KINDS)
|
||||
TEMPLATES_DIRNAME = "templates"
|
||||
INITIAL_DIRNAME = "_initial"
|
||||
@@ -174,6 +175,7 @@ _SKELETON = {
|
||||
"labor": ("담은로직",),
|
||||
"area": ("시트",),
|
||||
"precombo": ("담은로직",),
|
||||
"sangeun": ("더한줄",),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -404,3 +404,11 @@ table.m02-sod__table--wrap {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* M01 본 칸(716-1) — 정의 화면을 얹는 틀: 머리 줄 아래 화면 */
|
||||
.m02-sod__host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -102,3 +102,7 @@ B05 평면도 / D그룹 예정노선의 지도의 아이콘 셋 그룹화/위치
|
||||
## 요구 553 (11-01 · 사용자 · 데스크탑 서브에 시킬 일 검토)
|
||||
|
||||
데스크탑 서브 워크트리에 시킬 작업하나 검토 해보자. 지금 라이다 데이터를 입력할때 사용하는 로직이 내부 자체 코드로 구현되었어. 혹시 QGIS나 무료로 사용하는 프로그램 중에 사용할 수 있는게 뭐가 있는지 조사해보자. 자체코드도 좋지만 품질문제가 조금 있는것같아서 말이야.
|
||||
|
||||
## 요구 554 (11-01 · 사용자 · 553 라이다 조사 아티팩트 보고 뒤)
|
||||
|
||||
라이다관련 확인했고 내가 보기에는 그냥 단순히 많은 점이 누락되거나 조금 어긋난다고 해서 잘못되었다고 보기 힘들어. 스무딩이나 평균처리하는 과정에서 결국 데이터는 조정이 되거든. 그래서 현재의 로직은 그냥 두고 외부라이브러리나 프로그램을 활용하여 결과를 보고 판단하는게 좋을것같아. 데스크탑 서브만을 이용해서 해당 워크트리에 깃 이그너 폴더가 포함된 폴더를 추가하고 해당폴더에 시험용 라이브러리와 프로그램을 설치해서 테스트하고 결과를 보고 판단하자. 아티팩트로 공유해줘.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# 내역 14-5 — M01 단가산출 로직 원문 대조 · 고친 이력 (조사 에이전트 · 11-01)
|
||||
|
||||
읽기 전용 조사. 코드 · 자료 안 고침. 수치 = git 이력 · `resources/master_data/로직_*.json` · 원문 md · storage 를 일회 스크립트로 직접 센 값(스크립트 지움).
|
||||
경로 줄임 — `MD/` = `resources/master_data/` · `원문/` = `resources/knowledge/original/원가계산/` · `M01/` = `M01_MasterData/M01_MasterData_`.
|
||||
|
||||
## ① 결론 다섯 줄
|
||||
|
||||
1. **사람 · 화면이 정본 로직을 고치거나 더한 흔적 없음.** 로직 파일을 바꾼 커밋(09-19 작성 69 · 09-20 뒤 74)은 모두 AI 작업임. 화면 편집은 자체 로직 GX 3 개(10-01)뿐이고, 다음 날 사용자 지시로 지움. **다만 고치는 길은 지금도 열려 있음** — 시스템 관리자가 M01 화면 [저장] · 흐름도 [저장]을 누르면 정본 로직 · 표 줄이 바뀜(고침 · 지움 · 더함). 이력 없이 덮어씀.
|
||||
2. **로직 1,469 개 모두 원문 절에 닿음 — 원문에 없는 절을 가리키는 로직 0.**
|
||||
- 1,354 개 = 09-19 오후 AI 창들이 원문 md 를 장마다 읽고 직접 씀(로직을 만드는 빌더 스크립트는 없음).
|
||||
- 115 개 = 09-30 원문 대조에서 「절 누락」으로 나온 원문 소절을 채움. 원문에 없는 것을 덧붙인 것이 아님.
|
||||
3. **갈라진 로직은 거의 다 원문 짜임 그대로임.** 로직이 여럿인 절 122 곳(355 로직) = 원문 소절 · 표 · 항목 · 열을 따라 갈린 곳 97 · 단가 + 작업량 짝(엔진 꼴) 22 · 그 밖 3(13-1-1 · 13-3-1 임의 · 공통 8장 규정 갈래).
|
||||
- 구조물터파기 24 = 원문 소절 18 에 단가 로직 18 이 하나씩 짝 + 작업량 로직 6.
|
||||
- 택지조성측량 6 도 원문 표가 여섯임. 앞 조사 12-B 의 「한 표 → 합칠 후보」 판정을 바로잡음.
|
||||
4. **임의로 쪼갠 것은 12 로직뿐이고, 이것을 쓰는 M02 문서 · 프로젝트는 없음.**
|
||||
- 13-1-1 플랜트 배관 5 = 원문 한 표를 인쇄 쪽이 나뉜 대로 쪼갬.
|
||||
- 13-3-1 기초다짐 3 = 원문 한 표.
|
||||
- 작업량 복제 4 = 원문 「0~1m와 동일」을 로직 넷으로 베낌.
|
||||
- 엔진 쓰임새로 나눈 작업량 27 · 기계경비 10 은 까닭이 있으니 그대로 둠.
|
||||
5. **원문과 다른 것 — 사람이 바꾼 것은 0, 품셈 본문 밖 근거가 하나.**
|
||||
- 기계경비 로직 8 의 조종원 시간 환산(1/8 · 16/12 · 25/20)은 품셈 본문이 아니라 대한건설협회 질의회신(2001)에서 옴. 이 8 개를 부르는 로직 471 에 번짐.
|
||||
- 그 밖 「해석:」 728 · 주 미반영 13 · 결정목록 물음 127(흠 365 줄)은 원문이 덜 정한 곳을 AI 가 메운 것임. 사용자 답을 기다리는 중.
|
||||
|
||||
## ② 이전 기능 이력 — 로직을 고치거나 더하던 길
|
||||
|
||||
| 언제 | 기능 | 정본 로직에 닿나 | 지금 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 09-07~09-18 | 옛 공종 마스터(축 · B08/B09) — 품셈 476 표 정규화 · 사람이 가른 표 형태 66 · B09 프로젝트 일위대가 · 산근 구성행 · Q 식 고치기 · STmate 엑셀 → 내 라이브러리 | 옛 체계(다른 파일) | 09-19 마스터 재구축 때 `MD/old/` 로 옮김 · 지금 로직은 이어받지 않고 새로 씀 | `3497486b` · `c3618fe8` · `d6fd3c17` · `0e6412fe` |
|
||||
| 09-19 | M01 관리자 화면 호표 편집기(수량 식 · 중간 · 덧줄 · 끝수 칸 고쳐 [저장]) | **정본 줄을 바로 고침**(`/save`) | 09-21 사용자 「정본은 못 고침」 확정 → 편집기는 자체 로직에만 씀 · 길(`/save`)은 남음 | `3a36a037` · `10598f36` · `docs/raw/M01_쉬운단가/쉬운단가_검토_D_지난시도.md` 1번 |
|
||||
| 09-20~09-21 | 방식 A·B·C · 흐름 그림 · 새로 만들기 마법사 · 본떠 만들기 · 식으로 새로 만들기 | 자체 로직 GX(`MD/로직_자체.json`)만 | 마법사 · A·B·C 는 하루 안에 걷음 · 본떠 · 식 만들기 서버 길은 남음 · 단추는 10-01 지움 | `c57f0ea0` · `be8868ca` · `946425af` · `5b693242` |
|
||||
| 10-01 | 시험판 셋(쉬운 단가산출 · 로직 만들기 판 · 시트 로직) | 자체만 | 10-01 지움 | `67ced35a` |
|
||||
| 10-01~10-02 | 흐름도 — 관리자가 정본 로직을 흐름도로 고쳐 [저장] → 그 줄은 흐름이 정본 · 옛 식 칸(입력 · 중간 · 호표 · 덧줄) 지움 · 새 로직 = GX | **정본 줄을 고침** | 지금도 있음(관리자만 편집 · `M01/UI_LogicLab.ts:302` `readOnly: !admin`) · 정본 줄 「흐름」 칸 = 0(아무도 저장 안 함) | `7b90d28e` · `28da700d` · `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` |
|
||||
| 10-01 | 사용자 화면 편집 — GX000001 메쌓기(인력) · GX000002 채움재 · GX000003 메쌓기 | 자체만 | 10-02 사용자 지시로 지움(사본 없음) | `57ce7bb2` · `89f8af2a` · `66d0fd58` |
|
||||
| 10-05~10-09 | M02 단가승격(SP) · 사전조합(SC) 서고 | 안 닿음(M02 층) | 10-09 승격 서고 지움 · 이름 「일위대가화」 | `9ee6c9f3` · `15f2e7c4` · `8ad85167` |
|
||||
|
||||
지금 정본에 쓰는 길:
|
||||
|
||||
| 길 | 하는 일 | 막는 장치 |
|
||||
|---|---|---|
|
||||
| `POST /api/m01/save` — M01 화면 [저장](로직 · 표 · 요소 모두) | 정본 장 파일 줄 고침 · 지움 · 더함(새 GF/GC 키 발급) · 통째로 덮어씀 | 시스템 관리자만 · 판 다르면 409 · 검사 오류 422 · **이력 · 개정 표시 없음** |
|
||||
| 흐름도 [저장](같은 `/save`) | 정본 줄에 「흐름」 칸 · 옛 식 칸 지움 | 같음 |
|
||||
| `/logic/edit` · `/logic/delete` · `/logic/new` · `/logic/copy` · `/logic/formula/save` · `/logic/draft` | 자체 파일(GX)만 | 정본은 403 |
|
||||
|
||||
근거 — `M01/Store.py:626-695`(`_apply` · `save`) · `M01/Store_Flow.py:378-406` · `M01/Router.py:266-330`.
|
||||
|
||||
## ③ 이력 — 누가 무엇을 바꿨나
|
||||
|
||||
첫 작성 = 09-19 15:32~19:55. AI 창들이 원문 md 를 장마다 읽고 손으로 씀(커밋 글 「건설품셈 공통 3장(토공사) 로직 50줄」 꼴). 로직 빌더는 없음(`MD/_갱신절차.md`). 그 뒤 키 단위 바뀜(09-20 키 개편부터 · 합치기 아닌 커밋):
|
||||
|
||||
| 갈래 | 언제 | 커밋 | 로직 | 성격 |
|
||||
|---|---|---|---|---|
|
||||
| 첫 작성 | 09-19 | 69(장 작성 · 장 사이 고침 · 노임 · 재료 연결) | 1,354 | 원문 분석 · AI |
|
||||
| 키 개편 | 09-20 | 1(`6c9ac97b` · `MD/scripts/_migrate_keys.py`) | 1,351 | 열쇠 → 키 · 뜻 그대로 |
|
||||
| 꼴 · 단가 연결 | 09-20~10-05 | 26 | 1,354(겹침) | 구분 칸 · 원문번호 꼴 · 옛 직종 이름 · 자재품목 · 재료 고르기 · 검색어 · 시험 입력 · 입력 설명 — 품 수치 그대로 |
|
||||
| 원문대로 고침 · 결함 | 09-20~09-30 | 36 | 227 | 빠진 기계 줄 · 주 할증 · 칸 밀림 · 재료 검색어 · 일부 구현 |
|
||||
| 절 누락 채움 | 09-30 | 8 | +115 | 원문 소절에 새 로직(PLAN 28-2a) |
|
||||
| 화면 편집 | 10-01~02 | 3 | GX 3(지움) | 사용자 · 자체 파일 |
|
||||
|
||||
- **정본 로직 줄을 사람 · 화면이 고친 커밋 = 0.** 자동 커밋(`auto:`)은 1 건(`7270f13c` · 09-19)이고, AI 가 기계 고르기를 넓힌 것임.
|
||||
- 모든 브랜치 · 원격에서 로직 파일이 바뀐 커밋은 HEAD 안에 다 있음. 마지막 바뀜은 10-05(`bc094969` 체인톱 가격 연결).
|
||||
- 못 본 곳 — 데스크탑 메인(임시 배포) 폴더에서 커밋하지 않은 화면 저장(다른 PC). 마지막 전역 깃 싱크(10-31)까지 로직 파일 바뀜은 없음.
|
||||
|
||||
품 · 식 뜻이 바뀐 커밋:
|
||||
|
||||
| 커밋 | 날 | 무엇 | 로직 · 예 |
|
||||
|---|---|---|---|
|
||||
| `eb7cc600` | 09-20 | **조종원 시간 환산 1/8 · 16/12 · 25/20(질의회신 · 다산소프트 대조 −40 % → 0 %)** · 빠진 기계 줄 16 자리 · 산림 9장 돈 로직 셋 새로 | 33 · GC000267~276 · GF000249~251 |
|
||||
| `2e304f52` | 09-20 | PBD 타설깊이 0 나눔 · 합판거푸집 1회 합판율 | 2 · GC000185 · GC000211 |
|
||||
| `a55d3966` | 09-20 | 전기용접봉 줄 원문대로 · 표 밖 조합 멈춤 풀기 | 12 |
|
||||
| `8f5566e7` | 09-21 | 모래 · 자갈 단위중량 · 레미콘 강도 · 슬럼프 입력 | 18 · GF000156 · GF000157 |
|
||||
| `d9a31061` | 09-21 | 돌쌓기 · 돌붙임 첫 고르기 = 원문 조합 | 2 · GF000219 · GF000223 |
|
||||
| 28-2b 5 커밋(`a9a46311` 등) | 09-30 | 재료 검색어 잘못 연결 | 78 |
|
||||
| 28-2c 6 커밋(`c105d7ff` 등) | 09-30 | 주 할증 · 공구손료 % · 단위 환산 | 62 |
|
||||
| 28-2d 11 커밋(`434e65ad` 등) | 09-30 | 값 · 칸 밀림 · 계산식(원문 설계예와 같게) | 34 |
|
||||
| 28-2e 6 커밋(`de9b3958` 등) | 09-30 | 일부 구현(빠진 줄 더함) | 32 |
|
||||
| 28-2a 8 커밋(`d8e5723c` ~ `4d128335`) | 09-30 | 절 누락 채움 — GF000252~330 79 · GC001104~1139 36 | +115 |
|
||||
|
||||
- 새로 채운 115 가운데 옛 로직과 같은 절을 나눈 것은 2 개뿐임(GF000256 9-7-1 깨기 ↔ 옛 집토 · GF000258 9-8-1 깨기 ↔ 옛 철근절단). 둘 다 원문 안의 다른 항목이라 겹치지 않음.
|
||||
|
||||
## ④ 원문 대조
|
||||
|
||||
방법 — 로직 원문번호를 원문 md 머리(산림 · 건설 부문별 1,780 개)와 자동으로 짝지음. 위험 묶음은 원문 글을 직접 읽음.
|
||||
|
||||
자동 짝:
|
||||
|
||||
| 닿는 곳 | 로직 |
|
||||
|---|---|
|
||||
| 같은 번호 머리 | 1,411 |
|
||||
| 윗 절 머리 + 원문 안 항목(이름 낱말이 원문에 다 있음) — 측량 9장 44 · 산림 12-11-2 · 12-17-1 4 | 48 |
|
||||
| 장 규정(공통 8장 기계경비) | 10 |
|
||||
| **원문에 없는 절** | **0** |
|
||||
|
||||
절마다 로직 수:
|
||||
|
||||
| 꼴 | 절 | 로직 | 판정 |
|
||||
|---|---|---|---|
|
||||
| 한 절에 로직 하나 | 1,114 | 1,114 | 가 1,111 · 라(엔진 꼴 작업량) 3 |
|
||||
| 단가 1 + 작업량 짝 | 22 | 44 | 가 22(단가) · 라(엔진 꼴 작업량) 22 |
|
||||
| 로직 수 ≤ 원문 표 수 | 89 | 269 | 가 262 · 라 7(작업량 2 · 13-1-1 5) |
|
||||
| 로직 수 > 원문 표 수 | 11 | 42 | 가 29(9 절) · 라 13(공통 8 기계경비 10 · 13-3-1 3) |
|
||||
|
||||
위험 묶음 · 표본 대조:
|
||||
|
||||
| 로직 키 | 원문 자리 | 판정 | 근거 |
|
||||
|---|---|---|---|
|
||||
| GF000101 · 103 · 105 · 107 · 109 · 111 · 265~276(18) | 산림 9-13-1~18 구조물터파기(`원문/산림_표준품셈/본문/제09장_토공/9-13_구조물터파기.md`) | 가 | 원문 소절 18(육상 · 용수 × 토사 · 암절취 · 발파암 × 깊이 3) ↔ 단가 로직 18 이 하나씩 짝. 표 QF000270~287 이 자기 소절 표 · 「~와 동일」은 앞 소절 표를 바로 가리킴. 용수 암절취는 할석공이 없는 것도 원문대로. 표 18 개 모두 원문 대조 「끝남」 |
|
||||
| GF000100 · 106 | 9-13-1 · 9-13-4 작업량(Q 식) | 라(엔진 꼴) | 원문 표 k · f · E · ㎝ 로 Q 를 셈. 단가 로직이 부름 |
|
||||
| GF000102 · 104 · 108 · 110 | 9-13-2 · 3 · 5 · 6 작업량 | **라(복제)** | 원문은 장비 「육상(용수)토사(0~1m)와 동일」. 로직 몸이 GF000100 · 106 과 글자까지 같음 |
|
||||
| (9-13 꼴) | 9-13 머리 「※ 관보호공 · 관부설 보통인력 30 % 가산」 | 꼴 어긋남 | 1~6(09-19 작성) = 작업량 짝 · 비고에 30 % 미반영 적음. 7~18(09-30 작성) = 작업량을 안에 품음 · 30 % 언급 없음. 비율 표도 CF000127 · CF000128 두 벌(값 같음 · 원문 머리는 하나). 금액은 같음 |
|
||||
| GF000099 · 263 · 264 | 산림 9-12-1~3 측구터파기 | 가 | 소절 셋 ↔ 로직 셋(+ 작업량 GF000098) |
|
||||
| GF000219~222 · 324 | 산림 13-4-1 · 2 · 4 · 5 · 6 돌쌓기 | 가 | 소절마다(메 · 찰 × 인력 · 장비 · 밑돌) · 뒷길이 · 돌 · 쌓기 입력 · 높이 증가율(주③)도 원문대로 |
|
||||
| GF000215 · 216 · 217 | 산림 13-3-1 기초다짐(인력) | **라(임의)** | 원문 표 하나(㎥당 · 보통인부만 · 다섯 줄). 모래 · 자갈은 입력으로 한 로직에 묶고 조약돌 · 뒤채움만 따로 뗌 |
|
||||
| GF000218 | 산림 13-3-2 기초다짐 · 뒷채움(기계) | 가 | 표 줄 · 칸 그대로(플레이트 1.54 → 1.5ton 기계 대응만 비고) |
|
||||
| GF000124 · 125 · 126 | 산림 9-19-1 토사면 고르기 | 가 | 원문 「1. 절토면」 · 「2. 성토면」(인력시공 · 기계시공 줄) |
|
||||
| GF000076~079 | 산림 8-3 소각 · 매몰 · 훈증 · 박피 | 가 | 원문 한 표의 열 = 서로 다른 작업 · 단위(㎥/인 · RM/인 · 본/인). 단, 열 8 가운데 로직은 4(훈증 · 박피 빠짐) |
|
||||
| GF000094 · 095 · 256 | 산림 9-7-1 무근콘크리트 깨기 | 가 | 깨기(본 표) · 집토(주⑥ 굴착기 능력) — 원문 안 두 일 |
|
||||
| GF000243 · 244 | 산림 13-14 식생토낭 및 포트 | 가 | 토낭 = 표 세 줄 합 · 포트 = 주④ |
|
||||
| GF000005 · 006 | 산림 3-4-3 운반로 보수비 | 가 | 보수공정표(4.) · 상용인부(2.) — 원문 안 두 일 |
|
||||
| GF000134 · 135 · 136 | 산림 10-3-1~3 골재운반 | 가 | 소절 셋 · 로직 몸 같음(차이 = 설계자 입력 qt) |
|
||||
| GC000467~472 | 토목 9-3-3 택지조성측량 | 가 | 원문 표 여섯(촌락 가 · 나 · 다 · 구릉 가 · 나 · 다 — 면적 · 축척 · 방안 다름) |
|
||||
| GC000473~477 · 482~485 · 490~513 | 토목 9-3-4 · 9-4-3 · 9-5-4 측량 | 가 | 원문 안 번호 항목(「4. 계획가로 가구확정 계산측량」 등) 이름 그대로 |
|
||||
| GC000085~087 · 088~090 | 공통 3-2-3 흙깎기 · 3-2-4 터파기(기계) | 가 | 토질마다 표(가. 보통토사 · 나. 혼합토사 · 다. 암) |
|
||||
| GC000442~450 | 토목 8-4-1 · 8-4-2 천공 | 가 | 지층마다 표 |
|
||||
| GC000829~833 | 기계설비 9-5-1 시운전 | 가 | 원문 한 표 · 줄마다 다른 작업과 단위(m · 대 · 식 · ㎡) |
|
||||
| GC000806 · 807 | 기계설비 8-3-1 보일러 | 가 | 한 표 · 단위 다름(인/절 · 인/중량톤) |
|
||||
| GC000217~221 · GC001081~1084 | 공통 6-3-6 슬립폼 · 유지관리 2-2-13 도상갱환 | 가 | 소절 · 열(설치 · 해체 / 개당 · m당)마다 |
|
||||
| GC000229~235 | 공통 6-4-1 PSC빔 | 가 | 원문 표 일곱 |
|
||||
| GC000924~926 | 기계설비 13-5-12 펌프 | 가 | 원문 1. · 2. · 3. 소항목 |
|
||||
| GC000871~875 | 기계설비 13-1-1 플랜트 배관 | **라(임의)** | 원문은 한 표(칸 짜임 같음)가 인쇄 쪽으로 나뉨. 로직 다섯이 쪽 경계(6∼300 · 350∼600 · 600∼850 · 850∼1500+KSD3562 6∼10 · KSD3562 15∼500)를 그대로 따름. 한 로직에 관 종류 둘이 섞임 |
|
||||
| GC000267~276 | 공통 8장 기계경비 | 라(엔진 꼴) | 8-1~8-4 규정 한 벌을 기종 경우(손료 보정 · 조종원 없음 · 손료만 · 부착물 8-4-8 주⑤)로 나눔 · 부르는 로직 500 |
|
||||
| GC000267~271 · 275 · 276 · GF000145 | 조종원 시간당 노무비 | **다(본문 밖 근거)** | `원문/질의회신/대한건설협회/2001-05-29_노무비산출_관련_회신.md` — 회신 스스로 「표준품셈 등에서는 일률적으로 정하고 있지 않음」 · 품셈 8장 본문에 16/12 · 25/20 없음 |
|
||||
| GC000316~320 ↔ GC001047~1051 | 토목 1-8-9 ↔ 유지관리 2-1-15 차선도색 | 가 | 원문이 두 부문에 같은 절을 둠(표 7 씩) |
|
||||
| GC000382 · 383 등 (인력)/(장비) 45 | 토목 6-2-1 · 산림 13-4 등 | 가 | 표본 확인 — 원문 표 · 소절이 인력 · 장비 따로 |
|
||||
| 로직 1,469 의 출처 칸 | — | — | 품셈 절 · 주 · 한전 요금표(값) 밖 출처는 질의회신 8 줄뿐 |
|
||||
|
||||
## ⑤ 갈래별 수 · 권하는 처리 · 쓰는 곳
|
||||
|
||||
쓰는 곳 = M02 층 일위대가 · 사전조합 · 승격 문서(시스템 10 · 회사 16 · 개인 89 파일)와 프로젝트 사본(templates 가 있는 프로젝트 13). 직접 담은 로직은 모두 49 개임.
|
||||
|
||||
| 갈래 | 수 | 목록 | 권하는 처리 | 쓰는 곳 |
|
||||
|---|---|---|---|---|
|
||||
| (가) 원문 그대로 · 갈라짐도 원문 짜임 | **1,424** | 한 절 하나 1,111 · 소절 · 표 · 항목 · 열마다 313 | 그대로 둠 · 목록에서 같은 절 · 윗 절 묶음으로 접어 보이기 | 49 개 전부(예 구조물터파기 GF000101 = 시스템 1 · 개인 2 · 프로젝트 13) |
|
||||
| (나) 원문에 없는 덧 로직 | **0** | (화면 덧 GX 3 은 10-02 지움) | — | — |
|
||||
| (다) 원문과 다르게 바뀐 로직 | 사람이 바꾼 것 **0** · 본문 밖 근거 **8**(위 가 · 라와 겹쳐 셈) | GC000267 · 268 · 269 · 270 · 271 · 275 · 276 · GF000145 | 사용자 판단 — 질의회신을 기준으로 인정하면 그대로 · 아니면 품셈 본문대로(하루 노임 그대로)로 되돌림(되돌리면 조종원 값이 크게 바뀜) | 직접 0 · 이 8 을 부르는 로직 471 · 프로젝트 사본 언급 10 |
|
||||
| (다') 원문이 덜 정한 곳을 AI 가 해석 | 「해석:」 728 · 주 미반영 13 · 결정목록 127 물음(흠 365 줄 · 임도 23 물음) | `docs/raw/M01_결정목록/결정목록.md` | 결정목록에 답을 받아 그대로 반영 — 「바뀐 것」이 아니라 「정할 것」임 | — |
|
||||
| (라-1) 엔진 꼴로 나눔(까닭 있음) | **37** | 작업량 27(산림 26 · 건설 1) · 기계경비 10 | 그대로 둠 · 목록에서 부르는 단가 로직 밑으로 접기 | 직접 0 · 단가 로직이 부름 |
|
||||
| (라-2) 원문 한 항목을 임의로 쪼갬 | **8**(+ 복제 4 는 라-1 과 겹침) | GC000871~875 · GF000215~217 · (복제 GF000102 · 104 · 108 · 110) | 합침 후보 — 플랜트 배관 5 → 1(입력 = 관 종류 · 규격) · 기초다짐 3 → 1(입력 = 종별) · 작업량 복제 4 → GF000100 · GF000106 을 부르게 하고 키 폐기 | **0**(M02 · 프로젝트 · 다른 로직 모두 안 씀. 복제 4 는 짝 단가 로직 하나씩만 부름) |
|
||||
|
||||
덤(갈래 밖 · 빠짐):
|
||||
- 산림 8-3 방제 — 원문 열 8 가운데 로직 4(무더기 · 그루터기 훈증, 그루터기 · 원목 박피 없음).
|
||||
- 결정목록 「로직 새로 만들기」 물음 6(Q001~Q006).
|
||||
|
||||
## ⑥ 사용자에게 물을 것
|
||||
|
||||
1. **정본 쓰기 길을 막을지.** 지금은 시스템 관리자가 M01 화면 · 흐름도 [저장]으로 정본 로직 · 표를 고치고 지우고 더할 수 있음(이력 없음). 불변 기준값이라면 화면 쓰기를 막고, 고칠 일은 M02 단가산출로 옮기기를 권함.
|
||||
2. **질의회신 근거 조종원 환산(1/8 · 16/12 · 25/20)을 기준으로 인정할지.** 기계경비 8 로직 · 부르는 로직 471 이 여기에 닿음.
|
||||
3. **임의로 쪼갠 8 개 + 복제 4 를 정본에서 합칠지, 목록에서만 묶을지.** 쓰는 곳이 0 이라 지금 합쳐도 M02 · 프로젝트에는 영향 없음.
|
||||
4. **9-13 꼴 맞춤** — 1~6 · 7~18 을 한 꼴로, 30 % 가산 비고와 중복 비율 표(CF000128)도 정리할지. 금액은 그대로임.
|
||||
@@ -0,0 +1,186 @@
|
||||
# 내역 14-6 — 장비 줄(기사 · 기름 · 손료) 보이는 꼴 · EST · STmate 조사
|
||||
|
||||
읽기 전용 조사 · 결정 아님. 줄임:
|
||||
- `실무/` = `resources/knowledge/original/실무문서/` · `EST/` = `resources/knowledge/original/경쟁사 프로그램/EST Plus 2/REA 분석/`
|
||||
- 노 · 재 · 경 = 노무비 · 재료비 · 경비
|
||||
|
||||
## ① 결론 다섯 줄
|
||||
|
||||
1. EST 는 장비를 **흩지 않음** — 장비 하나 = 한 줄(일위대가) 또는 한 덩어리(단가산출 「가. 굴착기」)이고, 그 안 돈만 노 · 재 · 경 **칸**에 나눠 적음. 기사가 인력 줄로, 기름이 재료 줄로 따로 나오는 꼴(나)은 표본 어디에도 없음.
|
||||
2. 장비 속(손료 · 주연료 · 잡품 · 운전사)은 산근 아래가 아니라 **따로 붙은 표 「기계경비」(산출표) + 「기계경비총괄표」** 에 나옴 — 손료 → 경비 칸 · 주연료 · 잡품 → 재료비 칸 · 운전사 → 노무비 칸. 산근 · 일위대가는 총괄표의 시간당 노 · 재 · 경 세 값을 끌어다 씀.
|
||||
3. STmate 도 똑같음 — 「중기사용료」(산출표) · 「중기목록표」(총괄표) · 산근 글 `노무비 : 55,700 / Q` · 일위대가 한 줄 노 · 재 · 경 칸. 집계표도 인력 집계에 운전사 없음 · 「중기 시간,금액 집계표」 따로.
|
||||
4. 원가계산서로는 칸 그대로 감 — 운전사 → 직접노무비 · 연료 · 잡품 → 직접재료비 · 손료 → 산출경비(EST 거창 수식 `직접노무비 = 내역서!J85`(노무비 열)). 우리 지금 셈(M01 엔진 · B09)도 이미 같음 → 바꿀 것은 화면 묶음 보기뿐.
|
||||
5. 권함 = **(가)** — 장비 묶음에 한 줄 + 그 줄 노 · 재 · 경 칸 + 펼치면 시간당 손료 · 주연료 · 잡품 · 운전사 넉 줄(EST 기계경비표 한 덩어리와 같은 줄). 실무자가 EST · STmate · 손 엑셀에서 매일 보는 꼴이고, 품셈 원문 줄(「굴착기 0.1 hr」)과 1:1 로 맞음.
|
||||
|
||||
## ② EST 표본
|
||||
|
||||
### 2-0 이 파일들이 EST 출력인 근거
|
||||
|
||||
- EST 엑셀 내보내기 창 `TFRMEXCELCONVERT` 의 고르기 칸 = 노임단가 · 자재단가 · 견적단가 · 표준단가 · **기계경비 적용기준 · 기계경비 총괄표 · 기계경비** · 단가산출 총괄표 · 단가산출 · 일위대가 총괄표 · 일위대가 · 내역서 총괄표 · 내역서 · 원가계산(`EST/02_화면구성/captions.json:17947-17996`).
|
||||
- 아래 다섯 파일의 시트 차례가 이것과 같음: 원가계산서 · 내역서총괄표 · 내역서 · (일위대가총괄표 · 일위대가) · 단가산출총괄표 · 단가산출 · 기계경비총괄표 · 기계경비 · 기계경비적용기준 · 자재단가 · 노임단가.
|
||||
- A `실무/2026_실무일위대가 모음/2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls`
|
||||
- B `실무/01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(…)내역+수량.xlsx` (수식 살아 있음)
|
||||
- C `실무/2026_실무일위대가 모음/2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls`
|
||||
- D `실무/2026_실무일위대가 모음/2026_상반_산림청_수목진료.xls`
|
||||
- (E 2016 사방품셈(안) xls 도 같은 꼴 · 장수 · 진안 xlsx 는 DRM 잠김(`DRMONE` 머리) — 못 읽음)
|
||||
|
||||
### 2-1 「기계경비」 시트 = 장비 속 산출표 (손료 · 연료 · 잡품 · 운전사)
|
||||
|
||||
머리 칸: B 명칭 · C 규격 · D 산출근거 · Z 합계 · **AA 노무비 · AB 재료비 · AC 경비** · AD 비고.
|
||||
|
||||
A `기계경비!4-8`:
|
||||
|
||||
| 줄 | B | 산출근거 | AA 노무비 | AB 재료비 | AC 경비 |
|
||||
|---|---|---|---|---|---|
|
||||
| 4 | 1. 덤프트럭 4.5Ton (머리) | | 49,778 | 9,494 | 7,613 |
|
||||
| 5 | 손 료 | 25,662,000 ₩× 2,967 × 10(-7) | | | 7,613.9 |
|
||||
| 6 | 주연료 경유 | 5 ℓ × 1,376 | | 6,880 | |
|
||||
| 7 | 잡 품 주연료의 | 38 % × 6,880 | | 2,614.4 | |
|
||||
| 8 | 화물차운전사 | 1 인 × 238,936 × 1/8*16/12*25/20 | 49,778.3 | | |
|
||||
|
||||
B `기계경비!4-8`(수식): 머리 `AA4 = TRUNC(AA5+AA6+AA7+AA8)` · 손료 `AC5 = TRUNC(D5*K5*0.0000001,1)` · 주연료 `AB6 = TRUNC(D6*J6,1)`(`J6 = 자재단가!T5`) · 잡품 `AB7 = TRUNC(D7/100*J7,1)` · 운전사 `AA8 = TRUNC(D8*I8*기계경비적용기준!E21,1)`(`I8 = 노임단가!H4`).
|
||||
|
||||
D `기계경비!4-7` 동력분무기 4.85kw = 손료 258(경비) · 휘발유 1,914.9 · 잡품 382.9(재료) · **운전사 줄 없음 → 노무비 0**. 같은 시트 9-13 물탱크 = 화물차운전사 49,778.3(노무).
|
||||
|
||||
### 2-2 「기계경비총괄표」 = 장비마다 한 줄 · 시간당 노 · 재 · 경
|
||||
|
||||
머리 칸: No · 품명 · 규격 · 단위 · 합계 · **노무비 · 재료비 · 경비**.
|
||||
|
||||
B `기계경비총괄표!21`: `18 | 굴삭기(타이어형) | 0.6㎥ | hr | F=G21+H21+I21 | G=기계경비!AA111 | H=기계경비!AB111 | I=기계경비!AC111` → 값 105,932 = 59,020 + 19,792 + 27,120.
|
||||
|
||||
A `기계경비총괄표!6`: `3 | 굴착기(무한궤도) | 0.7㎥ | hr | 103,047`(노 59,020 · 재 19,473 · 경 24,554 — xls 저장값은 G~I 칸이 0 으로 읽히고 같은 값이 당초 칸 AA~AC 에 있음 · 수식 미계산 저장으로 보임).
|
||||
|
||||
### 2-3 「단가산출」(산근) 안 장비 = 한 덩어리 + 노 · 재 · 경 세 글 줄 + 소계
|
||||
|
||||
열: 산출근거(글) · 합계 · 노무비 · 재료비 · 경비.
|
||||
|
||||
A `단가산출!18-42` (뒷채움돌 채집):
|
||||
|
||||
```
|
||||
18 가. 굴착기(타이어형)(0.6㎥)
|
||||
20 q = 0.6㎥ K = 0.55 f = 1 / 22 E = 0.35 Cm = 18sec(90˚)
|
||||
25 Q = … = 23.1 ㎥/hr
|
||||
28 노무비 : 59,020 ÷ 23.1 × 90% = 2,299.4
|
||||
29 재료비 : 19,792 ÷ 23.1 × 90% = 771.1
|
||||
30 경 비 : 27,120 ÷ 23.1 × 90% = 1,056.6
|
||||
31 소 계 : 4,127.1 | 합 4,127.1 | 노 2,299.4 | 재 771.1 | 경 1,056.6
|
||||
33 나. 인 력 (10%)
|
||||
35 보통인부 : 172,068 × 0.3인/㎥ × 10% = 5,162 | 합 5,162 | 노 5,162
|
||||
37 다. 소 계
|
||||
39 노무비 : 2,299.4 + 5,162 = 7,461.4
|
||||
40 재료비 : 771.1
|
||||
41 경 비 : 1,056.6
|
||||
42 계 : 9,289.1 | 합 9,289.1 | 노 7,461.4 | 재 771.1 | 경 1,056.6
|
||||
```
|
||||
|
||||
B `단가산출!4-33` (측구터파기 토사 · 노 · 재 · 경 = EM · EN · EO 열):
|
||||
|
||||
```
|
||||
7 1.기계(100%):굴삭기(무한궤도)(0.7㎥)
|
||||
14 Q = … = 23.39㎥/hr
|
||||
17 노무비:59,020÷23.39=2,523.3
|
||||
18 재료비:19,473÷23.39=832.5
|
||||
19 경비:24,554÷23.39=1,049.7
|
||||
20 소계:4,405.5 | EL 4405.5 | EM 2,523.3 | EN 832.5 | EO 1,049.7
|
||||
23 2.보조인부(1일당1인)
|
||||
25 보통인부:172,068÷8.÷23.39=919.5 | EM 919.5
|
||||
28 3.합계
|
||||
30 노무비:2,523.3+919.5=3,442
|
||||
33 계:5,323 | EL 5323 | EM 3,442 | EN 832 | EO 1,049
|
||||
```
|
||||
|
||||
같은 시트 40-58(측구터파기 연암): 「가. 굴삭기(0.7+브레이카)」 노 11,804 · 재 3,703 · 경 7,193.2 + 「나. 치즐(0.7㎥용) 267.6 → 재료비」 + 「다. 소계」.
|
||||
|
||||
C `단가산출!40-62` (가설창고 운반): 「가. 크레인(타이어)(10Ton) Q = 2hr」 → `노무비 : 59,020 × 2 = 118,040` · `재료비 : 7,268 × 2 = 14,536` · `경 비 : 31,344 × 2 = 62,688` · 소계 195,264 → 「나. 인건비」 비계공 · 특별인부 240,406 → 「다. 소계」 노무비 118,040 + 240,406 = 358,446.
|
||||
|
||||
- 숫자 칸은 「소계」 줄에만 노 · 재 · 경 셋이 함께 찍힘. 노무비 · 재료비 · 경비 글 줄은 글뿐.
|
||||
- 장비 노무(기사)는 덩어리 안에 머물다가 「다. 소계 · 합계」에서야 인력 노무와 더해짐.
|
||||
|
||||
### 2-4 「일위대가」 안 장비 = 한 줄 · 노 · 재 · 경 단가 칸 셋
|
||||
|
||||
열: 품명 · 규격 · 수량 · 단위 · 합계(단가 · 금액) · 노무비(단가 · 금액) · 재료비(단가 · 금액) · 경비(단가 · 금액).
|
||||
|
||||
B `일위대가!10`: `굴삭기(타이어형) | 0.6㎥ | 0.1 | hr | 105,932 · 10,593.2 | 59,020 · 5,902 | 19,792 · 1,979.2 | 27,120 · 2,712` — 수식 `I10 = 기계경비총괄표!G21` · `K10 = …!H21` · `M10 = …!I21`.
|
||||
|
||||
C `일위대가!104`: `콘크리트 펌프차 | 41m,80~95㎥/hr | 9.38 | hr | 195,494 | 노 59,020 | 재 43,282 | 경 93,192`.
|
||||
|
||||
### 2-5 EST 화면 · 엔진
|
||||
|
||||
- 단가산출 편집기 표 = 코드 · 구분 · **산출근거(글)** · 합계 · 노무비 · 재료비 · 경비 · 실적. 우클릭 「노무비 · 재료비 · 경비 · 소계 지정 · 노,재,경 해제(Shift+F5~F9)」 — 글 줄마다 어느 칸으로 갈지 표시(`docs/raw/요구_내역서/_분석_내역3-A_EST_화면.md:65`). 작업도구 「기계경비 공식」 = 위 「노무비 : … ÷ Q」 꼴 글을 끼워 넣는 틀(`captions.json:21139-21251`).
|
||||
- 산근 글의 `59,020` 은 손으로 친 수가 아니라 **기계 목록의 노무(NC) 칸 연결**(LinkIndex 105) · 재료비 줄 = GC · 경비 줄 = JC(`EST/09_…/ESTPlus2_산근LineData_수량연결_정적분석_20261029.md:25,42`).
|
||||
- 기계 상세 셈: 손료 → JC(경비) · 연료 · 잡품 → GC(재료) · 운전사 → NC(노무) · 머리 합 원 미만 절사(`EST/09_…/ESTPlus2_기계경비_정적분석_20261029.md`).
|
||||
- 일위대가 우클릭 「비목별 단가 불러오기 ▸ 노무비만 · 재료비만 · 경비만 · 전체비목」(`_분석_내역3-A_EST_화면.md:56`) — 장비 줄에서 일부 비목만 쓰는 길(기본 = 전체).
|
||||
- 운전사 노임을 다른 비목으로 옮기는 설정은 화면 글 전체에 없음(`captions.json` 「운전 · 조종」 검색 0).
|
||||
|
||||
## ③ STmate 표본 (짧게)
|
||||
|
||||
봉화 `실무/04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx`
|
||||
|
||||
| 시트 · 줄 | 줄 그대로 |
|
||||
|---|---|
|
||||
| 중기사용료 5-11 | 제 1 호표 굴삭기(무한궤도) 0.2㎥ 시간 → `굴삭기(무한궤도) 0.2㎥ 0.2085 천원 62,550 → 경비 13,041.6` · `건설기계운전사 1 인 55,700 → 노무 55,700` · `경유 저유황 5 L 1,273 → 재료 6,365` · `잡품 주연료의% 21 % → 재료 1,336.6` · `합 계 76,442 | 노 55,700 | 재 7,701 | 경 13,041` |
|
||||
| 중기목록표 4 | `1 | 굴삭기(무한궤도) | 0.2㎥ | 시간 | 76,442 | 노 55,700 | 재 7,701 | 경 13,041` |
|
||||
| 단가산출근거 69-78 | `노 무 비 : 55,700 / Q = 3,545.5`(노 칸) · `재 료 비 : 7,701 / Q = 490.1`(재 칸) · `경 비 : 13,041 / Q = 830.1`(경 칸) · `소계 4,865.7` · `계` — 숨은 식 `'노 무 비 :'&X00001L& / Q` · `X00001M` · `X00001S` = 중기 X00001 의 노(L) · 재(M) · 경(S) |
|
||||
| 일위대가표 9 | `굴삭기(타이어) | 0.6㎥,(부착용집게) | 0.3 | 시간 | 105,178 | 노 55,700 | 재 18,310 | 경 31,168` |
|
||||
| 노무비수량금액집계표 4-19 | 건축목공 · 형틀목공 · 석공 · 보통인부 … — **운전사 줄 없음** |
|
||||
| 재료비수량금액집계표 | 경유 · 휘발유 줄 없음(단가표 「재료비목록표」에만 단가로 있음) |
|
||||
| 중기시간금액집계표 4-12 | 장비마다 한 줄 · 수량(시간) · 합계 · 노 · 재 · 경 — 예 `굴삭기(무한궤도) 0.7㎥ | 940.69 시간 | 91,099,241 | 노 52,396,433 | 재 16,946,530 | 경 21,756,278` |
|
||||
|
||||
영월 `일위대가표!27`: `크레인(타이어) | 10톤 | 2 | 시간 | 92,526 | 노 55,700 | 재 6,723 | 경 30,103 | X02104`.
|
||||
|
||||
- 다른 점 하나: STmate 산근은 노무비 · 재료비 · 경비 글 줄마다 자기 칸에 숫자를 찍음(EST 는 소계 줄에만). 꼴은 같음.
|
||||
- STmate 규칙 문서도 「연료 · 소모품 = 재료 · 운전원 = 노무 · 손료 = 경비로 나눠 상위 단가에 전달」(`…/STmate 분석/20_분석/07_규칙2_3_4_5_직접비_단가_중기_자재.md:51`).
|
||||
|
||||
## ④ 원가계산서로 가는 비목
|
||||
|
||||
| 출처 | 운전사(기사) | 주연료 · 잡품 | 손료 |
|
||||
|---|---|---|---|
|
||||
| 건설 표준품셈 8-1-6 | 기계경비 = 기계손료 + 운전경비(연료 · 운전 노무비 · 소모품) + 수송비 — **어느 비목인지는 안 적음**(`…/건설공사_표준품셈/본문/01_공통부문/제08장_건설기계/8-01_적용기준.md:165-172`) | 〃 | 〃 |
|
||||
| 예정가격작성기준 제19조 3호 | 「기계경비 = 표준품셈상 건설기계 경비산정기준에 의한 비용」(경비 세비목) — 안의 노무 · 연료를 따로 보내라는 글 없음(`…/행정규칙/(계약예규) 예정가격작성기준/현행_20260825.md:180`) | 〃 | 〃 |
|
||||
| EST | 직접노무비 | 직접재료비 | 산출경비 |
|
||||
| STmate | 직접노무비 | 직접재료비 | 산출경비 |
|
||||
| 우리 지금 셈 | 직접노무비 | 직접재료비 | 산출경비 |
|
||||
|
||||
- EST 근거 = 거창 `원가계산서!F4 = 내역서!L85`(재료비 열) · `F8 = 내역서!J85`(노무비 열) · `F11 = 내역서!N85`(경비 열) — 내역서 열 = 일위대가 · 산근 노 · 재 · 경 칸 합이고, 장비 노무가 그 칸에 들어 있음(②-3 · ②-4).
|
||||
- STmate 근거 = 봉화 직접노무비 426,802,539 ↔ 인력 집계만 146,900,061 · 중기 노무 집계 294,516,923 → 중기 노무를 넣어야 맞는 크기(남는 3 % 차이는 덧줄 · 끝수 · 집계 범위로 보임 · 미대조).
|
||||
- 우리 = M01 엔진 로직 줄 = 하위 노 · 재 · 경을 수량만큼 나눠 얹음(`resources/master_data/scripts/master_formula.py:588-599`) · B09 중기사용료 손료 경비 · 운전사 노무 · 연료 · 잡품 재료(`B09_Estimation/B09_Estimation_Calc_Base.py:239-293`) · B09 산근 텍스트 중기 = 노 · 재 · 경 세 줄(`B09_Estimation/B09_Estimation_Sangeun.py:27-28`) · 원가계산서 직접노무비 = 내역서 노무 합(`B09_Estimation/B09_Estimation_Calc_Total.py:9`).
|
||||
- 뜻: 운전사 몫이 직접노무비에 들어가므로 간접노무비 · 산재 · 고용 · 건강 · 연금 · 퇴직공제 요율이 운전사 몫에도 걸림. 장비 돈을 「경비」 한 칸으로 몰면 원가계산서 금액이 달라짐 → **금액 칸은 꼭 노 · 재 · 경으로 나눈 채** 둬야 함. (가) · (나) 어느 쪽이든 칸만 나뉘면 원가계산서 결과는 같음 — 다른 것은 보기뿐.
|
||||
|
||||
## ⑤ 우리 권함 · 화면 그림
|
||||
|
||||
권함 = (가) 장비 묶음 한 줄 + 노 · 재 · 경 칸 + 펼침.
|
||||
|
||||
까닭:
|
||||
1. 실무 출력 셋(EST · STmate · 손 엑셀 거창 숨은 「중기경비집계표」까지)이 모두 이 꼴 — 장비 한 줄 · 노 · 재 · 경 칸 나뉨 · 속은 따로 붙은 장비 산출표.
|
||||
2. 품셈 원문 줄과 1:1 — 원문은 「굴착기 0.1 hr」 한 줄. (나)로 흩으면 「건설기계운전사 0.0043 인 · 경유 0.5 ℓ」처럼 원문에 없는 줄이 생겨 원문 대조 · 검수가 어긋남.
|
||||
3. 인력 묶음에 기사가 섞이지 않음 — STmate 인력 집계에도 운전사 없음. 실무자 눈에 「인력 = 사람 품」.
|
||||
4. 지금 셈을 안 고침 — 엔진 · B09 가 이미 노 · 재 · 경을 나눠 들고 있음. 묶음 파생만 더함(`_분석_내역12-B_로직원론.md:135` 규칙 「로직 줄 · 대상 원/시간 = 장비 묶음」).
|
||||
|
||||
화면 그림(단가산출 상세 · 묶음 보기):
|
||||
|
||||
```
|
||||
묶음 / 명칭 규격 수량 단위 | 합계 노무비 재료비 경비
|
||||
▾ 인력
|
||||
보통인부 0.03 인 | 5,162 5,162
|
||||
인력 소계 | 5,162 5,162
|
||||
▾ 재료
|
||||
(없음)
|
||||
▾ 장비
|
||||
굴착기(타이어) 0.6㎥ 0.039 hr | 4,127.1 2,299.4 771.1 1,056.6 ← 한 줄
|
||||
▸ 펼침(시간당 · 기계경비 산출 · 거창 `기계경비!111-115` 값)
|
||||
손료 119,000,000 × 2,279 × 10⁻⁷ | 27,120.1
|
||||
주연료 경유 11.6 ℓ × 1,376 | 15,961.6
|
||||
잡품 주연료의 24 % × 15,961.6 | 3,830.7
|
||||
운전사 건설기계운전사 1 인 × 283,297 × 1/8·16/12·25/20 | 59,020.2
|
||||
시간당 계 | 105,932 59,020 19,792 27,120
|
||||
장비 소계 | 4,127.1 2,299.4 771.1 1,056.6
|
||||
──────────────────────────────────────────────────────────────────
|
||||
계 | 9,289.1 7,461.4 771.1 1,056.6
|
||||
```
|
||||
|
||||
- 장비 줄 노무비 칸은 「장비 소계」에 머물고 맨 아래 「계」에서 인력 노무와 더해짐(EST 「다. 소계 노무비 2,299.4 + 5,162」와 같음).
|
||||
- 펼침 = EST 「기계경비」 시트 한 덩어리 · STmate 「중기사용료」 한 호표와 같은 넉 줄.
|
||||
- 운전사 없는 장비(동력분무기 · 굴삭기 부착 콤팩터 · 덤프 자동덮개)는 노무비 칸 빈칸 · 펼침에 운전사 줄 없음(표본 D).
|
||||
- 묶음 이름(「장비」 · 「기계경비」 · 「중기」)은 사용자 협의 — EST 툴바 단추 = 「장비」 · 표 이름 = 「기계경비」 · STmate = 「중기」.
|
||||
|
||||
곁가지(이번 물음 밖 · 확인만): 거창 일위대가 `9` 「공구손료 및 경장비(진동기)의 기계경비 | 노무비의 2 %」가 **재료비 칸**(K)에 들어감 — 우리 로직 덧줄은 비목 칸 따라(대부분 경비 · `_분석_내역12-B_로직원론.md:132`). 갈림 여부는 따로 볼 일.
|
||||
@@ -106,3 +106,9 @@ M02 구조물/도면/일위대가 관련
|
||||
6. 장비 줄(기사 · 기름 · 손료) 보이는 꼴 → 이건 EST 프로그램에서 어떻게 했는지 확인해봐.
|
||||
7. 공사(B09) 단가산출근거 탭 → 같은 보기로 바꿈
|
||||
8. M01 텍스트 수식 「변경」 뜻 → 시험 계산 값만 바꿈
|
||||
|
||||
## 내역 15 (11-01 · 사용자 · 내역 14-5 갈린 로직 원문 대조 결과 물음 셋에 답)
|
||||
|
||||
1. 기계경비 로직 8 의 조종원 시간 환산(1/8 · 16/12 · 25/20 · 대한건설협회 질의회신 2001 근거 · 부르는 로직 471) → 빼고 본문대로
|
||||
2. 원문 한 표를 임의로 쪼갠 로직 12(플랜트 배관 5 · 기초다짐 3 · 작업량 복제 4 · 쓰는 곳 0) → 원문대로 합침(방제 8-3 빠진 로직 4 도 채움)
|
||||
3. 구조물터파기(9-13) 로직 18 의 꼴 섞임 → 한 꼴로 맞춤
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
3. 보기 = 넷(단가산출 상세 · 인력·재료·경비 · 시험 계산 · 텍스트 수식) · 기본정보 늘 맨 위 접힘 · 상세 머리에 「지금 입력」 한 줄 · 보기 기억 = 화면 안에서만.
|
||||
4. 숫자 고침 자리 = M01 단가산출 상세는 보기 전용(정본 불변) · M02 단가산출 · 일위대가 모달은 단가산출 상세 표의 품 · 단가 칸을 바로 고침(고친 칸 색 + M01 값 곁).
|
||||
5. 인력·재료·경비 보기 = 704-2 계산서 보기를 공용 「단가산출 계산서」 부품으로 올려 M01 · M02 · 일위대가 모달 · 기준 목록 미리 보기가 한 벌로 씀 · 줄 꼴 = 산출근거 글(「석공 : 단가 × 품 = 금액」) + 그룹 머리 변수 칸 · 변수 가르기 = 서버가 줄마다 「쓰는 입력」 줌.
|
||||
6. 기계경비 · 복합 하위 로직 줄 꼴 = 4-1 물음(권함 = 한 줄 + 노 · 재 · 경 금액 칸 + 펼침).
|
||||
6. 장비(기계경비) 줄 = 경비(장비) 묶음에 한 줄 + 그 줄 안 노 · 재 · 경 금액 칸 · 펼치면 손료 · 주연료 · 잡품 · 운전사 넉 줄(EST · STmate 같은 꼴 · `_분석_내역14-6_EST_장비줄.md`) · 장비 노무는 장비 소계에 머물다 맨 아래 「계」에서 인력 노무와 더함 · 원가계산서 비목 배정은 지금 셈 그대로 · 복합 하위 로직 = 한 줄 + 펼침 · 묶음 이름 = 「장비」.
|
||||
7. 갈라진 로직 = 4-1 물음(권함 = 정본 그대로 · 목록 묶음 접기) · 작업량 로직 27 = 부르는 로직 밑으로 접음 · 비고 「별도」 = 계산 밖 「관련 로직」 링크.
|
||||
8. 기준단위 = 원문 배 그대로 · 표 `기준` → 「배 · 단위」 두 칸은 읽을 때 파생(정본 표 안 씀) · 화면에 환산 근거.
|
||||
9. 단위 변환 = 별칭 · 개수 묶음 · 배 읽기 한 벌 + 부피 ↔ 무게 = 마스터 단위중량 표 CC000337 고르기(기본 줄 = 사용자 확인) · 밀도 · ㎾h 는 경고만.
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
1. 「일위대가 사전조합」 새 이름 = 「일위대가」 · 「실무문서」 = 도면 양식 뒤 그대로(정함).
|
||||
2. 갈린 로직 = 원문을 보고 판단 · 누가 바꾸거나 더한 로직일 수 있음 → 조사 중(`_분석_내역14-5_로직_원문대조.md`) · 결과 뒤 정함 7 고침.
|
||||
3. 장비 줄 꼴 = EST 가 한 방식 확인 → 조사 중(`_분석_내역14-6_EST_장비줄.md`) · 결과 뒤 정함 6 고침.
|
||||
3. 장비 줄 꼴 = EST 가 한 방식 확인 → EST · STmate 모두 장비 한 줄 + 노 · 재 · 경 칸 · 흩어 넣기 없음 → 정함 6.
|
||||
4. M02 단가산출 고침 범위 = 숫자 · 줄 더하기/빼기(식 안 받음) · 옵션 열어 둔 단가산출의 배율 규칙은 정함 13 그대로.
|
||||
5. 일위대가 안 단가산출 창에서 고친 값 = 그 일위대가에만 + [단가산출로 저장].
|
||||
6. 별칭 모음 = 시스템 관리자가 승인한 것만 모두에게 · 공사 내역 맞춤도 같이 모음(정함).
|
||||
|
||||
@@ -544,6 +544,10 @@ app.include_router(b09_estimation_router, dependencies=protected_with_company)
|
||||
# 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다.
|
||||
system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
|
||||
app.include_router(m01_master_data_router, dependencies=system_admin_only)
|
||||
app.include_router(
|
||||
__import__("M01_MasterData.M01_MasterData_Alias", fromlist=["router"]).router,
|
||||
dependencies=system_admin_only,
|
||||
) # 별칭 장부 모으기(718-2)
|
||||
# 단가산출 흐름도 시험 길 — 폴더째 지우면 이 줄도 지움
|
||||
app.include_router(
|
||||
__import__("M02_MasterTemplete.DataFlow.M02_DF_Router", fromlist=["router"]).router,
|
||||
@@ -559,6 +563,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)
|
||||
# 단가산출(SK) 셈 길(PLAN 715-1b) — 로그인 · 층 읽기 권한은 길 안에서
|
||||
app.include_router(
|
||||
__import__("M02_MasterTemplete.M02_Sangeun_Router", fromlist=["router"]).router,
|
||||
dependencies=protected,
|
||||
)
|
||||
# 회사에 올리기 요청 · 승인(PLAN 706-1) — 로그인만 · 층 권한은 길 안에서
|
||||
app.include_router(
|
||||
__import__("M02_MasterTemplete.M02_Share_Request", fromlist=["router"]).router,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""197-1 대시보드 「구조물 옵션」 카드 → M02 「구조물 옵션 정의」 화면이 바로 열림 시험 — 조작은 사용자.
|
||||
"""197-1 · 716-1 대시보드 「구조물 옵션 정의」 줄 → M01 「구조물 옵션 정의」 화면이 바로 열림 시험 — 조작은 ORCA.
|
||||
|
||||
716-1 — 화면 입구가 M02 에서 M01 로 옮겨감(화면 코드 · 서버 길은 M02 폴더 그대로) · 옛 M02 `open=옵션` 주소는
|
||||
시스템 관리자면 M01 `?open=옵션` 으로 돌려보냄.
|
||||
|
||||
원인 — 진입 해시 `open=옵션` 이 옵션 줄을 눌러 정의 화면을 띄우는 사이 첫 화면(집계표)의 `mount` 가
|
||||
모듈 · 번호를 기다리다 뒤늦게 끝나 정의 화면 위에 집계표를 덮음(`mount` 에 낡음 검사 없었음).
|
||||
|
||||
· 카드 셋 → 해시(open=구조물조합 · 옵션 · 도면) · 해시 → 진입(`entryFromHash`) Node 로 바로 돌림
|
||||
· `reveal` 가 옵션만 줄을 누르고 도면은 펼치기만 · 구조물조합은 아무것도 안 함
|
||||
· 줄 → 해시(M01 open=로직 · 옵션 / M02 open=일위대가조합 · 구조물조합 · 도면) · 해시 → 진입(`entryFromHash`) Node
|
||||
· M02 `reveal` 가 옵션만 줄을 누르고 도면은 펼치기만 · 구조물조합은 아무것도 안 함 · M01 `reveal` 도 같은 꼴
|
||||
· 옛 `open=옵션` 돌려보냄(`redirectOldOptions`)
|
||||
· `mount` 집계표 갈래(진짜 소스 조각)를 Node 로 돌림 — 기다리는 사이 다른 화면이 열리면 그리지 않음
|
||||
"""
|
||||
|
||||
@@ -41,16 +45,20 @@ def _run(script: str, *args: str) -> object:
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_카드_셋_해시():
|
||||
def test_줄_해시_옵션은_M01():
|
||||
from test_b01_settings_cards import _cards
|
||||
|
||||
cards = _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3)
|
||||
assert [c[2].split("?")[1].split("&")[0] for c in cards[1:4]] == [
|
||||
"open=구조물조합",
|
||||
"open=옵션",
|
||||
"open=도면",
|
||||
heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:7]]
|
||||
assert heads == [
|
||||
"m01-master-data?open=로직",
|
||||
"m01-master-data?open=옵션",
|
||||
"m01-master-data?open=별칭", # 718-3
|
||||
"m02-master-template?open=일위대가조합",
|
||||
"m02-master-template?open=구조물조합",
|
||||
"m02-master-template?open=도면",
|
||||
]
|
||||
assert all("&root=master" in c[2] for c in cards[1:4])
|
||||
assert all("&root=master" in c[2] for c in cards[4:7])
|
||||
|
||||
|
||||
@needs_node
|
||||
@@ -69,7 +77,7 @@ def test_해시_진입_셋():
|
||||
+ fn.group(0).replace("export function", "function")
|
||||
+ "\nconsole.log(JSON.stringify(entryFromHash(null)));"
|
||||
)
|
||||
for want in ("구조물조합", "옵션", "도면"):
|
||||
for want in ("구조물조합", "옵션", "도면", "일위대가조합"):
|
||||
got = _run(script, f"open={want}&root=master&company=3")
|
||||
assert got["open"] == want and got["root"] == "master" and got["company"] == "3"
|
||||
|
||||
@@ -139,3 +147,38 @@ def test_집계표_갈래_기다리는_사이_다른_화면이_열리면_그리
|
||||
assert got["none"] == ["create", "panel:set"] # 안 끼어들면 그대로 그림
|
||||
assert got["mod"] == ["panel:null"] # 모듈 기다리는 사이 다른 화면 — 그리지 않음
|
||||
assert got["num"] == ["panel.destroy", "panel:null"] # 번호 기다리는 사이 — 패널도 버림
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_옛_옵션_주소_M01로_돌려보냄():
|
||||
layer = _text(M02 / "M02_MasterTemplete_Layer.ts")
|
||||
fn = re.search(r"^export function redirectOldOptions\([\s\S]*?^}", layer, re.M)
|
||||
assert fn, "redirectOldOptions 를 못 찾음"
|
||||
script = (
|
||||
"const currentQuery = () => new URLSearchParams(process.argv[1]);\n"
|
||||
"const ROUTES = { M01_MASTER_DATA: 'm01' }; const sent: string[] = [];\n"
|
||||
"const navigateTo = (r: string, q: any) => sent.push(r + JSON.stringify(q));\n"
|
||||
"type SessionUser = any;\n"
|
||||
+ fn.group(0).replace("export function", "function")
|
||||
+ "\nconst a = redirectOldOptions({ role: 'SYSTEM_ADMIN' });"
|
||||
+ "\nconst b = redirectOldOptions({ role: 'USER' });"
|
||||
+ "\nconst c = redirectOldOptions(null);"
|
||||
+ "\nconsole.log(JSON.stringify([a, b, c, sent]));"
|
||||
)
|
||||
assert _run(script, "open=옵션&root=master") == [True, False, False, ['m01{"open":"옵션"}']]
|
||||
# 옵션이 아니면 안 돌려보냄
|
||||
assert _run(script, "open=도면&root=master") == [False, False, False, []]
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert "if (redirectOldOptions(user))" in page
|
||||
assert 'mountSettingsDrawer(user ?? {}, "m02", entry.company);' in page
|
||||
|
||||
|
||||
def test_M01_open_인자_로직_옵션():
|
||||
m01 = ROOT / "M01_MasterData"
|
||||
page = _text(m01 / "M01_MasterData_UI_Page.ts")
|
||||
assert 'if (want === "로직" || want === "옵션" || want === "별칭") side.reveal(want);' in page
|
||||
assert "setLeaveGuard(leaveOptions)" in page # 저장 안 한 정의 고침 — 떠날 때 물음
|
||||
side = _text(m01 / "M01_MasterData_UI_Side.ts")
|
||||
assert "buildOptionsSection({ ...optionsOpt, isAdmin: true })" in side
|
||||
# 로직 칸 아래 구분선 부품 + 옵션 칸
|
||||
assert "logic, createDivider(), optionsSec, aliasSec" in side # 718-3 별칭 장부 칸
|
||||
|
||||
@@ -263,7 +263,7 @@ def test_카드_시스템_관리자만_글_다_있음():
|
||||
links = nav.split("export function mountSettingsDrawer")[0]
|
||||
admin, rest = links.split('if (user.role === "SYSTEM_ADMIN") {')[1].split("// 회사 수정 =")
|
||||
assert 'key: "calcSites"' in admin and "calcSites" not in rest
|
||||
assert 'settingLinks(user, companyId).filter((link) => link.key !== "calcSites")' in nav
|
||||
assert "calcSites" not in nav.split("export function pageLinks")[1] # ☰ 는 페이지 둘만(716-2)
|
||||
page = _text("B01_Dashboard/B01_Dashboard_UI_View_SysDefaults.ts")
|
||||
assert 'link.key === "calcSites" ? void openCalcSitesDialog()' in page
|
||||
locale = _text("ui_template/ui_template_locale_m1.ts")
|
||||
|
||||
@@ -94,8 +94,10 @@ def test_실무_xlsx_넷_붙여넣기_만듦_합계_같음(client, tag):
|
||||
assert got["같음"] == FILES[tag][1] and all(abs(x["차이"]) <= 1 for x in got["다름"])
|
||||
assert len(got["만듦"]) == got["호표"] and got["고정"] + got["호표참조"] == got["줄"]
|
||||
assert master_hash() == before
|
||||
rows = client.get("/api/m02/layers/personal/hopyo").json()["줄"]
|
||||
assert rows[0]["파일"].startswith("붙여넣기") and rows[0]["시트"] == "붙여넣기"
|
||||
# 718-1 — 붙여넣기 실무자료 = 지문 없음 → 불러오기 목록엔 숨김 · 파일은 남음
|
||||
assert client.get("/api/m02/layers/personal/hopyo").json()["줄"] == []
|
||||
doc = client.get(f"/api/m02/layers/personal/hopyo/{got['키']}").json()
|
||||
assert doc["파일"].startswith("붙여넣기") and doc["시트"] == "붙여넣기"
|
||||
|
||||
|
||||
def _block(rows, no):
|
||||
|
||||
@@ -211,6 +211,9 @@ def test_못_잇는_짝(geo):
|
||||
)
|
||||
def test_기억_층(tmp_path, monkeypatch, role, layer):
|
||||
monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer)
|
||||
monkeypatch.setattr(route.m02_store, "FOLDER", tmp_path / "system")
|
||||
master = {"a||": {"확정": "earth:9"}, "m||": {"확정": "earth:1"}} # 시스템 승인(718-2)
|
||||
hopyo_store.remember(tmp_path / "system", master, lk.MEMORY)
|
||||
session = {"company_id": 7, "user_id": 3, "role": role}
|
||||
assert route.keep(session, {"a||": {"확정": "earth:4", "이름": "절토(토사)"}}) == layer
|
||||
assert route.keep(session, {}) is None
|
||||
@@ -220,7 +223,8 @@ def test_기억_층(tmp_path, monkeypatch, role, layer):
|
||||
got = route.recalled(session)
|
||||
assert got["a||"]["확정"] == ("earth:4" if layer == "personal" else "earth:5")
|
||||
assert got["b||"]["확정"] == "x"
|
||||
assert route.recalled({"user_id": 3, "role": role}) == {} # 회사 없음 = 층 없음
|
||||
assert got["m||"]["확정"] == "earth:1" # 회사 · 개인 줄이 마스터보다 앞섬(a = 아래 층)
|
||||
assert route.recalled({"user_id": 3, "role": role}) == master # 회사 없음 = 마스터만
|
||||
|
||||
|
||||
def _same(a, b):
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""709 쪽 단추 부품 지킴 시험 — 쪽 수 · 화면 높이 → 줄 수 · 쪽 크기 바뀔 때 첫 줄 유지(순수 함수).
|
||||
|
||||
`ui_template/ui_template_pager.ts` 의 「순수 셈」 구간만 떼어 node 로 돌림(화면 · 서버 없음).
|
||||
부품 모음 견본 · 옛 이름 `buildPager` 가 새 부품을 부르는지도 글로 잡음.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PAGER = ROOT / "ui_template" / "ui_template_pager.ts"
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
|
||||
def _pure() -> str:
|
||||
text = PAGER.read_text(encoding="utf-8")
|
||||
start = text.index("export const ROW_H")
|
||||
end = text.index("/* ── 아래부터 화면 재기")
|
||||
return text[start:end]
|
||||
|
||||
|
||||
def _run(body: str) -> object:
|
||||
script = _pure() + "\n" + body
|
||||
out = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert out.returncode == 0, out.stderr
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def test_쪽_수():
|
||||
got = _run(
|
||||
"console.log(JSON.stringify([pageCount(0,30), pageCount(30,30), pageCount(31,30), pageCount(1469,30)]));"
|
||||
)
|
||||
assert got == [1, 1, 2, 49]
|
||||
|
||||
|
||||
def test_화면_높이_줄_수():
|
||||
# 머리 40 + 줄 40 · 표 칸 위 200 · 쪽 단추 자리 64 → 1080: (816-40)/40=19 · 768: (504-40)/40=11
|
||||
got = _run(
|
||||
"console.log(JSON.stringify(["
|
||||
"fitRows(1080-200-PAGER_ROOM), fitRows(768-200-PAGER_ROOM), "
|
||||
"fitRows(100), fitRows(100000), fitRows(Number.NaN), fitRows(520, 36, 40)]));"
|
||||
)
|
||||
assert got == [19, 11, 5, 200, 5, 13]
|
||||
|
||||
|
||||
def test_높이가_클수록_줄이_많음():
|
||||
got = _run(
|
||||
"const a=[]; for (let h=300; h<=1400; h+=50) a.push(fitRows(h)); "
|
||||
"console.log(JSON.stringify(a.every((v,i)=>i===0||v>=a[i-1])));"
|
||||
)
|
||||
assert got is True
|
||||
|
||||
|
||||
def test_쪽_크기_바뀌어도_첫_줄_유지():
|
||||
# 30줄 쪽의 3쪽(첫 줄 61번째) → 20줄 쪽이면 4쪽(61~80) · 50줄 쪽이면 2쪽(51~100)
|
||||
got = _run(
|
||||
"console.log(JSON.stringify([pageKeepFirst(3,30,20), pageKeepFirst(3,30,50), pageKeepFirst(1,30,12)]));"
|
||||
)
|
||||
assert got == [4, 2, 1]
|
||||
|
||||
|
||||
def test_옛_이름은_새_부품():
|
||||
cells = (ROOT / "M01_MasterData" / "M01_MasterData_UI_Cells.ts").read_text(encoding="utf-8")
|
||||
assert re.search(r"export const buildPager = createPager;", cells)
|
||||
assert "createButton" not in cells
|
||||
parts = (ROOT / "ui_template" / "ui_template_ui_kit_parts.ts").read_text(encoding="utf-8")
|
||||
assert 'file: "ui_template_pager.ts"' in parts and 'file: "ui_template_divider.ts"' in parts
|
||||
@@ -0,0 +1,179 @@
|
||||
"""M02 단가산출(SK) 문서 꼴 · 줄 정체 · 고친 줄(PLAN 715-1a · `M02_Sangeun_Doc`) — 엔진 안 부름."""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
ROW = {
|
||||
"키": "GX000001",
|
||||
"결과단위": "원/㎡",
|
||||
"입력": [{"이름": "높이"}, {"이름": "돌", "고르기": ["깬잡석", "견치돌"]}],
|
||||
"호표": [
|
||||
{
|
||||
"종류": "인력",
|
||||
"요소": "LB000033",
|
||||
"이름": "석공",
|
||||
"수량": "높이 * 0.1",
|
||||
"비목": "노무비",
|
||||
},
|
||||
{"종류": "재료", "요소": {"검색어": "모래"}, "이름": "모래", "수량": "1", "비목": "재료비"},
|
||||
{"종류": "인력", "요소": "LB000033", "이름": "석공", "수량": "0.2", "비목": "노무비"},
|
||||
{"종류": "재료", "요소": {"검색어": "자갈"}, "이름": "자갈", "수량": "2", "비목": "재료비"},
|
||||
{
|
||||
"종류": "로직",
|
||||
"요소": "로직(GC000276, 기계='5401-0017')",
|
||||
"이름": "굴착기",
|
||||
"수량": "0.5",
|
||||
},
|
||||
],
|
||||
"덧줄": [{"이름": "공구손료", "식": "(석공 * LB000033) * 3 / 100", "비목": "경비"}],
|
||||
}
|
||||
|
||||
|
||||
def _doc(**extra):
|
||||
return {
|
||||
"키": "SK-000001",
|
||||
"이름": "돌쌓기 내 값",
|
||||
"단위": "㎡",
|
||||
"바탕": {"로직": "GX000001"},
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def test_line_ids_kind_key_n():
|
||||
assert sd.line_ids(ROW) == [
|
||||
"labor:LB000033#0",
|
||||
"material:모래#0",
|
||||
"labor:LB000033#1",
|
||||
"material:자갈#0",
|
||||
"logic:GC000276#0",
|
||||
"pct:공구손료#0",
|
||||
]
|
||||
|
||||
|
||||
def test_clean_shape_and_refusals():
|
||||
got = sd.clean(_doc(입력값={"높이": 3, "모름": 1, "돌": ""}, 원값=1), ROW)
|
||||
assert got["입력값"] == {"높이": 3} and got["종류"] == "단가산출" and got["뺀줄"] == []
|
||||
for bad, why in (
|
||||
({"키": "SC-000001"}, "SK-"),
|
||||
({"이름": " "}, "이름"),
|
||||
({"단위": ""}, "단위"),
|
||||
({"바탕": {"참조": {"키": "SC-000001"}}}, "SK-"),
|
||||
({"덮개": {"labor:LB000033#0": {"qty": "식"}}}, "수가 아님"),
|
||||
({"덮개": {"labor:LB000033#0": {"qty": 1, "배율": 2}}}, "하나"),
|
||||
({"덮개": {"labor:LB#x": {"qty": 1}}}, "kind:키#n"),
|
||||
({"뺀줄": ["material:모래#0", "material:모래#0"]}, "두 번"),
|
||||
({"열린입력": ["높이"], "입력값": {"높이": 3}}, "열린입력"),
|
||||
({"더한줄": [{"종류": "인력", "요소": "LB000002"}]}, "수량"),
|
||||
({"더한줄": [{"종류": "식", "수량": 1}]}, "종류"),
|
||||
):
|
||||
with pytest.raises(sd.DocError, match=why):
|
||||
sd.clean(_doc(**bad), ROW)
|
||||
# 원값 · 바탕판 = 서버 몫 — 브라우저 값 버림
|
||||
got = sd.clean(_doc(덮개={"labor:LB000033#0": {"qty": 0.5, "원값": 9}}), ROW)
|
||||
assert got["덮개"] == {"labor:LB000033#0": {"qty": 0.5}}
|
||||
|
||||
|
||||
def test_open_inputs_only_ratio():
|
||||
with pytest.raises(sd.DocError, match="배율만"):
|
||||
sd.clean(_doc(열린입력=["돌"], 덮개={"labor:LB000033#0": {"qty": 1}}), ROW)
|
||||
got = sd.clean(_doc(열린입력=["돌"], 덮개={"labor:LB000033#0": {"배율": 1.1}}), ROW)
|
||||
assert got["덮개"]["labor:LB000033#0"] == {"배율": 1.1}
|
||||
|
||||
|
||||
def test_patched_cover_qty_price_ratio():
|
||||
doc = sd.clean(
|
||||
_doc(
|
||||
덮개={
|
||||
"labor:LB000033#1": {"qty": 0.25, "labor": 200000},
|
||||
"labor:LB000033#0": {"배율": 1.5},
|
||||
"logic:GC000276#0": {"expense": 1000},
|
||||
"pct:공구손료#0": {"qty": 5},
|
||||
}
|
||||
),
|
||||
ROW,
|
||||
)
|
||||
got = sd.patched(ROW, doc)
|
||||
rows = got["줄"]["호표"]
|
||||
assert rows[0]["수량"] == "(높이 * 0.1) * 1.5"
|
||||
assert rows[2]["수량"] == "0.25" and rows[2]["단가"] == Decimal(200000)
|
||||
assert rows[4]["값묶음"] == {"경비": Decimal(1000)}
|
||||
assert got["줄"]["덧줄"][0]["식"] == "(석공 * LB000033) * 5 / 100"
|
||||
assert got["짝잃음"] == [] and ROW["호표"][2]["수량"] == "0.2" # 바탕은 안 바뀜
|
||||
|
||||
|
||||
def test_drop_keeps_pick_on_its_line():
|
||||
"""뺀줄 뒤 고름 제자리 — 자갈(바탕 3째) 고름이 뺀줄 뒤 새 2째로 옮겨 감 · 모래 줄에 안 붙음."""
|
||||
doc = sd.clean(_doc(뺀줄=["material:모래#0"], 고름={"material:자갈#0": "MT000010"}), ROW)
|
||||
got = sd.patched(ROW, doc)
|
||||
names = [x["이름"] for x in got["줄"]["호표"]]
|
||||
assert names == ["석공", "석공", "자갈", "굴착기"]
|
||||
assert got["고름"] == {"2": "MT000010"}
|
||||
assert got["정체"][:4] == [
|
||||
"labor:LB000033#0",
|
||||
"labor:LB000033#1",
|
||||
"material:자갈#0",
|
||||
"logic:GC000276#0",
|
||||
]
|
||||
|
||||
|
||||
def test_added_lines_before_extras():
|
||||
doc = sd.clean(
|
||||
_doc(
|
||||
더한줄=[
|
||||
{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": 0.1},
|
||||
{
|
||||
"종류": "로직",
|
||||
"로직": "GC000001",
|
||||
"입력값": {"규격": "가", "두께": 2},
|
||||
"수량": 1,
|
||||
},
|
||||
{"참조": {"층": "system", "주인": None, "키": "SK-000002"}, "수량": 2},
|
||||
]
|
||||
),
|
||||
ROW,
|
||||
)
|
||||
got = sd.patched(ROW, doc)
|
||||
added = got["줄"]["호표"][5:]
|
||||
assert added[0]["비목"] == "노무비" and added[0]["수량"] == "0.1"
|
||||
assert added[1]["요소"] == "로직(GC000001, 규격='가', 두께=2)"
|
||||
assert added[2]["참조"]["키"] == "SK-000002" and added[2]["값묶음"] is None
|
||||
assert got["정체"][5:] == ["add:#0", "add:#1", "add:#2", "pct:공구손료#0"]
|
||||
|
||||
|
||||
def test_lost_covers_kept_and_listed():
|
||||
"""짝 잃은 덮개 = 문서에 남김 + 목록(지우지 않음) · 비목 다른 단가 · 재료 아닌 고름."""
|
||||
doc = sd.clean(
|
||||
_doc(
|
||||
덮개={"labor:LB999999#0": {"qty": 1}, "labor:LB000033#0": {"material": 5}},
|
||||
고름={"labor:LB000033#1": "MT000010"},
|
||||
뺀줄=["material:없음#0"],
|
||||
),
|
||||
ROW,
|
||||
)
|
||||
got = sd.patched(ROW, doc)
|
||||
why = {x["cid"]: x["까닭"] for x in got["짝잃음"]}
|
||||
assert why["labor:LB999999#0"].startswith("덮개") and "비목 다름" in why["labor:LB000033#0"]
|
||||
assert why["labor:LB000033#1"].startswith("고름") and why["material:없음#0"].startswith("뺀줄")
|
||||
assert "labor:LB999999#0" in doc["덮개"]
|
||||
|
||||
|
||||
def test_chain_moves_base_picks():
|
||||
"""바탕 SK 사슬 — 바탕 쪽 차례 고름이 아래 SK 뺀줄 뒤에도 그 줄에."""
|
||||
first = sd.patched(ROW, sd.clean(_doc(고름={"material:자갈#0": "MT000010"}), ROW))
|
||||
child = sd.clean({**_doc(뺀줄=["labor:LB000033#0"]), "바탕": None}, first["줄"])
|
||||
got = sd.patched(first["줄"], child, first["고름"])
|
||||
assert got["고름"] == {"2": "MT000010"} and got["줄"]["호표"][2]["이름"] == "자갈"
|
||||
|
||||
|
||||
def test_empty_base():
|
||||
row = sd.empty_row({"키": "SK-000003", "이름": "자체", "단위": "식"})
|
||||
doc = sd.clean(
|
||||
{"키": "SK-000003", "이름": "자체", "단위": "식", "바탕": None,
|
||||
"더한줄": [{"종류": "재료", "요소": "MT000010", "수량": 3, "단가": 1200}]},
|
||||
row,
|
||||
) # fmt: skip
|
||||
got = sd.patched(row, doc)
|
||||
assert got["줄"]["호표"][0]["단가"] == Decimal(1200) and got["줄"]["결과단위"] == "원/식"
|
||||
@@ -0,0 +1,191 @@
|
||||
"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2 · `M02_Sangeun_Calc` · `M02_Labor_Ref`) — 임시 storage.
|
||||
|
||||
고친 줄 값 = 손셈 · 덧줄 % 엔진이 다시 셈 · 뺀줄 뒤 고름 제자리 · 짝 잃음 남김 · 원값 서버 ·
|
||||
순환 422 · 쓰는 곳 지우기 409 · 사전조합이 SK 를 담음 · 층 복사 왕복 같은 값.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
from resources.tester.test_m02_labor_ref import COMPANY, MINE, world # noqa: F401 — 같은 임시 층
|
||||
|
||||
PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」
|
||||
TWO_MATS = "GC000565" # 원/m · 재료 줄 둘(검색어)
|
||||
|
||||
|
||||
def _sk(folder, **doc):
|
||||
with store.at(folder):
|
||||
return refs.create("sangeun", {"이름": "내 단가산출", "단위": "㎡", "더한줄": [], **doc})[
|
||||
"이름"
|
||||
]
|
||||
|
||||
|
||||
def _doc(folder, key):
|
||||
with store.at(folder):
|
||||
return store.read("sangeun", key)["문서"]
|
||||
|
||||
|
||||
def _calc(folder, key, given=None):
|
||||
with store.at(folder):
|
||||
return calc.calc_doc(_doc(folder, key), given)
|
||||
|
||||
|
||||
def _whole():
|
||||
return m01.loaded()[1]
|
||||
|
||||
|
||||
def _bare(code):
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
return labor.run_line(_whole(), code, {})
|
||||
|
||||
|
||||
def test_qty_cover_hand_and_pct_recomputed(world):
|
||||
"""품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄이 덮인 노무비에서 다시 셈 · 원값 = 서버가 그때 M01 값."""
|
||||
bare = _bare(PCT)
|
||||
first, second = bare["줄"][0], bare["줄"][1]
|
||||
key = _sk(
|
||||
world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5, "원값": 9}}
|
||||
)
|
||||
doc = _doc(world["personal"], key)
|
||||
assert doc["덮개"]["labor:LB000036#0"]["원값"]["qty"] == pytest.approx(float(first["수량"]))
|
||||
assert len(doc["바탕판"]) == 16
|
||||
got = _calc(world["personal"], key)
|
||||
labor_sum = Decimal("0.5") * Decimal(str(first["단가"])) + Decimal(str(second["금액"]))
|
||||
assert got["기준"]["노무비"] == pytest.approx(float(labor_sum))
|
||||
assert got["기준"]["경비"] == pytest.approx(float(labor_sum * 3 / 100), abs=1)
|
||||
assert got["줄"][0]["덮개"] == ["qty"] and got["줄"][0]["cid"] == "labor:LB000036#0"
|
||||
assert got["짝잃음"] == []
|
||||
# 덧줄 율 덮개 = 5 %
|
||||
key = _sk(
|
||||
world["personal"],
|
||||
바탕={"로직": PCT},
|
||||
덮개={"pct:" + bare["줄"][2]["이름"] + "#0": {"qty": 5}},
|
||||
)
|
||||
got = _calc(world["personal"], key)
|
||||
assert got["기준"]["경비"] == pytest.approx(float(bare["기준"]["노무비"]) * 0.05, abs=1)
|
||||
|
||||
|
||||
def test_drop_then_pick_stays_on_its_line(world):
|
||||
"""뺀줄 뒤 고름 제자리 — 둘째 재료 줄을 고른 자재가 첫 재료 줄을 뺀 뒤에도 그 줄 품목."""
|
||||
import master_pick as mp
|
||||
|
||||
whole = _whole()
|
||||
row = whole.logic(TWO_MATS)
|
||||
item = row["호표"][2]
|
||||
picks = mp.search(whole, item["요소"], {}, item.get("단위"))
|
||||
want = picks[1] if len(picks) > 1 else picks[0]
|
||||
first_cid = "material:" + row["호표"][1]["이름"] + "#0"
|
||||
second_cid = "material:" + item["이름"] + "#0"
|
||||
key = _sk(
|
||||
world["personal"],
|
||||
단위="m",
|
||||
바탕={"로직": TWO_MATS},
|
||||
뺀줄=[first_cid],
|
||||
고름={second_cid: want},
|
||||
자재표시={second_cid: {"별도계상": "직접계산"}}, # 기본 = 별도계상(222-1) 줄 빠짐
|
||||
)
|
||||
got = _calc(world["personal"], key)
|
||||
names = [x["이름"] for x in got["줄"]]
|
||||
assert row["호표"][1]["이름"] not in names
|
||||
line = next(x for x in got["줄"] if x["cid"] == second_cid)
|
||||
assert line["품목"]["키"] == want
|
||||
|
||||
|
||||
def test_lost_cover_kept_and_listed(world):
|
||||
key = _sk(world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB999999#0": {"qty": 1}})
|
||||
got = _calc(world["personal"], key)
|
||||
assert got["짝잃음"][0]["cid"] == "labor:LB999999#0"
|
||||
assert "labor:LB999999#0" in _doc(world["personal"], key)["덮개"]
|
||||
assert got["기준"]["계"] == pytest.approx(float(_bare(PCT)["기준"]["계"]))
|
||||
|
||||
|
||||
def test_chain_loop_422_and_delete_409(world):
|
||||
"""바탕 사슬 A ← B · B 를 A 의 바탕으로 = 422 · 쓰는 곳 있는 A 지우기 = 409(이름)."""
|
||||
a = _sk(world["personal"], 바탕={"로직": PCT})
|
||||
b = _sk(
|
||||
world["personal"], 바탕={"참조": {**MINE, "키": a}}, 덮개={"labor:LB000002#0": {"qty": 0}}
|
||||
)
|
||||
got = _calc(world["personal"], b)
|
||||
bare = _bare(PCT)
|
||||
assert got["기준"]["노무비"] == pytest.approx(float(bare["줄"][0]["금액"]))
|
||||
with store.at(world["personal"]):
|
||||
doc = store.read("sangeun", a)
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
store.write(
|
||||
"sangeun", a, doc["판"], {**doc["문서"], "바탕": {"참조": {**MINE, "키": b}}}
|
||||
)
|
||||
assert e.value.status == 422
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
store.delete("sangeun", a)
|
||||
assert e.value.status == 409 and "단가산출 개인 7/42" in str(e.value.detail)
|
||||
|
||||
|
||||
def test_precombo_holds_sangeun(world):
|
||||
"""사전조합 참조 줄이 SK 를 담음 — 1 단위 값 × 수량 · SK 지우기 막음."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
sk = _sk(world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5}})
|
||||
with store.at(world["personal"]):
|
||||
sc = refs.create(
|
||||
"precombo",
|
||||
{"이름": "조합", "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}]},
|
||||
)["이름"]
|
||||
got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {})
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
store.delete("sangeun", sk)
|
||||
one = _calc(world["personal"], sk)["1단위"]["계"]
|
||||
assert got["반영합"]["계"] == pytest.approx(one * 2, abs=1)
|
||||
assert e.value.status == 409 and "사전조합 개인 7/42" in str(e.value.detail)
|
||||
|
||||
|
||||
def test_layer_copy_round_trip(world):
|
||||
"""개인 B(바탕 = 개인 A) → 회사로 복사 = A 도 같이(새 키 · 본뜬) → 개인으로 다시 = 같은 값."""
|
||||
a = _sk(world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5}})
|
||||
b = _sk(world["personal"], 바탕={"참조": {**MINE, "키": a}}, 뺀줄=["labor:LB000002#0"])
|
||||
want = _calc(world["personal"], b)["기준"]
|
||||
src = _doc(world["personal"], b)
|
||||
with store.at(world["company"]):
|
||||
made = refs.create(
|
||||
"sangeun", refs.carry(refs.origin_mark(src, {**MINE, "키": b}), ("personal", "7/42"))
|
||||
)
|
||||
copy = _doc(world["company"], made["이름"])
|
||||
assert copy["바탕"]["참조"]["층"] == "company" and copy["본뜬"]["키"] == b
|
||||
assert _calc(world["company"], made["이름"])["기준"] == want
|
||||
with store.at(world["personal"]):
|
||||
back = refs.create(
|
||||
"sangeun",
|
||||
refs.carry(refs.origin_mark(copy, {**COMPANY, "키": made["이름"]}), ("company", "7")),
|
||||
)
|
||||
assert _doc(world["personal"], back["이름"])["바탕"]["참조"]["층"] == "company"
|
||||
assert _calc(world["personal"], back["이름"])["기준"] == want
|
||||
|
||||
|
||||
def test_refusals(world):
|
||||
with store.at(world["personal"]):
|
||||
for bad, status in (
|
||||
({"이름": "x", "더한줄": []}, 400), # 단위 빔
|
||||
({"이름": "x", "단위": "㎡", "더한줄": [], "바탕": {"로직": "GC999999"}}, 400),
|
||||
(
|
||||
{
|
||||
"이름": "x",
|
||||
"단위": "㎡",
|
||||
"더한줄": [],
|
||||
"바탕": {"참조": {**MINE, "키": "SK-000999"}},
|
||||
},
|
||||
400,
|
||||
),
|
||||
({"이름": "x", "단위": "㎡"}, 400), # 뼈대(더한줄) 없음
|
||||
):
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
refs.create("sangeun", bad)
|
||||
assert e.value.status == status, bad
|
||||
key = _sk(world["personal"], 바탕={"로직": PCT})
|
||||
with store.at(world["personal"]), pytest.raises(store.StoreError) as e:
|
||||
calc.calc_doc({**_doc(world["personal"], key), "바탕": {"참조": {**MINE, "키": key}}})
|
||||
assert e.value.status == 422
|
||||
@@ -0,0 +1,67 @@
|
||||
"""일위대가 불러오기 한시(PLAN 718-1) — 올림 지문 · [끝내기] 지움 · 겹침 막음.
|
||||
|
||||
같은 엑셀 작업 중 다시 올림 = 그 파일 이어서(새 키 없음) · [끝내기] = HP · 격자 · 맞춤셈 캐시 0 ·
|
||||
만든 일위대가 · 별칭 장부 그대로 · 끝낸 같은 엑셀 다시 올림 409 · 만든 것 없이 지움 = 다시 올림 됨 ·
|
||||
옛 HP(지문 없음) = 파일 그대로 목록에서만 숨김 · 키 번 = 지운 뒤에도 다시 안 씀. 층 = 임시 폴더.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Fit as fit
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hs
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester.test_m02_hopyo_excel import NORTH, URL, _post, client # noqa: F401
|
||||
|
||||
ALL = "/api/m02/layers/personal/hopyo-build-all"
|
||||
|
||||
|
||||
def _keys(client):
|
||||
return [r["키"] for r in client.get(URL.format("personal")).json()["줄"]]
|
||||
|
||||
|
||||
def test_이어서_끝내기_지움_겹침_막음(client):
|
||||
folder = layers.personal_dir(7, 42)
|
||||
first = _post(client).json()
|
||||
assert first["키"] == "HP-000001" and len(first["지문"]) == 64
|
||||
again = _post(client).json()
|
||||
assert again["키"] == "HP-000001" and again["이어서"] is True # 작업 중 같은 엑셀 = 새 키 없음
|
||||
assert _keys(client) == ["HP-000001"]
|
||||
made = client.post(ALL, json={"층": "personal", "키": "HP-000001"}).json()
|
||||
assert len(made["만듦"]) == 68
|
||||
hs.remember(folder, {"장부 줄": {"갈래": "안 씀"}}) # 별칭 장부 = 끝내기 뒤 남음
|
||||
cache = fit.cache_path(folder, "HP-000001")
|
||||
cache.write_text("{}", encoding="utf-8")
|
||||
old_cache = cache.with_name("_".join(cache.name.split("_")[:2]) + "_옛판_1.json")
|
||||
old_cache.write_text("{}", encoding="utf-8")
|
||||
sc_before = sorted((folder / "precombo").glob("SC-*.json"))
|
||||
|
||||
res = client.delete(URL.format("personal") + "/HP-000001")
|
||||
assert res.status_code == 200 and res.json() == {"지움": "HP-000001", "만든": 68}
|
||||
assert not list((folder / hs.DIRNAME).glob("HP-*.json")) # 이번 HP 0
|
||||
assert not list((folder / hs.DIRNAME / hs.GRID).glob("HP-*.json"))
|
||||
assert not cache.exists() and not old_cache.exists()
|
||||
assert hs.memory(folder) == {"장부 줄": {"갈래": "안 씀"}}
|
||||
assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before # 만든 일위대가 그대로
|
||||
|
||||
block = _post(client) # 끝낸 같은 엑셀 = 막음 · 안내
|
||||
assert block.status_code == 409 and "이미 불러온 엑셀" in block.json()["detail"]
|
||||
assert "일위대가 68" in block.json()["detail"] and _keys(client) == []
|
||||
assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before
|
||||
|
||||
|
||||
def test_만든_것_없이_지움_다시_올림_키_번_안_되씀(client):
|
||||
assert _post(client).json()["키"] == "HP-000001"
|
||||
assert client.delete(URL.format("personal") + "/HP-000001").json()["만든"] == 0
|
||||
got = _post(client) # 만든 것 없음 = 막지 않음 · 번은 이어서
|
||||
assert got.status_code == 200 and got.json()["키"] == "HP-000002"
|
||||
|
||||
|
||||
def test_옛_HP_목록에서만_숨김(client):
|
||||
folder = layers.personal_dir(7, 42) / hs.DIRNAME
|
||||
folder.mkdir(parents=True)
|
||||
old = folder / "HP-000007.json"
|
||||
old.write_text(json.dumps({"키": "HP-000007", "파일": "옛.xls", "호표": []}), encoding="utf-8")
|
||||
assert _keys(client) == []
|
||||
assert client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"] == []
|
||||
assert _post(client).json()["키"] == "HP-000008" # 옛 번 뒤로
|
||||
assert old.is_file() and _keys(client) == ["HP-000008"]
|
||||
@@ -0,0 +1,134 @@
|
||||
"""별칭 장부 시스템 모으기(PLAN 718-2 · `M01_MasterData_Alias`) — 회사 · 개인 층 맞춤 +
|
||||
내역맞춤 모음 · 같은 짝 묶기 · 회사 수 · 사람 수(익명) · 충돌 · 승인 → 마스터 줄 ·
|
||||
반려 기억 · 다른 회사 새 사용자가 마스터 줄을 씀
|
||||
· 시스템 관리자만 · master_data 원본 그대로. 층 · storage 는 임시 폴더.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from B09_Estimation import B09_Estimation_Bill_Link as lk
|
||||
from B09_Estimation import B09_Estimation_Router_Link as route
|
||||
from common_util.common_util_auth import require_system_admin, verify_session
|
||||
from M01_MasterData import M01_MasterData_Alias as alias
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hs
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
# 올리기 길 시험의 `client` 마당(임시 storage · 마스터 층)을 같이 씀
|
||||
# ruff: noqa: F811
|
||||
from resources.tester.test_m02_hopyo_excel import client, master_hash # noqa: F401
|
||||
|
||||
DIG = {"이름": "구조물터파기(인력100%)", "규격": "토사", "단위": "㎥"}
|
||||
DIG_KEY = "구조물터파기(인력100%)|토사|m3"
|
||||
FORM_KEY = "합판거푸집|4회|m2"
|
||||
CUT_KEY = "토사절취||m3"
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _pair(code):
|
||||
return ledger.check(DIG_KEY, {"갈래": "짝", "짝": [{"로직": code, "배": 1}]})
|
||||
|
||||
|
||||
def _seed():
|
||||
"""회사 7(개인 42 · 43 = 같은 짝) · 회사 8(개인 50 = 다른 짝 · 회사 층 = 안 씀) · 내역맞춤."""
|
||||
for user, who in ((42, "갑"), (43, "을")):
|
||||
mine = layers.personal_dir(7, user)
|
||||
ledger.write(mine, {DIG_KEY: _pair("GF000101")}, {"id": user, "이름": who}, "가공사.xls")
|
||||
ledger.write(layers.personal_dir(8, 50), {DIG_KEY: _pair("GF000103")}, {"id": 50}, "나.xls")
|
||||
skip = ledger.check(FORM_KEY, {"갈래": "안 씀"})
|
||||
ledger.write(layers.company_dir(8), {FORM_KEY: skip}, {"id": 51, "이름": "병"}, "다.xls")
|
||||
live = {"확정": "earth:4", "이름": "절토(토사)"}
|
||||
hs.remember(layers.personal_dir(7, 42), {CUT_KEY: live, "a||": {lk.FORGOT: True}}, lk.MEMORY)
|
||||
hs.remember(layers.company_dir(8), {CUT_KEY: live}, lk.MEMORY)
|
||||
|
||||
|
||||
def _one(got, kind, key, code=None):
|
||||
rows = [x for x in got["후보"] if (x["종류"], x["열쇠"]) == (kind, key)]
|
||||
if code:
|
||||
rows = [x for x in rows if x["짝"][0]["로직"] == code]
|
||||
assert len(rows) == 1, rows
|
||||
return rows[0]
|
||||
|
||||
|
||||
def test_모음_묶기_익명_충돌(client):
|
||||
before = master_hash()
|
||||
_seed()
|
||||
got = alias.gather()
|
||||
assert got["모은곳"] == {"회사": 2, "사람": 5} and got["반려"] == 0
|
||||
assert len(got["후보"]) == 4 # 터파기 둘(충돌) · 거푸집 안 씀 · 토사절취 내역맞춤(같은 짝 하나)
|
||||
same = _one(got, alias.ALIAS, DIG_KEY, "GF000101")
|
||||
assert (same["회사"], same["사람"], same["충돌"], same["마스터"]) == (1, 2, True, None)
|
||||
assert _one(got, alias.ALIAS, DIG_KEY, "GF000103")["사람"] == 1
|
||||
form = _one(got, alias.ALIAS, FORM_KEY)
|
||||
assert (form["갈래"], form["충돌"], form["사람"]) == ("안 씀", False, 1)
|
||||
cut = _one(got, alias.BILL, CUT_KEY)
|
||||
assert (cut["회사"], cut["사람"], cut["확정"], cut["충돌"]) == (2, 2, "earth:4", False)
|
||||
text = json.dumps(got, ensure_ascii=False)
|
||||
for secret in ("갑", "을", "병", "가공사.xls", "누가", "처음파일", "주인"):
|
||||
assert secret not in text # 익명 — 회사 수 · 사람 수만
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
def test_승인_마스터_줄_반려_기억(client, tmp_path, monkeypatch):
|
||||
before = master_hash()
|
||||
_seed()
|
||||
newbie = layers.personal_dir(9, 99) # 처음 쓰는 다른 회사 사용자
|
||||
assert ledger.recall(DIG, newbie) is None
|
||||
sig = _one(alias.gather(), alias.ALIAS, DIG_KEY, "GF000101")["서명"]
|
||||
got = alias.approve(alias.ALIAS, DIG_KEY, sig, {"id": 1, "이름": "관리자"})
|
||||
hit = ledger.recall(DIG, newbie)
|
||||
assert (hit["층"], hit["짝"][0]["로직"]) == ("system", "GF000101") # 승인 → 모두가 씀
|
||||
master = ledger.read(client.system)
|
||||
assert master[DIG_KEY]["처음파일"] is None # 마스터 줄에도 남의 파일 이름 없음
|
||||
other = _one(got, alias.ALIAS, DIG_KEY) # 승인한 짝 = 마스터와 같음 → 후보에서 빠짐
|
||||
assert other["짝"][0]["로직"] == "GF000103"
|
||||
assert (other["마스터"], other["충돌"]) == ("다름", True)
|
||||
got = alias.reject(alias.ALIAS, DIG_KEY, other["서명"], {"id": 1})
|
||||
assert got["반려"] == 1 and not [x for x in got["후보"] if x["열쇠"] == DIG_KEY]
|
||||
assert alias.gather()["반려"] == 1 # 반려 기억 = 다시 모아도 안 보임
|
||||
with pytest.raises(alias.store.StoreError) as error:
|
||||
alias.approve(alias.ALIAS, DIG_KEY, other["서명"], {"id": 1})
|
||||
assert error.value.status == 404
|
||||
with pytest.raises(alias.store.StoreError):
|
||||
alias.approve("모름", DIG_KEY, sig, {"id": 1})
|
||||
# 내역맞춤 승인 → 마스터 기억 → 다른 회사 새 사용자 B09 잇기 기억에 나옴
|
||||
cut = _one(alias.gather(), alias.BILL, CUT_KEY)
|
||||
alias.approve(alias.BILL, CUT_KEY, cut["서명"], {"id": 1})
|
||||
assert [r["열쇠"] for r in alias.master_bill()] == [CUT_KEY]
|
||||
monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer)
|
||||
user = {"company_id": 9, "user_id": 99, "role": "USER"}
|
||||
assert route.recalled(user)[CUT_KEY]["확정"] == "earth:4"
|
||||
assert alias.forget_bill(CUT_KEY) == [] and CUT_KEY not in route.recalled(user)
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
def test_길_시스템_관리자만(client):
|
||||
_seed()
|
||||
app = FastAPI()
|
||||
app.include_router(alias.router, dependencies=[Depends(require_system_admin)])
|
||||
who = {"user_id": 1, "company_id": None, "role": "USER", "name": "갑"}
|
||||
app.dependency_overrides[verify_session] = lambda: who
|
||||
web = TestClient(app)
|
||||
for role in ("USER", "ADMIN"):
|
||||
who["role"] = role
|
||||
assert web.get("/api/m01/alias-ledger").status_code == 403
|
||||
who["role"] = "SYSTEM_ADMIN"
|
||||
got = web.get("/api/m01/alias-ledger").json()
|
||||
assert len(got["후보"]) == 4 and got["마스터내역"] == []
|
||||
form = _one(got, alias.ALIAS, FORM_KEY)
|
||||
pick = {"종류": alias.ALIAS, "열쇠": FORM_KEY, "서명": form["서명"]}
|
||||
res = web.post("/api/m01/alias-ledger/approve", json=pick)
|
||||
assert res.status_code == 200 and len(res.json()["후보"]) == 3
|
||||
assert ledger.read(client.system)[FORM_KEY]["갈래"] == "안 씀"
|
||||
assert web.post("/api/m01/alias-ledger/approve", json=pick).status_code == 404
|
||||
assert web.post("/api/m01/alias-ledger/reject", json={**pick, "서명": "x"}).status_code == 422
|
||||
gone = web.post("/api/m01/alias-ledger/master-bill/forget", json={"열쇠": CUT_KEY})
|
||||
assert gone.status_code == 404
|
||||
main = (ROOT / "main.py").read_text(encoding="utf-8")
|
||||
at = main.index("M01_MasterData.M01_MasterData_Alias")
|
||||
assert "dependencies=system_admin_only" in main[at : at + 120]
|
||||
@@ -58,7 +58,36 @@ def _cards(user: dict, company_id: int | None = None) -> list:
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _pages(user: dict, company_id: int | None = None) -> list:
|
||||
# ☰ 메뉴 = pageLinks(settingLinks 의 구조물 조합 줄을 바탕으로 페이지 둘만)
|
||||
script = (
|
||||
"const ROUTES = { M01_MASTER_DATA: 'm01-master-data',"
|
||||
" M02_MASTER_TEMPLATE: 'm02-master-template', B01_ACCOUNT: 'b01-account' };\n"
|
||||
"const L = (k: string) => k;\n"
|
||||
"let went = '';\n"
|
||||
"const navigateTo = (r: string, q?: Record<string, string>) =>"
|
||||
" { went = r + (q ? '?' + new URLSearchParams(q) : ''); };\n"
|
||||
+ _fn(_text(NAV), "settingLinks")
|
||||
+ "\n"
|
||||
+ _fn(_text(NAV), "pageLinks")
|
||||
+ "\nconst user = JSON.parse(process.argv[1]);"
|
||||
+ "\nconst links = pageLinks(user, JSON.parse(process.argv[2]));"
|
||||
+ "\nconsole.log(JSON.stringify(links.map((l: any) => {"
|
||||
+ " navigateTo(l.route, l.query); return [l.key, l.title, decodeURIComponent(went)]; })));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script, json.dumps(user), json.dumps(company_id)],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
M01 = "m01-master-data"
|
||||
M02 = "m02-master-template"
|
||||
READ_SRC = "B01_Dashboard_PermSourceRead"
|
||||
WRITE_M = "B01_Dashboard_PermMasterWrite"
|
||||
# 계산 자리(504-2) — 시스템 관리자만 · 이동 대신 창
|
||||
CALC_SITES = ["B01_CalcSites_Title", "B01_CalcSites_Desc", "calc-sites-dialog"]
|
||||
READ_CO = (
|
||||
@@ -68,21 +97,42 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite")
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_시스템_관리자_카드_다섯_고른_회사_해시():
|
||||
m = "B01_Dashboard_PermMasterWrite"
|
||||
def test_시스템_관리자_줄_칸별_여덟_고른_회사_해시():
|
||||
# 716-2 · 718-3 — 대시보드 설계 기본값 = 칸별 줄: M01 4(마스터 요소 · 단가산출 로직 · 구조물 옵션 정의 ·
|
||||
# 별칭 장부) +
|
||||
# M02 3(일위대가 · 구조물 조합 · 도면 양식) + 계산 자리
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [
|
||||
["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"],
|
||||
["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master&company=3"],
|
||||
["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"],
|
||||
["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"],
|
||||
["M01_Title", READ_SRC, M01],
|
||||
["B01_Dashboard_CardLogic", READ_SRC, f"{M01}?open=로직"],
|
||||
["B01_Dashboard_CardStructOptionDef", WRITE_M, f"{M01}?open=옵션"],
|
||||
["B01_Dashboard_CardAliasLedger", WRITE_M, f"{M01}?open=별칭"],
|
||||
["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=master&company=3"],
|
||||
["B01_Dashboard_CardStructCombo", WRITE_M, f"{M02}?open=구조물조합&root=master&company=3"],
|
||||
["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"],
|
||||
CALC_SITES,
|
||||
]
|
||||
# 회사가 없으면 해시에 안 실음
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[1][2] == (
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[5][2] == (
|
||||
f"{M02}?open=구조물조합&root=master"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_메뉴_페이지_둘만_역할별():
|
||||
# 716-2 — ☰ = M01 · M02 두 페이지(시스템 관리자) / M02 한 페이지(그 밖) · 열쇠 m01 · m02
|
||||
admin = _pages({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3)
|
||||
assert admin == [
|
||||
["m01", "B01_Dashboard_MasterElements", M01],
|
||||
["m02", "M02_Title", f"{M02}?open=구조물조합&root=personal&company=3"],
|
||||
]
|
||||
assert _pages({"role": "ADMIN", "is_master": False, "company_id": None}) == [
|
||||
["m02", "M02_Title", f"{M02}?open=구조물조합&root=company"],
|
||||
]
|
||||
assert _pages({"role": "USER", "is_master": False, "company_id": 1}) == [
|
||||
["m02", "M02_Title", f"{M02}?open=구조물조합&root=personal"],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_회사_관리자_개인_카드_하나():
|
||||
company = [
|
||||
@@ -191,14 +241,12 @@ def test_회사_찾기_거르기():
|
||||
def test_구조물_조합_기본_층_개인():
|
||||
"""123-1 — 회사 있는 사람은 역할 무관 구조물 조합 = 개인 · 회사 없으면 지금 규칙 · 다른 곳 그대로 ·
|
||||
134-4 — 층 없는 주소도 개인(회사 없으면 마스터) · 사전조합 기본 층도 같은 규칙."""
|
||||
m = "B01_Dashboard_PermMasterWrite"
|
||||
combo = f"{M02}?open=구조물조합&root=personal"
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3) == [
|
||||
["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"],
|
||||
["B01_Dashboard_CardStructCombo", m, f"{combo}&company=3"],
|
||||
["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"],
|
||||
["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"],
|
||||
CALC_SITES,
|
||||
rows = _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3)
|
||||
assert rows[4:7] == [
|
||||
["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=personal&company=3"],
|
||||
["B01_Dashboard_CardStructCombo", WRITE_M, f"{combo}&company=3"],
|
||||
["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"],
|
||||
]
|
||||
assert _cards({"role": "ADMIN", "is_master": False, "company_id": 1}) == [
|
||||
["B01_Dashboard_CardStructCombo", WRITE_CO, combo],
|
||||
@@ -206,7 +254,7 @@ def test_구조물_조합_기본_층_개인():
|
||||
assert _cards({"role": "USER", "is_master": True, "company_id": 1})[0][2] == combo
|
||||
assert _cards({"role": "USER", "is_master": False, "company_id": 1})[0][2] == combo
|
||||
# 개인 층 없음(회사 없음) = 지금 규칙
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[1][2] == (
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[5][2] == (
|
||||
f"{M02}?open=구조물조합&root=master"
|
||||
)
|
||||
assert _cards({"role": "ADMIN", "is_master": False, "company_id": None})[0][2] == (
|
||||
|
||||
@@ -179,8 +179,10 @@ def test_올리기_목록_읽기_지우기(client):
|
||||
assert (folder / hs.DIRNAME / "HP-000001.json").is_file()
|
||||
assert all(r["종류"] in layers.KINDS for r in layers.list_templates(folder)) # 양식에 안 섞임
|
||||
assert not any("실무자료" in str(p) for _, p in layers.layer_files("precombo"))
|
||||
assert _post(client).json()["키"] == "HP-000002"
|
||||
again = _post(client).json() # 718-1 — 작업 중 같은 엑셀 = 그 파일 이어서
|
||||
assert again["키"] == "HP-000001" and again["이어서"] is True
|
||||
assert client.delete(URL.format("personal") + "/HP-000001").status_code == 200
|
||||
assert _post(client).json()["키"] == "HP-000002" # 지운 번 다시 안 씀
|
||||
assert [r["키"] for r in client.get(URL.format("personal")).json()["줄"]] == ["HP-000002"]
|
||||
assert client.get(URL.format("personal") + "/HP-000001").status_code == 404
|
||||
assert client.get(URL.format("personal") + "/../x").status_code == 404
|
||||
|
||||
@@ -105,8 +105,8 @@ def test_끝까지_확인한_줄만_별칭(client):
|
||||
keys = {r["열쇠"]: r for r in rows}
|
||||
assert {p["로직"] for p in keys[rebar["맞춤"]["열쇠"]]["짝"]} == {"GC000206", "GC000207"}
|
||||
# 다시 올림 = 확인한 줄은 별칭 · 체크 · 만든 것이 보임
|
||||
_up(client, "HP-000002")
|
||||
again = {x["이름"]: x["맞춤"] for x in _one(client, "HP-000002", no)["줄"]}
|
||||
assert _post(client).json()["이어서"] is True # 718-1 — 같은 엑셀 = 그 파일 이어서(새 키 없음)
|
||||
again = {x["이름"]: x["맞춤"] for x in _one(client, "HP-000001", no)["줄"]}
|
||||
assert again["레미콘타설(장비사용)"]["상태"] == "별칭" and again["레미콘타설(장비사용)"]["체크"]
|
||||
assert again["철근가공조립(현장)"]["상태"] == "별칭"
|
||||
assert again["철근가공조립(현장)"]["대상"][0]["출처"] == {
|
||||
@@ -138,7 +138,9 @@ def test_막는_길(client):
|
||||
"/api/m02/layers/personal/hopyo-build",
|
||||
json={"층": "personal", "키": "HP-000001", "번": 1, "줄": []},
|
||||
)
|
||||
assert empty.status_code == 200 and empty.json()["줄수"]["품셈"] == 0 # 빈 몸 = 모두 고정(705-3)
|
||||
assert (
|
||||
empty.status_code == 200 and empty.json()["줄수"]["품셈"] == 0
|
||||
) # 빈 몸 = 모두 고정(705-3)
|
||||
money = client.post(
|
||||
"/api/m02/layers/personal/hopyo-build",
|
||||
json={"층": "personal", "키": "HP-000001", "번": 1, "줄": [{"차례": 1, "금액": 1}]},
|
||||
|
||||
@@ -84,13 +84,15 @@ def test_올리기_격자_200_404_지우기(client):
|
||||
path.unlink() # 옛 파일(324) = 격자 없음
|
||||
old = client.get(GRID.format("personal", "HP-000001"))
|
||||
assert old.status_code == 404 and old.json()["detail"] == "격자 없음 — 다시 올리면 보임"
|
||||
assert _post(client).status_code == 200
|
||||
assert client.delete(URL.format("personal") + "/HP-000001").status_code == 200
|
||||
assert _post(client).status_code == 200 # 718-1 — 같은 엑셀은 지운 뒤 새 번
|
||||
assert client.get(GRID.format("personal", "HP-000002")).status_code == 200
|
||||
assert client.delete(URL.format("personal") + "/HP-000002").status_code == 200
|
||||
assert not (folder / hs.DIRNAME / hs.GRID / "HP-000002.json").exists()
|
||||
assert client.get(GRID.format("personal", "HP-000002")).status_code == 404
|
||||
assert client.get(GRID.format("personal", "../x")).status_code == 404
|
||||
assert client.get(URL.format("personal") + "/HP-000001/3").status_code == 200 # 길 안 부딪힘
|
||||
gone = client.get(URL.format("personal") + "/HP-000001/3") # 길 안 부딪힘(718-1 지운 뒤 = 없음)
|
||||
assert gone.status_code == 404 and "없는 실무자료" in gone.json()["detail"]
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ NEW = (
|
||||
"Hopyo_CheckRows",
|
||||
"Hopyo_Choose",
|
||||
"Hopyo_Ledger",
|
||||
"Hopyo_Wizard",
|
||||
)
|
||||
ROW = {
|
||||
"층": "system",
|
||||
@@ -301,7 +302,7 @@ def test_옛_즉시_확정과_바로_만들기_없음():
|
||||
"근거 맞음 모두 확정",
|
||||
'"확정"',
|
||||
'"사전조합 만들기"',
|
||||
"불러오기",
|
||||
'"불러오기" 옛 단추', # 718-1 — 「불러오기」 = 칸 이름 · 마법사 단추라 낱말은 뺌
|
||||
):
|
||||
assert label not in every, label
|
||||
users = sorted(name for name, text in texts.items() if "buildHopyo(" in text)
|
||||
@@ -342,20 +343,18 @@ def test_좌측_판_자리와_권한():
|
||||
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
|
||||
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
|
||||
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
# 359-1 — [파일 업로드] = 구조물 조합 층 단추 꼴(보통 크기 · m02-layers__btn) · 파일 줄 = 바로 엑셀 보기
|
||||
up = hopyo_side.split("const upload = createButton({")[1].split("upload.title")[0]
|
||||
assert 'label: "파일 업로드"' in up and 'upload.classList.add("m02-layers__btn");' in up
|
||||
assert "size:" not in up and "엑셀 올리기" not in hopyo_side
|
||||
assert "buildHopyoView" not in hopyo_side
|
||||
assert 'row.addEventListener("click", () => void showSheet(f));' in hopyo_side
|
||||
assert (
|
||||
"onClose:" in hopyo_side
|
||||
and "onDeleted: opt.canWrite ? () => void refresh() : undefined," in hopyo_side
|
||||
)
|
||||
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
|
||||
assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side
|
||||
# 718-1 — 제목 = 화면 글 키 · [불러오기] = 구조물 조합 층 단추 꼴 → 본 칸 마법사 · 옛 파일 목록 없음
|
||||
assert 'L("M02_HopyoImport")' in hopyo_side and "일위대가 실무자료" not in hopyo_side
|
||||
start = hopyo_side.split("const start = createButton({")[1].split("start.title")[0]
|
||||
assert 'label: "불러오기"' in start and 'start.classList.add("m02-layers__btn");' in start
|
||||
assert "buildHopyoWizard()" in hopyo_side and "listHopyo" not in hopyo_side
|
||||
assert "uploadHopyo" not in hopyo_side and "openHopyoSheet" not in hopyo_side
|
||||
assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부]
|
||||
assert hopyo_side.index("list, ledger]") > 0
|
||||
wizard = _text("M02_MasterTemplete_Hopyo_Wizard.ts")
|
||||
assert 'accept: ".xls,.xlsx"' in wizard and "input.hidden = true;" in wizard
|
||||
assert "accessFor(layer, null, user).canWrite" in wizard # 결과 층 = 쓸 수 있는 층만
|
||||
assert 'let pick: ManageLayer = "personal";' in wizard # 기본 개인
|
||||
assert "export const SANGEUN_READY = false;" in wizard # 단가산출 결과 = 715-2 뒤 켬
|
||||
|
||||
|
||||
def test_로직_추가_한_곳과_파일_규칙():
|
||||
@@ -379,9 +378,9 @@ def test_로직_추가_한_곳과_파일_규칙():
|
||||
|
||||
def test_엑셀_보기_창_여는_자리와_늦게_불러옴():
|
||||
lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")'
|
||||
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
assert "await showSheet(got);" in side # 올린 직후 자동
|
||||
assert lazy in side.split("async function showSheet(")[1].split("async function showLedger(")[0]
|
||||
side = _text("M02_MasterTemplete_Hopyo_Wizard.ts")
|
||||
assert "second(pick, got);" in side # 올린 직후 ② 걸음
|
||||
assert lazy in side.split("function second(")[1].split("async function end(")[0]
|
||||
check = _text("M02_MasterTemplete_Hopyo_Check.ts")
|
||||
assert lazy in check and 'label: "엑셀 보기"' in check and "호표: opt.no" in check
|
||||
assert "엑셀에서 보기" not in check
|
||||
@@ -517,6 +516,7 @@ def test_명사형_단추와_별칭_장부_풀이_359():
|
||||
assert f"label: {label}" in ledger, label
|
||||
assert '"우리에 없음": "로직없음"' in ledger and '"안 씀": "사용안함"' in ledger
|
||||
mine = ("Hopyo_Side", "Hopyo_Sheet", "Hopyo_Check", "Hopyo_CheckRows", "Hopyo_Ledger")
|
||||
mine += ("Hopyo_Wizard",)
|
||||
every = "\n".join(_text(f"M02_MasterTemplete_{n}.ts") for n in mine)
|
||||
for gone in (
|
||||
"고친 것 버림",
|
||||
@@ -532,8 +532,8 @@ def test_명사형_단추와_별칭_장부_풀이_359():
|
||||
|
||||
|
||||
def test_372_2_저장은_개인_층에_삭제는_화면_층_권한():
|
||||
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
open_ = side.split("sheet.openHopyoSheet({")[1].split("});")[0]
|
||||
assert 'target: { layer: "personal", owner: null },' in open_
|
||||
assert "canWrite: true," in open_ # 만들기 = 개인 층 · 화면 층 무관
|
||||
assert "onDeleted: opt.canWrite ? () => void refresh() : undefined," in open_
|
||||
"""718-1 — 만들 층 = 마법사에서 고른 층(올린 층과 같음) · [삭제] = 끝내기 → 처음 걸음."""
|
||||
side = _text("M02_MasterTemplete_Hopyo_Wizard.ts")
|
||||
open_ = side.split("m.openHopyoSheet({")[1].split("}),")[0]
|
||||
assert "target: scope," in open_ and "canWrite: true," in open_
|
||||
assert "onDeleted: () => void first()," in open_
|
||||
|
||||
@@ -85,7 +85,7 @@ def _base(code):
|
||||
|
||||
# ── 자료 틀 ────────────────────────────────────────────────────────────
|
||||
def test_단가승격_서고는_없음_층_종류에서_뺌(world):
|
||||
assert "promote" not in layers.KINDS and layers.LIBRARY_KINDS == ("precombo",)
|
||||
assert "promote" not in layers.KINDS and layers.LIBRARY_KINDS == ("precombo", "sangeun")
|
||||
with store.at(world["system"]), pytest.raises(KeyError):
|
||||
refs.create("promote", {"담은로직": [{"로직": TEN}]})
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ def test_사전조합_층_단추는_페이지를_다시_안_세움():
|
||||
|
||||
def test_고름에_owner_권한_서고_길에_명시_owner():
|
||||
side = _text("M02_MasterTemplete_UI_Side_Precombo.ts")
|
||||
assert 'export const PRECOMBO_TITLE = "일위대가 사전조합";' in side
|
||||
assert "PRECOMBO_TITLE" not in side # 716-3 — 제목은 locale 키 「일위대가」
|
||||
assert "owner: scope.owner," in side and "canWrite: scope.canWrite," in side
|
||||
assert "listTemplates(at.layer, null, at.owner)" in side
|
||||
main = _text("M02_MasterTemplete_UI_Main.ts")
|
||||
@@ -133,3 +133,20 @@ def test_목록표_빈_구분_미분류_고르기_창_새_분류_거름():
|
||||
# 거름은 칸 값 그대로 — 품셈 구분을 고르면 서고 줄 빠지고 새 분류를 고르면 로직 줄 빠짐
|
||||
assert "(!sub.select.value || x.row.구분 === sub.select.value)" in pick
|
||||
assert "(!sub.select.value || x.구분 === sub.select.value)" in pick
|
||||
|
||||
|
||||
def test_왼쪽_칸_차례_일위대가_구조물조합_구분선_불러오기_716_3():
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
order = side[side.index("m02-side__lists") :]
|
||||
marks = [
|
||||
"precomboSec,",
|
||||
"summary,",
|
||||
"createDivider()",
|
||||
"hopyo.title",
|
||||
"drawing ?",
|
||||
"practice ?",
|
||||
]
|
||||
at = [order.index(m) for m in marks]
|
||||
assert at == sorted(at)
|
||||
|
||||
assert 'L("M02_PrecomboTitle")' in side and "buildOptionsSection" not in side
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 쓰기 없음 — 서고 목록 · 문서 읽기는 가짜 응답(fetch 바꿔 끼움 · 끝에 되돌림)
|
||||
* 기대 수치:
|
||||
* - table.wrap = "m01-master__grid-wrap" · table.table = "m01-master__grid" · table.row = "m01-master__table-row"
|
||||
* - table.filters = true(찾기 칸 + 쪽 `m01-master__pager`) · table.sameCell = true(M01 표 칸과 padding · 글자 · 밑줄 같음)
|
||||
* - table.filters = true(찾기 칸 + 표 바로 아래 쪽 단추 `ui-pager`) · table.sameCell = true(M01 표 칸과 padding · 글자 · 밑줄 같음)
|
||||
* - table.search = 1(「값 없음」 치면 한 줄)
|
||||
* - buttons.variants = ["filled", "ghost", "danger"](M01 [신규 로직] · [로직 복사/생성] · [로직 지우기] 와 같음) · buttons.redDelete = true
|
||||
* - precombo.add = "로직 추가" · picker.label = "로직 고르기" · picker.kinds ⊂ {단가승격, 일위조합} · picker.logicRows = 0
|
||||
@@ -100,7 +100,7 @@
|
||||
row: wrap.querySelector("[data-lib-key]").className,
|
||||
filters:
|
||||
!!wrap.parentElement.querySelector(".m01-head__filters input[type=search]") &&
|
||||
!!wrap.parentElement.querySelector(".m01-head__filters .m01-master__pager"),
|
||||
wrap.nextElementSibling?.classList.contains("ui-pager") === true,
|
||||
sameCell: box(td) === box(ref.querySelector("td")),
|
||||
cell: box(td),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/* =============================================================================
|
||||
* ui_template_divider.ts
|
||||
* 부품 「수평 구분선」 `divider` — 왼쪽 판 칸 사이 · 패널 안 소제목 사이 · 시험 계산 옵션 / 자재 사이
|
||||
* 꼴 = 가로 한 줄 `<hr class="ui-divider">` (모양 = `ui_template_overlay.css`) · 색 = 공통 값 `--color-border`
|
||||
* 쓰는 법 = createDivider() — 위아래 간격 = `--spacing-8`
|
||||
* (세로 나눔선 = 도구줄 `ui-toolbar__divider` · 소제목 밑줄 = 패널 머리 `divider` 꼴)
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_overlay.css";
|
||||
|
||||
export function createDivider(): HTMLHRElement {
|
||||
const hr = document.createElement("hr");
|
||||
hr.className = "ui-divider";
|
||||
return hr;
|
||||
}
|
||||
@@ -171,4 +171,7 @@ export const ui_locales_m1 = {
|
||||
B01_CalcSites_Saved: ["계산 자리 저장함", "Calc sites saved"],
|
||||
B01_CalcSites_Leave: ["저장 안 한 계산 자리가 있음", "You have unsaved calc sites"],
|
||||
B01_CalcSites_LoadFailed: ["계산 자리를 못 읽음", "Failed to load calc sites"],
|
||||
B01_Dashboard_CardLogic: ["단가산출 로직", "Cost-logic library"],
|
||||
B01_Dashboard_CardStructOptionDef: ["구조물 옵션 정의", "Structure option definition"],
|
||||
B01_Dashboard_CardAliasLedger: ["별칭 장부", "Alias ledger"],
|
||||
} as const;
|
||||
|
||||
@@ -188,4 +188,7 @@ export const ui_locales_m2 = {
|
||||
"그 사이 실무문서가 바뀜 — 목록을 다시 받음",
|
||||
"Practice documents changed — reloaded",
|
||||
],
|
||||
M02_HopyoImport: ["일위대가 불러오기", "Import unit-price sheets"],
|
||||
B01_Dashboard_CardPrecombo: ["일위대가", "Unit-cost items"],
|
||||
M02_PrecomboTitle: ["일위대가", "Unit-cost items"],
|
||||
} as const;
|
||||
|
||||
@@ -567,3 +567,13 @@ html.is-bm-route[data-touch="on"]
|
||||
)[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 수평 구분선(709-1) — 왼쪽 판 칸 사이 · 패널 안 소제목 사이 · 세로 나눔선은 도구줄 부품 */
|
||||
.ui-divider {
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
height: 0;
|
||||
margin: var(--spacing-8) 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/* 쪽 단추(pager) — 표 바로 아래 한 줄(표와 간격 한 칸) · 글은 보조 색 */
|
||||
.ui-pager {
|
||||
gap: var(--spacing-12);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.ui-table-wrap + .ui-pager {
|
||||
margin-top: var(--spacing-12);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/* =============================================================================
|
||||
* ui_template_pager.ts
|
||||
* 부품 「쪽 단추」 `pager` — 이전 · 다음 + 「n / N 쪽 · 전체 줄」 한 줄 (M01 `buildPager` 를 옮김)
|
||||
* 꼴 = 한 줄 도구줄(`ui-toolbar ui-pager`) · 글 → 이전 → 다음 · 쪽이 한 장뿐이어도 그대로 보임
|
||||
* 크기 = 도구줄 단 없음(안 단추 = 보조 단추 기본 높이)
|
||||
* 상태 = 첫 쪽 이전 막힘 · 끝 쪽 다음 막힘 (`disabled`)
|
||||
* 쓰는 법 = createPager(total, size, page, (next) => …) — 표 **바로 아래**에 놓음
|
||||
* 표 줄 수 = `fitRows(남은 높이)` 순수 셈 · `watchFit(바깥 칸, 표 칸 꺼내기, (줄 수) => …)` 가 그린 뒤 · 화면 크기 바뀔 때 알림
|
||||
* 한 장 표(쪽 없음) = `watchHeight(표 칸)` 화면 아래까지 높이 묶고 안에서 스크롤
|
||||
* 옛 이름 = M01 `buildPager`(`M01_MasterData_UI_Cells.ts`) 는 이 부품을 부르는 얇은 껍데기
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "./ui_template_button";
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import { t as L } from "./ui_template_locale";
|
||||
import "./ui_template_pager.css";
|
||||
|
||||
/** 표 한 줄 높이(px) · 머리 높이 — 실제 줄이 있으면 잰 값을 씀 */
|
||||
export const ROW_H = 40;
|
||||
/** 표 밑 쪽 단추 줄(32) + 간격(12) + 판 아래 여백(16) + 여유(4) */
|
||||
export const PAGER_ROOM = 64;
|
||||
/** 표가 보일 수 있는 줄 수 아래 한도 · 위 한도 */
|
||||
export const MIN_ROWS = 5;
|
||||
export const MAX_ROWS = 200;
|
||||
|
||||
/** 전체 줄 · 쪽 크기 → 쪽 수(최소 1). */
|
||||
export const pageCount = (total: number, size: number): number =>
|
||||
Math.max(1, Math.ceil(total / Math.max(1, size)));
|
||||
|
||||
/** 남은 높이(표 머리 포함) → 줄 수 — 머리 한 줄 빼고 줄 높이로 나눔 · 한도 안으로. */
|
||||
export function fitRows(room: number, rowH = ROW_H, headH = ROW_H): number {
|
||||
const n = Math.floor((room - headH) / Math.max(1, rowH));
|
||||
return Math.min(MAX_ROWS, Math.max(MIN_ROWS, Number.isFinite(n) ? n : MIN_ROWS));
|
||||
}
|
||||
|
||||
/** 쪽 크기가 바뀌어도 보던 첫 줄이 같은 쪽에 남도록 새 쪽 번호. */
|
||||
export const pageKeepFirst = (page: number, oldSize: number, newSize: number): number =>
|
||||
Math.floor(((Math.max(1, page) - 1) * oldSize) / Math.max(1, newSize)) + 1;
|
||||
|
||||
/* ── 아래부터 화면 재기(순수 셈 아님) ── */
|
||||
|
||||
export interface FitOptions {
|
||||
/** 화면 위치 대신 쓸 높이 한도(px) — 모달처럼 위치가 줄 수에 따라 움직이는 곳 */
|
||||
cap?: () => number;
|
||||
/** 사라지면(떼어지면) 알림을 그침 — 열 때마다 새로 만드는 목록용 */
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
/** 표 칸 위쪽 자리 ~ 화면 아래 사이로 들어가는 줄 수. 표 칸이 안 보이면 null. */
|
||||
function measureRows(grid: HTMLElement, opt: FitOptions): number | null {
|
||||
if (!grid.isConnected || !grid.offsetParent) return null;
|
||||
const body = grid.querySelector<HTMLTableSectionElement>("tbody");
|
||||
const count = body?.rows.length ?? 0;
|
||||
const head = grid.querySelector<HTMLElement>("thead");
|
||||
const room = opt.cap ? opt.cap() : window.innerHeight - grid.getBoundingClientRect().top;
|
||||
// 줄 높이 = 그린 줄들의 평균(작은 글 · 두 줄 칸이 섞여도 맞음) · 줄 없으면 40
|
||||
const rowH = count ? body!.offsetHeight / count : ROW_H;
|
||||
return fitRows(room - PAGER_ROOM, rowH, head?.offsetHeight || ROW_H);
|
||||
}
|
||||
|
||||
export interface Fit {
|
||||
/** 그린 뒤 다시 재라고 알림 */
|
||||
ask: () => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
/** 표가 그려진 뒤 · 창 크기 바뀔 때 줄 수를 재서 **바뀌었을 때만** 알림. `root` = 늘 붙어 있는 바깥 칸. */
|
||||
export function watchFit(
|
||||
root: HTMLElement,
|
||||
getGrid: () => HTMLElement | null,
|
||||
onRows: (rows: number) => void,
|
||||
opt: FitOptions = {},
|
||||
): Fit {
|
||||
let last = 0;
|
||||
let frame = 0;
|
||||
let seen = false;
|
||||
let late = 0;
|
||||
const stop = (): void => {
|
||||
window.clearTimeout(late);
|
||||
window.removeEventListener("resize", ask);
|
||||
watcher?.disconnect();
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
};
|
||||
const check = (): void => {
|
||||
frame = 0;
|
||||
if (opt.once) {
|
||||
if (root.isConnected) seen = true;
|
||||
else if (seen) return stop();
|
||||
}
|
||||
const grid = getGrid();
|
||||
if (grid) watcher?.observe(grid); // 글꼴이 늦게 실려 줄 높이가 바뀌어도 다시 잼
|
||||
const n = grid ? measureRows(grid, opt) : null;
|
||||
if (n !== null && n !== last) {
|
||||
last = n;
|
||||
onRows(n);
|
||||
}
|
||||
};
|
||||
function ask(): void {
|
||||
if (!frame) frame = requestAnimationFrame(check);
|
||||
// 위 칸(거름 · 찾기)이 늦게 자리 잡아 표 위치만 밀리면 크기 변화가 안 잡힘 — 한 번 더 잼
|
||||
window.clearTimeout(late);
|
||||
late = window.setTimeout(check, 400);
|
||||
}
|
||||
window.addEventListener("resize", ask);
|
||||
const watcher = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(ask);
|
||||
watcher?.observe(root); // 숨었다 다시 보일 때 · 위 칸이 늘고 줄 때
|
||||
ask();
|
||||
return { ask, stop };
|
||||
}
|
||||
|
||||
/** 첫 그림 전에 쓸 줄 수 어림 — `above` = 표 칸 위에 올 줄(px). */
|
||||
export function guessRows(host: HTMLElement, above = 0, cap?: () => number): number {
|
||||
const room = cap ? cap() : window.innerHeight - host.getBoundingClientRect().top - above;
|
||||
return fitRows(room - PAGER_ROOM);
|
||||
}
|
||||
|
||||
/** 쪽 없는 한 장 표 — 표 칸 위 ~ 화면 아래로 높이를 묶고 안에서 스크롤. 돌려받은 함수 = 해제. */
|
||||
export function watchHeight(grid: HTMLElement, pad = 16): () => void {
|
||||
const set = (): void => {
|
||||
if (!grid.isConnected || !grid.offsetParent) return;
|
||||
// 표 아래에 붙은 칸(주 · 안내)이 있으면 그 높이 + 칸 사이 간격(12)만큼 비워 둠
|
||||
let below = 0;
|
||||
for (let n = grid.nextElementSibling; n; n = n.nextElementSibling)
|
||||
below += (n as HTMLElement).offsetHeight + 12;
|
||||
const room = window.innerHeight - grid.getBoundingClientRect().top - pad - below;
|
||||
grid.style.maxHeight = `${Math.max(120, Math.floor(room))}px`;
|
||||
};
|
||||
const frame = (): number => requestAnimationFrame(set);
|
||||
window.addEventListener("resize", set);
|
||||
frame();
|
||||
return () => window.removeEventListener("resize", set);
|
||||
}
|
||||
|
||||
/** 쪽 나눔 띠 — 이전·다음 · 「n / N 쪽 · 전체 줄」. */
|
||||
export function createPager(
|
||||
total: number,
|
||||
size: number,
|
||||
page: number,
|
||||
goto: (page: number) => void,
|
||||
): HTMLElement {
|
||||
const last = pageCount(total, size);
|
||||
const prev = createButton({ label: L("M01_Prev"), variant: "ghost", disabled: page <= 1 });
|
||||
const next = createButton({ label: L("M01_Next"), variant: "ghost", disabled: page >= last });
|
||||
prev.addEventListener("click", () => goto(page - 1));
|
||||
next.addEventListener("click", () => goto(page + 1));
|
||||
return el("div", {
|
||||
className: "ui-toolbar ui-pager",
|
||||
children: [
|
||||
el("span", {
|
||||
className: "ui-pager__text",
|
||||
text: `${page} / ${last} ${L("M01_Page")} · ${total.toLocaleString("ko-KR")} ${L("M01_Rows")}`,
|
||||
}),
|
||||
prev,
|
||||
next,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,8 @@ import { createToggle } from "./ui_template_toggle";
|
||||
import { createBanner } from "./ui_template_banner";
|
||||
import { createPickMenu, createRowMenu, type RowMenuItem } from "./ui_template_context_menu";
|
||||
import { createDrop } from "./ui_template_drop";
|
||||
import { createDivider } from "./ui_template_divider";
|
||||
import { createPager } from "./ui_template_pager";
|
||||
import { buildModal, confirmPanel } from "./ui_template_modal";
|
||||
import { settingCard, settingRow, valueText } from "./ui_template_setting_row";
|
||||
import { createTable, type Cell, type Column } from "./ui_template_table";
|
||||
@@ -146,6 +148,17 @@ const pickDemo = (s: KitState): HTMLElement | null => {
|
||||
if (s === "disabled") [one.root, many.root].forEach((b) => (b.disabled = true));
|
||||
return row(one.root, many.root);
|
||||
};
|
||||
/** 709-1 견본 — 쪽 단추(가운데 쪽 · 한 장뿐이라 이전 · 다음 다 막힘) · 수평 구분선(소제목 사이) */
|
||||
const pagerDemo = (s: KitState): HTMLElement | null =>
|
||||
s === "default" ? row(createPager(1234, 30, 2, noop), createPager(12, 30, 1, noop)) : null;
|
||||
const dividerDemo = (s: KitState): HTMLElement | null => {
|
||||
if (s !== "default") return null;
|
||||
const box = document.createElement("div");
|
||||
box.style.cssText = "display:flex;flex-direction:column;width:200px";
|
||||
const label = (t: string): HTMLElement => Object.assign(document.createElement("span"), { textContent: t });
|
||||
box.append(label("옵션"), createDivider(), label("자재"));
|
||||
return box;
|
||||
};
|
||||
const openMenu = (s: KitState, items = KIT_MENU): HTMLElement => {
|
||||
const menu = createRowMenu(items);
|
||||
menu.classList.add("is-open");
|
||||
@@ -366,6 +379,8 @@ export const KIT_PARTS: KitPart[] = [
|
||||
if (s === "error") bar.setError(2); return bar.root; } },
|
||||
{ key: "setting-row", name: "설정 줄", group: "compound", kinds: "제목 · 설명 왼쪽 · 오른쪽 값 · 조작 · 줄 52 · 좁은 꼴(줄 28)", file: "ui_template_setting_row.ts",
|
||||
demo: (s) => (s === "default" ? col(settingCard([settingRow("회사 이름", "보고서 머리에 들어감", valueText("산림설계")), settingRow("단위", "", valueText(null)), settingRow("기본 노폭", "새 노선에 먼저 들어감", createInputField({ placeholder: "3.0 m" }).root), settingRow("글꼴 파일", "", createButton({ label: "바꾸기", variant: "ghost", size: 28, onClick: noop }))]), settingCard([settingRow("측면 한계각(°)", "", createInputField({ placeholder: "30", size: 24 }).root, { compact: true }), settingRow("균열 모드", "", valueText("없음"), { compact: true })])) : null) },
|
||||
{ key: "pager", name: "쪽 단추", group: "compound", kinds: "이전 · 다음 · 「n / N 쪽 · 전체 줄」 · 첫 쪽 이전 막힘 · 끝 쪽 다음 막힘 · 표 바로 아래 · 줄 수 = 화면 높이 셈(fitRows)", file: "ui_template_pager.ts", demo: pagerDemo },
|
||||
{ key: "divider", name: "수평 구분선", group: "compound", kinds: "가로 한 줄 · 왼쪽 판 칸 사이 · 패널 안 소제목 사이 · 시험 계산 옵션 / 자재 사이", file: "ui_template_divider.ts", demo: dividerDemo },
|
||||
{ key: "drop", name: "파일 놓기 칸", group: "compound", kinds: "끌어 놓기 · 고르기 단추 · 받는 꼴 안내 · 진행 · 오류", file: "ui_template_drop.ts",
|
||||
demo: (s) => { if (s === "active") return null; const d = createDrop({ accept: ".las,.laz", hint: "LAS · LAZ · 2 GB 까지", onFiles: noop, disabled: s === "disabled" });
|
||||
if (s === "error") d.setError("LAS · LAZ 파일만 받음"); if (s === "hover" || s === "error") markState(d.root, s);
|
||||
|
||||
Reference in New Issue
Block a user