Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into sub_laptop_2
This commit is contained in:
@@ -59,6 +59,8 @@ interface Opened extends Draft {
|
||||
reasons: string[];
|
||||
lines: CalcLine[] | null;
|
||||
text: TextAnswer | null;
|
||||
/** 마지막으로 잘 받은 읽는 식 — 멈춘 뒤에도 수량 칸 조각을 이걸로 */
|
||||
lastText?: TextAnswer;
|
||||
ownHost: HTMLElement;
|
||||
values: Record<string, string>;
|
||||
}
|
||||
@@ -170,6 +172,7 @@ export async function mountM01LogicLab(
|
||||
values: current.values,
|
||||
onText: (text) => {
|
||||
current.text = text;
|
||||
if (text.ok) current.lastText = text;
|
||||
drawEditor(); // [계산] 전 미리보기 응답도 왼쪽 이름 식 칸에 바로 비치게 — onLines 와 같은 자리
|
||||
},
|
||||
onLines: (lines) => {
|
||||
@@ -202,6 +205,7 @@ export async function mountM01LogicLab(
|
||||
prices: current.prices,
|
||||
lines: current.lines,
|
||||
text: current.text,
|
||||
lastText: current.lastText,
|
||||
values: current.values,
|
||||
ownHost: current.ownHost,
|
||||
onChange: touch,
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
import { buildEditor, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
|
||||
import {
|
||||
condText,
|
||||
@@ -64,6 +64,8 @@ export interface DetailContext {
|
||||
lines: CalcLine[] | null;
|
||||
/** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */
|
||||
text: TextAnswer | null;
|
||||
/** 마지막으로 잘 받은 읽는 식 — `text` 가 멈춤이면 조각을 이걸로 */
|
||||
lastText?: TextAnswer;
|
||||
values: Record<string, string>;
|
||||
/** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */
|
||||
ownHost: HTMLElement;
|
||||
@@ -166,7 +168,7 @@ function infoBox(ctx: DetailContext): HTMLElement {
|
||||
className: "m01-logic__reasons",
|
||||
children: [
|
||||
el("strong", { text: tx("Head_Blocked") }),
|
||||
...ctx.reasons.map((r) => el("div", { text: r })),
|
||||
...ctx.reasons.map((r) => el("div", { text: safeReason(r) })),
|
||||
],
|
||||
}),
|
||||
]
|
||||
@@ -287,14 +289,18 @@ function entries(ctx: DetailContext): Entry[] {
|
||||
* 이름을 차례로 따라가며 줄 차례에 맞춤 · 금액 0 로직 부름 줄처럼 묶음이 달라도 찾음 ·
|
||||
* 끝내 못 찾으면 날식 대신 빈칸(계산 전 · 서버 실패면 식 글자 그대로) */
|
||||
function attachPieces(all: Entry[], ctx: DetailContext): void {
|
||||
if (!ctx.text?.ok) return;
|
||||
const source = ctx.text?.ok ? ctx.text : ctx.lastText;
|
||||
if (!source?.ok) {
|
||||
if (ctx.text) for (const e of all) e.qtyFrag = e.piecesFrag = []; // 멈춤 · 받은 조각 없음 — 날식 대신 빈칸
|
||||
return;
|
||||
}
|
||||
const ho = ctx.row.호표 ?? [];
|
||||
const names = [
|
||||
...ho.map((item) => item.이름 ?? (condOf(item) ? condLabel(condOf(item)!) : item.요소)),
|
||||
...(ctx.row.덧줄 ?? []).map((extra) => extra.이름),
|
||||
];
|
||||
const found = new Map<string, TextLine>();
|
||||
for (const g of ctx.text.groups) {
|
||||
for (const g of source.groups) {
|
||||
let from = 0;
|
||||
for (const line of g.줄) {
|
||||
const at = names.findIndex((n, i) => i >= from && n === line.이름);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { TextAnswer } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import "./M01_MasterData_UI_LogicLab_Formula.css";
|
||||
|
||||
@@ -31,7 +31,10 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-logic__reasons",
|
||||
children: [el("strong", { text: tx("Calc_Stopped") }), el("div", { text: text.reason })],
|
||||
children: [
|
||||
el("strong", { text: tx("Calc_Stopped") }),
|
||||
el("div", { text: safeReason(text.reason) }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
@@ -62,7 +65,7 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
|
||||
? [
|
||||
el("div", { text: `${line.이름} : ${line.글}` }),
|
||||
...(line.까닭
|
||||
? [el("div", { className: "m01-logic__muted", text: line.까닭 })]
|
||||
? [el("div", { className: "m01-logic__muted", text: safeReason(line.까닭) })]
|
||||
: []),
|
||||
]
|
||||
: [el("div", { className: "m01-logic__muted", text: tl("Formula_Slot") })],
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
|
||||
/** 고르기 값이 마스터 키(자재·기계·직종)면 이름으로 바꿔 보임 — 값 칸은 키 그대로 보냄 */
|
||||
const KEY_RE = /^[A-Z]{2}\d{6}$/;
|
||||
@@ -201,7 +201,10 @@ function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLEle
|
||||
...note,
|
||||
el("div", {
|
||||
className: "m01-logic__reasons",
|
||||
children: [el("strong", { text: tx("Calc_Stopped") }), el("div", { text: answer.reason })],
|
||||
children: [
|
||||
el("strong", { text: tx("Calc_Stopped") }),
|
||||
el("div", { text: safeReason(answer.reason) }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -138,6 +138,10 @@ const TEXT = {
|
||||
"「{name}」 규격이 여럿이라 정하지 못함 — 위 「쓰이는 재료·기계」에서 하나를 골라 주세요",
|
||||
"「{name}」 has several specs — pick one in the materials list above",
|
||||
],
|
||||
Stop_Generic: [
|
||||
"이 조건으로는 셈하지 못함 — 입력값 · 표에 그 조합이 있는지 확인",
|
||||
"Cannot be calculated with these inputs — check the values and the table",
|
||||
],
|
||||
Stop_NoPrice: [
|
||||
"「{name}」 단가가 자료에 비어 있어 셈하지 못함 — 자료를 채워야 함",
|
||||
"「{name}」 has no price in the data yet",
|
||||
@@ -158,6 +162,12 @@ export function plainReason(reason: string): string {
|
||||
return reason;
|
||||
}
|
||||
|
||||
/** 화면에 찍는 까닭의 마지막 방어 — 중괄호 · 따옴표 · Decimal( 이 섞인 날글은 사람 말 한 줄로 */
|
||||
export function safeReason(reason: string): string {
|
||||
const plain = plainReason(reason);
|
||||
return /[{}"']|Decimal\(/.test(plain) ? tx("Stop_Generic") : plain;
|
||||
}
|
||||
|
||||
/** 흐름 그림 머리 「이렇게 씀」 — 줄 목록 */
|
||||
|
||||
/** 공용 `t()` 와 같은 뜻 — `{n}`·`{v}` 는 채움 */
|
||||
|
||||
@@ -36,6 +36,22 @@ class EmptyInput(FormulaError):
|
||||
"""값을 안 넣은(null) 입력을 씀 — 그 줄만 비우고 로직은 계속(`run`)."""
|
||||
|
||||
|
||||
class NoRow(EmptyInput):
|
||||
"""표에서 조건에 맞는 줄이 하나가 아님 — 그 찾기를 쓰는 줄만 비움(값이 비어 있는 입력과 같은 길)."""
|
||||
|
||||
|
||||
class Blank:
|
||||
"""못 구한 중간값 — 이 값을 쓰는 줄만 까닭과 함께 비움."""
|
||||
|
||||
def __init__(self, why: str):
|
||||
self.why = why
|
||||
|
||||
|
||||
def _plain(v) -> str:
|
||||
"""사람이 읽는 값 글 — 파이썬 날글(Decimal('1000') · 따옴표) 없이."""
|
||||
return format(v.normalize(), "f") if isinstance(v, Decimal) else str(v)
|
||||
|
||||
|
||||
# ── 읽기 ──────────────────────────────────────────────────────────────
|
||||
class _Parser:
|
||||
def __init__(self, text: str):
|
||||
@@ -299,7 +315,9 @@ def fill(template: str, env: dict) -> str:
|
||||
if m.group(1) not in env:
|
||||
raise FormulaError(f"원문번호의 {{{m.group(1)}}} 값 없음")
|
||||
value = env[m.group(1)]
|
||||
return format(value.normalize(), "f") if isinstance(value, Decimal) else str(value)
|
||||
if isinstance(value, Blank):
|
||||
raise EmptyInput(value.why)
|
||||
return _plain(value)
|
||||
|
||||
return _HOLE.sub(one, template)
|
||||
|
||||
@@ -353,7 +371,13 @@ def find(table: dict, conds: dict, want: str | None = None) -> dict:
|
||||
if len(hits) > 1 and want: # 여러 줄이면 찾는 값 칸이 있는 줄만
|
||||
hits = [row for row in hits if want in row]
|
||||
if len(hits) != 1:
|
||||
raise FormulaError(f"「{_name(table)}」 {conds} 맞는 줄 {len(hits)}개")
|
||||
where = f"표 {table.get('키')}({table.get('원문번호')})"
|
||||
what = " · ".join(f"{c} {_plain(v)}" for c, v in conds.items())
|
||||
raise NoRow(
|
||||
f"{where}에 {what} 인 줄이 없음"
|
||||
if not hits
|
||||
else f"{where}에 {what} 인 줄이 {len(hits)}개"
|
||||
)
|
||||
return hits[0]
|
||||
|
||||
|
||||
@@ -366,6 +390,8 @@ def evaluate(node, env: dict, master: Master, depth: int = 0):
|
||||
raise FormulaError(f"모르는 이름 「{node[1]}」")
|
||||
if env[node[1]] is None:
|
||||
raise EmptyInput(f"입력 「{node[1]}」 비어 있음")
|
||||
if isinstance(env[node[1]], Blank):
|
||||
raise EmptyInput(env[node[1]].why)
|
||||
return env[node[1]]
|
||||
if kind == "ref":
|
||||
value = element(master, fill(node[1], env), env)[0]
|
||||
@@ -489,15 +515,36 @@ def _inputs(row: dict, given: dict) -> dict:
|
||||
return env
|
||||
|
||||
|
||||
def _shown(env: dict) -> dict:
|
||||
"""돌려줄 중간값 — 못 구한 값은 null."""
|
||||
return {k: None if isinstance(v, Blank) else v for k, v in env.items()}
|
||||
|
||||
|
||||
def _blank_line(item: dict, why: str) -> dict:
|
||||
"""비어 있는 입력·못 구한 값을 쓰는 호표 줄 — 단가·금액만 비우고 까닭."""
|
||||
return {
|
||||
"이름": item.get("이름", ""),
|
||||
"단위": item.get("단위", ""),
|
||||
"수량": None,
|
||||
"단가": None,
|
||||
"금액": None,
|
||||
"비목": {item["비목"]: None} if "비목" in item else {},
|
||||
"까닭": why,
|
||||
}
|
||||
|
||||
|
||||
def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
"""로직 한 줄 계산 — 돈 로직은 호표 줄 · 비목 합 · 계, 돈 아닌 로직은 결과 수 하나."""
|
||||
row = master.logic(ref)
|
||||
env = _inputs(row, given)
|
||||
for step in row.get("중간", []):
|
||||
env[step["이름"]] = evaluate(parse(step["식"]), env, master, depth)
|
||||
try:
|
||||
env[step["이름"]] = evaluate(parse(step["식"]), env, master, depth)
|
||||
except EmptyInput as e: # 못 구한 중간값 — 쓰는 줄만 비움
|
||||
env[step["이름"]] = Blank(str(e))
|
||||
if "결과" in row:
|
||||
value = evaluate(parse(row["결과"]["식"]), env, master, depth)
|
||||
return {"결과": cut(row, value, "결과"), "중간": env}
|
||||
return {"결과": cut(row, value, "결과"), "중간": _shown(env)}
|
||||
sums = dict.fromkeys(COST_ITEMS, Decimal(0))
|
||||
lines = []
|
||||
for item in row.get("호표", []):
|
||||
@@ -505,21 +552,14 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
try:
|
||||
qty = evaluate(parse(item["수량"]), env, master, depth)
|
||||
except EmptyInput as e: # 비어 있는 입력을 쓰는 줄만 비움
|
||||
qty, price, why = None, None, str(e)
|
||||
lines.append(
|
||||
{
|
||||
"이름": item.get("이름", ""),
|
||||
"단위": item.get("단위", ""),
|
||||
"수량": None,
|
||||
"단가": None,
|
||||
"금액": None,
|
||||
"비목": {item["비목"]: None} if "비목" in item else {},
|
||||
"까닭": why,
|
||||
}
|
||||
)
|
||||
lines.append(_blank_line(item, str(e)))
|
||||
continue
|
||||
if item["종류"] == "로직":
|
||||
bundle = evaluate(parse(item["요소"]), env, master, depth)
|
||||
try:
|
||||
bundle = evaluate(parse(item["요소"]), env, master, depth)
|
||||
except EmptyInput as e:
|
||||
lines.append(_blank_line(item, str(e)))
|
||||
continue
|
||||
price = bundle["계"]
|
||||
split = (
|
||||
{k: qty * bundle[k] for k in COST_ITEMS}
|
||||
@@ -529,11 +569,15 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
else:
|
||||
# 고르기 조건 묶음은 그대로 넘김 — `element` 가 대표 줄·첫 줄로 풂
|
||||
# 조건 값의 `{입력}`(관경 등)도 채움 — 규격이 입력 따라 바뀌는 관 줄
|
||||
ref = (
|
||||
{k: fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()}
|
||||
if isinstance(item["요소"], dict)
|
||||
else fill(item["요소"], env)
|
||||
)
|
||||
try:
|
||||
ref = (
|
||||
{k: fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()}
|
||||
if isinstance(item["요소"], dict)
|
||||
else fill(item["요소"], env)
|
||||
)
|
||||
except EmptyInput as e:
|
||||
lines.append(_blank_line(item, str(e)))
|
||||
continue
|
||||
# 후보 0 · 값 빈 품목은 그 줄만 단가·금액을 비우고 까닭을 닮 — 로직은 계속 · 합에서 뺌
|
||||
# (없는 키는 틀린 로직 — 지금처럼 멈춤)
|
||||
if isinstance(ref, str) and qty != 0:
|
||||
@@ -589,7 +633,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
}
|
||||
)
|
||||
sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합
|
||||
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": env}
|
||||
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)}
|
||||
|
||||
|
||||
# ── 검사 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -16,7 +16,15 @@ LINKED = "MP" # 품셈재료 테이블ID
|
||||
MARKET = "MT" # 자재품목 테이블ID
|
||||
PRICED = "MO" # 유가전력 테이블ID — 값 한 칸
|
||||
SLOTS = ("물가자료", "유통물가", "물가정보", "거래가격", "관급") # 자재품목 값 열
|
||||
PICK_KEYS = ("구분", "상세구분", "이름", "규격", "대표", "지역", "계약종별") # 재료 고르기 조건 칸 (`_틀.md` 7장)
|
||||
PICK_KEYS = (
|
||||
"구분",
|
||||
"상세구분",
|
||||
"이름",
|
||||
"규격",
|
||||
"대표",
|
||||
"지역",
|
||||
"계약종별",
|
||||
) # 재료 고르기 조건 칸 (`_틀.md` 7장)
|
||||
REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀
|
||||
TARIFF = "전력 계약종별" # 로직 입력 — 품셈·계약예규에 계약종별 규정 없음
|
||||
_UNITS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"}
|
||||
@@ -98,7 +106,9 @@ def _region(master: mf.Master, label_ref, cond: dict, env: dict, unit) -> list[s
|
||||
if not hits:
|
||||
raise mf.FormulaError(f"「{master.label(label_ref)}」 지역 값 없음 — {env.get(REGION)}")
|
||||
if len({s for s, _ in hits}) > 1:
|
||||
raise mf.FormulaError(f"「{master.label(label_ref)}」 후보 여럿 — 규격 선택 필요 ({len(hits)}줄)")
|
||||
raise mf.FormulaError(
|
||||
f"「{master.label(label_ref)}」 후보 여럿 — 규격 선택 필요 ({len(hits)}줄)"
|
||||
)
|
||||
return [key for _, key in hits]
|
||||
|
||||
|
||||
@@ -155,7 +165,8 @@ def pick(master: mf.Master, cond: dict, env: dict, unit: str | None = None) -> s
|
||||
keys = candidates(master, cond, env, unit)
|
||||
keys = [k for k in keys if _offers(master, k)] or keys
|
||||
if not keys:
|
||||
raise mf.FormulaError(f"재료 고르기 후보 없음 — {cond}")
|
||||
what = " · ".join(f"{k} {mf._plain(v)}" for k, v in cond.items())
|
||||
raise mf.FormulaError(f"재료 고르기 후보 없음 — {what}")
|
||||
return keys[0]
|
||||
|
||||
|
||||
|
||||
@@ -124,3 +124,16 @@ def test_빠진_원_입력도_null_처럼_줄만_비움(client) -> None:
|
||||
assert got["ok"] is True, (path, got)
|
||||
lines = client.post("/api/m01/calc", json={"key": "GF000056", "inputs": given}).json()["lines"]
|
||||
assert {o["이름"] for o in lines if o.get("까닭")} == {"체인오일", "체인톱 손료"}
|
||||
|
||||
|
||||
def test_표_찾기_실패는_그_줄만_비우고_까닭은_날글_없이(client) -> None:
|
||||
given = {**_auto(client, "GC001000"), "시공구분": "기계시공"} # 표 1-2-3 에 그 조합 줄이 없음
|
||||
got = client.post("/api/m01/calc", json={"key": "GC001000", "inputs": given}).json()
|
||||
assert got["ok"] is True, got
|
||||
blank = [one for one in got["lines"] if one.get("까닭")]
|
||||
assert blank and all(one["금액"] is None for one in blank)
|
||||
why = blank[0]["까닭"]
|
||||
assert "표 QC001869(1-2-3)에 시공구분 기계시공" in why and "인 줄이 없음" in why
|
||||
assert not any(ch in why for ch in "{}'") and "Decimal" not in why
|
||||
rest = sum(Decimal(str(one["금액"])) for one in got["lines"] if one["금액"] is not None)
|
||||
assert Decimal(str(got["sums"]["계"])) == rest
|
||||
|
||||
Reference in New Issue
Block a user