diff --git a/M02_MasterTemplete/M02_Item_Personal.py b/M02_MasterTemplete/M02_Item_Personal.py index 906dec16a..bf6c9cbd4 100644 --- a/M02_MasterTemplete/M02_Item_Personal.py +++ b/M02_MasterTemplete/M02_Item_Personal.py @@ -18,8 +18,9 @@ from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_MasterTemplete_Items as items_store from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_MasterTemplete_Tree as tree +from M02_MasterTemplete import M02_Material_Own as own from M02_MasterTemplete import M02_Template_Layers as layers -from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, read_json +from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json #: 복사본 파일 → 층 양식 종류 PART_KINDS = (("도면.json", "structure"), ("면적.json", "area")) @@ -94,6 +95,8 @@ def create_item( # ponytail: 참조(단가승격 · 사전조합)는 키 그대로 — 개인 층이 못 읽는 참조는 계산 때 # 까닭으로 멈춤(참조사본까지 옮기려면 `Labor_Ref.carry` 길) if isinstance(labor, dict) and labor.get("종류") == "일위대가": + # 개인 자재(148-3) — 사본에 굳힌 줄을 내 목록에 같은 키로(남의 개인 자재여도 `clean` 통과) + own.adopt(((read_json(copy / PRICE_FILE) or {}).get(own.BUNDLE) or {}).get("줄") or []) store.copy_from("labor", new_id, new_id, body=labor) ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id) return {"key": ss["키"], "열": new_id, "이름": tree.name_of(heads)} diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 5f219ca70..2688b5aa0 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -18,6 +18,7 @@ import json from pathlib import Path from typing import Any +from M02_MasterTemplete import M02_Material_Own as own from M02_MasterTemplete import M02_Template_Layers as layers TABLE_NAME = "구조물집계표" @@ -157,4 +158,8 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: ] wanted = logic_keys + [code for code in materials if code] files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} + if mine := own.bundle( + own.codes(doc), root + ): # 개인 자재(148-3) — 원본 목록이 바뀌어도 사본 그대로 + files[PRICE_FILE][own.BUNDLE] = mine return files diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index faa87a732..2e7694970 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -1,8 +1,10 @@ """M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장). 문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, -담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?, 승격?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}], -자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠). +담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?, 승격?, 자재표시?} | +{참조: {층, 주인, 키}, 별칭, 메모, 수량}], +자재: [{코드, 이름, 규격, 단위, 수량, 자재구분?, 별도계상?, 운반비?}], 계산값}` — +이름은 구조물 열 이름(따로 안 둠). 수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` (손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값). 금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 @@ -18,6 +20,8 @@ 고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와 같은 품목 두 번은 [저장] 때 400. 승격(134-1) = 로직 줄 「단가승격」 표시(`true` 만 · 없음 = 단가산출) · 금액 무관 · B09 호표 몫. +자재 세 칸(148-4) = `MARKS` 허용 값만(그 밖 · 빈 것 버림 · 금액 무관) — 사용자 자재 = 줄 덧칸 · +로직 자재 = 로직 줄 `자재표시 {호표 차례: {세 칸}}`(차례가 재료 줄인 것만 · 틀리면 버림). % 덧줄(149-4) = 식 「X * Y / 100」 덧줄에 덧칸 `율`(Y) · `규격`(「노무비의 %」 등 · `pct_extras`) — 표기만 · 단위 · 수량 · 금액 그대로(「식 · 1」) · 화면이 율 · % 로 그림 · M01 시험계산 표도 이 함수. """ @@ -34,12 +38,18 @@ from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Material_Own as own mf = m01.cm.mf mm = mf.mp.mm COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비 MATERIAL_HEAD = "자재|" _MAT_COLS = ("코드", "이름", "규격", "단위") +MARKS = { + "자재구분": ("일반", "관급", "사급"), + "별도계상": ("별도계상", "직접계산"), + "운반비": ("포함", "미포함"), +} ALIAS_MAX = 40 # 별칭(44-6) 글자 수 상한 — 화면 칸과 같음 _BRACE = re.compile(r"\{([^{}]+)\}") # 품목 조건의 {입력} @@ -128,6 +138,9 @@ def clean(doc: Any) -> dict[str, Any]: **({"켜쌓기율": rate} if rate is not None else {}), **({"고름": picks} if picks else {}), **({"승격": True} if one.get("승격") is True else {}), + **( + {"자재표시": shown} if (shown := _mat_marks(one.get("자재표시"), logic)) else {} + ), } ) mats, codes = [], set() @@ -135,9 +148,11 @@ def clean(doc: Any) -> dict[str, Any]: where = f"자재 {at}째" code = str(one.get("코드") or "") if isinstance(one, dict) else "" try: - row = whole.get(code) if code[:2] == mm.MARKET else None + row = whole.get(code) if code[:2] == mm.MARKET else own.find(code) except mf.FormulaError: row = None + if row is None and own.is_own(code): + raise _bad(f"{where} · 개인 자재 목록에 없는 코드(개인 층에서만) 「{code}」") if row is None: raise _bad(f"{where} · M01 자재품목에 없는 코드 「{code}」") if code in codes: @@ -148,6 +163,7 @@ def clean(doc: Any) -> dict[str, Any]: "코드": code, **{k: row.get(k) or "" for k in _MAT_COLS[1:]}, "수량": _qty(one.get("수량"), where), + **marks(one), } ) out = { @@ -195,6 +211,24 @@ def _picks(value: Any, logic: dict, whole: Any, where: str) -> dict[str, str] | return out +def marks(one: Any) -> dict[str, str]: + """자재 세 칸(148-4) — 허용 값만.""" + got = one if isinstance(one, dict) else {} + return {k: got[k] for k, allowed in MARKS.items() if got.get(k) in allowed} + + +def _mat_marks(value: Any, logic: dict) -> dict[str, dict[str, str]] | None: + """로직 자재 세 칸(148-4) `{호표 차례: {세 칸}}` — 맨 위 층 재료 줄 차례 · 허용 값만 · + 빈 것 = None.""" + rows = logic.get("호표") or [] + out = {} + for at, got in (value if isinstance(value, dict) else {}).items(): + n = int(at) if str(at).isdigit() else -1 + if 0 <= n < len(rows) and rows[n].get("종류") == "재료" and (kept := marks(got)): + out[str(n)] = kept + return out or None + + def _rate(value: Any, code: str, where: str) -> int | float | None: """켜쌓기율(85-2) — 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖 로직은 None(안 둠).""" if value is None or value == "": @@ -366,7 +400,20 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: def material_price(master: Any, code: str, unit: str | None) -> dict[str, Any]: """자재 한 줄 기준 — `{기준단가, 가격: {값 열 다섯}, 기준칸, 까닭}` · 기준단가 = `mm.element` - (값 열 가운데 낮은 값) · 기준칸 = 그 값이 나온 열 이름(출처 `키.열`) · 원본 자료는 읽기만.""" + (값 열 가운데 낮은 값) · 기준칸 = 그 값이 나온 열 이름(출처 `키.열`) · 원본 자료는 읽기만. + 개인 자재(148-3 · `PM`) = 줄 「값」 하나(사본 `단가.json` 안 · 없으면 지금 층 목록 · 값 열 없음).""" + if own.is_own(code): + try: + row = master.get(code) + except mf.FormulaError: + row = own.find(code) or {} + price = row.get("값") + return { + "기준단가": price, + "가격": {slot: None for slot in mm.SLOTS}, + "기준칸": None, + "까닭": None if price is not None else "단가 없음", + } row = master.get(code) or {} prices = {slot: row.get(slot) for slot in mm.SLOTS} try: diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index a11395af4..ae135a0c4 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -2,7 +2,8 @@ 문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 · 여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 · -지우기는 양식 길 그대로). 층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌). +지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버). +층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌). ⚠ 등록은 `main.py`(로그인만). """ @@ -18,6 +19,7 @@ from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Material_Own as own from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Labor"]) @@ -101,3 +103,30 @@ def new_library( if doc is None: raise HTTPException(status_code=404, detail=f"없는 {refs.TITLE[kind]} 「{ref['키']}」") return _call(folder, _copy, kind, doc, (got.layer, got.owner), ref) + + +@router.get("/layers/{layer}/materials") +def own_materials( + layer: Layer, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """개인 자재 목록(148-2) — 개인 층만 · 회사 · 마스터 = 빈 목록.""" + if layer != "personal": + return {"줄": []} + return {"줄": own.rows(_root(session, layer, owner))} + + +@router.post("/layers/{layer}/materials") +def new_material( + layer: Layer, + body: dict[str, Any], + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """신규 자재(148-2) — `{이름, 규격, 단위, 값}` → 새 줄(품번 `PM` + 6자리 서버 일련) · + 개인 층만.""" + if layer != "personal": + raise HTTPException(status_code=400, detail="신규 자재는 개인 층에서만") + folder = _root(session, layer, owner, write=True) + return _call(folder, own.add, folder, body) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 76497fda7..d319abb5a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -205,15 +205,22 @@ } /* 구분 드롭다운(134-1) — 칸 너비만 · 품셈 책 글은 아래 */ -.m02-labrow__kind .ui-field { +.m02-labrow__kind .ui-field, +.m02-labrow__mark .ui-field { display: block; } -.m02-labrow__kind .ui-select { +.m02-labrow__kind .ui-select, +.m02-labrow__mark .ui-select { width: auto; min-width: 0; } +/* 자재 세 칸(148-4) — 글 한 줄 */ +.m02-labrow__mark { + white-space: nowrap; +} + /* 줄 접고 폄 표(51-2) · 고른 줄 하위 행 — 이름 들여쓰기 · 흐린 바탕 · 위 줄보다 촘촘(51-7) */ .m02-labrow .m02-labrow__sub td { padding: var(--spacing-4) var(--spacing-8); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 6f1fa2a76..341987933 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -135,8 +135,35 @@ export interface LaborRow { 고름?: Record; /** 단가승격(134-1 · 로직 줄 구분 드롭다운) — 없음 = 단가산출 · 금액 무관 · B09 호표 몫 */ 승격?: boolean; + /** 로직 자재 세 칸(148-4) — {호표 차례: 세 칸} · 금액 무관 */ + 자재표시?: Record; } -export interface LaborMat { +/** 자재 세 칸(148-4 · 서버 `M02_Labor_Doc.MARKS` 와 같음) — 처음 값 없음(「—」) · 금액 무관 */ +export const MAT_MARKS = { + 자재구분: ["일반", "관급", "사급"], + 별도계상: ["별도계상", "직접계산"], + 운반비: ["포함", "미포함"], +} as const; +export type MatMarkKey = keyof typeof MAT_MARKS; +export type MatMarks = { [K in MatMarkKey]?: (typeof MAT_MARKS)[K][number] }; +/** 허용 값만 남김(서버 `marks` 와 같은 규칙) */ +export function cleanMarks(v: unknown): MatMarks { + const got = (v && typeof v === "object" ? v : {}) as Record; + const out: Record = {}; + for (const [k, allowed] of Object.entries(MAT_MARKS)) + if ((allowed as readonly unknown[]).includes(got[k])) out[k] = got[k]; + return out as MatMarks; +} +/** 로직 줄 `자재표시` 정리 — 빈 차례는 뺌 · 다 비면 칸 없음 */ +function cleanShown(v: unknown): { 자재표시?: Record } { + const out: Record = {}; + for (const [at, one] of Object.entries(v && typeof v === "object" ? v : {})) { + const kept = cleanMarks(one); + if (Object.keys(kept).length) out[at] = kept; + } + return Object.keys(out).length ? { 자재표시: out } : {}; +} +export interface LaborMat extends MatMarks { 코드: string; 이름: string | null; 규격: string | null; @@ -173,6 +200,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { ...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}), ...(r.고름 && Object.keys(r.고름).length ? { 고름: { ...r.고름 } } : {}), ...(r.승격 === true && !r.참조 ? { 승격: true } : {}), + ...(r.참조 ? {} : cleanShown(r.자재표시)), })), 자재: (d.자재 ?? []).map((m) => ({ 코드: m.코드, @@ -180,6 +208,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { 규격: m.규격 ?? null, 단위: m.단위 ?? null, 수량: m.수량 ?? null, + ...cleanMarks(m), })), }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 9c6a02cb0..a37a05411 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -48,6 +48,7 @@ import { type LaborDoc, type LaborQty, type LaborRow, + type MatMarkKey, } from "./M02_MasterTemplete_Labor_Api"; import { buildLaborDiagram, @@ -60,11 +61,12 @@ import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit"; import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats"; +import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks"; import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model"; import { keepKnown } from "./M02_Labor_Values_Model"; -import { pickMaterial } from "./M02_Material_Pick"; +import { addMaterialButton } from "./M02_Material_Pick"; import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; import { fetchStructureDef } from "./StructOptions/M02_SO_Api"; import { precomboClasses } from "./M02_MasterTemplete_UI_Side_Precombo"; @@ -226,10 +228,18 @@ export function mountLaborDetail( cur.담은로직, calc ?? last, !calc && !fail, - edit ? { swap: swapMat, reset: resetMat } : undefined, + // 세 칸(148-4) = 구조물 일위대가만(사전조합 줄은 `_combo_lines` 가 안 남김) + edit + ? { + open: openRow, + swap: swapMat, + reset: resetMat, + mark: lib ? undefined : markLogicMat, + } + : { open: openRow }, ), }, - edit ? { open: openMat, remove: removeMat } : { open: openMat }, + edit ? { open: openMat, remove: removeMat, mark: markMat } : { open: openMat }, ), ); drawDg(); @@ -373,6 +383,15 @@ export function mountLaborDetail( else delete cur.담은로직[i].승격; opt.onChanged(dirty()); } + // 자재 세 칸(148-4) — 화면 캐시만 · 금액 무관이라 다시 셈 없음 + function markMat(i: number, key: MatMarkKey, v: string): void { + setMark(cur.자재[i], key, v); + opt.onChanged(dirty()); + } + function markLogicMat(i: number, at: number, key: MatMarkKey, v: string): void { + setLogicMark(cur.담은로직[i], at, key, v); + opt.onChanged(dirty()); + } function removeMat(i: number): void { cur.자재.splice(i, 1); changed(); @@ -489,25 +508,18 @@ export function mountLaborDetail( library, ), }); - const addMat = createButton({ - label: tc("Labor_AddMat"), - variant: "filled", - onClick: () => - pickMaterial((m) => { - if (insideKeys((calc ?? last)?.줄).has(m.코드)) - return showToast( - tc("Labor_InLogicMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), - "error", - ); - if (cur.자재.some((x) => x.코드 === m.코드)) - return showToast( - tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), - "error", - ); - cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null }); - changed(); - }), - }); + const addMat = addMaterialButton( + tc("Labor_AddMat"), + (m) => { + const why = insideKeys((calc ?? last)?.줄).has(m.코드) + ? "Labor_InLogicMat" + : cur.자재.some((x) => x.코드 === m.코드) && "Labor_AlreadyMat"; + if (why) return showToast(tc(why).replace("{value}", `${m.코드} ${m.이름 ?? ""}`), "error"); + cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null }); + changed(); + }, + opt.layer === "personal" ? { owner: library.owner } : undefined, // 개인 층만 신규 자재(148-3) + ); // 단위 = 표준 단위 목록에서 고르기(지금 값이 목록에 없어도 선택지로 둠) · 저장은 [저장] const unit = { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_LogicMats.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_LogicMats.ts index 90cea47f5..2fc6db140 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_LogicMats.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_LogicMats.ts @@ -5,23 +5,29 @@ * · 삭제 없음 · [바꾸기] = M01 자재품목 표에서 고름(문서 `담은로직[].고름` · 엔진 `choosing`) · 바꾼 줄만 [기본](고름 풂) * · 금액은 로직 단가 안이라 합에 다시 안 더함(보기만) · 수량은 로직 것(누름 · 알약 없음) * · 하위 로직 안 재료는 안 보임(엔진이 맨 위 층만 고름) + * · 줄 누름(148-1) = 그 로직 줄 [옵션] 모달(읽기만이면 보기) · [바꾸기] · [기본]은 고칠 때만 * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import { openPickTable } from "../M01_MasterData/M01_MasterData_UI_Logic_PickTable"; import { headWord } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Modal"; -import type { LaborCalc, LaborRow } from "./M02_MasterTemplete_Labor_Api"; +import type { LaborCalc, LaborRow, MatMarkKey } from "./M02_MasterTemplete_Labor_Api"; import { lineId } from "./M02_MasterTemplete_Labor_Calc"; +import { markCells } from "./M02_MasterTemplete_Labor_MatMarks"; import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import type { LogicMat } from "./M02_Labor_LogicMats_Model"; export interface LogicMatHandlers { - /** [바꾸기]로 고른 품목 키 — `row` = 담은 로직 차례 · `at` = 호표 차례 */ - swap: (row: number, at: number, key: string) => void; + /** 줄 누름(148-1) = 그 자재를 낸 로직 줄 [옵션] 모달 — `row` = 담은 로직 차례 */ + open: (row: number) => void; + /** [바꾸기]로 고른 품목 키 — `row` = 담은 로직 차례 · `at` = 호표 차례 · 없으면(읽기만) 단추 칸 없음 */ + swap?: (row: number, at: number, key: string) => void; /** [기본] — 그 줄 고름 풂 */ - reset: (row: number, at: number) => void; + reset?: (row: number, at: number) => void; + /** 세 칸(148-4) — 로직 줄 `자재표시` · 없으면 글만 */ + mark?: (row: number, at: number, key: MatMarkKey, v: string) => void; } const num = (v: number | null): string => @@ -63,7 +69,9 @@ function matRow( }; const money = moneyCell(mat.단가, mat.금액, wait, tc("Labor_AppliedSum")); money.title = tc("LogicMat_Inside"); - const tools = on + const swap = on?.swap; + const reset = on?.reset; + const tools = swap ? [ el("td", { className: "m02-labrow__move", @@ -82,18 +90,18 @@ function matRow( seed: headWord(name), alt: [headWord(mat.이름)], isCurrent: (x) => String(x["키"]) === item?.키, - onPick: (x) => on.swap(r, mat.차례, String(x["키"])), + onPick: (x) => swap(r, mat.차례, String(x["키"])), }); }, }), - ...(mat.고름 + ...(mat.고름 && reset ? [ createButton({ label: tc("LogicMat_Reset"), variant: "ghost", onClick: (e) => { e.stopPropagation(); - on.reset(r, mat.차례); + reset(r, mat.차례); }, }), ] @@ -104,8 +112,8 @@ function matRow( }), ] : []; - return el("tr", { - className: `m02-labrow__row m02-labrow__logic-mat${mat.고름 ? " is-picked-mat" : ""}`, + const tr = el("tr", { + className: `m02-labrow__row m02-labrow__logic-mat${mat.고름 ? " is-picked-mat" : ""}${on ? " is-open" : ""}`, attrs: { "data-labor-logic-mat": `${row.로직}|${mat.차례}` }, children: [ el("td", { className: "m02-labrow__code", text: item?.키 ?? "—" }), @@ -136,9 +144,15 @@ function matRow( text: mat.단위, attrs: { "data-label": tc("Labor_Unit") }, }), + ...markCells( + row.자재표시?.[String(mat.차례)] ?? {}, + on?.mark ? (k, v) => on.mark?.(r, mat.차례, k, v) : undefined, + ), ...PRICE_SLOTS.map(priceCell), money, ...tools, ], }); + if (on) tr.addEventListener("click", () => on.open(r)); + return tr; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_MatMarks.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_MatMarks.ts new file mode 100644 index 000000000..c9dcf8a0a --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_MatMarks.ts @@ -0,0 +1,78 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_MatMarks.ts + * 자재목록 세 칸(148-4) — 자재구분(일반 · 관급 · 사급) · 별도계상(별도계상 · 직접계산) · 운반비(포함 · 미포함) + * · 고칠 수 있으면 드롭다운(처음 값 빈 「—」 · 줄 누름과 따로) · 아니면 글 · 금액 무관 + * · 사용자 자재 = 줄 덧칸 · 로직 자재 = 로직 줄 `자재표시 {호표 차례: 세 칸}`(`Labor_Api` 정리 · 서버 `clean`) + * ========================================================================== */ + +import { createSelectField, el } from "@ui/ui_template_elements"; +import { + MAT_MARKS, + type LaborRow, + type MatMarkKey, + type MatMarks, +} from "./M02_MasterTemplete_Labor_Api"; +import { tc, type ComboTextKey } from "./M02_MasterTemplete_Labor_Text"; + +const HEAD: Record = { + 자재구분: "Mark_Kind", + 별도계상: "Mark_Separate", + 운반비: "Mark_Haul", +}; +/** 선택지 글(저장 값은 한국어 그대로) */ +const WORD: Record = { + 일반: "Mark_General", + 관급: "Mark_Gov", + 사급: "Mark_Private", + 별도계상: "Mark_Separate", + 직접계산: "Mark_Direct", + 포함: "Mark_In", + 미포함: "Mark_Out", +}; +const KEYS = Object.keys(MAT_MARKS) as MatMarkKey[]; + +export const markHeads = (): string[] => KEYS.map((k) => tc(HEAD[k])); + +/** 세 칸 — `set` 없으면 글만(빈 = 「—」) */ +export function markCells( + value: MatMarks, + set?: (key: MatMarkKey, v: string) => void, +): HTMLElement[] { + return KEYS.map((key) => { + const now = value[key] ?? ""; + const attrs = { "data-label": tc(HEAD[key]), "data-mat-mark": key }; + if (!set) + return el("td", { + className: "m02-labrow__mark", + text: now ? tc(WORD[now]) : "—", + attrs, + }); + const pick = createSelectField({ + options: [ + { value: "", text: "—" }, + ...MAT_MARKS[key].map((v) => ({ value: v, text: tc(WORD[v]) })), + ], + value: now, + compact: true, + ariaLabel: tc(HEAD[key]), + onChange: (v) => set(key, v), + }); + pick.root.addEventListener("click", (e) => e.stopPropagation()); // 줄 누름과 따로 + return el("td", { className: "m02-labrow__mark", attrs, children: [pick.root] }); + }); +} + +/** 한 칸 바꿈 — 빈 값 = 칸 지움 */ +export function setMark(target: MatMarks, key: MatMarkKey, v: string): void { + if (v) (target as Record)[key] = v; + else delete target[key]; +} + +/** 로직 줄 `자재표시` 한 차례 한 칸 — 빈 차례 · 빈 묶음은 뺌 */ +export function setLogicMark(row: LaborRow, at: number, key: MatMarkKey, v: string): void { + const all = (row.자재표시 ??= {}); + const one = (all[String(at)] ??= {}); + setMark(one, key, v); + if (!Object.keys(one).length) delete all[String(at)]; + if (!Object.keys(all).length) delete row.자재표시; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 0ef5796f6..0380f2469 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -23,8 +23,10 @@ import type { LaborCost, LaborMat, LaborRow, + MatMarkKey, } from "./M02_MasterTemplete_Labor_Api"; import { lineId } from "./M02_MasterTemplete_Labor_Calc"; +import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks"; import { attachRowDrag } from "./M02_MasterTemplete_Labor_RowDrag"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import "@ui/ui_template_general_layout.css"; @@ -214,6 +216,8 @@ export interface RowHandlers { alias?: (i: number, value: string | null) => void; /** 구분 드롭다운(134-1 · 로직 줄 단가산출 ↔ 단가승격) — 없으면 글만 */ promote?: (i: number, on: boolean) => void; + /** 자재 세 칸(148-4) — 없으면 글만 */ + mark?: (i: number, key: MatMarkKey, v: string) => void; } /** 별칭 칸(44-6) — 고칠 수 있으면 칸 안 글상자(누르면 바로 고침 · 줄 누름과 따로) · 아니면 글 · 비면 빈 칸 */ @@ -594,6 +598,7 @@ export function matTable(data: MatTableData, on?: Omit): HT text: mat.단위 ?? "", attrs: { "data-label": tc("Labor_Unit") }, }), + ...markCells(mat, on?.mark ? (k, v) => on.mark?.(i, k, v) : undefined), ...PRICE_SLOTS.map(priceCell), moneyCell(line?.기준단가, line?.반영, wait, tc("Labor_AppliedSum")), ...(remove @@ -624,6 +629,7 @@ export function matTable(data: MatTableData, on?: Omit): HT tc("Labor_Spec"), tc("Labor_QtyCol"), tc("Labor_Unit"), + ...markHeads(), ...PRICE_SLOTS, `${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`, ...(remove ? [tc("Col_Move")] : []), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index a808074aa..edfb56371 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -114,6 +114,16 @@ const TEXT = { "Inside the logic price — not added again", ], LogicMat_Picked: ["바꾼 품목", "Changed item"], + // 자재목록 세 칸(148-4) + Mark_Kind: ["자재구분", "Material type"], + Mark_Separate: ["별도계상", "Separate"], + Mark_Haul: ["운반비", "Haulage"], + Mark_General: ["일반", "General"], + Mark_Gov: ["관급", "Gov-supplied"], + Mark_Private: ["사급", "Contractor-supplied"], + Mark_Direct: ["직접계산", "Direct"], + Mark_In: ["포함", "Included"], + Mark_Out: ["미포함", "Excluded"], In_Title: ["넣을 값 — {value}", "Inputs — {value}"], In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"], In_Memo: ["메모", "Memo"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 5aa3aaf2e..b98e59a58 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -569,9 +569,11 @@ def _server(kind: str, doc: Any, source: Path | None = None) -> Any: if kind in ("labor", *layers.LIBRARY_KINDS): from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs + from M02_MasterTemplete import M02_Material_Own as own if source is not None and isinstance(doc, dict): doc = refs.carry(doc, refs.where(source)) + own.carry(doc, source) # 개인 자재(148-3) — 받는 개인 층 목록에 같은 키로 if kind == "labor": return labor.server(doc) return refs.clean_doc(kind, doc) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index de09c0339..9d429ee3c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -266,6 +266,22 @@ font-variant-numeric: tabular-nums; } +/* 개인 자재 표(148-3) · 신규 자재 입력 */ +.m02-pick__tag { + flex: none; + padding: 0 var(--spacing-4); + border-radius: var(--radius-sm, 4px); + background: var(--color-surface-sunken); + color: var(--color-text-secondary); + font-size: 0.85em; +} + +.m02-pick__new { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + /* 면적산출 · 재료산출 카드 미리보기 수정(31-3) — 시트는 카드 높이 · 아래 자재 조합 판은 카드 안에서 스크롤 */ .m02-struct__sheet.is-edit { display: flex; diff --git a/M02_MasterTemplete/M02_Material_Own.py b/M02_MasterTemplete/M02_Material_Own.py new file mode 100644 index 000000000..3eb6bab69 --- /dev/null +++ b/M02_MasterTemplete/M02_Material_Own.py @@ -0,0 +1,144 @@ +"""M02 개인 자재(PLAN 148-2) — 개인 층 뿌리 한 파일 `개인자재.json`(종류 폴더 밖 · 양식 목록 · +프로젝트 복사 · 층 복사에 안 섞임 · `구조물집계표조합.json` 꼴). + +꼴 = M01 자재 줄 꼴 `{그룹: 재료, 원문: 개인, 줄: [{키, 이름, 규격, 단위, 값}]}` — +사본 `단가.json` 에 그대로 넣으면 `mf.Master` 가 읽음(MT 아닌 키 = 줄 「값」 · +`master_material.element`). 품번 = `PM` + 6자리 — 모든 사람 통틀어 한 줄 번호(모든 개인 층 파일 +가장 큰 번호 + 1 · `store._LOCK` 안 · 화면이 보낸 키는 안 받음). 신규 자재는 개인 층에서만 +(회사 · 마스터 층 = 마스터 자재만). +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers + +FILE = "개인자재.json" +PREFIX = "PM" +KEY = re.compile(rf"^{PREFIX}(\d{{6}})$") +COLS = ("이름", "규격", "단위") + + +def _blank() -> dict[str, Any]: + return {"그룹": "재료", "원문": "개인", "줄": []} + + +def read(folder: str | Path) -> dict[str, Any]: + """층 폴더의 개인 자재 묶음 — 없거나 못 읽으면 빈 묶음.""" + path = Path(folder) / FILE + try: + got = json.loads(path.read_bytes()) if path.is_file() else None + except (OSError, ValueError): + got = None + return got if isinstance(got, dict) and isinstance(got.get("줄"), list) else _blank() + + +def rows(folder: str | Path) -> list[dict[str, Any]]: + return read(folder)["줄"] + + +def _last() -> int: + """모든 개인 층 파일의 가장 큰 PM 번호 — 사람 사이 번호 안 겹침.""" + found = [ + int(m.group(1)) + for path in layers.storage_root().glob(f"*/*/{layers.TEMPLATES_DIRNAME}/{FILE}") + for row in read(path.parent)["줄"] + if (m := KEY.match(str(row.get("키") or ""))) + ] + return max(found, default=0) + + +def _text(v: Any) -> str: + return str(v).strip() if v is not None else "" + + +def _value(v: Any) -> float | int | None: + if v is None or (isinstance(v, str) and not v.strip()): + return None + try: + got = float("nan") if isinstance(v, bool) else float(v) + except (TypeError, ValueError): + got = float("nan") + if not 0 <= got < float("inf"): # NaN 도 여기서 걸림 + raise store.StoreError(400, "단가는 0 이상 수 또는 빈 값") + return int(got) if got.is_integer() else got + + +def add(folder: str | Path, body: dict[str, Any]) -> dict[str, Any]: + """새 개인 자재 한 줄 → 그 줄(키는 서버) · 품명 · 단위 빈 = 400 · + 같은 품명 + 규격 + 단위 = 409.""" + row = {k: _text(body.get(k)) for k in COLS} + if not row["이름"] or not row["단위"]: + raise store.StoreError(400, "품명 · 단위는 꼭 적음") + row["값"] = _value(body.get("값")) + with store._LOCK: + doc = read(folder) + if any(all(_text(x.get(k)) == row[k] for k in COLS) for x in doc["줄"]): + raise store.StoreError(409, f"이미 있는 자재 「{row['이름']} {row['규격']}」") + got = {"키": f"{PREFIX}{_last() + 1:06d}", **row} + doc["줄"].append(got) + atomic_write_json(Path(folder) / FILE, doc) + return got + + +# ── 148-3 — 저장 검사 · 셈 · 사본 · 층 사이 ───────────────────────────── +BUNDLE = "재료_개인자재.json" # 사본 `단가.json` 안 이름 + + +def is_own(code: str) -> bool: + return bool(KEY.match(code)) + + +def _personal(folder: Path) -> bool: + from M02_MasterTemplete import M02_Labor_Ref as refs + + return refs.where(folder)[0] == "personal" + + +def find(code: str, folder: str | Path | None = None) -> dict[str, Any] | None: + """개인 자재 한 줄 — `folder`(없으면 지금 층)가 개인 층일 때만.""" + folder = Path(folder) if folder is not None else store.root() + if not is_own(code) or not _personal(folder): + return None + return next((r for r in rows(folder) if r.get("키") == code), None) + + +def codes(doc: Any) -> list[str]: + """일위대가 문서 사용자 자재의 개인 자재 코드.""" + mats = doc.get("자재") if isinstance(doc, dict) else None + return [c for m in mats or [] if isinstance(m, dict) and is_own(c := str(m.get("코드") or ""))] + + +def bundle(wanted: list[str], folder: str | Path) -> dict[str, Any] | None: + """사본 `단가.json` 에 넣을 개인 자재 묶음(원본 층 목록에서 쓰는 줄만) — 없으면 None.""" + got = [r for r in rows(folder) if r.get("키") in set(wanted)] + return {**_blank(), "줄": got} if got else None + + +def adopt(found: list[dict[str, Any]], folder: str | Path | None = None) -> None: + """남의 줄을 같은 키로 내 목록에 — 이미 있는 키는 그대로 · 개인 층 아니면 400.""" + folder = Path(folder) if folder is not None else store.root() + if not found: + return + if not _personal(folder): + raise store.StoreError(400, "개인 자재는 개인 층에서만 — " + found[0].get("키", "")) + with store._LOCK: + doc = read(folder) + have = {r.get("키") for r in doc["줄"]} + new = [{k: r.get(k) for k in ("키", *COLS, "값")} for r in found if r.get("키") not in have] + if new: + doc["줄"] += new + atomic_write_json(folder / FILE, doc) + + +def carry(doc: Any, source: str | Path) -> None: + """층 사이 복사(`Store._server`) — 원본 개인 층 자재를 받는 층(지금 층) 목록에 같은 키로.""" + wanted = codes(doc) + if wanted and Path(source) != store.root(): + adopt([r for r in rows(source) if r.get("키") in set(wanted)]) diff --git a/M02_MasterTemplete/M02_Material_Pick.ts b/M02_MasterTemplete/M02_Material_Pick.ts index 8b0e604b5..54579ce78 100644 --- a/M02_MasterTemplete/M02_Material_Pick.ts +++ b/M02_MasterTemplete/M02_Material_Pick.ts @@ -1,14 +1,19 @@ /* ============================================================================= * M02_Material_Pick.ts * M01 자재 하나 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(일위대가 자재목록 · 자재 추가가 씀). + * · 일위대가 [자재 추가] 단추(`addMaterialButton` · 148 에서 `Labor_Detail` 이 덜어 냄) + * · 개인 층(148-3) = 개인 자재(「개인」 표 · 앞) + M01 · 머리 [신규 자재] → 품명 · 단위(필수) · 규격 · 단가 → + * 서버가 품번(`PM` + 6자리)을 주고 그 줄로 고름(`M02_Material_Own`) * 모양(`m02-pick*`)은 `M02_MasterTemplete_Structure.css`. * ========================================================================== */ -import { createInputField, el, showToast } from "@ui/ui_template_elements"; +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; +import { call } from "./M02_MasterTemplete_Api_Fetch"; +import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; -/** 고른 자재 한 줄 — M01 자재(키 `MT`) */ +/** 고른 자재 한 줄 — M01 자재(키 `MT`) · 개인 자재(키 `PM`) */ export interface MaterialItem { 코드: string; 이름: string | null; @@ -16,6 +21,20 @@ export interface MaterialItem { 단위: string | null; } +/** 개인 자재 한 줄(서버 `개인자재.json` 꼴) */ +interface OwnRow { + 키: string; + 이름: string; + 규격: string; + 단위: string; + 값: number | null; +} + +/** 개인 층 주인(148-3 · 지금 층이면 null) — 넘기면 개인 자재 · [신규 자재] */ +export interface OwnLayer { + owner: string | null; +} + const TEXT = { pickTitle: "자재 추가", pickClose: "닫기", @@ -23,45 +42,92 @@ const TEXT = { searching: "찾는 중…", noMatch: "맞는 자재 없음", searchFailed: "자재 목록을 못 받음", + newMat: "신규 자재", + own: "개인", + noPrice: "단가 없음", + name: "품명", + spec: "규격", + unit: "단위", + price: "단가(원 · 비우면 단가 없음)", + key: "품번 — 저장 때 자동", + save: "저장", + back: "목록으로", + needName: "품명 · 단위는 꼭 적음", }; -/** 자재 고르기 창 — 고르면 `done` 부르고 닫음 */ -export function pickMaterial(done: (item: MaterialItem) => void): void { +const ownPath = (own: OwnLayer): string => + `/layers/personal/materials${own.owner ? `?owner=${encodeURIComponent(own.owner)}` : ""}`; +const toItem = (r: OwnRow): MaterialItem => ({ + 코드: r.키, + 이름: r.이름, + 규격: r.규격, + 단위: r.단위, +}); +/** 찾기 낱말이 모두 이름 · 규격 · 키에 듦(M01 찾기와 같은 규칙) */ +const matches = (r: OwnRow, words: string[]): boolean => { + const hay = `${r.이름} ${r.규격} ${r.키}`.toLowerCase(); + return words.every((w) => hay.includes(w)); +}; + +/** 자재 고르기 창 — 고르면 `done` 부르고 닫음 · `own` = 개인 층(개인 자재 · [신규 자재]) */ +export function pickMaterial(done: (item: MaterialItem) => void, own?: OwnLayer): void { + let mine: OwnRow[] = []; + const loaded = own + ? call<{ 줄: OwnRow[] }>(ownPath(own)) + .then((d) => void (mine = d.줄)) + .catch(() => undefined) + : Promise.resolve(); + let showNew = (): void => undefined; + const newButton = createButton({ + label: TEXT.newMat, + variant: "ghost", + onClick: () => showNew(), + }); + newButton.dataset.materialNew = ""; openModal({ title: TEXT.pickTitle, closeLabel: TEXT.pickClose, dialogClass: "m02-modal m02-pick", + ...(own ? { actions: [newButton] } : {}), mount: (body, close) => { const search = createInputField({ placeholder: TEXT.search, type: "search" }); const list = el("div", { className: "m02-pick__list" }); + const item = (m: MaterialItem, tags: string[] = []): HTMLElement => { + const row = el("button", { + className: "m02-pick__item", + attrs: { type: "button", "data-material": m.코드 }, + children: [ + el("span", { className: "m02-pick__key", text: m.코드 }), + `${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "), + ...tags.map((t) => el("span", { className: "m02-pick__tag", text: t })), + ], + }); + row.addEventListener("click", () => { + done(m); + close(); + }); + return row; + }; let seq = 0; const paint = async (): Promise => { - const mine = ++seq; + const ticket = ++seq; list.replaceChildren(el("p", { className: "m02-struct__empty", text: TEXT.searching })); try { - const hits = await searchMaterials(search.input.value.trim()); - if (mine !== seq) return; + const q = search.input.value.trim(); + const [hits] = await Promise.all([searchMaterials(q), loaded]); + if (ticket !== seq) return; + const words = q.toLowerCase().split(/\s+/).filter(Boolean); + const ownHits = mine + .filter((r) => matches(r, words)) + .map((r) => item(toItem(r), r.값 === null ? [TEXT.own, TEXT.noPrice] : [TEXT.own])); + const all = [...ownHits, ...hits.map((m) => item(m))]; list.replaceChildren( - ...(hits.length - ? hits.map((m) => { - const row = el("button", { - className: "m02-pick__item", - attrs: { type: "button", "data-material": m.코드 }, - children: [ - el("span", { className: "m02-pick__key", text: m.코드 }), - `${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "), - ], - }); - row.addEventListener("click", () => { - done(m); - close(); - }); - return row; - }) + ...(all.length + ? all : [el("p", { className: "m02-struct__empty", text: TEXT.noMatch })]), ); } catch { - if (mine === seq) showToast(TEXT.searchFailed, "error"); + if (ticket === seq) showToast(TEXT.searchFailed, "error"); } }; let timer = 0; @@ -69,9 +135,103 @@ export function pickMaterial(done: (item: MaterialItem) => void): void { window.clearTimeout(timer); timer = window.setTimeout(() => void paint(), 250); }); - body.append(search.root, list); + const picking = [search.root, list]; + if (own) + showNew = () => { + newButton.hidden = true; + body.replaceChildren( + newForm( + own, + search.input.value.trim(), + (row) => { + done(toItem(row)); + close(); + }, + () => { + newButton.hidden = false; + body.replaceChildren(...picking); + }, + ), + ); + }; + body.append(...picking); void paint(); search.input.focus(); }, }); } + +/** 신규 자재 입력(148-3) — [저장] = 서버 추가 → 그 줄로 `saved` · 실패는 서버 까닭 그대로 */ +function newForm( + own: OwnLayer, + seed: string, + saved: (row: OwnRow) => void, + back: () => void, +): HTMLElement { + const name = createInputField({ label: TEXT.name, value: seed, required: true }); + const spec = createInputField({ label: TEXT.spec }); + const price = createInputField({ label: TEXT.price, type: "number", min: 0 }); + let unit = ""; + const unitHost = el("span"); + const drawUnit = (): void => + unitHost.replaceChildren( + createUnitPick({ + value: unit || NO_UNIT, + ariaLabel: TEXT.unit, + onPick: (u) => { + unit = u === NO_UNIT ? "" : u; + drawUnit(); + }, + }), + ); + drawUnit(); + const save = createButton({ + label: TEXT.save, + variant: "filled", + onClick: async () => { + const 이름 = name.input.value.trim(); + if (!이름 || !unit) return showToast(TEXT.needName, "error"); + const raw = price.input.value.trim(); + try { + saved( + await call(ownPath(own), { + method: "POST", + body: JSON.stringify({ + 이름, + 규격: spec.input.value.trim(), + 단위: unit, + 값: raw === "" ? null : Number(raw), + }), + }), + ); + } catch (e) { + showToast(e instanceof Error ? e.message : String(e), "error"); + } + }, + }); + return el("div", { + className: "m02-pick__new", + children: [ + name.root, + spec.root, + el("div", { + className: "ui-field", + children: [el("label", { className: "ui-field__label", text: TEXT.unit }), unitHost], + }), + price.root, + el("p", { className: "m02-struct__empty", text: TEXT.key }), + el("div", { + className: "ui-btn-group", + children: [createButton({ label: TEXT.back, variant: "ghost", onClick: back }), save], + }), + ], + }); +} + +/** 일위대가 [자재 추가] 단추 — 누르면 고르기 창 · 고른 줄로 `done`(막을지는 부르는 쪽) */ +export const addMaterialButton = ( + label: string, + done: (item: MaterialItem) => void, + own?: OwnLayer, +): HTMLElement => + createButton({ label, variant: "filled", onClick: () => pickMaterial(done, own) }); diff --git a/resources/tester/test_m02_labor_logic_mats.py b/resources/tester/test_m02_labor_logic_mats.py index e2c39830e..4a26f251a 100644 --- a/resources/tester/test_m02_labor_logic_mats.py +++ b/resources/tester/test_m02_labor_logic_mats.py @@ -160,3 +160,20 @@ def test_입력이_바뀌면_걸린_고름만_풂_화면_짝(): assert _release(row, {"규격": "BX"}, mats)["고름"] == {"9": WIRE} # 규격에 걸린 줄만 row = {**row, "고름": {"4": "MT007895"}} assert "고름" not in _release(row, {}, mats) # 다 풀리면 칸째 지움 + + +def _src(name): + with open(os.path.join(HERE, "..", "..", "M02_MasterTemplete", name), encoding="utf-8") as f: + return f.read() + + +def test_로직_자재_줄_누름은_그_로직_옵션_모달_사용자_줄은_그대로(): + """148-1 — 고칠 때 · 읽기만 둘 다 `open: openRow` · 바꾸기 단추 칸은 swap 있을 때만 · 사용자 줄 = openMat.""" + detail = _src("M02_MasterTemplete_Labor_Detail.ts") + assert "open: openRow,\n swap: swapMat," in detail + assert ": { open: openRow }," in detail + assert "{ open: openMat, remove: removeMat, mark: markMat } : { open: openMat }" in detail + mats = _src("M02_MasterTemplete_Labor_LogicMats.ts") + assert "const tools = swap" in mats + assert 'tr.addEventListener("click", () => on.open(r))' in mats + assert '${on ? " is-open" : ""}' in mats diff --git a/resources/tester/test_m02_labor_mat_marks.py b/resources/tester/test_m02_labor_mat_marks.py new file mode 100644 index 000000000..eb1752081 --- /dev/null +++ b/resources/tester/test_m02_labor_mat_marks.py @@ -0,0 +1,130 @@ +"""일위대가 자재목록 세 칸(PLAN 148-4) — 자재구분 · 별도계상 · 운반비 · 금액 무관. + +- 서버 `clean` = 허용 값만(그 밖 · 빈 것 버림) · 사용자 자재 = 줄 덧칸 · + 로직 자재 = 로직 줄 `자재표시 {호표 차례: 세 칸}`(차례가 재료 줄인 것만 · 틀려도 400 아님) +- 기준합 · 반영합 그대로 · 저장 왕복에 남음 · 화면 `normalizeLabor` 도 같은 규칙(Node 헬퍼) +- 화면 칸 부품 = 새 파일 · 사용자 줄 · 로직 줄 같은 칸 · 사전조합 줄엔 안 넘김(소스 검사) +""" + +import json +import os +import subprocess + +import pytest + +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_m02_structure import write_sample_table + +HERE = os.path.dirname(os.path.abspath(__file__)) +SRC = os.path.join(HERE, "..", "..", "M02_MasterTemplete") +NET = "GC000337" # 낙석방지망 — 재료 줄 3 · 4 · 5 +WIRE = "MT000194" +MARKS = {"자재구분": "관급", "별도계상": "별도계상", "운반비": "미포함"} + + +def _doc(**extra): + return {"열": "rv", "담은로직": [], "자재": [], **extra} + + +def _first_non_material(code): + from M01_MasterData import M01_MasterData_Store as m01 + + rows = m01.loaded()[1].logic(code).get("호표") or [] + return next(n for n, r in enumerate(rows) if r.get("종류") != "재료") + + +def test_사용자_자재_허용_값만(): + got = labor.clean( + _doc(자재=[{"코드": WIRE, **MARKS}, {"코드": "MT000008", "자재구분": "아무", "운반비": ""}]) + )["자재"] + assert {k: got[0][k] for k in MARKS} == MARKS + assert not set(MARKS) & set(got[1]) + + +def test_로직_자재_자재표시_재료_줄_차례만_허용_값만(): + other = _first_non_material(NET) + shown = {"3": MARKS, "4": {"자재구분": "엉뚱"}, str(other): MARKS, "99": MARKS, "x": MARKS} + line = labor.clean(_doc(담은로직=[{"로직": NET, "자재표시": shown}]))["담은로직"][0] + assert line["자재표시"] == {"3": MARKS} + bare = labor.clean(_doc(담은로직=[{"로직": NET, "자재표시": {"4": {"운반비": "?"}}}])) + assert "자재표시" not in bare["담은로직"][0] + + +def test_금액_그대로(): + plain = _doc( + 담은로직=[{"로직": NET, "수량": {"손": 2}}], 자재=[{"코드": WIRE, "수량": {"손": 3}}] + ) + marked = _doc( + 담은로직=[{"로직": NET, "수량": {"손": 2}, "자재표시": {"3": MARKS}}], + 자재=[{"코드": WIRE, "수량": {"손": 3}, **MARKS}], + ) + a, b = (labor.calc(labor.clean(d), {}) for d in (plain, marked)) + assert a["기준합"] == b["기준합"] and a["반영합"] == b["반영합"] + + +@pytest.fixture() +def system(tmp_path, monkeypatch): + write_sample_table(tmp_path) + monkeypatch.setattr(store, "FOLDER", tmp_path) + store.create_structure("rv") + return tmp_path + + +def test_저장_왕복에_남음(system): + have = store.read("labor", "rv") + sent = { + **have["문서"], + "담은로직": [{"로직": NET, "수량": None, "자재표시": {"3": MARKS}}], + "자재": [{"코드": "MT000008", "수량": None, "자재구분": "사급"}], + } + store.write("labor", "rv", have["판"], sent) + doc = store.read("labor", "rv")["문서"] + assert doc["담은로직"][0]["자재표시"] == {"3": MARKS} + assert doc["자재"][0]["자재구분"] == "사급" and "운반비" not in doc["자재"][0] + + +def test_화면_정리도_허용_값만(): + doc = { + "담은로직": [ + {"로직": NET, "자재표시": {"3": MARKS, "4": {"운반비": "?"}}}, + { + "로직": "", + "참조": {"층": "system", "주인": None, "키": "SC-1"}, + "자재표시": {"3": MARKS}, + }, + ], + "자재": [{"코드": WIRE, "별도계상": "직접계산", "자재구분": "?"}], + } + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_labor_normalize.cjs")], + input=json.dumps({"doc": doc, "column": "rv"}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + got = json.loads(proc.stdout) + assert [r.get("자재표시") for r in got["담은로직"]] == [{"3": MARKS}, None] + assert got["자재"][0]["별도계상"] == "직접계산" and "자재구분" not in got["자재"][0] + + +def _src(name): + with open(os.path.join(SRC, name), encoding="utf-8") as f: + return f.read() + + +def test_화면_칸_부품_사용자_줄_로직_줄_같은_칸(): + marks = _src("M02_MasterTemplete_Labor_MatMarks.ts") + assert '{ value: "", text: "—" }' in marks + assert "e.stopPropagation()" in marks # 줄 누름과 따로 + rows = _src("M02_MasterTemplete_Labor_Rows.ts") + assert "...markCells(mat, on?.mark" in rows and "...markHeads()," in rows + assert "...markCells(" in _src("M02_MasterTemplete_Labor_LogicMats.ts") + detail = _src("M02_MasterTemplete_Labor_Detail.ts") + assert "mark: lib ? undefined : markLogicMat" in detail + assert "mark: markMat" in detail + # 서버 허용 값 = 화면 허용 값 + api = _src("M02_MasterTemplete_Labor_Api.ts") + for key, allowed in labor.MARKS.items(): + assert f"{key}: [{', '.join(json.dumps(v, ensure_ascii=False) for v in allowed)}]" in api diff --git a/resources/tester/test_m02_material_own.py b/resources/tester/test_m02_material_own.py new file mode 100644 index 000000000..6c33074c0 --- /dev/null +++ b/resources/tester/test_m02_material_own.py @@ -0,0 +1,231 @@ +"""M02 개인 자재(PLAN 148) — 개인 층 파일 `개인자재.json` · 서버 길(목록 · 추가) · +품번 `PM` + 6자리. + +- 품번 = 모든 사람 통틀어 한 줄 번호(두 사람 · 두 회사 안 겹침) · 화면이 보낸 키는 안 받음 +- 품명 · 단위 빈 = 400 · 단가 = 0 이상 수 또는 빈 값 · 같은 품명 + 규격 + 단위 = 409 +- 개인 층만(회사 = 빈 목록 · 추가 400) · 남의 개인 층 = 403 +층은 임시 폴더(마스터 · storage). +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M02_MasterTemplete import M02_Labor_Router as labor_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Material_Own as own +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +URL = "/api/m02/layers/{}/materials" +BOLT = {"이름": "앵커볼트", "규격": "M16", "단위": "EA", "값": 1200} + + +@pytest.fixture() +def world(tmp_path, monkeypatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app = FastAPI() + app.include_router(labor_router.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "session": session} + + +def _as(world, user: int, company: int = 7) -> TestClient: + world["session"].update(user_id=user, company_id=company) + return world["client"] + + +def test_추가하면_PM_6자리_키_목록에_보임_화면_키_무시(world): + c = _as(world, 42) + got = c.post(URL.format("personal"), json={**BOLT, "키": "MT000008"}) + assert got.status_code == 200, got.text + row = got.json() + assert row == {"키": "PM000001", "이름": "앵커볼트", "규격": "M16", "단위": "EA", "값": 1200} + assert c.get(URL.format("personal")).json()["줄"] == [row] + path = layers.personal_dir(7, 42) / own.FILE + assert path.is_file() + assert own.read(path.parent)["그룹"] == "재료" + + +def test_두_사람_두_회사_번호_안_겹침(world): + a = _as(world, 42).post(URL.format("personal"), json=BOLT).json()["키"] + b = _as(world, 43).post(URL.format("personal"), json=BOLT).json()["키"] + c = _as(world, 50, company=8).post(URL.format("personal"), json=BOLT).json()["키"] + d = _as(world, 42).post(URL.format("personal"), json={**BOLT, "규격": "M20"}).json()["키"] + assert [a, b, c, d] == ["PM000001", "PM000002", "PM000003", "PM000004"] + + +@pytest.mark.parametrize( + "body", + [ + {**BOLT, "이름": " "}, + {**BOLT, "단위": ""}, + {**BOLT, "값": -1}, + {**BOLT, "값": "abc"}, + {**BOLT, "값": True}, + ], +) +def test_빈_품명_단위_틀린_단가_400(world, body): + assert _as(world, 42).post(URL.format("personal"), json=body).status_code == 400 + + +def test_단가_빈_값_받음_같은_자재_409(world): + c = _as(world, 42) + row = c.post(URL.format("personal"), json={**BOLT, "값": ""}).json() + assert row["값"] is None + assert c.post(URL.format("personal"), json={**BOLT, "값": 5}).status_code == 409 + + +def test_회사_층은_빈_목록_추가_400_남의_개인_403(world): + c = _as(world, 42) + c.post(URL.format("personal"), json=BOLT) + assert c.get(URL.format("company")).json() == {"줄": []} + assert c.post(URL.format("company"), json=BOLT).status_code == 400 + other = _as(world, 43) + assert other.get(URL.format("personal"), params={"owner": "7/42"}).status_code == 403 + assert other.post(URL.format("personal") + "?owner=7/42", json=BOLT).status_code == 403 + + +# ── 148-3 — 저장 검사 · 셈 · 사본 · 층 사이 · 개인에 저장 ────────────── + + +def _personal(company: int, user: int, column: str = "a"): + return _layer(layers.personal_dir(company, user), column) + + +def _layer(folder, column): + """층 + 구조물 열 하나(빈 일위대가).""" + from M02_MasterTemplete import M02_MasterTemplete_Items as items_store + + items_store.ensure(folder) + with store.at(folder): + got = store.read("table", store.STRUCTURE_TABLE) + got["문서"]["열"] = [{"id": column, "머리": ["돌", "가"], "꼴": "수"}] + store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"]) + return folder + + +def _put_labor(folder, column, mats): + with store.at(folder): + have = store.read("labor", column) + store.write("labor", column, have["판"], {**have["문서"], "자재": mats}) + return store.read("labor", column)["문서"] + + +def test_개인_층_일위대가가_개인_자재를_받고_셈은_값(world): + from M02_MasterTemplete import M02_Labor_Doc as labor + + a = _personal(7, 42) + row = own.add(a, BOLT) + bare = own.add(a, {**BOLT, "규격": "M20", "값": None}) + doc = _put_labor(a, "a", [{"코드": row["키"], "수량": {"손": 2}}, {"코드": bare["키"]}]) + assert [(m["코드"], m["이름"], m["규격"]) for m in doc["자재"]] == [ + ("PM000001", "앵커볼트", "M16"), + ("PM000002", "앵커볼트", "M20"), + ] + with store.at(a): + got = labor.calc(doc, {})["자재"] + assert got[0]["기준단가"] == 1200 and got[0]["반영"] == 2400 + assert got[1]["기준단가"] is None and got[1]["까닭"] == "단가 없음" + + +def test_회사_층_문서엔_개인_자재_400(world): + from M02_MasterTemplete import M02_Labor_Doc as labor + + a = _personal(7, 42) + key = own.add(a, BOLT)["키"] + with store.at(layers.company_dir(7)), pytest.raises(store.StoreError) as e: + labor.clean({"열": "a", "담은로직": [], "자재": [{"코드": key}]}) + assert e.value.status == 400 and "개인 층에서만" in str(e.value.detail) + with pytest.raises(store.StoreError): # 기본(마스터) 층도 + labor.clean({"열": "a", "담은로직": [], "자재": [{"코드": key}]}) + + +def test_사본_단가에_개인_줄_굳힘_사본_셈은_층_셈(world): + from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Item_Source as source + from M02_MasterTemplete import M02_Labor_Doc as labor + + a = _personal(7, 42) + key = own.add(a, BOLT)["키"] + doc = _put_labor(a, "a", [{"코드": key, "수량": {"손": 3}}]) + with store.at(a): + ss = next(r["키"] for r in store.read_combos()["줄"] if r.get("열") == "a") + layer_calc = labor.calc(doc, {}) + files = source.build_source({"layer": "personal", "owner": "7/42", "key": ss}) + assert files["단가.json"][own.BUNDLE]["줄"] == [{"키": key, **{k: BOLT[k] for k in BOLT}}] + whole = prices.load_prices(files["단가.json"]) + copy_calc = labor.calc(doc, {}, whole=whole, read=lambda ref: None, pills=[]) + assert copy_calc["자재"][0]["기준단가"] == layer_calc["자재"][0]["기준단가"] == 1200 + # 원본 목록이 바뀌어도 사본 그대로 — 사본 단가는 굳힌 것 + (a / own.FILE).write_text(json.dumps(own._blank()), encoding="utf-8") + assert ( + labor.calc(doc, {}, whole=whole, read=lambda ref: None, pills=[])["자재"][0]["기준단가"] + == 1200 + ) + + +def test_층_사이_복사_받는_개인_층에_같은_키_회사로는_400(world): + a = _personal(7, 42) + b = _personal(7, 43, "b") + key = own.add(a, BOLT)["키"] + _put_labor(a, "a", [{"코드": key}]) + with store.at(b): + store.copy_from("labor", "b", "a", source_root=a) + assert store.read("labor", "b")["문서"]["자재"][0]["코드"] == key + assert [r["키"] for r in own.rows(b)] == [key] + company = _layer(layers.company_dir(7), "c") + with store.at(company): + with pytest.raises(store.StoreError) as e: + store.copy_from("labor", "c", "a", source_root=a) + assert e.value.status == 400 + + +def test_개인에_저장은_사본_개인_줄을_내_목록에_같은_키(world, tmp_path): + from M02_MasterTemplete import M02_Item_Personal as personal + from M02_MasterTemplete import M02_Item_Source as source + + a = _personal(7, 42) + key = own.add(a, BOLT)["키"] + _put_labor(a, "a", [{"코드": key}]) + with store.at(a): + ss = next(r["키"] for r in store.read_combos()["줄"] if r.get("열") == "a") + files = source.build_source({"layer": "personal", "owner": "7/42", "key": ss}) + copy = tmp_path / "copy" + copy.mkdir() + for name, body in files.items(): + (copy / name).write_text(json.dumps(body, ensure_ascii=False), encoding="utf-8") + b = _personal(7, 43, "b") + made = personal.create_item(b, ["돌", "나"], "볼트", "개", copy) + with store.at(b): + doc = store.read("labor", made["열"])["문서"] + assert doc["자재"][0]["코드"] == key + assert own.rows(b) == own.rows(a) + + +def test_화면_신규_자재는_머리_단추_개인_층만(): + """148-3 — [신규 자재] = 공용 모달 `actions`(개인 층만) · 품번은 안 보냄 · 상세는 개인 층에만 넘김.""" + import os + + here = os.path.join(os.path.dirname(__file__), "..", "..", "M02_MasterTemplete") + pick = open(os.path.join(here, "M02_Material_Pick.ts"), encoding="utf-8").read() + assert "...(own ? { actions: [newButton] } : {})" in pick + assert ( + "`/layers/personal/materials" in pick and "키:" not in pick.split("JSON.stringify")[1][:200] + ) + detail = open(os.path.join(here, "M02_MasterTemplete_Labor_Detail.ts"), encoding="utf-8").read() + assert 'opt.layer === "personal" ? { owner: library.owner } : undefined' in detail