Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_4' into sub_desktop_1

This commit is contained in:
2026-09-21 23:36:42 +09:00
7 changed files with 202 additions and 29 deletions
@@ -9,13 +9,16 @@ import { createButton, el } from "@ui/ui_template_elements";
import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import {
explain,
loadTable,
matchRow,
relatedFinds,
type RelatedFind,
type TableRow,
} from "./M01_MasterData_UI_Logic_Note";
import { pillBar, type Pill } from "./M01_MasterData_UI_LogicLab_Pill";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import "./M01_MasterData_UI_LogicLab_Pill.css";
export interface ModalTarget {
title: string;
@@ -87,6 +90,9 @@ function tableView(
});
}
const groupOf = (ref: string): string =>
ref.startsWith("LB") ? "인력" : ref.startsWith("M") ? "재료" : ref.startsWith("EQ") ? "기계" : "";
function priceView(ref: string, brief: ElementBrief): HTMLElement {
const cols = Object.entries(brief. ?? {});
return el("div", {
@@ -134,38 +140,59 @@ export function openMaterialModal(target: ModalTarget): void {
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
const parts: HTMLElement[] = [
el("p", { className: "m01-logic__muted", text: tl("Modal_Qty") }),
el("pre", { className: "m01lab__expr", text: target.expr }),
];
const finds = relatedFinds(target.expr, target.middles);
const price = target.element ? target.prices[target.element] : undefined;
if (target.element?.startsWith("로직(")) {
parts.push(el("p", { text: `${tl("Modal_Logic")} · ${target.element}` }));
} else if (price) {
parts.push(
el("p", { className: "m01-logic__muted", text: tl("Modal_Price") }),
priceView(target.element as string, price),
);
const base = target.element?.split(".")[0] ?? "";
const price = target.element ? (target.prices[target.element] ?? target.prices[base]) : undefined;
const logicRef = target.element?.startsWith("로직(") ? target.element : "";
const pills: Pill[] = [];
if (price && target.element) {
pills.push({
label: `${groupOf(base)} ${price. ?? base}`.trim(),
view: () => priceView(target.element as string, price),
});
} else if (target.element && !logicRef) {
pills.push({
label: `${groupOf(base)} ${target.title}`.trim(),
view: () => el("p", { text: target.element }),
});
}
const tables = el("div", { className: "m01lab__tables" });
parts.push(tables);
if (!finds.length && !price && !target.element?.startsWith("로직(")) {
parts.push(el("p", { className: "m01-logic__muted", text: tl("Modal_NoData") }));
if (logicRef) {
pills.push({
label: `${tl("Pill_Logic")} ${logicRef.slice(3).split(",")[0].replace(/\)$/, "")}`,
view: () => el("p", { text: `${tl("Modal_Logic")} · ${logicRef}` }),
});
}
for (const f of finds) {
pills.push({
label: `${tl("Pill_Table")} ${f.find.table}`,
view: async () => {
const table = await loadTable(f.find.table);
return table
? tableView(f, table, target.values)
: el("p", { className: "m01-logic__bad", text: `${f.find.table} ?` });
},
});
}
const unit = price ? `${tl("Modal_UnitPrice")} ${formatNumber(price.)} × ` : "";
const parts: HTMLElement[] = [
el("p", { className: "m01-logic__muted", text: tl("Modal_Sum") }),
el("p", {
className: "m01lab__sum",
text: `${target.title} = ${unit}${explain(target.expr)}`,
}),
...(pills.length
? [el("p", { className: "m01-logic__muted", text: tl("Pill_Hint") }), pillBar(pills)]
: [el("p", { className: "m01-logic__muted", text: tl("Modal_NoData") })]),
el("details", {
className: "m01lab__raw",
children: [
el("summary", { text: tl("Modal_Qty") }),
el("pre", { className: "m01lab__expr", text: target.expr }),
],
}),
];
body.replaceChildren(...parts);
document.body.append(backdrop);
dialog.tabIndex = -1;
dialog.focus();
void Promise.all(finds.map(async (f) => ({ f, table: await loadTable(f.find.table) }))).then(
(got) =>
tables.replaceChildren(
...got.map(({ f, table }) =>
table
? tableView(f, table, target.values)
: el("p", { className: "m01-logic__bad", text: `${f.find.table} ?` }),
),
),
);
}
@@ -0,0 +1,35 @@
/* M01 로직 개선 시험 — 줄 모달의 알약 단추 */
.m01lab__pills {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m01lab__pill-row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.m01lab__pill {
padding: var(--spacing-4) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-bg);
color: var(--color-text);
font: inherit;
cursor: pointer;
max-width: 100%;
overflow-wrap: anywhere;
}
.m01lab__pill.is-open {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-bg);
}
.m01lab__pill-stage {
min-width: 0;
}
@@ -0,0 +1,44 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Pill.ts
* 줄 모달의 알약 단추 — 쓰인 자료를 알약으로만 늘어놓고, 누른 것 하나만 펼침(다시 누르면 접힘)
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
export interface Pill {
/** 알약 글 — 보기 「표 QF000421」 · 「인력 석공」 */
label: string;
/** 펼칠 때 그림 — 표처럼 읽어 와야 하면 Promise */
view: () => HTMLElement | Promise<HTMLElement>;
}
export function pillBar(pills: Pill[]): HTMLElement {
const stage = el("div", { className: "m01lab__pill-stage" });
const buttons: HTMLButtonElement[] = [];
let open = -1;
let turn = 0;
const show = async (i: number): Promise<void> => {
const mine = ++turn;
open = open === i ? -1 : i;
buttons.forEach((b, k) => {
b.setAttribute("aria-expanded", String(k === open));
b.classList.toggle("is-open", k === open);
});
if (open < 0) return stage.replaceChildren();
const view = await pills[open].view();
if (mine === turn) stage.replaceChildren(view); // 빨리 눌러도 마지막 것만
};
pills.forEach((p, i) => {
const b = el("button", {
className: "m01lab__pill",
text: p.label,
attrs: { type: "button", "aria-expanded": "false", "data-pill": p.label },
});
b.addEventListener("click", () => void show(i));
buttons.push(b);
});
return el("div", {
className: "m01lab__pills",
children: [el("div", { className: "m01lab__pill-row", children: buttons }), stage],
});
}
@@ -45,6 +45,11 @@ const TEXT = {
],
Click_Hint: ["줄을 누르면 그 줄이 쓰는 자료가 뜸", "Click a line to see the data it uses"],
Modal_Close: ["닫기", "Close"],
Modal_Sum: ["한 줄 풀이", "In one line"],
Pill_Hint: ["쓰인 자료 — 누르면 펼침", "Data used — tap to open"],
Modal_UnitPrice: ["단가", "Unit price"],
Pill_Table: ["표", "Table"],
Pill_Logic: ["로직", "Logic"],
Modal_Qty: ["수량 식", "Quantity formula"],
Modal_Price: ["단가 자료", "Price source"],
Modal_Value: ["값", "Value"],
@@ -28,6 +28,7 @@
- `MP000006` 자갈 1줄(GF000183)
연결이 `{이름, 지역=입력}`·`{이름, 계약종별=입력}` 이라 **줄마다 값이 입력(자재지역·전력 계약종별)에 따라 달라짐** — 대표 줄 하나로 굳히면 지역이 바뀔 때 틀린 값이 나옴.
**정리 끝(2026-09-21)**: 지역 자리 = 로직 입력 `자재지역` · 전력 = 입력 `전력 계약종별` — 17줄 모두 그 입력이 있음 · 대표로 굳히지 않는 것이 맞음 · 품목별 표 `ref/_지역_갈림_재료.md`.
모래·자갈은 산림 12장에서 쓴 방식(고르기 조건 + 단위중량 환산)을 건설품셈 줄에도 그대로 옮기면 되지만, 원문 단위가 ㎥ 와 ㎏ 로 갈려 **사용자 확정이 한 번 더 필요**해 이번엔 손대지 않음.
### ③ 이름에 `{변수}` 가 든 참조 — 22줄
@@ -0,0 +1,61 @@
# 지역으로 값이 갈리는 재료 (2026-09-21)
자재품목 30,592줄 훑기. **지역 칸은 없음** — 지역은 품명(이름) 글자에만 있음. 값은 지어내지 않음(세기만 함).
## 지역이 이름에 적히는 꼴 (셋)
| 꼴 | 보기 |
|---|---|
| 괄호 꼴 | `레미콘(관급)-경상북도(울진군)` · `아스콘(관급)-경상북도(청송군,영양군,울진군)` |
| 쉼표 꼴 | `모 래, 서울, 도착도` · `시멘트, 서울` · `쇄석골재, 포항, …` |
| 앞괄호 꼴 | `레미콘(서울)-사급` |
엔진은 로직 입력 `자재지역`(시·군 이름)이 이름에 든 줄만 후보로 잡음(빈칸 없앰 · 지역 이름 그대로 맞댐) — 꼴을 가리지 않음.
## 품목별 (줄 = 그 품목 이름 줄 전체 · 지역 수 · 울진)
| 품목 | 구분 › 상세구분 | 줄 | 지역 수 | 울진 줄 | 울진 값 |
|---|---|---|---|---|---|
| 레미콘 | 레미콘 레미콘 | 3,203 | 87 | 19 | 19 |
| 아스콘(관급) | 아스콘·포장 › 아스콘 | 1,479 | 38 | 23 | 23 (「청송군·영양군·울진군」 묶음) |
| 쇄석골재 | 시멘트·골재 › 골재 | 124 | 56 | 0 | — |
| 혼합골재 | 시멘트·골재 › 골재 | 119 | 52 | 0 | — |
| 모래 | 시멘트·골재 › 골재 | 36 | 27 | 0 | — (포항 · 삼척 있음) |
| 시멘트 | 시멘트·골재 › 시멘트 | 71 | 21 | 0 | — |
| 자연자갈 | 시멘트·골재 › 골재 | 6 | 4 | 0 | — (서울·광주·대전·전주) |
| 쇄석자갈 | 시멘트·골재 › 골재 | 8 | 3 | 0 | — (금산·문경·원주) |
- 지역 수는 이름에서 뽑은 지역어 어휘(99)로 센 값 — 「전국」 · 「제주(관급)」 같은 묶음 이름은 하나로 셈.
- 이름에 「울진」 이 든 줄 = 레미콘 19 · 아스콘 23 뿐 — 모래 · 자갈 · 골재 · 시멘트에는 없음.
- 철근 · 형강 · 합판 · 각재 · 철선 등도 이름에 지역어가 있으나 **물가지 원문의 판매처·규격 글**이라 지역 선택 대상 아님(울진 0).
## 로직에서 어떻게 풀리는가
- 지역 자리 = 로직 입력 **`자재지역`**(자유 글 · 「시·군 이름」). 고르기 조건 칸(구분·상세구분·규격·대표)에는 지역 칸이 **없음** — 조건은 넓게 두고 이 입력이 이름 글자로 후보를 좁힘. 별도 자리를 더 만들지 않음.
- 지역 값은 마스터에 굳히지 않음 — 프로젝트가 고름. 시험 계산용 `시험입력` 만 있음(건설품셈 줄 「포항」 · 12-17-1-2 「울진」).
- 품셈재료 모래·자갈 줄(`MP000004` · `MP000006`) 비고에 「대표 줄로 굳히지 않음」 을 적음.
## 막힘이 나는 곳 (지역 값이 없거나 갈림)
| 시험 입력 | 결과 |
|---|---|
| `자재지역 = 울진` 으로 모래 줄을 부르는 로직 8줄(GC000211 · 677 · 685 · 933 · 934 · 938 · GF000176 · 183) | 「모래 지역 값 없음 — 울진」 — 울진 모래 줄이 없음 |
| `GF000183` (시험입력 울진) | 모래 뒤에 자갈도 울진 없음 — 두 줄 다 막힘 |
| `자재지역 = 삼척` | 「후보 여럿 — 규격 선택 필요 (4줄)」 — 삼척 모래는 강모래·해사·육상모래 규격이 섞임 |
| `자재지역 = 포항` | 모래 통과 · `GF000183` 은 자갈 줄이 없어 막힘 |
→ 울진 현장은 **가까운 지역(삼척 등)을 고르거나 울진 값을 새로 받아야** 모래·자갈 줄이 풀림. 이 창은 값을 지어 넣지 않음.
→ 12-17-1-2(`GF000183`) 시험입력이 울진이라 시험 계산이 멈춤 — 현장이 울진이면 데이터 대기 목록에 넣을 것.
## 전력 (계약종별) 원문 대조
원문 = `resources/knowledge/original/원가계산/전기요금_한국전력/20250101/별표1_월간전기요금표(C)_20250101.md`.
| 유가전력 줄 | 값 | 원문 | 판정 |
|---|---|---|---|
| `MO000058` 주택용 저압 전력량요금 | 120.0 | 1-가(2) 처음 200kWh까지 120.0 | 맞음 |
| `MO000073` 일반용(갑)Ⅰ 저압 전력량요금 | 114.4 | 2-가(1) 여름 132.4 · 봄가을 91.9 · 겨울 119.0 → 단순평균 114.43 | 맞음 |
- 두 줄만 남긴 것은 사용자 확정(2026-09-20) — 원문에 계약종별 지정은 없음(전력비 = 실비).
- 주택용은 **첫 구간 값만** 담음(다음 200kWh 214.6 · 초과 307.3 은 안 담음) — 시험 계산용 낮은 값이라는 뜻.
- 전력 로직 8줄(GC000893897 · 933 · 934 · 938)은 입력 `전력 계약종별`(고르기 둘)로 이 두 줄 가운데 하나를 고름 — 대표로 굳히지 않음.
@@ -12,9 +12,9 @@
{ "키": "MP000001", "원문번호": "레미콘", "이름": "레디믹스트콘크리트", "규격": "", "단위": "㎥", "요구절": ["산림품셈 12-9-1", "산림품셈 12-11-1", "산림품셈 12-11-2-1", "산림품셈 12-12", "산림품셈 12-13", "산림품셈 12-15", "산림품셈 12-16", "산림품셈 12-17-2", "산림품셈 12-17-3", "산림품셈 13-4-4 주①", "산림품셈 13-4-5", "산림품셈 13-5-1 주⑤", "산림품셈 13-5-2", "건설품셈 공통 7-3-1 주⑥ 채움콘크리트"], "연결": {"이름": "레미콘", "규격": "", "지역": "입력"}, "고르기": null, "비고": "규격(강도·골재·슬럼프)별 값이 다르면 관리자가 줄을 나눔 · 후보 조건: 지역 입력" },
{ "키": "MP000002", "원문번호": "시멘트", "이름": "시멘트", "규격": "", "단위": "kg", "요구절": ["산림품셈 12-1-3 주⑤⑥", "산림품셈 12-17-1-2 주③", "건설품셈 공통 6-3-1 주⑤", "건설품셈 건축 5-4-9", "건설품셈 건축 9-1-1", "건설품셈 건축 9-2-3", "건설품셈 건축 9-3-1"], "연결": "MT004176", "고르기": null, "비고": "연결: 자재품목 시멘트 전국 kg 줄" },
{ "키": "MP000003", "원문번호": "모래(중량)", "이름": "모래", "규격": "건조상태", "단위": "kg", "요구절": ["산림품셈 12-1-3 주⑤"], "연결": null, "고르기": null, "비고": "자재품목 모래는 ㎥ — kg 환산 안 함 · 고르기 없음(자재품목 그 묶음에 같은 단위·이름 줄이 없음)" },
{ "키": "MP000004", "원문번호": "모래", "이름": "모래", "규격": "", "단위": "㎥", "요구절": ["산림품셈 12-11-3", "산림품셈 12-17-1-2 주③", "건설품셈 공통 6-3-1 주⑤", "건설품셈 건축 9-1-1", "건설품셈 건축 9-3-1", "건설품셈 기계설비 13-6-3", "건설품셈 기계설비 13-6-6"], "연결": {"이름": "모래", "규격": "", "지역": "입력"}, "고르기": null, "비고": "후보 조건: 지역 입력" },
{ "키": "MP000004", "원문번호": "모래", "이름": "모래", "규격": "", "단위": "㎥", "요구절": ["산림품셈 12-11-3", "산림품셈 12-17-1-2 주③", "건설품셈 공통 6-3-1 주⑤", "건설품셈 건축 9-1-1", "건설품셈 건축 9-3-1", "건설품셈 기계설비 13-6-3", "건설품셈 기계설비 13-6-6"], "연결": {"이름": "모래", "규격": "", "지역": "입력"}, "고르기": null, "비고": "후보 조건: 지역 입력 · 대표 줄로 굳히지 않음 — 지역은 프로젝트가 정하는 로직 입력 「자재지역」 · 후보 = 이름에 그 지역이 든 줄(울진 모래 줄 없음 · ref/_지역_갈림_재료.md)" },
{ "키": "MP000005", "원문번호": "자갈(중량)", "이름": "자갈 또는 부순돌", "규격": "", "단위": "kg", "요구절": ["산림품셈 12-1-3 주⑤"], "연결": null, "고르기": null, "비고": "품명 둘(자갈·부순돌) · 자갈 값은 ㎥ · 고르기 없음(자재품목 그 묶음에 같은 단위·이름 줄이 없음)" },
{ "키": "MP000006", "원문번호": "자갈", "이름": "자갈", "규격": "", "단위": "㎥", "요구절": ["산림품셈 12-17-1-2 주③"], "연결": {"이름": "자갈", "규격": "", "지역": "입력"}, "고르기": null, "비고": "후보 조건: 지역 입력" },
{ "키": "MP000006", "원문번호": "자갈", "이름": "자갈", "규격": "", "단위": "㎥", "요구절": ["산림품셈 12-17-1-2 주③"], "연결": {"이름": "자갈", "규격": "", "지역": "입력"}, "고르기": null, "비고": "후보 조건: 지역 입력 · 대표 줄로 굳히지 않음 — 지역은 프로젝트가 정하는 로직 입력 「자재지역」 · 후보 = 이름에 그 지역이 든 줄(울진 자갈 줄 없음 · ref/_지역_갈림_재료.md)" },
{ "키": "MP000007", "원문번호": "철근", "이름": "철근", "규격": "", "단위": "ton", "요구절": ["산림품셈 12-15", "산림품셈 12-16"], "연결": null, "고르기": {"구분": "철근·강재", "상세구분": "철근"}, "비고": "SD300·400·500 갈림 — 원문 강도 없음" },
{ "키": "MP000008", "원문번호": "이형철근 SD300 D16", "이름": "이형철근", "규격": "SD300, D16", "단위": "ton", "요구절": ["산림품셈 12-9-1"], "연결": "MT014464", "고르기": null, "비고": "연결: 이형철근(SD300) D16 전국 줄" },
{ "키": "MP000009", "원문번호": "이형철근 SD300 D13", "이름": "이형철근", "규격": "SD300, D13", "단위": "ton", "요구절": ["산림품셈 12-12"], "연결": "MT014463", "고르기": null, "비고": "연결: 이형철근(SD300) D13 전국 줄" },