Merge remote-tracking branch 'origin/dev' into sub_laptop_6
This commit is contained in:
@@ -86,10 +86,11 @@ def now_text() -> str:
|
||||
|
||||
|
||||
def same_item(manifest: Any, item: dict[str, Any]) -> bool:
|
||||
"""같은 항목 · 같은 규격 열(704-4 · 열이 바뀌면 다른 사본 · 덮개는 폴더 열쇠라 그대로 남음)."""
|
||||
origin = manifest.get("원본") if isinstance(manifest, dict) else None
|
||||
if not isinstance(origin, dict):
|
||||
return False
|
||||
return all(origin.get(k) == item.get(k) for k in ("layer", "owner", "key"))
|
||||
return all(origin.get(k) == item.get(k) for k in ("layer", "owner", "key", "spec"))
|
||||
|
||||
|
||||
def origin_of(item: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -142,6 +143,9 @@ def write_snapshot(target: Path, item: dict[str, Any], owner_id: str, extra: dic
|
||||
atomic_write_json(target / name, content)
|
||||
stamp = now_text()
|
||||
source = version_of(source_files(files))
|
||||
spec = ((files.get(LABOR_FILE) or {}).get("줄") or {}).get("규격") # 굳힌 규격 열(704-4)
|
||||
if isinstance(spec, dict):
|
||||
extra = {**extra, "규격": {"이름": spec.get("이름"), "열": spec.get("기본")}}
|
||||
manifest = {
|
||||
"원본": origin_of(item),
|
||||
"판": source,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
모으는 것 = `{파일 이름: 내용}`:
|
||||
`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA ·
|
||||
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄)
|
||||
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄 ·
|
||||
규격 열 = summary_item `spec` 열 수량을 손으로 굳힘 · 704-4)
|
||||
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) +
|
||||
참조사본(단가승격 · 사전조합을 값으로 굳힘) ·
|
||||
`단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT)
|
||||
@@ -146,7 +147,9 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
files[name] = part
|
||||
doc = read_json(root / LABOR_KIND / f"{column_id}.json") if column_id else None
|
||||
if isinstance(doc, dict):
|
||||
labor = labor_doc_rows(doc)
|
||||
from M02_MasterTemplete import M02_Labor_Spec as labor_spec
|
||||
|
||||
labor = labor_doc_rows(labor_spec.freeze(doc, item.get("spec"))) # 고른 규격 열(704-4)
|
||||
else:
|
||||
labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None
|
||||
if labor is not None:
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만.
|
||||
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
|
||||
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||||
규격 열(704-4) = 문서 `규격?` 칸 · 열마다 줄 수량(`M02_Labor_Spec` · settle `col`) · 계산값 `규격합`.
|
||||
로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) ·
|
||||
고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와
|
||||
같은 품목 두 번은 [저장] 때 400.
|
||||
@@ -42,6 +43,7 @@ from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Labor_Spec as spec
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Material_Own as own
|
||||
|
||||
@@ -192,7 +194,11 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
}
|
||||
if doc.get("일위대가번"):
|
||||
out["일위대가번"] = doc["일위대가번"]
|
||||
return out
|
||||
try: # 규격 열(704-4 · `M02_Labor_Spec`) — 없으면 칸 없음(옛 문서)
|
||||
cols = spec.clean(doc.get("규격"), {spec.line_key(x) for x in [*lines, *mats]})
|
||||
except spec.SpecError as e:
|
||||
raise _bad(str(e)) from e
|
||||
return {**out, "규격": cols} if cols else out
|
||||
|
||||
|
||||
def _alias(alias: Any, where: str) -> str | None:
|
||||
@@ -424,9 +430,10 @@ def line_id(one: dict) -> Any:
|
||||
return refs.ref_id(one["참조"]) if _is_ref(one) else one.get("로직")
|
||||
|
||||
|
||||
def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]:
|
||||
def settle(result: dict, doc: dict, given: dict[str, Any], col: Any = None) -> dict[str, Any]:
|
||||
"""기준이 선 계산 답에 수량 · 반영 · 합을 채움 — 수량만 바뀌면 이것만 다시
|
||||
(짝: `Labor_Calc.ts` `settle` · 자재 = 재료비)."""
|
||||
(짝: `Labor_Calc.ts` `settle` · 자재 = 재료비 · `col` = 규격 열 · 없으면 기본 · 704-4)."""
|
||||
doc = spec.at(doc, col)
|
||||
have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None}
|
||||
hands = {line_id(one): one.get("수량") for one in doc.get("담은로직") or []}
|
||||
mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []}
|
||||
@@ -659,7 +666,14 @@ def server(doc: Any) -> dict[str, Any]:
|
||||
got = clean(doc)
|
||||
out = calc(got)
|
||||
no_twice(got, out)
|
||||
return {**got, "계산값": out}
|
||||
return {**got, "계산값": {**out, **spec_sums(got, out)}}
|
||||
|
||||
|
||||
def spec_sums(doc: dict, out: dict) -> dict[str, Any]:
|
||||
"""규격 열마다 반영합(704-4 · 고르기 창 열 단추) `{규격합: {열: 합}}` — 규격 없음 = 빈 것."""
|
||||
cols = (doc.get("규격") or {}).get("열") or []
|
||||
have = spec.given_of(out)
|
||||
return {"규격합": {c: settle(out, doc, have, c)["반영합"] for c in cols}} if cols else {}
|
||||
|
||||
|
||||
def no_twice(got: dict, out: dict) -> None:
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""일위대가 규격 열(PLAN 704-4) — 문서 `규격` 칸 검사 · 열 끼우기 · 사본 굳히기 · 쓰는 사본 찾기.
|
||||
|
||||
규격 = `{이름: "H", 열: ["1.5", "2.0"], 기본: "2.0", 수량: {줄 id: {열: 수}}}` —
|
||||
줄 id = 로직 키 · 참조 `ref_id`(`M02_Labor_Doc.line_id`) · 자재 코드.
|
||||
규격 수량이 있는 줄 = 고른 열 값이 `{손}` 자리 · 없는 줄 · 빈 열 = 문서 `수량` 그대로(공통).
|
||||
규격 없음 = 옛 문서(열 하나 · 셈 그대로 · 옮김 없음).
|
||||
짝(CLAUDE.md 5장 ①): `at` ↔ `M02_MasterTemplete_Labor_Calc.ts` `atSpec` —
|
||||
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||||
사본(`M02_Item_Source.build_source`) = 설계가 고른 열(summary_item `spec`)을 `{손}` 으로 굳히고
|
||||
`규격` 은 그 열 하나만 남김(`freeze`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
NAME_MAX = 20 # 규격 이름 · 열 이름 글자 수 상한 — 화면 칸과 같음
|
||||
COLS_MAX = 20
|
||||
|
||||
|
||||
class SpecError(ValueError):
|
||||
"""규격 칸 꼴이 틀림 — 부르는 쪽이 400 으로."""
|
||||
|
||||
|
||||
def _num(raw: Any) -> int | float | None:
|
||||
if raw is None or isinstance(raw, bool):
|
||||
return None
|
||||
try:
|
||||
got = Decimal(str(raw).replace(",", "").strip())
|
||||
except InvalidOperation:
|
||||
return None
|
||||
if not got.is_finite():
|
||||
return None
|
||||
return int(got) if got == got.to_integral_value() else float(got)
|
||||
|
||||
|
||||
def _text(raw: Any, what: str) -> str:
|
||||
text = str(raw).strip() if isinstance(raw, (str, int, float)) and raw is not True else ""
|
||||
if not text or len(text) > NAME_MAX:
|
||||
raise SpecError(f"규격 {what}은 1~{NAME_MAX}자 글")
|
||||
return text
|
||||
|
||||
|
||||
def line_key(one: dict) -> Any:
|
||||
"""줄 id — 참조 줄 = `ref_id` · 로직 줄 = 로직 키 · 자재 줄 = 코드."""
|
||||
if isinstance(one.get("참조"), dict):
|
||||
return refs.ref_id(one["참조"])
|
||||
return one.get("로직") or one.get("코드")
|
||||
|
||||
|
||||
def clean(raw: Any, ids: set[Any]) -> dict[str, Any] | None:
|
||||
"""받은 `규격` → 저장 꼴 — 열이 없으면 None(규격 없음) · 이름 · 열 이름이 비거나 겹치면 ·
|
||||
수량이 수 아니면 SpecError · 없는 줄 · 없는 열 수량은 버림(지운 줄)."""
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
raise SpecError("규격은 묶음")
|
||||
cols: list[str] = []
|
||||
for one in raw.get("열") or []:
|
||||
col = _text(one, "열 이름")
|
||||
if col in cols:
|
||||
raise SpecError(f"같은 규격 열 두 번 「{col}」")
|
||||
cols.append(col)
|
||||
if not cols:
|
||||
return None
|
||||
if len(cols) > COLS_MAX:
|
||||
raise SpecError(f"규격 열은 {COLS_MAX}개까지")
|
||||
qty: dict[str, dict[str, int | float]] = {}
|
||||
for lid, row in (raw.get("수량") or {}).items():
|
||||
if lid not in ids or not isinstance(row, dict):
|
||||
continue
|
||||
kept = {}
|
||||
for col, value in row.items():
|
||||
if col not in cols or value in (None, ""):
|
||||
continue
|
||||
got = _num(value)
|
||||
if got is None:
|
||||
raise SpecError(f"규격 수량 「{lid} · {col}」 은 수")
|
||||
kept[col] = got
|
||||
if kept:
|
||||
qty[str(lid)] = kept
|
||||
return {
|
||||
"이름": _text(raw.get("이름"), "이름"),
|
||||
"열": cols,
|
||||
"기본": raw.get("기본") if raw.get("기본") in cols else cols[0],
|
||||
"수량": qty,
|
||||
}
|
||||
|
||||
|
||||
def column(doc: Any, col: Any = None) -> str | None:
|
||||
"""쓸 열 — 고른 열이 규격에 있으면 그것 · 없으면 기본 · 규격 없음 = None."""
|
||||
spec = doc.get("규격") if isinstance(doc, dict) else None
|
||||
if not isinstance(spec, dict) or not spec.get("열"):
|
||||
return None
|
||||
return col if col in spec["열"] else spec.get("기본") or spec["열"][0]
|
||||
|
||||
|
||||
def at(doc: dict, col: Any = None) -> dict:
|
||||
"""열 끼우기 — 그 열 규격 수량이 있는 줄 · 자재의 `수량` 을 `{손: 값}` 으로
|
||||
(규격 없음 = 그대로)."""
|
||||
use = column(doc, col)
|
||||
if use is None:
|
||||
return doc
|
||||
qty = doc["규격"].get("수량") or {}
|
||||
|
||||
def put(one: Any) -> Any:
|
||||
if not isinstance(one, dict):
|
||||
return one
|
||||
value = (qty.get(line_key(one)) or {}).get(use)
|
||||
return {**one, "수량": {"손": value}} if value is not None else one
|
||||
|
||||
return {
|
||||
**doc,
|
||||
"담은로직": [put(one) for one in doc.get("담은로직") or []],
|
||||
"자재": [put(one) for one in doc.get("자재") or []],
|
||||
}
|
||||
|
||||
|
||||
def given_of(result: dict) -> dict[str, Any]:
|
||||
"""셈 답에서 칸 값 되살림 `{알약: 수량}` — 열마다 다시 셀 때 면적 칸 값
|
||||
(문서 · 층을 다시 안 읽음)."""
|
||||
rows = [*(result.get("줄") or []), *(result.get("자재") or [])]
|
||||
return {r["알약"]: r["수량"] for r in rows if r.get("수량출처") == "칸" and r.get("알약")}
|
||||
|
||||
|
||||
def freeze(doc: dict, col: Any = None) -> dict:
|
||||
"""사본 굳히기 — 고른 열(없거나 지운 열 = 기본)을 `{손}` 으로 끼우고 규격은 그 열 하나만."""
|
||||
use = column(doc, col)
|
||||
if use is None:
|
||||
return doc
|
||||
got = {k: v for k, v in at(doc, use).items() if k != "계산값"} # 기본 열 계산값은 버림
|
||||
return {**got, "규격": {"이름": doc["규격"].get("이름"), "열": [use], "기본": use, "수량": {}}}
|
||||
|
||||
|
||||
def users(layer_root: Path, keys: set[str], cols: set[str]) -> list[str]:
|
||||
"""그 층 항목(`keys`) · 그 규격 열(`cols`)을 쓰는 프로젝트 사본 —
|
||||
`프로젝트 폴더/사본 폴더` 목록.
|
||||
|
||||
ponytail: 저장소 프로젝트 사본 manifest 를 통째로 훑음 — 열 지우기 · 이름 바꾸기 [저장] 때만 ·
|
||||
느리면 사본 색인을 둠."""
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_Item_Source import layer_root as root_of
|
||||
from M02_MasterTemplete.M02_Item_Source import read_json
|
||||
|
||||
found = []
|
||||
here = Path(layer_root).resolve()
|
||||
for path in layers.storage_root().glob("*/*/*/templates/structures/*/manifest.json"):
|
||||
origin = (read_json(path) or {}).get("원본")
|
||||
if not isinstance(origin, dict) or origin.get("key") not in keys:
|
||||
continue
|
||||
if origin.get("spec") not in cols:
|
||||
continue
|
||||
try:
|
||||
same = root_of(origin).resolve() == here
|
||||
except (KeyError, ValueError, TypeError):
|
||||
same = False
|
||||
if same:
|
||||
found.append(f"{path.parents[3].name}/{path.parent.name}")
|
||||
return found
|
||||
|
||||
|
||||
def guard(path: Path, new: Any, column: str) -> None:
|
||||
"""[저장] 때 빠진 규격 열(지움 · 이름 바꿈)을 쓰는 프로젝트 사본이 있으면 409 —
|
||||
지금 층(`store.root`) 그 열 항목(SS 키 · 옛 키) 기준."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_Item_Source import read_json
|
||||
|
||||
old = (read_json(path) or {}).get("규격") if path.is_file() else None
|
||||
gone = set((old or {}).get("열") or []) - set(((new or {}).get("규격") or {}).get("열") or [])
|
||||
if not gone:
|
||||
return
|
||||
rows = [r for r in store.read_combos().get("줄", []) if str(r.get("열")) == column]
|
||||
keys = {str(k) for r in rows for k in [r.get("키"), *(r.get("옛키") or [])] if k}
|
||||
used = users(store.root(), keys, gone)
|
||||
if used:
|
||||
raise store.StoreError(
|
||||
409,
|
||||
f"규격 열 「{', '.join(sorted(gone))}」 을 쓰는 프로젝트 사본 {len(used)}개 — 지울 수 없음 "
|
||||
f"({', '.join(used[:3])}{' …' if len(used) > 3 else ''})",
|
||||
)
|
||||
@@ -119,6 +119,14 @@ def _list_extras(folder: Path, col: str) -> dict[str, Any]:
|
||||
"기준": _number(calc.get("기준합")), # 수량 없는 일위대가 = 단가 합(39-8 B2)
|
||||
"단위": doc.get("단위"), # 일위대가 문서 단위(구조물 열 단위 아님 · B3)
|
||||
}
|
||||
spec = doc.get("규격") if isinstance(doc.get("규격"), dict) else None
|
||||
if spec and spec.get("열"): # 규격 열(704-4) — 고르기 창 열 단추 · 열마다 합계
|
||||
sums = calc.get("규격합") or {}
|
||||
labor["규격"] = {
|
||||
"이름": spec.get("이름"),
|
||||
"열": [{"이름": c, "합계": _number(sums.get(c))} for c in spec["열"]],
|
||||
"기본": spec.get("기본"),
|
||||
}
|
||||
return {"수정일": stamps, "labor": labor}
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type LogicSummary,
|
||||
type SubBrief,
|
||||
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { LaborCalc, LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import type { LaborCalc, LaborRef, LaborSpec } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import {
|
||||
call,
|
||||
getActiveLayer,
|
||||
@@ -238,6 +238,8 @@ export interface LaborDoc {
|
||||
담은로직: LaborRow[];
|
||||
자재: LaborMat[];
|
||||
계산값?: unknown;
|
||||
/** 규격 열(704-4 · `Labor_Spec`) — 없으면 옛 문서(열 하나) */
|
||||
규격?: LaborSpec | null;
|
||||
}
|
||||
/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */
|
||||
export function normalizeLabor(doc: unknown, column: string): LaborDoc {
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만.
|
||||
* 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`).
|
||||
* 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`) ·
|
||||
* 고정 줄(705-1)은 「고정|이름|규격|단위」(짝 `M02_Labor_Fixed.fixed_id`) · 기준 = 서버가 낸 줄 단가. */
|
||||
* 고정 줄(705-1)은 「고정|이름|규격|단위」(짝 `M02_Labor_Fixed.fixed_id`) · 기준 = 서버가 낸 줄 단가.
|
||||
* 규격 열(704-4) = 문서 `규격` · 그 열 수량이 있는 줄 = `{손}` 자리(`atSpec` · 짝 `M02_Labor_Spec.at`). */
|
||||
|
||||
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { LogicMat } from "./M02_Labor_LogicMats_Model";
|
||||
@@ -94,10 +95,19 @@ export interface LaborCalc {
|
||||
반영합?: Money;
|
||||
}
|
||||
|
||||
/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */
|
||||
/** 규격 열(704-4) — 이름(H · Φ) · 열(화면 차례) · 기본 · 줄 id 마다 열 수량 */
|
||||
export interface LaborSpec {
|
||||
이름: string;
|
||||
열: string[];
|
||||
기본: string;
|
||||
수량: Record<string, Record<string, number>>;
|
||||
}
|
||||
|
||||
/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 · 규격 열 */
|
||||
export interface LaborQtyDoc {
|
||||
담은로직?: { 로직?: string; 참조?: LaborRef; 고정?: LaborFixed; 수량?: Qty }[];
|
||||
자재?: { 코드: string; 수량?: Qty; 별도계상?: string }[];
|
||||
규격?: LaborSpec | null;
|
||||
}
|
||||
|
||||
function num(raw: unknown): number | null {
|
||||
@@ -119,6 +129,37 @@ export function lineId(one: {
|
||||
return one.로직;
|
||||
}
|
||||
|
||||
/** 쓸 규격 열 — 고른 열이 있으면 그것 · 없으면 기본 · 규격 없음 = null(짝 `M02_Labor_Spec.column`) */
|
||||
export function specColumn(doc: LaborQtyDoc, col?: string | null): string | null {
|
||||
const cols = doc.규격?.열 ?? [];
|
||||
if (!cols.length) return null;
|
||||
return col && cols.includes(col) ? col : doc.규격?.기본 || cols[0];
|
||||
}
|
||||
|
||||
/** 열 끼우기 — 그 열 규격 수량이 있는 줄 · 자재의 수량을 `{손}` 으로(짝 `M02_Labor_Spec.at`) */
|
||||
export function atSpec<D extends LaborQtyDoc>(doc: D, col?: string | null): D {
|
||||
const use = specColumn(doc, col);
|
||||
if (use === null) return doc;
|
||||
const qty = doc.규격?.수량 ?? {};
|
||||
const put = <T extends { 수량?: Qty }>(one: T, id: string | undefined): T => {
|
||||
const v = id !== undefined ? qty[id]?.[use] : undefined;
|
||||
return v !== undefined && v !== null ? { ...one, 수량: { 손: v } } : one;
|
||||
};
|
||||
return {
|
||||
...doc,
|
||||
담은로직: (doc.담은로직 ?? []).map((one) => put(one, lineId(one))),
|
||||
자재: (doc.자재 ?? []).map((one) => put(one, one.코드)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 셈 답에서 칸 값 되살림 `{알약: 수량}` — 열마다 다시 셀 때(짝 `M02_Labor_Spec.given_of`) */
|
||||
export function givenOf(result: LaborCalc): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const r of [...result.줄, ...result.자재])
|
||||
if (r.수량출처 === "칸" && r.알약 && r.수량 != null) out[r.알약] = r.수량;
|
||||
return out;
|
||||
}
|
||||
|
||||
function zero(): Money {
|
||||
return { 노무비: 0, 재료비: 0, 경비: 0, 계: 0 };
|
||||
}
|
||||
@@ -142,12 +183,15 @@ export function reflect(base: Money, qty: number | null, times: number): Money |
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 */
|
||||
/** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 ·
|
||||
* `col` = 규격 열(없으면 기본 · 704-4) */
|
||||
export function settle(
|
||||
result: LaborCalc,
|
||||
doc: LaborQtyDoc,
|
||||
whole: LaborQtyDoc,
|
||||
given: Record<string, unknown>,
|
||||
col?: string | null,
|
||||
): LaborCalc {
|
||||
const doc = atSpec(whole, col);
|
||||
const have: Record<string, number> = {};
|
||||
for (const [k, raw] of Object.entries(given)) {
|
||||
const v = num(raw);
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 자재목록(175-1) · 원문(면적 · 다이어그램 없음) ·
|
||||
* 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1)
|
||||
* · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음)
|
||||
* · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만
|
||||
* · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 · 규격 표(704-4 · `Labor_Spec`)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -218,7 +218,7 @@ export function mountLaborDetail(
|
||||
),
|
||||
);
|
||||
const draw = (): void => {
|
||||
gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics));
|
||||
gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && !lib && changed));
|
||||
drawLogic();
|
||||
matHost.replaceChildren(
|
||||
matTable(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 로직 줄 = 이름(별칭 앞섬) · 규격(옵션 값 · 켜쌓기) · 수량 · 분모 · 단가 = 기준 ÷ 배 · 금액 = 반영 · 꼬리표 품셈
|
||||
* 자재 줄 = 이름 · 규격 · 수량 · 단위 · 재료비 단가 = 기준단가 · 금액 = 반영 · 별도계상은 비고 「합계 밖」
|
||||
* 빈 줄(금액 모름 · 수량 없음) = 합계 줄 「빈 줄 n」 붉게 — 701-4 경고와 같은 줄
|
||||
* 규격 열(704-4 · `Labor_Spec`) = 수량 칸 열마다 · 고칠 때 격자 위 규격 줄 + 수량 칸 고침
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ import type { LaborCalc, LaborCost, LaborDoc } from "./M02_MasterTemplete_Labor_
|
||||
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { valueLine } from "./M02_Labor_Values_Model";
|
||||
import { specBar, specCells, withSpecs, type SpecEdit } from "./M02_MasterTemplete_Labor_Spec";
|
||||
|
||||
const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney =>
|
||||
m
|
||||
@@ -77,7 +79,7 @@ export function laborBlock(
|
||||
};
|
||||
});
|
||||
const lines = [...logicLines, ...matLines];
|
||||
return {
|
||||
const block: HopyoBlock = {
|
||||
key: doc.열,
|
||||
name: doc.일위대가번 || doc.열,
|
||||
unit: doc.단위 ?? "",
|
||||
@@ -85,11 +87,20 @@ export function laborBlock(
|
||||
lines,
|
||||
...(calc ? {} : { blank: 0 }),
|
||||
};
|
||||
return withSpecs(block, doc, calc);
|
||||
}
|
||||
|
||||
/** 격자 하나(읽기 전용) */
|
||||
/** 격자 하나 — `edit` 있으면 위에 규격 줄 · 규격 수량 칸 고침(704-4) */
|
||||
export const laborGrid = (
|
||||
doc: LaborDoc,
|
||||
calc: LaborCalc | null,
|
||||
logics: ReadonlyMap<string, ItemLogic>,
|
||||
): HTMLElement => createHopyoGrid([laborBlock(doc, calc, logics)]).root;
|
||||
edit?: SpecEdit | false,
|
||||
): HTMLElement => {
|
||||
const block = laborBlock(doc, calc, logics);
|
||||
if (!edit) return createHopyoGrid([block]).root;
|
||||
const grid = createHopyoGrid([block], specCells(block, doc, edit)).root;
|
||||
const box = document.createElement("div");
|
||||
box.append(specBar(doc, calc, edit), grid);
|
||||
return box;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Spec.ts
|
||||
* 일위대가 규격 표(PLAN 704-4) — 704-3 격자에 규격 열(H=1.5 | 2.0 …)을 폄 · 고칠 때 격자 위 규격 줄.
|
||||
* 문서 `규격` = {이름, 열, 기본, 수량: {줄 id: {열: 수}}}(짝 `M02_Labor_Spec.py`) · 화면 캐시만(저장은 [저장])
|
||||
* 규격 줄 = 규격 이름 · 열마다 이름 칸 · [기본] · [×] · [+ 열](새 열 수량 = 지금 기본 열 값 그대로)
|
||||
* 격자 수량 칸(열마다) 고침 = 그 줄 · 그 열 수량(빈 글 = 공통 수량을 따름)
|
||||
* 열마다 수량 · 1 단위 합계 = 짝 셈 `settle(…, 열)`(서버 안 부름) · 금액 칸 = 기본 열
|
||||
* 쓰는 사본이 있는 열 지움 · 이름 바꿈은 [저장] 때 서버가 막음(409 까닭 그대로)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el } from "@ui/ui_template_elements";
|
||||
import type { DataTableOptions } from "@ui/ui_template_data_table";
|
||||
import { hopyoGrid, hopyoSpecKey, type HopyoBlock } from "@ui/ui_template_hopyo_grid";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import type { LaborCalc, LaborDoc } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { givenOf, lineId, settle, specColumn } from "./M02_MasterTemplete_Labor_Calc";
|
||||
|
||||
/** 고칠 때 — 규격이 바뀌면 `true`(수량만 · 기준 그대로 · 짝 셈으로 바로) */
|
||||
export type SpecEdit = (qtyOnly: boolean) => void;
|
||||
|
||||
const TEXT = {
|
||||
none: "규격 열 없음 — [+ 열] = H 같은 규격마다 줄 수량만 다른 열",
|
||||
add: "+ 열",
|
||||
name: "규격 이름",
|
||||
base: "기본",
|
||||
remove: "열 지움",
|
||||
first: "1",
|
||||
next: "새 열",
|
||||
};
|
||||
|
||||
/** 줄 id 차례 — 격자 줄 차례(로직 줄 → 자재 줄)와 같음 */
|
||||
const lineIds = (doc: LaborDoc): string[] => [
|
||||
...doc.담은로직.map((r) => lineId(r) ?? ""),
|
||||
...doc.자재.map((m) => m.코드),
|
||||
];
|
||||
|
||||
/** 열마다 셈 → 블록 `specs` · 줄 `qtys`(규격 없음 = 그대로) */
|
||||
export function withSpecs(block: HopyoBlock, doc: LaborDoc, calc: LaborCalc | null): HopyoBlock {
|
||||
const spec = doc.규격;
|
||||
if (!spec?.열.length) return block;
|
||||
const per = calc ? spec.열.map((c) => settle(calc, doc, givenOf(calc), c)) : [];
|
||||
const ids = lineIds(doc);
|
||||
const logics = doc.담은로직.length;
|
||||
const qtyOf = (p: LaborCalc, n: number): number | null =>
|
||||
(n < logics
|
||||
? p.줄.find((l) => lineId(l) === ids[n])?.수량
|
||||
: p.자재.find((m) => m.코드 === ids[n])?.수량) ?? null;
|
||||
return {
|
||||
...block,
|
||||
specs: {
|
||||
name: spec.이름,
|
||||
cols: spec.열,
|
||||
pick: specColumn(doc),
|
||||
totals: per.map((p) => p.반영합?.계 ?? null),
|
||||
},
|
||||
lines: block.lines.map((ln, n) => ({ ...ln, qtys: per.map((p) => qtyOf(p, n)) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** 격자 수량 칸 고침(열마다) — 공용 표 옵션 덧 */
|
||||
export function specCells(
|
||||
block: HopyoBlock,
|
||||
doc: LaborDoc,
|
||||
edit: SpecEdit,
|
||||
): Partial<DataTableOptions> {
|
||||
const spec = doc.규격;
|
||||
if (!spec?.열.length) return {};
|
||||
const g = hopyoGrid([block]);
|
||||
const ids = lineIds(doc);
|
||||
const col = (key: string): string | undefined => spec.열.find((_, n) => hopyoSpecKey(n) === key);
|
||||
return {
|
||||
editable: (i, key) => g.at.has(i) && col(key) !== undefined,
|
||||
onEdit: (i, key, text) => {
|
||||
const id = ids[g.at.get(i)?.[1] ?? -1];
|
||||
const name = col(key);
|
||||
if (id === undefined || name === undefined) return;
|
||||
const v = Number(text.replace(/,/g, "").trim());
|
||||
const row = { ...(spec.수량[id] ?? {}) };
|
||||
if (text.trim() === "" || !Number.isFinite(v)) delete row[name];
|
||||
else row[name] = v;
|
||||
if (Object.keys(row).length) spec.수량[id] = row;
|
||||
else delete spec.수량[id];
|
||||
edit(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 새 열 이름 — 겹치면 뒤에 번호 */
|
||||
const fresh = (cols: string[], want: string): string => {
|
||||
let out = want;
|
||||
for (let n = 2; cols.includes(out); n++) out = `${want} ${n}`;
|
||||
return out;
|
||||
};
|
||||
|
||||
/** [+ 열] — 규격이 없으면 지금 수량 열(1) + 새 열 · 새 열 수량 = 기본 열 손 값 그대로 */
|
||||
function addColumn(doc: LaborDoc, calc: LaborCalc | null): void {
|
||||
const spec = (doc.규격 ??= { 이름: "H", 열: [TEXT.first], 기본: TEXT.first, 수량: {} });
|
||||
const col = fresh(spec.열, TEXT.next);
|
||||
const base = specColumn(doc) ?? spec.열[0];
|
||||
spec.열.push(col);
|
||||
const ids = lineIds(doc);
|
||||
const logics = doc.담은로직.length;
|
||||
ids.forEach((id, n) => {
|
||||
const own = spec.수량[id]?.[base];
|
||||
const line =
|
||||
n < logics ? calc?.줄.find((l) => lineId(l) === id) : calc?.자재.find((m) => m.코드 === id);
|
||||
const v = own ?? (line?.수량출처 === "손" ? line.수량 : null);
|
||||
if (v != null) spec.수량[id] = { ...(spec.수량[id] ?? {}), [col]: v };
|
||||
});
|
||||
}
|
||||
|
||||
function renameColumn(doc: LaborDoc, from: string, to: string): boolean {
|
||||
const spec = doc.규격;
|
||||
const name = to.trim();
|
||||
if (!spec || !name || name.length > 20 || name === from || spec.열.includes(name)) return false;
|
||||
spec.열 = spec.열.map((c) => (c === from ? name : c));
|
||||
if (spec.기본 === from) spec.기본 = name;
|
||||
for (const row of Object.values(spec.수량))
|
||||
if (from in row) {
|
||||
row[name] = row[from];
|
||||
delete row[from];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeColumn(doc: LaborDoc, col: string): void {
|
||||
const spec = doc.규격;
|
||||
if (!spec) return;
|
||||
spec.열 = spec.열.filter((c) => c !== col);
|
||||
for (const [id, row] of Object.entries(spec.수량)) {
|
||||
delete row[col];
|
||||
if (!Object.keys(row).length) delete spec.수량[id];
|
||||
}
|
||||
if (!spec.열.length)
|
||||
doc.규격 = null; // 마지막 열 = 규격 없음(옛 문서 꼴)
|
||||
else if (spec.기본 === col) spec.기본 = spec.열[0];
|
||||
}
|
||||
|
||||
/** 격자 위 규격 줄(고칠 때만) */
|
||||
export function specBar(doc: LaborDoc, calc: LaborCalc | null, edit: SpecEdit): HTMLElement {
|
||||
const add = createButton({
|
||||
label: TEXT.add,
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
addColumn(doc, calc);
|
||||
edit(true);
|
||||
},
|
||||
});
|
||||
const spec = doc.규격;
|
||||
if (!spec?.열.length)
|
||||
return createToolbar({ items: [createText(TEXT.none, "muted"), add], label: TEXT.name });
|
||||
const name = createInputField({ value: spec.이름, compact: true, placeholder: TEXT.name });
|
||||
name.input.title = TEXT.name;
|
||||
name.root.style.width = "6em"; // 짧은 글 칸 — 열 이름 칸도 같은 폭(카드 한 줄에)
|
||||
name.input.addEventListener("change", () => {
|
||||
const v = name.input.value.trim();
|
||||
if (v && v.length <= 20) spec.이름 = v;
|
||||
edit(true);
|
||||
});
|
||||
const cols = spec.열.flatMap((col) => {
|
||||
const box = createInputField({ value: col, compact: true });
|
||||
box.input.setAttribute("data-spec-col", col);
|
||||
box.root.style.width = "6em";
|
||||
box.input.addEventListener("change", () => {
|
||||
renameColumn(doc, col, box.input.value);
|
||||
edit(true);
|
||||
});
|
||||
const base = createButton({
|
||||
label: TEXT.base,
|
||||
variant: col === spec.기본 ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
spec.기본 = col;
|
||||
edit(true);
|
||||
},
|
||||
});
|
||||
const drop = createButton({
|
||||
label: "×",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
removeColumn(doc, col);
|
||||
edit(true);
|
||||
},
|
||||
});
|
||||
drop.title = TEXT.remove;
|
||||
return [el("span", { children: [box.root] }), base, drop, "divider" as const];
|
||||
});
|
||||
return createToolbar({ items: [name.root, "divider", ...cols, add], label: TEXT.name });
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_ShareRequest.ts
|
||||
* 회사에 올리기 요청 · 승인(PLAN 706-1) — 서버 `M02_Share_Request.py` 짝(장부 = 회사 층 파일 하나).
|
||||
* 일반 사용자 = 자기 개인 층 구조물 조합 · 사전조합 나무 오른쪽 클릭 「회사에 올리기 요청」 → 글(선택) ·
|
||||
* 같은 것 요청이 남아 있으면 「요청 중」(흐림) · 결과는 다음에 M02 열 때 위 띠(닫으면 봄)
|
||||
* 회사 관리자 = 회사 층 위 띠 「올리기 요청 n」 → 목록(요청자 · 항목 · 날짜 · 글) · [미리 보기] ·
|
||||
* [승인](폴더 고름 = 「회사로 복사…」와 같은 길 · 원본 그대로) · [거절](사유 한 줄)
|
||||
* 관리자(회사 층을 고칠 수 있는 사람)는 「회사로 복사…」가 있어 요청 메뉴를 안 둠.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { createListRow } from "@ui/ui_template_list_row";
|
||||
import type { TreeMenuItem } from "@ui/ui_template_tree";
|
||||
import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import { call, fetchPrecomboTree, fetchTree, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { accessFor, ownOwner } from "./M02_MasterTemplete_Layer";
|
||||
import { askCopyFolder } from "./M02_MasterTemplete_TreePanel_CopyTo";
|
||||
import { askFolder, askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import type { Target } from "./M02_MasterTemplete_TreePanel_Pick";
|
||||
|
||||
type ShareKind = "item" | "precombo";
|
||||
|
||||
export interface ShareRow {
|
||||
id: string;
|
||||
종류: ShareKind;
|
||||
키: string;
|
||||
이름: string;
|
||||
경로: string[];
|
||||
주인: string;
|
||||
요청자: number;
|
||||
요청자이름: string;
|
||||
글: string;
|
||||
날짜: string;
|
||||
상태: "요청" | "승인" | "거절";
|
||||
처리자?: string;
|
||||
사유?: string;
|
||||
사본?: string;
|
||||
봄?: boolean;
|
||||
}
|
||||
|
||||
const KIND_NAME: Record<ShareKind, string> = { item: "구조물 조합", precombo: "사전조합" };
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const post = <T>(path: string, body: object = {}): Promise<T> =>
|
||||
call<T>(path, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
export interface ShareOptions {
|
||||
user: SessionUser | null;
|
||||
/** 볼 회사(시스템 관리자는 고른 회사) */
|
||||
company: string | null;
|
||||
/** 지금 페이지 층 — 회사 층이면 관리자 띠 */
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
showView: (label: string, node: HTMLElement) => void;
|
||||
/** 사전조합 미리 보기 — 편집 칸에 읽기만으로 */
|
||||
openPrecombo: (row: ShareRow) => void;
|
||||
/** 구조물 조합 미리 보기 — 요청자 개인 층으로 */
|
||||
openItem: (row: ShareRow) => void;
|
||||
/** 승인 뒤 — 나무 · 좌측 다시 읽기 */
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
export interface ShareHandle {
|
||||
/** 편집 칸 맨 위 띠 자리(관리자 「올리기 요청 n」 · 요청자 결과) */
|
||||
top: HTMLElement;
|
||||
/** 구조물 조합 나무 줄 덧메뉴 — 자기 개인 층 항목만 */
|
||||
itemMenu: (target: Target) => TreeMenuItem[];
|
||||
/** 사전조합 나무 줄 덧메뉴 — 자기 개인 층만(`mine`) */
|
||||
precomboMenu: (mine: boolean, key: string, name: string) => TreeMenuItem[];
|
||||
}
|
||||
|
||||
export function createShare(opt: ShareOptions): ShareHandle {
|
||||
const top = el("div", { attrs: { "data-share": "" } });
|
||||
const companyOwner = opt.company ?? ownOwner("company", opt.user);
|
||||
// 요청할 사람 = 회사가 있고 회사 층을 못 고침(고칠 수 있으면 「회사로 복사…」)
|
||||
const asker =
|
||||
!!ownOwner("personal", opt.user) && !accessFor("company", companyOwner, opt.user).canWrite;
|
||||
const boss =
|
||||
opt.layer === "company" &&
|
||||
!!companyOwner &&
|
||||
accessFor("company", companyOwner, opt.user).canWrite;
|
||||
const q = opt.company ? `?company=${encodeURIComponent(opt.company)}` : "";
|
||||
let rows: ShareRow[] = [];
|
||||
let listHost: HTMLElement | null = null;
|
||||
|
||||
const pending = (kind: ShareKind, key: string): boolean =>
|
||||
rows.some((r) => r.종류 === kind && r.키 === key && r.상태 === "요청");
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
if (!asker && !boss) return;
|
||||
rows = (await call<{ 줄: ShareRow[] }>(`/share-requests${q}`).catch(() => ({ 줄: [] }))).줄;
|
||||
paintTop();
|
||||
if (listHost?.isConnected) paintList(listHost);
|
||||
};
|
||||
|
||||
function paintTop(): void {
|
||||
if (boss) {
|
||||
const n = rows.filter((r) => r.상태 === "요청").length;
|
||||
top.replaceChildren(
|
||||
...(rows.length
|
||||
? [
|
||||
createBanner({
|
||||
tone: n ? "warning" : "info",
|
||||
text: n
|
||||
? `회사에 올리기 요청 ${n} 건 — 승인하면 회사 층에 사본`
|
||||
: "올리기 요청 없음",
|
||||
actions: [
|
||||
createButton({ label: `올리기 요청 ${n}`, size: "sm", onClick: openList }),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const done = rows.filter((r) => r.봄 === false);
|
||||
top.replaceChildren(
|
||||
...(done.length
|
||||
? [
|
||||
createBanner({
|
||||
tone: done.some((r) => r.상태 === "거절") ? "warning" : "success",
|
||||
title: "회사에 올리기 결과",
|
||||
text: done
|
||||
.map((r) => `「${r.이름}」 ${r.상태}${r.사유 ? `(${r.사유})` : ""}`)
|
||||
.join(" · "),
|
||||
onClose: () => void post(`/share-requests/seen`).catch(() => undefined),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
}
|
||||
|
||||
async function ask(kind: ShareKind, key: string, name: string): Promise<void> {
|
||||
const got = await askText(
|
||||
`회사에 올리기 요청 — ${name}`,
|
||||
[{ key: "글", label: "요청 글(선택)", optional: true }],
|
||||
"요청",
|
||||
);
|
||||
if (!got) return;
|
||||
try {
|
||||
await post("/share-requests", { 종류: kind, 키: key, 글: got.글 });
|
||||
showToast("요청함 — 회사 관리자가 승인하면 회사 층에 올라감", "success");
|
||||
} catch (error) {
|
||||
showToast(`요청 못 함 — ${why(error)}`, "error");
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
const menu = (kind: ShareKind, key: string, name: string): TreeMenuItem[] =>
|
||||
pending(kind, key)
|
||||
? [
|
||||
{
|
||||
label: "회사에 올리기 요청 중",
|
||||
run: () => {},
|
||||
disabledReason: "회사 관리자가 아직 안 봄",
|
||||
},
|
||||
]
|
||||
: [{ label: "회사에 올리기 요청", run: () => void ask(kind, key, name) }];
|
||||
|
||||
async function approve(row: ShareRow): Promise<void> {
|
||||
const title = `회사에 올리기 승인 — ${row.이름}`;
|
||||
const dest =
|
||||
row.종류 === "item"
|
||||
? await askCopyFolder(fetchTree, { layer: "company", owner: companyOwner, label: title }, 1)
|
||||
: await askFolder(
|
||||
title,
|
||||
(await fetchPrecomboTree("company", companyOwner).catch(() => null))?.폴더 ?? [],
|
||||
() => false,
|
||||
[],
|
||||
"넣을 폴더",
|
||||
);
|
||||
if (!dest) return;
|
||||
try {
|
||||
await post(`/share-requests/${row.id}/approve${q}`, { 경로: dest });
|
||||
showToast(`「${row.이름}」 회사 층에 올림 — ${dest.join(" / ") || "맨 위"}`, "success");
|
||||
opt.changed();
|
||||
} catch (error) {
|
||||
showToast(`승인 못 함 — ${why(error)}`, "error");
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function reject(row: ShareRow): Promise<void> {
|
||||
const got = await askText(
|
||||
`거절 — ${row.이름}`,
|
||||
[{ key: "사유", label: "사유(선택)", optional: true }],
|
||||
"거절",
|
||||
);
|
||||
if (!got) return;
|
||||
try {
|
||||
await post(`/share-requests/${row.id}/reject${q}`, { 사유: got.사유 });
|
||||
} catch (error) {
|
||||
showToast(`거절 못 함 — ${why(error)}`, "error");
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function paintList(host: HTMLElement): void {
|
||||
const line = (r: ShareRow): HTMLElement => {
|
||||
const tools =
|
||||
r.상태 === "요청"
|
||||
? el("span", {
|
||||
className: "ui-toolbar",
|
||||
children: [
|
||||
createButton({
|
||||
label: "미리 보기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => (r.종류 === "item" ? opt.openItem(r) : opt.openPrecombo(r)),
|
||||
}),
|
||||
createButton({ label: "승인", size: "sm", onClick: () => void approve(r) }),
|
||||
createButton({
|
||||
label: "거절",
|
||||
variant: "danger-ghost",
|
||||
size: "sm",
|
||||
onClick: () => void reject(r),
|
||||
}),
|
||||
],
|
||||
})
|
||||
: createBadge(
|
||||
`${r.상태}${r.사유 ? ` · ${r.사유}` : ""}`,
|
||||
r.상태 === "승인" ? "success" : "danger",
|
||||
);
|
||||
const sub = [KIND_NAME[r.종류], r.요청자이름, r.날짜.slice(0, 10).replace(/-/g, "."), r.글];
|
||||
return createListRow({
|
||||
label: r.이름,
|
||||
sub: sub.filter(Boolean).join(" · "),
|
||||
trailing: tools,
|
||||
size: 36,
|
||||
});
|
||||
};
|
||||
// 기다리는 것 먼저 · 그다음 최근 처리
|
||||
const order = [...rows].sort(
|
||||
(a, b) => Number(b.상태 === "요청") - Number(a.상태 === "요청") || (a.날짜 < b.날짜 ? 1 : -1),
|
||||
);
|
||||
host.replaceChildren(
|
||||
...(order.length
|
||||
? order.map(line)
|
||||
: [createEmpty({ text: "올리기 요청 없음", small: true })]),
|
||||
);
|
||||
}
|
||||
|
||||
function openList(): void {
|
||||
listHost = el("div", { className: "ui-sidebar-list", attrs: { "data-share-list": "" } });
|
||||
paintList(listHost);
|
||||
opt.showView("회사에 올리기 요청", listHost);
|
||||
}
|
||||
|
||||
void refresh();
|
||||
return {
|
||||
top,
|
||||
itemMenu: (t) =>
|
||||
asker && opt.layer === "personal" && !opt.owner && t.kind === "item"
|
||||
? menu("item", t.item.key, t.item.경로[t.item.경로.length - 1] ?? t.item.key)
|
||||
: [],
|
||||
precomboMenu: (mine, key, name) => (asker && mine ? menu("precombo", key, name) : []),
|
||||
};
|
||||
}
|
||||
@@ -166,6 +166,10 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
|
||||
if kind == "table" and name == STRUCTURE_TABLE:
|
||||
doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기)
|
||||
doc = _server(kind, doc)
|
||||
if kind == "labor": # 쓰는 규격 열 지움 막음(704-4)
|
||||
from M02_MasterTemplete import M02_Labor_Spec as labor_spec
|
||||
|
||||
labor_spec.guard(path, doc, name)
|
||||
fresh: list[dict[str, Any]] = []
|
||||
with _LOCK:
|
||||
have = version_of(path.read_bytes()) if path.is_file() else ""
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface TreePanelOptions {
|
||||
};
|
||||
/** 「〇〇로 복사…」 — 그 층 폴더를 골라 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로 · 39-6) */
|
||||
copyTo?: CopyTarget[];
|
||||
more?: (target: Target) => TreeMenuItem[]; // 줄 하나 덧메뉴(706-1 회사에 올리기 요청)
|
||||
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
|
||||
canEdit: boolean;
|
||||
/** 항목을 누름 — 표에서 그 열을 고름 */
|
||||
@@ -226,6 +227,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
return [
|
||||
...(opt.canEdit ? menuOf(target) : []),
|
||||
...copyItem([target]),
|
||||
...(opt.more?.(target) ?? []),
|
||||
...(target.kind === "folder" ? pasteItem(target.path) : []),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
STRUCTURE_TABLE,
|
||||
type StructureColumn,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { createShare } from "./M02_MasterTemplete_ShareRequest";
|
||||
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
|
||||
import {
|
||||
companyOwner,
|
||||
@@ -91,10 +92,32 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal";
|
||||
return [{ layer: to, owner: toOwner, label: L(`M02_TreeCopyTo${key}`) }];
|
||||
});
|
||||
// 회사에 올리기 요청 · 승인(706-1) — 요청 = 개인 층 나무 덧메뉴 · 승인 = 회사 층 위 띠 · 결과 띠
|
||||
const share = createShare({
|
||||
user,
|
||||
company,
|
||||
layer,
|
||||
owner,
|
||||
showView: (label, node) =>
|
||||
void main.confirmLeave().then((ok) => ok && main.showView(label, node)),
|
||||
openPrecombo: (r) =>
|
||||
void main.open({
|
||||
layer: "personal",
|
||||
owner: r.주인,
|
||||
canWrite: false,
|
||||
projectId: null,
|
||||
kind: "precombo",
|
||||
name: r.키,
|
||||
label: r.이름,
|
||||
}),
|
||||
openItem: (r) => void go("personal", r.주인, "구조물조합"),
|
||||
changed: () => void Promise.all([side.refresh(), tree.refresh()]),
|
||||
});
|
||||
const tree = createTreePanel({
|
||||
layer,
|
||||
owner,
|
||||
copyTo,
|
||||
more: share.itemMenu,
|
||||
canEdit: canEdit(layer, isAdmin),
|
||||
onPick: (item) => void main.openTable(layer, item.열),
|
||||
settle: () => main.settle(),
|
||||
@@ -138,7 +161,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
() => void Promise.all([side.refresh(), tree.refresh()]),
|
||||
() => side.paint(),
|
||||
{
|
||||
top: readOnlyBar?.root,
|
||||
top: el("div", { children: [...(readOnlyBar ? [readOnlyBar.root] : []), share.top] }),
|
||||
// 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5)
|
||||
...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {}),
|
||||
onColumn: (column) => {
|
||||
@@ -222,7 +245,11 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
|
||||
peopleTree("구조물조합", tree.root),
|
||||
],
|
||||
precombo: { scope: () => pre, bar: preBar },
|
||||
precombo: {
|
||||
scope: () => pre,
|
||||
bar: preBar,
|
||||
more: (key, name) => share.precomboMenu(pre.root === "personal" && !pre.owner, key, name),
|
||||
},
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
|
||||
@@ -69,7 +69,7 @@ export interface SideOptions {
|
||||
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
|
||||
tree?: HTMLElement[];
|
||||
/** 「일위대가 사전조합」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */
|
||||
precombo: Pick<PrecomboOptions, "scope" | "bar">;
|
||||
precombo: Pick<PrecomboOptions, "scope" | "bar" | "more">;
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
|
||||
@@ -86,6 +86,8 @@ export interface PrecomboOptions {
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
getOpen: () => Selection | null;
|
||||
/** 문서 줄 덧메뉴(706-1 회사에 올리기 요청) — 없거나 빈 목록이면 메뉴 없음 */
|
||||
more?: (key: string, name: string) => TreeMenuItem[];
|
||||
}
|
||||
|
||||
export interface PrecomboHandle {
|
||||
@@ -237,7 +239,8 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
|
||||
const label = row.제목 || row.이름;
|
||||
const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label };
|
||||
targets.set(id, { kind: "item", item });
|
||||
return { id, label, tip: row.이름, count: null, run: () => void open(row) };
|
||||
const menu = () => opt.more?.(row.이름, label) ?? [];
|
||||
return { id, label, tip: row.이름, count: null, run: () => void open(row), menu };
|
||||
};
|
||||
const folderNode = (node: PrecomboFolder): TreeNode => {
|
||||
const id = folderIdOf(node.path);
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""회사에 올리기 요청 · 승인(PLAN 706-1 · 요구서 4장 6) — 일반 사용자가 개인 층 항목 ·
|
||||
사전조합을 회사 층에 올려 달라고 요청 · 회사 관리자가 승인(= 「회사로 복사…」와 같은 길 ·
|
||||
원본은 그대로) · 거절.
|
||||
|
||||
장부 = 회사 층 폴더 `올리기요청.json` 하나(DB 안 씀) · `{줄: [요청 …]}`.
|
||||
요청 = `{id, 종류(item · precombo), 키, 이름, 경로(항목 폴더), 주인 C/U, 요청자, 요청자이름,
|
||||
글, 날짜, 상태(요청 · 승인 · 거절), 처리자, 처리날짜, 사유, 사본, 봄}` · `봄` = 요청자가 결과
|
||||
알림을 닫음. 권한 = `M02_Layer_Access` 한 벌 — 요청 = 자기 개인 층 · 목록 = 회사 관리자는
|
||||
그 회사 전부 · 일반은 자기 것 ·
|
||||
승인 · 거절 = 회사 층 쓰기(일반 403) · 다른 회사 장부는 안 봄(시스템 관리자만 `company`).
|
||||
같은 항목(주인 · 종류 · 키)이 「요청」 상태면 다시 요청 = 409 「요청 중」.
|
||||
⚠ 등록은 `main.py`(로그인만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_Item_Paste as paste_store
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Precombo_Folders as pre_folders
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import (
|
||||
NotedRoute,
|
||||
Session,
|
||||
_call,
|
||||
_root,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 Share Request"], route_class=NotedRoute)
|
||||
|
||||
FILE = "올리기요청.json"
|
||||
# ponytail: 한 프로세스 잠금 — 서버를 여럿 띄우면 파일 잠금으로
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class AskBody(BaseModel):
|
||||
종류: Literal["item", "precombo"]
|
||||
키: str
|
||||
글: str = ""
|
||||
|
||||
|
||||
class ApproveBody(BaseModel):
|
||||
경로: list[str] = []
|
||||
|
||||
|
||||
class RejectBody(BaseModel):
|
||||
사유: str = ""
|
||||
|
||||
|
||||
def _company(session: Session, company: str | None) -> str:
|
||||
"""장부 회사 — 시스템 관리자만 `company` 로 남의 회사 · 그 밖은 자기 회사(없으면 409)."""
|
||||
if company and access.is_system_admin(session):
|
||||
return str(company)
|
||||
own = session.get("company_id")
|
||||
if own is None:
|
||||
raise HTTPException(status_code=409, detail=access.NO_COMPANY)
|
||||
return str(own)
|
||||
|
||||
|
||||
def _path(company: str) -> Path:
|
||||
return access.folder(access.Grant("company", company, False, True)) / FILE
|
||||
|
||||
|
||||
def _read(company: str) -> list[dict[str, Any]]:
|
||||
path = _path(company)
|
||||
if not path.is_file():
|
||||
return []
|
||||
return list(json.loads(path.read_bytes()).get("줄") or [])
|
||||
|
||||
|
||||
def _write(company: str, rows: list[dict[str, Any]]) -> None:
|
||||
atomic_write_json(_path(company), {"줄": rows})
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _source(session: Session, kind: str, key: str) -> dict[str, Any]:
|
||||
"""자기 개인 층 원본 — `{이름, 경로}`(없으면 404)."""
|
||||
folder = _root(session, "personal")
|
||||
if kind == "item":
|
||||
found = next((r for r in items_store.items(folder) if r.get("key") == key), None)
|
||||
if found is None:
|
||||
raise HTTPException(status_code=404, detail=f"없는 항목 「{key}」")
|
||||
path = list(found.get("경로") or [])
|
||||
return {"이름": path[-1] if path else key, "경로": path[:-1]}
|
||||
owner = access.own_owner(session, "personal")
|
||||
doc = (
|
||||
refs.read_ref({"층": "personal", "주인": owner, "키": key})
|
||||
if refs._KEY.match(key)
|
||||
else None
|
||||
)
|
||||
if doc is None or refs.kind_of(key) != "precombo":
|
||||
raise HTTPException(status_code=404, detail=f"없는 사전조합 「{key}」")
|
||||
return {"이름": str(doc.get("이름") or key), "경로": []}
|
||||
|
||||
|
||||
@router.post("/share-requests")
|
||||
def ask(body: AskBody, session: Session = Depends(verify_session)) -> dict[str, Any]:
|
||||
"""요청 — 자기 개인 층 것만 · 같은 것이 「요청」 이면 409 「요청 중」."""
|
||||
owner = access.own_owner(session, "personal")
|
||||
if owner is None:
|
||||
raise HTTPException(status_code=409, detail=access.NO_COMPANY)
|
||||
src = _source(session, body.종류, body.키)
|
||||
company = owner.split("/")[0]
|
||||
row = {
|
||||
"id": uuid.uuid4().hex[:12],
|
||||
"종류": body.종류,
|
||||
"키": body.키,
|
||||
**src,
|
||||
"주인": owner,
|
||||
"요청자": session.get("user_id"),
|
||||
"요청자이름": session.get("name") or "",
|
||||
"글": body.글.strip()[:200],
|
||||
"날짜": _now(),
|
||||
"상태": "요청",
|
||||
}
|
||||
with _LOCK:
|
||||
rows = _read(company)
|
||||
same = (owner, body.종류, body.키)
|
||||
if any((r["주인"], r["종류"], r["키"]) == same and r["상태"] == "요청" for r in rows):
|
||||
raise HTTPException(status_code=409, detail="요청 중 — 회사 관리자가 아직 안 봄")
|
||||
_write(company, [*rows, row])
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/share-requests")
|
||||
def listing(
|
||||
company: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
"""목록 — 회사를 맡은 사람 = 그 회사 전부 · 그 밖 = 자기 요청만 · `관리` = 승인할 수 있나."""
|
||||
at = _company(session, company)
|
||||
rows = _read(at)
|
||||
boss = access.manages(session, at)
|
||||
if not boss:
|
||||
me = session.get("user_id")
|
||||
rows = [r for r in rows if r.get("요청자") == me]
|
||||
return {"회사": at, "관리": boss, "줄": rows}
|
||||
|
||||
|
||||
def _settle(session: Session, company: str | None, rid: str, done: dict[str, Any]) -> dict:
|
||||
"""요청 한 줄을 끝냄 — 회사 층 쓰기 권한(일반 403) · 「요청」 상태만(아니면 409)."""
|
||||
at = _company(session, company)
|
||||
access.require(session, "company", at, write=True)
|
||||
with _LOCK:
|
||||
rows = _read(at)
|
||||
row = next((r for r in rows if r.get("id") == rid), None)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="없는 요청")
|
||||
if row["상태"] != "요청":
|
||||
raise HTTPException(status_code=409, detail=f"이미 {row['상태']}")
|
||||
extra = done.pop("run", None)
|
||||
if extra:
|
||||
done["사본"] = extra(row, at)
|
||||
row.update({**done, "처리자": session.get("name") or "", "처리날짜": _now(), "봄": False})
|
||||
_write(at, rows)
|
||||
return row
|
||||
|
||||
|
||||
def _copy(session: Session, dest: list[str]):
|
||||
"""승인 = 「회사로 복사…」와 같은 길 — 구조물 항목 = 붙여넣기 ·
|
||||
사전조합 = 서고 복사(+ 폴더 자리)."""
|
||||
|
||||
def run(row: dict[str, Any], company: str) -> str:
|
||||
target = _root(session, "company", company, write=True)
|
||||
source = _root(session, "personal", row["주인"])
|
||||
if row["종류"] == "item":
|
||||
ref = [{"경로": row.get("경로") or [], "키": row["키"]}]
|
||||
origin = {"layer": "personal", "owner": row["주인"]}
|
||||
try:
|
||||
note = paste_store.paste(source, target, ref, dest, origin)
|
||||
except store.StoreError as e:
|
||||
raise HTTPException(status_code=e.status, detail=e.detail) from e
|
||||
return str(note["열"][0])
|
||||
ref = {"층": "personal", "주인": row["주인"], "키": row["키"]}
|
||||
doc = refs.read_ref(ref)
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=404, detail=f"원본이 없음 「{row['키']}」")
|
||||
|
||||
def make() -> dict[
|
||||
str, Any
|
||||
]: # 못 읽는 참조 같이 복사 = 받는 층 안에서(`new_library` 와 같음)
|
||||
return refs.create("precombo", refs.carry(refs.origin_mark(doc, ref), here))
|
||||
|
||||
here = ("personal", row["주인"])
|
||||
made = _call(target, make)
|
||||
if dest:
|
||||
view = _call(target, pre_folders.view)
|
||||
move = {"op": "move", "키": made["이름"], "새경로": dest, "판": view["판"]}
|
||||
_call(target, pre_folders.edit, move)
|
||||
return str(made["이름"])
|
||||
|
||||
return run
|
||||
|
||||
|
||||
@router.post("/share-requests/{rid}/approve")
|
||||
def approve(
|
||||
rid: str,
|
||||
body: ApproveBody,
|
||||
company: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
"""승인 — 회사 층 `경로` 폴더에 사본(새 키 · 같은 이름 「(1)」) · 원본 개인 것은 그대로."""
|
||||
return _settle(session, company, rid, {"상태": "승인", "run": _copy(session, body.경로)})
|
||||
|
||||
|
||||
@router.post("/share-requests/{rid}/reject")
|
||||
def reject(
|
||||
rid: str,
|
||||
body: RejectBody,
|
||||
company: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
return _settle(session, company, rid, {"상태": "거절", "사유": body.사유.strip()[:200]})
|
||||
|
||||
|
||||
@router.post("/share-requests/seen")
|
||||
def seen(session: Session = Depends(verify_session)) -> dict[str, int]:
|
||||
"""요청자가 결과 알림 줄을 닫음 — 자기 요청 가운데 끝난 것 `봄` = true."""
|
||||
at = _company(session, None)
|
||||
me = session.get("user_id")
|
||||
with _LOCK:
|
||||
rows = _read(at)
|
||||
hit = [r for r in rows if r.get("요청자") == me and r.get("봄") is False]
|
||||
for r in hit:
|
||||
r["봄"] = True
|
||||
if hit:
|
||||
_write(at, rows)
|
||||
return {"봄": len(hit)}
|
||||
Reference in New Issue
Block a user