Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2
This commit is contained in:
@@ -6,65 +6,30 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import { plainError } from "../EasyCalc/M01_EasyCalc_Api";
|
||||
import { ApiError } from "../M01_MasterData_UI_Logic_Api";
|
||||
import type { SlOutsideAnswer } from "./M01_SL_Eval";
|
||||
import type { SheetLogic, SlField, SlSource } from "./M01_SL_Model";
|
||||
|
||||
/** 바깥 값 하나 — 값은 열 때 · 저장 때 마스터에서 새로(표는 사본으로 들고 있지 않음)
|
||||
* · 표 = 품셈 표 통째(머리 줄 + 줄 · 범위 조건 · 값은 「이름·아래」 「이름·위」 두 칸)
|
||||
* · 노임 · 자재 = 그 줄의 단가(`/calc` 와 같은 셈 · 준용 · 낮은 값)
|
||||
* · 장비 = 기계경비 로직 부름 글(EasyCalc `machineElement` 의 element) + 넘길 입력값 → 한 시간 값
|
||||
* · 로직 = 기존 로직 결과(`/calc` · 돈 로직은 `field` 비목 · 기본 계) */
|
||||
export type SlOutside =
|
||||
| { id: string; kind: "표"; table: string }
|
||||
| { id: string; kind: "노임"; key: string }
|
||||
| { id: string; kind: "자재"; key: string }
|
||||
| {
|
||||
id: string;
|
||||
kind: "장비";
|
||||
element: string;
|
||||
inputs: Record<string, unknown>;
|
||||
field?: SlField;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "로직";
|
||||
key: string;
|
||||
inputs: Record<string, unknown>;
|
||||
field?: SlField;
|
||||
};
|
||||
export type SlField = "노무비" | "재료비" | "경비" | "계";
|
||||
/* 모양 = sub7 Model(SheetLogic · SlSource · SlField) · 답 = Eval(SlOutsideAnswer) · 아래는 Api 몫만.
|
||||
* 저장 때 서버가 바깥 값을 넣는 칸 = ① Model 규칙(알약 outside 참 · 상자 source · 없으면 표 · 로직 ref)
|
||||
* ② 저장 몸의 `outside: SlOutsideAt[]`(Item `itemOutsides` — 장비처럼 칸마다 비목이 다른 것) · 값은 늘 마스터에서. */
|
||||
|
||||
/** 바깥 값이 들어갈 자리 — 시트 id · 칸(표는 이 칸을 왼쪽 위로 펼침) */
|
||||
export type { SlField };
|
||||
|
||||
/** 서버 `/outside` 물음 한 줄 = `{id, ...SlSource}` · 자재 `key` = 키 또는 검색어 묶음 JSON 글
|
||||
* (`{구분, 상세구분, 검색어, 규격?}`) · `unit` = 그 줄 단위(주면 기존 로직 재료 줄과 같은 단가) */
|
||||
export type SlQuery = { id: string; unit?: string } & SlSource;
|
||||
/** Item 이 쓰는 옛 이름 — `SlQuery` 와 같음(Model `SlOutside` 와 다른 것) */
|
||||
export type SlOutside = SlQuery;
|
||||
|
||||
/** 바깥 값 자리 하나 — 시트 id · 칸(표는 이 칸을 왼쪽 위로 펼침) */
|
||||
export interface SlOutsideAt {
|
||||
item: SlOutside;
|
||||
item: SlQuery;
|
||||
sheet: string;
|
||||
cell: string;
|
||||
}
|
||||
|
||||
/** 바깥 값 답 — 표는 `rows`(머리 줄 포함) · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */
|
||||
export interface SlOutsideValue {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
value?: number | null;
|
||||
rows?: (string | number | null)[][];
|
||||
name?: string;
|
||||
unit?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** 시트 로직 한 벌 = 통합문서 하나(상자 = 시트) + 바깥 값 자리 */
|
||||
export interface SlLogic {
|
||||
/** 새 로직 null · 저장하면 서버가 `SL-0001` 꼴로 줌 */
|
||||
key: string | null;
|
||||
이름: string;
|
||||
결과단위: string;
|
||||
book: Workbook;
|
||||
outside: SlOutsideAt[];
|
||||
/** 다른 창이 더하는 칸(상자 · 알약 · 선 …)은 그대로 저장 · 돌려줌 */
|
||||
[extra: string]: unknown;
|
||||
}
|
||||
|
||||
/** 목록 한 줄 */
|
||||
export interface SlListItem {
|
||||
key: string;
|
||||
@@ -74,11 +39,11 @@ export interface SlListItem {
|
||||
saved: string;
|
||||
}
|
||||
|
||||
/** 저장 답 — `logic.book.계산값` 은 서버가 다시 셈한 값 · `values` 는 넣은 바깥 값 */
|
||||
/** 저장 답 — `logic.workbook.계산값` 은 서버가 다시 셈한 값 · `values` 는 넣은 바깥 값 */
|
||||
export interface SlSaved {
|
||||
key: string;
|
||||
logic: SlLogic;
|
||||
values: SlOutsideValue[];
|
||||
logic: SheetLogic;
|
||||
values: SlOutsideAnswer[];
|
||||
}
|
||||
|
||||
async function send<T>(path: string, body?: unknown): Promise<T> {
|
||||
@@ -103,12 +68,13 @@ export async function slList(): Promise<SlListItem[]> {
|
||||
return (await send<{ logics: SlListItem[] }>("/list")).logics;
|
||||
}
|
||||
|
||||
export async function slGet(key: string): Promise<SlLogic> {
|
||||
return (await send<{ logic: SlLogic }>(`/get?${new URLSearchParams({ key }).toString()}`)).logic;
|
||||
export async function slGet(key: string): Promise<SheetLogic> {
|
||||
return (await send<{ logic: SheetLogic }>(`/get?${new URLSearchParams({ key }).toString()}`))
|
||||
.logic;
|
||||
}
|
||||
|
||||
/** 저장 — 서버가 바깥 값을 마스터에서 다시 넣고 같은 엔진으로 다시 셈(브라우저 계산값은 버림) */
|
||||
export async function slSave(logic: SlLogic): Promise<SlSaved> {
|
||||
export async function slSave(logic: SheetLogic & { outside?: SlOutsideAt[] }): Promise<SlSaved> {
|
||||
return send<SlSaved>("/save", { logic });
|
||||
}
|
||||
|
||||
@@ -117,8 +83,8 @@ export async function slDelete(key: string): Promise<void> {
|
||||
}
|
||||
|
||||
/** 바깥 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 */
|
||||
export async function slOutside(items: SlOutside[]): Promise<SlOutsideValue[]> {
|
||||
return (await send<{ values: SlOutsideValue[] }>("/outside", { items })).values;
|
||||
export async function slOutside(items: SlQuery[]): Promise<SlOutsideAnswer[]> {
|
||||
return (await send<{ values: SlOutsideAnswer[] }>("/outside", { items })).values;
|
||||
}
|
||||
|
||||
/** 실패 → 쉬운 말(EasyCalc `plainError` 그대로) */
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/* 시트 로직 — 위 가로 다이어그램 */
|
||||
.m01-sldg {
|
||||
--sldg-in: var(--color-accent);
|
||||
--sldg-out: #c2610c;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .m01-sldg {
|
||||
--sldg-out: #e5a866;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .m01-sldg {
|
||||
--sldg-out: #e5a866;
|
||||
}
|
||||
}
|
||||
|
||||
.m01-sldg__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.m01-sldg__note {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.m01-sldg__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m01-sldg__menu {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m01-sldg__menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 띠 — 세로는 상자 높이만큼 다 보임(사용자 요청) · 길면 가로로 밀기 */
|
||||
.m01-sldg__band {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.m01-sldg__inner {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(11rem, 1fr));
|
||||
gap: var(--spacing-24);
|
||||
min-width: max-content;
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-sldg__lines {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.m01-sldg__col {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 11rem;
|
||||
}
|
||||
|
||||
.m01-sldg__col-head {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* 상자 */
|
||||
.m01-sldg__box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-sldg__box:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.m01-sldg__box.is-selected {
|
||||
border: 2px solid var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.m01-sldg__box-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.m01-sldg__kind {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-sldg__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-sldg__pills {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-sldg__ins,
|
||||
.m01-sldg__outs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-sldg__outs {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* 알약 — 입력 파랑 · 출력 주황 · 빈 자리 회색 */
|
||||
.m01-sldg__pill {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 10rem;
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid var(--sldg-in);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--sldg-in) 12%, var(--color-surface));
|
||||
color: var(--sldg-in);
|
||||
font: inherit;
|
||||
font-size: var(--text-caption);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-sldg__pill.is-out {
|
||||
border-color: var(--sldg-out);
|
||||
background: color-mix(in srgb, var(--sldg-out) 14%, var(--color-surface));
|
||||
color: var(--sldg-out);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.m01-sldg__pill.is-empty {
|
||||
border-style: dashed;
|
||||
border-color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m01-sldg__pill.is-chosen {
|
||||
box-shadow: 0 0 0 2px currentColor;
|
||||
}
|
||||
|
||||
.m01-sldg.is-linking .m01-sldg__pill.is-target {
|
||||
box-shadow: 0 0 0 2px var(--sldg-in);
|
||||
animation: m01-sldg-blink 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes m01-sldg-blink {
|
||||
to {
|
||||
background: color-mix(in srgb, var(--sldg-in) 30%, var(--color-surface));
|
||||
}
|
||||
}
|
||||
|
||||
.m01-sldg__pill-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m01-sldg__pill-value {
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-sldg__pill-value:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 선 */
|
||||
.m01-sldg__edge {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-sldg__hit {
|
||||
fill: none;
|
||||
stroke: transparent;
|
||||
stroke-width: 12;
|
||||
}
|
||||
|
||||
.m01-sldg__wire {
|
||||
fill: none;
|
||||
stroke: var(--color-text-muted);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.m01-sldg__edge.is-strong .m01-sldg__wire {
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.m01-sldg__edge.is-dim .m01-sldg__wire {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.m01-sldg__drag {
|
||||
fill: none;
|
||||
stroke: var(--sldg-out);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.m01-sldg.is-linking .m01-sldg__pill.is-target {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Diagram.ts
|
||||
* 시트 로직 — 위 가로 다이어그램(정함 8 · 제안서 「화면 배치」 · 「상자의 입출력도 알약으로」)
|
||||
* 단계 다섯 칸(입력값 · 표 · 계산 · 항목 · 결과) · 단계마다 상자를 세로로 쌓음(자리는 저절로) ·
|
||||
* 상자 왼쪽 입력 알약(파랑 · 빈 자리는 회색 「? 이름」) · 오른쪽 출력 알약(주황) · 선 = SVG.
|
||||
* 출력 알약을 끌어 입력 알약에 놓으면 on.connect · 선을 골라 Delete / 「끊기」 → on.disconnect ·
|
||||
* 「+」 → 종류 → on.add · 상자를 골라 Delete → 물음 → on.remove. 키보드: Tab · Enter · 「잇기」.
|
||||
* 그리기만 — 시트 로직을 바꾸지 않음(바꾸는 것은 부른 쪽이 Model 로).
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import type { SheetLogic, SlEdge, SlEnd, SlKind, SlNode } from "./M01_SL_Model";
|
||||
import "./M01_SL_Diagram.css";
|
||||
|
||||
const TEXT = {
|
||||
Stage0: ["입력값", "Inputs"],
|
||||
Stage1: ["표", "Tables"],
|
||||
Stage2: ["계산", "Calculations"],
|
||||
Stage3: ["항목", "Items"],
|
||||
Stage4: ["결과", "Result"],
|
||||
K_input: ["입력값", "Input"],
|
||||
K_const: ["고정값", "Fixed value"],
|
||||
K_table: ["표", "Table"],
|
||||
K_calc: ["계산", "Calculation"],
|
||||
K_item: ["항목", "Item"],
|
||||
K_logic: ["다른 로직", "Other logic"],
|
||||
K_result: ["결과", "Result"],
|
||||
Add: ["+ 상자", "+ Box"],
|
||||
AddTitle: ["더할 상자 종류", "Kind of box to add"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Connect: ["잇기", "Connect"],
|
||||
Disconnect: ["끊기", "Disconnect"],
|
||||
Remove: ["상자 지우기", "Remove box"],
|
||||
AskRemove: [
|
||||
"「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐",
|
||||
"Remove box “{v}”? Its lines go too",
|
||||
],
|
||||
PickTarget: ["「{v}」 을 받을 입력 알약을 누르세요", "Tap the input pill that receives “{v}”"],
|
||||
Empty: ["상자가 없음 — 「+ 상자」 로 더함", "No boxes — add one with “+ Box”"],
|
||||
NoOut: ["내보내는 알약이 없음", "No output pills"],
|
||||
NoIn: ["받는 알약이 없음", "No input pills"],
|
||||
SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"],
|
||||
NotIn: ["입력 알약에 놓아야 이어짐", "Drop it on an input pill"],
|
||||
NoMatch: [
|
||||
"이름이 같은 입력 알약이 없음 — 받을 알약에 바로 놓으세요",
|
||||
"No input pill with the same name — drop on the pill itself",
|
||||
],
|
||||
Taken: [
|
||||
"이미 이어진 입력 알약 — 먼저 그 선을 끊으세요",
|
||||
"That input already has a line — disconnect it first",
|
||||
],
|
||||
From: ["어디서", "From"],
|
||||
To: ["어디로", "To"],
|
||||
Value: ["지금 값", "Now"],
|
||||
LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"],
|
||||
} as const;
|
||||
|
||||
const td = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
|
||||
TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? "");
|
||||
|
||||
/** 종류 → 기본 단계(고정값 · 다른 로직은 앞 칸) */
|
||||
export const STAGE_OF: Record<SlKind, number> = {
|
||||
input: 0,
|
||||
const: 0,
|
||||
logic: 0,
|
||||
table: 1,
|
||||
calc: 2,
|
||||
item: 3,
|
||||
result: 4,
|
||||
};
|
||||
|
||||
const KINDS: SlKind[] = ["input", "const", "table", "calc", "item", "logic", "result"];
|
||||
const kindLabel = (k: SlKind): string => td(`K_${k}` as keyof typeof TEXT);
|
||||
|
||||
export interface DiagramOn {
|
||||
select(id: string | null): void;
|
||||
/** 잇기 — 막히면 까닭(쉬운 말)을 돌려주면 띠에 한 줄로 보임 */
|
||||
connect(from: SlEnd, to: SlEnd): string | null | void;
|
||||
disconnect(edgeId: string): void;
|
||||
add(kind: SlKind, stage: number): void;
|
||||
remove(nodeId: string): void;
|
||||
}
|
||||
|
||||
export interface DiagramOpts {
|
||||
/** 상자 id → 알약 이름 → 지금 값 */
|
||||
values?: Record<string, Record<string, unknown>>;
|
||||
/** 고른 상자 id · 선 id */
|
||||
selected?: string | null;
|
||||
}
|
||||
|
||||
/* ── 자리 정하기(DOM 없음) ─────────────────────────────────────────── */
|
||||
|
||||
/** 단계 칸마다 상자 차례 — node.stage(없으면 종류 기본) · 0~4 로 자름 · 같은 칸은 sl 차례 */
|
||||
export function layout(nodes: SlNode[]): SlNode[][] {
|
||||
const cols: SlNode[][] = [[], [], [], [], []];
|
||||
for (const n of nodes) {
|
||||
const s = Number.isInteger(n.stage) ? n.stage : STAGE_OF[n.kind];
|
||||
cols[Math.min(4, Math.max(0, s ?? 0))].push(n);
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
/** 떨군 자리 → 받을 끝 · 상자에 떨구면 이름 같은 입력 알약 · 못 정하면 까닭 */
|
||||
export function resolveDrop(
|
||||
sl: SheetLogic,
|
||||
from: SlEnd,
|
||||
target: { node: string; pill: string | null; dir: "in" | "out" | null } | null,
|
||||
): { to: SlEnd } | { why: string } {
|
||||
if (!target) return { why: td("NotIn") };
|
||||
if (target.node === from.node) return { why: td("SameBox") };
|
||||
if (target.dir === "out") return { why: td("NotIn") };
|
||||
const node = sl.nodes.find((n) => n.id === target.node);
|
||||
if (!node?.pillsIn.length) return { why: td("NoIn") };
|
||||
const name = target.pill ?? node.pillsIn.find((p) => p.name === from.pill)?.name;
|
||||
if (!name) return { why: td("NoMatch") };
|
||||
if (sl.edges.some((e) => e.to.node === node.id && e.to.pill === name))
|
||||
return { why: td("Taken") };
|
||||
return { to: { node: node.id, pill: name } };
|
||||
}
|
||||
|
||||
/* ── 그리기 ─────────────────────────────────────────────────────── */
|
||||
|
||||
interface HostState {
|
||||
selected: string | null;
|
||||
/** 잇기 중 — 출력 알약 */
|
||||
linking: SlEnd | null;
|
||||
/** 고른 알약(정보 줄) */
|
||||
pill: { node: string; pill: string; dir: "in" | "out" } | null;
|
||||
note: string;
|
||||
scroll: number;
|
||||
/** 선 다시 그리기 — 띠 크기 바뀜 */
|
||||
watch?: ResizeObserver;
|
||||
}
|
||||
|
||||
const states = new WeakMap<HTMLElement, HostState>();
|
||||
|
||||
const showValue = (v: unknown): string => {
|
||||
if (v === null || v === undefined || v === "") return "";
|
||||
if (typeof v === "number")
|
||||
return Number.isInteger(v) ? v.toLocaleString() : String(+v.toFixed(4));
|
||||
return String(v).slice(0, 12);
|
||||
};
|
||||
|
||||
const edgeTouches = (e: SlEdge, node: string): boolean =>
|
||||
e.from.node === node || e.to.node === node;
|
||||
|
||||
export function renderDiagram(
|
||||
host: HTMLElement,
|
||||
sl: SheetLogic,
|
||||
on: DiagramOn,
|
||||
opts: DiagramOpts = {},
|
||||
): void {
|
||||
const old = states.get(host);
|
||||
const st: HostState = old ?? { selected: null, linking: null, pill: null, note: "", scroll: 0 };
|
||||
if (opts.selected !== undefined) st.selected = opts.selected;
|
||||
states.set(host, st);
|
||||
const values = opts.values ?? {};
|
||||
const nodeName = (id: string): string => sl.nodes.find((n) => n.id === id)?.name ?? "";
|
||||
const redraw = (): void => renderDiagram(host, sl, on, { ...opts, selected: st.selected });
|
||||
const select = (id: string | null): void => {
|
||||
st.selected = id;
|
||||
st.pill = null;
|
||||
st.note = "";
|
||||
on.select(id);
|
||||
redraw();
|
||||
};
|
||||
const tryConnect = (from: SlEnd, target: Parameters<typeof resolveDrop>[2]): void => {
|
||||
const got = resolveDrop(sl, from, target);
|
||||
st.linking = null;
|
||||
if ("why" in got) st.note = got.why;
|
||||
else st.note = on.connect(from, got.to) || "";
|
||||
redraw();
|
||||
};
|
||||
|
||||
/* 상자 */
|
||||
const pillEl = (n: SlNode, name: string, dir: "in" | "out", unit?: string): HTMLElement => {
|
||||
const wired = dir === "out" || sl.edges.some((e) => e.to.node === n.id && e.to.pill === name);
|
||||
const linking = st.linking;
|
||||
const chosen = st.pill?.node === n.id && st.pill.pill === name && st.pill.dir === dir;
|
||||
const b = el("button", {
|
||||
className: [
|
||||
"m01-sldg__pill",
|
||||
`is-${dir}`,
|
||||
wired ? "" : "is-empty",
|
||||
chosen ? "is-chosen" : "",
|
||||
linking && dir === "in" && n.id !== linking.node ? "is-target" : "",
|
||||
].join(" "),
|
||||
attrs: {
|
||||
type: "button",
|
||||
"data-node": n.id,
|
||||
"data-pill": name,
|
||||
"data-dir": dir,
|
||||
title: unit ? `${name} (${unit})` : name,
|
||||
},
|
||||
children: [
|
||||
el("span", { className: "m01-sldg__pill-name", text: wired ? name : `? ${name}` }),
|
||||
el("span", { className: "m01-sldg__pill-value", text: showValue(values[n.id]?.[name]) }),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (st.linking && dir === "in")
|
||||
return tryConnect(st.linking, { node: n.id, pill: name, dir });
|
||||
st.pill = chosen ? null : { node: n.id, pill: name, dir };
|
||||
st.linking = null;
|
||||
st.note = "";
|
||||
redraw();
|
||||
});
|
||||
if (dir === "out")
|
||||
b.addEventListener("pointerdown", (ev) => startDrag(ev, { node: n.id, pill: name }));
|
||||
return b;
|
||||
};
|
||||
|
||||
const boxEl = (n: SlNode): HTMLElement => {
|
||||
const box = el("div", {
|
||||
className: ["m01-sldg__box", `is-${n.kind}`, st.selected === n.id ? "is-selected" : ""].join(
|
||||
" ",
|
||||
),
|
||||
attrs: {
|
||||
"data-node": n.id,
|
||||
tabindex: "0",
|
||||
role: "button",
|
||||
"aria-label": `${kindLabel(n.kind)} · ${n.name}`,
|
||||
},
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-sldg__box-head",
|
||||
children: [
|
||||
el("span", { className: "m01-sldg__kind", text: kindLabel(n.kind) }),
|
||||
el("strong", { className: "m01-sldg__name", text: n.name }),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-sldg__pills",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-sldg__ins",
|
||||
children: n.pillsIn.map((p) => pillEl(n, p.name, "in", p.unit)),
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-sldg__outs",
|
||||
children: n.pillsOut.map((p) => pillEl(n, p.name, "out", p.unit)),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
box.addEventListener("click", () => {
|
||||
if (st.linking) return tryConnect(st.linking, { node: n.id, pill: null, dir: null });
|
||||
select(st.selected === n.id ? null : n.id);
|
||||
});
|
||||
box.addEventListener("keydown", (ev) => {
|
||||
if (ev.target !== box) return;
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
box.click();
|
||||
}
|
||||
});
|
||||
return box;
|
||||
};
|
||||
|
||||
const cols = layout(sl.nodes);
|
||||
const inner = el("div", {
|
||||
className: "m01-sldg__inner",
|
||||
children: cols.map((list, i) =>
|
||||
el("div", {
|
||||
className: "m01-sldg__col",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-sldg__col-head",
|
||||
text: td(`Stage${i}` as keyof typeof TEXT),
|
||||
}),
|
||||
...list.map(boxEl),
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.classList.add("m01-sldg__lines");
|
||||
inner.prepend(svg);
|
||||
inner.addEventListener("click", (ev) => {
|
||||
if (ev.target === inner || (ev.target as HTMLElement).classList.contains("m01-sldg__col")) {
|
||||
st.linking = null;
|
||||
select(null);
|
||||
}
|
||||
});
|
||||
|
||||
/* 위 줄 — 더하기 · 고른 것 · 할 일 */
|
||||
const addMenu = el("div", { className: "m01-sldg__menu", attrs: { hidden: "", role: "menu" } });
|
||||
addMenu.append(
|
||||
el("span", { className: "m01-sldg__muted", text: td("AddTitle") }),
|
||||
...KINDS.map((k) =>
|
||||
createButton({
|
||||
label: kindLabel(k),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
addMenu.hidden = true;
|
||||
on.add(k, STAGE_OF[k]);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const addBtn = createButton({
|
||||
label: td("Add"),
|
||||
size: "sm",
|
||||
onClick: () => (addMenu.hidden = !addMenu.hidden),
|
||||
});
|
||||
const selEdge = sl.edges.find((e) => e.id === st.selected) ?? null;
|
||||
const selNode = sl.nodes.find((n) => n.id === st.selected) ?? null;
|
||||
const tools: HTMLElement[] = [];
|
||||
if (st.linking) {
|
||||
tools.push(
|
||||
el("span", { text: td("PickTarget", { v: st.linking.pill }) }),
|
||||
createButton({
|
||||
label: td("Cancel"),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => ((st.linking = null), redraw()),
|
||||
}),
|
||||
);
|
||||
} else if (st.pill) {
|
||||
const p = st.pill;
|
||||
const linked = sl.edges.filter((e) =>
|
||||
p.dir === "in"
|
||||
? e.to.node === p.node && e.to.pill === p.pill
|
||||
: e.from.node === p.node && e.from.pill === p.pill,
|
||||
);
|
||||
const where = linked
|
||||
.map((e) =>
|
||||
p.dir === "in"
|
||||
? `${nodeName(e.from.node)} · ${e.from.pill}`
|
||||
: `${nodeName(e.to.node)} · ${e.to.pill}`,
|
||||
)
|
||||
.join(", ");
|
||||
tools.push(
|
||||
el("span", {
|
||||
text: [
|
||||
`${p.pill}`,
|
||||
`${td("Value")} ${showValue(values[p.node]?.[p.pill]) || "—"}`,
|
||||
where ? `${td(p.dir === "in" ? "From" : "To")} ${where}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
}),
|
||||
);
|
||||
if (p.dir === "out")
|
||||
tools.push(
|
||||
createButton({
|
||||
label: td("Connect"),
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
st.linking = { node: p.node, pill: p.pill };
|
||||
st.pill = null;
|
||||
redraw();
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (selEdge) {
|
||||
tools.push(
|
||||
el("span", {
|
||||
text: td("LineOf", {
|
||||
a: `${nodeName(selEdge.from.node)} · ${selEdge.from.pill}`,
|
||||
b: `${nodeName(selEdge.to.node)} · ${selEdge.to.pill}`,
|
||||
}),
|
||||
}),
|
||||
createButton({
|
||||
label: td("Disconnect"),
|
||||
size: "sm",
|
||||
variant: "danger",
|
||||
onClick: () => on.disconnect(selEdge.id),
|
||||
}),
|
||||
);
|
||||
} else if (selNode) {
|
||||
tools.push(
|
||||
createButton({
|
||||
label: td("Remove"),
|
||||
size: "sm",
|
||||
variant: "danger",
|
||||
onClick: () => void askRemove(selNode),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const askRemove = async (n: SlNode): Promise<void> => {
|
||||
if (await showConfirmDialog(td("AskRemove", { v: n.name }), td("Remove"))) on.remove(n.id);
|
||||
};
|
||||
|
||||
const band = el("div", { className: "m01-sldg__band", children: [inner] });
|
||||
const root = el("div", {
|
||||
className: `m01-sldg${st.linking ? " is-linking" : ""}`,
|
||||
attrs: { tabindex: "-1" },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-sldg__bar",
|
||||
children: [addBtn, ...tools, el("span", { className: "m01-sldg__note", text: st.note })],
|
||||
}),
|
||||
addMenu,
|
||||
sl.nodes.length ? band : el("p", { className: "m01-sldg__muted", text: td("Empty") }),
|
||||
],
|
||||
});
|
||||
root.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Escape" && (st.linking || st.pill)) {
|
||||
st.linking = null;
|
||||
st.pill = null;
|
||||
redraw();
|
||||
}
|
||||
if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return;
|
||||
if (selEdge) on.disconnect(selEdge.id);
|
||||
else if (selNode) void askRemove(selNode);
|
||||
});
|
||||
band.addEventListener("scroll", () => (st.scroll = band.scrollLeft));
|
||||
host.replaceChildren(root);
|
||||
band.scrollLeft = st.scroll;
|
||||
|
||||
/* 선 — 붙인 뒤 알약 자리를 재서 그림 */
|
||||
const drawLines = (): void => {
|
||||
const box = inner.getBoundingClientRect();
|
||||
svg.setAttribute("width", String(inner.scrollWidth));
|
||||
svg.setAttribute("height", String(inner.scrollHeight));
|
||||
svg.replaceChildren();
|
||||
const at = (end: SlEnd, dir: "in" | "out"): { x: number; y: number } | null => {
|
||||
const pill = inner.querySelector<HTMLElement>(
|
||||
`.m01-sldg__pill[data-node="${CSS.escape(end.node)}"][data-pill="${CSS.escape(end.pill)}"][data-dir="${dir}"]`,
|
||||
);
|
||||
if (!pill) return null;
|
||||
const r = pill.getBoundingClientRect();
|
||||
return {
|
||||
x: (dir === "out" ? r.right : r.left) - box.left,
|
||||
y: r.top + r.height / 2 - box.top,
|
||||
};
|
||||
};
|
||||
for (const e of sl.edges) {
|
||||
const a = at(e.from, "out");
|
||||
const b = at(e.to, "in");
|
||||
if (!a || !b) continue;
|
||||
const dx = Math.max(30, Math.abs(b.x - a.x) / 2);
|
||||
const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`;
|
||||
const strong = st.selected === e.id || (selNode !== null && edgeTouches(e, selNode.id));
|
||||
const dim = st.selected !== null && !strong;
|
||||
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||
g.setAttribute("class", `m01-sldg__edge${strong ? " is-strong" : ""}${dim ? " is-dim" : ""}`);
|
||||
g.setAttribute("data-edge", e.id);
|
||||
g.setAttribute("data-x1", a.x.toFixed(1));
|
||||
g.setAttribute("data-y1", a.y.toFixed(1));
|
||||
g.setAttribute("data-x2", b.x.toFixed(1));
|
||||
g.setAttribute("data-y2", b.y.toFixed(1));
|
||||
for (const cls of ["m01-sldg__hit", "m01-sldg__wire"]) {
|
||||
const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
p.setAttribute("d", d);
|
||||
p.setAttribute("class", cls);
|
||||
g.append(p);
|
||||
}
|
||||
g.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
select(st.selected === e.id ? null : e.id);
|
||||
});
|
||||
svg.append(g);
|
||||
}
|
||||
};
|
||||
// 붙어 있으면 바로 · 나중에 붙거나 크기가 바뀌면 다시(띠 밀기 · 창 크기)
|
||||
st.watch?.disconnect();
|
||||
st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(drawLines);
|
||||
st.watch?.observe(inner);
|
||||
if (inner.isConnected) drawLines();
|
||||
|
||||
/* 끌어 잇기 */
|
||||
function startDrag(ev: PointerEvent, from: SlEnd): void {
|
||||
if (ev.button !== 0) return;
|
||||
const pill = ev.currentTarget as HTMLElement;
|
||||
const box = inner.getBoundingClientRect();
|
||||
const r = pill.getBoundingClientRect();
|
||||
const x0 = r.right - box.left;
|
||||
const y0 = r.top + r.height / 2 - box.top;
|
||||
let line: SVGPathElement | null = null;
|
||||
const move = (m: PointerEvent): void => {
|
||||
const x = m.clientX - box.left;
|
||||
const y = m.clientY - box.top;
|
||||
if (!line && Math.hypot(x - x0, y - y0) < 6) return;
|
||||
if (!line) {
|
||||
line = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
line.setAttribute("class", "m01-sldg__drag");
|
||||
svg.append(line);
|
||||
}
|
||||
line.setAttribute("d", `M${x0},${y0} L${x},${y}`);
|
||||
};
|
||||
const up = (u: PointerEvent): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
if (!line) return; // 끌지 않음 = 누름(click 이 처리)
|
||||
line.remove();
|
||||
const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null;
|
||||
const pillHit = hit?.closest<HTMLElement>(".m01-sldg__pill");
|
||||
const boxHit = hit?.closest<HTMLElement>(".m01-sldg__box");
|
||||
const target = pillHit
|
||||
? {
|
||||
node: pillHit.dataset.node ?? "",
|
||||
pill: pillHit.dataset.pill ?? null,
|
||||
dir: (pillHit.dataset.dir as "in" | "out") ?? null,
|
||||
}
|
||||
: boxHit
|
||||
? { node: boxHit.dataset.node ?? "", pill: null, dir: null }
|
||||
: null;
|
||||
// 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로
|
||||
const swallow = (c: MouseEvent): void => c.stopPropagation();
|
||||
window.addEventListener("click", swallow, { capture: true, once: true });
|
||||
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
|
||||
tryConnect(from, target);
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* 세 칸은 내용 높이만큼 — 넘치면 창(모달) 전체만 세로로 밈 */
|
||||
.m01-sl .ui-modal__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
@@ -111,11 +111,10 @@
|
||||
|
||||
/* 위 가로 다이어그램 · 아래 왼쪽 시트 판 · 아래 오른쪽 결과 */
|
||||
.m01-sl__layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
grid-template-rows: minmax(160px, 2fr) minmax(0, 3fr);
|
||||
grid-template-rows: auto auto;
|
||||
align-items: start;
|
||||
grid-template-areas:
|
||||
"diagram diagram"
|
||||
"panel result";
|
||||
@@ -137,7 +136,6 @@
|
||||
.m01-sl__part {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
@@ -149,9 +147,7 @@
|
||||
}
|
||||
|
||||
.m01-sl__part-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
padding: var(--spacing-12);
|
||||
}
|
||||
|
||||
@@ -164,6 +160,16 @@
|
||||
"diagram"
|
||||
"panel"
|
||||
"result";
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.m01-sl__note {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.m01-sl__note:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Entry.ts
|
||||
* 시트 로직(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 고치는 화면 틀(위 다이어그램 · 아래 시트 판 · 결과) · 위 띠
|
||||
* 시트 로직(시험) — 들어가는 단추 · 큰 창 · 시작 화면(내 시트 로직 · 새로 · 기존 로직 가져오기) · 잇기
|
||||
* 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄 + 서버 등록 한 줄).
|
||||
* 1걸음 = 틀 — 다이어그램 · 시트 판 · 결과 · 저장 · 지우기 · 실행 취소 · 가져오기는 2걸음에 이음.
|
||||
* 고치는 화면은 M01_SL_Entry_Paper.ts · SheetLogic 의 다른 파일은 창을 열 때 import() 로 늦게 불러옴.
|
||||
* 열기 = slGet → 바깥 값(slOutside → applyOutside) → 고치는 화면 · 가져오기 = 기존 로직 줄 + 품셈 표 → importLogicReport.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
createTag,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { listEasy, plainError, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api";
|
||||
import { slList, type SlListItem } from "./M01_SL_Api";
|
||||
import { newLogic, type SheetLogic } from "./M01_SL_Model";
|
||||
import { listEasy, openEasy, plainError, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api";
|
||||
import type { SlListItem } from "./M01_SL_Api";
|
||||
import type { SheetLogic } from "./M01_SL_Model";
|
||||
import type { SlImportTable } from "./M01_SL_Import";
|
||||
import { fillOutside, loadMods, showPaper, type Mods } from "./M01_SL_Entry_Paper";
|
||||
import "./M01_SL_Entry.css";
|
||||
|
||||
const TEXT = {
|
||||
Open: ["시트 로직(시험)", "Sheet logic (trial)"],
|
||||
Title: ["시트 로직", "Sheet logic"],
|
||||
CloseWindow: ["창 닫기", "Close window"],
|
||||
Close: ["닫기", "Close"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"],
|
||||
OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"],
|
||||
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
|
||||
Start_Mine: ["내 시트 로직", "My sheet logics"],
|
||||
Start_MineNone: ["아직 없음", "None yet"],
|
||||
@@ -38,17 +39,7 @@ const TEXT = {
|
||||
Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"],
|
||||
Start_None: ["찾은 것 없음", "Nothing found"],
|
||||
Per: ["{u} 당", "per {u}"],
|
||||
Name: ["이름", "Name"],
|
||||
NamePlaceholder: ["예: 돌쌓기 내 방식", "e.g. my stone wall"],
|
||||
Unit: ["결과 단위", "Result unit"],
|
||||
Undo: ["실행 취소", "Undo"],
|
||||
Save: ["저장", "Save"],
|
||||
Delete: ["지우기", "Delete"],
|
||||
Unsaved: ["저장 안 함", "Not saved"],
|
||||
Diagram: ["상자와 선", "Boxes and lines"],
|
||||
Panel: ["고른 상자의 시트", "Sheet of the chosen box"],
|
||||
Result: ["결과", "Result"],
|
||||
NotYet: ["준비 중", "Coming soon"],
|
||||
Outside: ["바깥 값을 못 받음 — {v}", "Couldn't get outside values — {v}"],
|
||||
Leave_Ask: [
|
||||
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
|
||||
"There are unsaved changes — close without saving?",
|
||||
@@ -69,22 +60,13 @@ const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133",
|
||||
/** 찾은 것 목록에 한 번에 보이는 줄 수 */
|
||||
const SHOWN = 50;
|
||||
|
||||
/** 「원/㎡」 → 「㎡」 · 「100㎡」 은 그대로 앞에 1 을 안 붙임 */
|
||||
/** 「원/㎡」 → 「1㎡」 · 「100㎡」 은 앞에 1 을 안 붙임 */
|
||||
const amountOf = (resultUnit: string): string => {
|
||||
const unit = resultUnit.replace(/^원\//, "");
|
||||
return /^\d/.test(unit) ? unit : `1${unit}`;
|
||||
};
|
||||
const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/");
|
||||
|
||||
/* ponytail: SL Api(sub4) · Model(sub7) 속이 「준비 중」 인 동안만 대신함 — 채워지면 두 catch 를 지움 */
|
||||
const loadMine = (): Promise<SlListItem[]> => slList().catch(() => []);
|
||||
const blankLogic = (name: string, unit: string): SheetLogic | null => {
|
||||
try {
|
||||
return newLogic(name, unit);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text });
|
||||
|
||||
/** 「로직 만들기(시험)」 옆에 놓는 단추 — 누르면 큰 창 */
|
||||
export function createSheetLogicButton(): HTMLButtonElement {
|
||||
@@ -94,58 +76,137 @@ export function createSheetLogicButton(): HTMLButtonElement {
|
||||
function openSheetLogic(): void {
|
||||
let body: HTMLElement;
|
||||
/** 고치는 화면이 열려 있을 때만 */
|
||||
let paper: { dirty(): boolean; sl: SheetLogic | null } | null = null;
|
||||
let paper: ReturnType<typeof showPaper> | null = null;
|
||||
let logics: EasyListItem[] | null = null;
|
||||
let query = "";
|
||||
const mods = loadMods();
|
||||
|
||||
const mayLeave = async (): Promise<boolean> =>
|
||||
!paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
|
||||
const notYet = (): void => showToast(te("NotYet"), "info");
|
||||
|
||||
/** Ctrl+Z · Ctrl+S — 이 창 안에서만(시트 편집기가 먼저 먹은 키는 그쪽 몫) */
|
||||
const onKey = (ev: KeyboardEvent): void => {
|
||||
if (!paper || !(ev.ctrlKey || ev.metaKey) || ev.defaultPrevented) return;
|
||||
const key = ev.key.toLowerCase();
|
||||
if (key === "z" && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
paper.undo();
|
||||
} else if (key === "s") {
|
||||
ev.preventDefault();
|
||||
void paper.save();
|
||||
}
|
||||
};
|
||||
|
||||
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
|
||||
const unitChoices = (): string[] => {
|
||||
const count = new Map<string, number>();
|
||||
for (const x of logics ?? []) {
|
||||
if (isMoney(x.resultUnit)) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1);
|
||||
}
|
||||
const units = [...count.entries()]
|
||||
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([u]) => u);
|
||||
return units.length ? units : ["원/㎡"];
|
||||
};
|
||||
|
||||
/** 고치는 화면 열기 — 바깥 값을 넣은 뒤 · 새로 · 가져오기는 처음부터 「저장 안 함」 */
|
||||
const open = async (
|
||||
M: Mods,
|
||||
sl: SheetLogic,
|
||||
saved: boolean,
|
||||
notes: string[] = [],
|
||||
): Promise<void> => {
|
||||
let filled = sl;
|
||||
try {
|
||||
const got = await fillOutside(M, sl);
|
||||
filled = got.sl;
|
||||
if (got.errors.length) notes = [...notes, te("Outside", { v: got.errors.join(" · ") })];
|
||||
} catch (e) {
|
||||
notes = [...notes, te("Outside", { v: M.api.slPlain(e) })];
|
||||
}
|
||||
paper = showPaper(M, body, filled, {
|
||||
saved,
|
||||
units: unitChoices(),
|
||||
notes,
|
||||
onClose: () =>
|
||||
void (async () => {
|
||||
if (await mayLeave()) void showStart();
|
||||
})(),
|
||||
});
|
||||
};
|
||||
|
||||
const run = (job: (M: Mods) => Promise<void>): void => {
|
||||
body.replaceChildren(muted(te("Loading")));
|
||||
void mods.then(job).catch((e: unknown) => {
|
||||
showToast(te("OpenFailed", { v: plainError(e) }), "error");
|
||||
void showStart();
|
||||
});
|
||||
};
|
||||
|
||||
const openSaved = (key: string): void => run(async (M) => open(M, await M.api.slGet(key), true));
|
||||
|
||||
const openNew = (): void =>
|
||||
run(async (M) => open(M, M.model.newLogic("", unitChoices()[0]), false));
|
||||
|
||||
/** 기존 로직 가져오기 — 로직 줄 + 그 줄이 쓰는 품셈 표(열 때 받음) → 시트 로직(저장 전) */
|
||||
const doImport = (key: string): void =>
|
||||
run(async (M) => {
|
||||
const { row } = await openEasy(key);
|
||||
const lb = await import("../LogicBuilder/M01_LB_Api");
|
||||
const keys = [...new Set(JSON.stringify(row).match(/Q[A-Z]\d{6}/g) ?? [])];
|
||||
const got = await Promise.all(
|
||||
keys.map((k) =>
|
||||
lb.fetchLbTable(k).then(
|
||||
(t) => [k, t.table] as const,
|
||||
() => null,
|
||||
),
|
||||
),
|
||||
);
|
||||
// 서버 표 통째(조건 · 값칸 · 줄 포함) — LB Api 타입이 느슨해 Import 모양으로 봄
|
||||
const tables = Object.fromEntries(got.filter((x) => x !== null)) as unknown as Record<
|
||||
string,
|
||||
SlImportTable
|
||||
>;
|
||||
const report = M.imp.importLogicReport(row, { tables });
|
||||
await open(M, report.sl, false, report.problems);
|
||||
});
|
||||
|
||||
/* ---------- 시작 화면 ---------- */
|
||||
|
||||
const importRow = (item: EasyListItem): HTMLElement => {
|
||||
const listButton = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => {
|
||||
const row = el("button", {
|
||||
className: "m01-sl__item",
|
||||
attrs: { type: "button", title: item.where },
|
||||
attrs: { type: "button", title },
|
||||
children: [
|
||||
el("span", { className: "m01-sl__item-name", text: item.name }),
|
||||
el("span", {
|
||||
className: "m01-sl__item-unit",
|
||||
text: isMoney(item.resultUnit)
|
||||
? te("Per", { u: amountOf(item.resultUnit) })
|
||||
: item.resultUnit,
|
||||
}),
|
||||
el("span", { className: "m01-sl__item-name", text: name }),
|
||||
el("span", { className: "m01-sl__item-unit", text: unit }),
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", notYet);
|
||||
return row;
|
||||
};
|
||||
|
||||
const mineRow = (item: SlListItem): HTMLElement => {
|
||||
const row = el("button", {
|
||||
className: "m01-sl__item",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("span", { className: "m01-sl__item-name", text: item.이름 }),
|
||||
el("span", { className: "m01-sl__item-unit", text: item.결과단위 }),
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", notYet);
|
||||
row.addEventListener("click", onClick);
|
||||
return row;
|
||||
};
|
||||
const perText = (u: string): string => (isMoney(u) ? te("Per", { u: amountOf(u) }) : u);
|
||||
const importRow = (item: EasyListItem): HTMLElement =>
|
||||
listButton(item.name, perText(item.resultUnit), () => doImport(item.key), item.where);
|
||||
const mineRow = (item: SlListItem): HTMLElement =>
|
||||
listButton(item.이름, perText(item.결과단위), () => openSaved(item.key), item.saved);
|
||||
|
||||
const showStart = async (): Promise<void> => {
|
||||
paper = null;
|
||||
const status = el("p", { className: "m01-sl__muted", text: te("Loading") });
|
||||
const trialHost = el("div", { className: "m01-sl__list" });
|
||||
const foundHost = el("div", { className: "m01-sl__list" });
|
||||
const mineHost = el("div", { className: "m01-sl__list" });
|
||||
void loadMine().then((mine) =>
|
||||
mineHost.replaceChildren(
|
||||
...(mine.length ? mine.map(mineRow) : [muted(te("Start_MineNone"))]),
|
||||
),
|
||||
);
|
||||
const mineHost = el("div", { className: "m01-sl__list", children: [muted(te("Loading"))] });
|
||||
void mods
|
||||
.then((M) => M.api.slList())
|
||||
.then(
|
||||
(mine) =>
|
||||
mineHost.replaceChildren(
|
||||
...(mine.length ? mine.map(mineRow) : [muted(te("Start_MineNone"))]),
|
||||
),
|
||||
(e: unknown) => mineHost.replaceChildren(muted(te("LoadFailed", { v: plainError(e) }))),
|
||||
);
|
||||
const search = createInputField({
|
||||
type: "search",
|
||||
placeholder: te("Start_Search"),
|
||||
@@ -180,7 +241,7 @@ function openSheetLogic(): void {
|
||||
el("div", {
|
||||
className: "m01-sl__start-col",
|
||||
children: [
|
||||
bigButton(te("Start_New"), () => showPaper()),
|
||||
bigButton(te("Start_New"), openNew),
|
||||
el("h4", { text: te("Start_Mine") }),
|
||||
mineHost,
|
||||
],
|
||||
@@ -217,86 +278,6 @@ function openSheetLogic(): void {
|
||||
drawFound();
|
||||
};
|
||||
|
||||
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
|
||||
const unitChoices = (): string[] => {
|
||||
const count = new Map<string, number>();
|
||||
for (const x of logics ?? []) {
|
||||
if (isMoney(x.resultUnit)) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1);
|
||||
}
|
||||
const units = [...count.entries()]
|
||||
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([u]) => u);
|
||||
return units.length ? units : ["원/㎡"];
|
||||
};
|
||||
|
||||
/* ---------- 고치는 화면 ---------- */
|
||||
|
||||
const showPaper = (): void => {
|
||||
let dirty = false;
|
||||
const units = unitChoices();
|
||||
paper = { dirty: () => dirty, sl: blankLogic("", units[0]) };
|
||||
|
||||
const unsaved = createTag(te("Unsaved"), "warning");
|
||||
unsaved.hidden = true;
|
||||
const touched = (): void => {
|
||||
dirty = true;
|
||||
unsaved.hidden = false;
|
||||
};
|
||||
const name = createInputField({
|
||||
label: te("Name"),
|
||||
placeholder: te("NamePlaceholder"),
|
||||
onInput: touched,
|
||||
});
|
||||
name.root.classList.add("m01-sl__name");
|
||||
const unit = createSelectField({
|
||||
label: te("Unit"),
|
||||
options: units.map((u) => ({ value: u, text: u.replace(/^원\//, "") })),
|
||||
value: units[0],
|
||||
onChange: touched,
|
||||
});
|
||||
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet });
|
||||
undo.disabled = true;
|
||||
const close = createButton({
|
||||
label: te("Close"),
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void (async () => {
|
||||
if (await mayLeave()) void showStart();
|
||||
})(),
|
||||
});
|
||||
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-sl__strip",
|
||||
children: [
|
||||
name.root,
|
||||
unit.root,
|
||||
unsaved,
|
||||
el("div", {
|
||||
className: "m01-sl__actions",
|
||||
children: [
|
||||
undo,
|
||||
createButton({ label: te("Save"), onClick: notYet }),
|
||||
createButton({ label: te("Delete"), variant: "danger", onClick: notYet }),
|
||||
close,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("p", { className: "m01-sl__trial-note", text: te("Trial") }),
|
||||
el("div", {
|
||||
className: "m01-sl__layout",
|
||||
children: [
|
||||
part(te("Diagram"), "m01-sl__diagram"),
|
||||
part(te("Panel"), "m01-sl__panel"),
|
||||
part(te("Result"), "m01-sl__result"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
name.input.focus();
|
||||
};
|
||||
|
||||
// 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름
|
||||
openModal({
|
||||
title: te("Title"),
|
||||
@@ -305,24 +286,12 @@ function openSheetLogic(): void {
|
||||
beforeClose: mayLeave,
|
||||
mount: (host) => {
|
||||
body = host;
|
||||
(host.parentElement ?? host).addEventListener("keydown", onKey);
|
||||
void showStart();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text });
|
||||
|
||||
/** 고치는 화면의 자리 하나 — 제목 + 몸(2걸음에 다이어그램 · 시트 판 · 결과가 들어옴) */
|
||||
function part(title: string, className: string): HTMLElement {
|
||||
return el("section", {
|
||||
className: `m01-sl__part ${className}`,
|
||||
children: [
|
||||
el("h4", { text: title }),
|
||||
el("div", { className: "m01-sl__part-body", children: [muted(te("NotYet"))] }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function bigButton(label: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = createButton({ label, variant: "filled", onClick });
|
||||
button.classList.add("m01-sl__big");
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Entry_Paper.ts
|
||||
* 시트 로직 고치는 화면 — 위 띠 · 위 가로 다이어그램 · 아래 시트 판 + 종류별 손잡이 · 결과 · 잇기
|
||||
* 바뀔 때마다 기록(객체 그대로) → evalLogic(화면 즉시) → 저장 slSave(서버가 다시 셈한 값으로 바꿔 보임)
|
||||
* SheetLogic 의 다른 파일은 Entry 가 import() 로 늦게 불러 `mods` 로 넘김(엔진이 M01 첫 묶음에 안 들어가게).
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
createTag,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import type { SlOutsideAt, SlQuery } from "./M01_SL_Api";
|
||||
import type { SlEvalResult, SlOutsideValue } from "./M01_SL_Eval";
|
||||
import type { SheetLogic, SlKind } from "./M01_SL_Model";
|
||||
|
||||
/** SheetLogic 의 다른 파일 — 창을 열 때 한 번에 불러옴 */
|
||||
export const loadMods = () =>
|
||||
Promise.all([
|
||||
import("./M01_SL_Model"),
|
||||
import("./M01_SL_Eval"),
|
||||
import("./M01_SL_Api"),
|
||||
import("./M01_SL_Diagram"),
|
||||
import("./M01_SL_Panel"),
|
||||
import("./M01_SL_Table"),
|
||||
import("./M01_SL_Item"),
|
||||
import("./M01_SL_Result"),
|
||||
import("./M01_SL_Import"),
|
||||
import("../../A00_Common/spreadsheet/spreadsheet_recalc"),
|
||||
import("./M01_SL_LogicTools"),
|
||||
]).then(([model, ev, api, diagram, panel, table, item, result, imp, recalc, logic]) => ({
|
||||
model,
|
||||
ev,
|
||||
api,
|
||||
diagram,
|
||||
panel,
|
||||
table,
|
||||
item,
|
||||
result,
|
||||
imp,
|
||||
recalc,
|
||||
logic,
|
||||
}));
|
||||
export type Mods = Awaited<ReturnType<typeof loadMods>>;
|
||||
|
||||
const TEXT = {
|
||||
Name: ["이름", "Name"],
|
||||
NamePlaceholder: ["예: 돌쌓기 내 방식", "e.g. my stone wall"],
|
||||
Unit: ["결과 단위", "Result unit"],
|
||||
Undo: ["실행 취소", "Undo"],
|
||||
Save: ["저장", "Save"],
|
||||
Delete: ["지우기", "Delete"],
|
||||
Close: ["닫기", "Close"],
|
||||
Unsaved: ["저장 안 함", "Not saved"],
|
||||
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
|
||||
Diagram: ["상자와 선", "Boxes and lines"],
|
||||
Panel: ["고른 상자의 시트", "Sheet of the chosen box"],
|
||||
PickBox: [
|
||||
"위에서 상자를 고르면 그 상자의 시트가 여기 보임",
|
||||
"Choose a box above to see its sheet",
|
||||
],
|
||||
Result: ["결과", "Result"],
|
||||
NoName: ["이름을 넣어 주세요", "Enter a name"],
|
||||
SaveAnyway: [
|
||||
"고칠 곳이 있음 — {v} · 그래도 저장할까요?",
|
||||
"There are issues — {v} · Save anyway?",
|
||||
],
|
||||
Saving: ["저장 중", "Saving"],
|
||||
Saved: ["저장했음", "Saved"],
|
||||
SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"],
|
||||
Differs: [
|
||||
"저장 때 다시 셈한 값 {s} 원 — 화면 값 {c} 원과 다름",
|
||||
"Recalculated on save: {s} — differs from the screen value {c}",
|
||||
],
|
||||
Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"],
|
||||
Deleted: ["지웠음", "Deleted"],
|
||||
Outside: ["바깥 값을 못 받음 — {v}", "Couldn't get outside values — {v}"],
|
||||
Kind_input: ["입력값", "Input"],
|
||||
Kind_const: ["고정값", "Fixed value"],
|
||||
Kind_table: ["표", "Table"],
|
||||
Kind_calc: ["계산", "Calculation"],
|
||||
Kind_item: ["항목", "Item"],
|
||||
Kind_logic: ["다른 로직", "Other logic"],
|
||||
Kind_result: ["결과", "Result"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
|
||||
const tp = (key: TextKey, vars: Record<string, string | number> = {}): string =>
|
||||
Object.entries(vars).reduce(
|
||||
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
|
||||
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
|
||||
);
|
||||
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text });
|
||||
|
||||
/** 바깥 값 자리 — 항목 단가 · 출처가 있는 알약 · 가져온 로직의 바깥 값(같은 칸은 앞의 것) */
|
||||
export function outsidePlaces(M: Mods, sl: SheetLogic): SlOutsideAt[] {
|
||||
const at = new Map<string, SlOutsideAt>();
|
||||
const put = (p: SlOutsideAt): void => {
|
||||
const k = `${p.sheet}!${p.cell.toUpperCase()}`;
|
||||
// 아직 고르지 않은 상자(빈 번호)는 묻지 않음
|
||||
const it = p.item as { key?: string; table?: string; element?: string };
|
||||
if (!(it.key ?? it.table ?? it.element)) return;
|
||||
if (!at.has(k)) at.set(k, p);
|
||||
};
|
||||
M.item.itemOutsides(sl).forEach(put);
|
||||
// 상자에 출처가 적힌 알약 → 넘길 값을 실은 물음(outsideOf) → 출처를 짐작한 나머지(넘길 값 없음) 차례
|
||||
const cells = M.ev.outsideCells(sl);
|
||||
const fromCell = (explicit: boolean): void => {
|
||||
for (const c of cells) {
|
||||
const node = sl.nodes.find((n) => n.id === c.node);
|
||||
if (node && c.source && !!node.source === explicit)
|
||||
put({ item: { id: c.id, ...c.source } as SlQuery, sheet: node.sheetId, cell: c.cell });
|
||||
}
|
||||
};
|
||||
fromCell(true);
|
||||
// 넘길 입력값이 수식 칸이면 계산값이 있어야 함(없으면 null 로 넘어가 단가가 0)
|
||||
M.imp.outsideOf(sl, M.recalc.recalcWorkbook(sl.workbook)).forEach(put);
|
||||
fromCell(false);
|
||||
return [...at.values()];
|
||||
}
|
||||
|
||||
/** 열 때 바깥 값 넣기 — 받은 것만 넣고 못 받은 것은 쉬운 말 목록 */
|
||||
export async function fillOutside(
|
||||
M: Mods,
|
||||
sl: SheetLogic,
|
||||
): Promise<{ sl: SheetLogic; errors: string[] }> {
|
||||
const places = outsidePlaces(M, sl);
|
||||
if (!places.length) return { sl, errors: [] };
|
||||
const answers = await M.api.slOutside(places.map((p) => p.item));
|
||||
const values: SlOutsideValue[] = [];
|
||||
const errors: string[] = [];
|
||||
for (const p of places) {
|
||||
const node = sl.nodes.find((n) => n.sheetId === p.sheet);
|
||||
const a = answers.find((x) => x.id === p.item.id);
|
||||
if (!node) continue;
|
||||
if (!a?.ok) errors.push(`${node.name} ${p.cell}${a?.reason ? ` — ${a.reason}` : ""}`);
|
||||
else values.push({ node: node.id, cell: p.cell, value: a.rows ?? a.value ?? null });
|
||||
}
|
||||
return { sl: M.ev.applyOutside(sl, values), errors };
|
||||
}
|
||||
|
||||
/* 시트 판 높이 — 그 시트가 쓰는 줄 수 + 두 줄(안쪽 세로 밀기 없게 · 사용자 요청)
|
||||
* ponytail: 편집기 줄 높이 · 띠 높이를 숫자로 둠 — 편집기 꾸밈이 바뀌면 같이 고침 */
|
||||
const ROW_PX = 20;
|
||||
const CHROME_PX = 26 + 24 + 24 + 20; // 위 띠 · 칸 머리 · 아래 상태줄 · 가로 밀기 막대
|
||||
function sheetHeight(sheet: { 칸: Record<string, unknown> } | null): number {
|
||||
const rows = Object.keys(sheet?.칸 ?? {}).reduce(
|
||||
(most, a1) => Math.max(most, Number(a1.replace(/^[A-Z]+/, "")) || 0),
|
||||
0,
|
||||
);
|
||||
return CHROME_PX + (Math.max(rows, 6) + 2) * ROW_PX;
|
||||
}
|
||||
|
||||
/** 서버가 다시 셈한 합계 — 결과 상자 「계」 알약 칸의 서버 계산값(수식이 아니면 칸 값) */
|
||||
function serverTotal(sl: SheetLogic): number | null {
|
||||
const box = sl.nodes.find((n) => n.kind === "result");
|
||||
const pill = box?.pillsOut.find((p) => p.name === "계");
|
||||
if (!box || !pill) return null;
|
||||
const cell = pill.cell.toUpperCase();
|
||||
const got = sl.workbook.계산값?.[box.sheetId]?.[cell];
|
||||
if (got && "수" in got) return Number(got.수);
|
||||
const plain = sl.workbook.시트.find((s) => s.id === box.sheetId)?.칸[cell]?.값;
|
||||
return typeof plain === "number" ? plain : null;
|
||||
}
|
||||
|
||||
export interface PaperCtx {
|
||||
/** 저장해 둔 것을 열었으면 true — 새로 · 가져오기는 처음부터 「저장 안 함」 */
|
||||
saved: boolean;
|
||||
units: string[];
|
||||
notes: string[];
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
/** 고치는 화면 — 돌려준 손잡이로 Entry 가 닫기 물음 · Ctrl+Z · Ctrl+S 를 부름 */
|
||||
export function showPaper(
|
||||
M: Mods,
|
||||
host: HTMLElement,
|
||||
first: SheetLogic,
|
||||
ctx: PaperCtx,
|
||||
): { dirty(): boolean; save(): Promise<void>; undo(): void } {
|
||||
let sl = first;
|
||||
let savedSl: SheetLogic | null = ctx.saved ? first : null;
|
||||
let stack: SheetLogic[] = [];
|
||||
let selected: string | null = first.nodes[0]?.id ?? null;
|
||||
let values: SlEvalResult;
|
||||
let busy = false;
|
||||
let notes = ctx.notes;
|
||||
/** 마지막으로 받은 바깥 값 물음 — 넘길 입력값이 바뀌면 다시 받음 */
|
||||
const sigOf = (x: SheetLogic): string =>
|
||||
JSON.stringify(outsidePlaces(M, x).map((p) => [p.sheet, p.cell, p.item]));
|
||||
let outsideSig = sigOf(first);
|
||||
let outsideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
/** 바깥 값 다시 받기 — 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */
|
||||
const refreshOutside = (): void => {
|
||||
const sig = sigOf(sl);
|
||||
if (sig === outsideSig) return;
|
||||
outsideSig = sig;
|
||||
clearTimeout(outsideTimer);
|
||||
outsideTimer = setTimeout(() => {
|
||||
const from = sl;
|
||||
void fillOutside(M, from).then(({ sl: next, errors }) => {
|
||||
if (sl !== from) {
|
||||
// 그 사이 또 바뀜 — 지금 것으로 다시 받음(옛 답 · 옛 까닭은 버림)
|
||||
outsideSig = "";
|
||||
return refreshOutside();
|
||||
}
|
||||
if (savedSl === from) savedSl = next;
|
||||
sl = next;
|
||||
outsideSig = sigOf(next);
|
||||
notes = errors;
|
||||
redraw();
|
||||
});
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const unsaved = createTag(tp("Unsaved"), "warning");
|
||||
const note = el("p", { className: "m01-sl__note", attrs: { role: "status" } });
|
||||
const diagramHost = el("div", { className: "m01-sl__part-body" });
|
||||
// 시트 판은 Panel 이 제 상태로 쥠 — 안을 갈아끼우지 않고 숨기기만(갈아끼우면 Panel 이 편집기가 있다고 여김)
|
||||
const panelHost = el("div");
|
||||
const pickHint = muted(tp("PickBox"));
|
||||
const toolsHost = el("div");
|
||||
const resultHost = el("div", { className: "m01-sl__part-body" });
|
||||
|
||||
const evalNow = (): SlEvalResult => {
|
||||
try {
|
||||
return M.ev.evalLogic(sl);
|
||||
} catch (e) {
|
||||
const none = { 노무비: null, 재료비: null, 경비: null, 계: null };
|
||||
return { pills: {}, pillsIn: {}, result: none, errors: [M.api.slPlain(e)] };
|
||||
}
|
||||
};
|
||||
|
||||
/** 바뀐 것을 받아 쌓음 — 구조가 바뀌면 비율 바탕을 다시 엮음 */
|
||||
const edit = (next: SheetLogic, structural = false): void => {
|
||||
if (next === sl) return;
|
||||
stack = [...stack, sl];
|
||||
sl = structural ? M.item.refreshBases(next) : next;
|
||||
redraw();
|
||||
};
|
||||
/** 다이어그램이 부른 바꿈 — 다이어그램이 제 옛 그림으로 한 번 더 그리므로 다음 틱에 다시 그림 */
|
||||
const editFromDiagram = (next: SheetLogic): void => {
|
||||
edit(next, true);
|
||||
setTimeout(redraw);
|
||||
};
|
||||
|
||||
const name = createInputField({
|
||||
label: tp("Name"),
|
||||
placeholder: tp("NamePlaceholder"),
|
||||
value: sl.name,
|
||||
onInput: (v) => edit({ ...sl, name: v }),
|
||||
});
|
||||
name.root.classList.add("m01-sl__name");
|
||||
const units = ctx.units.includes(sl.resultUnit) ? ctx.units : [sl.resultUnit, ...ctx.units];
|
||||
const unit = createSelectField({
|
||||
label: tp("Unit"),
|
||||
options: units.map((u) => ({ value: u, text: u.replace(/^원\//, "") })),
|
||||
value: sl.resultUnit,
|
||||
onChange: (v) => edit({ ...sl, resultUnit: v }),
|
||||
});
|
||||
|
||||
const kindName = (k: SlKind): string => tp(`Kind_${k}` as TextKey);
|
||||
const diagramOn = {
|
||||
select: (id: string | null): void => {
|
||||
selected = id;
|
||||
drawPanel();
|
||||
},
|
||||
connect: (from: Parameters<Mods["model"]["connect"]>[1], to: typeof from): string | null => {
|
||||
const got = M.model.connect(sl, from, to);
|
||||
if (got.why) return got.why;
|
||||
editFromDiagram(got.sl);
|
||||
return null;
|
||||
},
|
||||
disconnect: (edgeId: string): void => editFromDiagram(M.model.disconnect(sl, edgeId)),
|
||||
add: (kind: SlKind): void => {
|
||||
const got = M.model.addNode(sl, kind, kindName(kind));
|
||||
selected = got.node.id;
|
||||
editFromDiagram(got.sl);
|
||||
},
|
||||
remove: (nodeId: string): void => {
|
||||
if (selected === nodeId) selected = null;
|
||||
editFromDiagram(M.model.removeNode(sl, nodeId));
|
||||
},
|
||||
};
|
||||
|
||||
/** 항목 단가 — 고른 뒤 서버에서 받아 넣음(같은 조작의 뒤끝이라 기록을 더 쌓지 않음) */
|
||||
const loadPrices = (nodeId: string): void => {
|
||||
void M.item.loadPrices(sl, nodeId).then(({ sl: next, why }) => {
|
||||
if (why) note.textContent = why;
|
||||
if (next !== sl) {
|
||||
sl = next;
|
||||
redraw();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const drawPanel = (): void => {
|
||||
const node = sl.nodes.find((n) => n.id === selected);
|
||||
if (!node) {
|
||||
panelHost.hidden = true;
|
||||
pickHint.hidden = false;
|
||||
toolsHost.replaceChildren();
|
||||
return;
|
||||
}
|
||||
panelHost.hidden = false;
|
||||
pickHint.hidden = true;
|
||||
toolsHost.className = ""; // 표 손잡이가 제 꾸밈 이름을 붙임 — 다른 종류로 바뀌면 지움
|
||||
panelHost.style.setProperty("--sl-sheet-h", `${sheetHeight(M.model.nodeSheet(sl, node.id))}px`);
|
||||
M.panel.renderPanel(panelHost, sl, node.id, {
|
||||
change: (next) => edit(next),
|
||||
pill: (nodeId, cell, dir, pillName) => edit(M.model.setPill(sl, nodeId, cell, dir, pillName)),
|
||||
});
|
||||
if (node.kind === "table")
|
||||
M.table.renderTableTools(toolsHost, sl, node.id, { change: (next) => edit(next, true) });
|
||||
else if (node.kind === "item")
|
||||
M.item.renderItemTools(toolsHost, sl, node.id, {
|
||||
change: (next) => {
|
||||
edit(next, true);
|
||||
loadPrices(node.id);
|
||||
},
|
||||
});
|
||||
else if (node.kind === "logic")
|
||||
M.logic.renderLogicTools(toolsHost, sl, node.id, {
|
||||
change: (next) => {
|
||||
edit(next, true);
|
||||
loadPrices(node.id);
|
||||
},
|
||||
});
|
||||
else toolsHost.replaceChildren();
|
||||
};
|
||||
|
||||
const redraw = (): void => {
|
||||
values = evalNow();
|
||||
refreshOutside();
|
||||
unsaved.hidden = sl === savedSl;
|
||||
undo.disabled = !stack.length;
|
||||
deleteButton.hidden = !sl.key;
|
||||
const shown = Object.fromEntries(
|
||||
sl.nodes.map((n) => [n.id, { ...values.pillsIn[n.id], ...values.pills[n.id] }]),
|
||||
);
|
||||
M.diagram.renderDiagram(diagramHost, sl, diagramOn, { values: shown, selected });
|
||||
drawPanel();
|
||||
M.result.renderResult(resultHost, sl, values, { change: (next) => edit(next) });
|
||||
note.textContent = [...notes, ...values.errors].slice(0, 4).join("\n");
|
||||
};
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (busy) return;
|
||||
if (!sl.name.trim()) {
|
||||
note.textContent = tp("NoName");
|
||||
return void name.input.focus();
|
||||
}
|
||||
let problems: string[] = [];
|
||||
try {
|
||||
problems = M.ev.checkLogic(sl);
|
||||
} catch {
|
||||
problems = [];
|
||||
}
|
||||
const ask = tp("SaveAnyway", { v: problems.slice(0, 3).join(" · ") });
|
||||
if (problems.length && !(await showConfirmDialog(ask, tp("Save")))) return;
|
||||
busy = true;
|
||||
note.textContent = tp("Saving");
|
||||
const screen = values.result.계;
|
||||
try {
|
||||
const got = await M.api.slSave({ ...sl, outside: outsidePlaces(M, sl) });
|
||||
const server = serverTotal(got.logic);
|
||||
sl = got.logic;
|
||||
savedSl = sl;
|
||||
stack = [];
|
||||
notes = [];
|
||||
redraw();
|
||||
const differs = server !== null && screen !== null && Math.abs(server - screen) > 0.5;
|
||||
if (differs) notes = [tp("Differs", { s: formatMoney(server), c: formatMoney(screen) })];
|
||||
note.textContent = [...notes, ...values.errors].join("\n");
|
||||
showToast(tp("Saved"), "success");
|
||||
} catch (e) {
|
||||
note.textContent = tp("SaveFailed", { v: M.api.slPlain(e) });
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
const doUndo = (): void => {
|
||||
const back = stack[stack.length - 1];
|
||||
if (!back) return;
|
||||
stack = stack.slice(0, -1);
|
||||
sl = back;
|
||||
name.input.value = sl.name;
|
||||
unit.select.value = sl.resultUnit;
|
||||
redraw();
|
||||
};
|
||||
|
||||
const doDelete = async (): Promise<void> => {
|
||||
if (!sl.key || busy) return;
|
||||
if (!(await showConfirmDialog(tp("Delete_Ask", { v: sl.name }), tp("Delete")))) return;
|
||||
try {
|
||||
await M.api.slDelete(sl.key);
|
||||
savedSl = sl;
|
||||
showToast(tp("Deleted"), "success");
|
||||
ctx.onClose();
|
||||
} catch (e) {
|
||||
note.textContent = M.api.slPlain(e);
|
||||
}
|
||||
};
|
||||
|
||||
const undo = createButton({ label: tp("Undo"), variant: "ghost", onClick: doUndo });
|
||||
const deleteButton = createButton({
|
||||
label: tp("Delete"),
|
||||
variant: "danger",
|
||||
onClick: () => void doDelete(),
|
||||
});
|
||||
const close = createButton({ label: tp("Close"), variant: "ghost", onClick: ctx.onClose });
|
||||
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-sl__strip",
|
||||
children: [
|
||||
name.root,
|
||||
unit.root,
|
||||
unsaved,
|
||||
el("div", {
|
||||
className: "m01-sl__actions",
|
||||
children: [
|
||||
undo,
|
||||
createButton({ label: tp("Save"), onClick: () => void save() }),
|
||||
deleteButton,
|
||||
close,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("p", { className: "m01-sl__trial-note", text: tp("Trial") }),
|
||||
note,
|
||||
el("div", {
|
||||
className: "m01-sl__layout",
|
||||
children: [
|
||||
el("section", {
|
||||
className: "m01-sl__part m01-sl__diagram",
|
||||
children: [el("h4", { text: tp("Diagram") }), diagramHost],
|
||||
}),
|
||||
el("section", {
|
||||
className: "m01-sl__part m01-sl__panel",
|
||||
children: [
|
||||
el("h4", { text: tp("Panel") }),
|
||||
el("div", {
|
||||
className: "m01-sl__part-body",
|
||||
children: [pickHint, toolsHost, panelHost],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("section", {
|
||||
className: "m01-sl__part m01-sl__result",
|
||||
children: [el("h4", { text: tp("Result") }), resultHost],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
redraw();
|
||||
if (!sl.name) name.input.focus();
|
||||
return { dirty: () => sl !== savedSl, save, undo: doUndo };
|
||||
}
|
||||
@@ -9,7 +9,13 @@ import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import { nodeSheet, type SheetLogic, type SlNode, type SlOutside } from "./M01_SL_Model";
|
||||
import {
|
||||
nodeSheet,
|
||||
type SheetLogic,
|
||||
type SlNode,
|
||||
type SlOutside,
|
||||
type SlSource,
|
||||
} from "./M01_SL_Model";
|
||||
|
||||
/** 알약 값 — 수 · 글 · 참거짓 · 빈 칸 */
|
||||
export type SlValue = number | string | boolean | null;
|
||||
@@ -48,6 +54,16 @@ const TEXT = {
|
||||
"Outside value 「{pill}」 of 「{box}」 not filled yet",
|
||||
],
|
||||
NoResult: ["결과 상자가 없음", "There is no result box"],
|
||||
/** {box} */
|
||||
HeadChanged: [
|
||||
"「{box}」 표 머리 줄이 바뀜 — 표를 다시 골라야 함",
|
||||
"Table headers of 「{box}」 changed — choose the table again",
|
||||
],
|
||||
/** {cell} {why} */
|
||||
NoAnswer: [
|
||||
"{cell} 칸 바깥 값을 못 받음 {why}",
|
||||
"Couldn't get the outside value for {cell} {why}",
|
||||
],
|
||||
NoLine: ["결과 상자에 이어진 선이 없음", "No line reaches the result box"],
|
||||
} as const;
|
||||
|
||||
@@ -63,13 +79,67 @@ const tl = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =
|
||||
export function outsideCells(sl: SheetLogic): SlOutside[] {
|
||||
const kindOf = (n: SlNode): SlOutside["kind"] =>
|
||||
n.kind === "table" ? "table" : n.kind === "logic" ? "logic" : "price";
|
||||
const sourceOf = (n: SlNode): SlSource | undefined =>
|
||||
n.source ??
|
||||
(n.ref && n.kind === "table" ? { kind: "표", table: n.ref } : undefined) ??
|
||||
(n.ref && n.kind === "logic" ? { kind: "로직", key: n.ref, inputs: {} } : undefined);
|
||||
return sl.nodes.flatMap((n) =>
|
||||
[...n.pillsIn, ...n.pillsOut]
|
||||
.filter((p) => p.outside)
|
||||
.map((p) => ({ node: n.id, cell: p.cell, kind: kindOf(n), ref: n.ref ?? "" })),
|
||||
.map((p) => {
|
||||
const source = sourceOf(n);
|
||||
const at = { id: `${n.id}!${p.cell}`, node: n.id, cell: p.cell, kind: kindOf(n) };
|
||||
return { ...at, ref: n.ref ?? "", ...(source ? { source } : {}) };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 서버 바깥 값 답 한 줄(`/api/m01/sl/outside`) */
|
||||
export interface SlOutsideAnswer {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
value?: number | null;
|
||||
rows?: (string | number | null)[][];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** 서버 답 → 넣을 값 · 못 받은 것은 쉬운 말 */
|
||||
export function answersToValues(
|
||||
cells: SlOutside[],
|
||||
answers: SlOutsideAnswer[],
|
||||
sl?: SheetLogic,
|
||||
): { values: SlOutsideValue[]; errors: string[] } {
|
||||
const values: SlOutsideValue[] = [];
|
||||
const errors: string[] = [];
|
||||
for (const c of cells) {
|
||||
const a = answers.find((x) => x.id === c.id);
|
||||
if (!a || !a.ok) {
|
||||
errors.push(tl("NoAnswer", { cell: c.cell, why: a?.reason ?? "" }));
|
||||
continue;
|
||||
}
|
||||
const head = sl && a.rows ? headChanged(sl, c, a.rows[0] ?? []) : null;
|
||||
if (head) errors.push(head);
|
||||
values.push({ node: c.node, cell: c.cell, value: a.rows ?? a.value ?? null });
|
||||
}
|
||||
return { values, errors };
|
||||
}
|
||||
|
||||
/** 표 블록 머리 줄이 시트에 있던 것과 다르면 쉬운 말(열 차례가 바뀌면 찾기 판이 엉뚱한 열을 봄) */
|
||||
function headChanged(
|
||||
sl: SheetLogic,
|
||||
c: SlOutside,
|
||||
head: (string | number | null)[],
|
||||
): string | null {
|
||||
const sheet = nodeSheet(sl, c.node);
|
||||
const at = parseA1(c.cell);
|
||||
if (!sheet || !at) return null;
|
||||
const now = head.map((_, dc) => sheet.칸[toA1(at.r, at.c + dc)]?.값 ?? null);
|
||||
if (now.every((x) => x === null)) return null; // 처음 채움
|
||||
const same = head.length === now.length && head.every((h, i) => h === now[i]);
|
||||
const box = sl.nodes.find((n) => n.id === c.node)?.name ?? "";
|
||||
return same ? null : tl("HeadChanged", { box });
|
||||
}
|
||||
|
||||
/** 칸 하나에 값 — null 이면 값을 뺌(서식은 둠) */
|
||||
function putValue(칸: Record<string, Cell>, cell: string, value: SlValue): void {
|
||||
const style = 칸[cell]?.서식;
|
||||
@@ -93,6 +163,15 @@ export function applyOutside(sl: SheetLogic, values: SlOutsideValue[]): SheetLog
|
||||
v.value.forEach((line, dr) =>
|
||||
line.forEach((x, dc) => putValue(sheet.칸, toA1(at.r + dr, at.c + dc), x)),
|
||||
);
|
||||
// 표가 줄었으면 블록 열 아래 남은 옛 줄(값 칸)을 지움 — 수식 칸(찾기 판 · 열쇠)은 블록 열 밖이라 안 건드림
|
||||
const width = Math.max(0, ...v.value.map((line) => line.length));
|
||||
const last = at.r + v.value.length - 1;
|
||||
for (const a of Object.keys(sheet.칸)) {
|
||||
const p = parseA1(a);
|
||||
const c = sheet.칸[a];
|
||||
if (p && p.r > last && p.c >= at.c && p.c < at.c + width && c.식 === undefined)
|
||||
putValue(sheet.칸, a, null);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Import.ts
|
||||
* 시트 로직 — 기존 로직 가져오기(정함 9) · 로직 줄 → 시트 로직(상자 = 시트 · 알약 · 선) · DOM 없음
|
||||
*
|
||||
* 읽기 = LogicBuilder Blocks(`readLogic`)로 입력값을 받고 · 식은 엔진 문법(`master_formula`) 그대로 읽어
|
||||
* 엑셀 수식으로: 만약 → IF · 최소/최대 → MIN/MAX · 올림/버림/반올림 → ROUNDUP/ROUNDDOWN/ROUND ·
|
||||
* == → = · != → <> · 그리고/또는 → AND/OR · 찾기 → 표 상자 · 로직 → 다른 로직 상자 · 노임 키 → 바깥 값.
|
||||
* 다른 상자 값은 받는 상자에 입력 알약 한 칸(「'시트'!칸」 참조 수식) + 선 하나 — 식은 제 상자 칸만 봄.
|
||||
*
|
||||
* 상자 시트 꼴 — A 이름 · B 값 · C 설명(1행 머리) · 알약은 B 칸.
|
||||
* 표 상자 = 조건 알약(B) · 결과 알약(B) · E1 부터 품셈 표(머리 줄 + 줄 · 조건 칸 차례 → 값 칸 차례 ·
|
||||
* 범위 조건은 「이름·아래」 「이름·위」 두 칸) · 그 오른쪽에 「맞음」 칸(줄마다 조건 셈 → 1/0) ·
|
||||
* 결과 = 맞는 줄이 하나일 때 INDEX/MATCH · 아니면 #N/A(엔진 `find` 의 해당 없음 · 중복).
|
||||
* 빈 조건 칸은 무엇이든 맞음 · 범위규칙(초과·이하 · 이상·이하 · 이상·미만 · 이하)은 엔진과 같음.
|
||||
* 바깥 값(노임 · 자재 · 장비 · 로직 · 표)은 알약 `outside` · 실제 값은 열 때 · 저장 때 넣음(`outsideOf`).
|
||||
* 못 바꾸는 꼴 = 그 칸 #N/A + 칸 메모(원래 글) + 쉬운 말 한 줄(`problems`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { colName, quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import type {
|
||||
Cell,
|
||||
DataValidationKind,
|
||||
Sheet,
|
||||
Workbook,
|
||||
} from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { HoLine, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { readLogic } from "../LogicBuilder/M01_LB_Blocks";
|
||||
import type { SheetLogic, SlEdge, SlKind, SlNode, SlPill } from "./M01_SL_Model";
|
||||
import { applyItemLayout, refreshBases, type ItemRef } from "./M01_SL_Item";
|
||||
import {
|
||||
parseFormula,
|
||||
RULES,
|
||||
tableCols,
|
||||
tableRows,
|
||||
type Ast,
|
||||
type SlImportTable,
|
||||
} from "./M01_SL_Import_Parse";
|
||||
|
||||
const TEXT = {
|
||||
Name: ["이름", "Name"],
|
||||
Value: ["값", "Value"],
|
||||
Note: ["설명", "Note"],
|
||||
Qty: ["수량", "Quantity"],
|
||||
Price: ["단가", "Unit price"],
|
||||
Amount: ["금액", "Amount"],
|
||||
Result: ["결과", "Result"],
|
||||
Sum: ["항목 합", "Item total"],
|
||||
Input: ["입력값", "Input"],
|
||||
Table: ["표", "Table"],
|
||||
Calc: ["계산", "Calc"],
|
||||
Item: ["항목", "Item"],
|
||||
Logic: ["다른 로직", "Other logic"],
|
||||
Const: ["고정값", "Constant"],
|
||||
Outside: ["바깥 값 — 열 때 · 저장 때 넣음", "Outside value — filled on open and save"],
|
||||
Match: ["맞음", "Match"],
|
||||
Pass: ["넘길 값", "Passed value"],
|
||||
Cant: ["못 바꿈 — ", "Not converted — "],
|
||||
NoTable: ["품셈 표 「{v}」 를 못 받음", "Table “{v}” was not given"],
|
||||
NoCol: ["표에 없는 칸 「{v}」", "Column “{v}” is not in the table"],
|
||||
Rule: ["모르는 범위규칙 「{v}」", "Unknown range rule “{v}”"],
|
||||
Unknown: ["모르는 이름 「{v}」", "Unknown name “{v}”"],
|
||||
Hole: ["입력에 따라 바뀌는 요소 「{v}」", "Element changes with an input “{v}”"],
|
||||
Field: ["요소의 칸 값 「{v}」", "Element field “{v}”"],
|
||||
FindAll: ["표 줄을 통째로 씀", "Uses a whole table row"],
|
||||
Parse: ["읽을 수 없는 글", "Text that can't be read"],
|
||||
PassCalc: ["넘길 값에 계산이 들어 있음", "A passed value is calculated"],
|
||||
} as const;
|
||||
const t = (k: keyof typeof TEXT, v = ""): string =>
|
||||
TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v);
|
||||
|
||||
export type { SlImportTable };
|
||||
|
||||
export interface SlImportOptions {
|
||||
/** 표 키 → 품셈 표(열 때 받은 것) — 없으면 그 표 상자는 못 바꿈 */
|
||||
tables?: Record<string, SlImportTable>;
|
||||
/** 입력값 → 예시값 — 없으면 선택 목록 첫째 · 범위 낮은 끝 · 숫자 0 */
|
||||
examples?: Record<string, number | string>;
|
||||
/** 키 → 보일 이름(직종 · 자재 · 다른 로직) — 상자 이름에 번호 대신 */
|
||||
names?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SlImportReport {
|
||||
sl: SheetLogic;
|
||||
/** 못 바꾼 꼴(쉬운 말) — 비면 다 바뀜 */
|
||||
problems: string[];
|
||||
}
|
||||
|
||||
/* ── 짓기 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
class Fail extends Error {}
|
||||
const KEY = /^[A-Z]{2}\d{6}$/;
|
||||
const COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
type Cost = (typeof COSTS)[number];
|
||||
const lit = (v: unknown): string =>
|
||||
typeof v === "number" ? String(v) : `"${String(v).replace(/"/g, '""')}"`;
|
||||
const cellOf = (v: unknown): Cell | null =>
|
||||
typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? { 값: v } : null;
|
||||
|
||||
/** 표 상자 하나 = 표 + 조건 묶음 하나(같은 표 · 같은 조건이면 결과 알약만 더함) */
|
||||
interface TableBox {
|
||||
node: SlNode;
|
||||
table: SlImportTable;
|
||||
/** 결과 칸 이름 → 표 열 글자(범위 칸은 아래 · 위) */
|
||||
colOf: Map<string, { x: string; hi?: string }>;
|
||||
rows: number;
|
||||
/** 다음 빈 열(맞음 · 결과 셈 칸) */
|
||||
free: number;
|
||||
/** 결과 셈에 쓸 「맞음」 열(엔진 `find` 의 위 끝 고르기까지) */
|
||||
hit: string;
|
||||
}
|
||||
|
||||
class Build {
|
||||
readonly book: Workbook = { 종류: "통합문서", 판: 1, 열: "sheet-logic", 서식: [{}], 시트: [] };
|
||||
readonly nodes: SlNode[] = [];
|
||||
readonly edges: SlEdge[] = [];
|
||||
readonly problems: string[] = [];
|
||||
private readonly next = new Map<string, number>();
|
||||
private readonly sheets = new Map<string, Sheet>();
|
||||
private readonly linked = new Map<string, string>();
|
||||
readonly inputs = new Map<string, SlNode>();
|
||||
readonly mids = new Map<string, SlNode>();
|
||||
readonly tables = new Map<string, TableBox>();
|
||||
readonly logics = new Map<string, SlNode>();
|
||||
readonly consts = new Map<string, SlNode>();
|
||||
readonly specs = new Map<string, LogicInput>();
|
||||
/** 키 → 보일 이름 — 호표 줄 이름 · 받은 이름 */
|
||||
readonly names = new Map<string, string>();
|
||||
|
||||
constructor(readonly opts: SlImportOptions) {
|
||||
for (const [k, v] of Object.entries(opts.names ?? {})) this.names.set(k, v);
|
||||
}
|
||||
|
||||
/** 상자 하나 + 시트 — `head` 면 1행 머리(이름 · 값 · 설명) · 항목 틀은 머리 없음 */
|
||||
node(kind: SlKind, name: string, stage: number, ref?: string, head = true): SlNode {
|
||||
const n = this.nodes.length + 1;
|
||||
const word = { input: "Input", const: "Const", table: "Table", calc: "Calc", item: "Item", logic: "Logic", result: "Result" }[kind] as keyof typeof TEXT; // prettier-ignore
|
||||
const base = kind === "result" ? t("Result") : `${t(word)} · ${name}`.slice(0, 31);
|
||||
let title = base;
|
||||
for (let k = 2; [...this.sheets.values()].some((s) => s.이름 === title); k++)
|
||||
title = `${base.slice(0, 28)} ${k}`;
|
||||
const sheet: Sheet = {
|
||||
id: `s${n}`,
|
||||
이름: title,
|
||||
칸: head ? { A1: { 값: t("Name") }, B1: { 값: t("Value") }, C1: { 값: t("Note") } } : {},
|
||||
};
|
||||
this.book.시트.push(sheet);
|
||||
this.sheets.set(sheet.id, sheet);
|
||||
const node: SlNode = {
|
||||
id: `n${n}`,
|
||||
kind,
|
||||
name,
|
||||
sheetId: sheet.id,
|
||||
pillsIn: [],
|
||||
pillsOut: [],
|
||||
stage,
|
||||
...(ref ? { ref } : {}),
|
||||
};
|
||||
this.nodes.push(node);
|
||||
this.next.set(node.id, 2);
|
||||
return node;
|
||||
}
|
||||
|
||||
sheet(node: SlNode): Sheet {
|
||||
return this.sheets.get(node.sheetId)!;
|
||||
}
|
||||
|
||||
/** 줄 하나 더함 — A 이름 · B 값(또는 식) · C 설명 → B 칸 주소 */
|
||||
row(node: SlNode, label: string, cell: Cell, note = ""): string {
|
||||
const r = this.next.get(node.id)!;
|
||||
this.next.set(node.id, r + 1);
|
||||
const s = this.sheet(node);
|
||||
s.칸[`A${r}`] = { 값: label };
|
||||
s.칸[`B${r}`] = cell;
|
||||
if (note) s.칸[`C${r}`] = { 값: note };
|
||||
return `B${r}`;
|
||||
}
|
||||
|
||||
pill(node: SlNode, dir: "in" | "out", pill: SlPill): void {
|
||||
const list = dir === "in" ? node.pillsIn : node.pillsOut;
|
||||
let name = pill.name;
|
||||
for (let k = 2; list.some((p) => p.name === name); k++) name = `${pill.name} ${k}`;
|
||||
list.push({ ...pill, name });
|
||||
}
|
||||
|
||||
/** 출력 알약 하나 — 같은 이름이 있으면 그 칸 */
|
||||
out(node: SlNode, name: string, cell: Cell, note = ""): string {
|
||||
const had = node.pillsOut.find((p) => p.name === name);
|
||||
if (had) return had.cell;
|
||||
const at = this.row(node, name, cell, note);
|
||||
this.pill(node, "out", { name, cell: at });
|
||||
return at;
|
||||
}
|
||||
|
||||
/** 다른 상자 출력 알약 → 이 상자 입력 알약 한 칸 + 선 · 이 상자 안 칸 주소
|
||||
* `into` = 이미 있는 입력 알약(넘길 값 칸)에 바로 이음 */
|
||||
link(to: SlNode, from: SlNode, pill: string, into?: SlPill): string {
|
||||
const id = `${to.id}<${from.id}:${pill}`;
|
||||
const had = this.linked.get(id);
|
||||
if (had && !into) return had;
|
||||
const src = from.pillsOut.find((p) => p.name === pill)!;
|
||||
const ref: Cell = { 식: `${quoteSheet(this.sheet(from).이름)}!${src.cell}` };
|
||||
let target = into;
|
||||
if (target) this.sheet(to).칸[target.cell] = ref;
|
||||
else {
|
||||
const name = from.kind === "input" || from.kind === "calc" ? pill : `${pill} · ${from.name}`;
|
||||
this.pill(to, "in", { name, cell: this.row(to, name, ref) });
|
||||
target = to.pillsIn[to.pillsIn.length - 1];
|
||||
}
|
||||
this.edges.push({
|
||||
id: `e${this.edges.length + 1}`,
|
||||
from: { node: from.id, pill },
|
||||
to: { node: to.id, pill: target.name },
|
||||
});
|
||||
if (!had) this.linked.set(id, target.cell);
|
||||
return target.cell;
|
||||
}
|
||||
|
||||
/** 못 바꾸는 꼴 — 칸 #N/A + 메모(원래 글) + 쉬운 말 */
|
||||
cant(node: SlNode, at: string, why: string, src: string): void {
|
||||
const s = this.sheet(node);
|
||||
s.칸[at] = { 식: "NA()" };
|
||||
(s.comments ??= {})[at] = src;
|
||||
s.칸[`C${at.slice(1)}`] = { 값: `${t("Cant")}${why}` };
|
||||
this.problems.push(`${node.name} — ${why}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 식 → 엑셀 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
interface Scope {
|
||||
b: Build;
|
||||
/** 식이 놓일 상자 */
|
||||
here: SlNode;
|
||||
/** 덧줄 — 비목 합(앞 덧줄까지) 을 주는 상자 · 알약 */
|
||||
costs?: Record<Cost, [SlNode, string][]>;
|
||||
/** 덧줄 — 앞 덧줄 이름 · 줄.'이름' */
|
||||
extras?: Map<string, [SlNode, string]>;
|
||||
lines?: Map<string, [SlNode, string]>;
|
||||
}
|
||||
|
||||
function excel(n: Ast, sc: Scope): string {
|
||||
const { b, here } = sc;
|
||||
const wrap = (x: Ast): string => (x.t === "bin" ? `(${excel(x, sc)})` : excel(x, sc));
|
||||
switch (n.t) {
|
||||
case "num":
|
||||
return n.v;
|
||||
case "str":
|
||||
return lit(n.v);
|
||||
case "neg":
|
||||
return `-${wrap(n.a)}`;
|
||||
case "eref":
|
||||
throw new Fail(t("Hole", n.v));
|
||||
case "bin": {
|
||||
if (n.op === "그리고" || n.op === "또는")
|
||||
return `${n.op === "그리고" ? "AND" : "OR"}(${excel(n.a, sc)},${excel(n.b, sc)})`;
|
||||
const op = n.op === "==" ? "=" : n.op === "!=" ? "<>" : n.op;
|
||||
return `${wrap(n.a)}${op}${wrap(n.b)}`;
|
||||
}
|
||||
case "call": {
|
||||
const items = n.items.map((x) => excel(x, sc));
|
||||
if (n.f === "만약") return `IF(${items.join(",")})`;
|
||||
if (n.f === "최소" || n.f === "최대")
|
||||
return `${n.f === "최소" ? "MIN" : "MAX"}(${items.join(",")})`;
|
||||
const fn = { 올림: "ROUNDUP", 버림: "ROUNDDOWN", 반올림: "ROUND" }[n.f]!;
|
||||
return `${fn}(${items[0]},${items[1] ?? "0"})`;
|
||||
}
|
||||
case "find":
|
||||
throw new Fail(t("FindAll"));
|
||||
case "logic":
|
||||
return b.link(here, logicBox(b, n), t("Result"));
|
||||
case "field": {
|
||||
const a = n.a;
|
||||
if (a.t === "find") return b.link(here, ...findBox(b, a, n.f));
|
||||
if (a.t === "logic") return b.link(here, logicBox(b, a, n.f), n.f);
|
||||
if (a.t === "name" && a.v === "줄" && sc.lines?.has(n.f))
|
||||
return b.link(here, ...sc.lines.get(n.f)!);
|
||||
throw new Fail(t("Field", `${a.t === "name" ? a.v : ""}.${n.f}`));
|
||||
}
|
||||
case "name": {
|
||||
const v = n.v;
|
||||
if (b.inputs.has(v)) return b.link(here, b.inputs.get(v)!, v);
|
||||
if (b.mids.has(v)) return b.link(here, b.mids.get(v)!, v);
|
||||
if (sc.extras?.has(v)) return b.link(here, ...sc.extras.get(v)!);
|
||||
if (sc.costs && (COSTS as readonly string[]).includes(v)) {
|
||||
const refs = sc.costs[v as Cost].map(([node, pill]) => b.link(here, node, pill));
|
||||
return refs.length ? `(${refs.join("+")})` : "0";
|
||||
}
|
||||
if (KEY.test(v)) return b.link(here, constBox(b, v), t("Price"));
|
||||
throw new Fail(t("Unknown", v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 식 글 하나를 상자 칸에 — 못 바꾸면 #N/A + 메모 */
|
||||
function put(sc: Scope, label: string, text: string, pill: "out" | null, note = ""): string {
|
||||
const ast = parseFormula(text);
|
||||
let cell: Cell;
|
||||
let why = "";
|
||||
try {
|
||||
if (!ast) throw new Fail(t("Parse"));
|
||||
cell = ast.t === "num" ? { 값: Number(ast.v) } : { 식: excel(ast, sc) };
|
||||
} catch (e) {
|
||||
if (!(e instanceof Fail)) throw e;
|
||||
why = e.message;
|
||||
cell = { 식: "NA()" };
|
||||
}
|
||||
const at =
|
||||
pill === "out" ? sc.b.out(sc.here, label, cell, note) : sc.b.row(sc.here, label, cell, note);
|
||||
if (why) sc.b.cant(sc.here, at, why, text);
|
||||
return at;
|
||||
}
|
||||
|
||||
/** 엔진 노임 키(식 속 LB000006 등) → 고정값 상자 · 바깥 값 알약 */
|
||||
function constBox(b: Build, key: string): SlNode {
|
||||
const had = b.consts.get(key);
|
||||
if (had) return had;
|
||||
const node = b.node("const", b.names.get(key) ?? key, 0, key);
|
||||
const at = b.row(node, t("Price"), {}, t("Outside"));
|
||||
b.pill(node, "in", { name: t("Price"), cell: at, outside: true });
|
||||
b.pill(node, "out", { name: t("Price"), cell: at });
|
||||
b.consts.set(key, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 넘길 값(로직 부름 인자 · 표 조건) → 입력 알약 — 글 · 수는 그 칸에 값 · 입력값 · 계산 이름은 선 · 식은 셈 */
|
||||
function args(b: Build, node: SlNode, list: [string, Ast][]): void {
|
||||
for (const [k, v] of list) {
|
||||
const at = b.row(node, k, {}, t("Pass"));
|
||||
b.pill(node, "in", { name: k, cell: at });
|
||||
const pill = node.pillsIn[node.pillsIn.length - 1];
|
||||
const s = b.sheet(node);
|
||||
try {
|
||||
if (v.t === "num") s.칸[at] = { 값: Number(v.v) };
|
||||
else if (v.t === "str") s.칸[at] = { 값: v.v };
|
||||
else if (v.t === "name" && b.inputs.has(v.v)) b.link(node, b.inputs.get(v.v)!, v.v, pill);
|
||||
else if (v.t === "name" && b.mids.has(v.v)) b.link(node, b.mids.get(v.v)!, v.v, pill);
|
||||
else s.칸[at] = { 식: excel(v, { b, here: node }) };
|
||||
} catch (e) {
|
||||
if (!(e instanceof Fail)) throw e;
|
||||
b.cant(node, at, e.message, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 다른 로직 상자(식 속 `로직(키, …)`) — 결과 · 비목 칸은 바깥 값 */
|
||||
function logicBox(b: Build, n: Extract<Ast, { t: "logic" }>, field?: string): SlNode {
|
||||
const sig = n.src.replace(/\s+/g, "");
|
||||
let node = b.logics.get(sig);
|
||||
if (!node) {
|
||||
if (/[{}]/.test(n.head)) throw new Fail(t("Hole", n.head));
|
||||
node = b.node("logic", b.names.get(n.head) ?? n.head, 1, n.head);
|
||||
args(b, node, n.args);
|
||||
b.logics.set(sig, node);
|
||||
}
|
||||
const money = ["노무비", "재료비", "경비", "계"];
|
||||
for (const name of field && money.includes(field) ? money : [field ?? t("Result")])
|
||||
if (!node.pillsOut.some((p) => p.name === name)) {
|
||||
const at = b.row(node, name, {}, t("Outside"));
|
||||
b.pill(node, "in", { name, cell: at, outside: true });
|
||||
b.pill(node, "out", { name, cell: at });
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/* ── 표 상자 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 표 상자 — 같은 표 · 같은 조건이면 결과 알약만 더함 → [상자, 결과 알약 이름] */
|
||||
function findBox(b: Build, n: Extract<Ast, { t: "find" }>, col: string): [SlNode, string] {
|
||||
const sig = `${n.head}|${n.args.map(([k]) => k).join(",")}|${n.src.replace(/\s+/g, "").replace(/\)\..*$/, "")}`;
|
||||
let box = b.tables.get(sig);
|
||||
if (!box) box = newTable(b, n, sig);
|
||||
if (!box.colOf.has(col)) throw new Fail(t("NoCol", col));
|
||||
if (!box.node.pillsOut.some((p) => p.name === col)) resultCol(b, box, col);
|
||||
return [box.node, col];
|
||||
}
|
||||
|
||||
function newTable(b: Build, n: Extract<Ast, { t: "find" }>, sig: string): TableBox {
|
||||
const table = b.opts.tables?.[n.head];
|
||||
if (!table) throw new Fail(t("NoTable", n.head));
|
||||
const rule = String(table.범위규칙 ?? "초과·이하")
|
||||
.split(" · ")[0]
|
||||
.split(" (")[0];
|
||||
for (const [k] of n.args) if (!(k in table.조건)) throw new Fail(t("NoCol", k));
|
||||
if (n.args.some(([k]) => table.조건[k] === "범위") && !RULES.includes(rule))
|
||||
throw new Fail(t("Rule", String(table.범위규칙)));
|
||||
const node = b.node("table", table.이름 || b.names.get(n.head) || n.head, 1, n.head);
|
||||
const s = b.sheet(node);
|
||||
// 조건 알약 — 받는 값은 넘긴 식
|
||||
const cond = new Map<string, string>();
|
||||
args(b, node, n.args);
|
||||
for (const [k] of n.args) cond.set(k, node.pillsIn.find((p) => p.name === k)!.cell);
|
||||
// 표 E1 부터
|
||||
const cols = tableCols(table);
|
||||
const rows = tableRows(table);
|
||||
// 바깥 값 「표」 알약 — Table 상자와 같은 꼴(출력 · 블록 왼위 · 머리 줄 포함) · Eval · 서버가 이 칸에서 블록을 채움
|
||||
b.pill(node, "out", { name: "표", cell: "E1", outside: true });
|
||||
rows.forEach((r, i) =>
|
||||
r.forEach((v, j) => {
|
||||
const c = cellOf(v);
|
||||
if (c) s.칸[`${colName(4 + j)}${i + 1}`] = c;
|
||||
}),
|
||||
);
|
||||
const letter = (key: string, part: "v" | "lo" | "hi"): string =>
|
||||
colName(4 + cols.findIndex((c) => c.key === key && c.part === part));
|
||||
// 마스터 표 줄이 늘어도 따라가게 — 줄 수 이상 · 최소 20 줄까지 셈 · 「표 줄 수」 칸 밖 줄은 안 맞음
|
||||
const N = Math.max(20, table.줄.length);
|
||||
const range = (L: string): string => `$${L}$2:$${L}$${N + 1}`;
|
||||
const count = b.row(node, "표 줄 수", {
|
||||
식: `MAX(${cols.map((_, j) => `COUNTA(${range(colName(4 + j))})`).join(",")})`,
|
||||
});
|
||||
const last = `$B$${count.slice(1)}`;
|
||||
let free = 4 + cols.length + 1;
|
||||
// 맞음 — 줄마다 조건 셈(빈 칸은 무엇이든 맞음 · 범위는 규칙대로)
|
||||
const M = colName(free++);
|
||||
s.칸[`${M}1`] = { 값: t("Match") };
|
||||
const top = Math.max(
|
||||
...table.줄.flatMap((r) =>
|
||||
Object.keys(table.조건)
|
||||
.filter((k) => table.조건[k] === "범위" && Array.isArray(r[k]))
|
||||
.map((k) => (r[k] as (number | null)[])[1] ?? -Infinity),
|
||||
),
|
||||
);
|
||||
const closed = String(table.범위규칙 ?? "").includes("이하 포함");
|
||||
for (let r = 2; r <= N + 1; r++) {
|
||||
const parts = [...cond].map(([k, pin]) => {
|
||||
const x = `$${pin.slice(0, 1)}$${pin.slice(1)}`;
|
||||
if (table.조건[k] !== "범위") {
|
||||
const X = `${letter(k, "v")}${r}`;
|
||||
return `OR(${X}="",${X}=${x})`;
|
||||
}
|
||||
const L = `${letter(k, "lo")}${r}`;
|
||||
const H = `${letter(k, "hi")}${r}`;
|
||||
const low = rule === "이상·이하" || rule === "이상·미만" ? ">=" : ">";
|
||||
const up =
|
||||
rule === "이상·미만"
|
||||
? `OR(${H}="",${x}<${H}${closed ? `,AND(${H}=${top},${x}=${H})` : ""})`
|
||||
: `OR(${H}="",${x}<=${H})`;
|
||||
return `AND(OR(${L}="",${x}${low}${L}),${up})`;
|
||||
});
|
||||
const fit = parts.length ? `IF(AND(${parts.join(",")}),1,0)` : "1";
|
||||
s.칸[`${M}${r}`] = { 식: `IF(${r - 1}>${last},0,${fit})` };
|
||||
}
|
||||
// 여러 줄이 맞으면 위 끝이 가장 작은 줄(규칙 이하 · 이상·이하 — 엔진 `find`)
|
||||
let hit = M;
|
||||
const ranged = [...cond.keys()].filter((k) => table.조건[k] === "범위");
|
||||
if ((rule === "이하" || rule === "이상·이하") && ranged.length) {
|
||||
hit = colName(free++);
|
||||
s.칸[`${hit}1`] = { 값: `${t("Match")} 2` };
|
||||
for (let r = 2; r <= N + 1; r++) {
|
||||
const least = ranged.map((k) => {
|
||||
const H = letter(k, "hi");
|
||||
return `OR(${H}${r}="",${H}${r}=MINIFS(${range(H)},${range(M)},1))`;
|
||||
});
|
||||
s.칸[`${hit}${r}`] = {
|
||||
식: `IF(AND(${M}${r}=1,OR(COUNTIF(${range(M)},1)<2,${least.join(",")})),1,0)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
const colOf = new Map<string, { x: string; hi?: string }>();
|
||||
cols.forEach((c, j) => {
|
||||
if (c.part === "hi") colOf.set(c.key, { ...colOf.get(c.key)!, hi: colName(4 + j) });
|
||||
else if (!colOf.has(c.key)) colOf.set(c.key, { x: colName(4 + j) });
|
||||
});
|
||||
const box: TableBox = { node, table, colOf, rows: N, free, hit };
|
||||
b.tables.set(sig, box);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 결과 알약 — 그 칸이 빈 줄은 빼고(여러 줄일 때) 하나만 맞으면 INDEX/MATCH · 아니면 #N/A ·
|
||||
* 범위 값 칸은 아래 = 위(숫자 하나)일 때만 값 · 아니면 #N/A(엔진 「값이 범위」) */
|
||||
function resultCol(b: Build, box: TableBox, col: string): void {
|
||||
const s = b.sheet(box.node);
|
||||
const N = box.rows;
|
||||
const range = (L: string): string => `$${L}$2:$${L}$${N + 1}`;
|
||||
const { x: X, hi } = box.colOf.get(col)!;
|
||||
const W = colName(box.free++);
|
||||
s.칸[`${W}1`] = { 값: `${t("Match")} · ${col}` };
|
||||
for (let r = 2; r <= N + 1; r++)
|
||||
s.칸[`${W}${r}`] = {
|
||||
식: `IF(AND(${box.hit}${r}=1,OR(COUNTIF(${range(box.hit)},1)<2,${X}${r}<>"")),1,0)`,
|
||||
};
|
||||
const at = (L: string): string => `INDEX(${range(L)},MATCH(1,${range(W)},0))`;
|
||||
const value = hi ? `IF(${at(X)}=${at(hi)},${at(X)},NA())` : at(X);
|
||||
b.out(box.node, col, { 식: `IF(COUNTIF(${range(W)},1)=1,${value},NA())` });
|
||||
}
|
||||
|
||||
/* ── 상자들 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
const example = (spec: LogicInput, given?: number | string): Cell => {
|
||||
if (given !== undefined) return { 값: given };
|
||||
if (spec.고르기?.length) return { 값: spec.고르기[0] };
|
||||
if (spec.범위) return { 값: spec.범위[0] };
|
||||
return { 값: 0 };
|
||||
};
|
||||
|
||||
function inputBox(b: Build, spec: LogicInput): void {
|
||||
const node = b.node("input", spec.이름, 0);
|
||||
const at = b.out(node, spec.이름, example(spec, b.opts.examples?.[spec.이름]), spec.단위 ?? "");
|
||||
const r = Number(at.slice(1)) - 1;
|
||||
const whole = { r0: r, c0: 1, r1: r, c1: 1 };
|
||||
const rule: DataValidationKind | null = spec.고르기?.length
|
||||
? { 종류: "목록", 값들: spec.고르기.map(String) }
|
||||
: spec.범위 ? { 종류: "수범위", 아래: spec.범위[0], 위: spec.범위[1] } : null; // prettier-ignore
|
||||
if (rule) b.sheet(node).유효성 = [{ id: "v1", 범위: [whole], 규칙: rule, 어기면: "막음" }];
|
||||
const pill = node.pillsOut[0];
|
||||
if (spec.단위) pill.unit = spec.단위;
|
||||
b.inputs.set(spec.이름, node);
|
||||
b.specs.set(spec.이름, spec);
|
||||
}
|
||||
|
||||
/** 수량 — 숫자 · 이름 하나면 바로 · 식이면 계산 상자 「이름 수량」 → [상자, 알약] */
|
||||
function qtyFrom(b: Build, item: HoLine, name: string): [SlNode, string] | number {
|
||||
const ast = parseFormula(item.수량);
|
||||
if (ast?.t === "num") return Number(ast.v);
|
||||
if (ast?.t === "name" && b.inputs.has(ast.v)) return [b.inputs.get(ast.v)!, ast.v];
|
||||
if (ast?.t === "name" && b.mids.has(ast.v)) return [b.mids.get(ast.v)!, ast.v];
|
||||
const label = `${name} ${t("Qty")}`;
|
||||
const node = b.node("calc", label, 2);
|
||||
put({ b, here: node }, label, item.수량, "out");
|
||||
return [node, label];
|
||||
}
|
||||
|
||||
/** 항목 틀 — Item `applyItemLayout` 으로 그 상자 시트 · 알약을 채움(Item 이 알아보는 꼴 한 벌) */
|
||||
function layout(b: Build, node: SlNode, ref: ItemRef, qty?: number): void {
|
||||
const sheet = b.sheet(node);
|
||||
const mini = { key: null, name: "", resultUnit: "", workbook: { ...b.book, 시트: [sheet] }, nodes: [node], edges: [] }; // prettier-ignore
|
||||
const got = applyItemLayout(mini, node, ref, qty);
|
||||
sheet.칸 = got.workbook.시트[0].칸;
|
||||
const 유효성 = got.workbook.시트[0].유효성;
|
||||
if (유효성) sheet.유효성 = 유효성;
|
||||
Object.assign(node, got.nodes[0]);
|
||||
}
|
||||
|
||||
/** 항목 상자(호표 줄) — Item 틀 그대로(node.ref = ItemRef) → 비목 출력 알약들 */
|
||||
function itemBox(b: Build, item: HoLine, lines: Map<string, [SlNode, string]>): [SlNode, Cost][] {
|
||||
const name = item.이름 || String(item.요소);
|
||||
const el: unknown = item.요소;
|
||||
const call = typeof el === "string" && el.startsWith("로직(") ? parseFormula(el) : null;
|
||||
const logic = call?.t === "logic" ? call : null;
|
||||
const what: ItemRef["what"] = logic
|
||||
? logic.args.some(([k]) => k === "기계") ? "장비" : "일위대가"
|
||||
: item.종류 === "인력" ? "노무" : "재료"; // prettier-ignore
|
||||
// 넘길 값 — 입력값 이름은 예시값으로(Item 이 ref.inputs 로 바깥 값을 받음) · 글 · 수는 요소 글 안에 그대로
|
||||
const need: LogicInput[] = [];
|
||||
const inputs: Record<string, unknown> = {};
|
||||
const bad: string[] = [];
|
||||
for (const [k, v] of logic?.args ?? []) {
|
||||
if (v.t !== "name") {
|
||||
if (v.t !== "num" && v.t !== "str") bad.push(k);
|
||||
continue;
|
||||
}
|
||||
const spec = b.specs.get(v.v);
|
||||
if (!spec) bad.push(k);
|
||||
else {
|
||||
need.push({ ...spec, 이름: k });
|
||||
inputs[k] = example(spec, b.opts.examples?.[spec.이름]).값;
|
||||
}
|
||||
}
|
||||
const element = what === "재료" && typeof el === "string" ? { 키: el } : el;
|
||||
const ref: ItemRef = {
|
||||
what,
|
||||
element,
|
||||
...(item.규격 ? { spec: item.규격 } : {}),
|
||||
...(item.단위 ? { unit: item.단위 } : {}),
|
||||
...(need.length ? { need, inputs } : {}),
|
||||
};
|
||||
const node = b.node("item", name, 3, JSON.stringify(ref), false);
|
||||
const outside: Cost[] = what === "노무" ? ["노무비"] : what === "재료" ? ["재료비"] : [...COSTS];
|
||||
const q = qtyFrom(b, item, name);
|
||||
layout(b, node, ref, typeof q === "number" ? q : undefined);
|
||||
if (typeof q !== "number") b.link(node, q[0], q[1], node.pillsIn[0]);
|
||||
const holes = typeof el === "string" ? [el] : Object.values(el ?? {});
|
||||
const priceAt = what === "재료" ? "B3" : "B2";
|
||||
if (holes.some((v) => typeof v === "string" && /\{[^}]*\}/.test(v)))
|
||||
b.cant(node, priceAt, t("Hole", typeof el === "string" ? el : JSON.stringify(el)), String(el));
|
||||
if (bad.length) b.cant(node, priceAt, t("PassCalc"), bad.join(", "));
|
||||
lines.set(name, [node, "금액"]);
|
||||
return outside.map((c): [SlNode, Cost] => [node, c]);
|
||||
}
|
||||
|
||||
/** 덧줄 — 「노무비 * 3 / 100」 꼴이면 Item 비율 틀 · 아니면 식 그대로 계산하는 항목(금액 · 비목 · 단가 알약) */
|
||||
const RATE_RE = /^\(?(노무비|재료비|경비)\)?\*(\d+(?:\.\d+)?)\/100$/;
|
||||
|
||||
function extraBox(
|
||||
b: Build,
|
||||
extra: { 이름: string; 식: string; 비목?: string; 출처?: string },
|
||||
sc: Omit<Scope, "here">,
|
||||
): [SlNode, Cost] {
|
||||
const cost = (
|
||||
(COSTS as readonly string[]).includes(extra.비목 ?? "") ? extra.비목 : "경비"
|
||||
) as Cost;
|
||||
const m = RATE_RE.exec(extra.식.replace(/\s+/g, ""));
|
||||
if (m) {
|
||||
const base = m[1] as Cost;
|
||||
const ref: ItemRef = { what: "비율", base, into: cost };
|
||||
const node = b.node("item", extra.이름, 3, JSON.stringify(ref), false);
|
||||
b.sheet(node).칸.B1 = { 값: Number(m[2]) }; // 율 — Item 이 이 칸을 살림
|
||||
layout(b, node, ref);
|
||||
return [node, cost];
|
||||
}
|
||||
const node = b.node("item", extra.이름, 3);
|
||||
const at = put({ ...sc, here: node }, "금액", extra.식, "out", extra.출처 ?? "");
|
||||
b.out(node, "단가", { 식: at });
|
||||
b.out(node, cost, { 식: at });
|
||||
b.out(node, "사용 수량", { 값: 1 });
|
||||
return [node, cost];
|
||||
}
|
||||
|
||||
type Cut = { 대상?: string; 자리?: number; 방법?: string };
|
||||
const cutText = (row: LogicRow, on: string, x: string): string => {
|
||||
const rule = row.끝수 as unknown as Cut | null;
|
||||
if (!rule || typeof rule !== "object" || rule.대상 !== on) return x;
|
||||
const fn = { 올림: "ROUNDUP", 버림: "ROUNDDOWN", 반올림: "ROUND" }[String(rule.방법)];
|
||||
return fn ? `${fn}(${x},${rule.자리 ?? 0})` : x;
|
||||
};
|
||||
|
||||
/* ── 밖으로 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 기존 로직 → 시트 로직 + 못 바꾼 꼴 */
|
||||
export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): SlImportReport {
|
||||
const lb = readLogic(row, { key: row.키 || null, version: null, isBase: true });
|
||||
const b = new Build(opts);
|
||||
for (const spec of lb.inputs) inputBox(b, spec);
|
||||
for (const m of row.중간 ?? []) b.mids.set(m.이름, b.node("calc", m.이름, 2));
|
||||
for (const m of row.중간 ?? [])
|
||||
put({ b, here: b.mids.get(m.이름)! }, m.이름, m.식, "out", m.출처 ?? "");
|
||||
const result = b.node("result", t("Result"), 4);
|
||||
if (row.결과) {
|
||||
const at = put({ b, here: result }, t("Result"), row.결과.식, null);
|
||||
const s = b.sheet(result);
|
||||
if (s.칸[at].식 && s.칸[at].식 !== "NA()")
|
||||
s.칸[at] = { 식: cutText(row, "결과", s.칸[at].식!) };
|
||||
else if (s.칸[at].값 !== undefined)
|
||||
s.칸[at] = { 식: cutText(row, "결과", String(s.칸[at].값)) };
|
||||
b.pill(result, "out", { name: t("Result"), cell: at, unit: row.결과단위 });
|
||||
} else {
|
||||
const lines = new Map<string, [SlNode, string]>();
|
||||
for (const h of row.호표 ?? [])
|
||||
if (typeof h.요소 === "string" && h.이름 && !b.names.has(h.요소)) b.names.set(h.요소, h.이름);
|
||||
const outs = (row.호표 ?? []).flatMap((item) => itemBox(b, item, lines));
|
||||
// 항목 합 — 식 덧줄이 「노무비」 등으로 부르는 앞 줄까지의 비목 합
|
||||
const sum = b.node("calc", t("Sum"), 3);
|
||||
const costs = {} as Record<Cost, [SlNode, string][]>;
|
||||
for (const k of COSTS) {
|
||||
const refs = outs.filter(([, c]) => c === k).map(([n, c]) => b.link(sum, n, c));
|
||||
b.out(sum, k, refs.length ? { 식: `SUM(${refs.join(",")})` } : { 값: 0 });
|
||||
costs[k] = [[sum, k]];
|
||||
}
|
||||
const extras = new Map<string, [SlNode, string]>();
|
||||
const into: [SlNode, Cost][] = [...outs];
|
||||
for (const extra of row.덧줄 ?? []) {
|
||||
const [node, cost] = extraBox(b, extra, { b, costs, extras, lines });
|
||||
extras.set(extra.이름, [node, cost]);
|
||||
costs[cost] = [...costs[cost], [node, cost]];
|
||||
into.push([node, cost]);
|
||||
}
|
||||
// 결과 = Model 결과 틀 — B2~B4 노무비 · 재료비 · 경비 입력 알약(선이 여럿이면 +) · B5 계
|
||||
for (const k of COSTS) {
|
||||
const at = b.row(result, k, {});
|
||||
b.pill(result, "in", { name: k, cell: at });
|
||||
const refs = into
|
||||
.filter(([, c]) => c === k)
|
||||
.map(([n]) => {
|
||||
const src = n.pillsOut.find((p) => p.name === k)!;
|
||||
const to = { node: result.id, pill: k };
|
||||
b.edges.push({ id: `e${b.edges.length + 1}`, from: { node: n.id, pill: k }, to });
|
||||
return `${quoteSheet(b.sheet(n).이름)}!${src.cell}`;
|
||||
});
|
||||
if (refs.length) b.sheet(result).칸[at] = { 식: refs.join("+") };
|
||||
}
|
||||
const parts = COSTS.map((_, i) => cutText(row, "계", `B${2 + i}`));
|
||||
b.out(result, "계", { 식: parts.join("+") }, row.결과단위);
|
||||
}
|
||||
b.book.활성 = b.book.시트[0]?.id;
|
||||
const built: SheetLogic = {
|
||||
key: null,
|
||||
name: row.이름,
|
||||
resultUnit: row.결과단위,
|
||||
workbook: b.book,
|
||||
nodes: b.nodes,
|
||||
edges: b.edges,
|
||||
};
|
||||
// 비율 항목 바탕 = Item 과 같은 셈(다른 항목 상자의 그 비목 합 · 선 함께)
|
||||
const sl = refreshBases(built);
|
||||
return { sl, problems: b.problems };
|
||||
}
|
||||
|
||||
/** 기존 로직 → 시트 로직(정함 9) */
|
||||
export function importLogic(row: LogicRow, opts: SlImportOptions = {}): SheetLogic {
|
||||
return importLogicReport(row, opts).sl;
|
||||
}
|
||||
|
||||
export { outsideOf } from "./M01_SL_Import_Parse";
|
||||
@@ -0,0 +1,273 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Import_Parse.ts
|
||||
* 시트 로직 가져오기 — 엔진 문법(`master_formula._Parser`)으로 로직 식 글 읽기 · 나무 → `M01_SL_Import.ts` 가 엑셀 수식으로
|
||||
* 꼴 = 수 · '글' · 이름 · 찾기(표, 가=나).칸 · 로직(키, 가=나).칸 · 만약/최소/최대/올림/버림/반올림 · 키:… 요소 ·
|
||||
* - · * / · + - · 비교 · 그리고 · 또는 · 못 읽으면 null
|
||||
* 아래 = 품셈 표 → 시트 칸 놓기(조건 칸 → 값 칸 · 범위는 아래 · 위 두 칸 — 바깥 값 「표」 모양과 같음) ·
|
||||
* 바깥 값 자리(`outsideOf`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import type { CalcValues } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { SheetLogic, SlNode } from "./M01_SL_Model";
|
||||
import type { SlField, SlOutside, SlOutsideAt } from "./M01_SL_Api";
|
||||
import { itemRefOf, type ItemRef } from "./M01_SL_Item";
|
||||
|
||||
export type Ast =
|
||||
| { t: "num"; v: string }
|
||||
| { t: "str"; v: string }
|
||||
| { t: "name"; v: string }
|
||||
| { t: "eref"; v: string }
|
||||
| { t: "neg"; a: Ast }
|
||||
| { t: "field"; a: Ast; f: string }
|
||||
| { t: "call"; f: string; items: Ast[] }
|
||||
| { t: "find"; head: string; args: [string, Ast][]; src: string }
|
||||
| { t: "logic"; head: string; args: [string, Ast][]; src: string }
|
||||
| { t: "bin"; op: string; a: Ast; b: Ast };
|
||||
|
||||
const CMPS = ["<=", ">=", "==", "!=", "<", ">"];
|
||||
const CALLS = ["만약", "최소", "최대", "올림", "버림", "반올림"];
|
||||
|
||||
export function parseFormula(s: string): Ast | null {
|
||||
let i = 0;
|
||||
const fail = (): never => {
|
||||
throw new Error("parse");
|
||||
};
|
||||
const ws = (): void => {
|
||||
while (i < s.length && /\s/.test(s[i])) i++;
|
||||
};
|
||||
const take = (w: string): boolean => {
|
||||
ws();
|
||||
if (!s.startsWith(w, i)) return false;
|
||||
i += w.length;
|
||||
return true;
|
||||
};
|
||||
const need = (w: string): void => void (take(w) || fail());
|
||||
const word = (): string => {
|
||||
ws();
|
||||
const m = /[\p{L}\p{N}_]+/uy;
|
||||
m.lastIndex = i;
|
||||
const got = m.exec(s) ?? fail();
|
||||
i = m.lastIndex;
|
||||
return got[0];
|
||||
};
|
||||
const quoted = (): string => {
|
||||
const e = s.indexOf("'", i);
|
||||
if (e < 0) fail();
|
||||
const v = s.slice(i, e);
|
||||
i = e + 1;
|
||||
return v;
|
||||
};
|
||||
/** 로직 · 표 머리 글 — 「,」 「)」 에서 멈춤(괄호 · 중괄호 안은 넘김) */
|
||||
const head = (): string => {
|
||||
ws();
|
||||
const st = i;
|
||||
for (let d = 0; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
if ("({".includes(c)) d++;
|
||||
else if (")}".includes(c)) {
|
||||
if (!d) break;
|
||||
d--;
|
||||
} else if (!d && c === ",") break;
|
||||
}
|
||||
return s.slice(st, i).trim();
|
||||
};
|
||||
const fields = (n: Ast): Ast => {
|
||||
while (take(".")) n = { t: "field", a: n, f: take("'") ? quoted() : word() };
|
||||
return n;
|
||||
};
|
||||
const primary = (): Ast => {
|
||||
ws();
|
||||
const st = i;
|
||||
if (take("(")) {
|
||||
const a = expr();
|
||||
need(")");
|
||||
return fields(a);
|
||||
}
|
||||
if (take("'")) return { t: "str", v: quoted() };
|
||||
const num = /\d+(?:\.\d+)?/y;
|
||||
num.lastIndex = i;
|
||||
const n = num.exec(s);
|
||||
if (n) {
|
||||
i = num.lastIndex;
|
||||
return { t: "num", v: n[0] };
|
||||
}
|
||||
const w = word();
|
||||
if (w === "찾기" || w === "로직") {
|
||||
need("(");
|
||||
const h = head();
|
||||
const args: [string, Ast][] = [];
|
||||
while (take(",")) {
|
||||
const k = take("'") ? quoted() : word();
|
||||
need("=");
|
||||
args.push([k, expr()]);
|
||||
}
|
||||
need(")");
|
||||
const call = { head: h, args, src: s.slice(st, i) };
|
||||
return fields(w === "찾기" ? { t: "find", ...call } : { t: "logic", ...call });
|
||||
}
|
||||
if (CALLS.includes(w)) {
|
||||
need("(");
|
||||
const items = [expr()];
|
||||
while (take(",")) items.push(expr());
|
||||
need(")");
|
||||
return fields({ t: "call", f: w, items });
|
||||
}
|
||||
if (s[i] === ":") {
|
||||
for (i++; i < s.length && !/[\s,)*/+-]/.test(s[i]); i++);
|
||||
return { t: "eref", v: s.slice(st, i) };
|
||||
}
|
||||
return fields({ t: "name", v: w });
|
||||
};
|
||||
const unary = (): Ast => (take("-") ? { t: "neg", a: unary() } : primary());
|
||||
const chain = (next: () => Ast, ops: string[], once = false): Ast => {
|
||||
let n = next();
|
||||
for (let op = ops.find(take); op; op = once ? undefined : ops.find(take))
|
||||
n = { t: "bin", op, a: n, b: next() };
|
||||
return n;
|
||||
};
|
||||
const mul = (): Ast => chain(unary, ["*", "/"]);
|
||||
const add = (): Ast => chain(mul, ["+", "-"]);
|
||||
const cmp = (): Ast => chain(add, CMPS, true);
|
||||
const both = (): Ast => chain(cmp, ["그리고"]);
|
||||
const expr = (): Ast => chain(both, ["또는"]);
|
||||
try {
|
||||
const node = expr();
|
||||
ws();
|
||||
return i === s.length ? node : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 품셈 표 칸 놓기 ─────────────────────────────────────────────────────── */
|
||||
|
||||
/** 가져올 때 같이 받는 품셈 표 — 서버 `/table` 답의 쓸 칸만 */
|
||||
export interface SlImportTable {
|
||||
키?: string;
|
||||
이름?: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
범위규칙?: string | null;
|
||||
줄: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/** 범위 칸 꼬리 — 한 · 영 */
|
||||
const LOW_HIGH = currentLanguageIndex === 1 ? ["low", "high"] : ["아래", "위"];
|
||||
|
||||
export const RULES = ["초과·이하", "이상·이하", "이상·미만", "이하"];
|
||||
|
||||
/** 표 칸 놓기(서버 `M01_SL_Router._table` 과 같음 · 정함) — 조건 칸 → 값 칸 차례 · 범위(조건 「범위」 · 값 칸에
|
||||
* 「20~45」 꼴이 한 줄이라도 있음)는 「이름·아래」 「이름·위」 두 칸 · 범위 칸에 숫자 하나면 두 칸에 같은 수 */
|
||||
export function tableCols(
|
||||
table: SlImportTable,
|
||||
): { key: string; head: string; part: "v" | "lo" | "hi" }[] {
|
||||
const out: { key: string; head: string; part: "v" | "lo" | "hi" }[] = [];
|
||||
const conds = Object.keys(table.조건);
|
||||
for (const k of [...conds, ...Object.keys(table.값칸)]) {
|
||||
const split = conds.includes(k)
|
||||
? table.조건[k] === "범위"
|
||||
: table.줄.some((r) => Array.isArray(r[k]));
|
||||
if (split) {
|
||||
out.push({ key: k, head: `${k}·${LOW_HIGH[0]}`, part: "lo" });
|
||||
out.push({ key: k, head: `${k}·${LOW_HIGH[1]}`, part: "hi" });
|
||||
} else out.push({ key: k, head: k, part: "v" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 품셈 표 → 시트 칸(E1 부터 · 머리 줄 + 줄) — 바깥 값 「표」 답(`rows`)과 같음 */
|
||||
export function tableRows(table: SlImportTable): (string | number | null)[][] {
|
||||
const cols = tableCols(table);
|
||||
const one = (v: unknown, part: "v" | "lo" | "hi"): string | number | null => {
|
||||
if (part !== "v") {
|
||||
const x = Array.isArray(v) ? v[part === "lo" ? 0 : 1] : v;
|
||||
return typeof x === "number" ? x : null;
|
||||
}
|
||||
if (Array.isArray(v)) return v.map((x) => x ?? "").join(",");
|
||||
return typeof v === "number" || (typeof v === "string" && v !== "") ? v : null;
|
||||
};
|
||||
return [cols.map((c) => c.head), ...table.줄.map((r) => cols.map((c) => one(r[c.key], c.part)))];
|
||||
}
|
||||
|
||||
/* ── 바깥 값 자리 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 넘길 값 칸 표시(C 칸) — `M01_SL_Import` TEXT.Pass 한 · 영 */
|
||||
const PASS = ["넘길 값", "Passed value"];
|
||||
const COST3 = ["노무비", "재료비", "경비"] as const;
|
||||
|
||||
/** 칸 값 — 서버 · 화면 계산값이 있으면 그것 · 아니면 넣은 값 */
|
||||
function valueAt(sl: SheetLogic, sheetId: string, cell: string, calc?: CalcValues): unknown {
|
||||
const got = calc?.[sheetId]?.[cell];
|
||||
if (got && "수" in got) return Number(got.수);
|
||||
if (got && "글" in got) return got.글;
|
||||
if (got && "참" in got) return got.참;
|
||||
return sl.workbook.시트.find((s) => s.id === sheetId)?.칸[cell]?.값 ?? null;
|
||||
}
|
||||
|
||||
/** 바깥 값 자리(`M01_SL_Api` 모양) — 넘길 입력값은 지금 칸 값(`calc` 가 있으면 그 값)으로 */
|
||||
export function outsideOf(sl: SheetLogic, calc?: CalcValues): SlOutsideAt[] {
|
||||
const out: SlOutsideAt[] = [];
|
||||
for (const node of sl.nodes) {
|
||||
const passed: Record<string, unknown> = {};
|
||||
const outside = [...node.pillsIn, ...node.pillsOut].filter((p) => p.outside);
|
||||
const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId);
|
||||
for (const p of node.pillsIn) {
|
||||
const note = sheet?.칸[`C${p.cell.slice(1)}`]?.값;
|
||||
if (node.kind !== "table" && PASS.includes(String(note)))
|
||||
passed[p.name] = valueAt(sl, node.sheetId, p.cell, calc);
|
||||
}
|
||||
const ref = node.ref ?? "";
|
||||
const item = itemRefOf(node);
|
||||
if (item) {
|
||||
out.push(...itemOutside(node, item));
|
||||
continue;
|
||||
}
|
||||
for (const p of outside) {
|
||||
const id = `${node.id}:${p.name}`;
|
||||
const field = (["노무비", "재료비", "경비", "계"] as string[]).includes(p.name)
|
||||
? (p.name as SlField)
|
||||
: undefined;
|
||||
let item: SlOutside;
|
||||
if (node.kind === "table") item = { id, kind: "표", table: ref };
|
||||
else if (
|
||||
node.kind === "logic" ||
|
||||
ref.startsWith("GF") ||
|
||||
ref.startsWith("GC") ||
|
||||
ref.startsWith("GX")
|
||||
)
|
||||
item = { id, kind: "로직", key: ref, inputs: passed, ...(field ? { field } : {}) };
|
||||
else if (ref.startsWith("로직("))
|
||||
item = { id, kind: "장비", element: ref, inputs: passed, ...(field ? { field } : {}) };
|
||||
else if (ref.startsWith("LB")) item = { id, kind: "노임", key: ref };
|
||||
else item = { id, kind: "자재", key: ref };
|
||||
out.push({ item, sheet: node.sheetId, cell: p.cell });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 항목 상자 바깥 값 — Item `itemOutsides` 와 같은 칸 · 자재 검색어 묶음은 key = 묶음 JSON 글(서버가 「{」 로 풂) */
|
||||
function itemOutside(node: SlNode, ref: ItemRef): SlOutsideAt[] {
|
||||
const at = (cell: string, item: SlOutside): SlOutsideAt => ({ item, sheet: node.sheetId, cell });
|
||||
const id = (f: string): string => `${node.id}:${f}`;
|
||||
const el = ref.element;
|
||||
const inputs = ref.inputs ?? {};
|
||||
if (ref.what === "노무") return [at("B2", { id: id("노무비"), kind: "노임", key: String(el) })];
|
||||
if (ref.what === "재료") {
|
||||
const keyed = typeof el === "object" && el !== null && "키" in el;
|
||||
const key = keyed ? String((el as { 키: unknown }).키) : JSON.stringify(el);
|
||||
return [at("B3", { id: id("재료비"), kind: "자재", key })];
|
||||
}
|
||||
if (ref.what === "비율") return [];
|
||||
const text = String(el);
|
||||
const key = /로직\(\s*([A-Z]{2}\d{6})/.exec(text)?.[1] ?? "";
|
||||
return COST3.map((f, i) =>
|
||||
at(
|
||||
`B${2 + i}`,
|
||||
ref.what === "장비"
|
||||
? { id: id(f), kind: "장비", element: text, inputs, field: f }
|
||||
: { id: id(f), kind: "로직", key, inputs, field: f },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
.m01-slitem__title {
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-slitem__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.m01-slitem__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-slitem__input {
|
||||
box-sizing: border-box;
|
||||
width: 120px;
|
||||
height: var(--control-height-sm);
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m01-slitem__input[readonly] {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m01-slitem__chosen {
|
||||
margin: 0;
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-slitem__prices {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-slitem__price {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-slitem__price strong {
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-slitem__muted {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-slitem__msg {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Item.ts
|
||||
* 시트 로직 — 항목 상자 손잡이. 항목 상자를 고르면 시트 판 위에 붙음.
|
||||
* 종류(노무 · 재료 · 장비 · 일위대가 · 비율) 고르기 → 상자 시트를 항목 틀로 채움 · 알약 정하기
|
||||
* 단가 칸 = 바깥 값(노임 · 자재 · 장비 = 기계경비 로직 · 일위대가 = 기존 로직 결과) — Api `slOutside` 로 받아 넣음
|
||||
* 수량 칸 = 입력 알약 · 금액 칸 = 수량 × 단가(ROUND 소수 첫째) · 출력 알약 노무비 / 재료비 / 경비
|
||||
* 비율 = 「노무비의 [3]%」 꼴 — 바탕 = 다른 항목 상자의 그 비목 합(시트 사이 참조)
|
||||
* 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 3 · 4. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import type { Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { LogicInput } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { searchElements } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatPrice } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick";
|
||||
import type { PickResult } from "../EasyCalc/M01_EasyCalc_Model";
|
||||
import { slOutside, slPlain, type SlField, type SlOutside, type SlOutsideAt } from "./M01_SL_Api";
|
||||
import { connect, disconnect, type SheetLogic, type SlNode, type SlPill } from "./M01_SL_Model";
|
||||
import "./M01_SL_Item.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["항목", "Item"],
|
||||
Kind: ["종류", "Kind"],
|
||||
Labor: ["노무", "Labor"],
|
||||
Material: ["재료", "Material"],
|
||||
Machine: ["장비", "Machine"],
|
||||
Logic: ["일위대가", "Unit-price job"],
|
||||
Rate: ["비율", "Rate"],
|
||||
ToolLoss: ["공구손료", "Small tools"],
|
||||
Pick: ["고르기", "Choose"],
|
||||
Repick: ["다시 고르기", "Choose again"],
|
||||
None: ["아직 고르지 않았어요", "Nothing chosen yet"],
|
||||
Qty: ["수량", "Quantity"],
|
||||
Price: ["단가", "Unit price"],
|
||||
PriceLabor: ["노무 단가", "Labor price"],
|
||||
PriceMat: ["재료 단가", "Material price"],
|
||||
PriceExp: ["경비 단가", "Expense price"],
|
||||
Reload: ["단가 다시 가져오기", "Reload prices"],
|
||||
Loading: ["단가 가져오는 중", "Loading prices"],
|
||||
Linked: ["다른 상자에서 이어진 값", "Value from another box"],
|
||||
Split: [
|
||||
"장비는 노무비 · 재료비 · 경비 셋으로 나뉘어요",
|
||||
"Machines split into labor, material and expense",
|
||||
],
|
||||
Formula: ["금액 = 수량 × 단가 (소수 첫째 자리까지)", "Amount = quantity × price (one decimal)"],
|
||||
RatePct: ["율(%)", "Rate (%)"],
|
||||
Base: ["무엇의", "Of what"],
|
||||
Into: ["넣을 자리", "Goes into"],
|
||||
Fit: ["바탕 맞추기", "Refresh base"],
|
||||
BaseHint: ["바탕 = 다른 항목 상자의 그 합계", "Base = total of the other item boxes"],
|
||||
Labor3: ["노무비", "Labor cost"],
|
||||
Mat3: ["재료비", "Material cost"],
|
||||
Exp3: ["경비", "Expense"],
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
|
||||
NoPrice: ["단가를 찾지 못했어요", "Price not found"],
|
||||
Won: ["원", "KRW"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
const ti = (key: TextKey): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
export type ItemKind = "노무" | "재료" | "장비" | "일위대가" | "비율";
|
||||
export type CostName = "노무비" | "재료비" | "경비";
|
||||
const COSTS: CostName[] = ["노무비", "재료비", "경비"];
|
||||
|
||||
/** 항목 상자가 `node.ref` 에 글(JSON)로 들고 있는 것 */
|
||||
export interface ItemRef {
|
||||
what: ItemKind;
|
||||
/** 고른 것의 PickResult.element 그대로 */
|
||||
element?: unknown;
|
||||
/** 크기(규격) */
|
||||
spec?: string;
|
||||
/** 수량 단위 */
|
||||
unit?: string;
|
||||
/** 고른 것이 받는 값(장비 지역 · 일위대가 넘길 값)과 지금 값 */
|
||||
need?: LogicInput[];
|
||||
inputs?: Record<string, unknown>;
|
||||
/** 비율 */
|
||||
base?: CostName;
|
||||
into?: CostName;
|
||||
}
|
||||
|
||||
/** 칸 자리 — 항목 틀 둘(돈 · 비율) */
|
||||
const MONEY = {
|
||||
qty: "B1",
|
||||
labor: "B2",
|
||||
mat: "B3",
|
||||
exp: "B4",
|
||||
unit: "B5",
|
||||
amount: "B6",
|
||||
cost: { 노무비: "B7", 재료비: "B8", 경비: "B9" },
|
||||
used: "B10",
|
||||
};
|
||||
const RATE = {
|
||||
rate: "B1",
|
||||
base: "B2",
|
||||
baseKind: "B3",
|
||||
amount: "B4",
|
||||
into: "B5",
|
||||
cost: { 노무비: "B6", 재료비: "B7", 경비: "B8" },
|
||||
used: "B9",
|
||||
unit: "B10",
|
||||
};
|
||||
|
||||
export function itemRefOf(node: SlNode): ItemRef | null {
|
||||
try {
|
||||
const r = node.ref ? (JSON.parse(node.ref) as ItemRef) : null;
|
||||
return r && r.what ? r : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 항목 상자의 비목 칸 자리 — 합계 · 바탕이 같은 자리를 읽음 */
|
||||
export const costCell = (ref: ItemRef, cost: CostName): string =>
|
||||
ref.what === "비율" ? RATE.cost[cost] : MONEY.cost[cost];
|
||||
|
||||
/* ---- 시트 칸 고치기(받은 것은 안 바꿈) ---- */
|
||||
const text = (v: string): Cell => ({ 값: v });
|
||||
const num = (v: number): Cell => ({ 값: v });
|
||||
const calc = (f: string): Cell => ({ 식: f });
|
||||
|
||||
function editSheet(
|
||||
sl: SheetLogic,
|
||||
node: SlNode,
|
||||
edit: (cells: Record<string, Cell>, s: Sheet) => void,
|
||||
): SheetLogic {
|
||||
const 시트 = sl.workbook.시트.map((s) => {
|
||||
if (s.id !== node.sheetId) return s;
|
||||
const next = { ...s, 칸: { ...s.칸 } };
|
||||
edit(next.칸, next);
|
||||
return next;
|
||||
});
|
||||
return { ...sl, workbook: { ...sl.workbook, 시트 } };
|
||||
}
|
||||
const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined =>
|
||||
sl.workbook.시트.find((s) => s.id === node.sheetId);
|
||||
const nodeOf = (sl: SheetLogic, id: string): SlNode | undefined =>
|
||||
sl.nodes.find((n) => n.id === id);
|
||||
const withNode = (sl: SheetLogic, node: SlNode): SheetLogic => ({
|
||||
...sl,
|
||||
nodes: sl.nodes.map((n) => (n.id === node.id ? node : n)),
|
||||
});
|
||||
const listRule = (id: string, cell: string, values: string[]) => ({
|
||||
id,
|
||||
범위: [{ r0: Number(cell.slice(1)) - 1, c0: 1, r1: Number(cell.slice(1)) - 1, c1: 1 }],
|
||||
규칙: { 종류: "목록" as const, 값들: values },
|
||||
어기면: "막음" as const,
|
||||
});
|
||||
|
||||
/* ---- 항목 틀 ---- */
|
||||
/** 일위대가표가 읽는 출력 알약 이름 */
|
||||
export const USED = "사용 수량";
|
||||
export const PILL_PRICE = "단가";
|
||||
export const PILL_AMOUNT = "금액";
|
||||
const PILL_BASE = "바탕 금액";
|
||||
|
||||
function moneyLayout(ref: ItemRef, qty: Cell | undefined, outside: SlField[]) {
|
||||
const label = (cell: string, name: string): [string, Cell] => ["A" + cell.slice(1), text(name)];
|
||||
const cells: Record<string, Cell> = Object.fromEntries([
|
||||
label(MONEY.qty, ti("Qty")),
|
||||
label(MONEY.labor, ti("PriceLabor")),
|
||||
label(MONEY.mat, ti("PriceMat")),
|
||||
label(MONEY.exp, ti("PriceExp")),
|
||||
label(MONEY.unit, PILL_PRICE),
|
||||
label(MONEY.amount, PILL_AMOUNT),
|
||||
label(MONEY.cost.노무비, ti("Labor3")),
|
||||
label(MONEY.cost.재료비, ti("Mat3")),
|
||||
label(MONEY.cost.경비, ti("Exp3")),
|
||||
label(MONEY.used, USED),
|
||||
]);
|
||||
if (qty) cells[MONEY.qty] = qty;
|
||||
// 바깥 값이 아닌 단가 칸은 0 으로 고정
|
||||
for (const [cell, field] of [
|
||||
[MONEY.labor, "노무비"],
|
||||
[MONEY.mat, "재료비"],
|
||||
[MONEY.exp, "경비"],
|
||||
] as const) {
|
||||
if (!outside.includes(field)) cells[cell] = num(0);
|
||||
}
|
||||
cells[MONEY.unit] = calc("B2+B3+B4");
|
||||
cells[MONEY.amount] = calc("ROUND(B1*B5,1)");
|
||||
cells[MONEY.cost.노무비] = calc("ROUND(B1*B2,1)");
|
||||
cells[MONEY.cost.재료비] = calc("ROUND(B1*B3,1)");
|
||||
cells[MONEY.cost.경비] = calc("ROUND(B1*B4,1)");
|
||||
cells[MONEY.used] = calc("B1");
|
||||
const unit = ref.unit ?? "";
|
||||
const won = ti("Won");
|
||||
const out = (name: string, cell: string, u = won): SlPill => ({ name, cell, unit: u });
|
||||
const pillsIn: SlPill[] = [
|
||||
{ name: ti("Qty"), cell: MONEY.qty, unit },
|
||||
...(outside.includes("노무비")
|
||||
? [{ name: ti("PriceLabor"), cell: MONEY.labor, unit: won, outside: true }]
|
||||
: []),
|
||||
...(outside.includes("재료비")
|
||||
? [{ name: ti("PriceMat"), cell: MONEY.mat, unit: won, outside: true }]
|
||||
: []),
|
||||
...(outside.includes("경비")
|
||||
? [{ name: ti("PriceExp"), cell: MONEY.exp, unit: won, outside: true }]
|
||||
: []),
|
||||
];
|
||||
const pillsOut: SlPill[] = [
|
||||
out(PILL_PRICE, MONEY.unit),
|
||||
out(PILL_AMOUNT, MONEY.amount),
|
||||
out(ti("Labor3"), MONEY.cost.노무비),
|
||||
out(ti("Mat3"), MONEY.cost.재료비),
|
||||
out(ti("Exp3"), MONEY.cost.경비),
|
||||
out(USED, MONEY.used, unit),
|
||||
];
|
||||
return { cells, pillsIn, pillsOut, rules: [] as ReturnType<typeof listRule>[] };
|
||||
}
|
||||
|
||||
function rateLayout(ref: ItemRef, rate: Cell | undefined) {
|
||||
const base = ref.base ?? "노무비";
|
||||
const into = ref.into ?? "재료비";
|
||||
const cells: Record<string, Cell> = {
|
||||
A1: text(ti("RatePct")),
|
||||
B1: rate ?? num(3),
|
||||
A2: text(PILL_BASE),
|
||||
B2: num(0),
|
||||
A3: text(ti("Base")),
|
||||
B3: text(base),
|
||||
A4: text(PILL_AMOUNT),
|
||||
B4: calc("ROUND(B2*B1/100,1)"),
|
||||
A5: text(ti("Into")),
|
||||
B5: text(into),
|
||||
A6: text(ti("Labor3")),
|
||||
B6: calc('IF(B5="노무비",B4,0)'),
|
||||
A7: text(ti("Mat3")),
|
||||
B7: calc('IF(B5="재료비",B4,0)'),
|
||||
A8: text(ti("Exp3")),
|
||||
B8: calc('IF(B5="경비",B4,0)'),
|
||||
A9: text(USED),
|
||||
B9: calc("B1"),
|
||||
A10: text(ti("Price")),
|
||||
B10: calc("B2"),
|
||||
};
|
||||
const pillsIn: SlPill[] = [
|
||||
{ name: ti("RatePct"), cell: RATE.rate, unit: "%" },
|
||||
{ name: PILL_BASE, cell: RATE.base, unit: ti("Won") },
|
||||
];
|
||||
const pillsOut: SlPill[] = [
|
||||
{ name: PILL_PRICE, cell: RATE.unit, unit: ti("Won") },
|
||||
{ name: PILL_AMOUNT, cell: RATE.amount, unit: ti("Won") },
|
||||
{ name: ti("Labor3"), cell: RATE.cost.노무비, unit: ti("Won") },
|
||||
{ name: ti("Mat3"), cell: RATE.cost.재료비, unit: ti("Won") },
|
||||
{ name: ti("Exp3"), cell: RATE.cost.경비, unit: ti("Won") },
|
||||
{ name: USED, cell: RATE.used, unit: "%" },
|
||||
];
|
||||
const rules = [listRule("sl-base", RATE.baseKind, COSTS), listRule("sl-into", RATE.into, COSTS)];
|
||||
return { cells, pillsIn, pillsOut, rules };
|
||||
}
|
||||
|
||||
/** 어떤 바깥 값이 필요한가 — 노임 = 노무 · 자재 = 재료 · 장비 · 일위대가 = 셋 */
|
||||
const outsideFields = (what: ItemKind): SlField[] =>
|
||||
what === "노무" ? ["노무비"] : what === "재료" ? ["재료비"] : what === "비율" ? [] : COSTS;
|
||||
const FIELD_CELL: Record<string, string> = {
|
||||
노무비: MONEY.labor,
|
||||
재료비: MONEY.mat,
|
||||
경비: MONEY.exp,
|
||||
};
|
||||
|
||||
/** 상자 시트를 항목 틀로 채움 — 예전 수량 칸(값 · 이어진 참조)은 살림 */
|
||||
/** 상자 시트를 항목 틀로 채움 — 가져오기(Import)도 이 틀 · ref 로 맞춤 */
|
||||
export function applyItemLayout(
|
||||
sl: SheetLogic,
|
||||
node: SlNode,
|
||||
ref: ItemRef,
|
||||
qty?: number | null,
|
||||
): SheetLogic {
|
||||
return applyLayout(sl, node, ref, qty);
|
||||
}
|
||||
|
||||
function applyLayout(sl: SheetLogic, node: SlNode, ref: ItemRef, qty?: number | null): SheetLogic {
|
||||
const sheet = sheetOf(sl, node);
|
||||
const oldRef = itemRefOf(node);
|
||||
const keep =
|
||||
oldRef && oldRef.what !== "비율" && ref.what !== "비율" ? sheet?.칸[MONEY.qty] : undefined;
|
||||
const keepRate =
|
||||
oldRef?.what === "비율" && ref.what === "비율" ? sheet?.칸[RATE.rate] : undefined;
|
||||
const given: Cell | undefined = qty !== undefined && qty !== null ? num(qty) : undefined;
|
||||
const lay =
|
||||
ref.what === "비율"
|
||||
? rateLayout(ref, keepRate)
|
||||
: moneyLayout(ref, given ?? keep, outsideFields(ref.what));
|
||||
const next = editSheet(sl, node, (cells, s) => {
|
||||
for (const k of Object.keys(cells)) delete cells[k];
|
||||
Object.assign(cells, lay.cells);
|
||||
if (lay.rules.length) s.유효성 = lay.rules;
|
||||
else delete s.유효성;
|
||||
});
|
||||
const names = new Set([...lay.pillsIn.map((p) => p.name), ...lay.pillsOut.map((p) => p.name)]);
|
||||
const kept = (e: { from: { node: string; pill: string }; to: { node: string; pill: string } }) =>
|
||||
(e.from.node !== node.id || names.has(e.from.pill)) &&
|
||||
(e.to.node !== node.id || names.has(e.to.pill));
|
||||
return {
|
||||
...withNode(next, {
|
||||
...node,
|
||||
ref: JSON.stringify(ref),
|
||||
pillsIn: lay.pillsIn,
|
||||
pillsOut: lay.pillsOut,
|
||||
stage: 3,
|
||||
}),
|
||||
edges: next.edges.filter(kept),
|
||||
};
|
||||
}
|
||||
|
||||
/** 비율 상자마다 「바탕」 = 다른 항목 상자의 그 비목 출력 — 선 잇기(Model `connect`)로 함
|
||||
* · 아는 항목 상자(ref 가 이 틀 것)만 바탕이 됨 — 모르는 상자(가져온 것 등)는 읽지도 쓰지도 않음
|
||||
* · 비목을 바꾸면 옛 비목 선만 끊음 — 손으로 이은 다른 선 · 그 칸 수식은 그대로 */
|
||||
export function refreshBases(sl: SheetLogic): SheetLogic {
|
||||
let out = sl;
|
||||
for (const node of sl.nodes) {
|
||||
const ref = itemRefOf(node);
|
||||
if (!ref || ref.what !== "비율" || !node.pillsIn.some((p) => p.name === PILL_BASE)) continue;
|
||||
const base = ref.base ?? "노무비";
|
||||
const known = (n: SlNode): boolean => {
|
||||
const r = itemRefOf(n);
|
||||
return n.id !== node.id && n.kind === "item" && !!r && r.what !== "비율";
|
||||
};
|
||||
for (const e of out.edges.filter((x) => x.to.node === node.id && x.to.pill === PILL_BASE)) {
|
||||
const from = out.nodes.find((n) => n.id === e.from.node);
|
||||
if (from && known(from) && e.from.pill !== base && (COSTS as string[]).includes(e.from.pill))
|
||||
out = disconnect(out, e.id);
|
||||
}
|
||||
for (const src of out.nodes.filter(known)) {
|
||||
const there = out.edges.some(
|
||||
(e) =>
|
||||
e.from.node === src.id &&
|
||||
e.from.pill === base &&
|
||||
e.to.node === node.id &&
|
||||
e.to.pill === PILL_BASE,
|
||||
);
|
||||
if (!there)
|
||||
out = connect(out, { node: src.id, pill: base }, { node: node.id, pill: PILL_BASE }).sl;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 이 항목 상자들이 필요로 하는 바깥 값 자리 — Api `slOutside` · 저장이 같은 목록을 씀 */
|
||||
export function itemOutsides(sl: SheetLogic): SlOutsideAt[] {
|
||||
return sl.nodes.flatMap((node) => {
|
||||
const ref = itemRefOf(node);
|
||||
const sheet = sheetOf(sl, node);
|
||||
if (!ref || !sheet) return [];
|
||||
const items = outsideItems(ref, node.id);
|
||||
return items.map(({ field, item }) => ({ item, sheet: sheet.id, cell: FIELD_CELL[field] }));
|
||||
});
|
||||
}
|
||||
|
||||
function outsideItems(ref: ItemRef, id: string): { field: SlField; item: SlOutside }[] {
|
||||
const key = typeof ref.element === "string" ? ref.element : "";
|
||||
const name = (f: string): string => `${id}:${f}`;
|
||||
if (ref.what === "노무")
|
||||
return [{ field: "노무비", item: { id: name("노무비"), kind: "노임", key } }];
|
||||
if (ref.what === "재료") {
|
||||
const k = (ref.element as { 키?: string } | null)?.키 ?? "";
|
||||
return [{ field: "재료비", item: { id: name("재료비"), kind: "자재", key: k } }];
|
||||
}
|
||||
const inputs = ref.inputs ?? {};
|
||||
if (ref.what === "장비") {
|
||||
return COSTS.map((f) => ({
|
||||
field: f,
|
||||
item: { id: name(f), kind: "장비" as const, element: key, inputs, field: f },
|
||||
}));
|
||||
}
|
||||
if (ref.what === "일위대가") {
|
||||
const logicKey = key.match(/로직\(\s*([A-Z]{2}\d{6})/)?.[1] ?? "";
|
||||
return COSTS.map((f) => ({
|
||||
field: f,
|
||||
item: { id: name(f), kind: "로직" as const, key: logicKey, inputs, field: f },
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 단가를 서버에서 받아 단가 칸에 넣음 — 못 구한 것은 까닭을 돌려줌(쉬운 말) */
|
||||
export async function loadPrices(
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
): Promise<{ sl: SheetLogic; why: string | null }> {
|
||||
const node = nodeOf(sl, nodeId);
|
||||
const ref = node && itemRefOf(node);
|
||||
if (!node || !ref) return { sl, why: null };
|
||||
const items = outsideItems(ref, node.id);
|
||||
if (!items.length) return { sl, why: null };
|
||||
const got = await slOutside(items.map((i) => i.item));
|
||||
let why: string | null = null;
|
||||
const next = editSheet(sl, node, (cells) => {
|
||||
for (const { field, item } of items) {
|
||||
const v = got.find((g) => g.id === item.id);
|
||||
if (v?.ok && typeof v.value === "number") cells[FIELD_CELL[field]] = num(v.value);
|
||||
else why = v?.reason ?? ti("NoPrice");
|
||||
}
|
||||
});
|
||||
return { sl: next, why };
|
||||
}
|
||||
|
||||
const PICK_KIND = { 노무: "person", 재료: "material", 장비: "machine", 일위대가: "logic" } as const;
|
||||
const KIND_OF_PICK: Record<PickResult["kind"], ItemKind> = {
|
||||
person: "노무",
|
||||
material: "재료",
|
||||
machine: "장비",
|
||||
logic: "일위대가",
|
||||
};
|
||||
|
||||
/** 재료는 검색어 묶음만 오므로 단가를 부를 번호를 찾아 둠 */
|
||||
async function materialKey(el_: Record<string, string>): Promise<string> {
|
||||
const found = await searchElements("재료", el_["검색어"] ?? "");
|
||||
const hit = found.items.find(
|
||||
(it) => `${it.이름 ?? ""} ${it.규격 ?? ""}`.trim() === (el_["검색어"] ?? "").trim(),
|
||||
);
|
||||
return (hit ?? found.items[0])?.ref ?? "";
|
||||
}
|
||||
|
||||
/** 고른 것으로 항목 상자를 채움(단가는 따로 loadPrices) */
|
||||
export async function applyPick(
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
pick: PickResult,
|
||||
): Promise<SheetLogic> {
|
||||
const node = nodeOf(sl, nodeId);
|
||||
if (!node) return sl;
|
||||
const what = KIND_OF_PICK[pick.kind];
|
||||
let element = pick.element;
|
||||
if (what === "재료") {
|
||||
const e = { ...(pick.element as Record<string, string>) };
|
||||
element = { ...e, 키: await materialKey(e) };
|
||||
}
|
||||
const inputs: Record<string, unknown> = {};
|
||||
for (const n of pick.needInputs ?? []) inputs[n.이름] = n.고르기?.[0] ?? n.범위?.[0];
|
||||
const ref: ItemRef = {
|
||||
what,
|
||||
element,
|
||||
spec: pick.spec,
|
||||
unit: pick.unit,
|
||||
need: pick.needInputs,
|
||||
inputs,
|
||||
};
|
||||
const named = withNode(sl, { ...node, name: `${pick.name}${pick.spec ? ` ${pick.spec}` : ""}` });
|
||||
return refreshBases(applyLayout(named, nodeOf(named, nodeId)!, ref, pick.qty));
|
||||
}
|
||||
|
||||
/* ---- 손잡이 ---- */
|
||||
const cellValue = (sl: SheetLogic, node: SlNode, cell: string): Cell | undefined =>
|
||||
sheetOf(sl, node)?.칸[cell];
|
||||
|
||||
export function renderItemTools(
|
||||
host: HTMLElement,
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
on: { change(sl: SheetLogic): void },
|
||||
): void {
|
||||
const node = nodeOf(sl, nodeId);
|
||||
const msg = el("p", { className: "m01-slitem__msg" });
|
||||
if (!node) {
|
||||
host.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const ref = itemRefOf(node);
|
||||
const emit = (make: () => SheetLogic): void => {
|
||||
msg.textContent = "";
|
||||
try {
|
||||
on.change(make());
|
||||
} catch (e) {
|
||||
msg.textContent = slPlain(e) || ti("Cant");
|
||||
}
|
||||
};
|
||||
const setRef = (patch: Partial<ItemRef>): SheetLogic => {
|
||||
const next: ItemRef = { ...ref!, ...patch };
|
||||
return refreshBases(applyLayout(sl, node, next));
|
||||
};
|
||||
const chosen = (): HTMLElement =>
|
||||
el("p", {
|
||||
className: "m01-slitem__chosen",
|
||||
text: ref && ref.what !== "비율" ? node.name : ti("None"),
|
||||
});
|
||||
|
||||
const pickAndLoad = async (kind: ItemKind): Promise<void> => {
|
||||
if (kind === "비율") {
|
||||
emit(() => {
|
||||
const named = /^항목 \d+$/.test(node.name)
|
||||
? withNode(sl, { ...node, name: ti("ToolLoss") })
|
||||
: sl;
|
||||
return refreshBases(
|
||||
applyLayout(named, nodeOf(named, node.id)!, {
|
||||
what: "비율",
|
||||
base: "노무비",
|
||||
into: "재료비",
|
||||
}),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const got = await openEasyPick(PICK_KIND[kind]);
|
||||
if (!got) return;
|
||||
msg.textContent = ti("Loading");
|
||||
try {
|
||||
const filled = await applyPick(sl, node.id, got);
|
||||
on.change(filled);
|
||||
const priced = await loadPrices(filled, node.id);
|
||||
msg.textContent = priced.why ?? "";
|
||||
on.change(priced.sl);
|
||||
} catch (e) {
|
||||
msg.textContent = slPlain(e) || ti("Cant");
|
||||
}
|
||||
};
|
||||
|
||||
const kindField = createSelectField({
|
||||
label: ti("Kind"),
|
||||
options: (["노무", "재료", "장비", "일위대가", "비율"] as ItemKind[]).map((k) => ({
|
||||
value: k,
|
||||
text: ti(
|
||||
k === "노무"
|
||||
? "Labor"
|
||||
: k === "재료"
|
||||
? "Material"
|
||||
: k === "장비"
|
||||
? "Machine"
|
||||
: k === "비율"
|
||||
? "Rate"
|
||||
: "Logic",
|
||||
),
|
||||
})),
|
||||
value: ref?.what ?? "노무",
|
||||
compact: true,
|
||||
onChange: (v) => void pickAndLoad(v as ItemKind),
|
||||
});
|
||||
|
||||
const numberBox = (cell: string, label: string, unit: string): HTMLElement => {
|
||||
const c = cellValue(sl, node, cell);
|
||||
const linked = c?.식 !== undefined;
|
||||
const box = el("input", {
|
||||
className: "m01-slitem__input",
|
||||
attrs: { type: "text", inputmode: "decimal", "aria-label": label },
|
||||
});
|
||||
box.value = linked ? "" : c?.값 === undefined ? "" : String(c.값);
|
||||
box.readOnly = linked;
|
||||
box.addEventListener("change", () => {
|
||||
const raw = box.value.trim();
|
||||
const n = Number(raw);
|
||||
if (raw === "" || Number.isNaN(n)) {
|
||||
msg.textContent = ti("Bad");
|
||||
return;
|
||||
}
|
||||
emit(() => editSheet(sl, node, (cells) => (cells[cell] = num(n))));
|
||||
});
|
||||
return el("label", {
|
||||
className: "m01-slitem__field",
|
||||
children: [
|
||||
el("span", { text: `${label}${unit ? ` (${unit})` : ""}` }),
|
||||
box,
|
||||
...(linked ? [el("small", { className: "m01-slitem__muted", text: ti("Linked") })] : []),
|
||||
],
|
||||
});
|
||||
};
|
||||
const costSelect = (
|
||||
label: string,
|
||||
value: CostName,
|
||||
patch: (c: CostName) => Partial<ItemRef>,
|
||||
): HTMLElement => {
|
||||
const f = createSelectField({
|
||||
label,
|
||||
options: COSTS.map((c) => ({ value: c, text: c })),
|
||||
value,
|
||||
compact: true,
|
||||
onChange: (v) => emit(() => setRef(patch(v as CostName))),
|
||||
});
|
||||
return f.root;
|
||||
};
|
||||
|
||||
const body: HTMLElement[] = [kindField.root];
|
||||
if (ref && ref.what !== "비율") {
|
||||
const price = (cell: string, label: string): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m01-slitem__price",
|
||||
children: [
|
||||
el("span", { text: label }),
|
||||
el("strong", { text: formatPrice(cellValue(sl, node, cell)?.값 ?? 0) }),
|
||||
],
|
||||
});
|
||||
body.push(
|
||||
chosen(),
|
||||
createButton({
|
||||
label: ref.element === undefined ? ti("Pick") : ti("Repick"),
|
||||
size: "sm",
|
||||
onClick: () => void pickAndLoad(ref.what),
|
||||
}),
|
||||
numberBox(MONEY.qty, ti("Qty"), ref.unit ?? ""),
|
||||
el("div", {
|
||||
className: "m01-slitem__prices",
|
||||
children: [
|
||||
price(MONEY.labor, ti("PriceLabor")),
|
||||
price(MONEY.mat, ti("PriceMat")),
|
||||
price(MONEY.exp, ti("PriceExp")),
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (ref.element !== undefined) {
|
||||
body.push(
|
||||
createButton({
|
||||
label: ti("Reload"),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void loadPrices(sl, node.id).then(
|
||||
(r) => {
|
||||
msg.textContent = r.why ?? "";
|
||||
on.change(r.sl);
|
||||
},
|
||||
(e) => (msg.textContent = slPlain(e) || ti("NoPrice")),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
body.push(
|
||||
el("p", {
|
||||
className: "m01-slitem__muted",
|
||||
text: ref.what === "장비" || ref.what === "일위대가" ? ti("Split") : ti("Formula"),
|
||||
}),
|
||||
);
|
||||
} else if (ref) {
|
||||
body.push(
|
||||
numberBox(RATE.rate, ti("RatePct"), "%"),
|
||||
costSelect(ti("Base"), ref.base ?? "노무비", (c) => ({ base: c })),
|
||||
costSelect(ti("Into"), ref.into ?? "재료비", (c) => ({ into: c })),
|
||||
createButton({
|
||||
label: ti("Fit"),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => emit(() => refreshBases(sl)),
|
||||
}),
|
||||
el("p", { className: "m01-slitem__muted", text: ti("BaseHint") }),
|
||||
);
|
||||
} else {
|
||||
body.push(chosen());
|
||||
}
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m01-slitem__title", text: `${ti("Title")} · ${node.name}` }),
|
||||
el("div", { className: "m01-slitem__body", children: body }),
|
||||
msg,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
.m01-sllogic__title {
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-sllogic__chosen {
|
||||
margin: var(--spacing-4) 0;
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-sllogic__search {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.m01-sllogic__input {
|
||||
box-sizing: border-box;
|
||||
width: 200px;
|
||||
max-width: 100%;
|
||||
height: var(--control-height-sm);
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-sllogic__input[readonly] {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m01-sllogic__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-height: 240px;
|
||||
margin: 0 0 var(--spacing-8);
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.m01-sllogic__list .ui-btn {
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.m01-sllogic__fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
margin: var(--spacing-8) 0;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-sllogic__fields > strong,
|
||||
.m01-sllogic__fields > .m01-sllogic__muted {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.m01-sllogic__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-sllogic__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-sllogic__msg {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_LogicTools.ts
|
||||
* 시트 로직 — 「다른 로직」 상자 손잡이. 기존 로직을 찾아 고르면 상자 시트를 그 로직의 틀로 채움
|
||||
* 로직의 입력값마다 입력 알약(「넘길 값」 줄 · 예시값) · 결과는 바깥 값 출력 알약
|
||||
* 돈 로직 = 노무비 · 재료비 · 경비 · 계 / 돈 아닌 로직(작업량 등) = 결과
|
||||
* 틀 모양은 Import 의 다른 로직 상자와 한 벌(`outsideOf` 가 「넘길 값」 칸과 알약 이름으로 물음을 만듦)
|
||||
* — 상자 `source` 는 안 씀: 한 상자에 비목별 물음이 여럿이라 알약 이름이 비목을 정함. 상자 `ref` = 로직 번호.
|
||||
* 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 3 · 4. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import type { Cell } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { listEasy, openEasy, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api";
|
||||
import { slPlain } from "./M01_SL_Api";
|
||||
import { disconnect, renameNode, type SheetLogic, type SlNode, type SlPill } from "./M01_SL_Model";
|
||||
import "./M01_SL_LogicTools.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["다른 로직", "Other logic"],
|
||||
Search: ["로직 이름 찾기", "Find a logic by name"],
|
||||
Find: ["찾기", "Find"],
|
||||
None: ["아직 고르지 않았어요", "Nothing chosen yet"],
|
||||
Chosen: ["고른 로직", "Chosen logic"],
|
||||
NoHit: ["찾은 로직이 없어요", "No logic found"],
|
||||
More: ["더 있어요 — 이름을 더 적어 보세요", "More exist — type more of the name"],
|
||||
Loading: ["가져오는 중", "Loading"],
|
||||
Inputs: ["넘길 값", "Passed values"],
|
||||
NoInputs: ["넘길 값이 없는 로직이에요", "This logic takes no values"],
|
||||
Linked: ["다른 상자에서 이어진 값", "Value from another box"],
|
||||
Pick: ["고르세요", "Choose"],
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
Hint: [
|
||||
"결과는 바깥 값 — 열 때 · 저장 때 넣어요",
|
||||
"Result is an outside value — filled on open and save",
|
||||
],
|
||||
Result: ["결과", "Result"],
|
||||
Labor: ["노무비", "Labor cost"],
|
||||
Material: ["재료비", "Material cost"],
|
||||
Expense: ["경비", "Expense"],
|
||||
Sum: ["계", "Total"],
|
||||
Name: ["이름", "Name"],
|
||||
Value: ["값", "Value"],
|
||||
Note: ["설명", "Note"],
|
||||
Pass: ["넘길 값", "Passed value"],
|
||||
Outside: ["바깥 값 — 열 때 · 저장 때 넣음", "Outside value — filled on open and save"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
const tt = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
const LIMIT = 30;
|
||||
|
||||
/** 로직 결과가 돈인가 */
|
||||
const isMoney = (unit: string): boolean => unit.startsWith("원");
|
||||
|
||||
/** 상자 시트를 다른 로직 틀로 채움 · `trial` = 입력값 예시(열 때 받은 자동값) */
|
||||
export function applyLogic(
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
row: LogicRow,
|
||||
trial: Record<string, unknown> = {},
|
||||
): SheetLogic {
|
||||
const node = sl.nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return sl;
|
||||
if (node.ref === row.키 && node.pillsIn.length) return sl; // 같은 로직을 또 고름 — 그대로
|
||||
// 다른 로직을 고르면 옛 선은 끊음(받는 칸 수식도 함께 정리)
|
||||
let work = sl;
|
||||
for (const e of sl.edges)
|
||||
if (e.from.node === nodeId || e.to.node === nodeId) work = disconnect(work, e.id);
|
||||
for (const name of [row.이름, `${row.이름} 2`, `${row.이름} 3`]) {
|
||||
const got = renameNode(work, nodeId, name);
|
||||
if (!got.why) {
|
||||
work = got.sl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const cells: Record<string, Cell> = {
|
||||
A1: { 값: tt("Name") },
|
||||
B1: { 값: tt("Value") },
|
||||
C1: { 값: tt("Note") },
|
||||
};
|
||||
const pillsIn: SlPill[] = [];
|
||||
const pillsOut: SlPill[] = [];
|
||||
let r = 2;
|
||||
for (const spec of row.입력 ?? []) {
|
||||
const at = `B${r}`;
|
||||
cells[`A${r}`] = { 값: spec.이름 };
|
||||
const v = trial[spec.이름];
|
||||
if (typeof v === "number" || typeof v === "string") cells[at] = { 값: v };
|
||||
cells[`C${r}`] = { 값: tt("Pass") };
|
||||
pillsIn.push({ name: spec.이름, cell: at, ...(spec.단위 ? { unit: spec.단위 } : {}) });
|
||||
r++;
|
||||
}
|
||||
const unit = row.결과단위 ?? "";
|
||||
const money = isMoney(unit);
|
||||
const outs = money ? [tt("Labor"), tt("Material"), tt("Expense"), tt("Sum")] : [tt("Result")];
|
||||
for (const name of outs) {
|
||||
const at = `B${r}`;
|
||||
cells[`A${r}`] = { 값: name };
|
||||
cells[`C${r}`] = { 값: tt("Outside") };
|
||||
pillsIn.push({ name, cell: at, outside: true });
|
||||
pillsOut.push({ name, cell: at, unit: money ? "원" : unit });
|
||||
r++;
|
||||
}
|
||||
const 시트 = work.workbook.시트.map((s) => (s.id === node.sheetId ? { ...s, 칸: cells } : s));
|
||||
return {
|
||||
...work,
|
||||
workbook: { ...work.workbook, 시트 },
|
||||
nodes: work.nodes.map((n) => {
|
||||
if (n.id !== nodeId) return n;
|
||||
const next: SlNode = { ...n, ref: row.키, pillsIn, pillsOut, stage: 1 };
|
||||
delete next.source;
|
||||
return next;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 그 로직의 입력 설명(고르기 · 범위) — 한 번 받은 것은 다시 안 받음 */
|
||||
const rows = new Map<string, LogicRow>();
|
||||
|
||||
/** 다른 로직 상자 손잡이 — 찾기 → 고르기 → 넘길 값 칸 */
|
||||
export function renderLogicTools(
|
||||
host: HTMLElement,
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
on: { change(sl: SheetLogic): void },
|
||||
): void {
|
||||
const node = sl.nodes.find((n) => n.id === nodeId);
|
||||
const msg = el("p", { className: "m01-sllogic__msg" });
|
||||
if (!node) {
|
||||
host.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId);
|
||||
const results = el("ul", { className: "m01-sllogic__list" });
|
||||
const fieldsHost = el("div", { className: "m01-sllogic__fields" });
|
||||
const box = el("input", {
|
||||
className: "m01-sllogic__input",
|
||||
attrs: { type: "text", placeholder: tt("Search"), "aria-label": tt("Search") },
|
||||
});
|
||||
|
||||
const emit = (make: () => SheetLogic): void => {
|
||||
msg.textContent = "";
|
||||
try {
|
||||
on.change(make());
|
||||
} catch (e) {
|
||||
msg.textContent = slPlain(e);
|
||||
}
|
||||
};
|
||||
|
||||
const choose = async (item: EasyListItem): Promise<void> => {
|
||||
msg.textContent = tt("Loading");
|
||||
try {
|
||||
const opened = await openEasy(item.key);
|
||||
rows.set(item.key, opened.row);
|
||||
msg.textContent = "";
|
||||
on.change(applyLogic(sl, node.id, opened.row, opened.trial));
|
||||
} catch (e) {
|
||||
msg.textContent = slPlain(e);
|
||||
}
|
||||
};
|
||||
|
||||
const search = async (): Promise<void> => {
|
||||
msg.textContent = tt("Loading");
|
||||
try {
|
||||
const found = await listEasy(box.value.trim());
|
||||
msg.textContent = "";
|
||||
results.replaceChildren(
|
||||
...(found.length
|
||||
? found.slice(0, LIMIT).map((it) => {
|
||||
const b = createButton({
|
||||
label: `${it.name} · ${it.resultUnit}${it.where ? ` · ${it.where}` : ""}`,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void choose(it),
|
||||
});
|
||||
return el("li", { children: [b] });
|
||||
})
|
||||
: [el("li", { className: "m01-sllogic__muted", text: tt("NoHit") })]),
|
||||
...(found.length > LIMIT
|
||||
? [el("li", { className: "m01-sllogic__muted", text: tt("More") })]
|
||||
: []),
|
||||
);
|
||||
} catch (e) {
|
||||
msg.textContent = slPlain(e);
|
||||
}
|
||||
};
|
||||
box.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") void search();
|
||||
});
|
||||
|
||||
/** 넘길 값 칸 — 고르기가 있으면 고르기 · 아니면 숫자 · 다른 상자에서 이어진 칸은 읽기만 */
|
||||
const fieldFor = (pill: SlPill, spec: LogicInput | undefined): HTMLElement => {
|
||||
const cur = sheet?.칸[pill.cell];
|
||||
const linked = cur?.식 !== undefined;
|
||||
const put = (v: number | string): void =>
|
||||
emit(() => ({
|
||||
...sl,
|
||||
workbook: {
|
||||
...sl.workbook,
|
||||
시트: sl.workbook.시트.map((s) =>
|
||||
s.id === node.sheetId ? { ...s, 칸: { ...s.칸, [pill.cell]: { 값: v } } } : s,
|
||||
),
|
||||
},
|
||||
}));
|
||||
const label = el("span", { text: pill.unit ? `${pill.name} (${pill.unit})` : pill.name });
|
||||
let control: HTMLElement;
|
||||
if (spec?.고르기?.length && !linked) {
|
||||
control = createSelectField({
|
||||
options: [
|
||||
{ value: "", text: tt("Pick") },
|
||||
...spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
|
||||
],
|
||||
value: cur?.값 === undefined ? "" : String(cur.값),
|
||||
compact: true,
|
||||
ariaLabel: pill.name,
|
||||
onChange: (v) => {
|
||||
const hit = spec.고르기?.find((o) => String(o) === v);
|
||||
if (hit !== undefined) put(hit);
|
||||
},
|
||||
}).root;
|
||||
} else {
|
||||
const input = el("input", {
|
||||
className: "m01-sllogic__input",
|
||||
attrs: { type: "text", inputmode: "decimal", "aria-label": pill.name },
|
||||
});
|
||||
if (spec?.범위) input.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`;
|
||||
input.value = linked || cur?.값 === undefined ? "" : String(cur.값);
|
||||
input.readOnly = linked;
|
||||
input.addEventListener("change", () => {
|
||||
const raw = input.value.trim();
|
||||
const n = Number(raw);
|
||||
if (raw === "") return;
|
||||
if (Number.isNaN(n)) msg.textContent = tt("Bad");
|
||||
else put(n);
|
||||
});
|
||||
control = input;
|
||||
}
|
||||
return el("label", {
|
||||
className: "m01-sllogic__field",
|
||||
children: [
|
||||
label,
|
||||
control,
|
||||
...(linked ? [el("small", { className: "m01-sllogic__muted", text: tt("Linked") })] : []),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const drawFields = (row: LogicRow | undefined): void => {
|
||||
const passed = node.pillsIn.filter((p) => !p.outside);
|
||||
fieldsHost.replaceChildren(
|
||||
el("strong", { text: tt("Inputs") }),
|
||||
...(passed.length
|
||||
? passed.map((p) =>
|
||||
fieldFor(
|
||||
p,
|
||||
row?.입력?.find((i) => i.이름 === p.name),
|
||||
),
|
||||
)
|
||||
: [el("p", { className: "m01-sllogic__muted", text: tt("NoInputs") })]),
|
||||
el("p", { className: "m01-sllogic__muted", text: tt("Hint") }),
|
||||
);
|
||||
};
|
||||
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m01-sllogic__title", text: `${tt("Title")} · ${node.name}` }),
|
||||
el("p", {
|
||||
className: "m01-sllogic__chosen",
|
||||
text: node.ref ? `${tt("Chosen")}: ${node.name}` : tt("None"),
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-sllogic__search",
|
||||
children: [
|
||||
box,
|
||||
createButton({ label: tt("Find"), size: "sm", onClick: () => void search() }),
|
||||
],
|
||||
}),
|
||||
results,
|
||||
...(node.ref ? [fieldsHost] : []),
|
||||
msg,
|
||||
);
|
||||
if (node.ref) {
|
||||
const known = rows.get(node.ref);
|
||||
drawFields(known);
|
||||
if (!known) {
|
||||
void openEasy(node.ref).then(
|
||||
(o) => {
|
||||
rows.set(o.row.키, o.row);
|
||||
drawFields(o.row);
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,15 @@
|
||||
* 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` 정함 1~5 그대로
|
||||
*
|
||||
* 모든 함수는 새 시트 로직을 돌려줌(받은 것은 안 바꿈) · 엔진(A00_Common/spreadsheet)은 가져다 쓰기만
|
||||
*
|
||||
* 약속 ① 표 블록 = 서버(SL_Router `_table`) 칸 차례 하나 — 머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 조건이든 값이든
|
||||
* 「이름·아래」 「이름·위」 두 칸(숫자 하나면 둘 다 그 수) · 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸
|
||||
* 약속 ② 항목 상자 단가 = Item 의 칸별 목록(`itemOutsides`)이 바깥 값 주인 · 서버는 저장 때 그 목록도 받고 같은 칸이면 그쪽이 이김
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import { applyCommand } from "../../A00_Common/spreadsheet/spreadsheet_commands";
|
||||
import type { Cell, Sheet, Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
/** 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과 */
|
||||
@@ -34,6 +39,8 @@ export interface SlNode {
|
||||
pillsOut: SlPill[];
|
||||
/** 표 키 · 요소 · 기존 로직 키 · 내 표 id */
|
||||
ref?: string;
|
||||
/** 바깥 값을 어디서 받나(서버 `/api/m01/sl/outside` 물음 모양) — 없으면 kind · ref 로 짐작 */
|
||||
source?: SlSource;
|
||||
/** 띠 단계 — 0 입력값 · 1 표 · 2 계산 · 3 항목 · 4 결과 */
|
||||
stage: number;
|
||||
}
|
||||
@@ -45,14 +52,34 @@ export interface SlEdge {
|
||||
to: { node: string; pill: string };
|
||||
}
|
||||
|
||||
/** 바깥 값 칸 */
|
||||
/** 돈 로직 결과에서 꺼낼 칸 */
|
||||
export type SlField = "노무비" | "재료비" | "경비" | "계";
|
||||
|
||||
/** 바깥 값 출처 — 서버 `/api/m01/sl/outside` 의 물음 한 줄(id 뺌) 그대로
|
||||
* · 표 = 품셈 표 통째(머리 줄 + 줄 · 열 차례 조건 칸 → 값 칸 · 범위 칸은 「이름·아래」 「이름·위」 두 칸 — `rangeHead`)
|
||||
* 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸(pillsOut · outside) — 고정 A1 아님 · Eval 이 그 칸부터 채움
|
||||
* · 노임 · 자재 = 그 줄 단가 · 장비 = 기계경비 로직 부름 글 + 넘길 값 · 로직 = 기존 로직 결과 */
|
||||
export type SlSource =
|
||||
| { kind: "표"; table: string }
|
||||
| { kind: "노임" | "자재"; key: string }
|
||||
| { kind: "장비"; element: string; inputs: Record<string, unknown>; field?: SlField }
|
||||
| { kind: "로직"; key: string; inputs: Record<string, unknown>; field?: SlField };
|
||||
|
||||
/** 바깥 값 칸 — id = 「상자 id!칸」(서버 답 id 와 짝) */
|
||||
export interface SlOutside {
|
||||
id: string;
|
||||
node: string;
|
||||
cell: string;
|
||||
kind: "table" | "price" | "logic";
|
||||
ref: string;
|
||||
source?: SlSource;
|
||||
}
|
||||
|
||||
/** 표 범위 조건 · 범위 값 — 한 칸을 두 칸(아래 · 위)으로 펼친 머리 이름 */
|
||||
export const RANGE_LOW = "·아래";
|
||||
export const RANGE_HIGH = "·위";
|
||||
export const rangeHead = (name: string): [string, string] => [name + RANGE_LOW, name + RANGE_HIGH];
|
||||
|
||||
/** 내 표 — 품셈에 없는 표(사용자가 만듦) */
|
||||
export interface SlUserTable {
|
||||
id: string;
|
||||
@@ -85,7 +112,6 @@ const TEXT = {
|
||||
Fixed: ["늘 이 값", "Always this value"],
|
||||
Cond: ["조건", "Condition"],
|
||||
Found: ["결과", "Result"],
|
||||
FromTable: ["표에서 찾은 값", "Value found in the table"],
|
||||
ValueA: ["값1", "Value 1"],
|
||||
ValueB: ["값2", "Value 2"],
|
||||
Times: ["값1 × 값2", "Value 1 × Value 2"],
|
||||
@@ -96,10 +122,24 @@ const TEXT = {
|
||||
PriceNote: ["바깥 값 — 열 때 넣음", "Outside value — filled on open"],
|
||||
LogicNote: ["다른 로직 결과 — 열 때 넣음", "Other logic's result — filled on open"],
|
||||
Sum: ["계", "Total"],
|
||||
PriceLabor: ["노무 단가", "Labor price"],
|
||||
PriceMat: ["재료 단가", "Material price"],
|
||||
PriceExp: ["경비 단가", "Expense price"],
|
||||
Labor3: ["노무비", "Labor cost"],
|
||||
Mat3: ["재료비", "Material cost"],
|
||||
Exp3: ["경비", "Expense"],
|
||||
Won: ["원", "KRW"],
|
||||
NoPill: ["알약이 없음", "No such pill"],
|
||||
Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"],
|
||||
Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"],
|
||||
Twice: ["이미 이어져 있음", "Already linked"],
|
||||
NameLen: ["이름은 1~31 글자", "Name must be 1-31 characters"],
|
||||
NameChar: [
|
||||
"이름에 못 쓰는 글자 — [ ] : * ? / \\ 와 앞뒤 '",
|
||||
"Name can't use [ ] : * ? / \\ or a leading/trailing '",
|
||||
],
|
||||
NameSame: ["같은 이름 상자가 있음", "A box with this name exists"],
|
||||
NoBox: ["상자가 없음", "No such box"],
|
||||
} as const;
|
||||
|
||||
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
@@ -107,6 +147,9 @@ const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex ==
|
||||
/** 비목 이름(결과 · 항목 칸) — 화면 글 아님 · 알약 이름 열쇠 */
|
||||
const COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
|
||||
/** 일위대가표가 읽는 출력 알약 이름(Item `USED` 와 같음) */
|
||||
const USED = "사용 수량";
|
||||
|
||||
/* ── 작은 도움 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const STAGE: Record<SlKind, number> = {
|
||||
@@ -130,12 +173,25 @@ function nextId(prefix: string, ids: string[]): string {
|
||||
return `${prefix}${Math.max(0, ...nums) + 1}`;
|
||||
}
|
||||
|
||||
/** 시트 이름 — 통합문서 안에서 하나(같으면 「 (2)」) */
|
||||
function uniqueName(book: Workbook, name: string): string {
|
||||
const base = name.trim() || "상자";
|
||||
const used = new Set(book.시트.map((s) => s.이름));
|
||||
if (!used.has(base)) return base;
|
||||
for (let n = 2; ; n++) if (!used.has(`${base} (${n})`)) return `${base} (${n})`;
|
||||
/** 시트 이름 규칙(엔진 `checkSheetName`) — 1~31 글자 · [ ] : * ? / \\ 와 앞뒤 ' 못 씀 */
|
||||
const BAD_CHARS = /[[\]:*?/\\]/g;
|
||||
const cleanName = (name: string): string =>
|
||||
name
|
||||
.replace(BAD_CHARS, "·")
|
||||
.replace(/^'+|'+$/g, "")
|
||||
.trim()
|
||||
.slice(0, 31) || "상자";
|
||||
|
||||
/** 시트 이름 — 통합문서 안에서 하나(같으면 「 (2)」 · 대소문자 안 가림) */
|
||||
function uniqueName(book: Workbook, name: string, self?: string): string {
|
||||
const base = cleanName(name);
|
||||
const used = new Set(book.시트.filter((s) => s.id !== self).map((s) => s.이름.toLowerCase()));
|
||||
if (!used.has(base.toLowerCase())) return base;
|
||||
for (let n = 2; ; n++) {
|
||||
const tail = ` (${n})`;
|
||||
const next = base.slice(0, 31 - tail.length) + tail;
|
||||
if (!used.has(next.toLowerCase())) return next;
|
||||
}
|
||||
}
|
||||
|
||||
const sheetOf = (sl: SheetLogic, node: SlNode): Sheet => {
|
||||
@@ -164,14 +220,8 @@ function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; out
|
||||
case "const":
|
||||
return { rows: [[name, v(0), tl("Fixed")]], ins: [], outs: [{ name, cell: "B2" }] };
|
||||
case "table":
|
||||
return {
|
||||
rows: [
|
||||
[tl("Cond"), null, tl("QtyNote")],
|
||||
[tl("Found"), null, tl("FromTable")],
|
||||
],
|
||||
ins: [{ name: tl("Cond"), cell: "B2" }],
|
||||
outs: [{ name: tl("Found"), cell: "B3" }],
|
||||
};
|
||||
// 빈 시트 — 표를 고르면 Table(sub3)이 A1 블록 · 찾기 판 · 알약을 채움(머리 줄이 표 블록과 겹치지 않게)
|
||||
return { rows: [], ins: [], outs: [] };
|
||||
case "calc":
|
||||
return {
|
||||
rows: [
|
||||
@@ -186,24 +236,8 @@ function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; out
|
||||
outs: [{ name: tl("Found"), cell: "B4" }],
|
||||
};
|
||||
case "item":
|
||||
return {
|
||||
rows: [
|
||||
[tl("Qty"), null, tl("QtyNote")],
|
||||
[tl("Price"), null, tl("PriceNote")],
|
||||
[tl("Amount"), f("B2*B3"), ""],
|
||||
["노무비", f("B4"), ""],
|
||||
["재료비", v(0), ""],
|
||||
["경비", v(0), ""],
|
||||
],
|
||||
ins: [
|
||||
{ name: tl("Qty"), cell: "B2" },
|
||||
{ name: tl("Price"), cell: "B3", outside: true },
|
||||
],
|
||||
outs: [
|
||||
{ name: tl("Amount"), cell: "B4" },
|
||||
...COSTS.map((c, i) => ({ name: c, cell: `B${5 + i}` })),
|
||||
],
|
||||
};
|
||||
// 항목 틀은 머리 줄 없음 — Item(sub5) 노무 틀과 한 벌(`itemSheet`)
|
||||
return { rows: [], ins: [], outs: [] };
|
||||
case "logic":
|
||||
return {
|
||||
rows: [[tl("Found"), null, tl("LogicNote")]],
|
||||
@@ -224,6 +258,52 @@ function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; out
|
||||
}
|
||||
}
|
||||
|
||||
/** 항목 상자 틀 — `M01_SL_Item.ts` moneyLayout(노무)과 같은 칸 · 같은 알약(종류를 바꾸면 Item 이 다시 채움)
|
||||
* B1 수량 · B2~B4 노무/재료/경비 단가(노무는 B2 만 바깥 값) · B5 단가 · B6 금액 · B7~B9 비목 · B10 사용 수량 */
|
||||
function itemSheet(): { cells: Record<string, Cell>; rows: Row[]; ins: SlPill[]; outs: SlPill[] } {
|
||||
const won = tl("Won");
|
||||
const labels: [string, string][] = [
|
||||
["A1", tl("Qty")],
|
||||
["A2", tl("PriceLabor")],
|
||||
["A3", tl("PriceMat")],
|
||||
["A4", tl("PriceExp")],
|
||||
["A5", tl("Price")],
|
||||
["A6", tl("Amount")],
|
||||
["A7", tl("Labor3")],
|
||||
["A8", tl("Mat3")],
|
||||
["A9", tl("Exp3")],
|
||||
["A10", USED],
|
||||
];
|
||||
const cells: Record<string, Cell> = Object.fromEntries(labels.map(([k, v]) => [k, { 값: v }]));
|
||||
Object.assign(cells, {
|
||||
B3: { 값: 0 },
|
||||
B4: { 값: 0 },
|
||||
B5: { 식: "B2+B3+B4" },
|
||||
B6: { 식: "ROUND(B1*B5,1)" },
|
||||
B7: { 식: "ROUND(B1*B2,1)" },
|
||||
B8: { 식: "ROUND(B1*B3,1)" },
|
||||
B9: { 식: "ROUND(B1*B4,1)" },
|
||||
B10: { 식: "B1" },
|
||||
});
|
||||
const out = (name: string, cell: string, unit = won): SlPill => ({ name, cell, unit });
|
||||
return {
|
||||
cells,
|
||||
rows: [],
|
||||
ins: [
|
||||
{ name: tl("Qty"), cell: "B1", unit: "" },
|
||||
{ name: tl("PriceLabor"), cell: "B2", unit: won, outside: true },
|
||||
],
|
||||
outs: [
|
||||
out(tl("Price"), "B5"),
|
||||
out(tl("Amount"), "B6"),
|
||||
out(tl("Labor3"), "B7"),
|
||||
out(tl("Mat3"), "B8"),
|
||||
out(tl("Exp3"), "B9"),
|
||||
out(USED, "B10", ""),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */
|
||||
|
||||
/** 빈 시트 로직 */
|
||||
@@ -251,12 +331,13 @@ export function addNode(
|
||||
book.시트.map((s) => s.id),
|
||||
);
|
||||
const title = uniqueName(book, name);
|
||||
const t = template(kind, title);
|
||||
const 칸: Record<string, Cell> = {
|
||||
A1: { 값: tl("Name") },
|
||||
B1: { 값: tl("Value") },
|
||||
C1: { 값: tl("Note") },
|
||||
};
|
||||
const item = kind === "item" ? itemSheet() : null;
|
||||
const t = item ?? template(kind, title);
|
||||
const 칸: Record<string, Cell> =
|
||||
item?.cells ??
|
||||
(kind === "table"
|
||||
? {}
|
||||
: { A1: { 값: tl("Name") }, B1: { 값: tl("Value") }, C1: { 값: tl("Note") } });
|
||||
t.rows.forEach(([label, cell, note], i) => {
|
||||
칸[`A${i + 2}`] = { 값: label };
|
||||
if (cell) 칸[`B${i + 2}`] = cell;
|
||||
@@ -274,6 +355,7 @@ export function addNode(
|
||||
sheetId,
|
||||
pillsIn: t.ins,
|
||||
pillsOut: t.outs,
|
||||
...(item ? { ref: JSON.stringify({ what: "노무" }) } : {}),
|
||||
stage: STAGE[kind],
|
||||
};
|
||||
out.nodes.push(node);
|
||||
@@ -435,3 +517,22 @@ export function nodeSheet(sl: SheetLogic, nodeId: string): Sheet | null {
|
||||
const node = nodeOf(sl, nodeId);
|
||||
return node ? sheetOf(sl, node) : null;
|
||||
}
|
||||
|
||||
/** 상자 이름 바꾸기 — 시트 이름도(엔진 시트 이름 명령) · 그 시트를 가리키던 선 수식 · 다른 수식도 따라감 */
|
||||
export function renameNode(
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
name: string,
|
||||
): { sl: SheetLogic; why: string | null } {
|
||||
const node = nodeOf(sl, nodeId);
|
||||
if (!node) return { sl, why: tl("NoBox") };
|
||||
const next = name.trim();
|
||||
if (!next || next.length > 31) return { sl, why: tl("NameLen") };
|
||||
if (/[[\]:*?/\\]/.test(next) || next.startsWith("'") || next.endsWith("'"))
|
||||
return { sl, why: tl("NameChar") };
|
||||
if (uniqueName(sl.workbook, next, node.sheetId) !== next) return { sl, why: tl("NameSame") };
|
||||
const out = clone(sl);
|
||||
applyCommand(out.workbook, { 종류: "시트이름", 시트: node.sheetId, 이름: next });
|
||||
nodeOf(out, nodeId)!.name = next;
|
||||
return { sl: out, why: null };
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
.m01-slpanel-sheet {
|
||||
position: relative;
|
||||
min-height: 260px;
|
||||
/* 그 시트가 쓰는 줄 수만큼(Entry 가 --sl-sheet-h 를 정함) — 안쪽 세로 밀기 없음(사용자 요청) */
|
||||
height: var(--sl-sheet-h, 260px);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
.m01-slresult__big {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
margin: 0 0 var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-slresult__big strong {
|
||||
color: var(--color-text-body);
|
||||
font-size: 28px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-slresult__costs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-16);
|
||||
margin: 0 0 var(--spacing-12);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-slresult__costs dt {
|
||||
display: inline;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-slresult__costs dd {
|
||||
display: inline;
|
||||
margin: 0 0 0 var(--spacing-4);
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-slresult__strip {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-slresult__fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.m01-slresult__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-slresult__input {
|
||||
box-sizing: border-box;
|
||||
width: 140px;
|
||||
height: var(--control-height-sm);
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-slresult__input[readonly] {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m01-slresult__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-slresult__msg {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-slresult__problems {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-left: 4px solid var(--color-danger);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-slresult__problems ul {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
padding-left: var(--spacing-16);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Result.ts
|
||||
* 시트 로직 — 오른쪽 결과 칸: 큰 합계 · 노무비 · 재료비 · 경비 · 일위대가표(EasyCalc Sheet 에 값을 맞춰 넣음)
|
||||
* · 예시값 띠(입력값 상자의 예시값을 여기서도 바꿈) · 오류 · 빈 입력 한 줄
|
||||
* 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌.
|
||||
* 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 8. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createSelectField, el } from "@ui/ui_template_elements";
|
||||
import type {
|
||||
Cell,
|
||||
DataValidationRule,
|
||||
Sheet,
|
||||
} from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet";
|
||||
import type { SlEvalResult } from "./M01_SL_Eval";
|
||||
import { PILL_AMOUNT, PILL_PRICE, USED, itemRefOf } from "./M01_SL_Item";
|
||||
import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model";
|
||||
import "./M01_SL_Result.css";
|
||||
|
||||
const TEXT = {
|
||||
Per: ["{per} 당", "per {per}"],
|
||||
Total: ["합계", "Total"],
|
||||
Value: ["결과값", "Result value"],
|
||||
Won: ["원", "KRW"],
|
||||
Labor: ["노무비", "Labor cost"],
|
||||
Material: ["재료비", "Material cost"],
|
||||
Expense: ["경비", "Expense"],
|
||||
Sample: ["예시값", "Sample values"],
|
||||
SampleHint: [
|
||||
"여기서 바꾸면 입력값 상자의 예시값도 바뀌어요",
|
||||
"Changing here changes the input box too",
|
||||
],
|
||||
Problems: ["고칠 것", "To fix"],
|
||||
Empty: ["「{box}」 상자의 「{pill}」 칸이 비었어요", "“{pill}” in box “{box}” is empty"],
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
|
||||
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
|
||||
Pick: ["고르세요", "Choose"],
|
||||
RateOf: ["{base}의 %", "% of {base}"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
const tr = (key: TextKey, fill: Record<string, string> = {}): string =>
|
||||
TEXT[key][currentLanguageIndex].replace(/\{(\w+)\}/g, (m, k: string) => fill[k] ?? m);
|
||||
|
||||
const COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
const num = (v: number): string => v.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
const n0 = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
||||
|
||||
const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined =>
|
||||
sl.workbook.시트.find((s) => s.id === node.sheetId);
|
||||
const colOf = (cell: string): { r: number; c: number } => ({
|
||||
r: Number(cell.replace(/\D/g, "")) - 1,
|
||||
c: cell.charCodeAt(0) - 65,
|
||||
});
|
||||
|
||||
/** 일위대가표 줄 — 항목 상자 하나 = 줄 하나(출력 알약을 읽음) */
|
||||
function linesOf(sl: SheetLogic, values: SlEvalResult): CalcLine[] {
|
||||
return sl.nodes
|
||||
.filter((n) => n.kind === "item")
|
||||
.map((n) => {
|
||||
const ref = itemRefOf(n);
|
||||
const p = values.pills[n.id] ?? {};
|
||||
const unit = n.pillsOut.find((x) => x.name === USED)?.unit ?? "";
|
||||
const pickName = (name: string): number => n0(p[name]);
|
||||
return {
|
||||
이름: n.name,
|
||||
단위: unit,
|
||||
수량: pickName(USED),
|
||||
단가: pickName(PILL_PRICE),
|
||||
금액: pickName(PILL_AMOUNT),
|
||||
비목: Object.fromEntries(COSTS.map((c) => [c, pickName(c)])),
|
||||
...(ref?.what === "비율"
|
||||
? { 품목: { 키: "", 규격: tr("RateOf", { base: ref.base ?? "노무비" }) } }
|
||||
: ref?.spec
|
||||
? { 품목: { 키: "", 규격: ref.spec } }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 빈 입력 — 입력 알약 칸에 값도 참조도 없는 것 */
|
||||
function emptyInputs(sl: SheetLogic): string[] {
|
||||
return sl.nodes.flatMap((n) => {
|
||||
const cells = sheetOf(sl, n)?.칸 ?? {};
|
||||
return n.pillsIn
|
||||
.filter(
|
||||
(p) => !p.outside && !(cells[p.cell] && ("값" in cells[p.cell] || "식" in cells[p.cell])),
|
||||
)
|
||||
.map((p) => tr("Empty", { box: n.name, pill: p.name }));
|
||||
});
|
||||
}
|
||||
|
||||
/** 예시값 칸 — 선택 목록(유효성)이 걸린 칸은 고르기 · 수범위는 범위 안내 */
|
||||
function ruleAt(sheet: Sheet, cell: string): DataValidationRule["규칙"] | null {
|
||||
const { r, c } = colOf(cell);
|
||||
const hit = (sheet.유효성 ?? []).find((v) =>
|
||||
v.범위.some((g) => r >= g.r0 && r <= g.r1 && c >= g.c0 && c <= g.c1),
|
||||
);
|
||||
return hit?.규칙 ?? null;
|
||||
}
|
||||
|
||||
function sampleField(
|
||||
sl: SheetLogic,
|
||||
node: SlNode,
|
||||
pill: SlPill,
|
||||
put: (cell: string, v: number | string) => void,
|
||||
msg: HTMLElement,
|
||||
): HTMLElement {
|
||||
const sheet = sheetOf(sl, node);
|
||||
const cur: Cell | undefined = sheet?.칸[pill.cell];
|
||||
const rule = sheet ? ruleAt(sheet, pill.cell) : null;
|
||||
const label = el("span", { text: pill.unit ? `${pill.name} (${pill.unit})` : pill.name });
|
||||
let control: HTMLElement;
|
||||
if (rule?.종류 === "목록") {
|
||||
const field = createSelectField({
|
||||
options: [{ value: "", text: tr("Pick") }, ...rule.값들.map((v) => ({ value: v, text: v }))],
|
||||
value: cur?.값 === undefined ? "" : String(cur.값),
|
||||
compact: true,
|
||||
ariaLabel: pill.name,
|
||||
onChange: (v) => {
|
||||
if (v !== "") put(pill.cell, v);
|
||||
},
|
||||
});
|
||||
control = field.root;
|
||||
} else {
|
||||
const box = el("input", {
|
||||
className: "m01-slresult__input",
|
||||
attrs: { type: "text", inputmode: "decimal", "aria-label": pill.name },
|
||||
});
|
||||
if (rule?.종류 === "수범위") {
|
||||
box.placeholder = tr("Range", { a: String(rule.아래 ?? ""), b: String(rule.위 ?? "") });
|
||||
}
|
||||
const linked = cur?.식 !== undefined;
|
||||
box.value = linked || cur?.값 === undefined ? "" : String(cur.값);
|
||||
box.readOnly = linked;
|
||||
box.addEventListener("change", () => {
|
||||
const raw = box.value.trim();
|
||||
const n = Number(raw);
|
||||
if (raw === "" || Number.isNaN(n)) {
|
||||
msg.textContent = tr("Bad");
|
||||
return;
|
||||
}
|
||||
put(pill.cell, n);
|
||||
});
|
||||
control = box;
|
||||
}
|
||||
return el("label", { className: "m01-slresult__field", children: [label, control] });
|
||||
}
|
||||
|
||||
/** 결과 칸 — values = 화면 계산 결과 · 예시값을 바꾸면 on.change(새 시트 로직) */
|
||||
export function renderResult(
|
||||
host: HTMLElement,
|
||||
sl: SheetLogic,
|
||||
values: SlEvalResult,
|
||||
on: { change(sl: SheetLogic): void },
|
||||
): void {
|
||||
const money = !sl.resultUnit || sl.resultUnit.startsWith("원");
|
||||
const { 계 } = values.result;
|
||||
/** 돈이 아닌 결과(㎥/시간 …) — 결과 상자의 「계」 · 없으면 이어진 첫 숫자 */
|
||||
const plainValue = (): number | null => {
|
||||
const box = sl.nodes.find((n) => n.kind === "result");
|
||||
const ins = box ? Object.values(values.pillsIn[box.id] ?? {}) : [];
|
||||
const first = [계, ...COSTS.map((c) => values.result[c]), ...ins].find(
|
||||
(v) => typeof v === "number" && Number.isFinite(v),
|
||||
);
|
||||
return typeof first === "number" ? first : null;
|
||||
};
|
||||
const perUnit = sl.resultUnit.split("/").slice(1).join("/");
|
||||
const perText = !perUnit
|
||||
? tr("Total")
|
||||
: tr("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` });
|
||||
const shown = money ? 계 : plainValue();
|
||||
const big = el("p", {
|
||||
className: "m01-slresult__big",
|
||||
children: [
|
||||
el("span", { text: money ? perText : tr("Value") }),
|
||||
el("strong", {
|
||||
text:
|
||||
shown === null
|
||||
? "—"
|
||||
: money
|
||||
? `${formatMoney(shown)} ${tr("Won")}`
|
||||
: `${num(shown)} ${sl.resultUnit}`.trim(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
const costs = el("dl", {
|
||||
className: "m01-slresult__costs",
|
||||
children: COSTS.flatMap((c, i) => [
|
||||
el("dt", { text: tr((["Labor", "Material", "Expense"] as const)[i]) }),
|
||||
el("dd", { text: values.result[c] === null ? "—" : formatMoney(values.result[c]) }),
|
||||
]),
|
||||
});
|
||||
|
||||
const head = { name: sl.name, resultUnit: sl.resultUnit };
|
||||
const problems = [...values.errors, ...emptyInputs(sl)];
|
||||
const ans: CalcAnswer =
|
||||
values.errors.length || 계 === null
|
||||
? { ok: false, reason: values.errors[0] ?? emptyInputs(sl)[0] ?? "" }
|
||||
: {
|
||||
ok: true,
|
||||
lines: linesOf(sl, values),
|
||||
sums: {
|
||||
노무비: n0(values.result.노무비),
|
||||
재료비: n0(values.result.재료비),
|
||||
경비: n0(values.result.경비),
|
||||
계: n0(계),
|
||||
},
|
||||
middle: {},
|
||||
};
|
||||
const sheetHost = el("div", { className: "m01-slresult__sheet" });
|
||||
if (money) renderSheet(sheetHost, head, ans);
|
||||
|
||||
const msg = el("p", { className: "m01-slresult__msg" });
|
||||
const put =
|
||||
(node: SlNode) =>
|
||||
(cell: string, v: number | string): void => {
|
||||
msg.textContent = "";
|
||||
try {
|
||||
const 시트 = sl.workbook.시트.map((s) =>
|
||||
s.id === node.sheetId ? { ...s, 칸: { ...s.칸, [cell]: { 값: v } } } : s,
|
||||
);
|
||||
on.change({ ...sl, workbook: { ...sl.workbook, 시트 } });
|
||||
} catch {
|
||||
msg.textContent = tr("Cant");
|
||||
}
|
||||
};
|
||||
const fields = sl.nodes
|
||||
.filter((n) => n.kind === "input")
|
||||
.flatMap((n) => n.pillsOut.map((p) => sampleField(sl, n, p, put(n), msg)));
|
||||
const strip = fields.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m01-slresult__strip",
|
||||
children: [
|
||||
el("strong", { text: tr("Sample") }),
|
||||
el("div", { className: "m01-slresult__fields", children: fields }),
|
||||
el("p", { className: "m01-slresult__muted", text: tr("SampleHint") }),
|
||||
msg,
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
host.replaceChildren(
|
||||
big,
|
||||
...(money ? [costs] : []),
|
||||
...strip,
|
||||
...(problems.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m01-slresult__problems",
|
||||
children: [
|
||||
el("strong", { text: tr("Problems") }),
|
||||
el("ul", { children: problems.map((t) => el("li", { text: t })) }),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
sheetHost,
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
GET /api/m01/sl/list
|
||||
GET /api/m01/sl/get?key=SL-0001
|
||||
POST /api/m01/sl/save {logic} → {key, logic, values}
|
||||
POST /api/m01/sl/save {logic: SheetLogic} → {key, logic, values: SlOutsideAnswer[]}
|
||||
POST /api/m01/sl/delete {key}
|
||||
POST /api/m01/sl/outside {items: SlOutside[]} → {values: SlOutsideValue[]}
|
||||
POST /api/m01/sl/outside {items: [{id, ...SlSource}]} → {values: SlOutsideAnswer[]}
|
||||
|
||||
모양은 sub7 Model(`M01_SL_Model.ts` — SheetLogic · SlSource) · 답은 Eval `SlOutsideAnswer`.
|
||||
정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` 4 · 5 · 6 — 저장 때 바깥 값(품셈 표 · 노임 ·
|
||||
자재 · 장비 · 기존 로직 결과)을 마스터에서 다시 넣고 우리 스프레드시트 엔진(Node ·
|
||||
`common_util_spreadsheet`)으로 다시 셈 · 브라우저 계산값은 버림. 단가는 `/calc`(`Store.calc`)와
|
||||
@@ -35,7 +36,6 @@ router = APIRouter(prefix="/api/m01/sl", tags=["M01 SheetLogic (시험)"])
|
||||
DATA = Path(__file__).resolve().parent / "_data" # 시험은 임시 폴더로 바꿈
|
||||
_KEY = re.compile(r"^SL-(\d{4,})$")
|
||||
_CELL = re.compile(r"^([A-Z]+)(\d+)$")
|
||||
FIELDS = (*cm.mf.COST_ITEMS, "계")
|
||||
|
||||
|
||||
class SaveBody(BaseModel):
|
||||
@@ -76,8 +76,8 @@ def listing() -> dict:
|
||||
out.append(
|
||||
{
|
||||
"key": path.stem,
|
||||
"이름": logic.get("이름") or "",
|
||||
"결과단위": logic.get("결과단위") or "",
|
||||
"이름": logic.get("name") or "",
|
||||
"결과단위": logic.get("resultUnit") or "",
|
||||
"saved": logic.get("saved") or "",
|
||||
}
|
||||
)
|
||||
@@ -95,6 +95,15 @@ def delete(key: str) -> dict:
|
||||
|
||||
|
||||
# ── 바깥 값 ────────────────────────────────────────────────────────────
|
||||
# 물음 한 줄 = `{id, ...SlSource}`(Model) · 답 = Eval `SlOutsideAnswer {id, ok, value | rows}`
|
||||
# 표 {kind: "표", table} → rows = 머리 줄 + 줄(범위는 「이름·아래」 「이름·위」 — `rangeHead`)
|
||||
# 노임 · 자재 {kind, key, unit?} → 그 줄 단가(`/calc` 와 같은 셈) · 자재 key = 키 · 검색어 묶음
|
||||
# 장비 {kind: "장비", element, inputs, field?} → 기계경비 로직 부름 글 한 줄(수량 1)
|
||||
# 로직 {kind: "로직", key, inputs, field?} → 기존 로직 결과(빈 입력은 자동값으로 채움)
|
||||
RANGE_LOW, RANGE_HIGH = "·아래", "·위" # Model `RANGE_LOW` · `RANGE_HIGH` 와 같음
|
||||
FIELDS = (*cm.mf.COST_ITEMS, "계")
|
||||
|
||||
|
||||
def _row(line: dict, names) -> dict:
|
||||
"""바깥 단가를 `/calc` 와 같은 셈으로 — 호표 한 줄(수량 1)짜리 저장 안 하는 견본 로직."""
|
||||
return {
|
||||
@@ -111,11 +120,59 @@ def _row(line: dict, names) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _price(kind: str, key: str) -> dict:
|
||||
"""노임 · 자재 한 줄 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로."""
|
||||
whole = store.loaded()[1]
|
||||
def _money(got: dict, field: str | None) -> dict:
|
||||
if not got["ok"]:
|
||||
return got
|
||||
if "result" in got:
|
||||
return {"ok": True, "value": got["result"]}
|
||||
pick = field or "계"
|
||||
if pick not in FIELDS:
|
||||
return {"ok": False, "reason": f"모르는 칸 「{pick}」"}
|
||||
return {"ok": True, "value": got["sums"][pick]}
|
||||
|
||||
|
||||
def _pick(key: Any) -> dict | None:
|
||||
"""자재 검색어 묶음 `{구분, 상세구분, 검색어, 규격?}` — 묶음 · 그 JSON 글 · 아니면 None."""
|
||||
if isinstance(key, dict):
|
||||
return key
|
||||
if isinstance(key, str) and key.lstrip().startswith("{"):
|
||||
try:
|
||||
got = json.loads(key)
|
||||
except ValueError:
|
||||
return None
|
||||
return got if isinstance(got, dict) else None
|
||||
return None
|
||||
|
||||
|
||||
def _material(cond: dict, unit: str) -> dict:
|
||||
"""검색어 묶음 자재 단가 — 기존 로직 재료 줄과 같은 길(검색어 · 자동값 차례 · 단위 환산)."""
|
||||
line = {"종류": "재료", "요소": cond, "이름": cond.get("검색어") or "재료", "수량": "1"}
|
||||
got = store.calc("", {}, _row({**line, "단위": unit, "비목": "재료비"}, ()), None)
|
||||
if not got["ok"]:
|
||||
return got
|
||||
one = got["lines"][0]
|
||||
if one["단가"] is None:
|
||||
word = cond.get("검색어") or ""
|
||||
empty = "값 없음" in str(one.get("까닭") or "") # 품목은 찾았는데 단가 칸이 빔
|
||||
why = (
|
||||
"단가가 자료에 비어 있음 — 단가를 채우거나 다른 품목을 고름"
|
||||
if empty
|
||||
else "맞는 자재 없음"
|
||||
)
|
||||
return {"ok": False, "reason": f"「{word}」 {why}"}
|
||||
picked = one.get("품목") or {}
|
||||
out = {"ok": True, "value": one["단가"], "name": picked.get("이름"), "unit": unit or None}
|
||||
return {**out, **({"reason": one["단위경고"]} if one.get("단위경고") else {})}
|
||||
|
||||
|
||||
def _price(kind: str, key: Any, unit: str = "") -> dict:
|
||||
"""노임 · 자재 한 줄 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로 · 자재는 키 또는 검색어 묶음."""
|
||||
cond = _pick(key) if kind == "자재" else None
|
||||
if cond is not None:
|
||||
return _material(cond, unit)
|
||||
key = str(key or "")
|
||||
try:
|
||||
brief = whole.get(key)
|
||||
brief = store.loaded()[1].get(key)
|
||||
except cm.mf.FormulaError as e:
|
||||
return {"ok": False, "reason": str(e)}
|
||||
group = store.mk.GROUP_OF.get(store.mk.id_of(key))
|
||||
@@ -128,25 +185,25 @@ def _price(kind: str, key: str) -> dict:
|
||||
if not got["ok"]:
|
||||
return got
|
||||
one = got["lines"][0]
|
||||
return {"ok": one["단가"] is not None, "value": one["단가"], "reason": one.get("까닭")} | {
|
||||
"name": brief.get("이름"),
|
||||
"unit": unit,
|
||||
}
|
||||
out = {"ok": one["단가"] is not None, "value": one["단가"], "name": brief.get("이름")}
|
||||
return {**out, "unit": unit, **({"reason": one["까닭"]} if one.get("까닭") else {})}
|
||||
|
||||
|
||||
def _money(got: dict, field: str | None) -> dict:
|
||||
if not got["ok"]:
|
||||
return got
|
||||
if "result" in got:
|
||||
return {"ok": True, "value": got["result"]}
|
||||
pick = field or "계"
|
||||
if pick not in FIELDS:
|
||||
return {"ok": False, "reason": f"모르는 칸 「{pick}」"}
|
||||
return {"ok": True, "value": got["sums"][pick]}
|
||||
def _logic(key: str, inputs: dict, field: str | None) -> dict:
|
||||
"""기존 로직 결과 — 준 입력이 이기고 빈 입력은 자동값으로."""
|
||||
try:
|
||||
auto = store.auto(key)["값"]
|
||||
except store.StoreError:
|
||||
auto = {}
|
||||
return _money(store.calc(key, {**auto, **inputs}), field)
|
||||
|
||||
|
||||
def _table(key: str) -> dict:
|
||||
"""품셈 표 통째 → 머리 줄 + 줄(범위 · 범위 값은 「이름·아래」 「이름·위」 두 칸)."""
|
||||
"""품셈 표 통째 → 머리 줄 + 줄 — 표 블록 칸 차례의 정본(브레인 정함 · 화면도 이것을 따름).
|
||||
|
||||
조건 칸 → 값 칸 · 범위가 있는 칸(범위 조건 · 「20~45」 같은 범위 값)은 「이름·아래」 「이름·위」
|
||||
두 칸 · 그 칸에 수 하나면 아래 · 위에 같은 수 · 빈 칸은 빈 칸.
|
||||
"""
|
||||
files = store.loaded()[0]
|
||||
table = next(
|
||||
(
|
||||
@@ -160,49 +217,45 @@ def _table(key: str) -> dict:
|
||||
)
|
||||
if table is None:
|
||||
return {"ok": False, "reason": f"없는 표 「{key}」"}
|
||||
names = [*(table.get("조건") or {}), *(table.get("값칸") or {})]
|
||||
conds, lines = table.get("조건") or {}, table.get("줄") or []
|
||||
names = [*conds, *(table.get("값칸") or {})]
|
||||
split = {
|
||||
n
|
||||
for n in names
|
||||
if (table.get("조건") or {}).get(n) == "범위"
|
||||
or any(isinstance(r.get(n), list) for r in table.get("줄") or [])
|
||||
n for n in names if conds.get(n) == "범위" or any(isinstance(r.get(n), list) for r in lines)
|
||||
}
|
||||
head = [h for n in names for h in ((f"{n}·아래", f"{n}·위") if n in split else (n,))]
|
||||
head = [h for n in names for h in ((n + RANGE_LOW, n + RANGE_HIGH) if n in split else (n,))]
|
||||
rows = []
|
||||
for r in table.get("줄") or []:
|
||||
for r in lines:
|
||||
line = []
|
||||
for n in names:
|
||||
v = r.get(n)
|
||||
if n in split:
|
||||
low, high = v if isinstance(v, list) else (v, v)
|
||||
line += [low, high]
|
||||
if n in split: # 범위 칸 — 수 하나면 아래 · 위 같은 수
|
||||
line += list(v) if isinstance(v, list) else [v, v]
|
||||
else:
|
||||
line.append(v if not isinstance(v, (list, dict)) else str(v))
|
||||
line.append(None if v == "" else v if not isinstance(v, dict) else str(v))
|
||||
rows.append(line)
|
||||
return {"ok": True, "rows": [head, *rows], "name": table.get("이름"), "unit": table.get("기준")}
|
||||
|
||||
|
||||
def one_outside(item: dict) -> dict:
|
||||
kind, at = item.get("kind"), item.get("id") or ""
|
||||
"""물음 한 줄 `{id, ...SlSource}` → 답 `SlOutsideAnswer`."""
|
||||
kind = item.get("kind")
|
||||
try:
|
||||
if kind == "표":
|
||||
got = _table(str(item.get("table") or ""))
|
||||
elif kind in ("노임", "자재"):
|
||||
got = _price(kind, str(item.get("key") or ""))
|
||||
got = _price(kind, item.get("key"), str(item.get("unit") or ""))
|
||||
elif kind == "장비":
|
||||
inputs = item.get("inputs") or {}
|
||||
line = {"종류": "로직", "요소": item.get("element") or "", "이름": "장비"}
|
||||
row = _row({**line, "단위": "시간", "수량": "1"}, inputs)
|
||||
got = _money(store.calc("", inputs, row, None), item.get("field"))
|
||||
elif kind == "로직":
|
||||
got = _money(
|
||||
store.calc(str(item.get("key") or ""), item.get("inputs") or {}), item.get("field")
|
||||
)
|
||||
got = _logic(str(item.get("key") or ""), item.get("inputs") or {}, item.get("field"))
|
||||
else:
|
||||
got = {"ok": False, "reason": f"모르는 바깥 값 「{kind}」"}
|
||||
except store.StoreError as e:
|
||||
got = {"ok": False, "reason": str(e.detail)}
|
||||
return {"id": at, **got}
|
||||
return {"id": item.get("id") or "", **got}
|
||||
|
||||
|
||||
def outside(items: list[dict]) -> dict:
|
||||
@@ -210,6 +263,36 @@ def outside(items: list[dict]) -> dict:
|
||||
return {"values": [one_outside(i) for i in items]}
|
||||
|
||||
|
||||
def _source(node: dict) -> dict | None:
|
||||
"""Eval `outsideCells` 와 같은 규칙 — 상자 source · 없으면 표 · 로직은 ref 로 짐작."""
|
||||
if node.get("source"):
|
||||
return node["source"]
|
||||
ref = node.get("ref")
|
||||
if ref and node.get("kind") == "table":
|
||||
return {"kind": "표", "table": ref}
|
||||
if ref and node.get("kind") == "logic":
|
||||
return {"kind": "로직", "key": ref, "inputs": {}}
|
||||
return None
|
||||
|
||||
|
||||
def outside_cells(logic: dict) -> list[tuple[dict, str]]:
|
||||
"""바깥 값 칸 → [({id, ...source, _cell}, 시트)].
|
||||
|
||||
① 알약 outside 참 — id 「상자!칸」 · 출처 없으면 뺌(Eval `outsideCells` 규칙)
|
||||
② 몸의 `outside: [{item, sheet, cell}]` — Item 항목 단가(칸마다 비목) · 같은 칸은 ② 가 이김
|
||||
"""
|
||||
found: dict[tuple[str, str], dict] = {}
|
||||
for node in logic.get("nodes") or []:
|
||||
source = _source(node)
|
||||
for pill in [*(node.get("pillsIn") or []), *(node.get("pillsOut") or [])]:
|
||||
if pill.get("outside") and source:
|
||||
sheet, cell = node.get("sheetId") or "", pill.get("cell") or ""
|
||||
found[(sheet, cell)] = {"id": f"{node.get('id')}!{cell}", **source}
|
||||
for at in logic.get("outside") or []:
|
||||
found[(at.get("sheet") or "", at.get("cell") or "")] = dict(at.get("item") or {})
|
||||
return [({**item, "_cell": cell}, sheet) for (sheet, cell), item in found.items()]
|
||||
|
||||
|
||||
# ── 저장 ──────────────────────────────────────────────────────────────
|
||||
def _col(letters: str) -> int:
|
||||
n = 0
|
||||
@@ -227,45 +310,45 @@ def _letters(n: int) -> str:
|
||||
|
||||
|
||||
def _put(book: dict, sheet_id: str, cell: str, value: Any) -> None:
|
||||
"""바깥 값 칸 하나 — 값 칸으로 덮어씀(수식이 있었어도) · 빈 값은 칸을 비움."""
|
||||
"""바깥 값 칸 하나 — 값 칸으로 덮어씀(수식이 있었어도) · 빈 값은 값만 뺌(서식은 둠)."""
|
||||
sheet = next((s for s in book.get("시트") or [] if s.get("id") == sheet_id), None)
|
||||
if sheet is None:
|
||||
raise store.StoreError(400, f"없는 시트 「{sheet_id}」")
|
||||
cells = sheet.setdefault("칸", {})
|
||||
old = cells.get(cell) or {}
|
||||
if value is None:
|
||||
keep = {"서식": cells[cell]["서식"]} if "서식" in (cells.get(cell) or {}) else {}
|
||||
if value is None and not keep:
|
||||
cells.pop(cell, None)
|
||||
return
|
||||
keep = {"서식": old["서식"]} if "서식" in old else {}
|
||||
cells[cell] = {"값": value, **keep}
|
||||
else:
|
||||
cells[cell] = {**({"값": value} if value is not None else {}), **keep}
|
||||
|
||||
|
||||
def _fill(book: dict, at: dict, got: dict) -> None:
|
||||
m = _CELL.match(str(at.get("cell") or "").upper())
|
||||
def _fill(book: dict, sheet: str, cell: str, got: dict) -> None:
|
||||
m = _CELL.match(str(cell or "").upper())
|
||||
if not m:
|
||||
raise store.StoreError(400, f"칸 주소 모양이 아님 「{at.get('cell')}」")
|
||||
sheet, col, row = at.get("sheet") or "", _col(m.group(1)), int(m.group(2))
|
||||
if "rows" in got:
|
||||
raise store.StoreError(400, f"칸 주소 모양이 아님 「{cell}」")
|
||||
col, row = _col(m.group(1)), int(m.group(2))
|
||||
if "rows" in got: # 표 — cell 이 왼위
|
||||
for dy, line in enumerate(got["rows"]):
|
||||
for dx, value in enumerate(line):
|
||||
_put(book, sheet, f"{_letters(col + dx)}{row + dy}", value)
|
||||
for dx, one in enumerate(line):
|
||||
_put(book, sheet, f"{_letters(col + dx)}{row + dy}", one)
|
||||
else:
|
||||
_put(book, sheet, f"{m.group(1)}{row}", got.get("value") if got.get("ok") else None)
|
||||
|
||||
|
||||
def save(logic: dict) -> dict:
|
||||
if not isinstance(logic.get("book"), dict):
|
||||
raise store.StoreError(400, "통합문서(book)가 없음")
|
||||
if not str(logic.get("이름") or "").strip():
|
||||
"""SheetLogic 저장 — 바깥 값 다시 넣기 → Node 로 다시 셈(브라우저 계산값 버림) → `_data`."""
|
||||
if not isinstance(logic.get("workbook"), dict):
|
||||
raise store.StoreError(400, "통합문서(workbook)가 없음")
|
||||
if not str(logic.get("name") or "").strip():
|
||||
raise store.StoreError(400, "이름을 넣어 주세요")
|
||||
key = logic.get("key") or None
|
||||
if key:
|
||||
_read(key) # 있는 것만 고침 — 없으면 404
|
||||
places = logic.get("outside") or []
|
||||
values = [one_outside(at.get("item") or {}) for at in places]
|
||||
book = _num(logic["book"])
|
||||
for at, got in zip(places, values):
|
||||
_fill(book, at, _num(got))
|
||||
places = outside_cells(logic)
|
||||
values = [one_outside({k: v for k, v in item.items() if k != "_cell"}) for item, _ in places]
|
||||
book = _num(logic["workbook"])
|
||||
for (item, sheet), got in zip(places, values):
|
||||
_fill(book, sheet, item["_cell"], _num(got))
|
||||
try:
|
||||
book = spreadsheet.with_server_values(book) # 브라우저 계산값은 버리고 서버 값
|
||||
except spreadsheet.RecalcError as e:
|
||||
@@ -273,7 +356,7 @@ def save(logic: dict) -> dict:
|
||||
DATA.mkdir(parents=True, exist_ok=True)
|
||||
key = key or _next_key()
|
||||
stamp = datetime.now().isoformat(timespec="seconds")
|
||||
done = {**logic, "key": key, "book": book, "saved": stamp}
|
||||
done = {**logic, "key": key, "workbook": book, "saved": stamp}
|
||||
tmp = DATA / f".{key}.tmp"
|
||||
tmp.write_text(json.dumps(done, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n")
|
||||
os.replace(tmp, DATA / f"{key}.json")
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
.m01-sltbl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.m01-sltbl__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.m01-sltbl__box {
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.m01-sltbl__box > summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.m01-sltbl__box[open] > summary {
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
.m01-sltbl__box > :not(summary) {
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
.m01-sltbl__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
.m01-sltbl__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-height: 22vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.m01-sltbl__row,
|
||||
.m01-sltbl__role {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.m01-sltbl__row:hover,
|
||||
.m01-sltbl__row:focus-visible,
|
||||
.m01-sltbl__role:hover,
|
||||
.m01-sltbl__role:focus-visible {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.m01-sltbl__roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
.m01-sltbl__roles > p {
|
||||
flex: 1 0 100%;
|
||||
}
|
||||
.m01-sltbl__role span {
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.m01-sltbl__role.is-cond {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.m01-sltbl__role.is-out {
|
||||
border-style: dashed;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.m01-sltbl__role.is-off {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.m01-sltbl__status {
|
||||
margin: 0;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.m01-sltbl__status.is-ok {
|
||||
border-left: 4px solid var(--color-accent);
|
||||
}
|
||||
.m01-sltbl__status.is-bad {
|
||||
border-left: 4px solid var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.m01-sltbl__scroll {
|
||||
max-height: 24vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.m01-sltbl__view table {
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.m01-sltbl__view th,
|
||||
.m01-sltbl__view td {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
.m01-sltbl__view tr.is-hit td {
|
||||
background: rgba(240, 190, 40, 0.35);
|
||||
font-weight: 700;
|
||||
}
|
||||
.m01-sltbl__formula {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.m01-sltbl__formula code {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
.m01-sltbl__warn {
|
||||
margin: 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.m01-sltbl__paste {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
}
|
||||
.m01-sltbl h5 {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Table.ts
|
||||
* 시트 로직 표 상자 손잡이 — 표 상자를 고르면 시트 판 위에 붙음
|
||||
* · 품셈 표 가져오기(절 · 이름 찾기 → 표) — 표 줄은 바깥 값(사본으로 안 들고 있음)
|
||||
* · 내 표(빈 표 · 엑셀에서 붙여넣기) — 시트에 그대로
|
||||
* · 조건 칸 · 결과 칸 정하기 → 입력 알약 · 출력 알약 + 「표에서 찾기」 수식(시트 판에서 고칠 수 있음)
|
||||
* · 걸린 줄 칠하기 · 맞는 줄 없음 / 둘 이상 한 줄
|
||||
* 짜기 = `M01_SL_Table_Build.ts`(DOM 없음) · 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 10
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { ApiError } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { plainError } from "../EasyCalc/M01_EasyCalc_Api";
|
||||
import type { SheetLogic, SlNode } from "./M01_SL_Model";
|
||||
import {
|
||||
applyTable,
|
||||
blockCells,
|
||||
infoFromSheet,
|
||||
layoutOf,
|
||||
parsePaste,
|
||||
planLookup,
|
||||
type TblInfo,
|
||||
type TblRoles,
|
||||
} from "./M01_SL_Table_Build";
|
||||
import "./M01_SL_Table.css";
|
||||
|
||||
const TEXT = {
|
||||
NotTable: ["표 상자를 고르면 표 손잡이가 뜸", "Pick a table box to see its tools"],
|
||||
Import: ["품셈 표 가져오기", "Import a standard table"],
|
||||
Mine: ["내 표", "My table"],
|
||||
Book: ["적용 품셈", "Standard"],
|
||||
Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"],
|
||||
Loading: ["찾는 중…", "Searching…"],
|
||||
NoSection: ["찾는 절 없음", "No section"],
|
||||
SecLine: ["표 {t} 개", "{t} tables"],
|
||||
Tables: ["이 절의 표 — 하나를 고름", "Pick a table"],
|
||||
TableLine: [
|
||||
"{b} · 조건 {c} · 결과 {v} · {n} 줄",
|
||||
"{b} · {c} conditions · {v} results · {n} rows",
|
||||
],
|
||||
ImportNote: [
|
||||
"표 줄은 열 때 · 저장 때 품셈에서 새로 넣음 — 사본으로 안 들고 있음",
|
||||
"Rows are filled from the standard when opened or saved",
|
||||
],
|
||||
Empty: ["빈 표", "Empty table"],
|
||||
Paste: ["엑셀에서 붙여넣기", "Paste from Excel"],
|
||||
Heads: ["머리 칸 이름(쉼표로 나눔) — 예: 경사, 할증", "Column names, comma separated"],
|
||||
RowCount: ["줄 수", "Rows"],
|
||||
MakeEmpty: ["빈 표 만들기", "Create"],
|
||||
PasteHint: [
|
||||
"엑셀에서 머리 줄부터 복사해 여기에 붙임(탭으로 나뉜 글)",
|
||||
"Paste with the header row (tab separated)",
|
||||
],
|
||||
PutIn: ["시트에 넣기", "Put in sheet"],
|
||||
TableName: ["표 이름", "Table name"],
|
||||
Need2: ["머리 칸이 둘은 있어야 함", "Need at least two columns"],
|
||||
NeedRows: ["줄 수를 1 이상으로", "Rows must be 1 or more"],
|
||||
Source: ["원문번호 {n} · 기준 {b} · 출처 {s}", "No. {n} · base {b} · source {s}"],
|
||||
Notes: ["주(註)", "Notes"],
|
||||
Roles: ["칸 역할 — 누르면 바뀜", "Column roles — press to change"],
|
||||
RoleCond: ["조건 → 입력 알약", "Condition → input pill"],
|
||||
RoleOut: ["결과 → 출력 알약", "Result → output pill"],
|
||||
RoleOff: ["쓰지 않음", "Unused"],
|
||||
Redo: ["역할을 바꾸면 찾기 수식을 다시 씀", "Changing roles rewrites the lookup formulas"],
|
||||
Found: ["{n} 번째 줄이 걸림", "Row {n} matched"],
|
||||
NoRow: ["맞는 줄 없음 — 입력 칸 값에 맞는 줄이 표에 없음", "No matching row"],
|
||||
TwoRows: ["맞는 줄 둘 이상 — 조건 칸을 더 늘리거나 표 줄을 고침", "More than one matching row"],
|
||||
Err: ["찾기 칸에 오류가 있음", "Error in the lookup cells"],
|
||||
NoValues: ["표 줄을 아직 못 받음 — 열 때 품셈에서 넣음", "Rows are not loaded yet"],
|
||||
View: ["걸린 줄 보기", "Matched row"],
|
||||
Formulas: ["찾기 수식 — 시트 판에서 고침", "Lookup formulas — edit in the sheet"],
|
||||
Warn: ["알림", "Note"],
|
||||
Swap: ["다른 표로 바꾸기", "Change table"],
|
||||
Failed: ["불러오지 못함", "Could not load"],
|
||||
} as const;
|
||||
|
||||
type TKey = keyof typeof TEXT;
|
||||
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
|
||||
Object.entries(v).reduce(
|
||||
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
|
||||
TEXT[key][currentLanguageIndex] as string,
|
||||
);
|
||||
|
||||
/* ── 서버 읽기 — 절 · 표 ── */
|
||||
async function get<T>(path: string, params: Record<string, string>): Promise<T> {
|
||||
const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (!res.ok) throw new ApiError(res.status, data.detail ?? res.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
interface SectionItem {
|
||||
section: string;
|
||||
title: string;
|
||||
chapter: string;
|
||||
tables: number;
|
||||
}
|
||||
interface TableHead {
|
||||
file: string;
|
||||
키: string;
|
||||
이름: string;
|
||||
기준: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
count: number;
|
||||
}
|
||||
interface RawTable {
|
||||
키: string;
|
||||
원문번호: string;
|
||||
이름: string;
|
||||
기준: string;
|
||||
출처: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
범위규칙?: string | null;
|
||||
줄: TblInfo["rows"];
|
||||
주?: string[];
|
||||
}
|
||||
|
||||
const BOOKS = ["산림품셈", "건설품셈"];
|
||||
const cache = new Map<string, TblInfo>();
|
||||
const fileOf = new Map<string, string>();
|
||||
|
||||
const toInfo = (r: RawTable): TblInfo => ({
|
||||
key: r.키,
|
||||
name: r.이름,
|
||||
base: r.기준 ?? "",
|
||||
source: r.출처 ?? "",
|
||||
no: r.원문번호 ?? "",
|
||||
conds: r.조건,
|
||||
vals: r.값칸,
|
||||
rule: r.범위규칙 ?? null,
|
||||
rows: r.줄,
|
||||
notes: r.주 ?? [],
|
||||
});
|
||||
|
||||
/** 표 키 → 표 한 벌 — 파일은 목록에서 찾음(첫 가져오기 뒤에는 기억) */
|
||||
async function loadInfo(key: string): Promise<TblInfo> {
|
||||
const hit = cache.get(key);
|
||||
if (hit) return hit;
|
||||
let file = fileOf.get(key);
|
||||
if (!file) {
|
||||
const group = /^C[FC]/.test(key) ? "계수" : "소요량";
|
||||
const found = (await get<{ tables: TableHead[] }>("/tables", { group, q: key })).tables.find(
|
||||
(x) => x.키 === key,
|
||||
);
|
||||
if (!found) throw new Error(t("Failed"));
|
||||
file = found.file;
|
||||
}
|
||||
const info = toInfo((await get<{ table: RawTable }>("/table", { file, key })).table);
|
||||
cache.set(key, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
/* ── 역할 · 알약 ── */
|
||||
const rolesOf = (node: SlNode): TblRoles => ({
|
||||
cond: node.pillsIn.map((p) => p.name),
|
||||
out: [
|
||||
...new Set(
|
||||
node.pillsOut.filter((p) => !p.outside).map((p) => p.name.replace(/·(아래|위)$/, "")),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
/** 내 표 읽기 — 시트 블록에서 · 조건 칸은 입력 알약 이름 */
|
||||
function mineInfo(sl: SheetLogic, node: SlNode): TblInfo {
|
||||
const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId);
|
||||
const name = node.name.replace(/^표 · /, "");
|
||||
const conds = Object.fromEntries(node.pillsIn.map((p) => [p.name, "고르기"]));
|
||||
return sheet
|
||||
? infoFromSheet(sheet, { conds, name })
|
||||
: {
|
||||
key: "",
|
||||
name,
|
||||
base: "",
|
||||
source: "",
|
||||
no: "",
|
||||
conds,
|
||||
vals: {},
|
||||
rule: null,
|
||||
rows: [],
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** userTables 한 줄을 시트 모양으로 맞춤 */
|
||||
function syncUser(sl: SheetLogic, id: string, info: TblInfo): SheetLogic {
|
||||
const cols = layoutOf(info);
|
||||
const rows = info.rows.map((r) =>
|
||||
cols.map(
|
||||
(c) =>
|
||||
(Array.isArray(r[c.of])
|
||||
? ((r[c.of] as [number | null, number | null])[c.part === "아래" ? 0 : 1] ?? null)
|
||||
: (r[c.of] ?? null)) as number | string | null,
|
||||
),
|
||||
);
|
||||
const entry = { id, name: info.name, cols: cols.map((c) => c.header), rows };
|
||||
const list = (sl.userTables ?? []).filter((u) => u.id !== id);
|
||||
return { ...sl, userTables: [...list, entry] };
|
||||
}
|
||||
|
||||
/** 찾기 판을 임시 통합문서에서 풀어 → 상태 · 걸린 줄(0 부터) · 오류 */
|
||||
async function solve(
|
||||
sl: SheetLogic,
|
||||
node: SlNode,
|
||||
info: TblInfo,
|
||||
withBlock: boolean,
|
||||
): Promise<{ status: string; idx: number | null }> {
|
||||
const plan = planLookup(info, rolesOf(node));
|
||||
const book = structuredClone(sl.workbook);
|
||||
const sheet = book.시트.find((s) => s.id === node.sheetId);
|
||||
if (!sheet) return { status: "", idx: null };
|
||||
if (withBlock) Object.assign(sheet.칸, blockCells(info));
|
||||
const { createCalcEngine } = await import("../../A00_Common/spreadsheet/spreadsheet_graph");
|
||||
const vals = createCalcEngine(book).snapshot()[node.sheetId] ?? {};
|
||||
const read = (a: string): string | null => {
|
||||
const v = vals[a] as Record<string, unknown> | undefined;
|
||||
if (!v) return null;
|
||||
return String(v["수"] ?? v["글"] ?? v["오류"] ?? "");
|
||||
};
|
||||
const status = read(plan.statusCell) ?? "";
|
||||
const idx = Number(read(plan.idxCell));
|
||||
return { status, idx: Number.isFinite(idx) && idx >= 1 ? idx - 1 : null };
|
||||
}
|
||||
|
||||
/* ── 화면 ── */
|
||||
export function renderTableTools(
|
||||
host: HTMLElement,
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
on: { change(sl: SheetLogic): void },
|
||||
): void {
|
||||
host.className = "m01-sltbl";
|
||||
const node = sl.nodes.find((n) => n.id === nodeId);
|
||||
if (!node || node.kind !== "table") {
|
||||
host.replaceChildren(el("p", { className: "m01-sltbl__muted", text: t("NotTable") }));
|
||||
return;
|
||||
}
|
||||
const mine = node.ref ? sl.userTables?.find((u) => u.id === node.ref) : undefined;
|
||||
const state = !node.ref ? "empty" : mine ? "mine" : "base";
|
||||
const parts: HTMLElement[] = [];
|
||||
if (state !== "empty") parts.push(infoBox(sl, node, state, on));
|
||||
parts.push(
|
||||
importBox(sl, nodeId, state === "empty", on),
|
||||
mineBox(sl, nodeId, state === "empty", on),
|
||||
);
|
||||
host.replaceChildren(...parts);
|
||||
}
|
||||
|
||||
const sub = (title: string, open: boolean, ...kids: HTMLElement[]): HTMLDetailsElement => {
|
||||
const d = el("details", {
|
||||
className: "m01-sltbl__box",
|
||||
children: [el("summary", { text: title }), ...kids],
|
||||
});
|
||||
d.open = open;
|
||||
return d;
|
||||
};
|
||||
|
||||
/** 품셈 표 가져오기 */
|
||||
function importBox(
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
open: boolean,
|
||||
on: { change(sl: SheetLogic): void },
|
||||
): HTMLElement {
|
||||
let book = BOOKS[0];
|
||||
const bookBox = createSelectField({
|
||||
label: t("Book"),
|
||||
options: BOOKS.map((b) => ({ value: b, text: b })),
|
||||
value: book,
|
||||
compact: true,
|
||||
onChange: (v) => ((book = v), load()),
|
||||
});
|
||||
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
|
||||
const list = el("div", { className: "m01-sltbl__list" });
|
||||
const tables = el("div", { className: "m01-sltbl__list" });
|
||||
let seq = 0;
|
||||
const load = async (): Promise<void> => {
|
||||
const mineSeq = ++seq;
|
||||
list.replaceChildren(el("p", { className: "m01-sltbl__muted", text: t("Loading") }));
|
||||
try {
|
||||
const r = await get<{ sections: SectionItem[] }>("/sections", {
|
||||
book,
|
||||
q: q.input.value.trim(),
|
||||
limit: "60",
|
||||
});
|
||||
if (mineSeq !== seq) return;
|
||||
list.replaceChildren(
|
||||
...(r.sections.length
|
||||
? r.sections.map((s) => {
|
||||
const b = el("button", {
|
||||
className: "m01-sltbl__row",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("strong", { text: `${s.section} ${s.title}` }),
|
||||
el("span", {
|
||||
className: "m01-sltbl__muted",
|
||||
text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => void pickSection(s));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m01-sltbl__muted", text: t("NoSection") })]),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mineSeq === seq) showToast(plainError(e), "error");
|
||||
}
|
||||
};
|
||||
const pickSection = async (s: SectionItem): Promise<void> => {
|
||||
try {
|
||||
const heads: TableHead[] = [];
|
||||
for (const group of ["소요량", "계수"])
|
||||
heads.push(
|
||||
...(
|
||||
await get<{ tables: TableHead[] }>("/tables", {
|
||||
group,
|
||||
section: s.section,
|
||||
sub: book,
|
||||
size: "100",
|
||||
})
|
||||
).tables,
|
||||
);
|
||||
tables.replaceChildren(
|
||||
el("h5", { text: t("Tables") }),
|
||||
...heads.map((h) => {
|
||||
const b = el("button", {
|
||||
className: "m01-sltbl__row",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("strong", { text: h.이름 }),
|
||||
el("span", {
|
||||
className: "m01-sltbl__muted",
|
||||
text: t("TableLine", {
|
||||
b: h.기준 || "—",
|
||||
c: Object.keys(h.조건).length,
|
||||
v: Object.keys(h.값칸).length,
|
||||
n: h.count,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => void pickTable(h));
|
||||
return b;
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
showToast(plainError(e), "error");
|
||||
}
|
||||
};
|
||||
const pickTable = async (h: TableHead): Promise<void> => {
|
||||
try {
|
||||
fileOf.set(h.키, h.file);
|
||||
const info = await loadInfo(h.키);
|
||||
const roles: TblRoles = { cond: Object.keys(info.conds), out: Object.keys(info.vals) };
|
||||
on.change(
|
||||
applyTable(sl, nodeId, info, roles, { ref: h.키, fillBlock: false, outside: true }).sl,
|
||||
);
|
||||
} catch (e) {
|
||||
showToast(plainError(e), "error");
|
||||
}
|
||||
};
|
||||
let timer = 0;
|
||||
q.input.addEventListener(
|
||||
"input",
|
||||
() => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))),
|
||||
);
|
||||
const box = sub(
|
||||
t("Import"),
|
||||
open,
|
||||
el("p", { className: "m01-sltbl__muted", text: t("ImportNote") }),
|
||||
bookBox.root,
|
||||
q.root,
|
||||
list,
|
||||
tables,
|
||||
);
|
||||
box.addEventListener("toggle", () => box.open && !list.childElementCount && void load());
|
||||
if (open) void load();
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 내 표 — 빈 표 · 붙여넣기 */
|
||||
function mineBox(
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
open: boolean,
|
||||
on: { change(sl: SheetLogic): void },
|
||||
): HTMLElement {
|
||||
const name = createInputField({
|
||||
type: "text",
|
||||
label: t("TableName"),
|
||||
value: "내 표",
|
||||
compact: true,
|
||||
});
|
||||
const heads = createInputField({ type: "text", placeholder: t("Heads"), compact: true });
|
||||
const rows = createInputField({ type: "text", label: t("RowCount"), value: "2", compact: true });
|
||||
const paste = el("textarea", {
|
||||
className: "m01-sltbl__paste",
|
||||
attrs: { rows: "5", placeholder: t("PasteHint") },
|
||||
});
|
||||
const put = (info: TblInfo, roles: TblRoles): void => {
|
||||
const id = `UT${(sl.userTables?.length ?? 0) + 1}`;
|
||||
const done = applyTable(
|
||||
sl,
|
||||
nodeId,
|
||||
{ ...info, name: name.input.value.trim() || "내 표" },
|
||||
roles,
|
||||
{ ref: id, fillBlock: true, outside: false },
|
||||
);
|
||||
on.change(syncUser(done.sl, id, { ...info, name: name.input.value.trim() || "내 표" }));
|
||||
};
|
||||
const makeEmpty = createButton({
|
||||
label: t("MakeEmpty"),
|
||||
onClick: () => {
|
||||
const cols = heads.input.value
|
||||
.split(/[,,\t]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const n = Math.floor(Number(rows.input.value));
|
||||
if (cols.length < 2) return heads.setError(t("Need2"));
|
||||
if (!(n >= 1)) return rows.setError(t("NeedRows"));
|
||||
const info: TblInfo = {
|
||||
key: "",
|
||||
name: "",
|
||||
base: "",
|
||||
source: "",
|
||||
no: "",
|
||||
conds: { [cols[0]]: "고르기" },
|
||||
vals: Object.fromEntries(cols.slice(1).map((c) => [c, ""])),
|
||||
rule: null,
|
||||
rows: Array.from({ length: n }, () => ({})),
|
||||
notes: [],
|
||||
};
|
||||
put(info, { cond: [cols[0]], out: cols.slice(1) });
|
||||
},
|
||||
});
|
||||
const pasteBtn = createButton({
|
||||
label: t("PutIn"),
|
||||
onClick: () => {
|
||||
const r = parsePaste(paste.value);
|
||||
if (r.error) return void showToast(r.error, "error");
|
||||
put(r.info, { cond: Object.keys(r.info.conds), out: Object.keys(r.info.vals) });
|
||||
},
|
||||
});
|
||||
return sub(
|
||||
t("Mine"),
|
||||
open,
|
||||
name.root,
|
||||
el("h5", { text: t("Empty") }),
|
||||
heads.root,
|
||||
rows.root,
|
||||
makeEmpty,
|
||||
el("h5", { text: t("Paste") }),
|
||||
paste,
|
||||
pasteBtn,
|
||||
);
|
||||
}
|
||||
|
||||
/** 고른 표 — 머리 · 칸 역할 · 걸린 줄 · 수식 */
|
||||
function infoBox(
|
||||
sl: SheetLogic,
|
||||
node: SlNode,
|
||||
state: "base" | "mine",
|
||||
on: { change(sl: SheetLogic): void },
|
||||
): HTMLElement {
|
||||
const box = el("div", { className: "m01-sltbl__info" });
|
||||
const meta = el("p", { className: "m01-sltbl__muted" });
|
||||
const notes = el("details", { className: "m01-sltbl__notes" });
|
||||
const view = el("div", { className: "m01-sltbl__view" });
|
||||
const status = el("p", { className: "m01-sltbl__status", attrs: { "aria-live": "polite" } });
|
||||
const roleBox = el("div", { className: "m01-sltbl__roles" });
|
||||
const formulas = el("div", { className: "m01-sltbl__formulas" });
|
||||
const roles = rolesOf(node);
|
||||
|
||||
const paint = (info: TblInfo): void => {
|
||||
const withBlock = state === "base";
|
||||
meta.textContent =
|
||||
info.no || info.source
|
||||
? t("Source", { n: info.no || "—", b: info.base || "—", s: info.source || "—" })
|
||||
: "";
|
||||
notes.replaceChildren(
|
||||
...(info.notes.length
|
||||
? [el("summary", { text: t("Notes") }), ...info.notes.map((n) => el("p", { text: n }))]
|
||||
: []),
|
||||
);
|
||||
notes.hidden = !info.notes.length;
|
||||
// 칸 역할
|
||||
const all = [
|
||||
...Object.keys(info.conds).map((c) => ({ c, isCond: true })),
|
||||
...Object.keys(info.vals).map((c) => ({ c, isCond: false })),
|
||||
];
|
||||
const cur = (c: string): "cond" | "out" | "off" =>
|
||||
roles.cond.includes(c) ? "cond" : roles.out.includes(c) ? "out" : "off";
|
||||
roleBox.replaceChildren(
|
||||
el("p", { className: "m01-sltbl__muted", text: `${t("Roles")} · ${t("Redo")}` }),
|
||||
...all.map(({ c, isCond }) => {
|
||||
const r = cur(c);
|
||||
const b = el("button", {
|
||||
className: `m01-sltbl__role is-${r}`,
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("strong", { text: c }),
|
||||
el("span", {
|
||||
text: r === "cond" ? t("RoleCond") : r === "out" ? t("RoleOut") : t("RoleOff"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
const next: TblRoles = {
|
||||
cond: roles.cond.filter((x) => x !== c),
|
||||
out: roles.out.filter((x) => x !== c),
|
||||
};
|
||||
const to = r === "off" ? (isCond ? "cond" : "out") : "off";
|
||||
if (to === "cond") next.cond.push(c);
|
||||
if (to === "out") next.out.push(c);
|
||||
const order = Object.keys(info.conds);
|
||||
next.cond.sort((a, z) => order.indexOf(a) - order.indexOf(z));
|
||||
const done = applyTable(sl, node.id, info, next, {
|
||||
ref: node.ref ?? "",
|
||||
fillBlock: false,
|
||||
outside: state === "base",
|
||||
});
|
||||
on.change(state === "mine" ? syncUser(done.sl, node.ref ?? "", info) : done.sl);
|
||||
});
|
||||
return b;
|
||||
}),
|
||||
);
|
||||
// 수식 보기
|
||||
const plan = planLookup(info, roles);
|
||||
const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId);
|
||||
const line = (label: string, a1: string): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m01-sltbl__formula",
|
||||
children: [
|
||||
el("span", { className: "m01-sltbl__muted", text: label }),
|
||||
el("code", {
|
||||
text: sheet?.칸[a1]?.식 ? `=${sheet.칸[a1].식}` : String(sheet?.칸[a1]?.값 ?? ""),
|
||||
}),
|
||||
],
|
||||
});
|
||||
formulas.replaceChildren(
|
||||
el("h5", { text: t("Formulas") }),
|
||||
...Object.entries(plan.outCells).map(([c, a1]) => line(`${c} (${a1})`, a1)),
|
||||
...(plan.warnings.length
|
||||
? [
|
||||
el("p", {
|
||||
className: "m01-sltbl__warn",
|
||||
text: `${t("Warn")}: ${plan.warnings.join(" · ")}`,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
// 걸린 줄 칠하기
|
||||
const cols = layoutOf(info);
|
||||
const table = el("table");
|
||||
table.append(el("tr", { children: cols.map((c) => el("th", { text: c.header })) }));
|
||||
info.rows.forEach((r, i) =>
|
||||
table.append(
|
||||
el("tr", {
|
||||
attrs: { "data-row": String(i) },
|
||||
children: cols.map((c) => {
|
||||
const v = r[c.of];
|
||||
const x = Array.isArray(v) ? (c.part === "아래" ? v[0] : v[1]) : v;
|
||||
return el("td", { text: x === null || x === undefined ? "" : String(x) });
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
view.replaceChildren(
|
||||
el("h5", { text: t("View") }),
|
||||
el("div", { className: "m01-sltbl__scroll", children: [table] }),
|
||||
);
|
||||
void solve(sl, node, info, withBlock)
|
||||
.then(({ status: s, idx }) => {
|
||||
status.className = `m01-sltbl__status is-${s === "찾음" ? "ok" : "bad"}`;
|
||||
status.textContent =
|
||||
s === "찾음" && idx !== null
|
||||
? t("Found", { n: idx + 1 })
|
||||
: s === "맞는 줄 없음"
|
||||
? t("NoRow")
|
||||
: s === "맞는 줄 둘 이상"
|
||||
? t("TwoRows")
|
||||
: s
|
||||
? t("Err")
|
||||
: "";
|
||||
table
|
||||
.querySelectorAll("tr[data-row]")
|
||||
.forEach((tr) =>
|
||||
tr.classList.toggle(
|
||||
"is-hit",
|
||||
idx !== null && s === "찾음" && tr.getAttribute("data-row") === String(idx),
|
||||
),
|
||||
);
|
||||
table.querySelector(".is-hit")?.scrollIntoView({ block: "nearest" });
|
||||
})
|
||||
.catch(() => (status.textContent = t("Err")));
|
||||
};
|
||||
|
||||
box.append(meta, notes, roleBox, status, view, formulas);
|
||||
if (state === "base") {
|
||||
const key = node.ref ?? "";
|
||||
void loadInfo(key).then(paint, (e) => showToast(plainError(e), "error"));
|
||||
} else paint(mineInfo(sl, node));
|
||||
return box;
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Table_Build.ts
|
||||
* 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 합친 조건 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기
|
||||
* DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M01_SL_Table.ts`
|
||||
*
|
||||
* 시트 모양(표 상자 시트 하나):
|
||||
* A1 부터 = 표 블록(머리 줄 + 줄 · 범위 조건 「3~4」 는 「이름·아래」 「이름·위」 두 칸) — 품셈 표는 바깥 값이 채움
|
||||
* 빈 열 하나 건너 = 찾기용 합친 조건 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 합친 조건 열이 따로)
|
||||
* 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 합친 조건 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다)
|
||||
* 맞는 줄 없음 · 둘 이상이면 상태 칸이 글로 알리고 결과 칸은 #N/A
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model";
|
||||
|
||||
type Rec = Record<string, string | number | [number | null, number | null] | null | undefined>;
|
||||
|
||||
/** 표 한 벌 — 서버 `/table` 답에서 쓸 칸만 · 내 표는 시트에서 읽어 만듦 */
|
||||
export interface TblInfo {
|
||||
key: string;
|
||||
name: string;
|
||||
/** 기준 「1㎡」 */
|
||||
base: string;
|
||||
source: string;
|
||||
/** 원문번호 「13-4-1」 */
|
||||
no: string;
|
||||
/** 조건 칸 → 「수」 「고르기」 「범위」 */
|
||||
conds: Record<string, string>;
|
||||
/** 값 칸 → 단위 */
|
||||
vals: Record<string, string>;
|
||||
rule: string | null;
|
||||
rows: Rec[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** 칸 역할 — 조건 · 결과(나머지는 쓰지 않음) */
|
||||
export interface TblRoles {
|
||||
cond: string[];
|
||||
out: string[];
|
||||
}
|
||||
|
||||
/** 블록 열 하나 — 범위 칸은 아래 · 위 두 열 */
|
||||
export interface TblCol {
|
||||
header: string;
|
||||
of: string;
|
||||
part: "값" | "아래" | "위";
|
||||
}
|
||||
|
||||
export interface TblPlan {
|
||||
cols: TblCol[];
|
||||
/** 블록 안 줄 수(머리 줄 빼고) */
|
||||
rowCount: number;
|
||||
/** 블록 밖에 쓸 칸 — A1 → 칸 */
|
||||
cells: Record<string, Cell>;
|
||||
/** 조건 칸 이름 → 입력 칸 */
|
||||
inCells: Record<string, string>;
|
||||
/** 결과 칸 이름 → 출력 칸 */
|
||||
outCells: Record<string, string>;
|
||||
idxCell: string;
|
||||
statusCell: string;
|
||||
/** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 합친 조건 열은 다시 쓸 때 지움 */
|
||||
helperFrom: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** 열 글자 0 → A */
|
||||
export const colName = (c: number): string => {
|
||||
let s = "";
|
||||
for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26))
|
||||
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
|
||||
return s;
|
||||
};
|
||||
const colIndex = (letters: string): number =>
|
||||
[...letters].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0) - 1;
|
||||
const at = (r: number, c: number): string => `${colName(c)}${r}`;
|
||||
|
||||
/** 표 줄이 늘어도 따라가도록 블록 아래로 여유 줄을 둠 — 줄 수 이상 · 최소 20 */
|
||||
const marginOf = (n: number): number => Math.max(20, n);
|
||||
const quote = (s: string): string => `"${s.replace(/"/g, '""')}"`;
|
||||
|
||||
/** 범위 칸인가 — 조건 칸이 「범위」 이거나 값 칸에 범위(배열) 줄이 하나라도 있으면(서버 표 블록과 같은 규칙) */
|
||||
export const isSplit = (info: TblInfo, c: string): boolean =>
|
||||
info.conds[c] === "범위" || (c in info.vals && info.rows.some((r) => Array.isArray(r[c])));
|
||||
|
||||
/** 블록 열 차례 — 조건 칸 → 값 칸 · 범위 칸은 「이름·아래」 「이름·위」 둘로(서버 `_table` 과 같은 차례) */
|
||||
export function layoutOf(info: TblInfo): TblCol[] {
|
||||
const out: TblCol[] = [];
|
||||
for (const c of [...Object.keys(info.conds), ...Object.keys(info.vals)])
|
||||
if (isSplit(info, c))
|
||||
out.push(
|
||||
{ header: `${c}·아래`, of: c, part: "아래" },
|
||||
{ header: `${c}·위`, of: c, part: "위" },
|
||||
);
|
||||
else out.push({ header: c, of: c, part: "값" });
|
||||
return out;
|
||||
}
|
||||
|
||||
const cellOf = (v: Rec[string]): Cell | null =>
|
||||
v === null || v === undefined || v === ""
|
||||
? null
|
||||
: typeof v === "number"
|
||||
? { 값: v }
|
||||
: { 값: String(v) };
|
||||
|
||||
/** 블록 칸 — 머리 줄 + 줄(내 표 · 시험이 씀 · 품셈 표는 바깥 값이 채움) */
|
||||
export function blockCells(info: TblInfo): Record<string, Cell> {
|
||||
const cells: Record<string, Cell> = {};
|
||||
const cols = layoutOf(info);
|
||||
cols.forEach((col, c) => (cells[at(1, c)] = { 값: col.header }));
|
||||
info.rows.forEach((row, i) =>
|
||||
cols.forEach((col, c) => {
|
||||
const v = row[col.of];
|
||||
// 범위 칸에 숫자 하나면 아래 · 위에 같은 수
|
||||
const x = Array.isArray(v) ? (col.part === "아래" ? v[0] : v[1]) : v;
|
||||
const cell = cellOf(x);
|
||||
if (cell) cells[at(i + 2, c)] = cell;
|
||||
}),
|
||||
);
|
||||
return cells;
|
||||
}
|
||||
|
||||
/** 조건 칸 하나의 입력 칸 처음 값 — 첫 줄 */
|
||||
const sampleOf = (info: TblInfo, c: string): string | number => {
|
||||
const v = info.rows[0]?.[c];
|
||||
if (Array.isArray(v))
|
||||
return v[0] !== null && v[1] !== null
|
||||
? Number(((v[0] + v[1]) / 2).toFixed(6))
|
||||
: (v[1] ?? v[0] ?? 0);
|
||||
return v === null || v === undefined ? "" : v;
|
||||
};
|
||||
|
||||
/** 표 상자 시트에 쓸 찾기 칸 · 알약 계획 */
|
||||
export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
|
||||
const cols = layoutOf(info);
|
||||
const warnings: string[] = [];
|
||||
const W = cols.length;
|
||||
const n = info.rows.length;
|
||||
const letter = (header: string): string => colName(cols.findIndex((c) => c.header === header));
|
||||
const conds = roles.cond.filter((c) => c in info.conds);
|
||||
const rangeCols = conds.filter((c) => info.conds[c] === "범위");
|
||||
const exact = conds.filter((c) => info.conds[c] !== "범위");
|
||||
const range = rangeCols[0];
|
||||
if (rangeCols.length > 1) warnings.push("범위 조건은 한 칸까지만 찾음 — 나머지는 뺌");
|
||||
const blankable = exact.filter((c) =>
|
||||
info.rows.some((r) => r[c] === null || r[c] === undefined || r[c] === ""),
|
||||
);
|
||||
if (blankable.length > 2) warnings.push("빈칸(무관) 조건 칸은 둘까지만 지원");
|
||||
if (range && blankable.length)
|
||||
warnings.push("범위 조건과 빈칸(무관)은 함께 못 씀 — 빈칸 줄은 맞지 않음");
|
||||
const rule = info.rule ?? "초과·이하";
|
||||
const lowBased = /이상·미만/.test(rule) || (/^이상/.test(rule) && !/초과/.test(rule));
|
||||
const R = n + marginOf(n) + 1;
|
||||
const rng = (L: string): string => `${L}$2:${L}$${R}`;
|
||||
|
||||
// 합친 조건 열 — 맨 앞은 모든 조건 칸 · 이어서 빈칸(무관) 변형들
|
||||
const variants: string[][] = [[]];
|
||||
if (!range) {
|
||||
const bl = blankable.slice(0, 2);
|
||||
const subsets =
|
||||
bl.length === 2 ? [[bl[0]], [bl[1]], [bl[0], bl[1]]] : bl.length === 1 ? [[bl[0]]] : [];
|
||||
variants.push(...subsets.filter((sub) => sub.length < exact.length)); // 모든 조건 칸이 빈칸인 변형은 합친 조건가 없음
|
||||
}
|
||||
const keyCols = exact.length ? variants : [];
|
||||
const kStart = W + 1;
|
||||
const kLetters = keyCols.map((_, i) => colName(kStart + i));
|
||||
const L = kStart + keyCols.length + 1; // 찾기 판 왼쪽 열
|
||||
const V = L + 1;
|
||||
const cells: Record<string, Cell> = {};
|
||||
keyCols.forEach((skip, v) => {
|
||||
cells[at(1, kStart + v)] = {
|
||||
값: skip.length ? `합친 조건(${skip.join("·")} 무관)` : "합친 조건",
|
||||
};
|
||||
const rest = exact.filter((c) => !skip.includes(c));
|
||||
for (let r = 2; r <= R; r++) {
|
||||
const key = rest.map((c) => `${letter(c)}${r}`).join('&"|"&');
|
||||
if (!skip.length) cells[at(r, kStart + v)] = { 식: key };
|
||||
else {
|
||||
const guard = [
|
||||
...skip.map((c) => `${letter(c)}${r}=""`),
|
||||
...rest.map((c) => `${letter(c)}${r}<>""`),
|
||||
].join(",");
|
||||
cells[at(r, kStart + v)] = { 식: `IF(AND(${guard}),${key},"")` };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 찾기 판
|
||||
let row = 1;
|
||||
cells[at(row++, L)] = {
|
||||
값: ["표에서 찾기", info.name, info.no, info.base].filter(Boolean).join(" · "),
|
||||
};
|
||||
const inCells: Record<string, string> = {};
|
||||
const inputOf: Record<string, string> = {};
|
||||
for (const c of conds) {
|
||||
cells[at(row, L)] = { 값: c };
|
||||
const sample = sampleOf(info, c);
|
||||
if (sample !== "") cells[at(row, V)] = { 값: sample };
|
||||
inCells[c] = at(row, V);
|
||||
inputOf[c] = at(row, V);
|
||||
row++;
|
||||
}
|
||||
// 표 줄 수 — 블록 안 칸이 찬 줄 수(마스터 표 줄이 늘거나 줄어도 따라감)
|
||||
const rowsCell = ((): string => {
|
||||
cells[at(row, L)] = { 값: "표 줄 수" };
|
||||
cells[at(row, V)] = {
|
||||
식: `MAX(${cols.map((_, c) => `COUNTA(${rng(colName(c))})`).join(",")})`,
|
||||
};
|
||||
return at(row++, V);
|
||||
})();
|
||||
const keyOf = (skip: string[]): string =>
|
||||
exact
|
||||
.filter((c) => !skip.includes(c))
|
||||
.map((c) => inputOf[c])
|
||||
.join('&"|"&');
|
||||
const idxRow = (label: string, f: string): string => {
|
||||
cells[at(row, L)] = { 값: label };
|
||||
cells[at(row, V)] = { 식: f };
|
||||
return at(row++, V);
|
||||
};
|
||||
const up = range ? rng(letter(`${range}·위`)) : "";
|
||||
const lo = range ? rng(letter(`${range}·아래`)) : "";
|
||||
const x = range ? inputOf[range] : "";
|
||||
let idx = "";
|
||||
let count = "";
|
||||
if (exact.length) {
|
||||
const keyCell = idxRow("합친 조건", keyOf([]));
|
||||
const ks = keyCols.map((skip, v) => {
|
||||
if (v === 0) return keyCell;
|
||||
cells[at(row, L)] = { 값: `합친 조건(${skip.join("·")} 무관)` };
|
||||
cells[at(row, V)] = { 식: keyOf(skip) };
|
||||
return at(row++, V);
|
||||
});
|
||||
if (range) {
|
||||
idx = idxRow(
|
||||
"걸린 줄",
|
||||
`MATCH(${ks[0]},${rng(kLetters[0])},0)+COUNTIFS(${rng(kLetters[0])},${ks[0]},${up},"<"&${x})`,
|
||||
);
|
||||
count = idxRow(
|
||||
"일치 수",
|
||||
`IF(ISNUMBER(${idx}),IF(${idx}>${rowsCell},0,IF(AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx}))),1,0)),0)`,
|
||||
);
|
||||
} else {
|
||||
const found = ks.map((k, v) => `COUNTIF(${rng(kLetters[v])},${k})`);
|
||||
count = idxRow(
|
||||
"일치 수",
|
||||
found.reduceRight((acc, f) => `IF(${f}>0,${f},${acc})`, "0"),
|
||||
);
|
||||
const chain = ks
|
||||
.map((k, v) => `MATCH(${k},${rng(kLetters[v])},0)`)
|
||||
.reduceRight((acc, m) => `IFERROR(${m},${acc})`, "NA()");
|
||||
idx = idxRow("걸린 줄", chain);
|
||||
}
|
||||
} else if (range) {
|
||||
idx = idxRow("걸린 줄", lowBased ? `COUNTIF(${lo},"<="&${x})` : `COUNTIF(${up},"<"&${x})+1`);
|
||||
const inside = lowBased
|
||||
? `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>=INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<INDEX(${up},${idx})))`
|
||||
: `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx})))`;
|
||||
count = idxRow("일치 수", `IF(AND(${idx}>=1,${idx}<=${rowsCell}),IF(${inside},1,0),0)`);
|
||||
} else {
|
||||
warnings.push("조건 칸이 없음 — 찾을 줄이 없음");
|
||||
count = idxRow("일치 수", "0");
|
||||
idx = idxRow("걸린 줄", "NA()");
|
||||
}
|
||||
if (lowBased && range && exact.length)
|
||||
warnings.push("조건이 섞이면 범위는 「초과·이하」 로만 찾음");
|
||||
const statusCell = idxRow(
|
||||
"상태",
|
||||
`IF(${count}=0,${quote("맞는 줄 없음")},IF(${count}>1,${quote("맞는 줄 둘 이상")},${quote("찾음")}))`,
|
||||
);
|
||||
const outCells: Record<string, string> = {};
|
||||
for (const c of roles.out.filter((o) => o in info.vals)) {
|
||||
for (const h of isSplit(info, c) ? [`${c}·아래`, `${c}·위`] : [c])
|
||||
outCells[h] = idxRow(
|
||||
h,
|
||||
`IF(${statusCell}=${quote("찾음")},INDEX(${rng(letter(h))},${idx}),NA())`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
cols,
|
||||
rowCount: n,
|
||||
cells,
|
||||
inCells,
|
||||
outCells,
|
||||
idxCell: idx,
|
||||
statusCell,
|
||||
helperFrom: W,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/** 알약 — 조건 칸마다 입력 알약 · 결과 칸마다 출력 알약 · 바깥 값 표 알약(품셈 표만) */
|
||||
export function pillsOf(
|
||||
info: TblInfo,
|
||||
plan: TblPlan,
|
||||
outside: boolean,
|
||||
): { pillsIn: SlPill[]; pillsOut: SlPill[] } {
|
||||
const pillsIn = Object.entries(plan.inCells).map(([name, cell]): SlPill => ({ name, cell }));
|
||||
const pillsOut = Object.entries(plan.outCells).map(([name, cell]): SlPill => ({
|
||||
name,
|
||||
cell,
|
||||
unit: info.vals[name.replace(/·(아래|위)$/, "")] || undefined,
|
||||
}));
|
||||
if (outside) pillsOut.push({ name: "표", cell: "A1", outside: true });
|
||||
return { pillsIn, pillsOut };
|
||||
}
|
||||
|
||||
const sheetIndexOf = (sl: SheetLogic, node: SlNode): number =>
|
||||
sl.workbook.시트.findIndex((s) => s.id === node.sheetId);
|
||||
|
||||
/** 시트 이름을 바꿈 — 같은 이름이 있으면 번호를 붙이고 · 다른 시트가 이 시트를 가리키던 참조 수식도 따라 고침 */
|
||||
function renameSheet(sl: SheetLogic, sheet: Sheet, want: string): void {
|
||||
if (sheet.이름 === want) return;
|
||||
const taken = new Set(sl.workbook.시트.filter((s) => s !== sheet).map((s) => s.이름));
|
||||
let name = want;
|
||||
for (let i = 2; taken.has(name); i++) name = `${want} (${i})`;
|
||||
const quote = (n: string): string => `'${n.replace(/'/g, "''")}'`;
|
||||
const old = sheet.이름;
|
||||
const olds = [`${quote(old)}!`, ...(/^[\p{L}\p{N}_]+$/u.test(old) ? [`${old}!`] : [])];
|
||||
for (const s of sl.workbook.시트)
|
||||
for (const cell of Object.values(s.칸))
|
||||
if (cell.식) for (const o of olds) cell.식 = cell.식.split(o).join(`${quote(name)}!`);
|
||||
sheet.이름 = name;
|
||||
}
|
||||
|
||||
/** 표 상자에 표 · 찾기 판을 씀 — 새 시트 로직(받은 것은 안 바꿈) · `fillBlock` 이면 블록도 씀(내 표 · 시험) */
|
||||
export function applyTable(
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
info: TblInfo,
|
||||
roles: TblRoles,
|
||||
opts: { ref: string; fillBlock: boolean; outside: boolean },
|
||||
): { sl: SheetLogic; plan: TblPlan } {
|
||||
const next = structuredClone(sl);
|
||||
const node = next.nodes.find((n) => n.id === nodeId);
|
||||
if (!node) throw new Error("없는 상자");
|
||||
const si = sheetIndexOf(next, node);
|
||||
if (si < 0) throw new Error("상자의 시트가 없음");
|
||||
const plan = planLookup(info, roles);
|
||||
const sheet: Sheet = next.workbook.시트[si];
|
||||
// 이미 있던 입력 칸 내용(예시값 · 선을 그은 참조 수식)은 같은 이름이면 그대로 가져감
|
||||
const oldIn = Object.fromEntries(node.pillsIn.map((p) => [p.name, sheet.칸[p.cell]]));
|
||||
const keep = Object.entries(sheet.칸).filter(
|
||||
([a]) => colIndex(a.replace(/\d+/g, "")) < plan.helperFrom,
|
||||
);
|
||||
sheet.칸 = Object.fromEntries(keep);
|
||||
if (opts.fillBlock) {
|
||||
for (const a of Object.keys(sheet.칸)) delete sheet.칸[a];
|
||||
Object.assign(sheet.칸, blockCells(info));
|
||||
}
|
||||
Object.assign(sheet.칸, plan.cells);
|
||||
for (const [name, cell] of Object.entries(plan.inCells))
|
||||
if (oldIn[name]) sheet.칸[cell] = oldIn[name];
|
||||
const pills = pillsOf(info, plan, opts.outside);
|
||||
const gone = (dir: "in" | "out", name: string): boolean =>
|
||||
!(dir === "in" ? pills.pillsIn : pills.pillsOut).some((p) => p.name === name);
|
||||
next.edges = next.edges.filter(
|
||||
(e) =>
|
||||
!(e.from.node === nodeId && gone("out", e.from.pill)) &&
|
||||
!(e.to.node === nodeId && gone("in", e.to.pill)),
|
||||
);
|
||||
node.name = `표 · ${info.name}`;
|
||||
renameSheet(next, sheet, node.name);
|
||||
node.ref = opts.ref;
|
||||
node.pillsIn = pills.pillsIn;
|
||||
node.pillsOut = pills.pillsOut;
|
||||
return { sl: next, plan };
|
||||
}
|
||||
|
||||
/** 엑셀에서 붙여넣은 글(탭 · 줄바꿈) → 머리 · 줄 — 「3~4」 꼴 칸 열은 아래 · 위 범위 칸으로 */
|
||||
export function parsePaste(text: string): { info: TblInfo; error: string | null } {
|
||||
const lines = text
|
||||
.replace(/\r/g, "")
|
||||
.split("\n")
|
||||
.filter((l) => l.trim() !== "");
|
||||
const empty: TblInfo = {
|
||||
key: "",
|
||||
name: "",
|
||||
base: "",
|
||||
source: "",
|
||||
no: "",
|
||||
conds: {},
|
||||
vals: {},
|
||||
rule: null,
|
||||
rows: [],
|
||||
notes: [],
|
||||
};
|
||||
if (lines.length < 2) return { info: empty, error: "머리 한 줄과 줄 하나는 있어야 함" };
|
||||
const grid = lines.map((l) => l.split("\t").map((c) => c.trim()));
|
||||
const head = grid[0];
|
||||
if (head.some((h) => h === "") || new Set(head).size !== head.length)
|
||||
return { info: empty, error: "머리 칸 이름이 비었거나 겹침" };
|
||||
const num = (s: string): number | null =>
|
||||
s !== "" && Number.isFinite(Number(s.replace(/,/g, ""))) ? Number(s.replace(/,/g, "")) : null;
|
||||
const rangeRe = /^(-?\d+(?:\.\d+)?)?\s*[~∼]\s*(-?\d+(?:\.\d+)?)?$/;
|
||||
const body = grid.slice(1);
|
||||
const isRange = (c: number): boolean =>
|
||||
body.some((r) => (r[c] ?? "").includes("~") || (r[c] ?? "").includes("∼")) &&
|
||||
body.every((r) => !r[c] || rangeRe.test(r[c]));
|
||||
const rows: Rec[] = body.map((r) => {
|
||||
const rec: Rec = {};
|
||||
head.forEach((h, c) => {
|
||||
const s = r[c] ?? "";
|
||||
if (isRange(c)) {
|
||||
const m = rangeRe.exec(s);
|
||||
rec[h] = m
|
||||
? [m[1] === undefined ? null : Number(m[1]), m[2] === undefined ? null : Number(m[2])]
|
||||
: null;
|
||||
} else rec[h] = s === "" ? null : (num(s) ?? s);
|
||||
});
|
||||
return rec;
|
||||
});
|
||||
// 첫 칸부터 글 · 수 가운데 숫자가 아닌 칸 · 범위 칸이 조건 · 나머지(수만 든 칸)는 값
|
||||
const conds: Record<string, string> = {};
|
||||
const vals: Record<string, string> = {};
|
||||
head.forEach((h, c) => {
|
||||
const numeric = body.every((r) => !r[c] || num(r[c]) !== null);
|
||||
if (isRange(c)) conds[h] = "범위";
|
||||
else if (!numeric) conds[h] = "고르기";
|
||||
else if (c === 0 && head.length > 1) conds[h] = "수";
|
||||
else vals[h] = "";
|
||||
});
|
||||
if (!Object.keys(vals).length) return { info: empty, error: "결과로 쓸 숫자 칸이 없음" };
|
||||
return { info: { ...empty, conds, vals, rows }, error: null };
|
||||
}
|
||||
|
||||
/** 내 표 — 시트 블록(A1 머리 줄 · 아래로 줄)에서 표 한 벌을 읽음 · 「이름·아래」 「이름·위」 짝은 범위 칸 */
|
||||
export function infoFromSheet(sheet: Sheet, prev: Pick<TblInfo, "conds" | "name">): TblInfo {
|
||||
const heads: string[] = [];
|
||||
for (let c = 0; ; c++) {
|
||||
const h = sheet.칸[at(1, c)]?.값;
|
||||
if (h === undefined || h === "") break;
|
||||
heads.push(String(h));
|
||||
}
|
||||
let last = 1;
|
||||
for (const a of Object.keys(sheet.칸)) {
|
||||
const m = /^([A-Z]+)(\d+)$/.exec(a);
|
||||
if (m && colIndex(m[1]) < heads.length) last = Math.max(last, Number(m[2]));
|
||||
}
|
||||
const conds: Record<string, string> = {};
|
||||
const vals: Record<string, string> = {};
|
||||
const names: { of: string; part: "값" | "아래" | "위"; c: number }[] = [];
|
||||
heads.forEach((h, c) => {
|
||||
const m = /^(.+)·(아래|위)$/.exec(h);
|
||||
names.push(m ? { of: m[1], part: m[2] as "아래" | "위", c } : { of: h, part: "값", c });
|
||||
});
|
||||
for (const nm of names) {
|
||||
if (nm.part !== "값") {
|
||||
if (prev.conds[nm.of]) conds[nm.of] = "범위";
|
||||
else vals[nm.of] = "";
|
||||
} else if (prev.conds[nm.of]) conds[nm.of] = prev.conds[nm.of];
|
||||
else vals[nm.of] = "";
|
||||
}
|
||||
const rows: Rec[] = [];
|
||||
for (let r = 2; r <= last; r++) {
|
||||
const rec: Rec = {};
|
||||
for (const nm of names) {
|
||||
const v = sheet.칸[at(r, nm.c)]?.값;
|
||||
const x = v === undefined ? null : typeof v === "boolean" ? String(v) : v;
|
||||
if (nm.part === "값") rec[nm.of] = x;
|
||||
else {
|
||||
const prevPair = (rec[nm.of] as [number | null, number | null] | undefined) ?? [null, null];
|
||||
prevPair[nm.part === "아래" ? 0 : 1] = typeof x === "number" ? x : null;
|
||||
rec[nm.of] = prevPair;
|
||||
}
|
||||
}
|
||||
rows.push(rec);
|
||||
}
|
||||
return {
|
||||
key: "",
|
||||
name: prev.name,
|
||||
base: "",
|
||||
source: "",
|
||||
no: "",
|
||||
conds,
|
||||
vals,
|
||||
rule: null,
|
||||
rows,
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
_data/
|
||||
@@ -0,0 +1,136 @@
|
||||
/* =============================================================================
|
||||
* M02_CF_Api.ts
|
||||
* 단가산출 다이어그램 Api — 목록(내 것 + 견본) · 열기 · 저장(서버가 입력 값을 마스터에서 다시 넣고
|
||||
* 같은 엔진으로 함수 상자 시트를 다시 셈) · 지우기 · 입력 값 한 번에 받기 · 사용자 요소
|
||||
* 서버 = `M02_CF_Router.py`(`/api/m02/cf/*`) · 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md`
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
/** 입력 그룹 — M01 저장 그룹 + 사용자 요소 */
|
||||
export type CfGroup =
|
||||
"인력" | "재료" | "유가전력" | "기계" | "소요량" | "계수" | "환율" | "요율" | "사용자";
|
||||
|
||||
/** 비용 종류 칸 */
|
||||
export type CfCost = "노무비" | "재료비" | "경비" | "계";
|
||||
|
||||
/** 입력 값 물음 한 줄 — 값은 열 때 · 저장 때 마스터(사용자 요소는 `_data`)에서 새로
|
||||
* · 인력 · 재료 = 그 줄 단가(기존 계산과 같은 셈 · 준용 · 낮은 값) — 재료 key 는 키 또는 검색어 묶음 JSON 글,
|
||||
* unit(그 줄 단위)을 주면 기존 로직 재료 줄과 같은 단가
|
||||
* · 유가전력 · 환율 · 요율 · 사용자 = 그 줄 값
|
||||
* · 기계 = key(EQ…) + field(값 칸 이름 — 가격 · 손료계수 …) 또는 element(기계 시간당 경비 로직 부름 글) + inputs → 한 시간 값
|
||||
* · 소요량 · 계수 = 표 블록(머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 「이름·아래」 「이름·위」) · row 를 주면 그 줄 하나 */
|
||||
export type CfQuery =
|
||||
| { id: string; group: "인력" | "유가전력" | "환율" | "요율" | "사용자"; key: string }
|
||||
| { id: string; group: "재료"; key: string; unit?: string }
|
||||
| { id: string; group: "기계"; key: string; field?: string }
|
||||
| {
|
||||
id: string;
|
||||
group: "기계";
|
||||
element: string;
|
||||
inputs: Record<string, unknown>;
|
||||
field?: CfCost;
|
||||
}
|
||||
| { id: string; group: "소요량" | "계수"; table: string; row?: number };
|
||||
|
||||
/** 입력 값 답 — 표는 `rows`(머리 줄 포함) · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */
|
||||
export interface CfValue {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
value?: number | string | null;
|
||||
rows?: (string | number | null)[][];
|
||||
name?: string;
|
||||
unit?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** 저장 때 입력 값을 넣을 자리 — 함수 상자 시트 id · 칸(표는 이 칸을 왼쪽 위로 펼침) */
|
||||
export interface CfFill {
|
||||
item: CfQuery;
|
||||
sheet: string;
|
||||
cell: string;
|
||||
}
|
||||
|
||||
/** 다이어그램 한 벌 = 통합문서(함수 상자 = 시트) + 상자 · 선(다른 창 칸은 그대로 저장 · 돌려줌) */
|
||||
export interface CfLogic {
|
||||
/** 새 것 null · 저장하면 서버가 `CF-0001` 꼴로 줌 · 견본은 그 이름(저장하면 새 번호) */
|
||||
key: string | null;
|
||||
name: string;
|
||||
resultUnit?: string;
|
||||
workbook: Workbook;
|
||||
/** 입력 값 자리 — 서버가 저장 때 마스터에서 다시 넣음 */
|
||||
fills?: CfFill[];
|
||||
[extra: string]: unknown;
|
||||
}
|
||||
|
||||
/** 목록 한 줄 — `sample` = `_sample` 견본(읽기만 · 저장하면 새 번호) */
|
||||
export interface CfListItem {
|
||||
key: string;
|
||||
name: string;
|
||||
resultUnit: string;
|
||||
saved: string;
|
||||
sample: boolean;
|
||||
}
|
||||
|
||||
/** 저장 답 — `logic.workbook.계산값` 은 서버가 다시 셈한 값 · `values` 는 넣은 입력 값 */
|
||||
export interface CfSaved {
|
||||
key: string;
|
||||
logic: CfLogic;
|
||||
values: CfValue[];
|
||||
}
|
||||
|
||||
/** 사용자 요소 — 마스터에 없는 요소(시험 폴더 `_data` 에만) · 키 `UE-0001` 꼴 */
|
||||
export interface CfUserElement {
|
||||
key: string;
|
||||
group: CfGroup;
|
||||
이름: string;
|
||||
규격: string;
|
||||
단위: string;
|
||||
값: number | null;
|
||||
비고?: string;
|
||||
}
|
||||
|
||||
const notReady = (): never => {
|
||||
throw new Error("준비 중");
|
||||
};
|
||||
|
||||
export async function cfList(): Promise<CfListItem[]> {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
export async function cfGet(_key: string): Promise<CfLogic> {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
export async function cfSave(_logic: CfLogic): Promise<CfSaved> {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
export async function cfDelete(_key: string): Promise<void> {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
export async function cfValues(_items: CfQuery[]): Promise<CfValue[]> {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 사용자 요소 목록 */
|
||||
export async function cfUserElements(): Promise<CfUserElement[]> {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 사용자 요소 만들기 — 키는 서버가 줌 */
|
||||
export async function cfUserElementAdd(
|
||||
_element: Omit<CfUserElement, "key">,
|
||||
): Promise<CfUserElement> {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
export async function cfUserElementDelete(_key: string): Promise<void> {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 실패 → 쉬운 말 */
|
||||
export function cfPlain(_e: unknown): string {
|
||||
return notReady();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* =============================================================================
|
||||
* M02_CF_Eval.ts
|
||||
* 단가산출 다이어그램 계산 · 검사 — 함수 상자는 차례대로 시트를 우리 스프레드시트 엔진으로 셈
|
||||
* (받는 칸에 앞 값을 넣음) · 출력 상자를 비용 종류별로 모아 원가 · DOM 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CfLogic } from "./M02_CF_Model";
|
||||
|
||||
/** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */
|
||||
export type CfValue = number | string | boolean | null | (number | string | null)[][];
|
||||
|
||||
/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값) */
|
||||
export type CfValues = Record<string, Record<string, CfValue>>;
|
||||
|
||||
export interface CfEvalResult {
|
||||
/** 상자 id → 내보내는 알약 이름 → 값 */
|
||||
ports: Record<string, Record<string, CfValue>>;
|
||||
/** 출력 상자 id → 값 */
|
||||
outputs: Record<string, number | string | boolean | null>;
|
||||
cost: {
|
||||
노무비: number;
|
||||
재료비: number;
|
||||
경비: number;
|
||||
/** 비용 아닌 출력(값) — 출력 상자 이름 → 값 */
|
||||
값들: Record<string, number | string | boolean | null>;
|
||||
계: number;
|
||||
};
|
||||
/** 쉬운 말 */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const notReady = (): never => {
|
||||
throw new Error("준비 중");
|
||||
};
|
||||
|
||||
/** 화면 계산 */
|
||||
export function evalLogic(_lg: CfLogic, _values: CfValues = {}): CfEvalResult {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 검사 — 빈 받는 자리 · 출력 하나 아님 · 순환 · 오류(쉬운 말 목록) */
|
||||
export function checkLogic(_lg: CfLogic, _values: CfValues = {}): string[] {
|
||||
return notReady();
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/* =============================================================================
|
||||
* M02_CF_Model.ts
|
||||
* 단가산출 다이어그램 Model — 입력 · 함수 · 출력 상자를 선으로 이음 · 함수 상자 = 시트 하나(출력 하나)
|
||||
* DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 그대로
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
export type CfKind = "input" | "function" | "output";
|
||||
export type CfGroup = "인력" | "재료" | "유가전력" | "기계" | "소요량" | "계수" | "환율" | "요율";
|
||||
export type CfCost = "노무비" | "재료비" | "경비" | "값";
|
||||
|
||||
/** 입력 상자가 고른 요소 — ref = 마스터 키 또는 사용자 요소 id */
|
||||
export interface CfElementRef {
|
||||
ref: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
/** 변수(입력 정의) — 그룹 밖 정보(면적 · 길이 …) · 함수 상자의 받는 값 */
|
||||
export interface CfVariable {
|
||||
name: string;
|
||||
type: "number" | "choice" | "text";
|
||||
unit: string;
|
||||
choices?: (string | number)[];
|
||||
range?: [number, number];
|
||||
example: number | string | null;
|
||||
/** 함수 상자 안 변수의 칸(「B2」) — 입력 상자 변수는 없음 */
|
||||
cell?: string;
|
||||
}
|
||||
|
||||
export interface CfInput {
|
||||
mode: "group" | "variable";
|
||||
group?: CfGroup;
|
||||
elements: CfElementRef[];
|
||||
/** 소요량 · 계수 = 표(row 가 있으면 그 한 줄) */
|
||||
table?: { key: string; row?: number };
|
||||
variables: CfVariable[];
|
||||
}
|
||||
|
||||
export interface CfFunc {
|
||||
/** 우리 스프레드시트 시트 하나 */
|
||||
sheet: Sheet;
|
||||
/** 받는 알약 → 칸 */
|
||||
inputs: { port: string; cell: string }[];
|
||||
variables: CfVariable[];
|
||||
/** 내보내는 알약 하나 */
|
||||
output: { cell: string; name: string; unit: string };
|
||||
}
|
||||
|
||||
export interface CfOutput {
|
||||
cost: CfCost;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface CfBox {
|
||||
id: string;
|
||||
kind: CfKind;
|
||||
name: string;
|
||||
color: string;
|
||||
/** 오른쪽 위 단위 — 기본 「-」 */
|
||||
unit: string;
|
||||
x: number;
|
||||
y: number;
|
||||
input?: CfInput;
|
||||
func?: CfFunc;
|
||||
output?: CfOutput;
|
||||
}
|
||||
|
||||
/** 알약 한쪽 끝 */
|
||||
export interface CfPort {
|
||||
box: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CfLink {
|
||||
id: string;
|
||||
from: CfPort;
|
||||
to: CfPort;
|
||||
}
|
||||
|
||||
/** 사용자 요소 — 그룹에 없는 새 요소(마스터에 안 씀 · `_data` 에 저장) */
|
||||
export interface CfUserElement {
|
||||
id: string;
|
||||
group: CfGroup;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
export interface CfLogic {
|
||||
key: string | null;
|
||||
name: string;
|
||||
resultUnit: string;
|
||||
boxes: CfBox[];
|
||||
links: CfLink[];
|
||||
userElements: CfUserElement[];
|
||||
}
|
||||
|
||||
/** 알약 하나의 모습 */
|
||||
export interface CfPill {
|
||||
name: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
const notReady = (): never => {
|
||||
throw new Error("준비 중");
|
||||
};
|
||||
|
||||
export function newLogic(_name?: string, _resultUnit?: string): CfLogic {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나 · 출력은 노무비 */
|
||||
export function addBox(
|
||||
_lg: CfLogic,
|
||||
_kind: CfKind,
|
||||
_x: number,
|
||||
_y: number,
|
||||
): { lg: CfLogic; box: CfBox } {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 상자 지우기 — 이어진 선도 지움 */
|
||||
export function removeBox(_lg: CfLogic, _id: string): CfLogic {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
export function moveBox(_lg: CfLogic, _id: string, _x: number, _y: number): CfLogic {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
export function setColor(_lg: CfLogic, _id: string, _color: string): CfLogic {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
export function setUnit(_lg: CfLogic, _id: string, _unit: string): CfLogic {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 내보내는 알약 — 입력 = 요소 · 표 · 변수마다 · 함수 = 출력 하나 · 출력 = 없음 */
|
||||
export function portsOut(_box: CfBox): CfPill[] {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 받는 알약 — 함수 = 입력 알약 · 출력 = 하나 · 입력 = 없음 */
|
||||
export function portsIn(_box: CfBox): CfPill[] {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 선 잇기 — 규칙에 어긋나면 막고 까닭(쉬운 말) */
|
||||
export function link(
|
||||
_lg: CfLogic,
|
||||
_from: CfPort,
|
||||
_to: CfPort,
|
||||
): { lg: CfLogic; why: string | null } {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
export function unlink(_lg: CfLogic, _id: string): CfLogic {
|
||||
return notReady();
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
"""M01 시트 로직 가져오기 시험(35-5 · 정함 9) — Node 로 `M01_SL_Import.ts` 를 바로 돌림.
|
||||
|
||||
· 견본 로직 줄 여섯(정함 9 시험 로직)은 이 시험 안에 둠 — 품셈 표 · 노임 · 자재 · 기존 로직 값은 마스터에서 읽기만
|
||||
· 가져온 시트 로직 → 바깥 값 넣기(파이썬 엔진이 마스터에서) → 우리 스프레드시트 엔진(Node 번들)으로 셈
|
||||
→ 합계 · 비목 = 지금 `/calc`(`M01_LB_Calc.calc` = `/calc` 와 같은 길) · 끝수 차이는 원 미만
|
||||
· 마스터 로직 1,469 개(자체 빼고) 모두 가져와 다 바뀌는 수를 세고 · 바뀐 통합문서를 엔진이 다 풂(#NAME? 없음)
|
||||
· 파일 안 씀 — 마스터는 읽기만 · 넘김은 표준입력
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IMPORT = ROOT / "M01_MasterData" / "SheetLogic" / "M01_SL_Import.ts"
|
||||
PARSE = IMPORT.with_name("M01_SL_Import_Parse.ts")
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
# ── Node 고리 — 허락한 파일만 실제로 읽고 나머지 import 는 빈 것으로 ────────────
|
||||
HOOKS = r"""
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { stripTypeScriptTypes } from "node:module";
|
||||
const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks|M01_SL_Import(_Parse)?|M01_SL_(Item|Model)|spreadsheet_\w+)(\.ts)?$/;
|
||||
export async function resolve(spec, ctx, next) {
|
||||
if (spec.startsWith(".") && ctx.parentURL && !spec.endsWith(".ts")) {
|
||||
const u = new URL(spec + ".ts", ctx.parentURL);
|
||||
if (existsSync(fileURLToPath(u))) return { url: u.href, shortCircuit: true };
|
||||
}
|
||||
return next(spec, ctx);
|
||||
}
|
||||
export async function load(url, ctx, next) {
|
||||
if (!url.endsWith(".ts")) return next(url, ctx);
|
||||
let src = readFileSync(fileURLToPath(url), "utf8");
|
||||
src = src.replace(/import\s+"[^"]+\.css";/g, "");
|
||||
src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\(\s*"[^"]*"\s*\)/g, "({})");
|
||||
src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names, from) => {
|
||||
if (from.startsWith(".") && ALLOW.test(from)) return m;
|
||||
const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type "));
|
||||
return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : "";
|
||||
});
|
||||
return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true };
|
||||
}
|
||||
"""
|
||||
STUB = (
|
||||
"globalThis.__stub = new Proxy({}, { get: (_, k) => k === 'currentLanguageIndex' ? 0 : "
|
||||
"() => undefined });\n"
|
||||
)
|
||||
|
||||
|
||||
def _plain(x: object) -> object:
|
||||
return float(x) if isinstance(x, Decimal) else str(x)
|
||||
|
||||
|
||||
def _node(body: str, payload: object) -> object:
|
||||
"""`I` = Import 모듈 · `A` = 넘긴 것(표준입력) · `out(x)` 로 답"""
|
||||
script = (
|
||||
"import { register } from 'node:module';\n"
|
||||
"import { readFileSync } from 'node:fs';\n"
|
||||
f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n"
|
||||
+ STUB
|
||||
+ f"const I = {{ ...(await import({json.dumps(IMPORT.as_uri())})),"
|
||||
f" ...(await import({json.dumps(PARSE.as_uri())})) }};\n"
|
||||
"const A = JSON.parse(readFileSync(0, 'utf8'));\n"
|
||||
"const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--no-warnings", "--input-type=module", "-e", script],
|
||||
input=json.dumps(payload, ensure_ascii=False, default=_plain),
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=600, cwd=ROOT,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr[-3000:]
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
# ── 견본 줄 여섯(정함 9) + 넣을 값 ────────────────────────────────────────────
|
||||
H = {"원문번호": "", "출처": "자체", "소유": "공용", "끝수": None}
|
||||
SAMPLES = [
|
||||
(
|
||||
{**H, "키": "GF000208", "이름": "암거이음받침 채움재(T=20㎜)", "결과단위": "원/㎡", "입력": [], "중간": [],
|
||||
"호표": [{"종류": "재료", "요소": {"구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER"},
|
||||
"이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비"}],
|
||||
"덧줄": []},
|
||||
{},
|
||||
),
|
||||
(
|
||||
{**H, "키": "GF000219", "이름": "돌쌓기 메쌓기(인력)", "결과단위": "원/㎡",
|
||||
"입력": [{"이름": "뒷길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75]},
|
||||
{"이름": "돌", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"]},
|
||||
{"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]},
|
||||
{"이름": "높이", "단위": "m"}, {"이름": "초과증가율", "단위": "%", "범위": [80, 100]}],
|
||||
"중간": [{"이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))"}],
|
||||
"호표": [{"종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "비목": "노무비",
|
||||
"수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)"},
|
||||
{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "비목": "노무비",
|
||||
"수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)"}],
|
||||
"덧줄": []},
|
||||
{"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기", "높이": 4.5, "초과증가율": 80},
|
||||
),
|
||||
(
|
||||
{**H, "키": "GF000025", "이름": "떼붙임(재배잔디)", "결과단위": "원/100㎡",
|
||||
"입력": [{"이름": "품", "단위": "인/100㎡"}, {"이름": "비탈구배", "고르기": ["해당 없음", "평균 비탈구배 20˚~45˚ 이하"]}],
|
||||
"중간": [],
|
||||
"호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "비목": "노무비",
|
||||
"수량": "품 * (1 + 만약(비탈구배 == '해당 없음', 0, 찾기(QF000099, 구분='비탈구배 해당 시 품 할증').값) / 100)"}],
|
||||
"덧줄": []},
|
||||
{"품": 2.5, "비탈구배": "평균 비탈구배 20˚~45˚ 이하"},
|
||||
),
|
||||
(
|
||||
{**H, "키": "GF000249", "이름": "다짐(플레이트콤펙터 1.5ton)", "결과단위": "원/㎥",
|
||||
"입력": [{"이름": "지역", "고르기": ["전국평균", "전국", "서울", "경기"]}],
|
||||
"중간": [{"이름": "작업량", "식": "로직(GF000114)"}],
|
||||
"호표": [{"종류": "로직", "요소": "로직(GC000272, 기계='1730-0015', 지역=지역)", "이름": "플레이트 콤팩터 1.5ton",
|
||||
"단위": "시간", "수량": "1 / 작업량"}],
|
||||
"덧줄": []},
|
||||
{"지역": "서울"},
|
||||
),
|
||||
(
|
||||
{**H, "키": "GF000133", "이름": "골재운반 덤프트럭 시간당 작업량", "결과단위": "㎥/시간",
|
||||
"입력": [{"이름": "qt", "단위": "㎥"}, {"이름": "f"}, {"이름": "E"}, {"이름": "Cm", "단위": "분"}],
|
||||
"중간": [], "결과": {"식": "반올림(60 * qt * f * E / Cm, 3)"}},
|
||||
{"qt": 6, "f": 1.2, "E": 0.9, "Cm": 25},
|
||||
),
|
||||
(
|
||||
{**H, "키": "GC000051", "이름": "보행자 안전통로 설치 및 해체", "결과단위": "원/m", "입력": [],
|
||||
"중간": [{"이름": "비계공", "식": "찾기(QC000115, 구분='비계공').수량"}],
|
||||
"호표": [{"종류": "인력", "요소": "LB000006", "이름": "비계공", "단위": "인", "수량": "비계공", "비목": "노무비"}],
|
||||
"덧줄": [{"이름": "공구손료 및 경장비", "비목": "경비",
|
||||
"식": "(비계공 * LB000006) * 찾기(QC000116, 구분='공구손료 및 경장비 기계경비(인력품 기준)').값 / 100"}]},
|
||||
{},
|
||||
),
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
# ── 마스터(읽기만) ─────────────────────────────────────────────────────────
|
||||
@pytest.fixture(scope="module")
|
||||
def master():
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
|
||||
cm = store.cm
|
||||
files = cm.load(folder=store.FOLDER)
|
||||
tables = {
|
||||
str(t["키"]): t
|
||||
for data in files.values()
|
||||
if data.get("그룹") in cm.mf.TABLE_GROUPS
|
||||
for t in data.get("표") or []
|
||||
}
|
||||
return cm, files, tables
|
||||
|
||||
|
||||
def _tables_of(row: dict, tables: dict) -> dict:
|
||||
keys = set(re.findall(r"Q[A-Z]\d{6}", json.dumps(row, ensure_ascii=False)))
|
||||
return {k: tables[k] for k in keys if k in tables}
|
||||
|
||||
|
||||
def _names_of(row: dict, master) -> dict:
|
||||
"""다른 로직 · 노임 키 → 이름(화면이 마스터 목록에서 넘기는 것과 같음)"""
|
||||
cm, files, _ = master
|
||||
m = cm.mf.Master(files)
|
||||
keys = set(re.findall(r"(?:G[FCX]|LB|MT)\d{6}", json.dumps(row, ensure_ascii=False)))
|
||||
return {k: str(m.get(k).get("이름") or k) for k in keys}
|
||||
|
||||
|
||||
def _num(v) -> float:
|
||||
"""빈 칸(선이 없는 비목 알약)은 0 · 수가 아니면 nan"""
|
||||
if v is None:
|
||||
return 0.0
|
||||
return float(v["수"]) if isinstance(v, dict) and "수" in v else float("nan")
|
||||
|
||||
|
||||
def _fill(sl: dict, places: list, py_lines: list, master) -> None:
|
||||
"""바깥 값 넣기 — 표는 가져올 때 이미 · 노임 = 마스터 · 자재 = `/calc` 줄 단가 · 장비 · 로직 = 그 로직 `/calc`"""
|
||||
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
|
||||
|
||||
cm, files, _ = master
|
||||
m = cm.mf.Master(files)
|
||||
nodes = {n["id"]: n for n in sl["nodes"]}
|
||||
for at in places:
|
||||
item = at["item"]
|
||||
kind = item["kind"]
|
||||
if kind == "표":
|
||||
continue
|
||||
if kind == "노임":
|
||||
value = cm.mf.element(m, item["key"], {})[0]
|
||||
elif kind == "자재":
|
||||
name = nodes[item["id"].split(":")[0]]["name"]
|
||||
value = next(x["단가"] for x in py_lines if x["이름"] == name)
|
||||
elif kind == "장비": # 서버와 같음 — 요소 글 한 줄(수량 1)을 셈해 그 비목
|
||||
line = {
|
||||
"종류": "로직",
|
||||
"요소": item["element"],
|
||||
"이름": "장비",
|
||||
"단위": "시간",
|
||||
"수량": "1",
|
||||
}
|
||||
inputs = item["inputs"]
|
||||
one = {"키": "", "원문번호": "", "이름": "장비", "결과단위": "원/시간", "출처": "",
|
||||
"입력": [{"이름": k} for k in inputs], "중간": [], "호표": [line], "덧줄": []} # fmt: skip
|
||||
value = lb.calc(one, inputs, {})["lines"][0]["비목"].get(item["field"], 0)
|
||||
else:
|
||||
got = cm.mf.run(m, item["key"], {k: Decimal(str(v)) if isinstance(v, (int, float)) else v
|
||||
for k, v in item["inputs"].items()}) # fmt: skip
|
||||
value = got["결과"] if "결과" in got else got[item.get("field") or "계"]
|
||||
if value is None: # 마스터에 값이 없음 — 바깥 값 칸을 비움(`/calc` 도 그 줄을 비우고 0)
|
||||
continue
|
||||
sheet = next(s for s in sl["workbook"]["시트"] if s["id"] == at["sheet"])
|
||||
sheet["칸"][at["cell"]] = {"값": float(value)}
|
||||
|
||||
|
||||
def _result_cells(sl: dict) -> dict:
|
||||
node = next(n for n in sl["nodes"] if n["kind"] == "result")
|
||||
# 결과 틀 — 노무비 · 재료비 · 경비 = 입력 알약 · 계(또는 결과) = 출력 알약
|
||||
return {p["name"]: (node["sheetId"], p["cell"]) for p in node["pillsIn"] + node["pillsOut"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row,given", SAMPLES, ids=[r["키"] for r, _ in SAMPLES])
|
||||
def test_가져온_시트로직_값이_calc_와_같음(row: dict, given: dict, master) -> None:
|
||||
from common_util.common_util_spreadsheet import recalc_workbooks
|
||||
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
|
||||
|
||||
rep = _node(
|
||||
"const r = I.importLogicReport(A.row, { tables: A.tables, examples: A.given, names: A.names });"
|
||||
"out({ ...r, places: I.outsideOf(r.sl) });",
|
||||
{
|
||||
"row": row,
|
||||
"tables": _tables_of(row, master[2]),
|
||||
"given": given,
|
||||
"names": _names_of(row, master),
|
||||
},
|
||||
)
|
||||
assert rep["problems"] == []
|
||||
sl = rep["sl"]
|
||||
# 모양 — 상자 = 시트 · 선은 있는 상자 · 알약끼리
|
||||
sheets = {s["id"] for s in sl["workbook"]["시트"]}
|
||||
assert len(sheets) == len(sl["nodes"]) and all(n["sheetId"] in sheets for n in sl["nodes"])
|
||||
ids = {n["id"]: n for n in sl["nodes"]}
|
||||
for n in sl["nodes"]: # 표 상자 = 「표」 바깥 값 출력 알약(E1 · Table 상자와 같은 꼴)
|
||||
if n["kind"] == "table":
|
||||
assert {"name": "표", "cell": "E1", "outside": True} in n["pillsOut"]
|
||||
# 상자 이름에 번호(키) 없음 · 항목 상자 = Item 틀(ItemRef · B1 수량 · B6 금액)
|
||||
assert not [n["name"] for n in sl["nodes"] if re.fullmatch(r"[A-Z]{2}\d{6}", n["name"])]
|
||||
for n in sl["nodes"]:
|
||||
if n["kind"] == "item" and n.get("ref", "").startswith("{"):
|
||||
ref = json.loads(n["ref"])
|
||||
assert ref["what"] in ("노무", "재료", "장비", "일위대가", "비율")
|
||||
pills = {p["name"]: p["cell"] for p in n["pillsOut"]}
|
||||
assert pills["금액"] == ("B4" if ref["what"] == "비율" else "B6")
|
||||
for e in sl["edges"]:
|
||||
assert any(p["name"] == e["from"]["pill"] for p in ids[e["from"]["node"]]["pillsOut"])
|
||||
assert any(p["name"] == e["to"]["pill"] for p in ids[e["to"]["node"]]["pillsIn"])
|
||||
|
||||
want = lb.calc(row, given, {})
|
||||
assert want["ok"], want
|
||||
# 1차 셈 → 넘길 값(장비 · 로직 인자) 읽기 → 바깥 값 넣기 → 2차 셈
|
||||
first = recalc_workbooks([sl["workbook"]])[0]
|
||||
places = _node("out(I.outsideOf(A.sl, A.calc));", {"sl": sl, "calc": first})
|
||||
_fill(sl, places, want.get("lines", []), master)
|
||||
got = recalc_workbooks([sl["workbook"]])[0]
|
||||
cells = _result_cells(sl)
|
||||
read = {k: _num(got[s].get(c)) for k, (s, c) in cells.items()}
|
||||
print(f"\n{row['키']} 시트 {read} · /calc {want.get('result', want.get('sums'))}")
|
||||
if "result" in want:
|
||||
assert abs(read["결과"] - float(want["result"])) < 1e-9, (read, want["result"])
|
||||
return
|
||||
for k in ("노무비", "재료비", "경비", "계"):
|
||||
diff = abs(read[k] - float(want["sums"][k]))
|
||||
assert diff < 1, f"{row['키']} {k}: 시트 {read[k]} · /calc {want['sums'][k]}"
|
||||
|
||||
|
||||
def test_마스터_1469_가져오기_세기(master) -> None:
|
||||
from common_util.common_util_spreadsheet import recalc_workbooks
|
||||
|
||||
cm, files, tables = master
|
||||
rows = [
|
||||
r
|
||||
for name, data in files.items()
|
||||
if data.get("그룹") == "로직" and name != "로직_자체.json"
|
||||
for r in data.get("줄") or []
|
||||
]
|
||||
assert len(rows) == 1469
|
||||
got = _node(
|
||||
"const res = A.rows.map((row) => { try { const r = I.importLogicReport(row, { tables: A.tables });"
|
||||
"return { key: row.키, problems: r.problems, book: r.problems.length ? null : r.sl.workbook }; }"
|
||||
"catch (e) { return { key: row.키, problems: ['던짐 ' + e.message], book: null }; } });"
|
||||
"out(res);",
|
||||
{"rows": rows, "tables": tables},
|
||||
)
|
||||
thrown = [g["key"] for g in got if any(p.startswith("던짐") for p in g["problems"])]
|
||||
assert thrown == []
|
||||
full = [g for g in got if not g["problems"]]
|
||||
# 다 바뀐 것은 엔진이 다 풂 — 모르는 함수 · 이름(#NAME?) 없음
|
||||
values = recalc_workbooks([g["book"] for g in full])
|
||||
named = [
|
||||
g["key"] for g, v in zip(full, values) if "#NAME?" in json.dumps(v, ensure_ascii=False)
|
||||
]
|
||||
assert named == []
|
||||
reasons: dict[str, int] = {}
|
||||
for g in got:
|
||||
for p in g["problems"]:
|
||||
why = re.sub(r"「[^」]*」", "「…」", p.split(" — ", 1)[-1])
|
||||
reasons[why] = reasons.get(why, 0) + 1
|
||||
print(
|
||||
f"\n다 바뀜 {len(full)} / {len(rows)} · 못 바꾼 까닭 {sorted(reasons.items(), key=lambda x: -x[1])}"
|
||||
)
|
||||
assert len(full) > 1000
|
||||
|
||||
|
||||
def test_표_블록_칸_차례_정함() -> None:
|
||||
"""조건 칸 → 값 칸 · 범위(조건 「범위」 · 값 칸에 범위가 한 줄이라도)는 「이름·아래」 「이름·위」 · 숫자 하나면 두 칸에 같은 수"""
|
||||
table = {
|
||||
"조건": {"높이": "범위", "구분": "고르기"},
|
||||
"값칸": {"증가율": "%", "단위": "글"},
|
||||
"줄": [
|
||||
{"높이": [3, 4], "구분": "가", "증가율": 30, "단위": "%"},
|
||||
{"높이": 5, "구분": "나", "증가율": [80, 100]},
|
||||
{"높이": [7.5, None], "증가율": 50},
|
||||
],
|
||||
}
|
||||
rows = _node("out(I.tableRows(A));", table)
|
||||
assert rows == [
|
||||
["높이·아래", "높이·위", "구분", "증가율·아래", "증가율·위", "단위"],
|
||||
[3, 4, "가", 30, 30, "%"],
|
||||
[5, 5, "나", 80, 100, None],
|
||||
[7.5, None, None, 50, 50, None],
|
||||
]
|
||||
@@ -71,7 +71,7 @@ let sl = M.newLogic("시험 하나", "원/㎡");
|
||||
let r = M.addNode(sl, "input", "품"); sl = r.sl; const pum = r.node;
|
||||
r = M.addNode(sl, "item", "보통 인부"); sl = r.sl; const man = r.node; man.ref = "LB000002";
|
||||
r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node;
|
||||
sl = E.applyOutside(sl, [{ node: pum.id, cell: "B2", value: 0.5 }, { node: man.id, cell: "B3", value: 100000 }]);
|
||||
sl = E.applyOutside(sl, [{ node: pum.id, cell: "B2", value: 0.5 }, { node: man.id, cell: "B2", value: 100000 }]);
|
||||
sl = link(sl, pum, "품", man, "수량");
|
||||
sl = link(sl, man, "노무비", res, "노무비");
|
||||
"""
|
||||
@@ -87,7 +87,7 @@ r = M.addNode(sl, "item", "보통인부"); sl = r.sl; const man = r.node;
|
||||
r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node;
|
||||
sl = E.applyOutside(sl, [
|
||||
{ node: pum.id, cell: "B2", value: 0.2 }, { node: rate.id, cell: "B2", value: 1.3 },
|
||||
{ node: stone.id, cell: "B3", value: 200000 }, { node: man.id, cell: "B3", value: 100000 },
|
||||
{ node: stone.id, cell: "B2", value: 200000 }, { node: man.id, cell: "B2", value: 100000 },
|
||||
]);
|
||||
sl = link(sl, pum, "품", calc, "값1");
|
||||
sl = link(sl, rate, "할증률", calc, "값2");
|
||||
@@ -101,12 +101,12 @@ sl = link(sl, man, "노무비", res, "노무비");
|
||||
def test_입력값_항목_결과_수식과_값() -> None:
|
||||
got = _run(
|
||||
ONE
|
||||
+ "out([sheet(sl, man.id).칸.B2, sheet(sl, res.id).칸.B2, E.evalLogic(sl), sl.edges, sl.nodes.map(n => [n.kind, n.stage]),"
|
||||
+ "out([sheet(sl, man.id).칸.B1, sheet(sl, res.id).칸.B2, E.evalLogic(sl), sl.edges, sl.nodes.map(n => [n.kind, n.stage]),"
|
||||
" sl.workbook.시트.map(s => s.이름)]);"
|
||||
)
|
||||
qty, total_in, ev, edges, kinds, names = got
|
||||
assert qty == {"식": "품!B2"}
|
||||
assert total_in == {"식": "'보통 인부'!B5"}
|
||||
assert total_in == {"식": "'보통 인부'!B7"}
|
||||
assert ev["result"] == {"노무비": 50000, "재료비": None, "경비": None, "계": 50000}
|
||||
assert ev["pills"]["n2"]["금액"] == 50000 and ev["pillsIn"]["n2"]["수량"] == 0.5
|
||||
assert ev["errors"] == []
|
||||
@@ -132,7 +132,7 @@ def test_계산_두_항목_더함_값바뀜() -> None:
|
||||
# 석공 0.2×1.3×200000 = 52000 · 보통인부 0.2×100000 = 20000
|
||||
assert a == {"노무비": 72000, "재료비": None, "경비": None, "계": 72000}
|
||||
assert c == {"결과": 0.26}
|
||||
assert cell == {"식": "석공!B5+보통인부!B5"}
|
||||
assert cell == {"식": "석공!B7+보통인부!B7"}
|
||||
assert b["계"] == 0.2 * 1.5 * 200000 + 20000
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ def test_선_끊기_다시_잇기() -> None:
|
||||
" E.evalLogic(c).result, a.edges.length]);"
|
||||
)
|
||||
cell_a, ra, cell_b, check_b, rc, n_edges = got
|
||||
assert cell_a == {"식": "보통인부!B5"} and ra["계"] == 20000
|
||||
assert cell_a == {"식": "보통인부!B7"} and ra["계"] == 20000
|
||||
assert cell_b is None
|
||||
assert "결과 상자에 이어진 선이 없음" in check_b
|
||||
assert not any("입력 알약" in x for x in check_b)
|
||||
@@ -202,13 +202,27 @@ def test_바깥값_칸목록과_표_덩어리() -> None:
|
||||
)
|
||||
cells, e1, f2, e3, f3, lg, check = got
|
||||
assert cells == [
|
||||
{"node": "n2", "cell": "B3", "kind": "price", "ref": "LB000002"},
|
||||
{"node": "n4", "cell": "E1", "kind": "table", "ref": "QF000421"},
|
||||
{"node": "n5", "cell": "B2", "kind": "logic", "ref": "GF000114"},
|
||||
{"id": "n2!B2", "node": "n2", "cell": "B2", "kind": "price", "ref": "LB000002"},
|
||||
{
|
||||
"id": "n4!E1",
|
||||
"node": "n4",
|
||||
"cell": "E1",
|
||||
"kind": "table",
|
||||
"ref": "QF000421",
|
||||
"source": {"kind": "표", "table": "QF000421"},
|
||||
},
|
||||
{
|
||||
"id": "n5!B2",
|
||||
"node": "n5",
|
||||
"cell": "B2",
|
||||
"kind": "logic",
|
||||
"ref": "GF000114",
|
||||
"source": {"kind": "로직", "key": "GF000114", "inputs": {}},
|
||||
},
|
||||
]
|
||||
assert (e1, f2, e3, f3) == ({"값": "뒷길이"}, {"값": 0.15}, {"값": 30}, None)
|
||||
assert lg == {"결과": 12.5}
|
||||
assert "「메쌓기 표」의 입력 알약 「조건」이 비어 있음" in check
|
||||
assert check == []
|
||||
|
||||
|
||||
def test_알약_정하기_이름바꾸면_선이_따라감() -> None:
|
||||
@@ -216,16 +230,16 @@ def test_알약_정하기_이름바꾸면_선이_따라감() -> None:
|
||||
ONE + "let s2 = M.setPill(sl, pum.id, 'B2', 'out', '품셈 품');\n"
|
||||
"const e = s2.edges[0].from;\n"
|
||||
"s2 = M.setPill(s2, pum.id, 'D2', 'out', '품셈 품');\n"
|
||||
"const moved = sheet(s2, man.id).칸.B2;\n"
|
||||
"const moved = sheet(s2, man.id).칸.B1;\n"
|
||||
"const s3 = M.removePill(s2, pum.id, 'out', '품셈 품');\n"
|
||||
"const s4 = M.setPill(sl, man.id, 'C9', 'in', '할증');\n"
|
||||
"out([e, moved, s3.edges.length, sheet(s3, man.id).칸.B2 ?? null, s4.nodes[1].pillsIn.map(p => [p.name, p.cell])]);"
|
||||
"out([e, moved, s3.edges.length, sheet(s3, man.id).칸.B1 ?? null, s4.nodes[1].pillsIn.map(p => [p.name, p.cell])]);"
|
||||
)
|
||||
e, moved, n_edges, cleared, ins = got
|
||||
assert e == {"node": "n1", "pill": "품셈 품"}
|
||||
assert moved == {"식": "품!D2"}
|
||||
assert n_edges == 1 and cleared is None
|
||||
assert ins == [["수량", "B2"], ["단가", "B3"], ["할증", "C9"]]
|
||||
assert ins == [["수량", "B1"], ["노무 단가", "B2"], ["할증", "C9"]]
|
||||
|
||||
|
||||
def test_상자_지우기() -> None:
|
||||
@@ -236,7 +250,7 @@ def test_상자_지우기() -> None:
|
||||
)
|
||||
names, sheets, n_edges, cell, total, before = got
|
||||
assert names == ["품", "할증률", "할증 품", "보통인부", "결과"] and sheets == names
|
||||
assert n_edges == 4 and cell == {"식": "보통인부!B5"} and total == 20000
|
||||
assert n_edges == 4 and cell == {"식": "보통인부!B7"} and total == 20000
|
||||
assert before == 6 # 받은 것은 안 바뀜
|
||||
|
||||
|
||||
@@ -261,3 +275,139 @@ def test_화면_글에_금지_낱말_없음() -> None:
|
||||
for text in korean:
|
||||
for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"):
|
||||
assert bad not in text, (name, bad, text)
|
||||
|
||||
|
||||
def test_상자_이름_바꾸기_수식이_따라감() -> None:
|
||||
got = _run(
|
||||
TWO + "const a = M.renameNode(sl, stone.id, '석공 (돌쌓기)');\n"
|
||||
"const tries = [M.renameNode(sl, stone.id, '보통인부').why, M.renameNode(sl, stone.id, '').why,"
|
||||
" M.renameNode(sl, stone.id, '가:나').why, M.renameNode(sl, 'n99', '무엇').why,"
|
||||
" M.renameNode(sl, stone.id, 'x'.repeat(32)).why];\n"
|
||||
"out([a.why, a.sl.nodes.find(n => n.id === stone.id).name, sheet(a.sl, stone.id).이름,"
|
||||
" sheet(a.sl, res.id).칸.B2, E.evalLogic(a.sl).result.계, tries, sl.nodes[3].name]);"
|
||||
)
|
||||
why, name, sheet_name, cell, total, tries, before = got
|
||||
assert why is None and name == sheet_name == "석공 (돌쌓기)"
|
||||
assert cell == {"식": "'석공 (돌쌓기)'!B7+보통인부!B7"} and total == 72000
|
||||
assert tries == [
|
||||
"같은 이름 상자가 있음",
|
||||
"이름은 1~31 글자",
|
||||
"이름에 못 쓰는 글자 — [ ] : * ? / \\ 와 앞뒤 '",
|
||||
"상자가 없음",
|
||||
"이름은 1~31 글자",
|
||||
]
|
||||
assert before == "석공"
|
||||
|
||||
|
||||
def test_상자_이름_못쓰는_글자는_고쳐_만듦() -> None:
|
||||
got = _run(
|
||||
"let sl = M.newLogic(); let r = M.addNode(sl, 'input', '가:나/다'); sl = r.sl;"
|
||||
" r = M.addNode(sl, 'input', 'x'.repeat(40)); sl = r.sl; r = M.addNode(sl, 'input', 'X'.repeat(40)); sl = r.sl;"
|
||||
" out(sl.workbook.시트.map(s => s.이름));"
|
||||
)
|
||||
assert got == ["가·나·다", "x" * 31, "X" * 27 + " (2)"]
|
||||
|
||||
|
||||
def test_바깥값_답을_넣을값으로_범위머리() -> None:
|
||||
got = _run(
|
||||
ONE + "r = M.addNode(sl, 'table', '증가율 표'); sl = r.sl; const tb = r.node;\n"
|
||||
"sl.nodes.find(n => n.id === tb.id).source = { kind: '표', table: 'QF000422' };\n"
|
||||
"sl = M.setPill(sl, tb.id, 'E1', 'out', '표', { outside: true });\n"
|
||||
"const cells = E.outsideCells(sl);\n"
|
||||
"const head = M.rangeHead('높이');\n"
|
||||
"const got = E.answersToValues(cells, [\n"
|
||||
" { id: 'n2!B2', ok: true, value: 172068 },\n"
|
||||
" { id: 'n4!E1', ok: true, rows: [[...head, '증가율'], [3, 4, 30]] },\n"
|
||||
"]);\n"
|
||||
"const s2 = E.applyOutside(sl, got.values);\n"
|
||||
"out([head, got, sheet(s2, tb.id).칸.F1, sheet(s2, tb.id).칸.G2, E.evalLogic(s2).result.계]);"
|
||||
)
|
||||
head, got_values, f1, g2, total = got
|
||||
assert head == ["높이·아래", "높이·위"]
|
||||
assert got_values["errors"] == []
|
||||
assert got_values["values"][0] == {"node": "n2", "cell": "B2", "value": 172068}
|
||||
assert (f1, g2, total) == ({"값": "높이·위"}, {"값": 30}, 86034)
|
||||
missing = _run(
|
||||
ONE
|
||||
+ "out(E.answersToValues(E.outsideCells(sl), [{ id: 'n2!B2', ok: false, reason: '없는 직종' }]));"
|
||||
)
|
||||
assert missing == {"values": [], "errors": ["B2 칸 바깥 값을 못 받음 없는 직종"]}
|
||||
|
||||
|
||||
def test_항목_틀은_Item_노무_틀과_같은_칸() -> None:
|
||||
got = _run(
|
||||
"let sl = M.newLogic(); const r = M.addNode(sl, 'item', '석공');"
|
||||
" out([sheet(r.sl, r.node.id).칸, r.node.pillsIn, r.node.pillsOut.map(p => [p.name, p.cell]), r.node.ref]);"
|
||||
)
|
||||
cells, ins, outs, ref = got
|
||||
assert {k: v for k, v in cells.items() if k.startswith("B")} == {
|
||||
"B3": {"값": 0},
|
||||
"B4": {"값": 0},
|
||||
"B5": {"식": "B2+B3+B4"},
|
||||
"B6": {"식": "ROUND(B1*B5,1)"},
|
||||
"B7": {"식": "ROUND(B1*B2,1)"},
|
||||
"B8": {"식": "ROUND(B1*B3,1)"},
|
||||
"B9": {"식": "ROUND(B1*B4,1)"},
|
||||
"B10": {"식": "B1"},
|
||||
}
|
||||
assert [cells[f"A{i}"]["값"] for i in range(1, 11)] == [
|
||||
"수량", "노무 단가", "재료 단가", "경비 단가", "단가", "금액", "노무비", "재료비", "경비", "사용 수량",
|
||||
] # fmt: skip
|
||||
assert ins == [
|
||||
{"name": "수량", "cell": "B1", "unit": ""},
|
||||
{"name": "노무 단가", "cell": "B2", "unit": "원", "outside": True},
|
||||
]
|
||||
assert outs == [
|
||||
["단가", "B5"], ["금액", "B6"], ["노무비", "B7"], ["재료비", "B8"], ["경비", "B9"], ["사용 수량", "B10"],
|
||||
] # fmt: skip
|
||||
assert ref == '{"what":"노무"}'
|
||||
|
||||
|
||||
# 표 상자 이음매 — Table_Build(sub3) 가 짠 품셈 표 상자에 서버 꼴 블록(머리 줄 + 줄 · A1 부터)을 넣고 찾기
|
||||
TABLE = r"""
|
||||
const T = loadTs(path.join(ROOT, "M01_MasterData", "SheetLogic", "M01_SL_Table_Build.ts"));
|
||||
let sl = M.newLogic("표 시험");
|
||||
let r = M.addNode(sl, "table", "메쌓기"); sl = r.sl; const tb = r.node;
|
||||
const info = { key: "QF000421", name: "메쌓기", base: "1㎡", source: "", no: "13-4-1",
|
||||
conds: { 뒷길이: "수", 돌: "고르기" }, vals: { 석공: "인" }, rule: null, notes: [],
|
||||
rows: [{ 뒷길이: 25, 돌: "깬잡석", 석공: 0.15 }, { 뒷길이: 30, 돌: "깬돌", 석공: 0.26 },
|
||||
{ 뒷길이: 35, 돌: "깬돌", 석공: 0.3 }] };
|
||||
sl = T.applyTable(sl, tb.id, info, { cond: ["뒷길이", "돌"], out: ["석공"] },
|
||||
{ ref: "QF000421", fillBlock: false, outside: true }).sl;
|
||||
r = M.addNode(sl, "input", "뒷길이"); sl = r.sl; const len = r.node;
|
||||
r = M.addNode(sl, "input", "돌"); sl = r.sl; const stone = r.node;
|
||||
sl = E.applyOutside(sl, [{ node: len.id, cell: "B2", value: 35 }, { node: stone.id, cell: "B2", value: "깬돌" }]);
|
||||
sl = link(sl, len, "뒷길이", sl.nodes.find(n => n.id === tb.id), "뒷길이");
|
||||
sl = link(sl, stone, "돌", sl.nodes.find(n => n.id === tb.id), "돌");
|
||||
const ROWS = [["뒷길이", "돌", "석공"], [25, "깬잡석", 0.15], [30, "깬돌", 0.26], [35, "깬돌", 0.3]];
|
||||
const cells = E.outsideCells(sl);
|
||||
"""
|
||||
|
||||
|
||||
def test_표_상자_바깥값_블록_찾기_줄면_옛줄_지움_머리바뀜_알림() -> None:
|
||||
got = _run(
|
||||
TABLE
|
||||
+ "const first = E.answersToValues(cells, [{ id: cells[0].id, ok: true, rows: ROWS }], sl);\n"
|
||||
"const s1 = E.applyOutside(sl, first.values);\n"
|
||||
"const shrink = E.answersToValues(cells, [{ id: cells[0].id, ok: true, rows: ROWS.slice(0, 3) }], s1);\n"
|
||||
"const s2 = E.applyOutside(s1, shrink.values);\n"
|
||||
"const moved = E.answersToValues(cells, [{ id: cells[0].id, ok: true,"
|
||||
" rows: [['돌', '뒷길이', '석공'], ['깬돌', 30, 0.26]] }], s1);\n"
|
||||
"out([cells, first.errors, E.evalLogic(s1).pills[tb.id], shrink.errors, sheet(s2, tb.id).칸.A4 ?? null,"
|
||||
" sheet(s2, tb.id).칸.C4 ?? null, E.evalLogic(s2).pills[tb.id], moved.errors]);"
|
||||
)
|
||||
cells, first_err, found, shrink_err, a4, c4, gone, moved_err = got
|
||||
assert cells == [
|
||||
{
|
||||
"id": "n1!A1",
|
||||
"node": "n1",
|
||||
"cell": "A1",
|
||||
"kind": "table",
|
||||
"ref": "QF000421",
|
||||
"source": {"kind": "표", "table": "QF000421"},
|
||||
}
|
||||
]
|
||||
assert first_err == [] and found["석공"] == 0.3 and found["표"] == "뒷길이"
|
||||
assert shrink_err == [] and a4 is None and c4 is None
|
||||
assert gone["석공"] == "#N/A"
|
||||
assert moved_err == ["「표 · 메쌓기」 표 머리 줄이 바뀜 — 표를 다시 골라야 함"]
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
@@ -23,6 +25,7 @@ from M01_MasterData import M01_MasterData_Router as router_module
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData.SheetLogic import M01_SL_Router as sl
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
REAL = store.FOLDER
|
||||
OWN = "로직_자체.json"
|
||||
SAMPLE = "QF999997"
|
||||
@@ -36,10 +39,10 @@ TABLE = {
|
||||
"출처": "자체",
|
||||
"조건": {"돌": "고르기", "높이": "범위"},
|
||||
"범위규칙": "초과·이하",
|
||||
"값칸": {"석공": "인"},
|
||||
"값칸": {"석공": "인", "증가율": "%"},
|
||||
"줄": [
|
||||
{"돌": "깬돌", "높이": [None, 3], "석공": 0.5},
|
||||
{"돌": "깬돌", "높이": [3, None], "석공": 0.7},
|
||||
{"돌": "깬돌", "높이": [None, 3], "석공": 0.5, "증가율": [80, 100]},
|
||||
{"돌": "견치돌", "높이": 4, "석공": 0.7},
|
||||
],
|
||||
"주": [],
|
||||
}
|
||||
@@ -89,26 +92,59 @@ def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict:
|
||||
|
||||
|
||||
def _logic(labor: str, key: str | None = None) -> dict:
|
||||
"""노임 바깥 값(A1) × 2 = B1 · 표 바깥 값은 둘째 시트 A1 부터 · 브라우저 계산값은 엉터리."""
|
||||
"""Model SheetLogic — 노무 상자(source 노임 · A1) × 2 = B1 · 표 상자(ref 로 짐작 · A1 부터)
|
||||
· 출처 없는 바깥 알약은 뺌 · 몸의 outside 목록(Item 꼴)은 C2 에 · 브라우저 계산값은 엉터리."""
|
||||
return {
|
||||
"key": key,
|
||||
"이름": "견본 시트 로직",
|
||||
"결과단위": "원/㎡",
|
||||
"book": {
|
||||
"name": "견본 시트 로직",
|
||||
"resultUnit": "원/㎡",
|
||||
"workbook": {
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"서식": [{}],
|
||||
"시트": [
|
||||
{"id": "s1", "이름": "계산", "칸": {"A1": {"값": 1}, "B1": {"식": "A1*2"}}},
|
||||
{"id": "s1", "이름": "노무", "칸": {"A1": {"값": 1}, "B1": {"식": "A1*2"}}},
|
||||
{"id": "s2", "이름": "표", "칸": {}},
|
||||
],
|
||||
"계산값": {"s1": {"B1": {"수": "999"}}},
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "n1",
|
||||
"kind": "item",
|
||||
"name": "노무 석공",
|
||||
"sheetId": "s1",
|
||||
"stage": 3,
|
||||
"pillsIn": [{"name": "노임", "cell": "A1", "outside": True}],
|
||||
"pillsOut": [{"name": "금액", "cell": "B1"}],
|
||||
"source": {"kind": "노임", "key": labor},
|
||||
},
|
||||
{
|
||||
"id": "n2",
|
||||
"kind": "table",
|
||||
"name": "견본 표",
|
||||
"sheetId": "s2",
|
||||
"stage": 1,
|
||||
"pillsIn": [],
|
||||
"pillsOut": [{"name": "표", "cell": "A1", "outside": True}],
|
||||
"ref": SAMPLE,
|
||||
},
|
||||
{
|
||||
"id": "n3",
|
||||
"kind": "calc",
|
||||
"name": "계산",
|
||||
"sheetId": "s1",
|
||||
"stage": 2,
|
||||
"pillsIn": [],
|
||||
"pillsOut": [{"name": "x", "cell": "C1", "outside": True}],
|
||||
},
|
||||
], # fmt: skip
|
||||
"edges": [
|
||||
{"id": "e1", "from": {"node": "n2", "pill": "표"}, "to": {"node": "n1", "pill": "노임"}}
|
||||
],
|
||||
"outside": [
|
||||
{"item": {"id": "a", "kind": "노임", "key": labor}, "sheet": "s1", "cell": "A1"},
|
||||
{"item": {"id": "b", "kind": "표", "table": SAMPLE}, "sheet": "s2", "cell": "A1"},
|
||||
{"item": {"id": "n1:노무비", "kind": "노임", "key": labor}, "sheet": "s1", "cell": "C2"}
|
||||
],
|
||||
"상자": [{"id": "x", "종류": "계산"}], # 다른 창 칸 — 그대로 저장 · 돌려줌
|
||||
}
|
||||
|
||||
|
||||
@@ -119,25 +155,32 @@ def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient,
|
||||
|
||||
saved = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)})
|
||||
assert saved["key"] == "SL-0001"
|
||||
book = saved["logic"]["book"]
|
||||
assert book["시트"][0]["칸"]["A1"]["값"] == price # 바깥 값을 마스터에서 다시 넣음
|
||||
book = saved["logic"]["workbook"]
|
||||
cells = book["시트"][0]["칸"]
|
||||
assert cells["A1"]["값"] == price == cells["C2"]["값"] # 바깥 값을 마스터에서 다시 넣음(①②)
|
||||
assert Decimal(book["계산값"]["s1"]["B1"]["수"]) == 2 * price # 서버 다시 셈(999 는 버림)
|
||||
assert "C1" not in cells # 출처 없는 바깥 알약은 안 넣음
|
||||
table = book["시트"][1]["칸"]
|
||||
assert [table[c]["값"] for c in ("A1", "B1", "C1", "D1")] == [
|
||||
"돌",
|
||||
"높이·아래",
|
||||
"높이·위",
|
||||
"석공",
|
||||
]
|
||||
head = [table[c]["값"] for c in ("A1", "B1", "C1", "D1", "E1", "F1")]
|
||||
assert head == ["돌", "높이·아래", "높이·위", "석공", "증가율·아래", "증가율·위"] # A1 부터
|
||||
assert (
|
||||
"B2" not in table and table["C2"]["값"] == 3 and table["D3"]["값"] == 0.7
|
||||
"B2" not in table and table["C2"]["값"] == 3 and table["F2"]["값"] == 100
|
||||
) # 빈 끝은 빈 칸
|
||||
assert saved["logic"]["상자"] == [{"id": "x", "종류": "계산"}]
|
||||
assert [v["id"] for v in saved["values"]] == ["a", "b"]
|
||||
assert table["B3"]["값"] == table["C3"]["값"] == 4 and "E3" not in table # 수 하나 → 같은 수
|
||||
assert saved["logic"]["edges"] == _logic(labor)["edges"]
|
||||
answers = {v["id"]: v for v in saved["values"]}
|
||||
assert set(answers) == {"n1!A1", "n2!A1", "n1:노무비"} and all(
|
||||
v["ok"] for v in answers.values()
|
||||
)
|
||||
|
||||
second = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)})
|
||||
# 블록 왼위 = 표 상자 「표」 바깥 알약 칸(고정 A1 아님 — Import 는 E1)
|
||||
at_e1 = _logic(labor)
|
||||
at_e1["nodes"][1]["pillsOut"][0]["cell"] = "E1"
|
||||
second = _post(client, "/api/m01/sl/save", {"logic": at_e1})
|
||||
assert second["key"] == "SL-0002"
|
||||
renamed = {**saved["logic"], "이름": "고친 이름"}
|
||||
moved = second["logic"]["workbook"]["시트"][1]["칸"]
|
||||
assert moved["E1"]["값"] == "돌" and moved["J2"]["값"] == 100 and "A1" not in moved
|
||||
renamed = {**saved["logic"], "name": "고친 이름"}
|
||||
assert _post(client, "/api/m01/sl/save", {"logic": renamed})["key"] == "SL-0001"
|
||||
|
||||
listed = client.get("/api/m01/sl/list").json()["logics"]
|
||||
@@ -146,13 +189,13 @@ def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient,
|
||||
("SL-0002", "견본 시트 로직"),
|
||||
]
|
||||
got = client.get("/api/m01/sl/get", params={"key": "SL-0001"}).json()["logic"]
|
||||
assert got["book"]["계산값"] == book["계산값"]
|
||||
assert got["workbook"]["계산값"] == book["계산값"]
|
||||
|
||||
_post(client, "/api/m01/sl/delete", {"key": "SL-0002"})
|
||||
assert client.get("/api/m01/sl/get", params={"key": "SL-0002"}).status_code == 404
|
||||
_post(client, "/api/m01/sl/delete", {"key": "SL-0002"}, want=404)
|
||||
_post(client, "/api/m01/sl/save", {"logic": _logic(labor, "SL-0009")}, want=404)
|
||||
_post(client, "/api/m01/sl/save", {"logic": {**_logic(labor), "이름": " "}}, want=400)
|
||||
_post(client, "/api/m01/sl/save", {"logic": {**_logic(labor), "name": " "}}, want=400)
|
||||
assert client.get("/api/m01/sl/get", params={"key": "../x"}).status_code == 400
|
||||
|
||||
assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터 사본 그대로
|
||||
@@ -161,32 +204,18 @@ def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient,
|
||||
|
||||
def test_바깥_값_표_노임_로직_틀린_종류(client: TestClient) -> None:
|
||||
labor, price = _labor()
|
||||
made = _post(
|
||||
client,
|
||||
"/api/m01/logic/new",
|
||||
{
|
||||
"logic": {
|
||||
"원문번호": "",
|
||||
"이름": "견본 노무",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "자체",
|
||||
"입력": [{"이름": "품"}],
|
||||
"중간": [],
|
||||
"호표": [
|
||||
{
|
||||
"종류": "인력",
|
||||
"요소": labor,
|
||||
"이름": "석공",
|
||||
"단위": "인",
|
||||
"수량": "품",
|
||||
"비목": "노무비",
|
||||
}
|
||||
],
|
||||
"덧줄": [],
|
||||
"끝수": None,
|
||||
}
|
||||
},
|
||||
)
|
||||
line = {
|
||||
"종류": "인력",
|
||||
"요소": labor,
|
||||
"이름": "석공",
|
||||
"단위": "인",
|
||||
"수량": "품",
|
||||
"비목": "노무비",
|
||||
}
|
||||
row = {"원문번호": "", "이름": "견본 노무", "결과단위": "원/㎡", "출처": "자체",
|
||||
"입력": [{"이름": "품"}], "중간": [], "호표": [line], "덧줄": [],
|
||||
"끝수": None} # fmt: skip
|
||||
made = _post(client, "/api/m01/logic/new", {"logic": row})
|
||||
items = [
|
||||
{"id": "t", "kind": "표", "table": SAMPLE},
|
||||
{"id": "l", "kind": "노임", "key": labor},
|
||||
@@ -197,12 +226,17 @@ def test_바깥_값_표_노임_로직_틀린_종류(client: TestClient) -> None:
|
||||
{"id": "z", "kind": "모름"},
|
||||
]
|
||||
got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]}
|
||||
assert got["t"]["rows"][0] == ["돌", "높이·아래", "높이·위", "석공"]
|
||||
assert got["t"]["rows"][1:] == [["깬돌", None, 3, 0.5], ["깬돌", 3, None, 0.7]]
|
||||
# 정함: 조건 → 값 · 범위가 있는 칸(조건이든 값이든)은 두 칸 · 수 하나면 아래 · 위 같은 수
|
||||
assert got["t"]["rows"][0] == ["돌", "높이·아래", "높이·위", "석공", "증가율·아래", "증가율·위"]
|
||||
assert got["t"]["rows"][1:] == [
|
||||
["깬돌", None, 3, 0.5, 80, 100],
|
||||
["견치돌", 4, 4, 0.7, None, None],
|
||||
]
|
||||
assert Decimal(str(got["l"]["value"])) == price and got["l"]["unit"]
|
||||
assert Decimal(str(got["g"]["value"])) == Decimal("0.5") * price
|
||||
assert got["g2"]["value"] == 0
|
||||
assert [got[k]["ok"] for k in ("w", "x", "z")] == [False, False, False]
|
||||
for k in ("w", "x", "z"):
|
||||
assert got[k]["ok"] is False and got[k]["reason"], got[k]
|
||||
|
||||
|
||||
def test_시스템_관리자가_아니면_403(folder: Path) -> None:
|
||||
@@ -216,3 +250,117 @@ def test_시스템_관리자가_아니면_403(folder: Path) -> None:
|
||||
assert client.post("/api/m01/sl/outside", json={"items": []}).status_code == 403
|
||||
app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None}
|
||||
assert client.get("/api/m01/sl/list").json() == {"logics": []}
|
||||
|
||||
|
||||
def _node_json(script: str) -> object:
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
|
||||
def test_표_블록이_Table_Import_와_같은_열(client: TestClient) -> None:
|
||||
"""서버 표 답(머리 줄 + 줄) = sub3 Table_Build `blockCells` = sub6 Import_Parse `tableRows`."""
|
||||
got = _post(
|
||||
client, "/api/m01/sl/outside", {"items": [{"id": "t", "kind": "표", "table": SAMPLE}]}
|
||||
)
|
||||
rows = got["values"][0]["rows"]
|
||||
table = json.dumps(
|
||||
{"조건": TABLE["조건"], "값칸": TABLE["값칸"], "줄": TABLE["줄"]}, ensure_ascii=False
|
||||
)
|
||||
info = json.dumps(
|
||||
{"conds": TABLE["조건"], "vals": TABLE["값칸"], "rows": TABLE["줄"]}, ensure_ascii=False
|
||||
)
|
||||
|
||||
build = (ROOT / "M01_MasterData/SheetLogic/M01_SL_Table_Build.ts").as_uri()
|
||||
cells = _node_json(
|
||||
f"import {{ blockCells }} from '{build}';console.log(JSON.stringify(blockCells({info})));"
|
||||
)
|
||||
letters = "ABCDEFGHIJ"
|
||||
mine = {
|
||||
f"{letters[c]}{r + 1}": {"값": v}
|
||||
for r, line in enumerate(rows)
|
||||
for c, v in enumerate(line)
|
||||
if v is not None
|
||||
}
|
||||
assert cells == mine
|
||||
|
||||
src = (ROOT / "M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts").read_text(encoding="utf-8")
|
||||
body = re.search(r"/\*\* 범위 칸 꼬리[\s\S]*?\nexport function tableRows[\s\S]*?\n}\n", src)
|
||||
assert body, "Import_Parse 의 tableCols · tableRows 를 못 찾음"
|
||||
script = (
|
||||
"const currentLanguageIndex = 0;\n"
|
||||
+ body.group(0).replace("export ", "")
|
||||
+ f"\nconsole.log(JSON.stringify(tableRows({table})));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == rows
|
||||
|
||||
|
||||
def _material_cond() -> tuple[dict, str]:
|
||||
"""값 있는 자재품목 한 줄 → 검색어 묶음 · 단위(사본에서 읽음)."""
|
||||
rows = json.loads((store.FOLDER / "재료_자재품목.json").read_text(encoding="utf-8"))["줄"]
|
||||
row = next(r for r in rows if isinstance(r.get("물가자료"), (int, float)) and r.get("구분"))
|
||||
cond = {
|
||||
"구분": row["구분"],
|
||||
"상세구분": row["상세구분"],
|
||||
"검색어": f"{row['이름']} {row['규격'] or ''}".strip(),
|
||||
}
|
||||
return cond, row["단위"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
|
||||
def test_자재_검색어_묶음_단가는_calc_재료_줄과_같음(client: TestClient) -> None:
|
||||
cond, unit = _material_cond()
|
||||
line = {
|
||||
"종류": "재료",
|
||||
"요소": cond,
|
||||
"이름": "재료",
|
||||
"단위": unit,
|
||||
"수량": "1",
|
||||
"비목": "재료비",
|
||||
}
|
||||
row = {"키": "", "원문번호": "", "이름": "견본", "결과단위": "원/㎡", "출처": "자체",
|
||||
"입력": [],
|
||||
"중간": [], "호표": [line], "덧줄": [], "끝수": None} # fmt: skip
|
||||
want = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": row})["lines"][0]["단가"]
|
||||
assert want is not None
|
||||
|
||||
text = json.dumps(cond, ensure_ascii=False) # sub6 가져오기 꼴 — 묶음 JSON 글
|
||||
bad = json.dumps({**cond, "검색어": "없는품목가나다"}, ensure_ascii=False)
|
||||
items = [
|
||||
{"id": "s", "kind": "자재", "key": text, "unit": unit},
|
||||
{"id": "d", "kind": "자재", "key": cond, "unit": unit},
|
||||
{"id": "x", "kind": "자재", "key": bad, "unit": unit},
|
||||
]
|
||||
got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]}
|
||||
assert got["s"]["ok"] and got["s"]["value"] == want == got["d"]["value"]
|
||||
assert got["x"]["ok"] is False and got["x"]["reason"] == "「없는품목가나다」 맞는 자재 없음"
|
||||
|
||||
# 저장 때도 같은 길 — Item · Import 꼴(몸의 outside 목록)
|
||||
logic = {
|
||||
"key": None,
|
||||
"name": "자재 견본",
|
||||
"resultUnit": "원/㎡",
|
||||
"workbook": {
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"서식": [{}],
|
||||
"시트": [{"id": "s1", "이름": "재료", "칸": {}}],
|
||||
},
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"outside": [{"item": items[0], "sheet": "s1", "cell": "B2"}],
|
||||
}
|
||||
saved = _post(client, "/api/m01/sl/save", {"logic": logic})
|
||||
assert saved["logic"]["workbook"]["시트"][0]["칸"]["B2"]["값"] == want
|
||||
_post(client, "/api/m01/sl/delete", {"key": saved["key"]})
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""시트 로직 표 상자 — 찾기 수식 짜기(`M01_SL_Table_Build.ts`) · 붙여넣기 읽기.
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 짜고, 스프레드시트 엔진(서버 Node 번들)이 그 수식을 풀어 값을 대조.
|
||||
견본 표는 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from common_util.common_util_spreadsheet import recalc_workbooks
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / "M01_MasterData" / "SheetLogic" / "M01_SL_Table_Build.ts"
|
||||
|
||||
RUNNER = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const [root, file] = process.argv.slice(1);
|
||||
const ts = require(path.join(root, "config", "node_modules", "typescript"));
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
|
||||
const m = box.exports;
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = jobs.map((job) => {
|
||||
if (job.op === "plan") {
|
||||
const plan = m.planLookup(job.info, job.roles);
|
||||
const cells = { ...m.blockCells(job.blockInfo || job.info), ...plan.cells };
|
||||
for (const [c, v] of Object.entries(job.inputs || {})) cells[plan.inCells[c]] = { 값: v };
|
||||
return { cells, plan };
|
||||
}
|
||||
if (job.op === "paste") return m.parsePaste(job.text);
|
||||
if (job.op === "rename") {
|
||||
const sl = { key: null, name: "t", resultUnit: "", edges: [],
|
||||
workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [
|
||||
{ id: "s1", 이름: "표", 칸: {} },
|
||||
{ id: "s2", 이름: "계산", 칸: { B2: { 식: "'표'!G7*2" }, B3: { 식: "표!G7+1" } } } ] },
|
||||
nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] };
|
||||
const done = m.applyTable(sl, "n1", job.info, job.roles, { ref: job.info.key, fillBlock: false, outside: true }).sl;
|
||||
const texts = Object.values(done.workbook.시트[0].칸).map((c) => String(c.값 ?? ""));
|
||||
return { sheets: done.workbook.시트.map((x) => x.이름), refs: done.workbook.시트[1].칸, ref: done.nodes[0].ref, node: done.nodes[0].name, texts };
|
||||
}
|
||||
if (job.op === "reapply") {
|
||||
const base = { key: null, name: "t", resultUnit: "원/㎡", edges: [],
|
||||
workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [{ id: "s1", 이름: "표", 칸: {} }] },
|
||||
nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] };
|
||||
const opts = { ref: "T1", fillBlock: true, outside: false };
|
||||
const first = m.applyTable(base, "n1", job.info, job.roles, opts).sl;
|
||||
const cell = first.nodes[0].pillsIn[0].cell;
|
||||
first.workbook.시트[0].칸[cell] = { 식: "'다른 시트'!B2" };
|
||||
const second = m.applyTable(first, "n1", job.info, job.roles, { ...opts, fillBlock: false }).sl;
|
||||
return { cell, kept: second.workbook.시트[0].칸[cell], pills: second.nodes[0].pillsIn.map((p) => p.name), name: second.nodes[0].name };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
R = {"key": "T1", "name": "견본", "base": "1㎡", "source": "", "no": "", "rule": None, "notes": []}
|
||||
EXACT = {
|
||||
**R,
|
||||
"conds": {"뒷길이": "수", "돌": "고르기", "쌓기": "고르기"},
|
||||
"vals": {"석공": "인", "보통": "인"},
|
||||
"rows": [
|
||||
{"뒷길이": 25, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.15, "보통": 0.12},
|
||||
{"뒷길이": 25, "돌": "호박돌", "쌓기": None, "석공": 0.1, "보통": 0.09},
|
||||
{"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기", "석공": 0.26, "보통": 0.21},
|
||||
{"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기", "석공": 0.23, "보통": 0.18},
|
||||
{"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기", "석공": 0.5, "보통": 0.5},
|
||||
],
|
||||
}
|
||||
RATE = {
|
||||
**R,
|
||||
"conds": {"높이": "범위"},
|
||||
"vals": {"증가율": "%"},
|
||||
"rule": "초과·이하",
|
||||
"rows": [
|
||||
{"높이": [3, 4], "증가율": 30},
|
||||
{"높이": [4, 5.5], "증가율": 40},
|
||||
{"높이": [5.5, None], "증가율": 60},
|
||||
],
|
||||
}
|
||||
MIXED = {
|
||||
**R,
|
||||
"conds": {"구분": "고르기", "높이": "범위"},
|
||||
"vals": {"율": "%"},
|
||||
"rule": "초과·이하",
|
||||
"rows": [
|
||||
{"구분": "가", "높이": [0, 2], "율": 1},
|
||||
{"구분": "가", "높이": [2, 4], "율": 2},
|
||||
{"구분": "나", "높이": [0, 3], "율": 5},
|
||||
],
|
||||
}
|
||||
MINE = {
|
||||
**R,
|
||||
"conds": {"경사": "고르기"},
|
||||
"vals": {"할증": "%"},
|
||||
"rows": [{"경사": "완", "할증": 0}, {"경사": "급", "할증": 10}],
|
||||
}
|
||||
|
||||
|
||||
def run(jobs: list[dict]) -> list:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
done = subprocess.run(
|
||||
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
|
||||
input=json.dumps(jobs, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def solve(info: dict, inputs: dict, out: list[str], cond: list[str] | None = None) -> dict:
|
||||
"""견본 표를 찾기 판 수식으로 풀어 → {상태, 걸린 줄, 결과 칸들}."""
|
||||
roles = {"cond": cond or list(info["conds"]), "out": out}
|
||||
plan = run([{"op": "plan", "info": info, "roles": roles, "inputs": inputs}])[0]
|
||||
book = {
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "t",
|
||||
"서식": [{}],
|
||||
"시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}],
|
||||
"활성": "s1",
|
||||
}
|
||||
values = recalc_workbooks([book])[0]["s1"]
|
||||
p = plan["plan"]
|
||||
|
||||
def got(a1: str):
|
||||
v = values.get(a1) or {}
|
||||
return v.get("수", v.get("글", v.get("오류")))
|
||||
|
||||
res = {c: got(a) for c, a in p["outCells"].items()}
|
||||
return {"상태": got(p["statusCell"]), "줄": got(p["idxCell"]), **res}
|
||||
|
||||
|
||||
def test_같음_조건_셋은_열쇠로_찾음():
|
||||
r = solve(EXACT, {"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기"}, ["석공", "보통"])
|
||||
assert r["상태"] == "찾음" and r["줄"] == "3"
|
||||
assert float(r["석공"]) == 0.26 and float(r["보통"]) == 0.21
|
||||
|
||||
|
||||
def test_빈칸은_무관_줄로_찾음():
|
||||
r = solve(EXACT, {"뒷길이": 25, "돌": "호박돌", "쌓기": "켜쌓기"}, ["석공"])
|
||||
assert r["상태"] == "찾음" and float(r["석공"]) == 0.1
|
||||
|
||||
|
||||
def test_맞는_줄_없음과_둘_이상():
|
||||
none = solve(EXACT, {"뒷길이": 99, "돌": "깬돌", "쌓기": "골쌓기"}, ["석공"])
|
||||
assert none["상태"] == "맞는 줄 없음" and none["석공"] == "#N/A"
|
||||
dup = solve(EXACT, {"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기"}, ["석공"])
|
||||
assert dup["상태"] == "맞는 줄 둘 이상" and dup["석공"] == "#N/A"
|
||||
|
||||
|
||||
def test_범위_조건은_아래_위_두_칸으로_초과_이하():
|
||||
assert float(solve(RATE, {"높이": 4}, ["증가율"])["증가율"]) == 30 # 4 는 3~4 줄(이하)
|
||||
assert float(solve(RATE, {"높이": 4.5}, ["증가율"])["증가율"]) == 40
|
||||
assert float(solve(RATE, {"높이": 9}, ["증가율"])["증가율"]) == 60 # 위 칸이 빈 줄 = 끝 없음
|
||||
assert solve(RATE, {"높이": 3}, ["증가율"])["상태"] == "맞는 줄 없음" # 3 은 초과가 아님
|
||||
assert solve(RATE, {"높이": 1}, ["증가율"])["상태"] == "맞는 줄 없음"
|
||||
|
||||
|
||||
def test_같음_조건과_범위_조건이_섞임():
|
||||
assert float(solve(MIXED, {"구분": "가", "높이": 3}, ["율"])["율"]) == 2
|
||||
assert float(solve(MIXED, {"구분": "나", "높이": 3}, ["율"])["율"]) == 5
|
||||
assert solve(MIXED, {"구분": "다", "높이": 3}, ["율"])["상태"] == "맞는 줄 없음"
|
||||
assert solve(MIXED, {"구분": "나", "높이": 9}, ["율"])["상태"] == "맞는 줄 없음"
|
||||
|
||||
|
||||
def test_내_표_경사에서_할증_두_줄():
|
||||
assert float(solve(MINE, {"경사": "급"}, ["할증"])["할증"]) == 10
|
||||
assert float(solve(MINE, {"경사": "완"}, ["할증"])["할증"]) == 0
|
||||
|
||||
|
||||
def test_조건_칸_역할을_바꾸면_찾기_수식도_바뀜():
|
||||
r = solve(EXACT, {"뒷길이": 30}, ["석공"], cond=["뒷길이"])
|
||||
assert r["상태"] == "맞는 줄 둘 이상" # 뒷길이 30 줄이 셋
|
||||
|
||||
|
||||
def test_붙여넣기_읽기():
|
||||
text = "경사\t범위\t할증\n완\t0~10\t0\n급\t10~\t10"
|
||||
out = run([{"op": "paste", "text": text}])[0]
|
||||
info = out["info"]
|
||||
assert out["error"] is None
|
||||
assert info["conds"] == {"경사": "고르기", "범위": "범위"}
|
||||
assert info["vals"] == {"할증": ""}
|
||||
assert info["rows"][1] == {"경사": "급", "범위": [10, None], "할증": 10}
|
||||
assert run([{"op": "paste", "text": "한 줄뿐"}])[0]["error"]
|
||||
|
||||
|
||||
def test_다시_짜도_선_수식은_그대로():
|
||||
out = run([{"op": "reapply", "info": MINE, "roles": {"cond": ["경사"], "out": ["할증"]}}])[0]
|
||||
assert out["kept"] == {"식": "'다른 시트'!B2"}
|
||||
assert out["pills"] == ["경사"] and out["name"] == "표 · 견본"
|
||||
|
||||
|
||||
def test_표가_비었어도_수식이_깨지지_않음():
|
||||
"""내 표를 막 만든 때(줄이 다 빈칸) — 빈칸 변형 열쇠가 빈 수식이 되면 안 됨."""
|
||||
empty = {**MINE, "rows": [{}, {}]}
|
||||
r = solve(empty, {"경사": "급"}, ["할증"])
|
||||
assert r["상태"] == "맞는 줄 없음" and r["할증"] == "#N/A"
|
||||
|
||||
|
||||
def test_값_칸_범위도_아래_위_두_칸_숫자_하나는_같은_수():
|
||||
"""서버 표 블록 차례 — 범위 값은 「이름·아래」 「이름·위」 · 숫자 하나면 두 칸이 같은 수."""
|
||||
table = {
|
||||
**R,
|
||||
"conds": {"높이": "범위"},
|
||||
"vals": {"증가율": "%"},
|
||||
"rule": "초과·이하",
|
||||
"rows": [{"높이": [3, 4], "증가율": [80, 100]}, {"높이": [4, None], "증가율": 30}],
|
||||
}
|
||||
low = solve(table, {"높이": 3.5}, ["증가율"])
|
||||
assert float(low["증가율·아래"]) == 80 and float(low["증가율·위"]) == 100
|
||||
one = solve(table, {"높이": 5}, ["증가율"])
|
||||
assert float(one["증가율·아래"]) == 30 and float(one["증가율·위"]) == 30
|
||||
|
||||
|
||||
def test_블록_머리_차례():
|
||||
table = {
|
||||
**R,
|
||||
"conds": {"가": "고르기", "높이": "범위"},
|
||||
"vals": {"율": "%", "폭": "m"},
|
||||
"rows": [{"가": "a", "높이": [1, 2], "율": 1, "폭": [20, 45]}],
|
||||
}
|
||||
plan = run([{"op": "plan", "info": table, "roles": {"cond": ["가"], "out": ["율"]}}])[0]["plan"]
|
||||
assert [c["header"] for c in plan["cols"]] == [
|
||||
"가",
|
||||
"높이·아래",
|
||||
"높이·위",
|
||||
"율",
|
||||
"폭·아래",
|
||||
"폭·위",
|
||||
]
|
||||
|
||||
|
||||
def test_표가_늘어도_찾기_범위가_따라감():
|
||||
"""마스터 표 줄이 시험 때보다 늘었을 때 — 찾기 판이 만든 줄 수 밖 새 줄도 찾음(범위 표 · 같음 표 둘 다)."""
|
||||
small = {**RATE, "rows": RATE["rows"][:2]}
|
||||
extra = [{"높이": [5.5 + i, 6.5 + i], "증가율": 70 + i} for i in range(5)]
|
||||
grown = {**small, "rows": [*small["rows"], *extra]}
|
||||
roles = {"cond": ["높이"], "out": ["증가율"]}
|
||||
plan = run(
|
||||
[{"op": "plan", "info": small, "roles": roles, "inputs": {"높이": 8.5}, "blockInfo": grown}]
|
||||
)[0]
|
||||
book = {
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "t",
|
||||
"서식": [{}],
|
||||
"시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}],
|
||||
"활성": "s1",
|
||||
}
|
||||
values = recalc_workbooks([book])[0]["s1"]
|
||||
assert values[plan["plan"]["statusCell"]]["글"] == "찾음"
|
||||
assert float(values[plan["plan"]["outCells"]["증가율"]]["수"]) == 72
|
||||
|
||||
|
||||
def test_표_상자에_표_번호가_안_보임_이름으로_보임():
|
||||
info = {**EXACT, "key": "QF000421", "name": "메쌓기(인력)", "no": "13-4-1", "base": "1㎡"}
|
||||
roles = {"cond": list(info["conds"]), "out": ["석공"]}
|
||||
out = run([{"op": "rename", "info": info, "roles": roles}])[0]
|
||||
assert out["sheets"] == ["표 · 메쌓기(인력)", "계산"]
|
||||
assert out["ref"] == "QF000421" # 번호는 상자 ref 에만
|
||||
assert "QF000421" not in " ".join(out["texts"])
|
||||
assert any("13-4-1" in t and "1㎡" in t for t in out["texts"])
|
||||
assert out["refs"]["B2"]["식"] == "'표 · 메쌓기(인력)'!G7*2"
|
||||
assert out["refs"]["B3"]["식"] == "'표 · 메쌓기(인력)'!G7+1"
|
||||
Reference in New Issue
Block a user