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

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