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:
2026-10-09 15:15:19 +09:00
co-authored by Claude Opus 5.5
parent 099d964d99
commit 2fccd7c48a
17 changed files with 305 additions and 381 deletions
+39 -100
View File
@@ -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 {}),
}
+17 -9
View File
@@ -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).