Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-16 23:46:08 +09:00
10 changed files with 278 additions and 6 deletions
+4 -1
View File
@@ -92,6 +92,8 @@ def _pair(pair: dict, line: dict, ctx: Ctx) -> dict:
{"호표": pair.get("호표")},
)
conv = {"배": pair.get("배"), "갈래": "별칭"}
if "자재" in head: # 세 칸(361-2) = 만들기에 적힐 값(`Material.marks`)
head["세칸"] = hmat.marks(line, one["이름"])
return {**head, "이름": one["이름"], "단위": one["단위"], "입력값": vals, "못읽음": []} | {
"출처": {k: "별칭" for k in vals},
"환산": conv,
@@ -225,9 +227,10 @@ def _material(line: dict, ctx: Ctx, full: bool) -> dict:
def _mat_target(one: dict, line: dict, times: Any = None) -> dict:
"""자재 · 「전국」 자리표 → 대상(배 = 환산 · `times` 주면 그것)."""
"""자재 · 「전국」 자리표 → 대상(배 = 환산 · `times` 주면 그것 · 세칸 = 만들기에 적힐 세 칸)."""
conv = hm.factor(line.get("단위"), one["단위"], line.get("규격"))
target = {**hm._public(one), "입력값": {}, "못읽음": [], "출처": {}, "환산": conv}
target["세칸"] = hmat.marks(line, one["이름"])
ok = isinstance(times, (int, float)) and not isinstance(times, bool) and times > 0
return {**target, "몫": None, "배": times if ok else conv["배"]}
@@ -9,6 +9,7 @@ import { API_BASE_URL } from "@config/config_frontend";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import type { MatMarks } from "./M02_MasterTemplete_Labor_Api";
/** 층 · 주인(null = 자기 층) */
export interface HopyoScope {
@@ -119,6 +120,8 @@ export interface HopyoPart extends HopyoTarget {
배: number | null;
/** 블록 절 제목(조합 덧 로직) */
어디?: string;
/** 자재 세 칸(361-2) — 만들기에 적힐 값(서버 `Material.marks`) */
세칸?: MatMarks;
}
/** 품 묶음 — 직종 품 · 장비 시간 · 자재 수량 */
@@ -266,7 +266,7 @@ function matRow(line: HopyoLine, ctx: RowsContext): HTMLElement {
}),
qtyCell(line, parts),
el("td", { className: "m02-labrow__unit", text: p?.단위 ?? line.단위 }),
...markCells({ 별도계상: line.갈래 === "별도계상" ? "별도계상" : "직접계산" }),
...markCells(p?.세칸 ?? { 별도계상: line.갈래 === "별도계상" ? "별도계상" : "직접계산" }),
...PRICE_SLOTS.map((slot) =>
el("td", {
className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`,
@@ -146,6 +146,13 @@ export const MAT_MARKS = {
} as const;
export type MatMarkKey = keyof typeof MAT_MARKS;
export type MatMarks = { [K in MatMarkKey]?: (typeof MAT_MARKS)[K][number] };
/** 「전국」 자재(361-1 · `본뜬.전국자재` · 키 · 단가 없음 · 보기만 · `Labor_National`) */
export type NationalMat = MatMarks & {
이름: string;
규격: string;
단위: string;
수량: { 손: number };
};
/** 읽을 때 기본값(162-1 · 서버 `MARK_DEFAULT` 와 같음) — 저장은 비움 */
export const MAT_MARK_DEFAULT: Required<MatMarks> = {
자재구분: "사급",
@@ -182,7 +189,7 @@ export interface LaborDoc {
열: string;
일위대가번?: string;
단위: string | null;
본뜬?: { 층: string; 주인: string | null; 키: string } | null;
본뜬?: { 층: string; 주인: string | null; 키: string; 전국자재?: NationalMat[] } | null;
담은로직: LaborRow[];
자재: LaborMat[];
계산값?: unknown;
@@ -38,6 +38,7 @@ import {
type Layer,
} from "./M02_MasterTemplete_Api_Fetch";
import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick";
import { nationalRows } from "./M02_MasterTemplete_Labor_National";
import { lineId, settle, type LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import {
areaLinks,
@@ -241,6 +242,7 @@ export function mountLaborDetail(
},
edit ? { open: openMat, remove: removeMat, mark: markMat } : { open: openMat },
),
...nationalRows(cur.본뜬), // 361-2 — 전국 자재(보기만)
);
drawDg();
showSource();
@@ -0,0 +1,76 @@
/* =============================================================================
* M02_MasterTemplete_Labor_National.ts
* 「전국」 자재 줄(PLAN 361-2) — 실무자료 만들기가 `본뜬.전국자재` 에 적은 자재(키 · 단가 없음 · 361-1)를
* 상세 자재목록 아래 보기 전용 표로 · 이름 · 규격 · 수량 · 단위 · 세 칸(글만) · 금액 칸 비움 · 단추 없음.
* 없으면 아무것도 안 그림 · 셈 · 문서 꼴 · 저장 길은 그대로(보기만).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api";
import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks";
import { tc } from "./M02_MasterTemplete_Labor_Text";
const TEXT = {
Title: [
"전국 자재 — 지역은 프로젝트에서 · 금액 없음(보기만)",
"Nationwide materials — region set in the project · no price (view only)",
],
Code: ["전국", "Nationwide"],
Region: ["지역은 프로젝트에서", "Region set in the project"],
} as const;
const say = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
/** `본뜬.전국자재` 표 — 없으면 [] */
export function nationalRows(origin: LaborDoc["본뜬"]): HTMLElement[] {
const mats = origin?.전국자재 ?? [];
if (!mats.length) return [];
const heads = [
tc("Labor_Code"),
tc("Col_Name"),
tc("Labor_Spec"),
tc("Labor_QtyCol"),
tc("Labor_Unit"),
...markHeads(),
tc("Labor_AppliedSum"),
];
const rows = mats.map((m) =>
el("tr", {
className: "m02-labrow__row",
attrs: { "data-labor-national": m.이름 },
children: [
el("td", { className: "m02-labrow__code", text: say("Code") }),
el("td", {
className: "m02-labrow__name",
children: [
el("strong", { text: m.이름 }),
el("small", { className: "m02-labrow__note", text: say("Region") }),
],
}),
el("td", { className: "m02-labrow__spec", text: m.규격, attrs: { title: m.규격 } }),
el("td", { className: "m02-labrow__num", text: String(m.수량?.손 ?? "") }),
el("td", { className: "m02-labrow__unit", text: m.단위 }),
...markCells(m),
el("td", { className: "m02-labrow__num", text: "—" }),
],
}),
);
return [
el("p", { className: "m02-labrow__muted", text: say("Title") }),
el("div", {
className: "ui-general-block__table-wrap",
attrs: { "data-labor-national-table": "" },
children: [
el("table", {
className: "ui-general-block__table m02-labrow",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: rows }),
],
}),
],
}),
];
}
@@ -0,0 +1,57 @@
/* M02 「전국」 자재 줄(361-2) 헬퍼 — `Labor_National.nationalRows` 를 트랜스파일해 Node 로 돌림 ·
* `el` 대역 = 나무 꼴 {tag, className, text, attrs, children} · stdin = {origin} · stdout = 나무 JSON. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const el = (tag, o = {}) => ({ tag, ...o, children: o.children ?? [] });
const STUBS = {
"@ui/ui_template_elements": { el, createSelectField: () => ({ root: el("select") }) },
"@ui/ui_template_locale": { currentLanguageIndex: 0 },
"@config/config_frontend": { API_BASE_URL: "/api" },
};
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith(".") && !request.endsWith(".css"))
return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
return new Proxy({}, { get: () => () => ({}) });
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const national = loadTs(
path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_Labor_National.ts"),
);
const text = (n) => (n.text ?? "") + (n.children ?? []).map(text).join(" ");
const find = (n, tag) => [
...(n.tag === tag ? [n] : []),
...(n.children ?? []).flatMap((c) => find(c, tag)),
];
const out = input.origins.map((origin) => {
const got = national.nationalRows(origin);
const table = got.flatMap((n) => find(n, "table"))[0];
return {
count: got.length,
title: got[0]?.text ?? null,
heads: table ? find(table, "th").map((h) => h.text) : [],
rows: table
? find(table, "tr")
.slice(1)
.map((r) => r.children.map((c) => text(c).trim()))
: [],
buttons: got.flatMap((n) => [...find(n, "button"), ...find(n, "select")]).length,
};
});
process.stdout.write(JSON.stringify(out));
+2 -1
View File
@@ -117,7 +117,7 @@ def test_새_상태_체크_제안(client):
assert plywood["상태"] == "맞음" and set(plywood["갈리는칸"]) == {"측압", "폼타이규격"}
mat = first["레미콘(25-21-120)"] # 358-2 · 361-1 — 「별도계상」 표시 · 지역 변형만 = 전국
assert mat["상태"] == "맞음" and mat["체크"] is True and "별도계상" in mat["꼬리표"]
assert mat["대상"][0] | {"환산": None} == {
assert mat["대상"][0] | {"환산": None, "세칸": None} == {
"전국": True,
"이름": "레미콘(전국)-관급",
"규격": "25-21-120",
@@ -126,6 +126,7 @@ def test_새_상태_체크_제안(client):
"못읽음": [],
"출처": {},
"환산": None,
"세칸": None,
"몫": None,
"배": 1,
} # 특정 지역 키 없음
+3 -2
View File
@@ -357,7 +357,7 @@ def test_좌측_판_자리와_권한():
def test_로직_추가_한_곳과_파일_규칙():
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
assert len(detail.splitlines()) == 693 # 줄 수 그대로
assert len(detail.splitlines()) == 695 # 줄 수 그대로(361-2 전국 자재 부르는 줄 둘)
assert "withHopyo(library)," in detail
pick = _text("M02_MasterTemplete_Labor_Pick.ts")
assert "more?: () => Promise<PickExtra[]>;" in pick and "...later" in pick
@@ -485,7 +485,8 @@ def test_확인_창_표_일위대가_상세_꼴_359():
for word in ('"고르기"', '"원문 보기"', '"엑셀 보기"', '"옵션"', '"품 비교"'):
assert word in rows, word
assert 'import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";' in rows
assert "...markCells({ 별도계상:" in rows and "...markHeads()," in rows
assert "...markCells(p?.세칸 ?? { 별도계상:" in rows # 361-2 서버 세 칸 먼저
assert "...markHeads()," in rows
for head in ("Col_Order", "Sub", "Col_Name", "Col_Alias", "Labor_QtyCol", "Col_Unit"):
assert f'tc("{head}")' in rows, head
check = _text("M02_MasterTemplete_Hopyo_Check.ts")
+122
View File
@@ -0,0 +1,122 @@
"""「전국」 자재 줄(PLAN 361-2 · `M02_MasterTemplete_Labor_National`) — 사전조합 `본뜬.전국자재` 를
상세 자재목록 아래 보기 전용 표로(있으면 줄 · 없으면 안 보임 · 단추 없음 · 금액 칸 비움 ·
글 명사형) · 읽기 길(사전조합 GET 의 `layers.read_template`)이 `본뜬.전국자재` 를 그대로 줌 ·
확인 창 세 칸 = 만들기에 적힐 값(`세칸`).
층은 임시 폴더.
"""
import json
import os
import re
import subprocess
from pathlib import Path
# 올리기 길 시험의 `client` 마당을 같이 씀
# ruff: noqa: F811
from resources.tester.test_m02_hopyo_build import _build
from resources.tester.test_m02_hopyo_excel import URL, _post, client # noqa: F401
HERE = os.path.dirname(os.path.abspath(__file__))
M02 = Path(HERE).parents[1] / "M02_MasterTemplete"
REMICON = {
"이름": "레미콘(전국)-관급",
"규격": "25-21-120",
"단위": "㎥",
"수량": {"손": 2.5},
"별도계상": "별도계상",
"자재구분": "관급",
}
def _rows(origins: list) -> list:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_labor_national.cjs")],
input=json.dumps({"origins": origins}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_전국_자재_줄_있는_것_없는_것():
have, plain, empty, none = _rows(
[
{"층": "personal", "주인": "7/42", "키": "HP-000001 제1호표", "전국자재": [REMICON]},
{"층": "personal", "주인": "7/42", "키": "HP-000001 제3호표"},
{"층": "personal", "주인": None, "키": "k", "전국자재": []},
None,
]
)
assert have["title"] == "전국 자재 — 지역은 프로젝트에서 · 금액 없음(보기만)"
assert have["heads"] == [
"코드",
"이름",
"규격",
"수량산출",
"단위",
"자재구분",
"별도계상",
"운반비",
"반영 금액",
]
assert have["rows"] == [
[
"전국",
"레미콘(전국)-관급 지역은 프로젝트에서",
"25-21-120",
"2.5",
"㎥",
"관급",
"별도계상",
"포함", # 빈 칸 = 기본값 글
"—", # 금액 칸 비움
]
]
assert have["buttons"] == 0 # 고침 · 삭제 없음(보기만)
assert plain["count"] == empty["count"] == none["count"] == 0 # 없으면 안 보임
def test_소스_보기만_명사형_자리():
code = (M02 / "M02_MasterTemplete_Labor_National.ts").read_text(encoding="utf-8")
words = re.findall(r'"([^"]*[가-힣][^"]*)"', code)
assert words and not [w for w in words if re.search(r"(합니다|해요|한다|하세요)", w)]
assert "createButton" not in code and "fetch" not in code and "sessionStorage" not in code
detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text(encoding="utf-8")
assert "...nationalRows(cur.본뜬)" in detail and len(detail.splitlines()) <= 700
api = (M02 / "M02_MasterTemplete_Labor_Api.ts").read_text(encoding="utf-8")
assert "전국자재?: NationalMat[]" in api
def test_만든_사전조합_읽기_길에_전국자재(client):
from M02_MasterTemplete import M02_Template_Layers as layers
assert _post(client).status_code == 200
got = _build(client, 1, [{"차례": 0, "대상": [{"전국": True, "배": 1}]}])
key = got["참조"]["키"]
found = layers.read_template(layers.personal_dir(7, 42), "precombo", key)
doc = found.get("문서") or found
assert doc["본뜬"]["전국자재"][0] | {"수량": None} == REMICON | {"수량": None}
assert doc["자재"] == [] # 자재 칸(M01 키만)엔 없음 — 셈 · 문서 꼴 그대로
def test_확인_창_세_칸_만들기_값(client):
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Template_Layers as layers
assert _post(client).status_code == 200
fit.run_job(layers.personal_dir(7, 42), "HP-000001")
lines = client.get(f"{URL.format('personal')}/HP-000001/1").json()["호표"]["줄"]
at = next(x["차례"] for x in lines if x["이름"] == "레미콘(25-21-120)")
mat = lines[at]["맞춤"]["대상"][0]
assert mat["세칸"] == {
"별도계상": "별도계상",
"자재구분": "관급",
} # 전국-관급 = 관급(옛 「사급」)
path = f"{URL.format('personal')}/HP-000001/1/guess"
seoul = client.post(path, json={"차례": at, "대상": [{"자재": "MT004258", "배": 1}]}).json()
assert seoul["대상"][0]["세칸"]["자재구분"] == "사급" # 고른 자재 이름(-사급) 먼저
rows = (M02 / "M02_MasterTemplete_Hopyo_CheckRows.ts").read_text(encoding="utf-8")
assert "...markCells(p?.세칸 ??" in rows