feat(M02): 구조물 조합 고르기 모달 = M02 일위대가 카드 · 사본 셈 = 층 셈 한 벌(142-1)
- 사본 셈 = M02_Labor_Doc.calc 한 벌(사본 단가 · 굳힌 참조사본 · 사본 알약 주입 · 빈 입력 = 자동값) - 모달 일위대가 카드 = ItemCards labor(자료 자리 ItemSource.labor · 쓰는 길 없으면 읽기 전용) - 사본 문서에 사본 입력값 · 고른자재를 줄 입력값 · 고름으로 얹음 · [수정] = 넣을 값 편집 화면 - 옛 요약 카드(picker_labor) · 넣을 값 카드 · 머리 옛 일위대가 이름 줄 지움 · 처음 펼침 = 개인 - 시험: 사본 셈 = 층 셈 · 모달 일위대가 카드 읽기 전용 · 새 셈 꼴로 고침 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
`단가.json` = `{파일 이름: {그룹 · 머리 칸 …, 줄|표: [줄]}}` — `master_formula.Master` 가
|
||||
그대로 읽는 꼴.
|
||||
계산(`calc`)은 이 묶음과 `입력값.json` · `고른자재.json` · `면적.json`(알약 칸 값) 만 씀 — 마스터
|
||||
현재값을 읽지 않음. 수량 · 반영 · 합은 일위대가 문서와 같은 함수(`M02_Labor_Doc.settle`).
|
||||
현재값을 읽지 않음. 셈은 층 일위대가 셈 한 벌(`M02_Labor_Doc.calc` · 142-1 · 빈 입력 = 자동값).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -265,6 +265,27 @@ def missing_inputs(snapshot: Any, labor: dict[str, Any], values: dict[str, Any])
|
||||
return out
|
||||
|
||||
|
||||
def with_values(doc: dict[str, Any], values: dict[str, Any], picks: dict[str, Any]) -> dict:
|
||||
"""사본 문서에 사본 `입력값.json` · `고른자재.json` 을 줄 입력값 · 고름으로 얹음(142-1) —
|
||||
빈 값은 안 얹음(= 자동값 · M02 층 셈과 같음)."""
|
||||
lines = []
|
||||
for one in doc.get("담은로직") or []:
|
||||
code = one.get("로직") if isinstance(one, dict) else None
|
||||
if not code:
|
||||
lines.append(one)
|
||||
continue
|
||||
given = {
|
||||
name: got.get("값")
|
||||
for name, got in (values.get(code) or {}).items()
|
||||
if isinstance(got, dict) and got.get("값") not in (None, "")
|
||||
}
|
||||
line = {**one, "입력값": given}
|
||||
if isinstance((picks or {}).get(code), dict):
|
||||
line["고름"] = picks[code]
|
||||
lines.append(line)
|
||||
return {**doc, "담은로직": lines}
|
||||
|
||||
|
||||
def calc(
|
||||
labor: dict[str, Any],
|
||||
prices: dict[str, Any],
|
||||
@@ -272,107 +293,25 @@ def calc(
|
||||
picks: dict[str, Any],
|
||||
area: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""복사본만으로 계산 — 입력이 빠진 로직은 안 돌리고 `멈춤` · 세 비목 · 계는 돈 로직 합
|
||||
(수량 1 기준).
|
||||
|
||||
일위대가 문서(39-1c)면 줄마다 수량 · 반영 · `자재` · `기준합` · `반영합` 을 덧붙임 —
|
||||
수량 = 손 · 면적 알약 칸(`area`) · 반영 = `M02_Labor_Doc.settle`(문서 계산과 한 벌)."""
|
||||
snapshot = load_prices(prices)
|
||||
gaps = missing_inputs(snapshot, labor, values)
|
||||
blocked = {gap["로직"] for gap in gaps}
|
||||
"""복사본만으로 계산 — 층 셈(`M02_Labor_Doc.calc`) 한 벌(142-1) · 단가 = 사본 `단가.json` ·
|
||||
참조 = 굳힌 `참조사본` · 알약 = 가져온 때 것 · 수량 칸 = 사본 `면적.json` · 빈 입력 = 자동값.
|
||||
답 = 층 셈 꼴(`줄` · `자재` · `기준합` · `반영합`) + `알약` + `빠진입력`(「빈 값 n」 표시)."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
|
||||
|
||||
sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0))
|
||||
lines, stuck = [], []
|
||||
rates = { # 켜쌓기율(85-2) — 문서 계산과 같은 `layered`
|
||||
one.get("로직"): one.get("켜쌓기율")
|
||||
for one in (labor.get("줄") or {}).get("담은로직") or []
|
||||
if isinstance(one, dict)
|
||||
}
|
||||
for at, key in enumerate(_logic_keys(labor), start=1):
|
||||
head: dict[str, Any] = {"차례": at, "로직": key}
|
||||
if key in blocked:
|
||||
names = [gap["입력"] for gap in gaps if gap["로직"] == key]
|
||||
lines.append({**head, "ok": False, "까닭": f"입력 없음 — {', '.join(names)}"})
|
||||
stuck.append(key)
|
||||
continue
|
||||
given = {name: one.get("값") for name, one in (values.get(key) or {}).items()}
|
||||
try:
|
||||
with mf.mp.choosing((picks or {}).get(key)):
|
||||
got = labor_doc.layered(mf.run(snapshot, key, given), rates.get(key))
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as error:
|
||||
lines.append({**head, "ok": False, "까닭": str(error) or type(error).__name__})
|
||||
stuck.append(key)
|
||||
continue
|
||||
if "결과" in got:
|
||||
lines.append({**head, "ok": True, "갈래": "결과", "결과": got["결과"]})
|
||||
continue
|
||||
for name in mf.COST_ITEMS:
|
||||
sums[name] += got[name]
|
||||
lines.append(
|
||||
{
|
||||
**head,
|
||||
"ok": True,
|
||||
"갈래": "돈",
|
||||
**{k: got[k] for k in mf.COST_ITEMS},
|
||||
"계": got["계"],
|
||||
}
|
||||
)
|
||||
for line in _ref_lines(snapshot, labor, len(lines)): # 참조 줄(50-3) — 굳힌 사본으로
|
||||
if line["ok"]:
|
||||
for name in mf.COST_ITEMS:
|
||||
sums[name] += line[name]
|
||||
else:
|
||||
stuck.append(line["참조"]["키"])
|
||||
lines.append(line)
|
||||
out = {"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck}
|
||||
return to_json({**out, **_reflected(snapshot, labor, lines, area)})
|
||||
|
||||
|
||||
def _ref_lines(snapshot: Any, labor: dict[str, Any], start: int) -> list[dict]:
|
||||
"""참조 줄 — 기준 = 굳힌 사본(`참조사본`)을 복사본 단가로 펼친 1 단위 값
|
||||
(입력값은 승격 때 고정)."""
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
snapshot = load_prices(prices)
|
||||
doc = with_values(labor.get("줄") or {}, values or {}, picks or {})
|
||||
copies = labor.get("참조사본") or {}
|
||||
expand = refs.Expand(snapshot, lambda ref: copies.get(refs.ref_id(ref)))
|
||||
out = []
|
||||
for at, one in enumerate((labor.get("줄") or {}).get("담은로직") or [], start=start + 1):
|
||||
if not (isinstance(one, dict) and isinstance(one.get("참조"), dict)):
|
||||
continue
|
||||
got = expand(one["참조"])
|
||||
head = {"차례": at, "참조": one["참조"], "이름": got.get("이름"), "분모": got.get("단위")}
|
||||
if got.get("기준") is None:
|
||||
out.append({**head, "ok": False, "까닭": got.get("까닭")})
|
||||
continue
|
||||
out.append({**head, "ok": True, "갈래": "돈", **got["기준"]})
|
||||
return out
|
||||
|
||||
|
||||
def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: Any) -> dict:
|
||||
"""줄마다 수량 · 반영 + 자재 + 기준합 · 반영합 — 기준은 위 줄 그대로 · 알약은 가져온 때 것."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
|
||||
|
||||
doc = labor.get("줄") or {}
|
||||
pill_of = {code: pill for pill in labor.get("알약") or [] for code in pill.get("로직") or []}
|
||||
base = []
|
||||
for line in lines:
|
||||
money = {k: line[k] for k in (*mf.COST_ITEMS, "계")} if line.get("갈래") == "돈" else None
|
||||
if "참조" in line: # 알약 키 = ref_id · 배 1(50-3)
|
||||
head = {"참조": line["참조"], "알약": labor_doc.line_id(line), "배": 1}
|
||||
base.append({**head, "분모": line.get("분모"), "기준": money})
|
||||
continue
|
||||
pill = pill_of.get(line["로직"]) or {}
|
||||
head = {"로직": line["로직"], "알약": pill.get("키"), "배": pill.get("배") or 1}
|
||||
base.append({**head, "분모": pill.get("단위"), "기준": money})
|
||||
mats = []
|
||||
for one in doc.get("자재") or []:
|
||||
code = str(one.get("코드") or "")
|
||||
got = labor_doc.material_price(snapshot, code, one.get("단위")) # 가격 다섯 · 기준칸(43-3)
|
||||
mats.append({"코드": code, "알약": labor_doc.material_key(code), **got})
|
||||
settled = labor_doc.settle(
|
||||
to_json({"줄": base, "자재": mats}), doc, labor_doc.area_values(area)
|
||||
saved = labor.get("알약")
|
||||
got = labor_doc.calc(
|
||||
doc,
|
||||
labor_doc.area_values(area),
|
||||
whole=snapshot,
|
||||
read=lambda ref: copies.get(refs.ref_id(ref)),
|
||||
pills=[p for p in saved if p.get("로직")] if isinstance(saved, list) else None,
|
||||
)
|
||||
for line, done in zip(lines, settled["줄"]):
|
||||
line.update({k: done[k] for k in ("알약", "분모", "배", "수량", "수량출처", "반영")})
|
||||
return {"자재": settled["자재"], "기준합": settled["기준합"], "반영합": settled["반영합"]}
|
||||
return {
|
||||
**got,
|
||||
"알약": saved if isinstance(saved, list) else labor_doc.pills(doc),
|
||||
"빠진입력": missing_inputs(snapshot, labor, values or {}),
|
||||
}
|
||||
|
||||
@@ -380,16 +380,24 @@ def material_price(master: Any, code: str, unit: str | None) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
def calc(
|
||||
doc: dict,
|
||||
given: dict[str, Any] | None = None,
|
||||
*,
|
||||
whole: Any = None,
|
||||
read: Any = None,
|
||||
pills: list[dict] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) —
|
||||
줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음)."""
|
||||
whole = m01.loaded()[1]
|
||||
expand = refs.Expand(whole)
|
||||
by_logic = {
|
||||
code: pill
|
||||
for pill in combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
|
||||
for code in pill["로직"]
|
||||
}
|
||||
줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음).
|
||||
|
||||
프로젝트 사본 셈(142-1)도 이 한 벌 — `whole` = 사본 `단가.json`(`M02_Item_Prices.load_prices`) ·
|
||||
`read` = 참조 → 굳힌 `참조사본` · `pills` = 사본 `알약`(가져온 때 것). 없으면 지금 M01 · 층."""
|
||||
whole = m01.loaded()[1] if whole is None else whole
|
||||
expand = refs.Expand(whole) if read is None else refs.Expand(whole, read)
|
||||
if pills is None:
|
||||
pills = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
|
||||
by_logic = {code: pill for pill in pills for code in pill.get("로직") or []}
|
||||
lines = []
|
||||
for one in doc.get("담은로직") or []:
|
||||
if _is_ref(one):
|
||||
|
||||
@@ -20,11 +20,7 @@ import type {
|
||||
SpreadsheetHandle,
|
||||
Workbook,
|
||||
} from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import {
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
type StructureDoc,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { readTemplate, saveTemplate, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
cardShell,
|
||||
helpButton,
|
||||
@@ -293,13 +289,14 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
if (!laborCard) return;
|
||||
laborEd?.destroy();
|
||||
laborEd = null;
|
||||
const layer = opts.source().layer;
|
||||
const src = opts.source();
|
||||
const layer = src.layer;
|
||||
let mod: typeof import("./M02_MasterTemplete_Labor_Detail");
|
||||
let got: Awaited<ReturnType<typeof readTemplate>>;
|
||||
let got: { 문서: unknown; 판?: string | null };
|
||||
try {
|
||||
[mod, got] = await Promise.all([
|
||||
import("./M02_MasterTemplete_Labor_Detail"),
|
||||
readTemplate(layer, "labor", at, null),
|
||||
src.labor ? src.labor.read(at) : readTemplate(layer, "labor", at, null),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (mine === seq) laborCard.shell.fail(fill(TEXT.noSheet, why(error)), reload);
|
||||
@@ -310,15 +307,24 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
const d = (got.문서 ?? {}) as { 일위대가번?: string };
|
||||
laborCard.no.textContent =
|
||||
d.일위대가번 || numberOf("labor", at) || fill(TEXT.noFieldNumber, PART_FIELD.labor);
|
||||
const via = src.labor; // 자료 자리 길(142-1 · 고르기 모달) — 셈 · 면적도 그 자리에서
|
||||
laborEd = mod.mountLaborDetail(laborHost, got.문서, {
|
||||
layer,
|
||||
column: at,
|
||||
readOnly: !opts.editable(),
|
||||
onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable()),
|
||||
readOnly: !laborWritable(),
|
||||
...(via
|
||||
? {
|
||||
calc: (doc, 수량) => via.calc(at, doc, 수량),
|
||||
readArea: () => src.read("area", at),
|
||||
}
|
||||
: {}),
|
||||
onChanged: (dirty) => laborCard.saver.mark(dirty && laborWritable()),
|
||||
goArea: null, // 면적 칸 잇기는 수량 모달 안(43-1) — 카드에서 화면을 옮기지 않음
|
||||
card: true,
|
||||
});
|
||||
};
|
||||
/** 일위대가 카드를 고칠 수 있나 — 쓰는 길(`write`)이 없는 자료 자리(고르기 모달)는 보기만(142-1) */
|
||||
const laborWritable = (): boolean => opts.editable() && !!opts.source().write;
|
||||
/** 카드 [저장] — 상세와 같은 차례: 고른 칸 연결(면적 문서) 먼저 → 일위대가(판을 다시 읽어 409 막음) */
|
||||
async function saveLabor(): Promise<void> {
|
||||
const at = column;
|
||||
@@ -349,7 +355,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
const sheetEdit = !!opts.sheetEdit?.can();
|
||||
for (const c of cards) {
|
||||
const can =
|
||||
c.kind === "structure" ? drawEdit : c.kind === "labor" ? opts.editable() : sheetEdit;
|
||||
c.kind === "structure" ? drawEdit : c.kind === "labor" ? laborWritable() : sheetEdit;
|
||||
c.saver.button.hidden = !can;
|
||||
c.edit.hidden = !opts.editable();
|
||||
c.take.hidden = !opts.editable() || !opts.onImport;
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
saveTemplate,
|
||||
type Layer,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api";
|
||||
import type { LaborCalc } from "./M02_MasterTemplete_Labor_Calc";
|
||||
|
||||
/** 집계표 열 하나에 딸린 문서 — 구조물 도면 · 면적산출 */
|
||||
export type ItemPart = "structure" | "area";
|
||||
@@ -23,6 +25,16 @@ export interface ItemSource {
|
||||
read: (part: ItemPart, column: string) => Promise<{ 문서: unknown; 판?: string | null }>;
|
||||
/** 카드 안 [저장](PLAN 31-4) — 전체 편집기와 같은 저장 길 · 없으면 카드는 보기만 */
|
||||
write?: (part: ItemPart, column: string, 판: string, 문서: unknown) => Promise<{ 판: string }>;
|
||||
/** 일위대가 카드 자리(142-1) — 읽기 · 셈(고르기 모달 = 프로젝트 복사본 · 원본 층) ·
|
||||
* 없으면 카드가 지금 층 길(`readTemplate` · `calcLabor`) · `write` 가 없으면 카드는 보기만 */
|
||||
labor?: {
|
||||
read: (column: string) => Promise<{ 문서: unknown; 판?: string | null }>;
|
||||
calc: (
|
||||
column: string,
|
||||
doc: LaborDoc,
|
||||
수량?: Record<string, number>,
|
||||
) => Promise<LaborCalc & { 알약: unknown[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
/** M02 화면의 자료 자리 — 부를 때의 지금 층(`getActiveLayer`) 그대로 */
|
||||
|
||||
@@ -88,6 +88,9 @@ export interface LaborDetailOptions {
|
||||
mode?: "precombo";
|
||||
/** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) — 없으면 페이지 층 것 */
|
||||
owner?: string | null;
|
||||
/** 셈 · 면적 자리 주입(142-1 · 고르기 모달 = 프로젝트 복사본) — 없으면 층 길 */
|
||||
calc?: (doc: LaborDoc, 수량?: Record<string, number>) => Promise<LaborCalc & { 알약: unknown[] }>;
|
||||
readArea?: () => Promise<{ 문서: unknown; 판?: string | null }>;
|
||||
}
|
||||
|
||||
export interface LaborDetailHandle {
|
||||
@@ -245,13 +248,10 @@ export function mountLaborDetail(
|
||||
draw();
|
||||
try {
|
||||
// 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음)
|
||||
const got = await calcLabor(
|
||||
opt.layer,
|
||||
opt.column,
|
||||
cur,
|
||||
areaDirty ? given() : undefined,
|
||||
opt.owner,
|
||||
);
|
||||
const qty = areaDirty ? given() : undefined;
|
||||
const got = opt.calc
|
||||
? await opt.calc(cur, qty)
|
||||
: await calcLabor(opt.layer, opt.column, cur, qty, opt.owner);
|
||||
if (!alive || mine !== seq) return;
|
||||
calc = last = got;
|
||||
pills = new Map(
|
||||
@@ -644,7 +644,7 @@ export function mountLaborDetail(
|
||||
lib // 서고 모드엔 면적산출 없음
|
||||
? undefined
|
||||
: Promise.all([
|
||||
readTemplate(opt.layer, "area", opt.column, null),
|
||||
opt.readArea ? opt.readArea() : readTemplate(opt.layer, "area", opt.column, null),
|
||||
import("../A00_Common/spreadsheet/spreadsheet_recalc"),
|
||||
]).then(
|
||||
([got, { recalcWorkbook }]) => {
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적은 서버 계산값
|
||||
POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림)
|
||||
POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감)
|
||||
POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력)
|
||||
일위대가 문서면 줄마다 수량 · 반영 + 자재 · 기준합 · 반영합
|
||||
POST …/items/{폴더}/calc 복사본만으로 계산 — 층 셈 한 벌(142-1 · 줄 · 자재 ·
|
||||
기준합 · 반영합 + 알약 · 빠진입력 · 빈 입력 = 자동값)
|
||||
종류 = 도면 · 면적 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) ·
|
||||
`refresh` = 옛 이름(재적용).
|
||||
⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404).
|
||||
|
||||
@@ -143,15 +143,18 @@ globalThis.fetch = async (url, init = {}) => {
|
||||
}
|
||||
if (part === "calc")
|
||||
return reply(200, {
|
||||
줄: [],
|
||||
계: 12345.6,
|
||||
줄: [
|
||||
{ 로직: "GA1", 까닭: "없는 요소" },
|
||||
{ 로직: "GA2", 까닭: null },
|
||||
],
|
||||
자재: [],
|
||||
빠진입력: [
|
||||
{ 로직: "GA1", 입력: "지역" },
|
||||
{ 로직: "GA2", 입력: "지역" },
|
||||
],
|
||||
멈춤: ["GA1"],
|
||||
기준합: { 계: 100 },
|
||||
반영합: { 계: 300 },
|
||||
알약: [],
|
||||
});
|
||||
if (method === "GET") {
|
||||
const content = item.parts[part] ?? null;
|
||||
@@ -246,10 +249,25 @@ async function main() {
|
||||
out.calc = await items.calc(info.폴더);
|
||||
out.calcMissing = await items.calc("없는폴더");
|
||||
|
||||
// 일위대가 — 복사본 요약(복사본 계산) · 원본 층 요약(층 calc) · 넣을 값 = 담은 로직 입력 전부
|
||||
out.laborCopy = await project.laborOf(row(), items, info.폴더);
|
||||
out.laborOrigin = await project.laborOf(row(), null, null);
|
||||
out.laborNone = await project.laborOf(row({ 열: "없는열" }), null, null);
|
||||
// 일위대가(142-1) — 모달 카드 셈 = 복사본 셈 답 통째 · 문서 = 복사본 입력값 · 고른자재를 얹음
|
||||
const raw = await items.calcLabor(info.폴더);
|
||||
out.laborCalc = { 줄: raw.줄.length, 반영합: raw.반영합, 빠진입력: raw.빠진입력.length };
|
||||
out.laborCalcMissing = await items.calcLabor("없는폴더").then(
|
||||
() => "성공",
|
||||
(error) => error.message,
|
||||
);
|
||||
out.withValues = project.withCopyValues(
|
||||
{
|
||||
일위대가번: "SU-000007",
|
||||
담은로직: [
|
||||
{ 로직: "GA1", 입력값: { 지역: 1 } },
|
||||
{ 로직: "GA2", 입력값: { 지역: 2 }, 고름: { 0: "MT1" } },
|
||||
{ 참조: { 층: "system", 주인: null, 키: "SC-000001" } },
|
||||
],
|
||||
},
|
||||
{ GA1: { 지역: { 값: 15, 확인: true }, 소량감률: { 값: "", 확인: false } } },
|
||||
{ GA1: { 0: "MT9" } },
|
||||
);
|
||||
const valueData = await project.laborValueData(["GA1", "GA2"], { GA1: {} });
|
||||
out.laborValues = {
|
||||
keys: state.logicKeys,
|
||||
|
||||
@@ -84,6 +84,11 @@ def _read(path: Path) -> Any:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _stuck(got: dict) -> list:
|
||||
"""못 돈 줄(까닭) — 층 셈 꼴(142-1)."""
|
||||
return [line for line in got["줄"] if line.get("까닭")]
|
||||
|
||||
|
||||
def _auto_values(root: Path) -> dict[str, Any]:
|
||||
"""담은 로직마다 자동값을 확인한 값으로 — 사용자가 다 채운 것과 같게."""
|
||||
labor = _read(root / "templates/structures" / FOLDER / "일위대가.json")
|
||||
@@ -168,12 +173,13 @@ def _run(master: Any, key: str, values: dict) -> str:
|
||||
# ── 계산 · 마스터 바뀜 · 새로 가져오기 ───────────────────
|
||||
|
||||
|
||||
def test_입력이_없으면_빠진_입력_목록(world):
|
||||
def test_입력이_없으면_빠진_입력_목록_셈은_자동값(world):
|
||||
"""142-1 — 빈 입력은 M02 층 셈과 같이 자동값으로 돎 · 빠진 입력은 「빈 값 n」 표시용 목록."""
|
||||
root = world["root"]
|
||||
_save(root, [_pipe()])
|
||||
got = copies.calc(root, FOLDER)
|
||||
assert got["계"] == 0 and len(got["멈춤"]) == 3
|
||||
assert {"로직": "GF000176", "입력": "관경"} in got["빠진입력"]
|
||||
assert _stuck(got) == [] and got["기준합"]["계"] > 0
|
||||
|
||||
|
||||
def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바뀜(world):
|
||||
@@ -181,7 +187,7 @@ def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바
|
||||
_save(root, [_pipe()])
|
||||
values = _fill_values(root)
|
||||
before = copies.calc(root, FOLDER)
|
||||
assert before["빠진입력"] == [] and before["멈춤"] == [] and before["계"] > 0
|
||||
assert before["빠진입력"] == [] and _stuck(before) == [] and before["기준합"]["계"] > 0
|
||||
files, whole = m01.loaded()
|
||||
ua = next(
|
||||
r
|
||||
@@ -192,7 +198,8 @@ def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바
|
||||
)
|
||||
given = {k: {n: v["값"] for n, v in one.items()} for k, one in values.items()}
|
||||
expected = prices.to_json(prices.mcb.run(whole, ua, given))
|
||||
assert before["계"] == pytest.approx(expected["계"]) and before["노무비"] > 0
|
||||
assert before["기준합"]["계"] == pytest.approx(expected["계"])
|
||||
assert before["기준합"]["노무비"] > 0
|
||||
labor_file = master_dir / "인력.json"
|
||||
original = labor_file.read_bytes()
|
||||
try:
|
||||
@@ -204,7 +211,8 @@ def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바
|
||||
assert copies.list_items(root)[0]["마스터바뀜"] is True
|
||||
copies.refresh(root, FOLDER)
|
||||
after = copies.calc(root, FOLDER)
|
||||
assert after["노무비"] > before["노무비"] and after["재료비"] == before["재료비"]
|
||||
assert after["기준합"]["노무비"] > before["기준합"]["노무비"]
|
||||
assert after["기준합"]["재료비"] == before["기준합"]["재료비"]
|
||||
assert copies.read_file(root, FOLDER, "입력값")["내용"] == values # 같은 키 살림
|
||||
assert copies.list_items(root)[0]["마스터바뀜"] is False
|
||||
finally:
|
||||
@@ -299,7 +307,7 @@ def test_옛_복사본은_읽을_때_채움(world):
|
||||
assert copies.read_file(root, FOLDER, "입력값")["내용"] == {}
|
||||
filled = _read(folder / "manifest.json")
|
||||
assert filled["가져온날"] == filled["적용일"] and filled["옮겨받음"]
|
||||
assert (folder / "단가.json").is_file() and copies.calc(root, FOLDER)["멈춤"]
|
||||
assert (folder / "단가.json").is_file() and copies.calc(root, FOLDER)["빠진입력"]
|
||||
assert copies.list_items(root)[0]["마스터바뀜"] is False # 원본이 안 바뀐 옛 복사본
|
||||
|
||||
|
||||
@@ -328,7 +336,7 @@ def test_길_권한_남의_프로젝트는_403(world, monkeypatch):
|
||||
stale = client.put(f"{base}/{FOLDER}/입력값", json={"판": got["판"] + "x", "내용": {}})
|
||||
assert stale.status_code == 409 and stale.json()["detail"]["판"] == put.json()["판"]
|
||||
assert client.get(f"{base}/{FOLDER}/모름").status_code == 422
|
||||
assert client.post(f"{base}/{FOLDER}/calc").json()["멈춤"]
|
||||
assert client.post(f"{base}/{FOLDER}/calc").json()["빠진입력"]
|
||||
wall = {"structure": "st9", "layer": "system", "owner": None}
|
||||
assert client.post(base, json={**wall, "summary_item": {"layer": "x"}}).status_code == 400
|
||||
assert client.post(base, json={"layer": "system", "key": "SS000022"}).status_code == 400
|
||||
@@ -388,8 +396,9 @@ def test_같은_항목_구조물_둘은_따로(world):
|
||||
assert copies.read_file(root, "a1", "면적")["내용"]["메모"] == "a1 만 고침"
|
||||
assert "메모" not in copies.read_file(root, "a2", "면적")["내용"] # 다른 쪽 그대로
|
||||
_fill_values(root) # pipe@40.0 에만 값
|
||||
assert copies.calc(root, FOLDER)["멈춤"] == [] and copies.calc(root, FOLDER)["계"] > 0
|
||||
assert len(copies.calc(root, "pipe@60.0")["멈춤"]) == 3 # 계산도 각자
|
||||
got = copies.calc(root, FOLDER)
|
||||
assert _stuck(got) == [] and got["기준합"]["계"] > 0
|
||||
assert copies.calc(root, "pipe@60.0")["빠진입력"] and not got["빠진입력"] # 계산도 각자
|
||||
flags = {i["폴더"]: i["고침"] for i in copies.list_items(root)}
|
||||
assert flags == {"a1": True, "a2": False, FOLDER: True, "pipe@60.0": False}
|
||||
|
||||
|
||||
@@ -82,7 +82,8 @@ def test_재적용은_덮임을_돌려주고_계산_한_줄(out):
|
||||
assert out["reapplyCall"][0] == "POST /api/projects/P1/items/pipe%4085.05/reapply"
|
||||
assert out["reapply"]["covered"] == ["면적"] # 덮이는 것을 알림으로
|
||||
assert out["reapply"]["info"]["마스터바뀜"] is False
|
||||
assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1, "base": 100, "reflected": 300}
|
||||
# 142-1 — 계 = 반영합 · 빈 값 = 빠진입력 · 못 돈 줄 = 까닭 있는 줄
|
||||
assert out["calc"] == {"total": 300, "gaps": 2, "stuck": 1, "base": 100, "reflected": 300}
|
||||
assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음
|
||||
|
||||
|
||||
@@ -140,28 +141,20 @@ def test_넣을_값_카드는_sub2_파일_한_벌(out):
|
||||
assert "M02_Labor_Values" in text and 'InputsCard"' not in text, name
|
||||
|
||||
|
||||
def test_일위대가_요약_복사본_원본_넣을_값_전부(out):
|
||||
"""39-1c — 복사본이면 복사본 일위대가 · 계산 · 아니면 원본 층 calc ·
|
||||
넣을 값 = 담은 로직 입력 전부."""
|
||||
assert out["laborCopy"] == {
|
||||
"번호": "SU-000007",
|
||||
"로직": ["GA1", "GA2"],
|
||||
"자재": 1,
|
||||
"base": 100,
|
||||
"reflected": 300,
|
||||
"gaps": 2,
|
||||
"stuck": 1,
|
||||
}
|
||||
assert out["laborOrigin"] == {
|
||||
"번호": None,
|
||||
"로직": ["GA1", "GA2"],
|
||||
"자재": 1,
|
||||
"base": 500,
|
||||
"reflected": 1500,
|
||||
"gaps": 0,
|
||||
"stuck": 1,
|
||||
}
|
||||
assert out["laborNone"] is None # 일위대가 없는 열(층 calc 404)
|
||||
def test_일위대가_카드_셈은_복사본_셈_답_통째_문서는_복사본_값(out):
|
||||
"""142-1 — 모달 일위대가 카드 = M02 카드 · 셈 = 복사본 셈(층 셈 꼴) · 문서 = 복사본 입력값 ·
|
||||
고른자재를 줄 입력값 · 고름으로(빈 값 = 자동값이라 안 얹음 · 서버 `with_values` 와 같은 규칙)."""
|
||||
assert out["laborCalc"] == {"줄": 2, "반영합": {"계": 300}, "빠진입력": 2}
|
||||
assert out["laborCalcMissing"] == "복사본이 없음" # 서버 까닭 그대로(카드 안 글)
|
||||
lines = out["withValues"]["담은로직"]
|
||||
assert lines[0] == {"로직": "GA1", "입력값": {"지역": 15}, "고름": {"0": "MT9"}}
|
||||
assert lines[1] == {
|
||||
"로직": "GA2",
|
||||
"입력값": {},
|
||||
"고름": {"0": "MT1"},
|
||||
} # 복사본에 값 없음 = 자동값
|
||||
assert lines[2] == {"참조": {"층": "system", "주인": None, "키": "SC-000001"}}
|
||||
assert out["withValues"]["일위대가번"] == "SU-000007"
|
||||
values = out["laborValues"]
|
||||
assert values["keys"] == ["GA1", "GA2"]
|
||||
assert values["logics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]]
|
||||
@@ -169,9 +162,27 @@ def test_일위대가_요약_복사본_원본_넣을_값_전부(out):
|
||||
|
||||
|
||||
def test_모달_카드_차례_도면_일위대가_면적():
|
||||
"""142-1 — 일위대가 카드 = M02 카드 한 벌(`createItemCards` 의 `labor`) · 옛 요약 카드 · 넣을 값
|
||||
카드 · 머리 옛 「일위대가 {조합 이름}」 줄 없음."""
|
||||
detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8")
|
||||
assert 'import("./ui_template_item_picker_labor")' in detail
|
||||
assert "cards.root.insertBefore(labor.root, cards.root.children[1]" in detail
|
||||
assert not (UI / "ui_template_item_picker_labor.ts").exists()
|
||||
assert "ui_template_item_picker_labor" not in detail
|
||||
assert 'labor: () => void edit("inputs"),' in detail # [수정] = 넣을 값 편집 화면
|
||||
assert "createInputsValueCard" not in detail and "extraCards" not in detail
|
||||
assert "folderLines" not in detail and "unitPrice" not in detail
|
||||
|
||||
|
||||
def test_모달_일위대가_카드_읽기_전용():
|
||||
"""142-1 — 모달 자료 자리엔 쓰는 길(`write`)이 없고 카드는 그때 보기만(원본 층 [저장]을 안 엶)."""
|
||||
detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8")
|
||||
body = detail[detail.index("const makeSource") : detail.index("const badge")]
|
||||
assert "write" not in body and "labor: {" in body and "items.calcLabor(at)" in body
|
||||
cards = (M02 / "M02_MasterTemplete_ItemCards.ts").read_text(encoding="utf-8")
|
||||
assert "const laborWritable = (): boolean => opts.editable() && !!opts.source().write;" in cards
|
||||
assert "readOnly: !laborWritable()," in cards
|
||||
assert 'c.kind === "labor" ? laborWritable()' in cards
|
||||
picker = (UI / "ui_template_item_picker.ts").read_text(encoding="utf-8")
|
||||
assert '"personal" : "system"' in picker # 처음 펼침 = 개인(없으면 마스터)
|
||||
|
||||
|
||||
def test_모달은_원본_층에_쓰는_길을_안_부름():
|
||||
|
||||
@@ -60,10 +60,8 @@ def test_실패는_카드_안_글과_다시_불러오기():
|
||||
assert "showToast" not in cards # 토스트 안 씀(저장 알림은 틀)
|
||||
assert "c.shell.fail(" in cards and "reload" in cards
|
||||
picker = (ROOT / "ui_template" / "ui_template_item_picker_detail.ts").read_text("utf-8")
|
||||
# 일위대가 카드 · 값 카드가 다시 불러오기 하나(retry)를 같이 씀(39-1c)
|
||||
assert "const retry = (): void => void paintLabor(row, seq);" in picker
|
||||
assert "inputs.set(null, why(error), retry);" in picker
|
||||
assert "const stops = [labor.loading(), inputs.loading()];" in picker
|
||||
# 모달 일위대가 카드도 M02 카드 틀 안 실패 글(142-1 · 옛 요약 · 값 카드 retry 없음)
|
||||
assert "paintLabor" not in picker and 'labor: () => void edit("inputs"),' in picker
|
||||
|
||||
|
||||
def test_일위대가_카드는_상세_카드_갈래():
|
||||
@@ -75,7 +73,8 @@ def test_일위대가_카드는_상세_카드_갈래():
|
||||
assert 'makeCard("labor", PART_WORD.labor, laborHost, () => saveLabor())' in cards
|
||||
save = cards[cards.index("async function saveLabor()") :]
|
||||
assert save.index("laborEd.saveArea()") < save.index('saveTemplate(layer, "labor"')
|
||||
assert "onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable())," in cards
|
||||
# 고칠 수 있음 = editable + 쓰는 길(142-1 · 고르기 모달은 보기만)
|
||||
assert "onChanged: (dirty) => laborCard.saver.mark(dirty && laborWritable())," in cards
|
||||
assert "Labor_View" not in cards # 한 줄 표(40-2) 안 씀
|
||||
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert "move: reorder," in detail # 85-3 — 카드도 순서(▲▼ · 끌기) · 빼기는 그대로
|
||||
|
||||
@@ -169,14 +169,15 @@ def test_계산_반영_합은_손셈_원본과_같음(world):
|
||||
_save(root)
|
||||
_fill_auto(root)
|
||||
got = copies.calc(root, FOLDER)
|
||||
assert got["멈춤"] == [], got["줄"]
|
||||
assert [line for line in got["줄"] if line.get("까닭")] == [], got["줄"]
|
||||
lines = {line["로직"]: line for line in got["줄"]}
|
||||
hand, area = lines[LOGICS[1]], lines[LOGICS[2]]
|
||||
assert (hand["수량"], hand["수량출처"]) == (HAND, "손")
|
||||
assert (area["수량"], area["수량출처"]) == (AREA_QTY, "칸")
|
||||
assert lines[LOGICS[0]]["수량출처"] == "없음" and lines[LOGICS[0]]["반영"] is None
|
||||
for line in (hand, area):
|
||||
expect = Decimal(str(line["계"])) * Decimal(str(line["수량"])) / Decimal(str(line["배"]))
|
||||
base = Decimal(str(line["기준"]["계"]))
|
||||
expect = base * Decimal(str(line["수량"])) / Decimal(str(line["배"]))
|
||||
assert Decimal(str(line["반영"]["계"])) == pytest.approx(expect)
|
||||
(mat,) = got["자재"]
|
||||
assert mat["코드"] == MATERIAL and mat["수량"] == MAT_QTY and mat["기준단가"]
|
||||
@@ -208,3 +209,21 @@ def test_일위대가_쓰기는_막힘(world):
|
||||
assert current["내용"]["줄"]["열"] == COLUMN
|
||||
with pytest.raises(ValueError, match="원본"):
|
||||
copies.write_file(root, FOLDER, "일위대가", current["판"], current["내용"])
|
||||
|
||||
|
||||
def test_같은_문서_같은_단가면_사본_셈은_층_셈(world):
|
||||
"""142-1 — 사본 셈 = 층 셈 한 벌(`M02_Labor_Doc.calc`) · 빈 입력은 둘 다 자동값 · 줄 꼴 같음."""
|
||||
root = world["root"]
|
||||
_save(root)
|
||||
got = copies.calc(root, FOLDER)
|
||||
given = {world["area_pill"]: AREA_QTY, labor_doc.material_key(MATERIAL): MAT_QTY}
|
||||
origin = labor_doc.calc(world["doc"], given)
|
||||
keys = ("로직", "알약", "분모", "배", "기준", "수량", "수량출처", "반영", "까닭")
|
||||
assert [{k: line.get(k) for k in keys} for line in got["줄"]] == [
|
||||
{k: line.get(k) for k in keys} for line in origin["줄"]
|
||||
]
|
||||
assert got["자재"] == origin["자재"]
|
||||
assert got["기준합"] == origin["기준합"] and got["반영합"] == origin["반영합"]
|
||||
assert got["반영합"]["계"] > 0
|
||||
assert {p["키"] for p in got["알약"]} == set(world["pills"]) # 사본 알약(가져온 때 것)
|
||||
assert got["빠진입력"] # 비운 입력은 목록으로(화면 「빈 값 n」)
|
||||
|
||||
@@ -78,9 +78,9 @@ def test_문서_계산과_프로젝트_사본_같음() -> None:
|
||||
got = prices.calc(copy, prices.to_json(prices.collect([CODE])), values, {})
|
||||
origin = labor.calc(doc, {})
|
||||
(line,) = got["줄"]
|
||||
assert line["계"] == pytest.approx(origin["줄"][0]["기준"]["계"])
|
||||
assert line["기준"]["계"] == pytest.approx(origin["줄"][0]["기준"]["계"]) # 층 셈 꼴(142-1)
|
||||
full = labor.calc({**doc, "담은로직": [{**doc["담은로직"][0], "켜쌓기율": None}]}, {})
|
||||
assert _d(line["계"]) == pytest.approx(_d(full["줄"][0]["기준"]["계"]) * Decimal("0.9"))
|
||||
assert _d(line["기준"]["계"]) == pytest.approx(_d(full["줄"][0]["기준"]["계"]) * Decimal("0.9"))
|
||||
|
||||
|
||||
def test_옛_단가승격도_율() -> None:
|
||||
|
||||
@@ -373,8 +373,8 @@ def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_
|
||||
(world["system"] / kind / f"{key}.json").unlink()
|
||||
got = prices.calc(rows, bundle, {}, {}, None)
|
||||
line = got["줄"][0]
|
||||
assert line["ok"] and line["참조"] == ref
|
||||
assert line["계"] == pytest.approx(live, rel=1e-9)
|
||||
assert not line["까닭"] and line["참조"] == ref # 층 셈 꼴(142-1)
|
||||
assert line["기준"]["계"] == pytest.approx(live, rel=1e-9)
|
||||
assert line["수량"] == 1 and line["반영"]["계"] == pytest.approx(live, rel=1e-9)
|
||||
|
||||
|
||||
@@ -458,7 +458,7 @@ def test_사전조합_로직_줄_섞임_합은_손셈_지우기_409_굳힘(world
|
||||
for key in (sc, sp): # 원본이 사라져도 굳힌 사본으로 같은 값
|
||||
(world["system"] / refs.kind_of(key) / f"{key}.json").unlink()
|
||||
got = prices.calc(rows, bundle, {}, {}, None)["줄"][0]
|
||||
assert got["계"] == pytest.approx(float(want), rel=1e-9)
|
||||
assert got["기준"]["계"] == pytest.approx(float(want), rel=1e-9)
|
||||
|
||||
|
||||
def test_사전조합이_자기_자신을_담으면_422_수량_기본_1(world):
|
||||
|
||||
@@ -245,7 +245,8 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
for (const id of pathIds(start, labels())) opened.add(id);
|
||||
chosen = start;
|
||||
} else {
|
||||
opened.add("system"); // 지정된 게 없으면 첫 그룹만 펼침
|
||||
// 지정된 게 없으면 개인(142-1 · M02 기본 층 134-4 와 같게) — 개인 항목이 없으면(회사 없음 등) 마스터
|
||||
opened.add(rows.some((row) => layerOf(row) === "personal") ? "personal" : "system");
|
||||
}
|
||||
paintTree();
|
||||
paintDetail();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_detail.ts
|
||||
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b · 39-1c) — 카드 = 도면 · 일위대가 · 면적산출 ·
|
||||
* 넣을 값. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `M02_Labor_Values`) + 일위대가 카드
|
||||
* (`ui_template_item_picker_labor`)를 늦게 받아 씀(B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`).
|
||||
* 넣을 값 = 일위대가 담은 로직 입력 전부(복사본이면 복사본 입력값).
|
||||
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b · 39-1c · 142-1) — 카드 = 도면 · 일위대가 · 면적산출.
|
||||
* 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · 일위대가 = `Labor_Detail` 카드 갈래)을 늦게 받아 씀
|
||||
* (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). 일위대가 카드 = 자료 자리 길(`ItemSource.labor`) ·
|
||||
* 읽기 전용(쓰는 길 없음) · 복사본이면 복사본 문서 + 복사본 입력값 · 고른자재 · 셈은 복사본 셈(층 셈 한 벌).
|
||||
* 넣을 값 고침 = 일위대가 카드 [수정] → 넣을 값 편집 화면.
|
||||
*
|
||||
* 자료 자리 — **그 구조물의 복사본**(폴더 = 구조물 id · 같은 항목을 고른 구조물도 복사본은 따로)을 읽고,
|
||||
* 그 구조물에 아직 없으면 **원본 층**을 읽음(읽기만). [수정] 은 복사본만 고침 — 아직이면 「이 구조물로 가져와 고침」
|
||||
@@ -14,14 +15,12 @@
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements";
|
||||
import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { calcLabor, type LaborDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
||||
import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards";
|
||||
import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource";
|
||||
import type { InputsValueHandle } from "../M02_MasterTemplete/M02_Labor_Values";
|
||||
import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model";
|
||||
import type { LaborCardHandle } from "./ui_template_item_picker_labor";
|
||||
import { askFields } from "./ui_template_item_picker_ask";
|
||||
import {
|
||||
folderLines,
|
||||
itemName,
|
||||
layerOf,
|
||||
pathOf,
|
||||
@@ -30,8 +29,6 @@ import {
|
||||
type PickerRow,
|
||||
} from "./ui_template_item_picker_data";
|
||||
import {
|
||||
laborOf,
|
||||
laborValueData,
|
||||
PART_OF,
|
||||
projectItems,
|
||||
sameItem,
|
||||
@@ -39,6 +36,7 @@ import {
|
||||
type CreatedItem,
|
||||
type ProjectItemInfo,
|
||||
type ProjectItems,
|
||||
withCopyValues,
|
||||
} from "./ui_template_item_picker_project";
|
||||
|
||||
/** 편집 화면을 여는 데 필요한 것 — 「넣을 값」 도 한 종류 */
|
||||
@@ -105,10 +103,9 @@ const TEXT = {
|
||||
empty: "{label} 을(를) 넣을 것",
|
||||
importFailed: "이 구조물로 가져오지 못함 — {value}",
|
||||
listFailed: "프로젝트 항목을 못 읽음 — {value}",
|
||||
calc: "복사본 계산 — 계 {total}원 · 빠진 입력 {gaps}개",
|
||||
calcStuck: " · 멈춘 로직 {stuck}개",
|
||||
calc: "복사본 계산 — 계 {total}원 · 빈 값 {gaps}개(자동값)",
|
||||
calcStuck: " · 못 돈 줄 {stuck}개",
|
||||
noCopy: "복사본에 없음",
|
||||
price: "일위대가",
|
||||
parts: {
|
||||
도면: "도면",
|
||||
면적: "면적산출",
|
||||
@@ -131,8 +128,6 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
|
||||
let seq = 0;
|
||||
let infos: ProjectItemInfo[] | null = null;
|
||||
let cards: ItemCardsHandle | null = null;
|
||||
let inputs: InputsValueHandle | null = null;
|
||||
let labor: LaborCardHandle | null = null;
|
||||
let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() };
|
||||
/** 이 구조물에 있는 복사본 — 고른 항목의 것일 때만(다른 항목의 것이면 `other`) */
|
||||
let info: ProjectItemInfo | null = null;
|
||||
@@ -149,7 +144,6 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
|
||||
const where = [opts.layerLabels[layerOf(row)], ...pathOf(row).slice(0, -1)]
|
||||
.filter(Boolean)
|
||||
.join(" › ");
|
||||
const pairs = folderLines(row).filter((line) => line.kind === "unitPrice");
|
||||
head.replaceChildren(
|
||||
el("h4", {
|
||||
className: "ui-item-picker__name",
|
||||
@@ -158,29 +152,46 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
|
||||
}),
|
||||
small(where),
|
||||
...(row.단위 ? [small(`${opts.unit} ${row.단위}`)] : []),
|
||||
...pairs
|
||||
.filter((line) => line.name || line.key)
|
||||
.map((line) => small(`${TEXT.price} ${line.name || line.key}`)),
|
||||
);
|
||||
};
|
||||
|
||||
/** 이 구조물 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) */
|
||||
const makeSource = (row: PickerRow): ItemSource =>
|
||||
info && opts.projectId
|
||||
? {
|
||||
layer: "project",
|
||||
projectId: opts.projectId,
|
||||
read: async (part) => {
|
||||
const doc = await items?.read(info!.폴더, PART_OF[part]);
|
||||
if (doc == null) throw new Error(TEXT.noCopy);
|
||||
return { 문서: doc };
|
||||
/** 이 구조물 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) ·
|
||||
* 쓰는 길(`write`)은 없음 — 카드는 보기만(원본 층 · 복사본 고침은 편집 화면 · 142-1) */
|
||||
const makeSource = (row: PickerRow): ItemSource => {
|
||||
if (info && opts.projectId && items) {
|
||||
const at = info.폴더;
|
||||
return {
|
||||
layer: "project",
|
||||
projectId: opts.projectId,
|
||||
read: async (part) => {
|
||||
const doc = await items.read(at, PART_OF[part]);
|
||||
if (doc == null) throw new Error(TEXT.noCopy);
|
||||
return { 문서: doc };
|
||||
},
|
||||
labor: {
|
||||
read: async () => {
|
||||
const [labor, values, picks] = await Promise.all([
|
||||
items.read(at, "일위대가") as Promise<{ 줄?: LaborDoc } | null>,
|
||||
items.read(at, "입력값") as Promise<ValueDoc | null>,
|
||||
items.read(at, "고른자재") as Promise<Record<string, unknown> | null>,
|
||||
]);
|
||||
return { 문서: labor?.줄 ? withCopyValues(labor.줄, values, picks) : {} };
|
||||
},
|
||||
}
|
||||
: {
|
||||
layer: layerOf(row),
|
||||
projectId: null,
|
||||
read: (part, column) => readTemplate(layerOf(row), part, column, null),
|
||||
};
|
||||
calc: () => items.calcLabor(at),
|
||||
},
|
||||
};
|
||||
}
|
||||
const layer = layerOf(row);
|
||||
return {
|
||||
layer,
|
||||
projectId: null,
|
||||
read: (part, column) => readTemplate(layer, part, column, null),
|
||||
labor: {
|
||||
read: (column) => readTemplate(layer, "labor", column, null),
|
||||
calc: (column, doc, 수량) => calcLabor(layer, column, doc, 수량, null),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const badge = (text: string, className = ""): HTMLElement =>
|
||||
el("span", { className: `ui-item-picker__badge ${className}`.trim(), text });
|
||||
@@ -293,46 +304,17 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
|
||||
opts.onEdit({ kind, row, info, items, projectId: opts.projectId });
|
||||
}
|
||||
|
||||
/** 일위대가 카드 · 넣을 값 카드 — 복사본이 있으면 복사본(값도) · 아니면 원본 층(39-1c) */
|
||||
async function paintLabor(row: PickerRow, mine: number): Promise<void> {
|
||||
if (!inputs || !labor) return;
|
||||
const stops = [labor.loading(), inputs.loading()]; // 카드 안 「불러오는 중」(M02 31-7)
|
||||
try {
|
||||
const copy = info && items ? info.폴더 : null;
|
||||
const summary = await laborOf(row, copy ? items : null, copy);
|
||||
const values = copy ? (((await items?.read(copy, "입력값")) as ValueDoc | null) ?? {}) : {};
|
||||
const data = summary ? await laborValueData(summary.로직, values) : null;
|
||||
stops.forEach((stop) => stop());
|
||||
if (mine !== seq) return;
|
||||
labor.set(summary);
|
||||
inputs.set(data);
|
||||
} catch (error) {
|
||||
stops.forEach((stop) => stop());
|
||||
if (mine !== seq) return;
|
||||
const retry = (): void => void paintLabor(row, seq);
|
||||
labor.set(null, why(error), retry);
|
||||
inputs.set(null, why(error), retry);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCards(): Promise<void> {
|
||||
if (cards && inputs && labor) return;
|
||||
const [cardModule, inputModule, laborModule] = await Promise.all([
|
||||
import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"),
|
||||
import("../M02_MasterTemplete/M02_Labor_Values"),
|
||||
import("./ui_template_item_picker_labor"),
|
||||
]);
|
||||
labor = laborModule.createLaborCard();
|
||||
inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") });
|
||||
if (cards) return;
|
||||
const cardModule = await import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards");
|
||||
// 차례 = 도면 · 일위대가 · 면적산출(M02 카드 한 벌) · 일위대가 [수정] = 넣을 값 편집 화면
|
||||
cards = cardModule.createItemCards({
|
||||
source: () => source,
|
||||
editable: () => canCopy,
|
||||
onEdit: (kind) => void edit(kind),
|
||||
warnOnFail: () => false,
|
||||
extraCards: [inputs.root],
|
||||
labor: () => void edit("inputs"),
|
||||
});
|
||||
// 차례 = 도면 · 일위대가 · 면적산출 — 일위대가 카드를 도면 카드 바로 뒤에
|
||||
cards.root.insertBefore(labor.root, cards.root.children[1] ?? null);
|
||||
cardsHost.replaceChildren(cards.root);
|
||||
}
|
||||
|
||||
@@ -344,8 +326,6 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
|
||||
status.hidden = true;
|
||||
cardsHost.replaceChildren();
|
||||
cards = null;
|
||||
inputs = null;
|
||||
labor = null;
|
||||
return;
|
||||
}
|
||||
paintHead(row);
|
||||
@@ -362,14 +342,12 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
|
||||
other = own && !info ? own : null;
|
||||
source = makeSource(row);
|
||||
await ensureCards();
|
||||
if (mine !== seq || !cards || !inputs || !labor) return;
|
||||
if (mine !== seq || !cards) return;
|
||||
const label = !info && canCopy ? TEXT.importEdit : null;
|
||||
cards.relabel({ structure: label, area: label });
|
||||
inputs.setEditLabel(label ?? "넣을 값 수정");
|
||||
cards.root.hidden = false;
|
||||
paintStatus(row, mine);
|
||||
void cards.load({ id: row.열, label: itemName(row) });
|
||||
void paintLabor(row, mine);
|
||||
})();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_labor.ts
|
||||
* 항목 고르기 모달 「일위대가」 카드(계획서 39-1c) — 담은 로직 수 · 자재 수 · 기준 합 · 반영 합 글.
|
||||
* 보기만 · 고치기는 원본 층 일위대가 상세. 일위대가 보기 부품(39-3 C)이 오면 몸을 그것으로 바꿈.
|
||||
* 자료 = `ui_template_item_picker_project.laborOf`(복사본이면 복사본 계산 · 아니면 원본 층 계산).
|
||||
* 상세(`ui_template_item_picker_detail.ts`)가 카드를 받을 때 함께 `import()`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./ui_template_elements";
|
||||
import { cardNote, cardShell } from "../M02_MasterTemplete/M02_MasterTemplete_CardShell";
|
||||
import type { LaborSummary } from "./ui_template_item_picker_project";
|
||||
|
||||
const TEXT = {
|
||||
title: "일위대가",
|
||||
none: "일위대가 없음",
|
||||
failed: "일위대가를 못 읽음 — {value}",
|
||||
logics: "단가산출 로직 {value}개",
|
||||
materials: "자재 {value}개",
|
||||
base: "기준 합 {value}원",
|
||||
reflected: "반영 합 {value}원",
|
||||
gaps: "빠진 입력 {value}개",
|
||||
stuck: "멈춘 로직 {value}개",
|
||||
};
|
||||
|
||||
const fill = (text: string, value: string | number): string =>
|
||||
text.replace("{value}", String(value));
|
||||
const won = (value: number | null): string =>
|
||||
value === null ? "—" : Math.round(value).toLocaleString();
|
||||
|
||||
export interface LaborCardHandle {
|
||||
root: HTMLElement;
|
||||
loading: () => () => void;
|
||||
/** `null` = 일위대가 없음 · `note` 가 있으면 못 읽은 까닭 */
|
||||
set: (summary: LaborSummary | null, note?: string, retry?: () => void) => void;
|
||||
}
|
||||
|
||||
export function createLaborCard(): LaborCardHandle {
|
||||
const number = el("span", { className: "m02-struct__no" });
|
||||
const body = el("div", { className: "m02-struct__sheet ui-item-picker__labor" });
|
||||
const shell = cardShell({ title: TEXT.title, sub: [number], body });
|
||||
const line = (text: string, className = ""): HTMLElement =>
|
||||
el("p", { className: `ui-item-picker__labor-line ${className}`.trim(), text });
|
||||
|
||||
return {
|
||||
root: shell.root,
|
||||
loading: () => shell.loading(),
|
||||
set: (summary, note, retry) => {
|
||||
number.textContent = summary?.번호 ?? "";
|
||||
if (note && retry) return shell.fail(fill(TEXT.failed, note), retry);
|
||||
if (!summary) return void body.replaceChildren(cardNote(TEXT.none));
|
||||
body.replaceChildren(
|
||||
line(`${fill(TEXT.logics, summary.로직.length)} · ${fill(TEXT.materials, summary.자재)}`),
|
||||
line(fill(TEXT.base, won(summary.base))),
|
||||
line(fill(TEXT.reflected, won(summary.reflected)), "is-strong"),
|
||||
...(summary.gaps ? [line(fill(TEXT.gaps, summary.gaps), "is-warn")] : []),
|
||||
...(summary.stuck ? [line(fill(TEXT.stuck, summary.stuck), "is-warn")] : []),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,13 +9,15 @@
|
||||
* GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409)
|
||||
* POST …/{폴더}/reapply 재적용 → {폴더, 가져온날, 원본판, 덮임: [부분]}
|
||||
* POST …/{폴더}/save-as {to, 경로, 이름} 개인에 저장 → {key, 열, 이름, summary_item}
|
||||
* POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력 · 기준합 · 반영합)
|
||||
* 일위대가(39-1c) — 복사본 `일위대가` 부분 · 원본 층은 `calcLabor` · 넣을 값 목록은 `fetchLogicInputs`
|
||||
* POST …/{폴더}/calc 복사본만으로 계산 — 층 셈 꼴(줄 · 자재 · 기준합 · 반영합) + 알약 · 빠진입력(142-1)
|
||||
* 일위대가(39-1c · 142-1) — 모달 일위대가 카드(M02 카드 · 읽기 전용)가 `calcLabor` 로 셈 · 넣을 값 목록은 `fetchLogicInputs`
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { ItemInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { calcLabor, fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
||||
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
||||
import type { LaborDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
||||
import type { LaborCalc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Calc";
|
||||
import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model";
|
||||
import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data";
|
||||
|
||||
@@ -63,7 +65,10 @@ export interface CreatedItem {
|
||||
summary_item: { layer: string; owner: string | null; key: string; name: string };
|
||||
}
|
||||
|
||||
/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 · 일위대가 기준합 · 반영합 계 */
|
||||
/** 복사본 셈 답(142-1) — 층 셈 꼴 + 사본 알약 · 빈 입력 목록(셈은 자동값으로 돎) */
|
||||
export type CopyCalc = LaborCalc & { 알약: unknown[]; 빠진입력?: unknown[] };
|
||||
|
||||
/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빈 입력 수 · 못 돈 줄 수 · 일위대가 기준합 · 반영합 계 */
|
||||
export interface ProjectCalc {
|
||||
total: number;
|
||||
gaps: number;
|
||||
@@ -127,6 +132,8 @@ export interface ProjectItems {
|
||||
) => Promise<CreatedItem>;
|
||||
/** 복사본만으로 계산 — 계산할 것이 없거나 못 하면 null */
|
||||
calc: (folder: string) => Promise<ProjectCalc | null>;
|
||||
/** 복사본 셈 답 통째 — 모달 일위대가 카드(Labor_Detail)가 씀 · 못 하면 서버 까닭 그대로 던짐 */
|
||||
calcLabor: (folder: string) => Promise<CopyCalc>;
|
||||
}
|
||||
|
||||
const clients = new Map<string, ProjectItems>();
|
||||
@@ -142,6 +149,12 @@ export function projectItems(projectId: string): ProjectItems {
|
||||
for (const key of [...versions.keys()]) if (key.startsWith(`${folder}/`)) versions.delete(key);
|
||||
};
|
||||
const at = (folder: string, part: string): string => `${base}/${enc(folder)}/${enc(part)}`;
|
||||
/** 복사본 셈(142-1) — 못 하면 서버 까닭 그대로 던짐 */
|
||||
const calcCopy = async (folder: string): Promise<CopyCalc> => {
|
||||
const reply = await call(`${base}/${enc(folder)}/calc`, post({}));
|
||||
if (reply.status !== 200) throw new Error(reasonOf(reply));
|
||||
return reply.body as CopyCalc;
|
||||
};
|
||||
|
||||
const client: ProjectItems = {
|
||||
list: async () => {
|
||||
@@ -213,78 +226,23 @@ export function projectItems(projectId: string): ProjectItems {
|
||||
return reply.body as CreatedItem;
|
||||
},
|
||||
calc: async (folder) => {
|
||||
const reply = await call(`${base}/${enc(folder)}/calc`, post({}));
|
||||
if (reply.status !== 200) return null;
|
||||
const got = reply.body as {
|
||||
계?: number;
|
||||
빠진입력?: unknown[];
|
||||
멈춤?: unknown[];
|
||||
기준합?: { 계?: number };
|
||||
반영합?: { 계?: number };
|
||||
};
|
||||
const got = await calcCopy(folder).catch(() => null);
|
||||
if (!got) return null;
|
||||
return {
|
||||
total: Number(got.계 ?? 0),
|
||||
total: Number(got.반영합?.계 ?? got.기준합?.계 ?? 0),
|
||||
gaps: got.빠진입력?.length ?? 0,
|
||||
stuck: got.멈춤?.length ?? 0,
|
||||
stuck: (got.줄 ?? []).filter((line) => line.까닭).length,
|
||||
base: got.기준합?.계 ?? null,
|
||||
reflected: got.반영합?.계 ?? null,
|
||||
};
|
||||
},
|
||||
calcLabor: (folder) => calcCopy(folder),
|
||||
};
|
||||
clients.set(projectId, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
/* ── 일위대가(39-1c) — 카드 요약 · 넣을 값 자료 ─────────────────────────────── */
|
||||
|
||||
/** 일위대가 카드에 보이는 것 — 복사본이면 복사본 계산 · 아니면 원본 층 계산(저장 안 함) */
|
||||
export interface LaborSummary {
|
||||
번호: string | null;
|
||||
/** 담은 로직 키(차례 그대로) — 넣을 값 목록을 받는 데도 씀 */
|
||||
로직: string[];
|
||||
자재: number;
|
||||
base: number | null;
|
||||
reflected: number | null;
|
||||
gaps: number;
|
||||
stuck: number;
|
||||
}
|
||||
|
||||
interface LaborCopy {
|
||||
줄?: { 일위대가번?: string; 담은로직?: { 로직?: string }[]; 자재?: unknown[] };
|
||||
}
|
||||
|
||||
/** 그 항목의 일위대가 — 복사본(`folder`)이 있으면 그것 · 없으면 원본 층 열 · 일위대가가 없으면 null */
|
||||
export async function laborOf(
|
||||
row: PickerRow,
|
||||
items: ProjectItems | null,
|
||||
folder: string | null,
|
||||
): Promise<LaborSummary | null> {
|
||||
if (items && folder) {
|
||||
const copy = (await items.read(folder, "일위대가")) as LaborCopy | null;
|
||||
if (!copy?.줄) return null;
|
||||
const got = await items.calc(folder);
|
||||
return {
|
||||
번호: copy.줄.일위대가번 ?? null,
|
||||
로직: (copy.줄.담은로직 ?? []).map((one) => String(one.로직 ?? "")).filter(Boolean),
|
||||
자재: copy.줄.자재?.length ?? 0,
|
||||
base: got?.base ?? null,
|
||||
reflected: got?.reflected ?? null,
|
||||
gaps: got?.gaps ?? 0,
|
||||
stuck: got?.stuck ?? 0,
|
||||
};
|
||||
}
|
||||
const got = await calcLabor(layerOf(row), row.열, null).catch(() => null);
|
||||
if (!got) return null;
|
||||
return {
|
||||
번호: null,
|
||||
로직: got.줄.map((line) => line.로직),
|
||||
자재: got.자재.length,
|
||||
base: got.기준합?.계 ?? null,
|
||||
reflected: got.반영합?.계 ?? null,
|
||||
gaps: 0,
|
||||
stuck: got.줄.filter((line) => line.까닭).length,
|
||||
};
|
||||
}
|
||||
/* ── 일위대가(39-1c) — 넣을 값 자료 ─────────────────────────────── */
|
||||
|
||||
/** 넣을 값 카드 자료 — 담은 로직 입력 전부 */
|
||||
export async function laborValueData(
|
||||
@@ -295,3 +253,29 @@ export async function laborValueData(
|
||||
const list: ItemInputs = { 일위대가: null, 로직: got.로직, 빠진로직: got.빠진로직 };
|
||||
return { list, values };
|
||||
}
|
||||
|
||||
/** 복사본 일위대가 문서에 복사본 입력값 · 고른자재를 줄 입력값 · 고름으로 얹음(보기용 · 142-1) —
|
||||
* 빈 값은 안 얹음(= 자동값) · 셈은 서버가 같은 규칙으로(`M02_Item_Prices.with_values`) */
|
||||
export function withCopyValues(
|
||||
doc: LaborDoc,
|
||||
values: ValueDoc | null,
|
||||
picks: Record<string, unknown> | null,
|
||||
): LaborDoc {
|
||||
const lines = (doc.담은로직 ?? []).map((one) => {
|
||||
const code = (one as { 로직?: string }).로직;
|
||||
if (!code) return one;
|
||||
const given = Object.fromEntries(
|
||||
Object.entries(values?.[code] ?? {}).flatMap(([name, got]) => {
|
||||
const v = (got as { 값?: unknown } | null)?.값;
|
||||
return v === null || v === undefined || v === "" ? [] : [[name, v]];
|
||||
}),
|
||||
);
|
||||
const pick = picks?.[code];
|
||||
return {
|
||||
...one,
|
||||
입력값: given,
|
||||
...(pick && typeof pick === "object" ? { 고름: pick } : {}),
|
||||
} as typeof one;
|
||||
});
|
||||
return { ...doc, 담은로직: lines };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user