feat(M02): 자재목록 세 칸 드롭다운 — 자재구분 · 별도계상 · 운반비 · 자재 추가 단추 덜어 냄(148-4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
This commit is contained in:
2026-10-09 19:08:55 +09:00
co-authored by Claude Opus 5.5
parent 257b677fa0
commit d205866f54
11 changed files with 342 additions and 30 deletions
+33 -2
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` 허용 값만(그 밖 · 빈 것 버림 · 금액 무관) — 사용자 자재 = 줄 덧칸 ·
로직 자재 = 로직 줄 `자재표시 {호표 차례: {세 칸}}`(차례가 재료 줄인 것만 · 틀리면 버림).
"""
from __future__ import annotations
@@ -38,6 +42,11 @@ mm = mf.mp.mm
COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비
MATERIAL_HEAD = "자재|"
_MAT_COLS = ("코드", "이름", "규격", "단위")
MARKS = {
"자재구분": ("일반", "관급", "사급"),
"별도계상": ("별도계상", "직접계산"),
"운반비": ("포함", "미포함"),
}
ALIAS_MAX = 40 # 별칭(44-6) 글자 수 상한 — 화면 칸과 같음
_BRACE = re.compile(r"\{([^{}]+)\}") # 품목 조건의 {입력}
@@ -126,6 +135,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()
@@ -146,6 +158,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 = {
@@ -193,6 +206,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 == "":
@@ -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";
@@ -228,10 +230,18 @@ export function mountLaborDetail(
cur.담은로직,
calc ?? last,
!calc && !fail,
edit ? { open: openRow, swap: swapMat, reset: resetMat } : { open: openRow },
// 세 칸(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();
@@ -376,6 +386,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();
@@ -492,24 +511,13 @@ 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();
});
// 단위 = 표준 단위 목록에서 고르기(지금 값이 목록에 없어도 선택지로 둠) · 저장은 [저장]
@@ -12,8 +12,9 @@ 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";
@@ -25,6 +26,8 @@ export interface LogicMatHandlers {
swap?: (row: number, at: number, key: string) => 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 =>
@@ -141,6 +144,10 @@ 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,
@@ -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.자재표시;
}
@@ -22,8 +22,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";
@@ -213,6 +215,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) — 고칠 수 있으면 칸 안 글상자(누르면 바로 고침 · 줄 누름과 따로) · 아니면 글 · 비면 빈 칸 */
@@ -590,6 +594,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
@@ -620,6 +625,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")] : []),
@@ -120,6 +120,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"],
+6 -1
View File
@@ -1,10 +1,11 @@
/* =============================================================================
* M02_Material_Pick.ts
* M01 자재 하나 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(일위대가 자재목록 · 자재 추가가 씀).
* · 일위대가 [자재 추가] 단추(`addMaterialButton` · 148 에서 `Labor_Detail` 이 덜어 냄)
* 모양(`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";
@@ -75,3 +76,7 @@ export function pickMaterial(done: (item: MaterialItem) => void): void {
},
});
}
/** 일위대가 [자재 추가] 단추 — 누르면 고르기 창 · 고른 줄로 `done`(막을지는 부르는 쪽) */
export const addMaterialButton = (label: string, done: (item: MaterialItem) => void): HTMLElement =>
createButton({ label, variant: "filled", onClick: () => pickMaterial(done) });
@@ -170,8 +170,9 @@ def _src(name):
def test_로직_자재_줄_누름은_그_로직_옵션_모달_사용자_줄은_그대로():
"""148-1 — 고칠 때 · 읽기만 둘 다 `open: openRow` · 바꾸기 단추 칸은 swap 있을 때만 · 사용자 줄 = openMat."""
detail = _src("M02_MasterTemplete_Labor_Detail.ts")
assert "edit ? { open: openRow, swap: swapMat, reset: resetMat } : { open: openRow }" in detail
assert "edit ? { open: openMat, remove: removeMat } : { open: openMat }" in detail
assert "open: openRow,\n swap: swapMat," in detail
assert ": { open: openRow }," in detail
assert "{ open: openMat, remove: removeMat, mark: markMat } : { open: openMat }" in detail
mats = _src("M02_MasterTemplete_Labor_LogicMats.ts")
assert "const tools = swap" in mats
assert 'tr.addEventListener("click", () => on.open(r))' in mats
@@ -0,0 +1,130 @@
"""일위대가 자재목록 세 칸(PLAN 148-4) — 자재구분 · 별도계상 · 운반비 · 금액 무관.
- 서버 `clean` = 허용 값만(그 밖 · 빈 것 버림) · 사용자 자재 = 줄 덧칸 ·
로직 자재 = 로직 줄 `자재표시 {호표 차례: 세 칸}`(차례가 재료 줄인 것만 · 틀려도 400 아님)
- 기준합 · 반영합 그대로 · 저장 왕복에 남음 · 화면 `normalizeLabor` 도 같은 규칙(Node 헬퍼)
- 화면 칸 부품 = 새 파일 · 사용자 줄 · 로직 줄 같은 칸 · 사전조합 줄엔 안 넘김(소스 검사)
"""
import json
import os
import subprocess
import pytest
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_m02_structure import write_sample_table
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(HERE, "..", "..", "M02_MasterTemplete")
NET = "GC000337" # 낙석방지망 — 재료 줄 3 · 4 · 5
WIRE = "MT000194"
MARKS = {"자재구분": "관급", "별도계상": "별도계상", "운반비": "미포함"}
def _doc(**extra):
return {"열": "rv", "담은로직": [], "자재": [], **extra}
def _first_non_material(code):
from M01_MasterData import M01_MasterData_Store as m01
rows = m01.loaded()[1].logic(code).get("호표") or []
return next(n for n, r in enumerate(rows) if r.get("종류") != "재료")
def test_사용자_자재_허용_값만():
got = labor.clean(
_doc(자재=[{"코드": WIRE, **MARKS}, {"코드": "MT000008", "자재구분": "아무", "운반비": ""}])
)["자재"]
assert {k: got[0][k] for k in MARKS} == MARKS
assert not set(MARKS) & set(got[1])
def test_로직_자재_자재표시_재료_줄_차례만_허용_값만():
other = _first_non_material(NET)
shown = {"3": MARKS, "4": {"자재구분": "엉뚱"}, str(other): MARKS, "99": MARKS, "x": MARKS}
line = labor.clean(_doc(담은로직=[{"로직": NET, "자재표시": shown}]))["담은로직"][0]
assert line["자재표시"] == {"3": MARKS}
bare = labor.clean(_doc(담은로직=[{"로직": NET, "자재표시": {"4": {"운반비": "?"}}}]))
assert "자재표시" not in bare["담은로직"][0]
def test_금액_그대로():
plain = _doc(
담은로직=[{"로직": NET, "수량": {"손": 2}}], 자재=[{"코드": WIRE, "수량": {"손": 3}}]
)
marked = _doc(
담은로직=[{"로직": NET, "수량": {"손": 2}, "자재표시": {"3": MARKS}}],
자재=[{"코드": WIRE, "수량": {"손": 3}, **MARKS}],
)
a, b = (labor.calc(labor.clean(d), {}) for d in (plain, marked))
assert a["기준합"] == b["기준합"] and a["반영합"] == b["반영합"]
@pytest.fixture()
def system(tmp_path, monkeypatch):
write_sample_table(tmp_path)
monkeypatch.setattr(store, "FOLDER", tmp_path)
store.create_structure("rv")
return tmp_path
def test_저장_왕복에_남음(system):
have = store.read("labor", "rv")
sent = {
**have["문서"],
"담은로직": [{"로직": NET, "수량": None, "자재표시": {"3": MARKS}}],
"자재": [{"코드": "MT000008", "수량": None, "자재구분": "사급"}],
}
store.write("labor", "rv", have["판"], sent)
doc = store.read("labor", "rv")["문서"]
assert doc["담은로직"][0]["자재표시"] == {"3": MARKS}
assert doc["자재"][0]["자재구분"] == "사급" and "운반비" not in doc["자재"][0]
def test_화면_정리도_허용_값만():
doc = {
"담은로직": [
{"로직": NET, "자재표시": {"3": MARKS, "4": {"운반비": "?"}}},
{
"로직": "",
"참조": {"층": "system", "주인": None, "키": "SC-1"},
"자재표시": {"3": MARKS},
},
],
"자재": [{"코드": WIRE, "별도계상": "직접계산", "자재구분": "?"}],
}
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_labor_normalize.cjs")],
input=json.dumps({"doc": doc, "column": "rv"}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
got = json.loads(proc.stdout)
assert [r.get("자재표시") for r in got["담은로직"]] == [{"3": MARKS}, None]
assert got["자재"][0]["별도계상"] == "직접계산" and "자재구분" not in got["자재"][0]
def _src(name):
with open(os.path.join(SRC, name), encoding="utf-8") as f:
return f.read()
def test_화면_칸_부품_사용자_줄_로직_줄_같은_칸():
marks = _src("M02_MasterTemplete_Labor_MatMarks.ts")
assert '{ value: "", text: "—" }' in marks
assert "e.stopPropagation()" in marks # 줄 누름과 따로
rows = _src("M02_MasterTemplete_Labor_Rows.ts")
assert "...markCells(mat, on?.mark" in rows and "...markHeads()," in rows
assert "...markCells(" in _src("M02_MasterTemplete_Labor_LogicMats.ts")
detail = _src("M02_MasterTemplete_Labor_Detail.ts")
assert "mark: lib ? undefined : markLogicMat" in detail
assert "mark: markMat" in detail
# 서버 허용 값 = 화면 허용 값
api = _src("M02_MasterTemplete_Labor_Api.ts")
for key, allowed in labor.MARKS.items():
assert f"{key}: [{', '.join(json.dumps(v, ensure_ascii=False) for v in allowed)}]" in api