Merge remote-tracking branch 'origin/dev' into sub_laptop_4
This commit is contained in:
@@ -131,6 +131,8 @@ class Same:
|
|||||||
"덮개": norm(_bare(doc.get("덮개"))),
|
"덮개": norm(_bare(doc.get("덮개"))),
|
||||||
"더한줄": _sorted([self._line(x, trail, True) for x in doc.get("더한줄") or []]),
|
"더한줄": _sorted([self._line(x, trail, True) for x in doc.get("더한줄") or []]),
|
||||||
"뺀줄": sorted(map(str, doc.get("뺀줄") or [])),
|
"뺀줄": sorted(map(str, doc.get("뺀줄") or [])),
|
||||||
|
# 값 바꾸는 새 칸(790-2 · 792-1) — 없으면 빠짐 = 옛 지문 그대로
|
||||||
|
**{k: norm(doc.get(k)) for k in ("상수덮개", "표값덮개", "식덮개", "변수줄")},
|
||||||
}
|
}
|
||||||
return {k: v for k, v in body.items() if v not in EMPTY}
|
return {k: v for k, v in body.items() if v not in EMPTY}
|
||||||
|
|
||||||
|
|||||||
@@ -84,3 +84,43 @@
|
|||||||
margin-top: var(--spacing-8);
|
margin-top: var(--spacing-8);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 790-3 숫자 넓힘 — 식 속 숫자 · 표 값 칸 · 까닭 · 글 줄 */
|
||||||
|
.m02-sks__formula,
|
||||||
|
.m02-sks__find {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-4);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
font-weight: var(--font-weight-regular);
|
||||||
|
color: var(--color-text-body);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m02-sks__frag {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m02-sks__num {
|
||||||
|
width: 6.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m02-sks__num[data-cover] {
|
||||||
|
outline: 2px solid var(--color-warning);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m02-sks__reason .ui-field {
|
||||||
|
width: min(24em, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m02-sks__note .ui-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m02-sks__paper--note {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* 고침 = 지금 되는 것만(모델 안 바꿈) — 옵션 고름 · 품 · 단가 · 율 % · 줄 빼기 · 되살림 · [+ 자원 줄] · [+ 율 줄]
|
* 고침 = 지금 되는 것만(모델 안 바꿈) — 옵션 고름 · 품 · 단가 · 율 % · 줄 빼기 · 되살림 · [+ 자원 줄] · [+ 율 줄]
|
||||||
* → SK 문서(덮개 · 더한줄 · 뺀줄 = `M02_Sangeun_Cover_Model`) → 상세 `touch` = 서버 셈 한 번(최신 응답만)
|
* → SK 문서(덮개 · 더한줄 · 뺀줄 = `M02_Sangeun_Cover_Model`) → 상세 `touch` = 서버 셈 한 번(최신 응답만)
|
||||||
* 상태줄 = M01 원래 · 지금 · 고치기 전(화면 캐시) + 차이 · 자동저장 없음([저장] = 상세 그대로)
|
* 상태줄 = M01 원래 · 지금 · 고치기 전(화면 캐시) + 차이 · 자동저장 없음([저장] = 상세 그대로)
|
||||||
|
* 790-3 숫자 넓힘(식 속 상수 · 표값 · 글 줄 · 까닭 · [M01 원래로] · M01 바뀜) = `_Sangeun_SheetEdit`
|
||||||
* 부품 = `ui_template_calc_row` 시트 줄 · 입력 · 고르기 · 단추 · 배지 · 글 — 날 단추 · 입력 없음
|
* 부품 = `ui_template_calc_row` 시트 줄 · 입력 · 고르기 · 단추 · 배지 · 글 — 날 단추 · 입력 없음
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
@@ -22,6 +23,18 @@ import { formatLineMoney, formatMoney } from "../common_util/common_util_money";
|
|||||||
import type { LogicRow, TextAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
import type { LogicRow, TextAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||||
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
|
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
|
||||||
import { removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
|
import { removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
|
||||||
|
import {
|
||||||
|
addTopNote,
|
||||||
|
formulaEditor,
|
||||||
|
lostBanner,
|
||||||
|
noteRows,
|
||||||
|
reasonCell,
|
||||||
|
removedReasons,
|
||||||
|
restoreAll,
|
||||||
|
spotTail,
|
||||||
|
type EditCtx,
|
||||||
|
} from "./M02_MasterTemplete_Sangeun_SheetEdit";
|
||||||
|
import { coverKinds, dropAddKeys, type ConstDoc } from "./logic/M02_Sangeun_Const_Model";
|
||||||
import {
|
import {
|
||||||
dropLine,
|
dropLine,
|
||||||
PART_OF,
|
PART_OF,
|
||||||
@@ -35,9 +48,11 @@ import {
|
|||||||
} from "./logic/M02_Sangeun_Cover_Model";
|
} from "./logic/M02_Sangeun_Cover_Model";
|
||||||
import {
|
import {
|
||||||
diffText,
|
diffText,
|
||||||
|
liftAddKeys,
|
||||||
num,
|
num,
|
||||||
paperLines,
|
paperLines,
|
||||||
sheetItems,
|
sheetItems,
|
||||||
|
spotsOf,
|
||||||
statusOf,
|
statusOf,
|
||||||
type SheetItem,
|
type SheetItem,
|
||||||
type SheetKind,
|
type SheetKind,
|
||||||
@@ -61,7 +76,6 @@ const TEXT = {
|
|||||||
Price: ["단가", "Price"],
|
Price: ["단가", "Price"],
|
||||||
Rate: ["율 %", "Rate %"],
|
Rate: ["율 %", "Rate %"],
|
||||||
Drop: ["빼기", "Remove"],
|
Drop: ["빼기", "Remove"],
|
||||||
Undo: ["되돌림", "Revert"],
|
|
||||||
AddRes: ["+ 자원 줄", "+ Resource"],
|
AddRes: ["+ 자원 줄", "+ Resource"],
|
||||||
AddRate: ["+ 율 줄", "+ Rate line"],
|
AddRate: ["+ 율 줄", "+ Rate line"],
|
||||||
Table: ["표", "Table"],
|
Table: ["표", "Table"],
|
||||||
@@ -118,6 +132,8 @@ export function createSangeunSheet(
|
|||||||
let mode: "sheet" | "paper" = "sheet";
|
let mode: "sheet" | "paper" = "sheet";
|
||||||
let last: SheetDraw | null = null;
|
let last: SheetDraw | null = null;
|
||||||
let before: number | null = null; // 고치기 전 = 연 뒤 · 저장 뒤 첫 셈 계(화면 캐시)
|
let before: number | null = null; // 고치기 전 = 연 뒤 · 저장 뒤 첫 셈 계(화면 캐시)
|
||||||
|
let ctx: EditCtx | null = null; // 790-3 숫자 넓힘 칸 — 그릴 때마다 새로
|
||||||
|
let focusNext = ""; // 새 글 줄 칸 — 다음 그림 뒤 초점
|
||||||
const show = (v: View): void => {
|
const show = (v: View): void => {
|
||||||
host.hidden = v !== "sheet";
|
host.hidden = v !== "sheet";
|
||||||
editor.hidden = v === "sheet";
|
editor.hidden = v === "sheet";
|
||||||
@@ -169,6 +185,7 @@ export function createSangeunSheet(
|
|||||||
part("origin", t("Origin"), s.원래),
|
part("origin", t("Origin"), s.원래),
|
||||||
part("now", t("Now"), s.지금, diffText(s.지금, s.원래)),
|
part("now", t("Now"), s.지금, diffText(s.지금, s.원래)),
|
||||||
part("before", t("Before"), s.전, diffText(s.지금, s.전)),
|
part("before", t("Before"), s.전, diffText(s.지금, s.전)),
|
||||||
|
...(ctx ? restoreAll(ctx) : []),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,27 +248,23 @@ export function createSangeunSheet(
|
|||||||
return [el("div", { className: "m02-sks__edit", children: cells })];
|
return [el("div", { className: "m02-sks__edit", children: cells })];
|
||||||
}
|
}
|
||||||
|
|
||||||
function tailOf(d: SheetDraw, it: SheetItem): HTMLElement {
|
function tailOf(d: SheetDraw, it: SheetItem, nums: boolean): HTMLElement {
|
||||||
const r = it.row;
|
const r = it.row;
|
||||||
const kids: HTMLElement[] = it.비목
|
const kids: HTMLElement[] = it.비목
|
||||||
? [createBadge(it.비목, it.비목 === "별도" ? "warning" : "neutral")]
|
? [createBadge(it.비목, it.비목 === "별도" ? "warning" : "neutral")]
|
||||||
: [];
|
: [];
|
||||||
for (const tag of it.표) kids.push(createBadge(`${t("Table")} ${tag}`, "info"));
|
if (!nums) for (const tag of it.표) kids.push(createBadge(`${t("Table")} ${tag}`, "info"));
|
||||||
|
if (ctx) kids.push(...spotTail(ctx, it.자리, !!r && r.added !== null));
|
||||||
if (r && opt.edit) {
|
if (r && opt.edit) {
|
||||||
if (r.added === null && (r.covered.qty || r.covered.price))
|
|
||||||
kids.push(
|
|
||||||
createButton({
|
|
||||||
label: t("Undo"),
|
|
||||||
variant: "ghost",
|
|
||||||
size: 22,
|
|
||||||
onClick: () => (setLineCover(d.doc, r, null, opt.opened()), opt.touch()),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const drop = createButton({
|
const drop = createButton({
|
||||||
label: t("Drop"),
|
label: t("Drop"),
|
||||||
variant: "danger-ghost",
|
variant: "danger-ghost",
|
||||||
size: 22,
|
size: 22,
|
||||||
onClick: () => (dropLine(d.doc, r), opt.touch()),
|
onClick: () => {
|
||||||
|
dropLine(d.doc, r);
|
||||||
|
if (r.added !== null && !r.sub) apply(d, dropAddKeys(d.doc as ConstDoc, r.added));
|
||||||
|
else opt.touch();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
drop.dataset.sheetDrop = r.cid;
|
drop.dataset.sheetDrop = r.cid;
|
||||||
kids.push(drop);
|
kids.push(drop);
|
||||||
@@ -298,21 +311,35 @@ export function createSangeunSheet(
|
|||||||
};
|
};
|
||||||
|
|
||||||
function rowOf(d: SheetDraw, it: SheetItem): HTMLElement[] {
|
function rowOf(d: SheetDraw, it: SheetItem): HTMLElement[] {
|
||||||
|
const nums = ctx && it.자리 ? formulaEditor(ctx, it.자리) : null;
|
||||||
|
const shown = it.식 || (it.kind === "계산" ? t("Hidden") : "");
|
||||||
const middle =
|
const middle =
|
||||||
it.kind === "옵션"
|
it.kind === "옵션"
|
||||||
? optionCell(d, it)
|
? optionCell(d, it)
|
||||||
: el("div", {
|
: el("div", {
|
||||||
className: "m02-sks__mid",
|
className: "m02-sks__mid",
|
||||||
children: [
|
children: [
|
||||||
createCalcFormula(it.식 || (it.kind === "계산" ? t("Hidden") : "")),
|
...(it.row || !nums ? [createCalcFormula(shown)] : []),
|
||||||
|
...(nums ? [nums] : []),
|
||||||
...(it.row ? editLine(d, it.row) : []),
|
...(it.row ? editLine(d, it.row) : []),
|
||||||
|
...(ctx ? reasonCell(ctx, it.자리, it.changed) : []),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const row = createCalcSheetRow(it.이름, middle, valueOf(it), tailOf(d, it));
|
const row = createCalcSheetRow(it.이름, middle, valueOf(it), tailOf(d, it, !!nums));
|
||||||
row.dataset.sheetKind = it.kind;
|
row.dataset.sheetKind = it.kind;
|
||||||
if (it.row) row.dataset.sheetCid = it.row.cid;
|
if (it.row) row.dataset.sheetCid = it.row.cid;
|
||||||
|
if (it.자리) row.dataset.sheetSpot = it.자리;
|
||||||
if (it.changed) row.classList.add("is-changed");
|
if (it.changed) row.classList.add("is-changed");
|
||||||
return [row];
|
const notes = ctx && it.자리 ? noteRows(ctx, (x) => x.자리 === it.자리) : [];
|
||||||
|
return [row, ...notes];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 790-3 — 새 문서로 바꿔 끼움(상세가 쥔 같은 객체) → 다시 셈 */
|
||||||
|
function apply(d: SheetDraw, next: ConstDoc): void {
|
||||||
|
const doc = d.doc as Record<string, unknown>;
|
||||||
|
for (const k of Object.keys(doc)) if (!(k in next)) delete doc[k];
|
||||||
|
Object.assign(doc, next);
|
||||||
|
opt.touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
const head = (key: keyof typeof TEXT, extra: HTMLElement[] = []): HTMLElement =>
|
const head = (key: keyof typeof TEXT, extra: HTMLElement[] = []): HTMLElement =>
|
||||||
@@ -330,8 +357,10 @@ export function createSangeunSheet(
|
|||||||
ans: d.ans,
|
ans: d.ans,
|
||||||
rows: d.rows,
|
rows: d.rows,
|
||||||
text: d.text,
|
text: d.text,
|
||||||
|
covered: (s) => coverKinds(d.doc as ConstDoc, s).length > 0,
|
||||||
});
|
});
|
||||||
const out: HTMLElement[] = [];
|
const spots = new Set(items.map((it) => it.자리).filter(Boolean));
|
||||||
|
const out: HTMLElement[] = ctx ? lostBanner(ctx) : [];
|
||||||
const add = (label: keyof typeof TEXT, click: () => void): HTMLElement[] =>
|
const add = (label: keyof typeof TEXT, click: () => void): HTMLElement[] =>
|
||||||
opt.edit
|
opt.edit
|
||||||
? [createButton({ label: t(label), variant: "ghost", size: 22, onClick: click })]
|
? [createButton({ label: t(label), variant: "ghost", size: 22, onClick: click })]
|
||||||
@@ -344,22 +373,35 @@ export function createSangeunSheet(
|
|||||||
? add("AddRes", opt.addLine)
|
? add("AddRes", opt.addLine)
|
||||||
: sec === "율"
|
: sec === "율"
|
||||||
? add("AddRate", opt.addRate)
|
? add("AddRate", opt.addRate)
|
||||||
: [];
|
: sec === "근거" && ctx
|
||||||
|
? addTopNote(ctx)
|
||||||
|
: [];
|
||||||
if (!mine.length && !tools.length) continue;
|
if (!mine.length && !tools.length) continue;
|
||||||
out.push(head(sec, tools));
|
out.push(head(sec, tools));
|
||||||
if (sec === "자원")
|
if (sec === "자원")
|
||||||
out.push(
|
out.push(
|
||||||
...removedStrip(d.removed, opt.edit, (cid) => (restoreLine(d.doc, cid), opt.touch())),
|
...removedStrip(d.removed, opt.edit, (cid) => revive(d, cid)),
|
||||||
|
...(ctx ? removedReasons(ctx, d.removed) : []),
|
||||||
);
|
);
|
||||||
for (const it of mine) out.push(...rowOf(d, it));
|
for (const it of mine) out.push(...rowOf(d, it));
|
||||||
|
if (sec === "근거" && ctx) out.push(...noteRows(ctx, (x) => !spots.has(x.자리)));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 뺀 줄 되살림 — 뺀 더한줄(`gone:N`)이면 까닭 · 글줄 열쇠를 그 차례부터 한 칸 밂 */
|
||||||
|
function revive(d: SheetDraw, cid: string): void {
|
||||||
|
const gone = /^gone:(\d+)$/.exec(cid);
|
||||||
|
const at = gone ? d.doc.뺀더한줄?.[Number(gone[1])]?.차례 : undefined;
|
||||||
|
restoreLine(d.doc, cid);
|
||||||
|
if (at != null) apply(d, liftAddKeys(d.doc as ConstDoc, at));
|
||||||
|
else opt.touch();
|
||||||
|
}
|
||||||
|
|
||||||
function drawPaper(d: SheetDraw): HTMLElement[] {
|
function drawPaper(d: SheetDraw): HTMLElement[] {
|
||||||
return paperLines(d.text, d.rows, d.ans).map((p) => {
|
return paperLines(d.text, d.rows, d.ans, (d.doc as ConstDoc).글줄).map((p) => {
|
||||||
const line = el("p", {
|
const line = el("p", {
|
||||||
className: `m02-sks__paper m02-sks__paper--${p.kind === "줄" ? "line" : "sum"}`,
|
className: `m02-sks__paper m02-sks__paper--${p.kind === "줄" ? "line" : p.kind === "글" ? "note" : "sum"}`,
|
||||||
attrs: { "data-paper": p.kind },
|
attrs: { "data-paper": p.kind },
|
||||||
text: p.글,
|
text: p.글,
|
||||||
});
|
});
|
||||||
@@ -370,9 +412,17 @@ export function createSangeunSheet(
|
|||||||
function draw(d: SheetDraw): void {
|
function draw(d: SheetDraw): void {
|
||||||
last = d;
|
last = d;
|
||||||
if (before === null && d.ans?.기준?.계 != null) before = d.ans.기준.계;
|
if (before === null && d.ans?.기준?.계 != null) before = d.ans.기준.계;
|
||||||
|
ctx = {
|
||||||
|
doc: d.doc as ConstDoc,
|
||||||
|
edit: opt.edit,
|
||||||
|
spots: spotsOf(d.ans),
|
||||||
|
apply: (next) => apply(d, next),
|
||||||
|
focus: (key) => void (focusNext = key),
|
||||||
|
};
|
||||||
drawStatus(d);
|
drawStatus(d);
|
||||||
const active = document.activeElement as HTMLElement | null;
|
const active = document.activeElement as HTMLElement | null;
|
||||||
const keep = active && host.contains(active) ? active.dataset.sheetCell : undefined;
|
const keep = focusNext || (active && host.contains(active) ? active.dataset.sheetCell : "");
|
||||||
|
focusNext = "";
|
||||||
body.dataset.mode = mode;
|
body.dataset.mode = mode;
|
||||||
body.replaceChildren(
|
body.replaceChildren(
|
||||||
...(d.ans
|
...(d.ans
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* M02_MasterTemplete_Sangeun_SheetEdit.ts
|
||||||
|
* 단가산출(SK) 시트 숫자 넓힘 칸(PLAN 790-3 · E2 · _분석_650-H3 1 · 2 장) — 시트(`_Sangeun_Sheet`)가 부름.
|
||||||
|
* 식 칸 숫자 = 상수 덮개(식 모양 그대로 · 옵션 따라감) · 표 꼬리표 값 = 표값 덮개(「표 연결 끊김」)
|
||||||
|
* 글 줄(근거 글 · 셈 없음 · 산출서에 찍힘) · 까닭 칸 · [M01 원래로] · 「M01 바뀜」 · 짝잃음 띠
|
||||||
|
* 문서 고치기 = `logic/M02_Sangeun_Const_Model`(790-2 짝) · 금액은 늘 서버 셈(상세 touch)
|
||||||
|
* 부품 = 입력 · 단추 · 배지 · 띠 알림 · calc_row — 날 단추 · 입력 없음
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { createBadge } from "@ui/ui_template_badge";
|
||||||
|
import { createBanner } from "@ui/ui_template_banner";
|
||||||
|
import { createButton } from "@ui/ui_template_button";
|
||||||
|
import { createCalcFormula, createCalcSheetRow } from "@ui/ui_template_calc_row";
|
||||||
|
import { el } from "@ui/ui_template_elements";
|
||||||
|
import { createInputField } from "@ui/ui_template_input";
|
||||||
|
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||||
|
import { createText } from "@ui/ui_template_text";
|
||||||
|
import {
|
||||||
|
addText,
|
||||||
|
coverKinds,
|
||||||
|
dropText,
|
||||||
|
editText,
|
||||||
|
restoreM01,
|
||||||
|
setConst,
|
||||||
|
setReason,
|
||||||
|
setTable,
|
||||||
|
type ConstDoc,
|
||||||
|
} from "./logic/M02_Sangeun_Const_Model";
|
||||||
|
import {
|
||||||
|
formulaParts,
|
||||||
|
type NoteLine,
|
||||||
|
type SheetSpots,
|
||||||
|
type SpotInfo,
|
||||||
|
} from "./logic/M02_Sangeun_Sheet_Model";
|
||||||
|
|
||||||
|
const TEXT = {
|
||||||
|
Num: ["식 속 숫자", "Constant"],
|
||||||
|
TableVal: ["표 값", "Table value"],
|
||||||
|
Cut: ["표 연결 끊김", "Table link cut"],
|
||||||
|
Changed: ["M01 바뀜", "M01 changed"],
|
||||||
|
Restore: ["M01 원래로", "Reset to M01"],
|
||||||
|
RestoreAll: ["전체 M01 원래로", "Reset all to M01"],
|
||||||
|
Reason: ["까닭", "Reason"],
|
||||||
|
ReasonHint: ["고친 까닭(짧게)", "Why changed (short)"],
|
||||||
|
Note: ["글", "Note"],
|
||||||
|
NoteHint: ["근거 글", "Basis note"],
|
||||||
|
AddNote: ["+ 글", "+ Note"],
|
||||||
|
AddTop: ["+ 근거 글", "+ Basis note"],
|
||||||
|
DropNote: ["글 지움", "Delete note"],
|
||||||
|
Lost: ["짝 잃은 고침 — M01 이 바뀌어 붙일 자리가 없음", "Orphan edits — M01 changed"],
|
||||||
|
} as const satisfies Record<string, readonly [string, string]>;
|
||||||
|
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
|
||||||
|
|
||||||
|
export interface EditCtx {
|
||||||
|
doc: ConstDoc;
|
||||||
|
edit: boolean;
|
||||||
|
spots: SheetSpots | null;
|
||||||
|
/** 새 문서로 바꿔 끼움(같은 객체 유지) → 다시 셈 */
|
||||||
|
apply: (next: ConstDoc) => void;
|
||||||
|
/** 다음 그림 뒤 이 칸에 초점 */
|
||||||
|
focus: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const strip = (v: number | null | undefined): string =>
|
||||||
|
v == null ? "" : String(Number(Number(v).toFixed(6)));
|
||||||
|
|
||||||
|
/** 작은 칸 — 바꾸면(Enter · 떠남) `onSet(글)` */
|
||||||
|
function smallCell(
|
||||||
|
key: string,
|
||||||
|
label: string,
|
||||||
|
v: string,
|
||||||
|
hint: string,
|
||||||
|
onSet: (raw: string) => void,
|
||||||
|
): HTMLElement {
|
||||||
|
const box = createInputField({ value: v, compact: true, size: 24, placeholder: hint });
|
||||||
|
box.input.setAttribute("aria-label", label);
|
||||||
|
box.input.dataset.sheetCell = key;
|
||||||
|
box.input.addEventListener("change", () => onSet(box.input.value.trim()));
|
||||||
|
return box.root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 숫자 칸 — 빈 칸 · 원값과 같음 = null(덮개 풂) · 못 읽으면 되돌림 */
|
||||||
|
function numBox(
|
||||||
|
key: string,
|
||||||
|
label: string,
|
||||||
|
v: number | null,
|
||||||
|
was: number | null,
|
||||||
|
cover: boolean,
|
||||||
|
onSet: (x: number | null) => void,
|
||||||
|
): HTMLElement {
|
||||||
|
const root = smallCell(key, label, strip(v), strip(was), (raw) => {
|
||||||
|
const x = raw ? Number(raw.replaceAll(",", "")) : null;
|
||||||
|
if (x !== null && !Number.isFinite(x)) return void (input.value = strip(v));
|
||||||
|
onSet(x === null || (was != null && x === Number(was)) ? null : x);
|
||||||
|
});
|
||||||
|
const input = root.querySelector("input")!;
|
||||||
|
input.inputMode = "decimal";
|
||||||
|
root.classList.add("m02-sks__num");
|
||||||
|
if (cover) root.dataset.cover = "";
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 식 칸 — 숫자 · 표 값을 칸으로(편집 · 서버 차례와 맞을 때만) · 못 그리면 null */
|
||||||
|
export function formulaEditor(c: EditCtx, spot: string): HTMLElement | null {
|
||||||
|
const info = c.spots?.자리[spot];
|
||||||
|
const parts = c.edit ? formulaParts(info) : null;
|
||||||
|
if (!info || !parts) return null;
|
||||||
|
const nums = c.doc.상수덮개?.[spot] ?? {};
|
||||||
|
const finds = c.doc.표값덮개?.[spot] ?? {};
|
||||||
|
const kids = parts.map((p): HTMLElement => {
|
||||||
|
if (p.t === "글") return el("span", { className: "m02-sks__frag", text: p.글 });
|
||||||
|
if (p.t === "수") {
|
||||||
|
const one = info.상수![p.n];
|
||||||
|
const mine = nums[String(p.n)];
|
||||||
|
return numBox(
|
||||||
|
`${spot}|n${p.n}`,
|
||||||
|
`${t("Num")} ${p.n + 1}`,
|
||||||
|
mine ?? one.값,
|
||||||
|
one.원값,
|
||||||
|
mine != null,
|
||||||
|
(x) => c.apply(setConst(c.doc, spot, p.n, x)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const one = info.표값!.find((x) => x.n === p.n);
|
||||||
|
const was = typeof one?.원값 === "number" ? one.원값 : null;
|
||||||
|
const mine = finds[String(p.n)];
|
||||||
|
const shown = mine ?? (typeof one?.값 === "number" ? one.값 : null);
|
||||||
|
return el("span", {
|
||||||
|
className: "m02-sks__find",
|
||||||
|
children: [
|
||||||
|
numBox(`${spot}|t${p.n}`, `${t("TableVal")} ${p.n + 1}`, shown, was, mine != null, (x) =>
|
||||||
|
c.apply(setTable(c.doc, spot, p.n, x)),
|
||||||
|
),
|
||||||
|
...(p.표.length ? [createBadge(p.표.join(" "), "info")] : []),
|
||||||
|
...(mine != null ? [createBadge(t("Cut"), "warning")] : []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const box = el("div", {
|
||||||
|
className: "m02-sks__formula",
|
||||||
|
attrs: { "data-sheet-formula": spot },
|
||||||
|
children: kids,
|
||||||
|
});
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 꼬리 표지 · 단추 — M01 바뀜 · 표 연결 끊김 · [M01 원래로] · [+ 글] */
|
||||||
|
export function spotTail(c: EditCtx, spot: string, added: boolean): HTMLElement[] {
|
||||||
|
if (!spot) return [];
|
||||||
|
const info: SpotInfo | undefined = c.spots?.자리[spot];
|
||||||
|
const kinds = coverKinds(c.doc, spot);
|
||||||
|
const out: HTMLElement[] = [];
|
||||||
|
if (info?.M01바뀜) out.push(createBadge(t("Changed"), "danger"));
|
||||||
|
if (!c.edit) return out;
|
||||||
|
if (!added && kinds.length) {
|
||||||
|
const back = createButton({
|
||||||
|
label: t("Restore"),
|
||||||
|
variant: "ghost",
|
||||||
|
size: 22,
|
||||||
|
onClick: () => c.apply(restoreM01(c.doc, spot)),
|
||||||
|
});
|
||||||
|
back.dataset.sheetRestore = spot;
|
||||||
|
out.push(back);
|
||||||
|
}
|
||||||
|
const note = createButton({
|
||||||
|
label: t("AddNote"),
|
||||||
|
variant: "ghost",
|
||||||
|
size: 22,
|
||||||
|
onClick: () => addNote(c, spot),
|
||||||
|
});
|
||||||
|
note.dataset.sheetAddNote = spot;
|
||||||
|
out.push(note);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 까닭 칸 — 고친 자리(덮개 · 새 덮개 · 뺀줄 · 더한줄)만 */
|
||||||
|
export function reasonCell(c: EditCtx, key: string, need: boolean): HTMLElement[] {
|
||||||
|
const v = c.doc.까닭?.[key] ?? "";
|
||||||
|
if (!key || (!need && !v)) return [];
|
||||||
|
if (!c.edit) return [createCalcFormula(`${t("Reason")} · ${v}`)];
|
||||||
|
return [
|
||||||
|
el("span", {
|
||||||
|
className: "m02-sks__cell m02-sks__reason",
|
||||||
|
children: [
|
||||||
|
smallCell(`${key}|why`, t("Reason"), v, t("ReasonHint"), (x) =>
|
||||||
|
c.apply(setReason(c.doc, key, x)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isCovered = (doc: ConstDoc, spot: string): boolean =>
|
||||||
|
coverKinds(doc, spot).some((k) => k === "상수" || k === "표값");
|
||||||
|
|
||||||
|
function addNote(c: EditCtx, spot: string): void {
|
||||||
|
const lines = c.doc.글줄 ?? [];
|
||||||
|
let at = lines.length; // 같은 자리 글 줄 뒤
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--)
|
||||||
|
if (lines[i].자리 === spot) {
|
||||||
|
at = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (spot === "" && !lines.some((x) => x.자리 === "")) at = 0;
|
||||||
|
c.focus(`note|${at}`);
|
||||||
|
c.apply(addText(c.doc, spot, "", at));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 자리 뒤 글 줄들 — 편집 = 글 칸 · 지움 */
|
||||||
|
export function noteRows(c: EditCtx, pick: (x: NoteLine) => boolean): HTMLElement[] {
|
||||||
|
return (c.doc.글줄 ?? []).flatMap((x, i) => {
|
||||||
|
if (!pick(x)) return [];
|
||||||
|
const middle = c.edit
|
||||||
|
? smallCell(`note|${i}`, t("Note"), x.글, t("NoteHint"), (v) =>
|
||||||
|
c.apply(editText(c.doc, i, v)),
|
||||||
|
)
|
||||||
|
: createCalcFormula(x.글);
|
||||||
|
const tail = c.edit
|
||||||
|
? [
|
||||||
|
createButton({
|
||||||
|
label: t("DropNote"),
|
||||||
|
variant: "danger-ghost",
|
||||||
|
size: 22,
|
||||||
|
onClick: () => c.apply(dropText(c.doc, i)),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
const row = createCalcSheetRow(t("Note"), middle, el("span"), el("span", { children: tail }));
|
||||||
|
row.dataset.sheetKind = "글";
|
||||||
|
row.classList.add("m02-sks__note");
|
||||||
|
return [row];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addTopNote = (c: EditCtx): HTMLElement[] =>
|
||||||
|
c.edit
|
||||||
|
? [
|
||||||
|
createButton({
|
||||||
|
label: t("AddTop"),
|
||||||
|
variant: "ghost",
|
||||||
|
size: 22,
|
||||||
|
onClick: () => addNote(c, ""),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
/** 상태줄 [전체 M01 원래로] — 덮개 · 뺀줄 · 상수 · 표값 덮개가 있을 때만 */
|
||||||
|
export function restoreAll(c: EditCtx): HTMLElement[] {
|
||||||
|
const d = c.doc;
|
||||||
|
const any =
|
||||||
|
Object.keys(d.덮개 ?? {}).length +
|
||||||
|
(d.뺀줄 ?? []).length +
|
||||||
|
Object.keys(d.상수덮개 ?? {}).length +
|
||||||
|
Object.keys(d.표값덮개 ?? {}).length;
|
||||||
|
if (!c.edit || !any) return [];
|
||||||
|
const b = createButton({
|
||||||
|
label: t("RestoreAll"),
|
||||||
|
variant: "ghost",
|
||||||
|
size: 22,
|
||||||
|
onClick: () => c.apply(restoreM01(c.doc)),
|
||||||
|
});
|
||||||
|
b.dataset.sheetRestore = "*";
|
||||||
|
return [b];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 짝 잃은 새 덮개 띠(790-2 `시트.짝잃음`) */
|
||||||
|
export function lostBanner(c: EditCtx): HTMLElement[] {
|
||||||
|
const gone = c.spots?.짝잃음 ?? [];
|
||||||
|
return gone.length
|
||||||
|
? [
|
||||||
|
createBanner({
|
||||||
|
tone: "warning",
|
||||||
|
text: t("Lost"),
|
||||||
|
details: gone.map((x) => `${x.cid} — ${x.까닭}`).join("\n"),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 뺀 바탕 줄 까닭 칸(뺀 줄 띠 아래) */
|
||||||
|
export function removedReasons(
|
||||||
|
c: EditCtx,
|
||||||
|
removed: { cid: string; 이름: string }[],
|
||||||
|
): HTMLElement[] {
|
||||||
|
if (!c.edit) return [];
|
||||||
|
return removed
|
||||||
|
.filter((x) => (c.doc.뺀줄 ?? []).includes(x.cid))
|
||||||
|
.map((x) =>
|
||||||
|
el("div", {
|
||||||
|
className: "m02-sks__cell m02-sks__reason",
|
||||||
|
attrs: { "data-sheet-removed": x.cid },
|
||||||
|
children: [createText(x.이름, "muted"), ...reasonCell(c, x.cid, true)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -536,7 +536,7 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]:
|
|||||||
for cid, one in out["덮개"].items():
|
for cid, one in out["덮개"].items():
|
||||||
if cid in was:
|
if cid in was:
|
||||||
one["원값"] = _was(was[cid])
|
one["원값"] = _was(was[cid])
|
||||||
out |= const.stamp(base["줄"]) # 바탕판 · M01판(790-2)
|
out |= const.stamp(base["줄"], out) # 바탕판 · M01판(790-2) · 식 칸 엔진 식(792-1)
|
||||||
ratio_of(out, base["줄"], whole) # 단위 못 바꿈(계수 없음) = 저장 막음(728-2)
|
ratio_of(out, base["줄"], whole) # 단위 못 바꿈(계수 없음) = 저장 막음(728-2)
|
||||||
_stamp_adds(out, base, whole)
|
_stamp_adds(out, base, whole)
|
||||||
except sd.DocError as e:
|
except sd.DocError as e:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from decimal import Decimal
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||||
|
from M02_MasterTemplete import M02_Sangeun_Formula as fx
|
||||||
|
|
||||||
NEW = ("상수덮개", "표값덮개", "글줄", "까닭")
|
NEW = ("상수덮개", "표값덮개", "글줄", "까닭")
|
||||||
_SPOT = re.compile(r"^(mid:.+|(labor|material|machine|logic|pct):.*#\d+)$")
|
_SPOT = re.compile(r"^(mid:.+|(labor|material|machine|logic|pct):.*#\d+)$")
|
||||||
@@ -114,7 +115,17 @@ def places(row: dict) -> dict[str, tuple[str, int]]:
|
|||||||
|
|
||||||
|
|
||||||
def formulas(row: dict, doc: dict) -> dict:
|
def formulas(row: dict, doc: dict) -> dict:
|
||||||
"""바탕 줄 + 상수 · 표값 덮개 → 식 바꾼 사본(덮개 없음 = 그 줄 그대로 · 순수)."""
|
"""바탕 줄 + 식 칸(792-1) · 상수 · 표값 덮개 → 식 바꾼 사본(덮개 없음 = 그 줄 그대로 · 순수)."""
|
||||||
|
return formulas_lost(row, doc)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def formulas_lost(row: dict, doc: dict) -> tuple[dict, list[dict], dict]:
|
||||||
|
"""(식 바꾼 사본, 틀린 식 자리 짝잃음, 수량식 엔진 꼴 문서) — `sd.patched` 첫머리."""
|
||||||
|
row, bad, doc = fx.settle(row, doc)
|
||||||
|
return _swapped(row, doc), bad, doc
|
||||||
|
|
||||||
|
|
||||||
|
def _swapped(row: dict, doc: dict) -> dict:
|
||||||
nums, finds = doc.get("상수덮개") or {}, doc.get("표값덮개") or {}
|
nums, finds = doc.get("상수덮개") or {}, doc.get("표값덮개") or {}
|
||||||
if not nums and not finds:
|
if not nums and not finds:
|
||||||
return row
|
return row
|
||||||
@@ -147,13 +158,13 @@ def lost(row: dict, doc: dict) -> list[dict]:
|
|||||||
|
|
||||||
def no_flat(doc: Any) -> None:
|
def no_flat(doc: Any) -> None:
|
||||||
"""펼침 막음 — 상수 · 표값 덮개 SK(펼친 줄에 못 옮김 · 옛 참조 그대로 = 값 그대로)."""
|
"""펼침 막음 — 상수 · 표값 덮개 SK(펼친 줄에 못 옮김 · 옛 참조 그대로 = 값 그대로)."""
|
||||||
if isinstance(doc, dict) and (doc.get("상수덮개") or doc.get("표값덮개")):
|
if isinstance(doc, dict) and (doc.get("상수덮개") or doc.get("표값덮개") or fx.has(doc)):
|
||||||
raise sd.DocError("상수 · 표값 덮개 단가산출은 펼치지 않음(참조 그대로)")
|
raise sd.DocError("상수 · 표값 덮개 · 식 칸 단가산출은 펼치지 않음(참조 그대로)")
|
||||||
|
|
||||||
|
|
||||||
def covers_n(doc: dict) -> int:
|
def covers_n(doc: dict) -> int:
|
||||||
"""[초기화] 걷힐 고침 수 — 덮개 · 뺀줄 · 상수 · 표값 덮개 칸."""
|
"""[초기화] 걷힐 고침 수 — 덮개 · 뺀줄 · 상수 · 표값 덮개 칸."""
|
||||||
keys = ("덮개", "뺀줄", "상수덮개", "표값덮개")
|
keys = ("덮개", "뺀줄", "상수덮개", "표값덮개", "식덮개")
|
||||||
return sum(len(doc.get(k) or ()) for k in keys)
|
return sum(len(doc.get(k) or ()) for k in keys)
|
||||||
|
|
||||||
|
|
||||||
@@ -215,7 +226,7 @@ def clean_more(raw: Any, out: dict) -> dict:
|
|||||||
why = {_key(k, "까닭", _KEY): _words(v, f"까닭 「{k}」") for k, v in why.items()}
|
why = {_key(k, "까닭", _KEY): _words(v, f"까닭 「{k}」") for k, v in why.items()}
|
||||||
if why := {k: v for k, v in why.items() if v}:
|
if why := {k: v for k, v in why.items() if v}:
|
||||||
out["까닭"] = why
|
out["까닭"] = why
|
||||||
return out
|
return fx.clean(raw, out) # 식 칸(792-1)
|
||||||
|
|
||||||
|
|
||||||
def _print(one: Any) -> str:
|
def _print(one: Any) -> str:
|
||||||
@@ -223,10 +234,15 @@ def _print(one: Any) -> str:
|
|||||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
def stamp(row: dict) -> dict[str, Any]:
|
def stamp(row: dict, doc: dict | None = None) -> dict[str, Any]:
|
||||||
"""저장 때 서버 칸 — 바탕판(옛 칸 그대로) + M01판(자리마다 지문)."""
|
"""저장 때 서버 칸 — 바탕판(옛 칸 그대로) + M01판(자리마다 지문) · `doc` = 식 칸(792-1)을
|
||||||
|
엔진 식으로 바꿔 넣음(제자리 · 틀린 식 = `DocError`)."""
|
||||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||||
|
|
||||||
|
if doc is not None and fx.has(doc):
|
||||||
|
fixed = fx.settle(row, doc, strict=True)[2]
|
||||||
|
doc.update({k: fixed[k] for k in ("더한줄", *fx.FIELDS) if k in fixed})
|
||||||
|
|
||||||
lines = [*(row.get("중간") or []), *(row.get("호표") or []), *(row.get("덧줄") or [])]
|
lines = [*(row.get("중간") or []), *(row.get("호표") or []), *(row.get("덧줄") or [])]
|
||||||
spots = [f"mid:{s.get('이름')}" for s in row.get("중간") or []] + sd.line_ids(row)
|
spots = [f"mid:{s.get('이름')}" for s in row.get("중간") or []] + sd.line_ids(row)
|
||||||
return {"바탕판": calc.fingerprint(row), "M01판": dict(zip(spots, map(_print, lines)))}
|
return {"바탕판": calc.fingerprint(row), "M01판": dict(zip(spots, map(_print, lines)))}
|
||||||
@@ -234,10 +250,10 @@ def stamp(row: dict) -> dict[str, Any]:
|
|||||||
|
|
||||||
# ── 고치기(짝 = Const_Model.ts) ──────────────────────────────────────
|
# ── 고치기(짝 = Const_Model.ts) ──────────────────────────────────────
|
||||||
def restore(doc: dict, key: str | None = None) -> dict:
|
def restore(doc: dict, key: str | None = None) -> dict:
|
||||||
"""[M01 원래로] — 전체(None) = 덮개 · 뺀줄 · 상수 · 표값 덮개 · 그 까닭 지움(더한줄 · 글줄 ·
|
"""[M01 원래로] — 전체(None) = 덮개 · 뺀줄 · 상수 · 표값 · 식 덮개 · 그 까닭 지움(더한줄 ·
|
||||||
옵션 남김) · 열쇠 하나 = 그 자리 덮개 · 뺀줄 · 새 덮개 · 까닭(세부 `add:#k/cid` =
|
글줄 · 변수줄 · 옵션 남김) · 열쇠 하나 = 그 자리 덮개 · 뺀줄 · 새 덮개 · 까닭(세부
|
||||||
그 더한줄 세부)."""
|
`add:#k/cid` = 그 더한줄 세부)."""
|
||||||
out = {k: v for k, v in doc.items() if k not in ("상수덮개", "표값덮개", "까닭")}
|
out = {k: v for k, v in doc.items() if k not in ("상수덮개", "표값덮개", "식덮개", "까닭")}
|
||||||
why = dict(doc.get("까닭") or {})
|
why = dict(doc.get("까닭") or {})
|
||||||
if key is None:
|
if key is None:
|
||||||
out |= {"덮개": {}, "뺀줄": []}
|
out |= {"덮개": {}, "뺀줄": []}
|
||||||
@@ -256,7 +272,7 @@ def restore(doc: dict, key: str | None = None) -> dict:
|
|||||||
else:
|
else:
|
||||||
out["덮개"] = {c: v for c, v in (doc.get("덮개") or {}).items() if c != key}
|
out["덮개"] = {c: v for c, v in (doc.get("덮개") or {}).items() if c != key}
|
||||||
out["뺀줄"] = [c for c in doc.get("뺀줄") or [] if c != key]
|
out["뺀줄"] = [c for c in doc.get("뺀줄") or [] if c != key]
|
||||||
for name in ("상수덮개", "표값덮개"):
|
for name in ("상수덮개", "표값덮개", "식덮개"):
|
||||||
if got := {s: v for s, v in (doc.get(name) or {}).items() if s != key}:
|
if got := {s: v for s, v in (doc.get(name) or {}).items() if s != key}:
|
||||||
out[name] = got
|
out[name] = got
|
||||||
why.pop(key, None)
|
why.pop(key, None)
|
||||||
@@ -296,7 +312,9 @@ def kinds(doc: dict, spot: str) -> list[str]:
|
|||||||
if any(k in cover for k in sd.PARTS):
|
if any(k in cover for k in sd.PARTS):
|
||||||
out.append("단가")
|
out.append("단가")
|
||||||
out += [
|
out += [
|
||||||
n for n, k in (("상수", "상수덮개"), ("표값", "표값덮개")) if spot in (doc.get(k) or {})
|
n
|
||||||
|
for n, k in (("상수", "상수덮개"), ("표값", "표값덮개"), ("식", "식덮개"))
|
||||||
|
if spot in (doc.get(k) or {})
|
||||||
]
|
]
|
||||||
return out + (["뺌"] if spot in (doc.get("뺀줄") or []) else [])
|
return out + (["뺌"] if spot in (doc.get("뺀줄") or []) else [])
|
||||||
|
|
||||||
@@ -311,7 +329,7 @@ def answer(got: dict, clean: dict, raw: Any, given: dict | None, trail: tuple) -
|
|||||||
|
|
||||||
|
|
||||||
def _bare(doc: dict) -> dict:
|
def _bare(doc: dict) -> dict:
|
||||||
out = {k: v for k, v in doc.items() if k not in (*NEW, "뺀더한줄")}
|
out = {k: v for k, v in doc.items() if k not in (*NEW, *fx.FIELDS, "뺀더한줄")}
|
||||||
return out | {"덮개": {}, "뺀줄": [], "더한줄": []}
|
return out | {"덮개": {}, "뺀줄": [], "더한줄": []}
|
||||||
|
|
||||||
|
|
||||||
@@ -400,5 +418,6 @@ def sheet(ans: dict, clean: dict, raw: Any, given: dict | None, trail: tuple) ->
|
|||||||
gone = lost(base, clean) + [
|
gone = lost(base, clean) + [
|
||||||
{"cid": s, "까닭": "M01 바뀜 — 바탕에 없는 줄"} for s in stamps if s not in fresh
|
{"cid": s, "까닭": "M01 바뀜 — 바탕에 없는 줄"} for s in stamps if s not in fresh
|
||||||
]
|
]
|
||||||
top = {"글줄": clean.get("글줄") or [], "짝잃음": gone}
|
gone += fx.sheet_more(spots, base, clean, mids1) # 식 칸(792-1) 덧칸 · 변수 줄 · 틀린 식
|
||||||
|
top = {"글줄": clean.get("글줄") or [], "짝잃음": gone, "이름표": fx.name_table(base, clean)}
|
||||||
return {"원래": bare.get("기준"), "시트": {"자리": spots, **top}}
|
return {"원래": bare.get("기준"), "시트": {"자리": spots, **top}}
|
||||||
|
|||||||
@@ -436,7 +436,7 @@ def _covered(item: dict, cover: dict, extra: bool) -> tuple[dict, str | None]:
|
|||||||
def _add_item(one: dict) -> dict:
|
def _add_item(one: dict) -> dict:
|
||||||
"""더한줄 → 엔진 호표 줄 — 참조 · 고정 줄 `값묶음` 은 셈(`M02_Sangeun_Calc`)이 채움."""
|
"""더한줄 → 엔진 호표 줄 — 참조 · 고정 줄 `값묶음` 은 셈(`M02_Sangeun_Calc`)이 채움."""
|
||||||
head = {"이름": one.get("이름") or "", "단위": one.get("단위") or ""}
|
head = {"이름": one.get("이름") or "", "단위": one.get("단위") or ""}
|
||||||
qty = text(one["수량"])
|
qty = one.get("수량식") or text(one["수량"]) # 수량식(792-1) = 엔진 식(`fx.settle` 이 바꿈)
|
||||||
if "참조" in one or "고정" in one:
|
if "참조" in one or "고정" in one:
|
||||||
kept = {k: one[k] for k in ("참조", "고정") if k in one}
|
kept = {k: one[k] for k in ("참조", "고정") if k in one}
|
||||||
if is_rate(one): # 율 줄(778-4) = 그 비목 한 칸(엔진 묶음 · 보기 상자 = 비목)
|
if is_rate(one): # 율 줄(778-4) = 그 비목 한 칸(엔진 묶음 · 보기 상자 = 비목)
|
||||||
@@ -460,12 +460,12 @@ def patched(
|
|||||||
`picks` · `shown` = 바탕 쪽 차례 열쇠 고름 · 자재표시(바탕 SK 사슬) → 새 차례로 옮김.
|
`picks` · `shown` = 바탕 쪽 차례 열쇠 고름 · 자재표시(바탕 SK 사슬) → 새 차례로 옮김.
|
||||||
돌려줌 = {줄, 고름 {차례: MT}, 자재표시 {차례: 칸}, 정체 [새 줄 cid · 더한줄 = `add:#k`],
|
돌려줌 = {줄, 고름 {차례: MT}, 자재표시 {차례: 칸}, 정체 [새 줄 cid · 더한줄 = `add:#k`],
|
||||||
짝잃음 [{cid, 까닭}]}."""
|
짝잃음 [{cid, 까닭}]}."""
|
||||||
row = const.formulas(row, doc) # 상수 · 표값 덮개(790-2) = 식 바꾼 바탕
|
row, bad, doc = const.formulas_lost(row, doc) # 식 칸(792-1) · 상수 · 표값 덮개(790-2)
|
||||||
items, extras = list(row.get("호표") or []), list(row.get("덧줄") or [])
|
items, extras = list(row.get("호표") or []), list(row.get("덧줄") or [])
|
||||||
ids = line_ids(row)
|
ids = line_ids(row)
|
||||||
covers, drop = doc.get("덮개") or {}, set(doc.get("뺀줄") or [])
|
covers, drop = doc.get("덮개") or {}, set(doc.get("뺀줄") or [])
|
||||||
mine, marks = doc.get("고름") or {}, doc.get("자재표시") or {}
|
mine, marks = doc.get("고름") or {}, doc.get("자재표시") or {}
|
||||||
lost = [
|
lost = bad + [
|
||||||
{"cid": c, "까닭": "뺀줄 — 바탕에 없는 줄"} for c in doc.get("뺀줄") or [] if c not in ids
|
{"cid": c, "까닭": "뺀줄 — 바탕에 없는 줄"} for c in doc.get("뺀줄") or [] if c not in ids
|
||||||
]
|
]
|
||||||
new_items, new_extras, cids, out_picks, out_marks = [], [], [], {}, {}
|
new_items, new_extras, cids, out_picks, out_marks = [], [], [], {}, {}
|
||||||
|
|||||||
@@ -0,0 +1,619 @@
|
|||||||
|
"""M02 단가산출(SK) 사용자 식 칸(PLAN 792-1 · 요구서_652 1 장 3 · _분석_650-H3 E4) — 변수 줄 ·
|
||||||
|
식 덮개 · 더한줄 수량식 · 이름 꼴 ↔ 엔진 식 · 검사(「덮개 = 숫자만」 원칙을 바꿈).
|
||||||
|
|
||||||
|
짝 = `logic/M02_Sangeun_Formula_Model.ts`(캐시 고치기 — 서버 짝 함수 없음 · 꼴만 같음) ·
|
||||||
|
시험 `resources/tester/test_792_1_sangeun_formula.py`.
|
||||||
|
|
||||||
|
문서 덧칸(없으면 안 둠 — 옛 SK 그대로 · 저장 = 엔진 식 · 이름 꼴 글도 받아 서버가 번역):
|
||||||
|
변수줄 [{이름, 식, 표시?, 자리}] 엔진 중간 줄로 끼움 · 자리 = 그 뒤 중간 `mid:이름`
|
||||||
|
("" = 맨 위 · 없는 자리 = 중간 끝 + 짝잃음) · 표시 = 엔진 `표시`(0~6 반올림)
|
||||||
|
식덮개 {자리: 식} 중간 `mid:이름` 식 · 호표 cid 수량 · 덧줄 cid 식
|
||||||
|
(원래 식 = 바탕 · M01 바뀜 = 790-2 M01판)
|
||||||
|
더한줄[k].수량식 인력 · 재료 · 기계 · 참조 · 고정(율 줄 빼고) · `수량` = 보기 값
|
||||||
|
이름 꼴 — `×` `÷`(= `*` `/`) · `[노임 형틀목공]`(요소 · 겹치면 키 덧붙임) · `[별칭]`(바탕 줄
|
||||||
|
`찾기` · `로직` 부르기 = `master_copy.aliases` · 별칭표는 바탕 줄에서만 지음 = 사용자 고침에
|
||||||
|
안 흔들림) · 맨 이름 = 입력 · 중간 · 변수 · 덧줄 · 비목 · 엔진 날식도 그대로 받음.
|
||||||
|
셈 = `settle`(바탕 줄 + 식 칸 → 엔진 줄 · `M02_Sangeun_Const.formulas` 첫머리 → `patched` →
|
||||||
|
`_Over` 로 같은 엔진 한 벌 · B09 굳힘 `GX9#####` 도 그 줄) · 틀린 식 = 그 자리만 M01 식으로 +
|
||||||
|
짝잃음(셈 길) · 저장(`strict`) = 400.
|
||||||
|
보안 — eval 없음(엔진 파서만) · 식 길이 `LONG` · 괄호 깊이 `DEEP` · 변수 줄 `MANY` ·
|
||||||
|
로직 부르기 깊이 20(엔진).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||||
|
|
||||||
|
FIELDS = ("변수줄", "식덮개")
|
||||||
|
LONG = 300 # 식 글 길이
|
||||||
|
DEEP = 12 # 괄호 깊이
|
||||||
|
MANY = 100 # 변수 줄 수
|
||||||
|
FIELD = {"중간": "식", "호표": "수량", "덧줄": "식"}
|
||||||
|
WORD = {
|
||||||
|
"LB": "노임",
|
||||||
|
"EQ": "기계",
|
||||||
|
"MT": "자재",
|
||||||
|
"MP": "자재",
|
||||||
|
"MO": "유가",
|
||||||
|
"FX": "환율",
|
||||||
|
"RT": "요율",
|
||||||
|
}
|
||||||
|
_VAR = re.compile(r"^[A-Za-z가-힣_][\w가-힣]{0,19}$")
|
||||||
|
_ADD = re.compile(r"^add:#(\d+)$")
|
||||||
|
_SPOT = re.compile(r"^(mid:.+|(labor|material|machine|logic|pct):.*#\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _eng() -> Any:
|
||||||
|
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||||
|
|
||||||
|
return labor.m01.cm.mf
|
||||||
|
|
||||||
|
|
||||||
|
def _whole() -> Any:
|
||||||
|
from M01_MasterData import M01_MasterData_Store as m01
|
||||||
|
|
||||||
|
return m01.loaded()[1]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 꼴 검사(문서 · `sd.clean` 끝) ─────────────────────────────────────
|
||||||
|
def limits(text: str) -> list[str]:
|
||||||
|
"""식 글 상한 — 길이 · 괄호 깊이(파서 앞 · 깊은 괄호 = 서버 500 막음)."""
|
||||||
|
out, depth, top, quoted = [], 0, 0, False
|
||||||
|
if len(text) > LONG:
|
||||||
|
out.append(f"식이 {LONG} 자를 넘음")
|
||||||
|
for ch in text:
|
||||||
|
if ch == "'":
|
||||||
|
quoted = not quoted
|
||||||
|
elif not quoted and ch in "([":
|
||||||
|
depth += 1
|
||||||
|
top = max(top, depth)
|
||||||
|
elif not quoted and ch in ")]":
|
||||||
|
depth -= 1
|
||||||
|
if top > DEEP:
|
||||||
|
out.append(f"괄호가 {DEEP} 겹을 넘음")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _text(raw: Any, where: str) -> str:
|
||||||
|
got = str(raw if raw is not None else "").strip()
|
||||||
|
if not got:
|
||||||
|
raise sd.DocError(f"{where} · 식이 빔")
|
||||||
|
if bad := limits(got):
|
||||||
|
raise sd.DocError(f"{where} · {bad[0]}")
|
||||||
|
return got
|
||||||
|
|
||||||
|
|
||||||
|
def clean(raw: dict, out: dict) -> dict:
|
||||||
|
"""`sd.clean` 끝(790-2 `clean_more` 뒤) — 변수줄 · 식덮개 · 더한줄 수량식 꼴 · 상한 · 겹침."""
|
||||||
|
lines = raw.get("변수줄") or []
|
||||||
|
if not isinstance(lines, list) or len(lines) > MANY:
|
||||||
|
raise sd.DocError(f"변수줄 · {MANY} 줄까지 목록")
|
||||||
|
got, seen = [], set()
|
||||||
|
for at, one in enumerate(lines, 1):
|
||||||
|
one = one if isinstance(one, dict) else {}
|
||||||
|
name, where = str(one.get("이름") or "").strip(), f"변수줄 {at}째"
|
||||||
|
if not _VAR.match(name) or _eng().mk.KEY.fullmatch(name):
|
||||||
|
raise sd.DocError(f"{where} · 이름 꼴 아님 「{name}」(글자로 시작 · 20 자까지)")
|
||||||
|
if name in seen:
|
||||||
|
raise sd.DocError(f"{where} · 같은 이름 「{name}」")
|
||||||
|
seen.add(name)
|
||||||
|
line = {"이름": name, "식": _text(one.get("식"), f"{where} 「{name}」")}
|
||||||
|
if one.get("표시") not in (None, ""):
|
||||||
|
show = sd._num(one["표시"], f"{where} 표시")
|
||||||
|
if show != int(show) or not 0 <= show <= 6:
|
||||||
|
raise sd.DocError(f"{where} · 표시는 0~6 자리")
|
||||||
|
line["표시"] = int(show)
|
||||||
|
spot = str(one.get("자리") or "")
|
||||||
|
if spot and not spot.startswith("mid:"):
|
||||||
|
raise sd.DocError(f'{where} · 자리는 중간 `mid:이름` · 맨 위 ""')
|
||||||
|
got.append(line | {"자리": spot})
|
||||||
|
if got:
|
||||||
|
out["변수줄"] = got
|
||||||
|
covers = raw.get("식덮개") or {}
|
||||||
|
if not isinstance(covers, dict):
|
||||||
|
raise sd.DocError("식덮개 · 묶음 아님")
|
||||||
|
fx = {}
|
||||||
|
for spot, text in covers.items():
|
||||||
|
if not isinstance(spot, str) or not _SPOT.match(spot):
|
||||||
|
raise sd.DocError(f"식덮개 · 자리 꼴 아님 「{spot}」")
|
||||||
|
if spot in (out.get("상수덮개") or {}) or spot in (out.get("표값덮개") or {}):
|
||||||
|
raise sd.DocError(f"식덮개 「{spot}」 · 상수 · 표값 덮개와 같이 못 둠")
|
||||||
|
if "qty" in ((out.get("덮개") or {}).get(spot) or {}):
|
||||||
|
raise sd.DocError(f"식덮개 「{spot}」 · 품 덮개(qty)와 같이 못 둠 — 식에 넣을 것")
|
||||||
|
fx[spot] = _text(text, f"식덮개 「{spot}」")
|
||||||
|
if fx:
|
||||||
|
out["식덮개"] = fx
|
||||||
|
adds = raw.get("더한줄") or []
|
||||||
|
for k, one in enumerate(out.get("더한줄") or []):
|
||||||
|
text = adds[k].get("수량식") if k < len(adds) and isinstance(adds[k], dict) else None
|
||||||
|
if text in (None, ""):
|
||||||
|
continue
|
||||||
|
where = f"더한줄 {k + 1}째 수량식"
|
||||||
|
if sd.is_rate(one) or one.get("종류") == "로직":
|
||||||
|
raise sd.DocError(f"{where} · 율 줄 · 로직 줄은 수만")
|
||||||
|
one["수량식"] = _text(text, where)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def has(doc: Any) -> bool:
|
||||||
|
"""식 칸을 쓰는 문서."""
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
return False
|
||||||
|
adds = [x for x in doc.get("더한줄") or [] if isinstance(x, dict)]
|
||||||
|
return any(doc.get(k) for k in FIELDS) or any(x.get("수량식") for x in adds)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 이름 꼴 ↔ 엔진 식 ────────────────────────────────────────────────
|
||||||
|
@functools.lru_cache(maxsize=2)
|
||||||
|
def _labels(whole: Any) -> tuple[dict, dict]:
|
||||||
|
"""(키 → 이름 꼴 글, 글 → 키) — 같은 글이 여럿이면 글에 키를 덧붙임."""
|
||||||
|
base: dict[str, str] = {}
|
||||||
|
for tid, word in WORD.items():
|
||||||
|
for key, row in (whole.index.get(tid) or {}).items():
|
||||||
|
words = (word, str(row.get("이름") or ""), str(row.get("규격") or ""))
|
||||||
|
base[key] = re.sub(r"\s+", " ", re.sub(r"[\[\]]", "", " ".join(words))).strip()
|
||||||
|
count: dict[str, int] = {}
|
||||||
|
for label in base.values():
|
||||||
|
count[label] = count.get(label, 0) + 1
|
||||||
|
label_of = {k: v if count[v] == 1 else f"{v} {k}" for k, v in base.items()}
|
||||||
|
return label_of, {v: k for k, v in label_of.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _mc() -> Any:
|
||||||
|
_eng() # 엔진 길(scripts)이 sys.path 에 깔림
|
||||||
|
import master_copy
|
||||||
|
|
||||||
|
return master_copy
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=512)
|
||||||
|
def _alias_of(raw: str) -> tuple[tuple[str, str], ...]:
|
||||||
|
"""바탕 줄 별칭표 ((이름, 원문)) — 줄 글마다 한 번."""
|
||||||
|
return tuple((a["이름"], a["원문"]) for a in _mc().aliases(json.loads(raw), _whole()))
|
||||||
|
|
||||||
|
|
||||||
|
def aliases(row: dict) -> dict[str, str]:
|
||||||
|
"""{별칭: 원문 부르기}(바탕 줄 · `master_copy.aliases`)."""
|
||||||
|
return dict(_alias_of(json.dumps(row, ensure_ascii=False, sort_keys=True, default=str)))
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def _catch() -> type:
|
||||||
|
"""엔진 파서 + 요소 키 · 부르기 글자 자리 기록."""
|
||||||
|
mf = _eng()
|
||||||
|
|
||||||
|
class Catch(mf._Parser):
|
||||||
|
def __init__(self, text: str) -> None:
|
||||||
|
super().__init__(text)
|
||||||
|
self.refs: list = []
|
||||||
|
self.calls: list = []
|
||||||
|
|
||||||
|
def primary(self): # noqa: ANN202
|
||||||
|
self.ws()
|
||||||
|
at = self.i
|
||||||
|
node = super().primary()
|
||||||
|
if node[0] == "ref" and mf.mk.KEY.fullmatch(node[1]):
|
||||||
|
self.refs.append((at, self.i, node[1]))
|
||||||
|
elif node[0] in ("find", "logic"):
|
||||||
|
self.calls.append((at, self.i))
|
||||||
|
return node
|
||||||
|
|
||||||
|
return Catch
|
||||||
|
|
||||||
|
|
||||||
|
def _spots(text: str) -> tuple[list, list] | None:
|
||||||
|
"""(요소 키 자리, 바깥 부르기 자리) · 못 읽음 = None."""
|
||||||
|
mf, p = _eng(), _catch()(text)
|
||||||
|
try:
|
||||||
|
p.parse()
|
||||||
|
except (mf.FormulaError, RecursionError):
|
||||||
|
return None
|
||||||
|
calls = [
|
||||||
|
c for c in p.calls if not any(o[0] <= c[0] and c[1] <= o[1] and o != c for o in p.calls)
|
||||||
|
]
|
||||||
|
return p.refs, calls
|
||||||
|
|
||||||
|
|
||||||
|
def to_name(text: str, row: dict) -> str:
|
||||||
|
"""엔진 식 → 이름 꼴(`*` → `×` · 요소 = `[노임 …]` · 바탕 별칭 부르기 = `[별칭]`)."""
|
||||||
|
got = _spots(text)
|
||||||
|
if got is None:
|
||||||
|
return text
|
||||||
|
refs, calls = got
|
||||||
|
label_of = _labels(_whole())[0]
|
||||||
|
raw_to = {re.sub(r"\s+", " ", v): k for k, v in aliases(row).items()}
|
||||||
|
cuts = []
|
||||||
|
for a, b in calls:
|
||||||
|
name = raw_to.get(re.sub(r"\s+", " ", text[a:b]))
|
||||||
|
if name:
|
||||||
|
cuts.append((a, b, f"[{name}]"))
|
||||||
|
for a, b, key in refs:
|
||||||
|
if key in label_of and not any(x <= a and b <= y for x, y in calls):
|
||||||
|
cuts.append((a, b, f"[{label_of[key]}]"))
|
||||||
|
out, at = [], 0
|
||||||
|
for a, b, word in sorted(cuts):
|
||||||
|
out += [_times(text[at:a]), word]
|
||||||
|
at = b
|
||||||
|
return "".join(out) + _times(text[at:])
|
||||||
|
|
||||||
|
|
||||||
|
def _times(part: str) -> str:
|
||||||
|
"""따옴표 밖 `*` → `×`."""
|
||||||
|
bits = part.split("'")
|
||||||
|
return "'".join(b.replace("*", "×") if i % 2 == 0 else b for i, b in enumerate(bits))
|
||||||
|
|
||||||
|
|
||||||
|
def to_engine(text: str, row: dict) -> tuple[str, list[str]]:
|
||||||
|
"""이름 꼴 → (엔진 식, 까닭) — `×` `÷` · `[…]` = 키 · 바탕 별칭 · 요소 글 · 맨 별칭 이름."""
|
||||||
|
names = aliases(row)
|
||||||
|
find = _labels(_whole())[1]
|
||||||
|
mk = _eng().mk
|
||||||
|
out, why, at, quoted = [], [], 0, False
|
||||||
|
while at < len(text):
|
||||||
|
ch = text[at]
|
||||||
|
if ch == "'":
|
||||||
|
quoted = not quoted
|
||||||
|
if quoted or ch not in "×÷[]":
|
||||||
|
out.append(ch)
|
||||||
|
at += 1
|
||||||
|
continue
|
||||||
|
if ch in "×÷":
|
||||||
|
out.append("*" if ch == "×" else "/")
|
||||||
|
at += 1
|
||||||
|
continue
|
||||||
|
end = text.find("]", at)
|
||||||
|
if ch == "]" or end < 0:
|
||||||
|
return text, ["대괄호 짝 안 맞음"]
|
||||||
|
word = re.sub(r"\s+", " ", text[at + 1 : end]).strip()
|
||||||
|
keys = mk.KEY.findall(word)
|
||||||
|
if word in names:
|
||||||
|
out.append(names[word])
|
||||||
|
elif word in find:
|
||||||
|
out.append(find[word])
|
||||||
|
elif keys and len(keys) == 1:
|
||||||
|
out.append(keys[0])
|
||||||
|
else:
|
||||||
|
why.append(f"없는 이름 「[{word}]」")
|
||||||
|
at = end + 1
|
||||||
|
got = "".join(out)
|
||||||
|
taken = _names(row)
|
||||||
|
bare = {k: v for k, v in names.items() if k not in taken}
|
||||||
|
return (_unmask(got, bare) if bare else got), why
|
||||||
|
|
||||||
|
|
||||||
|
def _unmask(text: str, raw_of: dict) -> str:
|
||||||
|
return _mc()._unmask(text, raw_of)
|
||||||
|
|
||||||
|
|
||||||
|
def _names(row: dict) -> set[str]:
|
||||||
|
return {str(x.get("이름")) for k in ("입력", "중간", "덧줄") for x in row.get(k) or []}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 바탕 줄 + 식 칸 → 엔진 줄 ──────────────────────────────────────────
|
||||||
|
def _places(row: dict) -> dict[str, tuple[str, int]]:
|
||||||
|
from M02_MasterTemplete import M02_Sangeun_Const as const
|
||||||
|
|
||||||
|
return const.places(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _bad(spot: str, why: str) -> dict:
|
||||||
|
return {"cid": spot, "까닭": f"식 — {why}"}
|
||||||
|
|
||||||
|
|
||||||
|
def settle(row: dict, doc: dict, strict: bool = False) -> tuple[dict, list[dict], dict]:
|
||||||
|
"""바탕 줄 + 식 칸 → (엔진 줄, 틀린 자리 [{cid, 까닭}], 엔진 식으로 바꾼 문서) · 틀린 자리 =
|
||||||
|
M01 식(변수 = 뺌 · 수량식 = 수) · `strict` = 하나라도 틀리면 `DocError`(저장)."""
|
||||||
|
if not has(doc):
|
||||||
|
return row, [], doc
|
||||||
|
where, bad, gone = _places(row), [], []
|
||||||
|
covers, lines, adds = {}, [], [dict(x) for x in doc.get("더한줄") or []]
|
||||||
|
for spot, text in (doc.get("식덮개") or {}).items():
|
||||||
|
got, why = to_engine(text, row)
|
||||||
|
if spot not in where:
|
||||||
|
gone.append(_bad(spot, "바탕에 없는 줄"))
|
||||||
|
elif why:
|
||||||
|
bad += [_bad(spot, w) for w in why]
|
||||||
|
else:
|
||||||
|
covers[spot] = got
|
||||||
|
taken, mf = _names(row), _eng()
|
||||||
|
for one in doc.get("변수줄") or []:
|
||||||
|
spot, (got, why) = f"mid:{one['이름']}", to_engine(one["식"], row)
|
||||||
|
if one["이름"] in taken or one["이름"] in _reserved():
|
||||||
|
bad.append(
|
||||||
|
_bad(spot, f"이름 「{one['이름']}」 이 이미 있음(입력 · 중간 · 덧줄 · 예약)")
|
||||||
|
)
|
||||||
|
elif why:
|
||||||
|
bad += [_bad(spot, w) for w in why]
|
||||||
|
else:
|
||||||
|
lines.append({**one, "식": got})
|
||||||
|
for k, one in enumerate(adds):
|
||||||
|
if one.get("수량식"):
|
||||||
|
got, why = to_engine(one["수량식"], row)
|
||||||
|
bad += [_bad(f"add:#{k}", w) for w in why]
|
||||||
|
if why:
|
||||||
|
one.pop("수량식")
|
||||||
|
else:
|
||||||
|
one["수량식"] = got
|
||||||
|
while True: # 틀린 자리를 빼면 다른 자리가 틀릴 수 있음 — 더 안 틀릴 때까지
|
||||||
|
out, lost = _build(row, covers, lines)
|
||||||
|
more = _check(out, covers, lines, adds, mf)
|
||||||
|
if not more:
|
||||||
|
break
|
||||||
|
bad += more
|
||||||
|
hit = {b["cid"] for b in more}
|
||||||
|
covers = {s: v for s, v in covers.items() if s not in hit}
|
||||||
|
lines = [x for x in lines if f"mid:{x['이름']}" not in hit]
|
||||||
|
for k in [int(_ADD.match(c)[1]) for c in hit if _ADD.match(c)]:
|
||||||
|
adds[k].pop("수량식", None)
|
||||||
|
if strict and bad: # 짝 잃음(바탕에 없는 줄 · 자리)은 알림만 — [M01 원래로] 로 걷음
|
||||||
|
raise sd.DocError(f"「{bad[0]['cid']}」 {bad[0]['까닭']}")
|
||||||
|
fixed = {k: v for k, v in doc.items() if k not in FIELDS} | {"더한줄": adds}
|
||||||
|
fixed |= ({"식덮개": covers} if covers else {}) | ({"변수줄": lines} if lines else {})
|
||||||
|
return out, bad + gone + lost, fixed
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def _reserved() -> frozenset:
|
||||||
|
mf = _eng()
|
||||||
|
return frozenset(
|
||||||
|
(*mf.FUNCS, *mf.COST_ITEMS, "찾기", "로직", "그리고", "또는", "계", "줄", "번지", "단계")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build(row: dict, covers: dict, lines: list[dict]) -> tuple[dict, list[dict]]:
|
||||||
|
"""식 덮개를 바탕 칸에 · 변수 줄을 자리 뒤에 끼움(같은 자리 = 넣은 차례)."""
|
||||||
|
out = {**row, **{k: list(row.get(k) or []) for k in FIELD}}
|
||||||
|
for spot, (part, at) in _places(row).items():
|
||||||
|
if spot in covers:
|
||||||
|
out[part][at] = {**out[part][at], FIELD[part]: covers[spot]}
|
||||||
|
mids, after, lost = out["중간"], {}, []
|
||||||
|
for one in lines:
|
||||||
|
spot = one["자리"]
|
||||||
|
names = [f"mid:{x.get('이름')}" for x in mids]
|
||||||
|
if spot and spot not in names:
|
||||||
|
lost.append(_bad(f"mid:{one['이름']}", f"자리 「{spot}」 없음 — 중간 끝에 둠"))
|
||||||
|
at = len(mids)
|
||||||
|
else:
|
||||||
|
at = after.get(spot, names.index(spot) + 1 if spot else 0)
|
||||||
|
step = {"이름": one["이름"], "식": one["식"], "변수": True}
|
||||||
|
mids.insert(at, step | ({"표시": one["표시"]} if "표시" in one else {}))
|
||||||
|
after = {k: v + (v >= at) for k, v in after.items()} | {spot: at + 1}
|
||||||
|
return out, lost
|
||||||
|
|
||||||
|
|
||||||
|
def _uses(text: str) -> set[str] | None:
|
||||||
|
mf = _eng()
|
||||||
|
try:
|
||||||
|
return {n[1] for n in mf.walk(mf.parse(text)) if n[0] == "name"}
|
||||||
|
except (mf.FormulaError, RecursionError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _zero(node: tuple, mf: Any) -> bool:
|
||||||
|
return any(n[0] == "bin" and n[1] == "/" and n[3] == ("num", 0) for n in mf.walk(node))
|
||||||
|
|
||||||
|
|
||||||
|
def _check(row: dict, covers: dict, lines: list, adds: list, mf: Any) -> list[dict]:
|
||||||
|
"""식 칸 자리만 검사 — 식 · 0 나눔 · 이름(엔진 `_check_node`) · 아래 줄 · 순환."""
|
||||||
|
inputs = {str(x.get("이름")) for x in row.get("입력") or []}
|
||||||
|
mids = [str(x.get("이름")) for x in row["중간"]]
|
||||||
|
deps = {n: (_uses(str(x.get("식"))) or set()) & set(mids) for n, x in zip(mids, row["중간"])}
|
||||||
|
mine = set(covers) | {f"mid:{x['이름']}" for x in lines}
|
||||||
|
full = inputs | set(mids)
|
||||||
|
tail = full | set(mf.COST_ITEMS) | {"줄"} | ({"번지", "단계"} if mf.ms.uses(row) else set())
|
||||||
|
jobs = [
|
||||||
|
(f"mid:{n}", str(x["식"]), inputs | set(mids[:i]), n)
|
||||||
|
for i, (n, x) in enumerate(zip(mids, row["중간"]))
|
||||||
|
]
|
||||||
|
jobs = [j for j in jobs if j[0] in mine]
|
||||||
|
places = _places(row)
|
||||||
|
for spot, (part, at) in places.items():
|
||||||
|
if spot in covers and part != "중간":
|
||||||
|
names = (
|
||||||
|
tail | {str(x.get("이름")) for x in row["덧줄"][:at]} if part == "덧줄" else full
|
||||||
|
)
|
||||||
|
jobs.append((spot, covers[spot], names, None))
|
||||||
|
jobs += [(f"add:#{k}", x["수량식"], full, None) for k, x in enumerate(adds) if x.get("수량식")]
|
||||||
|
out = []
|
||||||
|
for spot, text, known, me in jobs:
|
||||||
|
try:
|
||||||
|
node = mf.parse(text)
|
||||||
|
except (mf.FormulaError, RecursionError) as e:
|
||||||
|
out.append(_bad(spot, str(e)))
|
||||||
|
continue
|
||||||
|
why = ["0 으로 나눔"] if _zero(node, mf) else []
|
||||||
|
used = {n[1] for n in mf.walk(node) if n[0] == "name"}
|
||||||
|
for n in sorted(used - known):
|
||||||
|
if n == me:
|
||||||
|
why.append(f"순환 — 「{n}」 이 자기 자신을 씀")
|
||||||
|
elif me and n in deps:
|
||||||
|
loop = _path(deps, n, me)
|
||||||
|
why.append(
|
||||||
|
f"순환 — {' → '.join([me, *loop])}"
|
||||||
|
if loop
|
||||||
|
else f"「{n}」 은 아래 줄 — 위로 옮길 것"
|
||||||
|
)
|
||||||
|
found: list[str] = []
|
||||||
|
mf._check_node(row, node, known | (used & set(mids)), _whole(), "", found, set())
|
||||||
|
why += [w.lstrip(" ·") for w in found]
|
||||||
|
out += [_bad(spot, w) for w in why]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _path(deps: dict, start: str, goal: str) -> list[str] | None:
|
||||||
|
"""`start` 에서 `goal` 로 가는 의존 길(있으면 순환)."""
|
||||||
|
seen, todo = {start}, [[start]]
|
||||||
|
while todo:
|
||||||
|
path = todo.pop()
|
||||||
|
if path[-1] == goal:
|
||||||
|
return path
|
||||||
|
for nxt in sorted(deps.get(path[-1], ())):
|
||||||
|
if nxt not in seen:
|
||||||
|
seen.add(nxt)
|
||||||
|
todo.append([*path, nxt])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 셈 답 덧칸(790-2 `시트`) ─────────────────────────────────────────
|
||||||
|
def sheet_more(spots: dict, base: dict, clean: dict, mids: dict) -> list[dict]:
|
||||||
|
"""790-2 시트 자리에 덧칸 — 식글(지금 식 이름 꼴) · 원식글 · 식덮개 · 식틀림 · 변수 줄 ·
|
||||||
|
더한줄 식글 · 돌려줌 = 틀린 식 · 짝 잃은 식 칸(시트 `짝잃음` 에 더함)."""
|
||||||
|
from M02_MasterTemplete import M02_Sangeun_Const as const
|
||||||
|
|
||||||
|
row, bad, fixed = settle(base, clean)
|
||||||
|
eff, covers = const._swapped(row, clean), fixed.get("식덮개") or {}
|
||||||
|
now, why = _places(eff), clean.get("까닭") or {}
|
||||||
|
wrong: dict[str, list[str]] = {}
|
||||||
|
for b in bad:
|
||||||
|
wrong.setdefault(b["cid"], []).append(b["까닭"])
|
||||||
|
for spot, one in spots.items():
|
||||||
|
if spot in now and one.get("갈래") != "더한줄":
|
||||||
|
part, at = now[spot]
|
||||||
|
one["원식글"] = to_name(str(one.get("식")), base)
|
||||||
|
one["식글"] = to_name(str(eff[part][at].get(FIELD[part])), base)
|
||||||
|
one["식덮개"] = spot in covers
|
||||||
|
hit = _ADD.match(spot)
|
||||||
|
adds = fixed.get("더한줄") or []
|
||||||
|
if hit and int(hit[1]) < len(adds) and adds[int(hit[1])].get("수량식"):
|
||||||
|
one["식"] = adds[int(hit[1])]["수량식"]
|
||||||
|
one["식글"] = to_name(one["식"], base)
|
||||||
|
if spot in wrong:
|
||||||
|
one["식틀림"] = wrong[spot]
|
||||||
|
good = {x["이름"]: x["식"] for x in fixed.get("변수줄") or []}
|
||||||
|
for line in clean.get("변수줄") or []:
|
||||||
|
spot, text = f"mid:{line['이름']}", good.get(line["이름"], line["식"])
|
||||||
|
spots[spot] = {
|
||||||
|
"갈래": "변수",
|
||||||
|
"이름": line["이름"],
|
||||||
|
"식": text,
|
||||||
|
"식글": to_name(text, base),
|
||||||
|
"표시": line.get("표시"),
|
||||||
|
"자리": line["자리"],
|
||||||
|
"원래": None,
|
||||||
|
"지금": mids.get(line["이름"]) if line["이름"] in good else None,
|
||||||
|
"덮개": [],
|
||||||
|
"까닭": why.get(spot),
|
||||||
|
"M01바뀜": False,
|
||||||
|
**({"식틀림": wrong[spot]} if spot in wrong else {}),
|
||||||
|
}
|
||||||
|
return bad
|
||||||
|
|
||||||
|
|
||||||
|
def name_table(base: dict, clean: dict) -> list[dict]:
|
||||||
|
"""식 칸에서 부를 이름 — 입력 · 중간 · 변수 · 덧줄 · 비목 · 별칭 · 자원 · 번지 · 단계."""
|
||||||
|
mf = _eng()
|
||||||
|
out = [{"글": str(x.get("이름")), "갈래": "입력"} for x in base.get("입력") or []]
|
||||||
|
out += [{"글": str(x.get("이름")), "갈래": "중간"} for x in base.get("중간") or []]
|
||||||
|
out += [{"글": x["이름"], "갈래": "변수"} for x in clean.get("변수줄") or []]
|
||||||
|
out += [{"글": str(x.get("이름")), "갈래": "덧줄"} for x in base.get("덧줄") or []]
|
||||||
|
out += [{"글": k, "갈래": "비목"} for k in mf.COST_ITEMS]
|
||||||
|
out += [{"글": f"[{k}]", "갈래": "별칭"} for k in aliases(base)]
|
||||||
|
label_of, seen = _labels(_whole())[0], set()
|
||||||
|
for part in FIELD:
|
||||||
|
for x in base.get(part) or []:
|
||||||
|
texts = [
|
||||||
|
str(x.get(FIELD[part])),
|
||||||
|
*([x["요소"]] if isinstance(x.get("요소"), str) else []),
|
||||||
|
]
|
||||||
|
for key in (k for t in texts for k in mf.mk.KEY.findall(t)):
|
||||||
|
if key in label_of and key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
out.append({"글": f"[{label_of[key]}]", "갈래": "자원"})
|
||||||
|
lines = [*(base.get("호표") or []), *(base.get("덧줄") or [])]
|
||||||
|
out += [{"글": f"번지.{x['번지']}", "갈래": "번지"} for x in lines if x.get("번지")]
|
||||||
|
out += [{"글": f"단계.'{x.get('이름')}'", "갈래": "단계"} for x in base.get("단계") or []]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── 검사 길(시트가 칠 때마다 · 셈 없음) ──────────────────────────────
|
||||||
|
def check_doc(doc: Any, spot: str, text: str) -> dict[str, Any]:
|
||||||
|
"""식 글 하나 → {되나, 까닭, 알림, 식(엔진), 글(이름 꼴)} — 자리 = 중간 `mid:이름`(변수 줄
|
||||||
|
포함) · 호표 · 덧줄 cid · `add:#k` · 문서 = 화면 캐시(그 자리 옛 식은 빼고 봄)."""
|
||||||
|
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||||
|
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||||
|
|
||||||
|
text = str(text or "").strip()
|
||||||
|
out: dict[str, Any] = {"되나": False, "까닭": [], "알림": [], "식": None, "글": None}
|
||||||
|
if not text:
|
||||||
|
return out | {"까닭": ["식이 빔"]}
|
||||||
|
if bad := limits(text):
|
||||||
|
return out | {"까닭": bad}
|
||||||
|
whole, doc = _whole(), _without(doc if isinstance(doc, dict) else {}, spot)
|
||||||
|
try:
|
||||||
|
clean_ = sd.clean(doc, None, whole)
|
||||||
|
base = calc._base_of(clean_, whole, refs.read_ref, ())["줄"]
|
||||||
|
except sd.DocError as e:
|
||||||
|
return out | {"까닭": [f"문서 · {e}"]}
|
||||||
|
got, why = to_engine(text, base)
|
||||||
|
if why:
|
||||||
|
return out | {"까닭": why}
|
||||||
|
out |= {"식": got, "글": to_name(got, base)}
|
||||||
|
trial, why = _put(clean_, base, spot, got)
|
||||||
|
if why:
|
||||||
|
return out | {"까닭": [why]}
|
||||||
|
bad = [b["까닭"].removeprefix("식 — ") for b in settle(base, trial)[1] if b["cid"] == spot]
|
||||||
|
return out | {"되나": not bad, "까닭": bad, "알림": _risks(got, base, trial)}
|
||||||
|
|
||||||
|
|
||||||
|
def _without(doc: dict, spot: str) -> dict:
|
||||||
|
"""그 자리 옛 식을 뺀 문서(옛 식이 틀려도 문서 검사가 안 막게)."""
|
||||||
|
out = dict(doc)
|
||||||
|
if isinstance(out.get("식덮개"), dict):
|
||||||
|
out["식덮개"] = {k: v for k, v in out["식덮개"].items() if k != spot}
|
||||||
|
if isinstance(out.get("변수줄"), list):
|
||||||
|
out["변수줄"] = [
|
||||||
|
{**x, "식": "0"} if isinstance(x, dict) and f"mid:{x.get('이름')}" == spot else x
|
||||||
|
for x in out["변수줄"]
|
||||||
|
]
|
||||||
|
hit = _ADD.match(spot)
|
||||||
|
if hit and isinstance(out.get("더한줄"), list) and int(hit[1]) < len(out["더한줄"]):
|
||||||
|
adds = list(out["더한줄"])
|
||||||
|
adds[int(hit[1])] = {k: v for k, v in dict(adds[int(hit[1])]).items() if k != "수량식"}
|
||||||
|
out["더한줄"] = adds
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _put(doc: dict, base: dict, spot: str, text: str) -> tuple[dict, str | None]:
|
||||||
|
"""문서 그 자리에 식(엔진) — (새 문서, 못 넣는 까닭)."""
|
||||||
|
names = [x["이름"] for x in doc.get("변수줄") or []]
|
||||||
|
hit = _ADD.match(spot)
|
||||||
|
if spot.startswith("mid:") and spot[4:] in names:
|
||||||
|
lines = [{**x, "식": text} if x["이름"] == spot[4:] else x for x in doc["변수줄"]]
|
||||||
|
return {**doc, "변수줄": lines}, None
|
||||||
|
if spot in _places(base):
|
||||||
|
if spot in (doc.get("상수덮개") or {}) or spot in (doc.get("표값덮개") or {}):
|
||||||
|
return doc, "상수 · 표값 덮개가 있는 줄 — [M01 원래로] 뒤 식 고침"
|
||||||
|
return {**doc, "식덮개": {**(doc.get("식덮개") or {}), spot: text}}, None
|
||||||
|
adds = doc.get("더한줄") or []
|
||||||
|
if hit and int(hit[1]) < len(adds):
|
||||||
|
one = adds[int(hit[1])]
|
||||||
|
if sd.is_rate(one) or one.get("종류") == "로직":
|
||||||
|
return doc, "율 줄 · 로직 줄 수량은 수만"
|
||||||
|
adds = [{**x, "수량식": text} if k == int(hit[1]) else x for k, x in enumerate(adds)]
|
||||||
|
return {**doc, "더한줄": adds}, None
|
||||||
|
return doc, f"없는 자리 「{spot}」"
|
||||||
|
|
||||||
|
|
||||||
|
def _risks(text: str, base: dict, doc: dict) -> list[str]:
|
||||||
|
"""0 나눔 위험 — 나누는 쪽 이름이 입력값 0 · 고르기에 0."""
|
||||||
|
mf, out = _eng(), []
|
||||||
|
given = doc.get("입력값") or {}
|
||||||
|
try:
|
||||||
|
tree = mf.parse(text)
|
||||||
|
except (mf.FormulaError, RecursionError):
|
||||||
|
return out
|
||||||
|
picks = {str(x.get("이름")): x.get("고르기") or [] for x in base.get("입력") or []}
|
||||||
|
for node in mf.walk(tree):
|
||||||
|
if node[0] == "bin" and node[1] == "/":
|
||||||
|
for n in (x[1] for x in mf.walk(node[3]) if x[0] == "name"):
|
||||||
|
zero = str(given.get(n)) in ("0", "0.0") or any(
|
||||||
|
str(c) == "0" for c in picks.get(n, [])
|
||||||
|
)
|
||||||
|
if zero and f"「{n}」" not in " ".join(out):
|
||||||
|
out.append(f"「{n}」 가 0 이면 0 나눔")
|
||||||
|
return out
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc` ·
|
"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc` ·
|
||||||
[단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한).
|
[단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한) ·
|
||||||
|
식 칸 검사(792-1) `POST /api/m02/layers/{층}/sangeun/formula-check`(셈 없음 · 층 읽기 권한).
|
||||||
|
|
||||||
저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만.
|
저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만.
|
||||||
몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값) ·
|
몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값) ·
|
||||||
@@ -19,6 +20,7 @@ from common_util.common_util_auth import verify_session
|
|||||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||||
|
from M02_MasterTemplete import M02_Sangeun_Formula as fx
|
||||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
|
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Sangeun"])
|
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Sangeun"])
|
||||||
@@ -71,3 +73,20 @@ def calc_sangeun(
|
|||||||
if body.문서 is None and body.키 is None:
|
if body.문서 is None and body.키 is None:
|
||||||
raise HTTPException(status_code=400, detail="문서 또는 키")
|
raise HTTPException(status_code=400, detail="문서 또는 키")
|
||||||
return _call(_root(session, layer, owner), _calc, body)
|
return _call(_root(session, layer, owner), _calc, body)
|
||||||
|
|
||||||
|
|
||||||
|
class FormulaCheckBody(BaseModel):
|
||||||
|
문서: dict[str, Any]
|
||||||
|
자리: str
|
||||||
|
식: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/layers/{layer}/sangeun/formula-check")
|
||||||
|
def check_formula(
|
||||||
|
layer: Layer,
|
||||||
|
body: FormulaCheckBody,
|
||||||
|
owner: str | None = None,
|
||||||
|
session: Session = Depends(verify_session),
|
||||||
|
) -> dict:
|
||||||
|
"""식 칸 검사(792-1) — {되나, 까닭, 알림, 식(엔진), 글(이름 꼴)} · 셈 · 저장 없음."""
|
||||||
|
return _call(_root(session, layer, owner), fx.check_doc, body.문서, body.자리, body.식)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface ConstDoc {
|
|||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CoverKind = "품" | "단가" | "상수" | "표값" | "뺌";
|
export type CoverKind = "품" | "단가" | "상수" | "표값" | "식" | "뺌";
|
||||||
|
|
||||||
const ADD = /^add:#(\d+)(\/.*)?$/;
|
const ADD = /^add:#(\d+)(\/.*)?$/;
|
||||||
const PARTS = ["labor", "material", "expense"];
|
const PARTS = ["labor", "material", "expense"];
|
||||||
@@ -120,6 +120,7 @@ export function restoreM01<T extends ConstDoc>(doc: T, key?: string): T {
|
|||||||
let out: ConstDoc = { ...doc };
|
let out: ConstDoc = { ...doc };
|
||||||
delete out.상수덮개;
|
delete out.상수덮개;
|
||||||
delete out.표값덮개;
|
delete out.표값덮개;
|
||||||
|
delete out.식덮개; // 식 칸(792-1)
|
||||||
delete out.까닭;
|
delete out.까닭;
|
||||||
const why = { ...(doc.까닭 ?? {}) };
|
const why = { ...(doc.까닭 ?? {}) };
|
||||||
if (key == null) {
|
if (key == null) {
|
||||||
@@ -149,8 +150,9 @@ export function restoreM01<T extends ConstDoc>(doc: T, key?: string): T {
|
|||||||
} else {
|
} else {
|
||||||
out.덮개 = Object.fromEntries(Object.entries(doc.덮개 ?? {}).filter(([c]) => c !== key));
|
out.덮개 = Object.fromEntries(Object.entries(doc.덮개 ?? {}).filter(([c]) => c !== key));
|
||||||
out.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== key);
|
out.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== key);
|
||||||
for (const name of ["상수덮개", "표값덮개"] as const) {
|
for (const name of ["상수덮개", "표값덮개", "식덮개"] as const) {
|
||||||
const got = Object.fromEntries(Object.entries(doc[name] ?? {}).filter(([s]) => s !== key));
|
const all = (doc[name] ?? {}) as Record<string, unknown>;
|
||||||
|
const got = Object.fromEntries(Object.entries(all).filter(([s]) => s !== key));
|
||||||
out = put(out, name, got);
|
out = put(out, name, got);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,6 +189,7 @@ export function coverKinds(doc: ConstDoc, spot: string): CoverKind[] {
|
|||||||
if (PARTS.some((k) => k in cover)) out.push("단가");
|
if (PARTS.some((k) => k in cover)) out.push("단가");
|
||||||
if (doc.상수덮개?.[spot]) out.push("상수");
|
if (doc.상수덮개?.[spot]) out.push("상수");
|
||||||
if (doc.표값덮개?.[spot]) out.push("표값");
|
if (doc.표값덮개?.[spot]) out.push("표값");
|
||||||
|
if ((doc.식덮개 as Record<string, string> | undefined)?.[spot]) out.push("식");
|
||||||
if ((doc.뺀줄 ?? []).includes(spot)) out.push("뺌");
|
if ((doc.뺀줄 ?? []).includes(spot)) out.push("뺌");
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* M02_MasterTemplete/logic/M02_Sangeun_Formula_Model.ts
|
||||||
|
* 단가산출(SK) 사용자 식 칸 캐시 고치기(PLAN 792-1 · 요구서_652 · _분석_650-H3 E4) —
|
||||||
|
* 서버 `M02_MasterTemplete/M02_Sangeun_Formula.py`(꼴 검사 · 번역 · 셈)와 같은 꼴 · 서버 짝 함수 없음
|
||||||
|
* · 시험 `resources/tester/test_792_1_sangeun_formula.py`(여기 결과를 서버 `clean` 이 받음).
|
||||||
|
* 변수줄 [{이름, 식, 표시?, 자리}] 엔진 중간 줄 · 자리 = 그 뒤 중간 `mid:이름`("" = 맨 위)
|
||||||
|
* 식덮개 {자리: 식} 중간 `mid:이름` · 호표 · 덧줄 cid
|
||||||
|
* 더한줄[k].수량식 율 줄 · 로직 줄 빼고
|
||||||
|
* 식 글 = 검사 길(`formula-check`) 답 `식`(엔진) 을 넣음 · 이름 꼴 글을 넣어도 서버가 번역.
|
||||||
|
* 금액은 늘 서버가 셈 — 여기는 문서 칸 고치기만(새 문서를 돌려줌 · 받은 문서 안 바꿈).
|
||||||
|
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import type { ConstDoc } from "./M02_Sangeun_Const_Model";
|
||||||
|
|
||||||
|
export interface VarLine {
|
||||||
|
이름: string;
|
||||||
|
식: string;
|
||||||
|
표시?: number;
|
||||||
|
자리: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormulaDoc extends ConstDoc {
|
||||||
|
변수줄?: VarLine[];
|
||||||
|
식덮개?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADD_ONE = /^add:#(\d+)$/;
|
||||||
|
|
||||||
|
/** 빈 묶음 · 목록 칸은 지움(서버처럼 없으면 안 둠). */
|
||||||
|
function putField<T extends FormulaDoc>(doc: T, name: string, value: unknown): T {
|
||||||
|
const out: FormulaDoc = { ...doc };
|
||||||
|
const empty =
|
||||||
|
value == null ||
|
||||||
|
(Array.isArray(value)
|
||||||
|
? value.length === 0
|
||||||
|
: typeof value === "object" && Object.keys(value as object).length === 0);
|
||||||
|
if (empty) delete out[name];
|
||||||
|
else out[name] = value;
|
||||||
|
return out as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVar = (doc: FormulaDoc, spot: string): boolean =>
|
||||||
|
spot.startsWith("mid:") && (doc.변수줄 ?? []).some((v) => v.이름 === spot.slice(4));
|
||||||
|
|
||||||
|
/** 자리 식 — 변수 줄 = 그 식 · `add:#k` = 수량식 · 그 밖 = 식덮개 · null · "" = 지움(M01 식 · 수). */
|
||||||
|
export function setFormula<T extends FormulaDoc>(doc: T, spot: string, text: string | null): T {
|
||||||
|
const t = (text ?? "").trim();
|
||||||
|
if (isVar(doc, spot)) return editVar(doc, spot.slice(4), { 식: t });
|
||||||
|
const hit = ADD_ONE.exec(spot);
|
||||||
|
if (hit) {
|
||||||
|
const k = Number(hit[1]);
|
||||||
|
const adds = (doc.더한줄 ?? []).map((x, i) => {
|
||||||
|
if (i !== k) return x;
|
||||||
|
const one: Record<string, unknown> = { ...x };
|
||||||
|
if (t) one.수량식 = t;
|
||||||
|
else delete one.수량식;
|
||||||
|
return one;
|
||||||
|
});
|
||||||
|
return { ...doc, 더한줄: adds };
|
||||||
|
}
|
||||||
|
const covers = { ...(doc.식덮개 ?? {}) };
|
||||||
|
if (t) covers[spot] = t;
|
||||||
|
else delete covers[spot];
|
||||||
|
return putField(doc, "식덮개", covers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 변수 줄 넣기 — `at` 차례(없으면 끝) · 같은 이름이 있으면 그대로. */
|
||||||
|
export function addVar<T extends FormulaDoc>(doc: T, line: VarLine, at?: number): T {
|
||||||
|
const lines = [...(doc.변수줄 ?? [])];
|
||||||
|
if (lines.some((v) => v.이름 === line.이름)) return doc;
|
||||||
|
lines.splice(at ?? lines.length, 0, { ...line });
|
||||||
|
return putField(doc, "변수줄", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 변수 줄 고침 — 이름 바꾸면 까닭 · 글줄 · 다른 변수 자리 열쇠 `mid:이름` 도 따라감(식 속 이름은
|
||||||
|
* 그대로 — 검사 길이 짚음). */
|
||||||
|
export function editVar<T extends FormulaDoc>(doc: T, name: string, patch: Partial<VarLine>): T {
|
||||||
|
const to = patch.이름 ?? name;
|
||||||
|
const lines = (doc.변수줄 ?? []).map((v) => {
|
||||||
|
if (v.이름 === name) {
|
||||||
|
const one: VarLine = { ...v, ...patch };
|
||||||
|
if (patch.표시 == null && "표시" in patch) delete one.표시;
|
||||||
|
return one;
|
||||||
|
}
|
||||||
|
return v.자리 === `mid:${name}` ? { ...v, 자리: `mid:${to}` } : v;
|
||||||
|
});
|
||||||
|
const out = putField(doc, "변수줄", lines);
|
||||||
|
return to === name ? out : moveKey(out, `mid:${name}`, `mid:${to}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 변수 줄 빼기 — 그 뒤에 붙은 변수는 그 자리를 이어받음 · 까닭 지움 · 글줄은 맨 위로. */
|
||||||
|
export function dropVar<T extends FormulaDoc>(doc: T, name: string): T {
|
||||||
|
const gone = (doc.변수줄 ?? []).find((v) => v.이름 === name);
|
||||||
|
if (!gone) return doc;
|
||||||
|
const lines = (doc.변수줄 ?? [])
|
||||||
|
.filter((v) => v.이름 !== name)
|
||||||
|
.map((v) => (v.자리 === `mid:${name}` ? { ...v, 자리: gone.자리 } : v));
|
||||||
|
return moveKey(putField(doc, "변수줄", lines), `mid:${name}`, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 까닭 · 글줄 열쇠 옮김(null = 까닭 지움 · 글줄 맨 위). */
|
||||||
|
function moveKey<T extends FormulaDoc>(doc: T, from: string, to: string | null): T {
|
||||||
|
let out: T = doc;
|
||||||
|
if (doc.까닭 && from in doc.까닭) {
|
||||||
|
const why = { ...doc.까닭 };
|
||||||
|
const text = why[from];
|
||||||
|
delete why[from];
|
||||||
|
if (to) why[to] = text;
|
||||||
|
out = putField(out, "까닭", why);
|
||||||
|
}
|
||||||
|
if (doc.글줄) {
|
||||||
|
out = { ...out, 글줄: doc.글줄.map((t) => (t.자리 === from ? { ...t, 자리: to ?? "" } : t)) };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@ export interface SheetItem {
|
|||||||
input?: LogicInput;
|
input?: LogicInput;
|
||||||
/** M01 과 다름(덮개 · 더한줄) */
|
/** M01 과 다름(덮개 · 더한줄) */
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
|
/** 790-2 자리(바탕 cid · 중간 `mid:이름` · `add:#k`) · 없으면 "" */
|
||||||
|
자리: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SheetBase {
|
export interface SheetBase {
|
||||||
@@ -89,10 +91,12 @@ export function sheetItems(o: {
|
|||||||
ans: SkAnswer | null;
|
ans: SkAnswer | null;
|
||||||
rows: CoverRow[];
|
rows: CoverRow[];
|
||||||
text: TextAnswer | null;
|
text: TextAnswer | null;
|
||||||
|
/** 790-3 자리에 새 덮개(상수 · 표값 · 뺌 …)가 있나 — 캐시 문서 기준 */
|
||||||
|
covered?: (spot: string) => boolean;
|
||||||
}): SheetItem[] {
|
}): SheetItem[] {
|
||||||
const out: SheetItem[] = [];
|
const out: SheetItem[] = [];
|
||||||
const item = (x: Partial<SheetItem> & { kind: SheetKind; 이름: string }): void =>
|
const item = (x: Partial<SheetItem> & { kind: SheetKind; 이름: string }): void =>
|
||||||
void out.push({ 식: "", 값: null, 비목: "", 표: [], changed: false, ...x });
|
void out.push({ 식: "", 값: null, 비목: "", 표: [], changed: false, 자리: "", ...x });
|
||||||
const b = o.base;
|
const b = o.base;
|
||||||
if (b?.출처) item({ kind: "근거", 이름: b.원문번호 || "", 식: b.출처 });
|
if (b?.출처) item({ kind: "근거", 이름: b.원문번호 || "", 식: b.출처 });
|
||||||
for (const line of (b?.비고 ?? "").split(/\r?\n/).filter((x) => x.trim()))
|
for (const line of (b?.비고 ?? "").split(/\r?\n/).filter((x) => x.trim()))
|
||||||
@@ -107,9 +111,11 @@ export function sheetItems(o: {
|
|||||||
const raw = String(f.식 ?? "").trim();
|
const raw = String(f.식 ?? "").trim();
|
||||||
const v = mid[f.이름];
|
const v = mid[f.이름];
|
||||||
const 값 = typeof v === "number" || typeof v === "string" ? v : null;
|
const 값 = typeof v === "number" || typeof v === "string" ? v : null;
|
||||||
if (NUM_RE.test(raw)) item({ kind: "상수", 이름: f.이름, 값: 값 ?? Number(raw) });
|
const 자리 = `mid:${f.이름}`;
|
||||||
else if (LOOKUP_RE.test(raw)) item({ kind: "표", 이름: f.이름, 값, 표: tablesOf(raw) });
|
const one = { 이름: f.이름, 값, 자리, changed: !!o.covered?.(자리) };
|
||||||
else item({ kind: "계산", 이름: f.이름, 식: readable(raw, aliases), 값, 표: tablesOf(raw) });
|
if (NUM_RE.test(raw)) item({ ...one, kind: "상수", 값: 값 ?? Number(raw) });
|
||||||
|
else if (LOOKUP_RE.test(raw)) item({ ...one, kind: "표", 표: tablesOf(raw) });
|
||||||
|
else item({ ...one, kind: "계산", 식: readable(raw, aliases), 표: tablesOf(raw) });
|
||||||
}
|
}
|
||||||
const lines = textLines(o.text);
|
const lines = textLines(o.text);
|
||||||
for (const pct of [false, true])
|
for (const pct of [false, true])
|
||||||
@@ -124,7 +130,8 @@ export function sheetItems(o: {
|
|||||||
비목: partTag(r),
|
비목: partTag(r),
|
||||||
표: tablesOf(line?.이름글 ?? ""),
|
표: tablesOf(line?.이름글 ?? ""),
|
||||||
row: r,
|
row: r,
|
||||||
changed: r.covered.qty || r.covered.price || r.added !== null,
|
changed: r.covered.qty || r.covered.price || r.added !== null || !!o.covered?.(r.cid),
|
||||||
|
자리: r.cid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const sums = o.ans?.기준 ?? null;
|
const sums = o.ans?.기준 ?? null;
|
||||||
@@ -139,19 +146,28 @@ export const num = (v: number | null | undefined): string => (v == null ? "—"
|
|||||||
|
|
||||||
/** 산출서 한 줄 글 — 경쟁사 꼴 「형틀목공 : 275,790 × 5 / 45 = 30,643.3」 · 소계 · 계 */
|
/** 산출서 한 줄 글 — 경쟁사 꼴 「형틀목공 : 275,790 × 5 / 45 = 30,643.3」 · 소계 · 계 */
|
||||||
export interface PaperLine {
|
export interface PaperLine {
|
||||||
kind: "줄" | "소계" | "합계" | "머리";
|
kind: "줄" | "소계" | "합계" | "머리" | "글";
|
||||||
글: string;
|
글: string;
|
||||||
}
|
}
|
||||||
export function paperLines(
|
export function paperLines(
|
||||||
text: TextAnswer | null,
|
text: TextAnswer | null,
|
||||||
rows: CoverRow[],
|
rows: CoverRow[],
|
||||||
ans: SkAnswer | null,
|
ans: SkAnswer | null,
|
||||||
|
notes: NoteLine[] = [],
|
||||||
): PaperLine[] {
|
): PaperLine[] {
|
||||||
if (!ans) return [];
|
if (!ans) return [];
|
||||||
const out: PaperLine[] = [];
|
const out: PaperLine[] = [];
|
||||||
const by = new Map(rows.map((r) => [r.cid, r]));
|
const by = new Map(rows.map((r) => [r.cid, r]));
|
||||||
const sums = ans.기준 ?? {};
|
const sums = ans.기준 ?? {};
|
||||||
const groups = text?.ok ? text.groups : [];
|
const groups = text?.ok ? text.groups : [];
|
||||||
|
const shown = new Set(groups.flatMap((g) => g.줄.flatMap((l) => (l.옵션꺼짐 ? [] : [l.cid]))));
|
||||||
|
const note = (spot: string): PaperLine[] =>
|
||||||
|
notes.filter((x) => x.글 && x.자리 === spot).map((x) => ({ kind: "글", 글: x.글 }));
|
||||||
|
out.push(
|
||||||
|
...notes
|
||||||
|
.filter((x) => x.글 && !shown.has(x.자리))
|
||||||
|
.map((x) => ({ kind: "글" as const, 글: x.글 })),
|
||||||
|
);
|
||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
out.push({ kind: "머리", 글: g.비목 });
|
out.push({ kind: "머리", 글: g.비목 });
|
||||||
for (const l of g.줄) {
|
for (const l of g.줄) {
|
||||||
@@ -162,7 +178,7 @@ export function paperLines(
|
|||||||
: r
|
: r
|
||||||
? `${r.pct ? `${num(r.수량)} %` : `${num(r.단가)} × ${num(r.수량)}`} = ${num(r.금액)}`
|
? `${r.pct ? `${num(r.수량)} %` : `${num(r.단가)} × ${num(r.수량)}`} = ${num(r.금액)}`
|
||||||
: num(l.금액);
|
: num(l.금액);
|
||||||
out.push({ kind: "줄", 글: `${l.이름} : ${body}` });
|
out.push({ kind: "줄", 글: `${l.이름} : ${body}` }, ...(l.cid ? note(l.cid) : []));
|
||||||
}
|
}
|
||||||
const sub = sums[g.비목] ?? g.소계;
|
const sub = sums[g.비목] ?? g.소계;
|
||||||
out.push({ kind: "소계", 글: `${g.비목} 소계 = ${num(sub)}${g.끝수 ? ` (${g.끝수})` : ""}` });
|
out.push({ kind: "소계", 글: `${g.비목} 소계 = ${num(sub)}${g.끝수 ? ` (${g.끝수})` : ""}` });
|
||||||
@@ -195,3 +211,112 @@ export const diffText = (now: number | null, was: number | null): string =>
|
|||||||
now == null || was == null || now === was
|
now == null || was == null || now === was
|
||||||
? ""
|
? ""
|
||||||
: `${now > was ? "+" : "−"}${NUM.format(Math.abs(now - was))}`;
|
: `${now > was ? "+" : "−"}${NUM.format(Math.abs(now - was))}`;
|
||||||
|
|
||||||
|
// ── 790-3 숫자 넓힘 화면 이음(790-2 셈 답 `시트` · 문서 `글줄`) ──────────────
|
||||||
|
|
||||||
|
/** 글 줄(셈 없음) — 자리 뒤 · "" = 맨 위 */
|
||||||
|
export interface NoteLine {
|
||||||
|
자리: string;
|
||||||
|
글: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 셈 답 `시트.자리[자리]`(790-2) */
|
||||||
|
export interface SpotInfo {
|
||||||
|
갈래: string;
|
||||||
|
이름: string;
|
||||||
|
식?: string;
|
||||||
|
상수?: { n: number; 값: number | null; 원값: number | null }[];
|
||||||
|
표값?: { n: number; 글: string; 값: unknown; 원값: unknown }[];
|
||||||
|
원래?: number | null;
|
||||||
|
지금?: unknown;
|
||||||
|
덮개?: string[];
|
||||||
|
까닭?: string | null;
|
||||||
|
M01바뀜?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SheetSpots {
|
||||||
|
자리: Record<string, SpotInfo>;
|
||||||
|
글줄: NoteLine[];
|
||||||
|
짝잃음: { cid: string; 까닭: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const spotsOf = (ans: SkAnswer | null): SheetSpots | null =>
|
||||||
|
((ans as { 시트?: SheetSpots } | null)?.시트 ?? null) || null;
|
||||||
|
|
||||||
|
export type FormulaPart =
|
||||||
|
{ t: "글"; 글: string } | { t: "수"; n: number } | { t: "표"; n: number; 표: string[] };
|
||||||
|
|
||||||
|
const IDENT = /^[\p{L}_][\p{L}\p{N}_]*/u;
|
||||||
|
const DIGITS = /^\d+(\.\d+)?/;
|
||||||
|
|
||||||
|
/** 식 글 → 숫자 자리(왼쪽부터 · 따옴표 글 · 이름 안 숫자 뺌) — 서버 `scan` 숫자 차례와 같음 */
|
||||||
|
export function numSpots(s: string): [number, number][] {
|
||||||
|
const out: [number, number][] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < s.length) {
|
||||||
|
const c = s[i];
|
||||||
|
if (c === "'" || c === '"') {
|
||||||
|
const j = s.indexOf(c, i + 1);
|
||||||
|
i = j < 0 ? s.length : j + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rest = s.slice(i);
|
||||||
|
const id = IDENT.exec(rest);
|
||||||
|
const d = id ? null : DIGITS.exec(rest);
|
||||||
|
if (d) out.push([i, i + d[0].length]);
|
||||||
|
i += id ? id[0].length : d ? d[0].length : 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 엔진 식을 조각으로 — 글 · 숫자(n) · 표 찾기(n) · 서버 차례와 안 맞으면 null(숫자 칸 안 그림) */
|
||||||
|
export function formulaParts(info: SpotInfo | undefined): FormulaPart[] | null {
|
||||||
|
const s = info?.식 ?? "";
|
||||||
|
const nums = info?.상수 ?? [];
|
||||||
|
const finds = info?.표값 ?? [];
|
||||||
|
if (!s || (!nums.length && !finds.length)) return null;
|
||||||
|
const at = numSpots(s);
|
||||||
|
if (at.length !== nums.length) return null;
|
||||||
|
if (at.some(([a, b], i) => Number(s.slice(a, b)) !== Number(nums[i].원값))) return null;
|
||||||
|
const cuts: { a: number; b: number; p: FormulaPart }[] = [];
|
||||||
|
let from = 0;
|
||||||
|
for (const f of finds) {
|
||||||
|
const a = s.indexOf(f.글, from);
|
||||||
|
if (a < 0) return null;
|
||||||
|
from = a + f.글.length;
|
||||||
|
cuts.push({ a, b: from, p: { t: "표", n: f.n, 표: tablesOf(f.글) } });
|
||||||
|
}
|
||||||
|
at.forEach(([a, b], n) => {
|
||||||
|
if (!cuts.some((c) => c.a <= a && b <= c.b)) cuts.push({ a, b, p: { t: "수", n } });
|
||||||
|
});
|
||||||
|
cuts.sort((x, y) => x.a - y.a);
|
||||||
|
const out: FormulaPart[] = [];
|
||||||
|
const text = (x: string): void => {
|
||||||
|
const 글 = x.replace(/\s*\*\s*/g, " × ");
|
||||||
|
if (글) out.push({ t: "글", 글 });
|
||||||
|
};
|
||||||
|
let pos = 0;
|
||||||
|
for (const c of cuts) {
|
||||||
|
text(s.slice(pos, c.a));
|
||||||
|
out.push(c.p);
|
||||||
|
pos = c.b;
|
||||||
|
}
|
||||||
|
text(s.slice(pos));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 뺀 더한줄 되살림(차례 k 에 다시 끼움) 뒤 — 까닭 · 글줄 열쇠 `add:#j`(j ≥ k) 한 칸 밂 · `dropAddKeys` 거꾸로 */
|
||||||
|
export function liftAddKeys<T extends { 까닭?: Record<string, string>; 글줄?: NoteLine[] }>(
|
||||||
|
doc: T,
|
||||||
|
k: number,
|
||||||
|
): T {
|
||||||
|
const move = (key: string): string =>
|
||||||
|
key.replace(/^add:#(\d+)/, (all, j: string) =>
|
||||||
|
Number(j) >= k ? `add:#${Number(j) + 1}` : all,
|
||||||
|
);
|
||||||
|
const out = { ...doc };
|
||||||
|
if (doc.까닭)
|
||||||
|
out.까닭 = Object.fromEntries(Object.entries(doc.까닭).map(([x, v]) => [move(x), v]));
|
||||||
|
if (doc.글줄) out.글줄 = doc.글줄.map((x) => ({ ...x, 자리: move(x.자리) }));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -178,3 +178,97 @@ def test_줄_갈래_읽기_쉬운_식_산출서_상태줄():
|
|||||||
assert got["status"] == {"원래": 51000, "지금": 51419, "전": 50000} # 원래 = 바탕 셈 계
|
assert got["status"] == {"원래": 51000, "지금": 51419, "전": 50000} # 원래 = 바탕 셈 계
|
||||||
assert got["origin"]["원래"] == 49000 # 790-2 `원래` 있으면 그 값
|
assert got["origin"]["원래"] == 49000 # 790-2 `원래` 있으면 그 값
|
||||||
assert got["diff"] == ["+383", "−419", ""]
|
assert got["diff"] == ["+383", "−419", ""]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 790-3 E2 화면 이음(식 속 상수 · 표값 · 글 줄 · 까닭 · M01 원래로 · M01 바뀜) ──────────
|
||||||
|
EDIT = M02 / "M02_MasterTemplete_Sangeun_SheetEdit.ts"
|
||||||
|
|
||||||
|
|
||||||
|
def test_790_3_숫자_넓힘_칸_정적():
|
||||||
|
s = _text(EDIT)
|
||||||
|
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
|
||||||
|
assert "fetch(" not in s and "sessionStorage." not in s and len(s.splitlines()) < 700
|
||||||
|
for call in (
|
||||||
|
"setConst(c.doc, spot, p.n, x)", # 식 속 숫자 = 상수 덮개
|
||||||
|
"setTable(c.doc, spot, p.n, x)", # 표 꼬리표 값 = 표값 덮개
|
||||||
|
'createBadge(t("Cut"), "warning")', # 표 연결 끊김
|
||||||
|
"addText(c.doc, spot,", # 글 줄 더함 · 고침 · 지움
|
||||||
|
"editText(c.doc, i, v)",
|
||||||
|
"dropText(c.doc, i)",
|
||||||
|
"setReason(c.doc, key, x)", # 까닭
|
||||||
|
"restoreM01(c.doc, spot)", # 줄마다 M01 원래로
|
||||||
|
"restoreM01(c.doc)", # 전체
|
||||||
|
'createBadge(t("Changed"), "danger")', # M01 바뀜
|
||||||
|
"c.spots?.짝잃음",
|
||||||
|
):
|
||||||
|
assert call in s, call
|
||||||
|
sheet = _text(SHEET)
|
||||||
|
for call in (
|
||||||
|
"formulaEditor(ctx, it.자리)",
|
||||||
|
"reasonCell(ctx, it.자리, it.changed)",
|
||||||
|
"spotTail(ctx, it.자리,",
|
||||||
|
"restoreAll(ctx)",
|
||||||
|
"lostBanner(ctx)",
|
||||||
|
"removedReasons(ctx, d.removed)",
|
||||||
|
"dropAddKeys(d.doc as ConstDoc, r.added)", # 뺀 더한줄 = 까닭 · 글줄 열쇠 당김
|
||||||
|
"liftAddKeys(d.doc as ConstDoc, at)", # 되살림 = 거꾸로 밂
|
||||||
|
"paperLines(d.text, d.rows, d.ans, (d.doc as ConstDoc).글줄)", # 글 줄 산출서에
|
||||||
|
"spots: spotsOf(d.ans)",
|
||||||
|
):
|
||||||
|
assert call in sheet, call
|
||||||
|
assert "Object.assign(doc, next);" in sheet # 상세가 쥔 같은 문서 객체 · 자동저장 없음
|
||||||
|
assert len(sheet.splitlines()) < 700
|
||||||
|
|
||||||
|
|
||||||
|
SPOT = {
|
||||||
|
"식": "찾기(QC000431, 유형=유형).시공량 * (1 - (높이감률 + 만약(지붕슬래브 == '아님', 0, 17)) / 100)",
|
||||||
|
"상수": [{"n": 0, "값": 1, "원값": 1}, {"n": 1, "값": 0, "원값": 0},
|
||||||
|
{"n": 2, "값": 17, "원값": 17}, {"n": 3, "값": 100, "원값": 100}],
|
||||||
|
"표값": [{"n": 0, "글": "찾기(QC000431, 유형=유형).시공량", "값": 45, "원값": 45}],
|
||||||
|
} # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def _run_e2():
|
||||||
|
src = IMPORTS.sub("", _text(MODEL))
|
||||||
|
bad = {**SPOT, "상수": SPOT["상수"][:3]}
|
||||||
|
body = (
|
||||||
|
f"\nconst S = {json.dumps(SPOT, ensure_ascii=False)};"
|
||||||
|
f"\nconst B = {json.dumps(bad, ensure_ascii=False)};"
|
||||||
|
f"\nconst F = {json.dumps(FIX, ensure_ascii=False)};"
|
||||||
|
"\nconst show = (p) => p && p.map((x) => x.t === '글' ? x.글 : x.t === '수' ? `[#${x.n}]` : `[표${x.n} ${x.표}]`).join('');"
|
||||||
|
"\nconst notes = [{ 자리: '', 글: '맨 위 글' }, { 자리: 'labor:LB000007#0', 글: '형틀 뒤 글' },"
|
||||||
|
" { 자리: 'material:폼타이#0', 글: '꺼진 줄 뒤' }, { 자리: 'labor:LB000007#0', 글: '' }];"
|
||||||
|
"\nconsole.log(JSON.stringify({"
|
||||||
|
"\n parts: show(formulaParts(S)),"
|
||||||
|
"\n bad: formulaParts(B),"
|
||||||
|
"\n none: formulaParts({ 식: '형틀목공', 상수: [], 표값: [] }),"
|
||||||
|
"\n spots: numSpots(\"만약(a == '3회', LB000007 * 2.5, 줄.'합판 12㎜' / 10)\"),"
|
||||||
|
"\n paper: paperLines(F.text, F.rows, F.ans, notes).map((p) => p.kind + ':' + p.글),"
|
||||||
|
"\n lift: liftAddKeys({ 까닭: { 'add:#0': 'a', 'add:#1/x#0': 'b', 'mid:q': 'c' },"
|
||||||
|
" 글줄: [{ 자리: 'add:#2', 글: 'g' }] }, 1),"
|
||||||
|
"\n spotsNone: spotsOf(null),"
|
||||||
|
"\n}));"
|
||||||
|
) # fmt: skip
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "--experimental-strip-types", "--no-warnings",
|
||||||
|
"--input-type=module-typescript", "-"],
|
||||||
|
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||||
|
) # fmt: skip
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_790_3_식_조각_글줄_산출서_열쇠_밂():
|
||||||
|
got = _run_e2()
|
||||||
|
assert got["parts"] == (
|
||||||
|
"[표0 QC000431] × ([#0] - (높이감률 + 만약(지붕슬래브 == '아님', [#1], [#2])) / [#3])"
|
||||||
|
) # 서버 scan 차례 그대로 · 표 찾기 = 한 칸
|
||||||
|
assert got["bad"] is None and got["none"] is None # 차례가 안 맞으면 숫자 칸 안 그림
|
||||||
|
assert len(got["spots"]) == 2 # 2.5 · 10 만 — 따옴표 글(3회 · 12㎜) · 이름(LB000007) 안 숫자 뺌
|
||||||
|
paper = got["paper"]
|
||||||
|
assert paper[0] == "글:맨 위 글" and paper[1] == "글:꺼진 줄 뒤" # 맨 위 · 안 그린 줄 = 위로
|
||||||
|
i = paper.index("줄:형틀목공 : 275,790 × 0.1111 = 30,643.3")
|
||||||
|
assert paper[i + 1] == "글:형틀 뒤 글" and "글:" not in paper[i + 2] # 빈 글 안 찍음
|
||||||
|
assert got["lift"]["까닭"] == {"add:#0": "a", "add:#2/x#0": "b", "mid:q": "c"}
|
||||||
|
assert got["lift"]["글줄"] == [{"자리": "add:#3", "글": "g"}]
|
||||||
|
assert got["spotsNone"] is None
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
|
||||||
|
"""792-1 단가산출(SK) 사용자 식 칸 — 변수 줄 · 식 덮개 · 더한줄 수량식 · 이름 꼴 ↔ 엔진 식 · 검사.
|
||||||
|
|
||||||
|
- 금액 = 서버 셈 = _분석_650-H2 4 장 견본(인력운반 「할증율 20」 변수 줄 + 보통인부 품 식
|
||||||
|
× (1 + 할증율 / 100) = 51,501) · 합판거푸집 줄 식 고침 · 공구손료 식 2 % = 51,802(790-2 같은 값)
|
||||||
|
- 이름 꼴(`×` · `[노임 형틀목공]` · `[별칭]`) ↔ 엔진 식 왕복 = M01 로직 견본 식 전부 같음
|
||||||
|
- 검사 까닭 — 없는 이름 · 괄호 · 순환 · 0 나눔 · 길이 · 괄호 깊이 · 변수 줄 수 · 자리 겹침
|
||||||
|
- 옛 SK(식 칸 없음) = 꼴 · 금액 그대로 · 저장 = 엔진 식 · 틀린 식 저장 400 · 셈 답 덧칸
|
||||||
|
- B09 사본 굳힘(`GX9#####`) 줄 = 식 칸 그대로 · 다시 셈 같은 금액
|
||||||
|
- TS `Formula_Model` · `Const_Model`(식덮개) 결과 = 서버가 받음 · [M01 원래로] 거울
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from M01_MasterData import M01_MasterData_Store as m01
|
||||||
|
from M02_MasterTemplete import M02_Item_Sangeun as sk
|
||||||
|
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||||
|
from M02_MasterTemplete import M02_Library_Flat as flat
|
||||||
|
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||||
|
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||||
|
from M02_MasterTemplete import M02_Sangeun_Const as const
|
||||||
|
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||||
|
from M02_MasterTemplete import M02_Sangeun_Formula as fx
|
||||||
|
from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
FORM, CARRY = "GC000211", "GC001140" # 합판거푸집 · 인력운반(지게)
|
||||||
|
MAN = "labor:LB000002#0" # 인력운반 보통인부
|
||||||
|
TOOL = "pct:공구손료 및 경장비#0"
|
||||||
|
F211 = {"자재지역": "포항", "유형": "보통", "사용횟수": "6회", "높이감률": 0, "지붕슬래브": "아님"}
|
||||||
|
F211 |= {"폼타이": "아님", "폼타이규격": "12.7㎜", "측압": "3t/㎡", "구멍땜": 0}
|
||||||
|
PLUS = "무게 / 운반량 / 일운반회수 × (1 + 할증율 / 100)"
|
||||||
|
|
||||||
|
|
||||||
|
def _whole():
|
||||||
|
return m01.loaded()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _carry():
|
||||||
|
base = json.loads(json.dumps(_whole().logic(CARRY)["시험입력"], default=str))
|
||||||
|
return {**base, "재료종별": "암석", "재료형상": "화강암", "운반회수": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _doc(code, given, **more):
|
||||||
|
return {
|
||||||
|
"키": "SK-000001",
|
||||||
|
"이름": "견본",
|
||||||
|
"단위": "식",
|
||||||
|
"바탕": {"로직": code},
|
||||||
|
"입력값": given,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sk_doc(code, given, **more):
|
||||||
|
return {**_doc(code, given), **more}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(doc):
|
||||||
|
whole = _whole()
|
||||||
|
return calc.run(sd.clean(doc, None, whole), whole)
|
||||||
|
|
||||||
|
|
||||||
|
def _sum(doc):
|
||||||
|
return float(_run(doc)["기준"]["계"])
|
||||||
|
|
||||||
|
|
||||||
|
def _plus(**more):
|
||||||
|
var = [{"이름": "할증율", "식": "20", "자리": ""}]
|
||||||
|
return _sk_doc(CARRY, _carry(), **({"변수줄": var, "식덮개": {MAN: PLUS}} | more))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 견본 금액 ─────────────────────────────────────────────────────────
|
||||||
|
def test_변수_줄_할증율_견본():
|
||||||
|
assert _sum(_doc(CARRY, _carry())) == 42917
|
||||||
|
assert _sum(_plus()) == 51501 # _분석_650-H2 4 장
|
||||||
|
var = [{"이름": "할증율", "식": "만약(운반거리 > 5, 20, 0)", "표시": 0, "자리": "mid:무게"}]
|
||||||
|
assert _sum(_sk_doc(CARRY, _carry(), 변수줄=var, 식덮개={MAN: PLUS})) == 51501
|
||||||
|
eng = PLUS.replace("×", "*")
|
||||||
|
assert _sum(_sk_doc(CARRY, _carry(), 변수줄=var, 식덮개={MAN: eng})) == 51501 # 엔진 꼴도
|
||||||
|
|
||||||
|
|
||||||
|
def test_합판거푸집_줄_식_고침():
|
||||||
|
base = _sum(_doc(FORM, F211))
|
||||||
|
assert base == 51419
|
||||||
|
tool = "(형틀목공 × [노임 형틀목공] + 보통인부 × [노임 보통인부]) × 2 / 100"
|
||||||
|
assert _sum(_sk_doc(FORM, F211, 식덮개={TOOL: tool})) == 51802 # = 790-2 공구손료 2 %
|
||||||
|
more = "[합판거푸집설치및해체일당].형틀목공 × 1.1 / 시공량"
|
||||||
|
got = _run(_sk_doc(FORM, F211, 식덮개={"mid:형틀목공": more}))
|
||||||
|
line = next(x for x in got["줄"] if x["cid"] == "labor:LB000007#0")
|
||||||
|
assert float(line["수량"]) == pytest.approx(5 * 1.1 / 45)
|
||||||
|
assert got["기준"]["계"] > base
|
||||||
|
|
||||||
|
|
||||||
|
def test_더한줄_수량식():
|
||||||
|
add = {
|
||||||
|
"종류": "인력",
|
||||||
|
"요소": "LB000002",
|
||||||
|
"수량": 1,
|
||||||
|
"수량식": "무게 / 운반량 / 일운반회수 / 2",
|
||||||
|
}
|
||||||
|
got = _run(_sk_doc(CARRY, _carry(), 더한줄=[add]))
|
||||||
|
one, mine = (next(x for x in got["줄"] if x["cid"] == c) for c in (MAN, "add:#0"))
|
||||||
|
assert float(mine["수량"]) == pytest.approx(float(one["수량"]) / 2)
|
||||||
|
for bad in ({"수량식": "1", "종류": "로직", "로직": FORM, "수량": 1},):
|
||||||
|
with pytest.raises(sd.DocError, match="수만"):
|
||||||
|
sd.clean(_sk_doc(FORM, F211, 더한줄=[bad]), None, _whole())
|
||||||
|
|
||||||
|
|
||||||
|
# ── 이름 꼴 ↔ 엔진 식 ────────────────────────────────────────────────
|
||||||
|
def test_이름_꼴_양방향():
|
||||||
|
whole = _whole()
|
||||||
|
row = whole.logic(FORM)
|
||||||
|
tool = row["덧줄"][0]["식"]
|
||||||
|
named = fx.to_name(tool, row)
|
||||||
|
assert named == "(형틀목공 × [노임 형틀목공] + 보통인부 × [노임 보통인부]) × 1 / 100"
|
||||||
|
mid = fx.to_name(row["중간"][2]["식"], row)
|
||||||
|
assert mid.startswith("[합판거푸집설치및해체일당].시공량 × (1 - ")
|
||||||
|
assert fx.to_engine("[LB000007] ÷ 2", row) == ("LB000007 / 2", [])
|
||||||
|
assert fx.to_engine("[노임 형틀목공 LB000007] × 2", row)[0] == "LB000007 * 2"
|
||||||
|
assert fx.to_engine("합판거푸집구멍땜.시멘트", row)[0] == "찾기(QC000430).시멘트" # 맨 별칭
|
||||||
|
assert fx.to_engine("'[가]' × 1", row)[0] == "'[가]' * 1" # 따옴표 안 그대로
|
||||||
|
assert fx.to_engine("[없는것] × 1", row)[1] == ["없는 이름 「[없는것]」"]
|
||||||
|
assert fx.to_engine("[노임 × 1", row)[1] == ["대괄호 짝 안 맞음"]
|
||||||
|
for code in (FORM, CARRY, "GC000374", "GC000360"): # 띄어 쓴 자재 이름 · 긴 덧줄
|
||||||
|
one = whole.logic(code)
|
||||||
|
for part, f in (("중간", "식"), ("호표", "수량"), ("덧줄", "식")):
|
||||||
|
for x in one.get(part) or []:
|
||||||
|
assert fx.to_engine(fx.to_name(str(x[f]), one), one) == (str(x[f]), [])
|
||||||
|
|
||||||
|
|
||||||
|
# ── 검사 ─────────────────────────────────────────────────────────────
|
||||||
|
def _check(spot, text, doc=None):
|
||||||
|
return fx.check_doc(doc or _plus(), spot, text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_검사_까닭():
|
||||||
|
ok = _check(MAN, PLUS)
|
||||||
|
assert ok["되나"] and ok["식"] == PLUS.replace("×", "*") and ok["글"] == PLUS
|
||||||
|
assert _check(MAN, "무게 / 없는것")["까닭"] == ["모르는 이름 「없는것」"]
|
||||||
|
assert "「)」 없음" in _check(MAN, "(무게 / 2")["까닭"][0]
|
||||||
|
assert _check("mid:할증율", "할증율 + 1")["까닭"] == ["순환 — 「할증율」 이 자기 자신을 씀"]
|
||||||
|
two = _plus(
|
||||||
|
변수줄=[{"이름": "가", "식": "1", "자리": ""}, {"이름": "나", "식": "가", "자리": ""}]
|
||||||
|
)
|
||||||
|
assert _check("mid:가", "나 + 1", two)["까닭"] == ["순환 — 가 → 나 → 가"]
|
||||||
|
assert _check("mid:할증율", "운반무게 / 0")["까닭"] == ["0 으로 나눔"]
|
||||||
|
assert _check(MAN, "무게 / 운반회수")["알림"] == ["「운반회수」 가 0 이면 0 나눔"]
|
||||||
|
assert _check(MAN, "1" + " + 1" * 80)["까닭"] == [f"식이 {fx.LONG} 자를 넘음"]
|
||||||
|
assert _check(MAN, "(" * 13 + "1" + ")" * 13)["까닭"] == [f"괄호가 {fx.DEEP} 겹을 넘음"]
|
||||||
|
assert _check("add:#3", "1")["까닭"] == ["없는 자리 「add:#3」"]
|
||||||
|
assert _check(MAN, " ")["까닭"] == ["식이 빔"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_꼴_상한_겹침():
|
||||||
|
whole = _whole()
|
||||||
|
many = [{"이름": f"v{i}", "식": "1", "자리": ""} for i in range(fx.MANY + 1)]
|
||||||
|
for bad, why in (
|
||||||
|
({"변수줄": many}, "까지 목록"),
|
||||||
|
({"변수줄": [{"이름": "1가", "식": "1"}]}, "이름 꼴"),
|
||||||
|
({"변수줄": [{"이름": "가", "식": "1"}, {"이름": "가", "식": "2"}]}, "같은 이름"),
|
||||||
|
({"변수줄": [{"이름": "가", "식": "1", "표시": 7}]}, "0~6"),
|
||||||
|
({"식덮개": {"mid:시공량": "1"}, "상수덮개": {"mid:시공량": {"0": 1}}}, "같이 못 둠"),
|
||||||
|
({"식덮개": {"labor:LB000007#0": "1"}, "덮개": {"labor:LB000007#0": {"qty": 1}}}, "qty"),
|
||||||
|
({"식덮개": {"엉뚱": "1"}}, "자리 꼴"),
|
||||||
|
({"식덮개": {"mid:시공량": "(" * 13}}, "괄호"),
|
||||||
|
):
|
||||||
|
with pytest.raises(sd.DocError, match=why):
|
||||||
|
sd.clean(_sk_doc(FORM, F211, **bad), None, whole)
|
||||||
|
|
||||||
|
|
||||||
|
def test_틀린_식은_그_자리만_M01_식():
|
||||||
|
"""셈 길 = 틀린 자리만 M01 식 + 짝잃음 · 이름 겹친 변수 = 뺌."""
|
||||||
|
doc = _sk_doc(
|
||||||
|
CARRY,
|
||||||
|
_carry(),
|
||||||
|
변수줄=[{"이름": "무게", "식": "1", "자리": ""}],
|
||||||
|
식덮개={MAN: "무게 / 없는것", "mid:없는줄": "1"},
|
||||||
|
)
|
||||||
|
got = _run(doc)
|
||||||
|
assert got["기준"]["계"] == 42917
|
||||||
|
why = {(x["cid"], x["까닭"]) for x in got["짝잃음"]}
|
||||||
|
assert (MAN, "식 — 모르는 이름 「없는것」") in why
|
||||||
|
assert ("mid:없는줄", "식 — 바탕에 없는 줄") in why
|
||||||
|
assert any(c == "mid:무게" and "이미 있음" in w for c, w in why)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 옛 SK · 저장 · 셈 답 · 펼침 · 초기화 ─────────────────────────────────
|
||||||
|
def test_옛_SK_그대로():
|
||||||
|
whole = _whole()
|
||||||
|
old = _sk_doc(
|
||||||
|
FORM, F211, 덮개={"labor:LB000007#0": {"qty": 0.2}}, 상수덮개={"mid:시공량": {"2": 20}}
|
||||||
|
)
|
||||||
|
got = sd.clean(old, None, whole)
|
||||||
|
assert not set(got) & set(fx.FIELDS) and not fx.has(got)
|
||||||
|
row = whole.logic(FORM)
|
||||||
|
assert fx.settle(row, got) == (row, [], got)
|
||||||
|
assert _sum(_doc(FORM, F211)) == 51419 and _sum(old) == _sum({**old, "식덮개": {}})
|
||||||
|
|
||||||
|
|
||||||
|
def _sk(folder, **doc):
|
||||||
|
with store.at(folder):
|
||||||
|
made = refs.create("sangeun", {"이름": "내 단가산출", "단위": "식", "더한줄": [], **doc})
|
||||||
|
return made["이름"]
|
||||||
|
|
||||||
|
|
||||||
|
def _read(folder, key):
|
||||||
|
with store.at(folder):
|
||||||
|
return store.read("sangeun", key)["문서"]
|
||||||
|
|
||||||
|
|
||||||
|
def _calc(folder, doc):
|
||||||
|
with store.at(folder):
|
||||||
|
return calc.calc_doc(doc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_저장_엔진_식_셈_답(world):
|
||||||
|
mine = world["personal"]
|
||||||
|
add = {"종류": "인력", "요소": "LB000002", "수량": 1, "수량식": "1 ÷ 일운반회수"}
|
||||||
|
var = [{"이름": "할증율", "식": "20", "자리": ""}]
|
||||||
|
key = _sk(mine, 바탕={"로직": CARRY}, 입력값=_carry(), 변수줄=var, 식덮개={MAN: PLUS})
|
||||||
|
doc = _read(mine, key)
|
||||||
|
assert doc["식덮개"] == {MAN: PLUS.replace("×", "*")} and doc["변수줄"] == var
|
||||||
|
got = _calc(mine, doc)
|
||||||
|
assert got["기준"]["계"] == 51501 and got["원래"]["계"] == 42917
|
||||||
|
spot = got["시트"]["자리"][MAN]
|
||||||
|
assert spot["식덮개"] is True and spot["덮개"] == ["식"] and spot["식글"] == PLUS
|
||||||
|
assert spot["원식글"] == "무게 / 운반량 / 일운반회수" and spot["식"] == spot["원식글"]
|
||||||
|
v = got["시트"]["자리"]["mid:할증율"]
|
||||||
|
assert v["갈래"] == "변수" and v["식글"] == "20" and float(v["지금"]) == 20
|
||||||
|
names = {(x["글"], x["갈래"]) for x in got["시트"]["이름표"]}
|
||||||
|
assert {("할증율", "변수"), ("[인력운반기본공식]", "별칭"), ("노무비", "비목")} <= names
|
||||||
|
assert ("[노임 보통인부]", "자원") in names # 호표 요소
|
||||||
|
key2 = _sk(mine, 바탕={"로직": CARRY}, 입력값=_carry(), 더한줄=[add])
|
||||||
|
doc2 = _read(mine, key2)
|
||||||
|
assert doc2["더한줄"][0]["수량식"] == "1 / 일운반회수"
|
||||||
|
assert _calc(mine, doc2)["시트"]["자리"]["add:#0"]["식글"] == "1 / 일운반회수"
|
||||||
|
with store.at(mine), pytest.raises(store.StoreError) as e: # 틀린 식 저장 = 400
|
||||||
|
refs.create("sangeun", {**_plus(식덮개={MAN: "무게 / 없는것"}), "더한줄": []})
|
||||||
|
assert e.value.status == 400 and "모르는 이름" in str(e.value.detail)
|
||||||
|
|
||||||
|
|
||||||
|
def test_펼침_막음_초기화_걷음(world):
|
||||||
|
mine = world["personal"]
|
||||||
|
a = _sk(mine, 바탕={"로직": CARRY}, 입력값=_carry(), 변수줄=[{"이름": "할증율", "식": "20"}])
|
||||||
|
with store.at(mine), pytest.raises(store.StoreError):
|
||||||
|
flat.flat_lines("sangeun", {**MINE, "키": a})
|
||||||
|
doc, n = flat.reset("sangeun", {**_plus(), "까닭": {MAN: "할증"}})
|
||||||
|
assert n == 1 and "식덮개" not in doc and "까닭" not in doc and doc["변수줄"]
|
||||||
|
assert const.restore(_plus(), MAN).get("식덮개") is None
|
||||||
|
assert const.kinds(_plus(), MAN) == ["식"] and const.covers_n(_plus()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── B09 사본 굳힘 ─────────────────────────────────────────────────────
|
||||||
|
def test_B09_굳힘_식_칸_그대로():
|
||||||
|
whole = _whole()
|
||||||
|
add = {"종류": "인력", "요소": "LB000002", "수량": 1, "수량식": "1 / 일운반회수"}
|
||||||
|
doc = sd.clean({**_plus(), "더한줄": [add]}, None, whole)
|
||||||
|
live = calc.run(doc, whole)["기준"]["계"]
|
||||||
|
rid = "personal|7/42|SK-000001"
|
||||||
|
frozen = sk.freeze({rid: doc})[rid]
|
||||||
|
row, key = frozen[sk.ROW], frozen["바탕"]["로직"]
|
||||||
|
assert key.startswith("GX9") and frozen["원본"]["식덮개"] == doc["식덮개"]
|
||||||
|
assert [x["이름"] for x in row["중간"]][0] == "할증율"
|
||||||
|
assert row["호표"][0]["수량"] == PLUS.replace("×", "*")
|
||||||
|
assert row["호표"][1]["수량"] == "1 / 일운반회수"
|
||||||
|
over = calc._Over(whole, key, json.loads(json.dumps(row)))
|
||||||
|
again = calc.run(sd.clean(frozen, None, over), over)["기준"]["계"]
|
||||||
|
assert again == live and float(live) > 51501
|
||||||
|
|
||||||
|
|
||||||
|
# ── TS ───────────────────────────────────────────────────────────────
|
||||||
|
def _ts(body: str):
|
||||||
|
src = "".join(
|
||||||
|
(ROOT / f"M02_MasterTemplete/logic/M02_Sangeun_{n}_Model.ts").read_text("utf-8")
|
||||||
|
for n in ("Const", "Formula")
|
||||||
|
)
|
||||||
|
src = "\n".join(x for x in src.splitlines() if not x.startswith("import type"))
|
||||||
|
got = subprocess.run(
|
||||||
|
["node", "--experimental-strip-types", "--no-warnings",
|
||||||
|
"--input-type=module-typescript", "-e", src + body],
|
||||||
|
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||||
|
) # fmt: skip
|
||||||
|
assert got.returncode == 0, got.stderr
|
||||||
|
return json.loads(got.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_TS_고치기_꼴_서버가_받음():
|
||||||
|
ts = _ts(
|
||||||
|
f"\nlet d = {json.dumps(_doc(CARRY, _carry()), ensure_ascii=False)};"
|
||||||
|
"\nd = {...d, 더한줄: [{종류: '인력', 요소: 'LB000002', 수량: 1}]};"
|
||||||
|
"\nd = addVar(d, {이름: '할증', 식: '10', 자리: ''});"
|
||||||
|
"\nd = addVar(d, {이름: '덤', 식: '0', 자리: 'mid:할증'});"
|
||||||
|
"\nd = editVar(d, '할증', {이름: '할증율', 식: '20'});"
|
||||||
|
"\nd = setFormula(d, 'mid:할증율', '20'); d = dropVar(d, '덤');"
|
||||||
|
f"\nd = setFormula(d, '{MAN}', '{PLUS}'); d = setFormula(d, 'mid:운반량', '25');"
|
||||||
|
"\nd = setFormula(d, 'mid:운반량', null); d = setFormula(d, 'add:#0', '1 / 일운반회수');"
|
||||||
|
"\nd = setReason(d, 'mid:할증율', '할증');"
|
||||||
|
f"\nconst out = {{d, r: restoreM01(d, '{MAN}'), k: coverKinds(d, '{MAN}')}};"
|
||||||
|
"\nconsole.log(JSON.stringify(out));"
|
||||||
|
)
|
||||||
|
d = ts["d"]
|
||||||
|
assert d["변수줄"] == [{"이름": "할증율", "식": "20", "자리": ""}]
|
||||||
|
assert d["식덮개"] == {MAN: PLUS} and d["까닭"] == {"mid:할증율": "할증"}
|
||||||
|
got = sd.clean(d, None, _whole())
|
||||||
|
assert {k: got[k] for k in fx.FIELDS} == {k: d[k] for k in fx.FIELDS}
|
||||||
|
assert got["더한줄"][0]["수량식"] == "1 / 일운반회수"
|
||||||
|
assert _sum(d) > 51501
|
||||||
|
assert ts["r"] == const.restore(d, MAN) and ts["k"] == const.kinds(d, MAN) == ["식"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_같은_내용_지문_식_칸_가림():
|
||||||
|
"""식 칸만 다른 SK = 다른 내용(409 같은 것 물음 안 함) · 옛 SK 지문 그대로."""
|
||||||
|
from M02_MasterTemplete import M02_Library_Same as same
|
||||||
|
|
||||||
|
plain = _doc(CARRY, _carry())
|
||||||
|
one, two = same.finger("sangeun", plain), same.finger("sangeun", _plus())
|
||||||
|
assert one and two and one != two
|
||||||
|
assert same.finger("sangeun", {**plain, "식덮개": {}, "변수줄": []}) == one
|
||||||
Reference in New Issue
Block a user