Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_2

# Conflicts:
#	M02_MasterTemplete/M02_Labor_Doc.py
This commit is contained in:
2026-10-09 19:17:59 +09:00
18 changed files with 1006 additions and 66 deletions
+4 -1
View File
@@ -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)}
+5
View File
@@ -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
+51 -4
View File
@@ -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:
+30 -1
View File
@@ -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)
@@ -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);
@@ -135,8 +135,35 @@ export interface LaborRow {
고름?: Record<string, string>;
/** 단가승격(134-1 · 로직 줄 구분 드롭다운) — 없음 = 단가산출 · 금액 무관 · B09 호표 몫 */
승격?: boolean;
/** 로직 자재 세 칸(148-4) — {호표 차례: 세 칸} · 금액 무관 */
자재표시?: Record<string, MatMarks>;
}
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<string, unknown>;
const out: Record<string, unknown> = {};
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<string, MatMarks> } {
const out: Record<string, MatMarks> = {};
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),
})),
};
}
@@ -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 = {
@@ -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;
}
@@ -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<MatMarkKey, ComboTextKey> = {
자재구분: "Mark_Kind",
별도계상: "Mark_Separate",
운반비: "Mark_Haul",
};
/** 선택지 글(저장 값은 한국어 그대로) */
const WORD: Record<string, ComboTextKey> = {
일반: "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<string, string>)[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.자재표시;
}
@@ -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<RowHandlers, "move">): 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<RowHandlers, "move">): HT
tc("Labor_Spec"),
tc("Labor_QtyCol"),
tc("Labor_Unit"),
...markHeads(),
...PRICE_SLOTS,
`${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`,
...(remove ? [tc("Col_Move")] : []),
@@ -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"],
@@ -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)
@@ -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;
+144
View File
@@ -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)])
+185 -25
View File
@@ -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<void> => {
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<OwnRow>(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) });