Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Source.ts
|
||||
* 원문 보기(품셈 본문 절) — 출처 글로 서버가 찾음(`GET /m01/logic/source`) · 늦게 온 답은 버림
|
||||
* M01 로직 상세 「원문」 패널 · M02 일위대가 상세 「원문」 패널(47-4)이 같이 씀
|
||||
* M01 로직 상세 「원문」 패널 · M02 일위대가 상세 「원문」 패널(47-4)이 같이 씀 ·
|
||||
* 길은 부르는 쪽이 줌(85-4 · M02 = `/m02/logic-lib/source` + 제목 행 [원문 다운로드] pdf 통째)
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
@@ -16,6 +17,7 @@ const TEXT = {
|
||||
From: ["출처", "Source"],
|
||||
NoSource: ["출처 없음", "No source"],
|
||||
Near: ["{v} 절이 없어 위 절", "No {v} section — showing the parent"],
|
||||
Download: ["원문 다운로드", "Download source"],
|
||||
} as const;
|
||||
|
||||
const ts = (key: keyof typeof TEXT, v = ""): string =>
|
||||
@@ -70,15 +72,38 @@ export interface SourceView {
|
||||
show(src: string): void;
|
||||
}
|
||||
|
||||
export function createSourceView(): SourceView {
|
||||
/** 출처 글 → 원문 pdf 책(85-4) — 모르면 null */
|
||||
const bookOf = (src: string): string | null =>
|
||||
src.startsWith("건설품셈") ? "건설" : src.startsWith("산림품셈") ? "산림" : null;
|
||||
|
||||
export interface SourceViewOptions {
|
||||
/** 원문 절 길(기본 M01 `/m01/logic/source` — 시스템 관리자) */
|
||||
path?: string;
|
||||
/** 원문 pdf 길(`?book=`) — 주면 제목 행 오른쪽 [원문 다운로드] */
|
||||
pdf?: string;
|
||||
}
|
||||
|
||||
export function createSourceView(opts: SourceViewOptions = {}): SourceView {
|
||||
const body = el("div", { className: "m01lab-src__body" });
|
||||
const title = el("strong", { className: "m02-dfp__title" });
|
||||
const download = opts.pdf
|
||||
? (el("a", {
|
||||
className: "ui-btn ui-btn--ghost ui-btn--sm",
|
||||
text: ts("Download"),
|
||||
attrs: { download: "", "data-source-pdf": "" },
|
||||
}) as HTMLAnchorElement)
|
||||
: null;
|
||||
let shown: string | null = null;
|
||||
|
||||
const show = (src: string): void => {
|
||||
if (shown === src) return;
|
||||
shown = src;
|
||||
title.textContent = src || ts("NoSource");
|
||||
if (download) {
|
||||
const book = bookOf(src);
|
||||
download.hidden = !book;
|
||||
download.href = book ? `${API_BASE_URL}${opts.pdf}?${new URLSearchParams({ book })}` : "";
|
||||
}
|
||||
body.replaceChildren(el("p", { className: "m01-logic__muted", text: ts("Loading") }));
|
||||
const none = (): void =>
|
||||
body.replaceChildren(
|
||||
@@ -86,9 +111,12 @@ export function createSourceView(): SourceView {
|
||||
el("p", { className: "m01-logic__muted", text: `${ts("From")} · ${src || "—"}` }),
|
||||
);
|
||||
if (!src.trim()) return none();
|
||||
void fetch(`${API_BASE_URL}/m01/logic/source?${new URLSearchParams({ src })}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
void fetch(
|
||||
`${API_BASE_URL}${opts.path ?? "/m01/logic/source"}?${new URLSearchParams({ src })}`,
|
||||
{
|
||||
credentials: "include",
|
||||
},
|
||||
)
|
||||
.then((r) => (r.ok ? (r.json() as Promise<{ found: Found[] }>) : { found: [] }))
|
||||
.catch(() => ({ found: [] as Found[] }))
|
||||
.then(({ found }) => {
|
||||
@@ -109,11 +137,19 @@ export function createSourceView(): SourceView {
|
||||
...f.blocks.map(blockNode),
|
||||
]),
|
||||
);
|
||||
requestAnimationFrame(() =>
|
||||
body.querySelector(".m01lab-src__hit")?.scrollIntoView({ block: "center" }),
|
||||
);
|
||||
// 강조 항을 패널 몸 가운데로 — 몸만 굴림(85-5 · scrollIntoView 는 메인 창까지 굴림)
|
||||
requestAnimationFrame(() => {
|
||||
const hit = body.querySelector<HTMLElement>(".m01lab-src__hit");
|
||||
const box = hit?.closest<HTMLElement>(".ui-side-panels__body");
|
||||
if (!hit || !box) return hit?.scrollIntoView({ block: "nearest" });
|
||||
const off = hit.getBoundingClientRect().top - box.getBoundingClientRect().top;
|
||||
box.scrollTop += off - (box.clientHeight - hit.offsetHeight) / 2;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return { root: el("div", { className: "m01lab-src", children: [title, body] }), show };
|
||||
const head = download
|
||||
? el("div", { className: "m02-dfp__head", children: [title, download] })
|
||||
: title;
|
||||
return { root: el("div", { className: "m01lab-src", children: [head, body] }), show };
|
||||
}
|
||||
|
||||
@@ -280,8 +280,15 @@ def calc(
|
||||
snapshot = load_prices(prices)
|
||||
gaps = missing_inputs(snapshot, labor, values)
|
||||
blocked = {gap["로직"] for gap in gaps}
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
|
||||
|
||||
sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0))
|
||||
lines, stuck = [], []
|
||||
rates = { # 켜쌓기율(85-2) — 문서 계산과 같은 `layered`
|
||||
one.get("로직"): one.get("켜쌓기율")
|
||||
for one in (labor.get("줄") or {}).get("담은로직") or []
|
||||
if isinstance(one, dict)
|
||||
}
|
||||
for at, key in enumerate(_logic_keys(labor), start=1):
|
||||
head: dict[str, Any] = {"차례": at, "로직": key}
|
||||
if key in blocked:
|
||||
@@ -292,7 +299,7 @@ def calc(
|
||||
given = {name: one.get("값") for name, one in (values.get(key) or {}).items()}
|
||||
try:
|
||||
with mf.mp.choosing((picks or {}).get(key)):
|
||||
got = mf.run(snapshot, key, given)
|
||||
got = labor_doc.layered(mf.run(snapshot, key, given), rates.get(key))
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as error:
|
||||
lines.append({**head, "ok": False, "까닭": str(error) or type(error).__name__})
|
||||
stuck.append(key)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
|
||||
|
||||
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}
|
||||
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?}
|
||||
| {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
|
||||
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
|
||||
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
|
||||
@@ -8,6 +8,8 @@
|
||||
금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은
|
||||
반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) ·
|
||||
기준단가가 된 칸 이름(`기준칸`)도 실음(43-3).
|
||||
켜쌓기율(85-2) = 표 주문 「골쌓기 기준」 로직만 · % · 없음 = 100 · 그 로직 세부항목 줄 전부 수량 × 율
|
||||
(`layered` · `run_line` 한 곳 → 문서 · 프로젝트 사본 · 서고 같은 함수).
|
||||
참조 줄(단가승격 · 사전조합 · 50-3) = 기준이 가리킨 문서 1 단위 값(펼침 `M02_Labor_Ref.Expand`) ·
|
||||
분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만.
|
||||
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
|
||||
@@ -22,6 +24,7 @@ from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
@@ -105,6 +108,7 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
if not isinstance(given, dict):
|
||||
raise _bad(f"{where} · 입력값이 묶음 아님")
|
||||
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
|
||||
rate = _rate(one.get("켜쌓기율"), code, where)
|
||||
lines.append(
|
||||
{
|
||||
"로직": code,
|
||||
@@ -112,6 +116,7 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
"메모": str(memo) if memo else None,
|
||||
"입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")},
|
||||
"수량": _qty(one.get("수량"), where),
|
||||
**({"켜쌓기율": rate} if rate is not None else {}),
|
||||
}
|
||||
)
|
||||
mats, codes = [], set()
|
||||
@@ -157,6 +162,42 @@ def _alias(alias: Any, where: str) -> str | None:
|
||||
return alias or None
|
||||
|
||||
|
||||
def _rate(value: Any, code: str, where: str) -> int | float | None:
|
||||
"""켜쌓기율(85-2) — 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖 로직은 None(안 둠)."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
rate = _number(value)
|
||||
if rate is None or not Decimal(0) < rate <= Decimal(200):
|
||||
raise _bad(f"{where} · 켜쌓기율은 0 초과 200 이하 %")
|
||||
if rate != rate.quantize(Decimal("0.1")):
|
||||
raise _bad(f"{where} · 켜쌓기율은 소수 한 자리까지")
|
||||
if rate == 100 or code not in labor_inputs.layered():
|
||||
return None
|
||||
return int(rate) if rate == rate.to_integral_value() else float(rate)
|
||||
|
||||
|
||||
def layered(got: dict[str, Any], rate: Any) -> dict[str, Any]:
|
||||
"""엔진 답 × 켜쌓기율(85-2) — 세부항목 줄 전부 수량 · 금액 · 비목 · 비목 합 · 계 · 단가는 그대로."""
|
||||
if rate in (None, 100) or "결과" in got:
|
||||
return got
|
||||
times = Decimal(str(rate)) / 100
|
||||
|
||||
def mul(value: Any) -> Any:
|
||||
return value * times if isinstance(value, Decimal) else value
|
||||
|
||||
lines = [
|
||||
{
|
||||
**x,
|
||||
"수량": mul(x.get("수량")),
|
||||
"금액": mul(x.get("금액")),
|
||||
"비목": {k: mul(v) for k, v in (x.get("비목") or {}).items()},
|
||||
}
|
||||
for x in got.get("줄") or []
|
||||
]
|
||||
sums = {k: mul(got[k]) for k in COSTS}
|
||||
return {**got, "줄": lines, **sums, "계": sum(sums.values())}
|
||||
|
||||
|
||||
def from_combo(key: str) -> dict[str, Any]:
|
||||
"""옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 —
|
||||
담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키).
|
||||
@@ -331,7 +372,7 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"분모": pill.get("단위"),
|
||||
"배": pill.get("배") or 1,
|
||||
}
|
||||
lines.append({**line, **run_line(whole, code, one.get("입력값"))})
|
||||
lines.append({**line, **run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"))})
|
||||
mats = [
|
||||
{
|
||||
"코드": one["코드"],
|
||||
@@ -344,13 +385,13 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return settle(_json({"줄": lines, "자재": mats}), doc, have)
|
||||
|
||||
|
||||
def run_line(whole: Any, code: str, given: Any) -> dict[str, Any]:
|
||||
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값) — `{기준, 까닭}` ·
|
||||
def run_line(whole: Any, code: str, given: Any, rate: Any = None) -> dict[str, Any]:
|
||||
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율) — `{기준, 까닭}` ·
|
||||
돈 아닌 로직은 `{결과, 기준: None}`(반영 없음)."""
|
||||
try:
|
||||
logic = whole.logic(code)
|
||||
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
|
||||
got = mf.run(whole, code, {**auto, **m01._dec(given or {})})
|
||||
got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||||
return {"기준": None, "까닭": str(e) or type(e).__name__}
|
||||
if "결과" in got:
|
||||
|
||||
@@ -15,6 +15,7 @@ from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
|
||||
_SPEC = ("이름", "설명", "단위", "고르기", "범위")
|
||||
_LAYERED = "골쌓기 기준" # 85-2 — 표 주문에 이 글이 있으면 켜쌓기율 대상 로직
|
||||
|
||||
|
||||
def _num(value: Any) -> Any:
|
||||
@@ -40,6 +41,16 @@ def _logics(codes: list[str]) -> tuple[list[dict], list[str]]:
|
||||
return found, missing
|
||||
|
||||
|
||||
def layered() -> set[str]:
|
||||
"""켜쌓기율(85-2) 대상 로직 키 — 표 `주` 에 「골쌓기 기준」 · 그 표 `용도.로직키`(자동 판정)."""
|
||||
out: set[str] = set()
|
||||
for data in m01.loaded()[0].values():
|
||||
for row in data.get("표") or []:
|
||||
if any(_LAYERED in str(note) for note in row.get("주") or []):
|
||||
out.update((row.get("용도") or {}).get("로직키") or [])
|
||||
return out
|
||||
|
||||
|
||||
def _labels(whole: Any, spec: dict) -> dict[str, str]:
|
||||
"""고르기 원문번호 → 보이는 글(85-1) — 기계 「이름 규격」 · 직종(입력 이름에 「직종」) 「이름」 · 모르는 번호는 뺌."""
|
||||
table = "LB" if "직종" in str(spec.get("이름") or "") else "EQ"
|
||||
@@ -53,8 +64,9 @@ def _labels(whole: Any, spec: dict) -> dict[str, str]:
|
||||
|
||||
|
||||
def _listed(logics: list[dict]) -> list[dict[str, Any]]:
|
||||
"""로직 줄마다 `{로직, 이름, 결과단위, 출처, 입력: [{이름, 설명, 단위, 고르기?, 고르기이름?, 범위?, 자동값}]}`."""
|
||||
"""로직 줄마다 `{로직, 이름, 결과단위, 출처, 켜쌓기, 입력: [{이름, 설명, 단위, 고르기?, 고르기이름?, 범위?, 자동값}]}`."""
|
||||
whole = m01.loaded()[1]
|
||||
layers = layered()
|
||||
out = []
|
||||
for logic in logics:
|
||||
auto = m01.mau.values(whole, logic)
|
||||
@@ -73,6 +85,7 @@ def _listed(logics: list[dict]) -> list[dict[str, Any]]:
|
||||
"이름": logic.get("이름"),
|
||||
"결과단위": logic.get("결과단위"),
|
||||
"출처": logic.get("출처"),
|
||||
"켜쌓기": logic.get("키") in layers,
|
||||
"입력": specs,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -152,7 +152,7 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]:
|
||||
이름=name or pill.get("이름") or line["로직"],
|
||||
단위=unit or pill.get("단위") or None,
|
||||
배=pill.get("배") or 1, # ponytail: 저장 때 적음 · 마스터 결과단위가 바뀌면 다시 저장
|
||||
담은로직=[{"로직": line["로직"], "입력값": line["입력값"]}],
|
||||
담은로직=[{k: line[k] for k in ("로직", "입력값", "켜쌓기율") if k in line}],
|
||||
**origin(line["로직"]),
|
||||
)
|
||||
else:
|
||||
@@ -200,6 +200,7 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
|
||||
head = {"참조": one["참조"]} if "참조" in one else {"로직": one["로직"]}
|
||||
if "로직" in one:
|
||||
head["입력값"] = one["입력값"]
|
||||
head.update({k: one[k] for k in ("켜쌓기율",) if k in one})
|
||||
lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]})
|
||||
at_ = here()
|
||||
me = {"층": at_[0], "주인": at_[1], "키": key}
|
||||
@@ -368,7 +369,8 @@ class Expand:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
line = (doc.get("담은로직") or [{}])[0]
|
||||
got = labor.run_line(self.whole, str(line.get("로직") or ""), line.get("입력값"))
|
||||
code = str(line.get("로직") or "")
|
||||
got = labor.run_line(self.whole, code, line.get("입력값"), line.get("켜쌓기율"))
|
||||
times = labor._number(doc.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
why = got.get("까닭") or (None if base else "돈 아닌 로직")
|
||||
@@ -395,7 +397,7 @@ class Expand:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
pill = pill or {}
|
||||
got = labor.run_line(self.whole, one["로직"], one.get("입력값"))
|
||||
got = labor.run_line(self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"))
|
||||
times = labor._number(pill.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
return {
|
||||
|
||||
@@ -345,6 +345,8 @@ export interface ItemLogic {
|
||||
결과단위?: string;
|
||||
/** 품셈 출처 글(47-4 · 원문 패널) */
|
||||
출처?: string | null;
|
||||
/** 켜쌓기율 대상(85-2 · 표 주문 「골쌓기 기준」) */
|
||||
켜쌓기?: boolean;
|
||||
입력: ItemInput[];
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel {
|
||||
const panels = createSidePanels({
|
||||
prefKey: "m02-area-panels",
|
||||
defaultWidth: DEFAULT_WIDTH,
|
||||
follow: false, // 85-5 — 면적 상세 고정 높이 칸을 채움 · 메인 스크롤과 따로
|
||||
panels: [
|
||||
{ id: "labor", title: TEXT.labor, body: view.root },
|
||||
{ id: "drawing", title: TEXT.drawing, body: drawing.root },
|
||||
|
||||
@@ -90,7 +90,33 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 순서 칸 2행(51-14) — 위 [위로] [아래로] / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */
|
||||
/* 순서(85-3) — 손잡이 + ▲▼ 작은 단추 · 끌어 놓을 줄 위 선 */
|
||||
.m02-labrow__order {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.m02-labrow__arrow.ui-btn {
|
||||
min-width: 0;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.m02-labrow__grip {
|
||||
padding: 0 4px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.m02-labrow__row.is-dragover td {
|
||||
box-shadow: inset 0 2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
/* 순서 칸 2행(51-14) — 위 손잡이 ▲▼ / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */
|
||||
.m02-labrow__tools {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -129,6 +129,8 @@ export interface LaborRow {
|
||||
메모: string | null;
|
||||
입력값: Record<string, string | number>;
|
||||
수량: LaborQty;
|
||||
/** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 없음 = 100) */
|
||||
켜쌓기율?: number | null;
|
||||
}
|
||||
export interface LaborMat {
|
||||
코드: string;
|
||||
@@ -164,6 +166,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
|
||||
입력값: r.입력값 ?? {},
|
||||
수량: r.수량 ?? null,
|
||||
...(r.참조 ? { 참조: r.참조 } : {}),
|
||||
...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}),
|
||||
})),
|
||||
자재: (d.자재 ?? []).map((m) => ({
|
||||
코드: m.코드,
|
||||
|
||||
@@ -131,12 +131,17 @@ export function mountLaborDetail(
|
||||
});
|
||||
|
||||
/** 원문 패널(47-4 · 카드 갈래엔 없음) — 열려 있을 때만 찾음 · 고른 줄 없으면 안내 한 줄 */
|
||||
const source = createSourceView();
|
||||
// 85-4 — M02 길(로그인 사용자 모두) · 제목 행 [원문 다운로드]
|
||||
const source = createSourceView({
|
||||
path: "/m02/logic-lib/source",
|
||||
pdf: "/m02/logic-lib/source-pdf",
|
||||
});
|
||||
const sourceHost = el("div", { attrs: { "data-labor-source": "" } });
|
||||
const side = opt.card
|
||||
? null
|
||||
: createSidePanels({
|
||||
prefKey: "m02-labor-side",
|
||||
follow: false, // 85-5 — 상세 고정 높이 칸을 채움 · 메인 스크롤과 따로
|
||||
panels: [{ id: "source", title: tc("Labor_Source"), body: sourceHost }],
|
||||
onChange: () => showSource(),
|
||||
});
|
||||
@@ -186,7 +191,7 @@ export function mountLaborDetail(
|
||||
option: openRow,
|
||||
ref: openRef,
|
||||
qty: openQty,
|
||||
...(opt.card ? {} : { move }),
|
||||
move: reorder,
|
||||
remove: removeRow,
|
||||
alias: setAlias,
|
||||
}
|
||||
@@ -310,10 +315,10 @@ export function mountLaborDetail(
|
||||
}
|
||||
};
|
||||
|
||||
function move(i: number, d: number): void {
|
||||
const j = i + d;
|
||||
if (j < 0 || j >= cur.담은로직.length) return;
|
||||
[cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]];
|
||||
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 끌기 = 놓은 줄 자리) */
|
||||
function reorder(from: number, to: number): void {
|
||||
if (from === to || to < 0 || to >= cur.담은로직.length) return;
|
||||
cur.담은로직.splice(to, 0, ...cur.담은로직.splice(from, 1));
|
||||
changed();
|
||||
}
|
||||
/** 줄 고름(47-5) — 하이라이트 + 하위 행(51-2 · 표만 다시 그림 · 다이어그램 안 다시 그림) + 원문 패널(47-4) ·
|
||||
@@ -347,13 +352,18 @@ export function mountLaborDetail(
|
||||
logic,
|
||||
values: row.입력값,
|
||||
memo: row.메모,
|
||||
rate: row.켜쌓기율 ?? null,
|
||||
readOnly: !edit,
|
||||
done: (next) => {
|
||||
const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
|
||||
const same = JSON.stringify(values) === JSON.stringify(row.입력값);
|
||||
const same =
|
||||
JSON.stringify(values) === JSON.stringify(row.입력값) &&
|
||||
next.켜쌓기율 === (row.켜쌓기율 ?? null);
|
||||
row.입력값 = values;
|
||||
row.메모 = next.메모;
|
||||
changed(same); // 입력값이 그대로면(메모만) 서버 안 부름
|
||||
if (next.켜쌓기율 === null) delete row.켜쌓기율;
|
||||
else row.켜쌓기율 = next.켜쌓기율;
|
||||
changed(same); // 입력값 · 켜쌓기율이 그대로면(메모만) 서버 안 부름
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,6 +39,17 @@ function footer(
|
||||
export interface LaborInputResult {
|
||||
입력값: LaborValues;
|
||||
메모: string | null;
|
||||
/** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 빈 칸 · 100 = null) */
|
||||
켜쌓기율: number | null;
|
||||
}
|
||||
|
||||
/** 켜쌓기율 칸 글 → 값 — 빈 글 · 100 = null · 0 초과 200 이하 · 소수 한 자리 밖 = NaN */
|
||||
export function layerRate(raw: string): number | null {
|
||||
if (raw.trim() === "") return null;
|
||||
const v = Number(raw);
|
||||
const ok =
|
||||
Number.isFinite(v) && v > 0 && v <= 200 && Math.abs(v * 10 - Math.round(v * 10)) < 1e-9;
|
||||
return !ok ? NaN : v === 100 ? null : v;
|
||||
}
|
||||
|
||||
export interface LaborInputOptions {
|
||||
@@ -48,6 +59,8 @@ export interface LaborInputOptions {
|
||||
logic: ItemLogic | null;
|
||||
values: LaborValues;
|
||||
memo: string | null;
|
||||
/** 켜쌓기율(85-2) — 로직이 대상일 때만 칸 */
|
||||
rate?: number | null;
|
||||
readOnly: boolean;
|
||||
done: (next: LaborInputResult) => void;
|
||||
/** 머리 한 줄 알림(51-14 · 서고 원본 고침) */
|
||||
@@ -100,9 +113,27 @@ export function openLaborInput(opt: LaborInputOptions): void {
|
||||
}) as HTMLInputElement;
|
||||
memo.value = opt.memo ?? "";
|
||||
memo.readOnly = opt.readOnly;
|
||||
const rate = opt.logic?.켜쌓기
|
||||
? (el("input", {
|
||||
className: "ui-input m02-inputval__control",
|
||||
attrs: {
|
||||
type: "number",
|
||||
min: "0.1",
|
||||
max: "200",
|
||||
step: "0.1",
|
||||
placeholder: "100",
|
||||
"data-labor-layer": "",
|
||||
},
|
||||
}) as HTMLInputElement)
|
||||
: null;
|
||||
if (rate) {
|
||||
rate.value = opt.rate == null ? "" : String(opt.rate);
|
||||
rate.readOnly = opt.readOnly;
|
||||
}
|
||||
const ok = (): void => {
|
||||
if (bad.size) return showToast(tc("In_Bad"), "error");
|
||||
opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null });
|
||||
const layer = rate ? layerRate(rate.value) : null;
|
||||
if (bad.size || Number.isNaN(layer)) return showToast(tc("In_Bad"), "error");
|
||||
opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null, 켜쌓기율: layer });
|
||||
close();
|
||||
};
|
||||
body.append(
|
||||
@@ -129,6 +160,21 @@ export function openLaborInput(opt: LaborInputOptions): void {
|
||||
...(rows.length
|
||||
? rows
|
||||
: [el("p", { className: "m02-labin__muted", text: tc("In_None") })]),
|
||||
...(rate
|
||||
? [
|
||||
el("div", {
|
||||
className: "m02-inputval__row",
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-inputval__name",
|
||||
text: tc("In_Layer"),
|
||||
attrs: { title: tc("In_LayerHint") },
|
||||
}),
|
||||
rate,
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
el("div", {
|
||||
className: "m02-inputval__row",
|
||||
children: [
|
||||
|
||||
@@ -56,12 +56,15 @@ export async function openRefEdit(opt: RefEditOptions): Promise<void> {
|
||||
logic,
|
||||
values: row.입력값,
|
||||
memo: row.메모,
|
||||
rate: row.켜쌓기율 ?? null,
|
||||
readOnly: opt.readOnly,
|
||||
notice,
|
||||
okLabel: tc("Save"),
|
||||
done: (next) => {
|
||||
row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
|
||||
row.메모 = next.메모;
|
||||
if (next.켜쌓기율 === null) delete row.켜쌓기율;
|
||||
else row.켜쌓기율 = next.켜쌓기율;
|
||||
void save(doc)
|
||||
.catch((error) => fail(error, "Lib_SaveFailed"))
|
||||
.finally(opt.closed);
|
||||
|
||||
@@ -148,6 +148,9 @@ function moneyCell(
|
||||
}
|
||||
|
||||
/** 이름 칸 글마다 title = 전체 글(한 줄 말줄임 · 45-7) */
|
||||
/** 끌고 있는 줄 차례(85-3) — 손잡이 dragstart ~ drop */
|
||||
let dragFrom: number | null = null;
|
||||
|
||||
const titled = (td: HTMLElement): HTMLElement => {
|
||||
for (const c of td.children) c.setAttribute("title", c.textContent ?? "");
|
||||
return td;
|
||||
@@ -179,7 +182,8 @@ export interface RowHandlers {
|
||||
ref?: (i: number) => void;
|
||||
/** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */
|
||||
qty?: (i: number) => void;
|
||||
move?: (i: number, d: number) => void;
|
||||
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 손잡이 끌기 = 놓은 줄 자리) */
|
||||
move?: (from: number, to: number) => void;
|
||||
remove?: (i: number) => void;
|
||||
/** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */
|
||||
alias?: (i: number, value: string | null) => void;
|
||||
@@ -307,7 +311,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
// 참조 줄 — 키 · 이름 · 단위 = 가리킨 문서 것(calc 답) · 넣을 값 없음(승격 때 고정)
|
||||
const unit = row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? "");
|
||||
const values = valueLine(logic?.입력 ?? [], row.입력값);
|
||||
const note = values.text;
|
||||
const layer =
|
||||
row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : "";
|
||||
const note = [values.text, layer].filter(Boolean).join(" · ");
|
||||
const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null);
|
||||
const small = (
|
||||
label: string,
|
||||
@@ -322,6 +328,32 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
run();
|
||||
},
|
||||
});
|
||||
// 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) + 손잡이 끌기(줄 전체는 안 끎 · 별칭 칸 글 고르기)
|
||||
const arrow = (mark: string, label: string, to: number): HTMLElement => {
|
||||
const b = small(mark, () => move?.(i, to));
|
||||
b.classList.add("m02-labrow__arrow");
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
if (to < 0 || to >= data.rows.length) (b as HTMLButtonElement).disabled = true;
|
||||
return b;
|
||||
};
|
||||
const grip = (): HTMLElement => {
|
||||
const g = el("span", {
|
||||
className: "m02-labrow__grip",
|
||||
text: "⠿",
|
||||
attrs: { draggable: "true", title: tc("Labor_Drag"), "data-labor-grip": String(i) },
|
||||
});
|
||||
g.addEventListener("click", (e) => e.stopPropagation());
|
||||
g.addEventListener("dragstart", (ev) => {
|
||||
dragFrom = i;
|
||||
ev.dataTransfer?.setData("text/plain", String(i));
|
||||
if (ev.dataTransfer) ev.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
g.addEventListener("dragend", () => {
|
||||
dragFrom = null;
|
||||
});
|
||||
return g;
|
||||
};
|
||||
const sel = data.selected === id ? " is-selected" : "";
|
||||
const tr = el("tr", {
|
||||
className: (on ? "m02-labrow__row is-open" : "m02-labrow__row") + sel,
|
||||
@@ -371,10 +403,14 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
children: [
|
||||
...(move
|
||||
? [
|
||||
group([
|
||||
small(tc("Up"), () => move(i, -1)),
|
||||
small(tc("Down"), () => move(i, 1)),
|
||||
]),
|
||||
el("div", {
|
||||
className: "m02-labrow__order",
|
||||
children: [
|
||||
grip(),
|
||||
arrow("▲", tc("Up"), i - 1),
|
||||
arrow("▼", tc("Down"), i + 1),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
group([
|
||||
@@ -401,6 +437,21 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
],
|
||||
});
|
||||
if (on) tr.addEventListener("click", () => on.open(i));
|
||||
if (move) {
|
||||
tr.addEventListener("dragover", (ev) => {
|
||||
if (dragFrom === null || dragFrom === i) return;
|
||||
ev.preventDefault();
|
||||
tr.classList.add("is-dragover");
|
||||
});
|
||||
tr.addEventListener("dragleave", () => tr.classList.remove("is-dragover"));
|
||||
tr.addEventListener("drop", (ev) => {
|
||||
ev.preventDefault();
|
||||
tr.classList.remove("is-dragover");
|
||||
const from = dragFrom;
|
||||
dragFrom = null;
|
||||
if (from !== null && from !== i) move(from, i);
|
||||
});
|
||||
}
|
||||
const subs = sel ? subRows(line, wait, tools) : [];
|
||||
if (!note) return [tr, ...subs];
|
||||
// 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐
|
||||
|
||||
@@ -33,6 +33,7 @@ const TEXT = {
|
||||
Col_Move: ["순서", "Move"],
|
||||
Up: ["위로", "Up"],
|
||||
Down: ["아래로", "Down"],
|
||||
Labor_Drag: ["끌어 순서 바꿈", "Drag to reorder"],
|
||||
Remove: ["삭제", "Delete"],
|
||||
Labor_Option: ["옵션", "Options"],
|
||||
Labor_Edit: ["수정", "Edit"],
|
||||
@@ -112,6 +113,12 @@ const TEXT = {
|
||||
In_Title: ["넣을 값 — {value}", "Inputs — {value}"],
|
||||
In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"],
|
||||
In_Memo: ["메모", "Memo"],
|
||||
In_Layer: ["켜쌓기 적용율 (%)", "Layered-course rate (%)"],
|
||||
In_LayerHint: [
|
||||
"0 초과 200 이하 · 소수 한 자리 · 빈 칸 = 100",
|
||||
"0 < rate ≤ 200 · 1 decimal · empty = 100",
|
||||
],
|
||||
Labor_Layer: ["켜쌓기 {n}%", "Layered {n}%"],
|
||||
In_QtyCell: ["면적산출 칸", "Area sheet cell"],
|
||||
In_QtyEmpty: ["빈 칸", "empty cell"],
|
||||
In_QtyHand: ["손 입력", "Manual"],
|
||||
|
||||
@@ -7,14 +7,19 @@
|
||||
담을 로직 고르기 · 일위대가 입력 모달이 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은
|
||||
`/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음.
|
||||
39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌).
|
||||
85-4 — 원문 패널 읽기(`/logic-lib/source` · M01 길과 같은 몸) · 원문 pdf 통째
|
||||
(`/logic-lib/source-pdf` · 책 이름만 받음 · 경로 안 받음)도 로그인만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_http_cache import cached_file_response
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||||
@@ -100,3 +105,27 @@ def get_logic_auto(key: str) -> dict:
|
||||
def get_logic_inputs(keys: str = "") -> dict:
|
||||
"""일위대가 입력 모달 — 로직 키들(쉼표)의 입력 목록 + 자동값(`M02_Labor_Inputs`)."""
|
||||
return _read(labor_inputs.logic_inputs, keys.split(","))
|
||||
|
||||
|
||||
#: 원문 pdf(85-4) — 책 이름 → 파일(쪽 자르기 없음)
|
||||
SOURCE_PDF = {
|
||||
"건설": make.BODY / "건설공사_표준품셈" / "2026년_건설공사_표준품셈.pdf",
|
||||
"산림": make.BODY / "산림_표준품셈" / "첨부" / "(산림청고시 제2025-82호) 산림사업 표준품셈.pdf",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/logic-lib/source", dependencies=[Depends(verify_session)])
|
||||
def get_logic_source(src: str) -> dict:
|
||||
"""원문 패널 — 출처 글로 찾은 품셈 본문 절(M01 `/logic/source` 와 같은 몸 · 로그인만)."""
|
||||
return _read(make.source, src)
|
||||
|
||||
|
||||
@router.get("/logic-lib/source-pdf", dependencies=[Depends(verify_session)])
|
||||
def get_source_pdf(request: Request, book: str) -> Response:
|
||||
"""원문 pdf 통째 내려받기 — `book` = 건설 | 산림(그 밖 400)."""
|
||||
path = SOURCE_PDF.get(book)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=400, detail="책은 건설 · 산림 중 하나")
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="원문 pdf 없음")
|
||||
return cached_file_response(request, path, "application/pdf", filename=path.name)
|
||||
|
||||
@@ -152,6 +152,30 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 일위대가 상세(85-5) — 면적 상세와 같은 고정 높이 · 본문 칸만 굴림 · 오른쪽 패널은 칸 높이(따로 스크롤) */
|
||||
.m02-main:has(> .m02-main__host > .ui-side-panels-row) {
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - var(--spacing-64));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m02-main__host:has(> .ui-side-panels-row) {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m02-main__host > .ui-side-panels-row {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m02-main__host > .ui-side-panels-row > :first-child {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m02-main__empty {
|
||||
margin: 0;
|
||||
padding: var(--spacing-24);
|
||||
|
||||
@@ -104,3 +104,53 @@ def test_item_all_marked() -> None:
|
||||
total += 1
|
||||
missing += not any(f.get("item") for f in make.source(src)["found"])
|
||||
assert total >= 30 and missing == 0, (total, missing)
|
||||
|
||||
|
||||
def _m02_client(role: str = "USER") -> TestClient:
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(combos.router)
|
||||
app.dependency_overrides[verify_session] = lambda: {"user_id": 42, "role": role}
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_m02_원문_대리_길과_pdf() -> None:
|
||||
"""85-4 — 일반 사용자도 원문(M01 길과 같은 몸) · pdf 통째(책 이름만 · 그 밖 400 · 한글 파일명)."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos
|
||||
|
||||
client = _m02_client()
|
||||
got = client.get("/api/m02/logic-lib/source", params={"src": "산림품셈 3-1"})
|
||||
assert got.status_code == 200 and got.json() == make.source("산림품셈 3-1")
|
||||
for book in ("건설", "산림"):
|
||||
res = client.get("/api/m02/logic-lib/source-pdf", params={"book": book})
|
||||
assert res.status_code == 200 and res.headers["content-type"] == "application/pdf"
|
||||
assert (
|
||||
len(res.content) == combos.SOURCE_PDF[book].stat().st_size
|
||||
and res.content[:4] == b"%PDF"
|
||||
)
|
||||
assert "filename*=utf-8''" in res.headers["content-disposition"]
|
||||
again = client.get(
|
||||
"/api/m02/logic-lib/source-pdf",
|
||||
params={"book": book},
|
||||
headers={"if-none-match": res.headers["etag"]},
|
||||
)
|
||||
assert again.status_code == 304
|
||||
for bad in ("../../main.py", "건설품셈", ""):
|
||||
assert client.get("/api/m02/logic-lib/source-pdf", params={"book": bad}).status_code == 400
|
||||
|
||||
|
||||
def test_원문_패널_길_주입() -> None:
|
||||
"""85-4 — M01 은 옛 길 그대로(기본값) · M02 일위대가 상세는 M02 길 + pdf."""
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
src = (root / "M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts").read_text(encoding="utf-8")
|
||||
assert 'opts.path ?? "/m01/logic/source"' in src
|
||||
side = (root / "M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts").read_text(encoding="utf-8")
|
||||
assert "createSourceView()" in side
|
||||
labor = (root / "M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert 'path: "/m02/logic-lib/source"' in labor and 'pdf: "/m02/logic-lib/source-pdf"' in labor
|
||||
|
||||
@@ -78,7 +78,7 @@ def test_일위대가_카드는_상세_카드_갈래():
|
||||
assert "onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable())," in cards
|
||||
assert "Labor_View" not in cards # 한 줄 표(40-2) 안 씀
|
||||
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert "...(opt.card ? {} : { move })" in detail # 순서 단추 없음 · 빼기는 그대로
|
||||
assert "move: reorder," in detail # 85-3 — 카드도 순서(▲▼ · 끌기) · 빼기는 그대로
|
||||
# 서고 모드(50-3 · lib)도 더하기 · 다이어그램 없음
|
||||
# 51-11 사전조합 = 일위대가 상세와 같은 [로직 추가] · 같은 목록(걸러냄 없음)
|
||||
assert "opt.card || promote ? null : addLogic" in detail
|
||||
@@ -134,7 +134,10 @@ def test_일위대가_줄_고름과_옵션_단추():
|
||||
) # 읽기만도 고름 · [옵션] = 보기 모달
|
||||
assert "selected: picked" in detail
|
||||
rows = _text("M02_MasterTemplete_Labor_Rows.ts")
|
||||
up, down = rows.index('small(tc("Up")'), rows.index('small(tc("Down")')
|
||||
# 85-3 — ▲▼ 작은 단추(글은 title · aria) + 손잡이 끌기
|
||||
up, down = rows.index('arrow("▲", tc("Up")'), rows.index('arrow("▼", tc("Down")')
|
||||
assert rows.index("grip(),") < up and 'b.setAttribute("aria-label", label)' in rows
|
||||
assert "move: reorder," in detail and "opt.card ? {} : { move }" not in detail # 카드도 순서
|
||||
opt, rm = (
|
||||
rows.index('small(tc("Labor_Option")'),
|
||||
rows.index('small(tc("Remove"), () => remove(i), "danger")'),
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""켜쌓기율(85-2) — 표 주문 「골쌓기 기준」 로직만 · 세부항목 줄 전부 수량 × 율 · 문서 · 사본 같은 함수.
|
||||
|
||||
못박는 것
|
||||
- 대상 = 표 주문 자동 판정(지금 GC000260 · GC000261) · 입력 목록에 「켜쌓기」 표시.
|
||||
- GC000260 90% = 100% × 0.9(비목 · 계 · 줄 수량 · 금액) · 단가는 그대로.
|
||||
- clean = 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖은 안 둠.
|
||||
- 프로젝트 사본 계산(`M02_Item_Prices.calc`)도 같은 율 · 같은 값.
|
||||
- 서고(단가승격)도 율을 담고 같은 값.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
CODE = "GC000260"
|
||||
|
||||
|
||||
def _d(value) -> Decimal:
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def test_대상은_골쌓기_기준_주문_로직() -> None:
|
||||
assert labor_inputs.layered() == {"GC000260", "GC000261"}
|
||||
got = labor_inputs.logic_inputs([CODE, "GF000219"])["로직"]
|
||||
assert [x["켜쌓기"] for x in got] == [True, False]
|
||||
|
||||
|
||||
def test_90퍼센트는_100퍼센트_곱하기_0점9() -> None:
|
||||
whole = m01.loaded()[1]
|
||||
full = labor.run_line(whole, CODE, {})
|
||||
part = labor.run_line(whole, CODE, {}, 90)
|
||||
for k in (*labor.COSTS, "계"):
|
||||
assert part["기준"][k] == pytest.approx(full["기준"][k] * Decimal("0.9"))
|
||||
for a, b in zip(full["줄"], part["줄"]):
|
||||
assert b["단가"] == a["단가"]
|
||||
assert b["수량"] == pytest.approx(a["수량"] * Decimal("0.9"))
|
||||
assert b["금액"] == pytest.approx(a["금액"] * Decimal("0.9"))
|
||||
for k, v in a["비목"].items():
|
||||
assert b["비목"][k] == pytest.approx(v * Decimal("0.9"))
|
||||
assert labor.run_line(whole, CODE, {}, 100) == full
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -5, 200.1, 90.55, "abc"])
|
||||
def test_율_범위_밖은_400(bad) -> None:
|
||||
with pytest.raises(store.StoreError):
|
||||
labor.clean({"담은로직": [{"로직": CODE, "켜쌓기율": bad}]})
|
||||
|
||||
|
||||
def test_율_정리() -> None:
|
||||
def rate(code: str, value):
|
||||
return labor.clean({"담은로직": [{"로직": code, "켜쌓기율": value}]})["담은로직"][0].get(
|
||||
"켜쌓기율"
|
||||
)
|
||||
|
||||
assert rate(CODE, "90") == 90
|
||||
assert rate(CODE, 87.5) == 87.5
|
||||
assert rate(CODE, 200) == 200
|
||||
assert rate(CODE, 100) is None # 100 = 없음
|
||||
assert rate(CODE, None) is None
|
||||
assert rate("GF000219", 90) is None # 대상 밖은 안 둠
|
||||
|
||||
|
||||
def test_문서_계산과_프로젝트_사본_같음() -> None:
|
||||
doc = labor.clean({"단위": "㎡", "담은로직": [{"로직": CODE, "켜쌓기율": 90}]})
|
||||
whole = m01.loaded()[1]
|
||||
auto = m01.mau.values(whole, whole.logic(CODE))
|
||||
values = {CODE: {k: {"값": labor._json(v), "확인": True} for k, v in auto.items()}}
|
||||
copy = {"줄": doc, "알약": labor.pills(doc)}
|
||||
got = prices.calc(copy, prices.to_json(prices.collect([CODE])), values, {})
|
||||
origin = labor.calc(doc, {})
|
||||
(line,) = got["줄"]
|
||||
assert line["계"] == pytest.approx(origin["줄"][0]["기준"]["계"])
|
||||
full = labor.calc({**doc, "담은로직": [{**doc["담은로직"][0], "켜쌓기율": None}]}, {})
|
||||
assert _d(line["계"]) == pytest.approx(_d(full["줄"][0]["기준"]["계"]) * Decimal("0.9"))
|
||||
|
||||
|
||||
def test_서고_단가승격도_율() -> None:
|
||||
doc = refs.clean_doc(
|
||||
"promote", {"키": "SP-900001", "담은로직": [{"로직": CODE, "켜쌓기율": 90}]}
|
||||
)
|
||||
assert doc["담은로직"][0]["켜쌓기율"] == 90
|
||||
whole = m01.loaded()[1]
|
||||
expand = refs.Expand(whole, lambda ref: doc)
|
||||
got = expand({"층": "system", "주인": None, "키": "SP-900001"})
|
||||
full = labor.run_line(whole, CODE, {})
|
||||
times = _d(doc["배"])
|
||||
assert got["기준"]["계"] == pytest.approx(full["기준"]["계"] * Decimal("0.9") / times)
|
||||
|
||||
|
||||
RATE_SCRIPT = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const root = process.argv[1];
|
||||
const ts = require(path.join(root, "config", "node_modules", "typescript"));
|
||||
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : () => undefined) });
|
||||
const f = path.join(root, "M02_MasterTemplete", "M02_MasterTemplete_Labor_Input.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(f, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
|
||||
const m = box.exports;
|
||||
console.log(JSON.stringify(["", "90", "87.5", "100", "200", "0", "200.1", "90.55", "x"].map(
|
||||
(v) => { const r = m.layerRate(v); return Number.isNaN(r) ? "bad" : r; })));
|
||||
"""
|
||||
|
||||
|
||||
def test_화면_율_칸_검사() -> None:
|
||||
"""옵션 모달 켜쌓기율 칸 — 서버 `_rate` 와 같은 범위(빈 칸 · 100 = 없음)."""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node 없음")
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
out = subprocess.run(
|
||||
[node, "-e", RATE_SCRIPT, str(root)], capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert json.loads(out.stdout) == [None, 90, 87.5, None, 200, "bad", "bad", "bad", "bad"]
|
||||
@@ -42,3 +42,24 @@ def test_폭_자르기():
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [280, 500, 1360, 280]
|
||||
|
||||
|
||||
def test_따라붙기_끔_옵션():
|
||||
"""85-5 — `follow: false` = followView 안 부름 · 칸 높이 채움(is-fill) · M02 상세 둘만 씀 · M01 그대로."""
|
||||
src = PART.read_text(encoding="utf-8")
|
||||
assert (
|
||||
'if (opts.follow === false) root.classList.add("is-fill");\n else followView(root);' in src
|
||||
)
|
||||
css = PART.with_suffix(".css").read_text(encoding="utf-8")
|
||||
assert ".ui-side-panels.is-fill {" in css and "overscroll-behavior: contain" in css
|
||||
m02 = ROOT / "M02_MasterTemplete"
|
||||
for name in ("M02_MasterTemplete_AreaPanel.ts", "M02_MasterTemplete_Labor_Detail.ts"):
|
||||
assert "follow: false," in (m02 / name).read_text(encoding="utf-8"), name
|
||||
m01 = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts").read_text(encoding="utf-8")
|
||||
assert "follow" not in m01
|
||||
style = (m02 / "M02_MasterTemplete_UI_Style.css").read_text(encoding="utf-8")
|
||||
assert ".m02-main:has(> .m02-main__host > .ui-side-panels-row)" in style
|
||||
source = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert 'scrollIntoView({ block: "center" })' not in source and "box.scrollTop +=" in source
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* - handles = 패널 수(M01 2 · 일위대가 1 · 면적 2) · 손잡이 x < 패널 x(왼쪽 가장자리 밖)
|
||||
* - steps[i].open = 누른 패널 하나만 · width 모두 같음(폭 공유) · none.open = [] · none.rootW = 0(손잡이만 남음)
|
||||
* - sticky.tops = [0 · 250 · 400 굴렸을 때 패널 top] — 머리띠(head) 아래 8 에 닿으면 그 자리에 멈춤(250 = 400) · 본문이 짧으면 덜 굴러감(scrolled) · inView = 패널 위아래 끝이 창 안
|
||||
* · M02 일위대가 · 면적산출(85-5 · is-fill) = 본문 스크롤 칸이 패널 형제 본문 · tops 셋 같음(이동 0)
|
||||
* - ownScroll: 패널 몸을 굴리면 몸 scrollTop 만 바뀌고 본문 스크롤 그대로(mainMoved 0)
|
||||
* - m01Cells = m02Cells(같은 로직 GC000198 원문 표 칸 수) · 표 둘(7칸 · 2칸)
|
||||
* - m01Look = m02Look(sameLook true) — 같은 클래스 · 글자 크기(16px) · 표 칸 꼴 · tableFit 모두 true(표 높이 = 내용 · 자체 세로 스크롤 없음)
|
||||
@@ -60,7 +61,16 @@
|
||||
const panel = box(scrollId);
|
||||
const body = $(".ui-side-panels__body", panel);
|
||||
// 본문 스크롤 칸 — 위 칸 중 굴러가는 것 · 없으면 창 · 숨은 탭은 scroll 알림이 안 와 직접 보냄
|
||||
let sc = null;
|
||||
let sc = sp.classList.contains("is-fill")
|
||||
? ([...sp.parentElement.querySelectorAll("*")].find((n) => {
|
||||
const oy = getComputedStyle(n).overflowY;
|
||||
return (
|
||||
!sp.contains(n) &&
|
||||
(oy === "auto" || oy === "scroll") &&
|
||||
n.scrollHeight > n.clientHeight + 1
|
||||
);
|
||||
}) ?? null)
|
||||
: null;
|
||||
for (let n = sp.parentElement; n && !sc; n = n.parentElement) {
|
||||
const oy = getComputedStyle(n).overflowY;
|
||||
if ((oy === "auto" || oy === "scroll") && n.scrollHeight > n.clientHeight + 1) sc = n;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 시작 화면: 패널이 이미 열린 상세 화면 하나(M02 일위대가 상세 · M02 면적산출 · M01 로직 상세 중 하나) · 화면마다 한 번씩 부름
|
||||
* 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌)
|
||||
* 기대: ok = true · h = want(innerHeight − max(머리띠, 칸 위)) ±2 · 칸이 올라가 붙은 뒤는 innerHeight − 머리띠 · inView = 그대로·처음·중간·끝 모두 true
|
||||
* · M02 일위대가 · 면적산출 상세(85-5 · follow:false = is-fill) — want = 칸 높이 · 본문 스크롤 칸 = 패널 형제 본문 · moved(처음~끝 top 차) = 0
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -10,7 +11,17 @@
|
||||
);
|
||||
if (!sp) return JSON.stringify({ error: "펴진 패널 없음" });
|
||||
const head = document.querySelector(".app-header")?.getBoundingClientRect().bottom ?? 0;
|
||||
const fill = sp.classList.contains("is-fill");
|
||||
const scrolls = (n) =>
|
||||
["auto", "scroll"].includes(getComputedStyle(n).overflowY) &&
|
||||
n.scrollHeight > n.clientHeight + 1;
|
||||
const scroller = (() => {
|
||||
// is-fill(85-5) — 메인 = 같은 칸의 본문(패널 밖)
|
||||
if (fill)
|
||||
return (
|
||||
[...sp.parentElement.querySelectorAll("*")].find((n) => !sp.contains(n) && scrolls(n)) ??
|
||||
document.scrollingElement
|
||||
);
|
||||
for (let n = sp.parentElement; n; n = n.parentElement) {
|
||||
const y = getComputedStyle(n).overflowY;
|
||||
if ((y === "auto" || y === "scroll") && n.scrollHeight > n.clientHeight + 1) return n;
|
||||
@@ -23,9 +34,9 @@
|
||||
window.dispatchEvent(new Event("scroll")); // 뒤에 가린 탭은 스크롤 알림이 안 와서 직접
|
||||
await w(150);
|
||||
const r = sp.getBoundingClientRect();
|
||||
const want = Math.round(
|
||||
innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top),
|
||||
);
|
||||
const want = fill
|
||||
? Math.round(sp.parentElement.getBoundingClientRect().height)
|
||||
: Math.round(innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top));
|
||||
const body = sp.querySelector(".ui-side-panels__panel:not([hidden]) .ui-side-panels__body");
|
||||
return {
|
||||
name,
|
||||
@@ -43,7 +54,9 @@
|
||||
const still = {
|
||||
name: "그대로",
|
||||
h: Math.round(r0.height),
|
||||
want: Math.round(innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top)),
|
||||
want: fill
|
||||
? Math.round(sp.parentElement.getBoundingClientRect().height)
|
||||
: Math.round(innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top)),
|
||||
inView: r0.top >= head - 2 && r0.bottom <= innerHeight + 2,
|
||||
};
|
||||
const rows = [
|
||||
@@ -58,6 +71,11 @@
|
||||
head: Math.round(head),
|
||||
scrollMax: Math.round(max),
|
||||
rows,
|
||||
ok: rows.every((r) => Math.abs(r.h - r.want) <= 2 && r.inView),
|
||||
fill,
|
||||
moved:
|
||||
Math.max(...rows.slice(1).map((r) => r.top)) - Math.min(...rows.slice(1).map((r) => r.top)),
|
||||
ok:
|
||||
rows.every((r) => Math.abs(r.h - r.want) <= 2 && r.inView) &&
|
||||
(!fill || rows.slice(1).every((r) => r.top === rows[1].top)),
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -28,6 +28,21 @@
|
||||
margin-left: calc(var(--spacing-24) + var(--spacing-8));
|
||||
}
|
||||
|
||||
/* follow:false(85-5) — 부르는 칸(고정 높이) 높이를 채움 · 패널 내용이 칸 높이를 늘리지 않음 */
|
||||
.ui-side-panels.is-fill {
|
||||
align-self: stretch;
|
||||
width: var(--ui-side-panels-width, 420px);
|
||||
height: auto;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.ui-side-panels.is-fill > .ui-side-panels__panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* 모두 접힘 — 폭 0 · 손잡이만 왼쪽 밖에 남음 */
|
||||
.ui-side-panels.is-collapsed {
|
||||
width: 0;
|
||||
@@ -113,4 +128,5 @@
|
||||
min-height: 0;
|
||||
padding: var(--spacing-12);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain; /* 몸 끝에서 메인으로 이어 굴리지 않음(85-5) */
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* 좌측 패널과 같은 접기 양식 — 패널 왼쪽 가장자리 손잡이(공용 createWorkflowPanelHandle · 오른쪽으로 접힘)
|
||||
* 패널마다 손잡이 하나(이름표 세로) · 누르면 폄/접음 · 모두 접히면 손잡이만 남음
|
||||
* 편 패널 = 머리(제목) + 몸 · 왼쪽 경계 끌기(화살표 키) · 보이는 칸 안에 붙어 몸만 따로 스크롤(followView)
|
||||
* `follow: false`(85-5) = followView 안 씀 · 부르는 칸(고정 높이) 높이를 채움 · 메인 스크롤과 따로(M02 상세)
|
||||
* 열린 패널 · 폭 = 부르는 쪽 취향 키(`aislo:pref:{prefKey}`)
|
||||
* 부르기: const sp = createSidePanels({ prefKey, panels: [{ id, title, body }] }) →
|
||||
* 본문과 함께 `.ui-side-panels-row` 칸에 [본문, sp.root] 로 넣음
|
||||
@@ -42,6 +43,8 @@ export interface SidePanelsOpts {
|
||||
onChange?(open: string | null): void;
|
||||
/** 폭이 바뀜(끌기 · 화살표) */
|
||||
onResize?(px: number): void;
|
||||
/** 보이는 칸 따라 붙음(followView) · 기본 참 · 거짓 = 부르는 칸 높이를 채움(칸이 고정 높이 · 85-5) */
|
||||
follow?: boolean;
|
||||
}
|
||||
|
||||
export interface SidePanels {
|
||||
@@ -166,7 +169,8 @@ export function createSidePanels(opts: SidePanelsOpts): SidePanels {
|
||||
}
|
||||
|
||||
draw();
|
||||
followView(root);
|
||||
if (opts.follow === false) root.classList.add("is-fill");
|
||||
else followView(root);
|
||||
return { root, open, current: () => now, width: () => width };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user