feat(B09): 단가연결 즉시 금액(뒤에서 서버 셈) · [저장] 단추 · 옮길 때 물음(PLAN 702-3)

- 새 산근(로직 연결 · 입력값) = 연결 즉시 뒤에서 POST …/estimation/calc(화면 캐시로 셈 ·
  저장 안 함) · 차례 번호 · 옛 요청 끊기 · 오는 동안 그 줄 금액 칸만 「셈 중」 · 잠금 없음
  (새 `B09_Estimation_UI_Live.ts`) · 기준 = payload.user(서버가 그 셈에 쓴 값)
- 조작 중 셈 = 마지막 GET · [저장] 의 B08 원천 다시 씀(120 s · 0.6 → 0.15 s) · [저장] · GET 은 늘 새로 읽음
- [저장] 단추 = 왼쪽 아래 [초기화] 옆(B08 과 같은 부품 · 꼴) · 고침 없으면 알림만
- 단계 이동 · [>] = 저장 안 한 고침 있으면 공용 떠남 물음(취소 · 폐기 · 저장) — 옛 자동 저장 없앰
- 서고 사본 payload 의 user 받음(서버 기준) — 화면 캐시와 다른 로직 줄은 뒤에서 다시 셈
- 낡은 시험 고침: 481-2 · 298-2(자동 저장 → 물음 · [저장] 단추) · 184-3 · 304(단추 줄) ·
  204-2(user 받음) · 200-2(701-3 띠 · 702-1 펼침 뒤 셸 꼴)
- 확인 = test_702_3 3 · B09 관련 312 통과 · typecheck 0 · 검증_sub5 T6 11 누름 · 연결 뒤 0.32~0.49 s 금액

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 14:11:44 +09:00
co-authored by Claude Opus 5.5
parent 1599014504
commit 59a694a7ed
16 changed files with 399 additions and 48 deletions
+2 -2
View File
@@ -14,7 +14,7 @@
제목 줄 = 아래 항목 금액 합(엑셀 SUMIF — 빈 줄은 0 이 아니라 「빈 줄 n」 으로 셈) 제목 줄 = 아래 항목 금액 합(엑셀 SUMIF — 빈 줄은 0 이 아니라 「빈 줄 n」 으로 셈)
빈칸 = 수량이나 단가를 낸 원천이 없으면 그 줄 금액 None + 「연결 없음」 목록. 빈칸 = 수량이나 단가를 낸 원천이 없으면 그 줄 금액 None + 「연결 없음」 목록.
단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 · 단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 ·
logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 「[저장] 뒤」) · logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 · 화면은 연결 즉시 뒤에서 서버 셈 702-3) ·
그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴). 그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴).
자유 편집(184) = 기본 로직 표 = 초기값(저장 안 함) · 자유 편집(184) = 기본 로직 표 = 초기값(저장 안 함) ·
사용자 값 `user.bill` v2 = 덮개(계약 — 184-3 화면): 사용자 값 `user.bill` v2 = 덮개(계약 — 184-3 화면):
@@ -149,7 +149,7 @@ def _material_items(inputs: dict[str, Any], compare: list[dict[str, Any]]) -> li
PriceOf = Callable[[str], dict[str, Any] | None] PriceOf = Callable[[str], dict[str, Any] | None]
WHY_HAND = "수기 줄 — 연결 없음(우클릭 [연결])" WHY_HAND = "수기 줄 — 연결 없음(우클릭 [연결])"
WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)" WHY_LOGIC = "새 산근 — 서버 셈 뒤 단가(702-3 뒤에서 셈)"
def ref_price( def ref_price(
+2 -2
View File
@@ -10,7 +10,7 @@
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료 * 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
* 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0) * 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0)
* 묶음 금액 = 자손 항목 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(아는 금액까지 + blank · 701-3 · 관급만 빈 줄 있으면 null) * 묶음 금액 = 자손 항목 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(아는 금액까지 + blank · 701-3 · 관급만 빈 줄 있으면 null)
* 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo) * 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근(702-3 연결 즉시 뒤에서 셈) · 그 밖 hopyo)
* 자유 편집(184) = `user.bill` v2 덮개 {v: 2, rows: [표 차례 · 고친 칸 · 새 줄], gone: [지운 자동 줄]} · * 자유 편집(184) = `user.bill` v2 덮개 {v: 2, rows: [표 차례 · 고친 칸 · 새 줄], gone: [지운 자동 줄]} ·
* 옛 꼴(qty · 손 줄 after · parent) 그대로 읽음 · 저장 꼴 · 출력 줄 칸(계약) = 짝 .py 머리 * 옛 꼴(qty · 손 줄 after · parent) 그대로 읽음 · 저장 꼴 · 출력 줄 칸(계약) = 짝 .py 머리
* 출력 줄 칸 = kind · key · name · parent · level · no · cost · auto | hand · auto_{칸} · section * 출력 줄 칸 = kind · key · name · parent · level · no · cost · auto | hand · auto_{칸} · section
@@ -112,7 +112,7 @@ function materialItems(inputs: Row, compare: Row[]): Row[] {
/** 사본 주인 id → 대가 1 단위 단가(145-2 `daegaPrice`) */ /** 사본 주인 id → 대가 1 단위 단가(145-2 `daegaPrice`) */
export type PriceOf = (owner: string) => Row | null; export type PriceOf = (owner: string) => Row | null;
const WHY_HAND = "수기 줄 — 연결 없음(우클릭 [연결])"; const WHY_HAND = "수기 줄 — 연결 없음(우클릭 [연결])";
const WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)"; const WHY_LOGIC = "새 산근 — 서버 셈 뒤 단가(702-3 뒤에서 셈)";
/** 연결 → 1 단위 단가 {no, key, labor, material, expense, sum} — 없으면 null */ /** 연결 → 1 단위 단가 {no, key, labor, material, expense, sum} — 없으면 null */
export function refPrice(ref: Row | null, books: Row, hopyo: Row, rowKey = ""): Row | null { export function refPrice(ref: Row | null, books: Row, hopyo: Row, rowKey = ""): Row | null {
+34 -3
View File
@@ -13,6 +13,8 @@
POST /api/projects/{id}/estimation/prices {user?} POST /api/projects/{id}/estimation/prices {user?}
[단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 ·
지운 줄 · 그 사본은 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 지운 줄 · 그 사본은 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로
POST /api/projects/{id}/estimation/calc {user}
조작 중 서버 셈(702-3 · 단가연결 즉시 금액) — 화면 캐시로 GET 꼴 셈 · 저장 안 함
POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks} POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks}
「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 · 「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 ·
저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) · `blocks` = 칸 나눔(277-2) 저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) · `blocks` = 칸 나눔(277-2)
@@ -38,6 +40,7 @@ import asyncio
import copy import copy
import importlib import importlib
import logging import logging
import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -80,6 +83,25 @@ except ImportError: # pragma: no cover
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
# 702-3 조작 중 셈 = 마지막 GET · [저장] 이 읽은 B08 원천 다시 씀(B08 은 B09 조작으로 안 바뀜)
# ponytail: 시간 창만 봄 — 창 안에 남이 B08 을 고치면 미리보기만 옛 수량 · [저장] 은 늘 새로 읽음
B08_REUSE_S = 120.0
_b08_seen: dict[UUID, tuple[float, dict[str, Any], list[str]]] = {}
async def _b08_for(project_id: UUID, notes: list[str], reuse: bool) -> dict[str, Any]:
"""B08 원천 — reuse = 시간 창 안 마지막 값(사본) · 아니면 새로 읽고 남김."""
seen = _b08_seen.get(project_id)
if reuse and seen and time.monotonic() - seen[0] < B08_REUSE_S:
notes.extend(seen[2])
return copy.deepcopy(seen[1])
mine: list[str] = []
out = await _b08(project_id, mine)
_b08_seen[project_id] = (time.monotonic(), copy.deepcopy(out), mine)
notes.extend(mine)
return out
async def _b08(project_id: UUID, notes: list[str]) -> dict[str, Any]: async def _b08(project_id: UUID, notes: list[str]) -> dict[str, Any]:
"""B08 수량 원천 — 못 읽으면 빈칸 + 알림(B09 는 B08 를 고치지 않음).""" """B08 수량 원천 — 못 읽으면 빈칸 + 알림(B09 는 B08 를 고치지 않음)."""
out: dict[str, Any] = {} out: dict[str, Any] = {}
@@ -170,10 +192,10 @@ async def _demand(root: Path, draft: dict[str, Any] | None = None) -> list[str]:
async def payload( async def payload(
project_id: UUID, root: Path, draft: dict[str, Any] | None = None project_id: UUID, root: Path, draft: dict[str, Any] | None = None, reuse_b08: bool = False
) -> dict[str, Any]: ) -> dict[str, Any]:
notes: list[str] = [] notes: list[str] = []
b08 = await _b08(project_id, notes) b08 = await _b08_for(project_id, notes, reuse_b08)
keys = await _demand(root, draft) keys = await _demand(root, draft)
if not keys: if not keys:
notes.append( notes.append(
@@ -182,7 +204,8 @@ async def payload(
book = await asyncio.to_thread(store.read_book, root) book = await asyncio.to_thread(store.read_book, root)
inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"]) inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"])
hauls = await asyncio.to_thread(haul_routes, root) hauls = await asyncio.to_thread(haul_routes, root)
user = await asyncio.to_thread(store.read_user, root) # 702-3 draft = 화면 캐시로 셈(저장 안 함) · 없으면 저장 값
user = await asyncio.to_thread(store.read_user, root) if draft is None else draft
calc = await asyncio.to_thread(library.resolve_hauls, root, {**user, **haul_user(hauls)}) calc = await asyncio.to_thread(library.resolve_hauls, root, {**user, **haul_user(hauls)})
inputs["bill"] = bill_items({**b08, **calc, "haul_routes": hauls}) inputs["bill"] = bill_items({**b08, **calc, "haul_routes": hauls})
inputs["hopyo"] = hopyo_prices(root) inputs["hopyo"] = hopyo_prices(root)
@@ -276,6 +299,14 @@ async def refresh_prices(
return await payload(project_id, root, draft) return await payload(project_id, root, draft)
@router.post("/{project_id}/estimation/calc")
async def calc_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""702-3 조작 중 서버 셈 — 화면 캐시(user)로 GET 꼴 셈 · 저장 안 함 · 단가 = 굳힌 책
(없는 줄 = 지금 M01 · [저장] 때 책에 굳힘 → 같은 값)."""
root = await _project_root(project_id)
return await payload(project_id, root, store.clean_user(body.get("user")), reuse_b08=True)
class PreviewBody(BaseModel): class PreviewBody(BaseModel):
code: str = Field(min_length=1, max_length=40) code: str = Field(min_length=1, max_length=40)
given: dict[str, Any] = Field(default_factory=dict) given: dict[str, Any] = Field(default_factory=dict)
+106
View File
@@ -0,0 +1,106 @@
/* =============================================================================
* B09_Estimation_UI_Live.ts
* B09 조작 중 서버 셈(PLAN 702-3) — 새 산근(단가연결 · 로직 입력값)은 서버 M01 엔진 몫 →
* 바뀌면 그 자리에서 뒤로 POST …/estimation/calc {user}(저장 안 함 · GET 꼴 답) ·
* 차례 번호 · 옛 요청 끊기(`design_pipeline` 꼴) · 오는 동안 그 줄 금액 칸만 「셈 중」 · 화면 잠금 없음
*
* 기준 = payload.user(서버가 그 셈에 쓴 사용자 값) — 로직 연결 지문이 다른 줄 = 기다림 ·
* 기다리는 줄은 옛 산근 값을 안 씀(sg_hand 에서 뺌) · 못 받으면 알림 뒤 빈칸(같은 지문 다시 안 보냄)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import type { Row } from "./B09_Estimation_Calc_Base";
export const WAITING = "셈 중";
/** 줄 키 → 로직 연결 지문(코드 · 입력값 · 켜쌓기율 · 고름) */
function logicSig(user: Row | null | undefined): Map<string, string> {
const out = new Map<string, string>();
for (const r of user?.bill?.rows || []) {
const ref = r.ref;
if (ref?.tab === "logic")
out.set(
r.key,
JSON.stringify([ref.code, ref.given ?? {}, ref.rate ?? null, ref.picks ?? null]),
);
}
return out;
}
/** 서버 셈을 기다리는 줄 — 로직 연결이 서버 기준(payload.user)과 다름 */
export function waitingKeys(payload: Row, user: Row): Set<string> {
const known = logicSig(payload.user);
const out = new Set<string>();
for (const [key, sig] of logicSig(user)) if (known.get(key) !== sig) out.add(key);
return out;
}
/** 셈 입력 — 기다리는 줄은 옛 산근 단가를 안 씀 */
export function withoutWaiting(payload: Row, wait: Set<string>): Row {
if (!wait.size) return payload;
const hand = { ...(payload.sheets?.sg_hand || {}) };
for (const key of wait) delete hand[key];
return { ...payload, sheets: { ...payload.sheets, sg_hand: hand } };
}
/** 기다리는 줄 금액 칸 = 「셈 중」(셈이 끝난 뒤 · 계에는 안 듦) */
export function markWaiting(sheets: Row, wait: Set<string>): void {
for (const r of sheets.bill?.rows || []) if (wait.has(r.key) && r.sum == null) r.sum = WAITING;
}
/** settle(next, sent) — next = 서버 답(못 받음 = null) · sent = 보낸 사용자 값 */
export function createLiveCalc(
projectId: string,
settle: (next: Row | null, sent: Row) => void,
): { busy: () => boolean; kick: (wait: Set<string>, user: Row) => void } {
let seq = 0;
let ctrl: AbortController | null = null;
let sentSig = "";
let failedSig = "";
return {
busy: () => ctrl !== null,
/** 기다리는 줄 있으면 서버 셈(같은 것이 가는 중 · 못 받은 것이면 그대로) · 없으면 가던 것 끊음 */
kick(wait, user) {
const sig = JSON.stringify([...logicSig(user)]);
if (wait.size && (sig === failedSig || (ctrl && sig === sentSig))) return;
ctrl?.abort();
ctrl = null;
seq += 1;
if (!wait.size) return;
const mine = seq;
const me = new AbortController();
ctrl = me;
sentSig = sig;
fetch(`${API_BASE_URL}/projects/${projectId}/estimation/calc`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user }),
signal: me.signal,
})
.then(async (response) => {
const body = (await response.json().catch(() => null)) as Row | null;
if (!response.ok || !body) {
const detail = body?.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
return body;
})
.then(
(next) => {
if (mine !== seq) return;
ctrl = null;
settle(next, user);
},
(error: unknown) => {
if (mine !== seq || me.signal.aborted) return;
ctrl = null;
failedSig = sig;
showToast(`새 산근 단가를 못 받음 — [저장] 때 다시 셈 · ${String(error)}`, "warning");
settle(null, user);
},
);
},
};
}
+48 -19
View File
@@ -4,8 +4,9 @@
* *
* 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄 * 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄
* 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) + * 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) +
* 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · 저장(PUT → 서버 정본) = 단계 이동 · [>] 때(481-2 · [저장] 단추 없음 · 요구 301) * 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · 새 산근(로직 연결) = 뒤에서 서버 셈(702-3 · `…_UI_Live`) ·
* [>] = 고친 값 저장 → POST …/estimation/complete(단계 6 완료) → `DRAFT.next`(인쇄 미리보기 · 초안) · 실패면 머묾 * [저장](702-3 · 왼쪽 아래 [초기화] 옆) = PUT → 서버 정본으로 다시 셈 · 저장 안 하고 단계 옮김 · [>] = 물음(저장 · 버림 · 머묾)
* [>] = 물음 → POST …/estimation/complete(단계 6 완료) → `DRAFT.next`(인쇄 미리보기 · 초안) · 실패면 머묾
* [초기화](요구 298-2) = 확인 창 → POST …/estimation/reset(사용자 값 · 단가 책 지움 · 견적서 남김) → 셈 값 · [업데이트] = POST …/prices * [초기화](요구 298-2) = 확인 창 → POST …/estimation/reset(사용자 값 · 단가 책 지움 · 견적서 남김) → 셈 값 · [업데이트] = POST …/prices
* 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음 * 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음
* ========================================================================== */ * ========================================================================== */
@@ -36,6 +37,7 @@ import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor";
import { computeTotal } from "./B09_Estimation_Calc_Total"; import { computeTotal } from "./B09_Estimation_Calc_Total";
import { createTabBars } from "./B09_Estimation_UI_TabBar"; import { createTabBars } from "./B09_Estimation_UI_TabBar";
import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { createCheckPanel } from "./B09_Estimation_UI_Check";
import { createLiveCalc, markWaiting, waitingKeys, withoutWaiting } from "./B09_Estimation_UI_Live";
import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders"; import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders";
import { createDock, linkMode } from "./B09_Estimation_UI_Dock"; import { createDock, linkMode } from "./B09_Estimation_UI_Dock";
import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup"; import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup";
@@ -56,7 +58,11 @@ import {
renderGroupPanel, renderGroupPanel,
unpickOnRelease, unpickOnRelease,
} from "./B09_Estimation_UI_Tabs_Groups"; } from "./B09_Estimation_UI_Tabs_Groups";
import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_switch"; import {
askProjectLeave,
currentProjectId,
guardProjectLeave,
} from "../A00_Common/b_project_switch";
// 252 초안 탭(공기 등) — draft 폴더가 있으면 등록 · 지우면 빈 목록(오류 없음) // 252 초안 탭(공기 등) — draft 폴더가 있으면 등록 · 지우면 빈 목록(오류 없음)
import.meta.glob("./draft/*_UI.ts", { eager: true }); import.meta.glob("./draft/*_UI.ts", { eager: true });
@@ -139,22 +145,37 @@ export function renderB09Estimation(root: HTMLElement): void {
body.className = "b09-estimation"; body.className = "b09-estimation";
body.append(bars.top, bars.sub, panel); body.append(bars.top, bars.sub, panel);
// 702-3 새 산근 = 뒤에서 서버 셈 → 받은 답을 같은 자료 묶음에 끼움(열린 창이 쥔 payload 그대로)
const live = projectId
? createLiveCalc(projectId, (next, sent) => {
if (next && payload) Object.assign(payload, next, { user: sent });
redraw();
})
: null;
const recompute = (): void => { const recompute = (): void => {
if (!payload) return; if (!payload) return;
sheets = computeSheets(payload, user); const wait = waitingKeys(payload, user);
live?.kick(wait, user);
sheets = computeSheets(withoutWaiting(payload, wait), user);
if (live?.busy()) markWaiting(sheets, wait);
check.draw(payload, sheets); // 203-1 좌측 연결 점검 · [업데이트] 배지 check.draw(payload, sheets); // 203-1 좌측 연결 점검 · [업데이트] 배지
}; };
const setUser = (next: Row): void => { const setUser = (next: Row): void => {
user = next; user = next;
if (projectId) writeState("b09-estimation", user, projectId); if (projectId) writeState("b09-estimation", user, projectId);
redraw();
};
function redraw(): void {
recompute(); recompute();
// 고친 뒤 다시 그려도 보던 자리 지킴(166-2 내역서 수량 · 손 줄) // 고친 뒤 다시 그려도 보던 자리 지킴(166-2 내역서 수량 · 손 줄)
const top = panel.querySelector<HTMLElement>(".ui-data-table")?.scrollTop ?? 0; const top = panel.querySelector<HTMLElement>(".ui-data-table")?.scrollTop ?? 0;
show(current); show(current);
const box = panel.querySelector<HTMLElement>(".ui-data-table"); const box = panel.querySelector<HTMLElement>(".ui-data-table");
if (box && top) box.scrollTop = top; if (box && top) box.scrollTop = top;
}; }
/** 탭 하나 그림 · focus = 이동해 온 줄 키 */ /** 탭 하나 그림 · focus = 이동해 온 줄 키 */
const show = (tab: TabId, focus?: string): void => { const show = (tab: TabId, focus?: string): void => {
@@ -342,11 +363,22 @@ export function renderB09Estimation(root: HTMLElement): void {
}), }),
}); });
reset.dataset.b09Reset = ""; reset.dataset.b09Reset = "";
// 702-3 [저장] — 화면 캐시 → 서버 정본으로 다시 셈(고침 없으면 요청 없이 알림)
const save = createButton({
label: L("Common_Btn_Save"),
variant: "filled",
onClick: () => {
if (!projectId || readState("b09-estimation", projectId) === null)
return void showToast("저장할 고침이 없습니다.", "info");
void saveChanged();
},
});
save.dataset.b09Save = "";
// 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식) // 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식)
const section = createPanelCard("데이터 점검", true); // 311 기본 접힘 const section = createPanelCard("데이터 점검", true); // 311 기본 접힘
section.append(refresh, check.list); section.append(refresh, check.list);
// 303-1 · 304-1 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · 초안 단추 · [?][<][>][초기화] // 303-1 · 304-1 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · 초안 단추 · [?][<][>][초기화]
const actions = createDock([reset], () => isGroupTab(current) && show(current)); const actions = createDock([save, reset], () => isGroupTab(current) && show(current));
const left = document.createElement("div"); const left = document.createElement("div");
left.className = "ui-sidebar-panel b09-options"; left.className = "ui-sidebar-panel b09-options";
left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), actions); left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), actions);
@@ -359,15 +391,15 @@ export function renderB09Estimation(root: HTMLElement): void {
mainContent: body, mainContent: body,
leftPanel: left, leftPanel: left,
routes: WORKFLOW_STEP_ROUTES, routes: WORKFLOW_STEP_ROUTES,
// 481-2 단계 이동 = 고친 값 저장 성공 뒤 · 실패면 머묾 · 불러오기 전 · 고침 없음 = 요청 없이 // 702-3 단계 이동 = 저장 안 한 고침 있으면 물음(저장 · 버림 · 머묾) 뒤 · 고침 없음 = 바로
onStepClick: (stepIndex: number) => { onStepClick: (stepIndex: number) => {
if (!projectId) return; if (!projectId) return;
void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], [ void askProjectLeave().then((go) => {
{ name: "원가 저장", run: saveChanged }, if (go) void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], []);
]); });
}, },
}); });
// [>] = 다음 단계 없음 → 공용 틀 대신 여기서 — 저장 → 단계 6 완료 → 초안 다음 길 · 초안 없으면 흐림 그대로 // [>] = 다음 단계 없음 → 공용 틀 대신 여기서 — 물음(702-3) → 단계 6 완료 → 초안 다음 길 · 초안 없으면 흐림 그대로
const next = left.querySelector<HTMLButtonElement>('[data-step-nav="1"]'); const next = left.querySelector<HTMLButtonElement>('[data-step-nav="1"]');
const goNext = DRAFT.next; const goNext = DRAFT.next;
if (next && goNext) next.disabled = false; if (next && goNext) next.disabled = false;
@@ -376,14 +408,11 @@ export function renderB09Estimation(root: HTMLElement): void {
event.stopPropagation(); event.stopPropagation();
const why = DRAFT.blocked?.(); const why = DRAFT.blocked?.();
if (why) return void showToast(why, "warning"); // 701-3 확정 = 빈 줄 0 일 때만 if (why) return void showToast(why, "warning"); // 701-3 확정 = 빈 줄 0 일 때만
void moveStage(projectId, goNext, [ const complete = async (): Promise<void> =>
{ name: "원가 저장", run: saveChanged }, void (await request(`/projects/${projectId}/estimation/complete`, { method: "POST" }));
{ void askProjectLeave().then((go) => {
name: "원가 계산 단계 완료", if (go) void moveStage(projectId, goNext, [{ name: "원가 계산 단계 완료", run: complete }]);
run: async () => });
void (await request(`/projects/${projectId}/estimation/complete`, { method: "POST" })),
},
]);
}); });
root.append(layout.root); root.append(layout.root);
show(current); show(current);
@@ -2,7 +2,7 @@
* B09_Estimation_UI_Tabs_Bill_Link.ts * B09_Estimation_UI_Tabs_Bill_Link.ts
* B09 설계내역서 연결 고르기(166-2 · 184-3) — 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · * B09 설계내역서 연결 고르기(166-2 · 184-3) — 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) ·
* 서고(마스터 · 회사 · 개인) 구조물조합 · 일위대가 사전조합(204-2 — 서버가 프로젝트 사본으로 들여와 새 * 서고(마스터 · 회사 · 개인) 구조물조합 · 일위대가 사전조합(204-2 — 서버가 프로젝트 사본으로 들여와 새
* 대가 호표 · 같은 것 = 있는 사본) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · * 대가 호표 · 같은 것 = 있는 사본) · M01 로직(새 산근 = 서버 셈 · 연결 즉시 뒤에서 · 702-3) · 로직 입력값 고치기 ·
* 연결 풀기. * 연결 풀기.
* 251 단가연결 = 한 모달(`openPriceLink` · 종류 고르기 + 검색) — B09 설계내역서 · B08 거리이정표 같이 씀 · * 251 단가연결 = 한 모달(`openPriceLink` · 종류 고르기 + 검색) — B09 설계내역서 · B08 거리이정표 같이 씀 ·
* 단가산출 = 옵션 값 창(`askInputs` · 160) * 단가산출 = 옵션 값 창(`askInputs` · 160)
@@ -35,7 +35,7 @@ const LAYER_TEXT: Record<PickerLayer, string> = {
personal: "개인", personal: "개인",
}; };
/** M01 로직 → 입력값 창 → 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */ /** M01 로직 → 입력값 창 → 연결(새 산근 · 단가 = 화면이 뒤에서 서버 셈 · 702-3 `…_UI_Live`) */
export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void { export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void {
fetchLogicInputs([code]).then( fetchLogicInputs([code]).then(
(got) => { (got) => {
@@ -48,14 +48,14 @@ export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row
rate: old?.rate ?? null, rate: old?.rate ?? null,
readOnly: false, readOnly: false,
okLabel: "연결", okLabel: "연결",
notice: "새 산근 = 단가산출근거 뒤 번호 · 단가는 [저장] 뒤 서버가 셈", notice: "새 산근 = 단가산출근거 뒤 번호 · 단가는 연결 뒤 서버가 바로 셈",
done: (next) => { done: (next) => {
const unit = String(logic?.결과단위 || "") const unit = String(logic?.결과단위 || "")
.replace(/^원\//, "") .replace(/^원\//, "")
.replace(/^[\d.]+/, ""); .replace(/^[\d.]+/, "");
const ref = { tab: "logic", code, name, given: next.입력값, rate: next.켜쌓기율 }; const ref = { tab: "logic", code, name, given: next.입력값, rate: next.켜쌓기율 };
apply({ ...ref, picks: old?.picks ?? null }, { name, unit }); apply({ ...ref, picks: old?.picks ?? null }, { name, unit });
showToast("연결함 — 새 산근 단가는 [저장] 뒤에 채워짐", "success"); showToast("연결함 — 새 산근 단가 셈 중", "success");
}, },
}); });
}, },
@@ -130,7 +130,8 @@ export async function importLib(
try { try {
const got = await postLib("library", pick); const got = await postLib("library", pick);
if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦"); if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦");
const { user: _user, ...rest } = got.payload as Row; // 702-3 user = 서버가 이 셈에 쓴 값(저장 값) — 화면 캐시와 다른 로직 줄은 뒤에서 다시 셈
const rest = got.payload as Row;
o.onPayload?.(rest); o.onPayload?.(rest);
const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key); const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key);
const hand = lib.haul ? { given: lib.haul.given, own: lib.haul.own } : {}; const hand = lib.haul ? { given: lib.haul.given, own: lib.haul.own } : {};
@@ -86,6 +86,8 @@ const buttons = []; // 만든 단추 — { variant, node }
let layoutOpts = null; let layoutOpts = null;
let confirmAnswer = false; let confirmAnswer = false;
const store = new Map(); // 가짜 세션 캐시 const store = new Map(); // 가짜 세션 캐시
let leaveGuard = null;
let leaveAnswer = "save"; // save | discard | stay
const common = { const common = {
"../A00_Common/b_page_tabs": { "../A00_Common/b_page_tabs": {
// 탭 줄 부품 흉내 — 칸마다 dataset[data] = 열쇠 · 지금 칸 누름은 안 부름 // 탭 줄 부품 흉내 — 칸마다 dataset[data] = 열쇠 · 지금 칸 누름은 안 부름
@@ -140,8 +142,18 @@ const common = {
return true; return true;
}, },
}, },
// 572-2 지금 프로젝트 = 탭마다 · 떠남 가드는 test_560_4 // 572-2 지금 프로젝트 = 탭마다 · 떠남 가드는 test_560_4 · 702-3 B09 단계 이동 물음 = 가드 흉내(leaveAnswer)
"../A00_Common/b_project_switch": { currentProjectId: () => "P1", guardProjectLeave() {} }, "../A00_Common/b_project_switch": {
currentProjectId: () => "P1",
guardProjectLeave: (_root, g) => (leaveGuard = g),
askProjectLeave: async () => {
if (!leaveGuard || !leaveGuard.dirty()) return true;
calls.push("ask");
if (leaveAnswer === "stay") return false;
if (leaveAnswer === "discard") return (await leaveGuard.discard?.(), true);
return (await leaveGuard.save()) !== false;
},
},
"../A00_Common/b_page_state": { "../A00_Common/b_page_state": {
readState: (name) => store.get(name) ?? null, readState: (name) => store.get(name) ?? null,
writeState: (name, v) => store.set(name, v), writeState: (name, v) => store.set(name, v),
@@ -295,8 +307,8 @@ async function b09(withNext) {
unfinished = "빈 줄 2 · 미완성"; unfinished = "빈 줄 2 · 미완성";
out.blocked = await nav(1); out.blocked = await nav(1);
unfinished = null; unfinished = null;
// [초기화] // [초기화](702-3 [저장] 다음)
const reset = dock.children[0]; const reset = dock.children[1];
const press = async () => { const press = async () => {
take(); take();
reset.onClick(); reset.onClick();
+23 -1
View File
@@ -80,6 +80,8 @@ const flush = () => new Promise((r) => setTimeout(r, 0));
const calls = []; const calls = [];
let layoutOpts = null; let layoutOpts = null;
const store = new Map(); // 가짜 세션 캐시 — 이름 → 값 const store = new Map(); // 가짜 세션 캐시 — 이름 → 값
let leaveGuard = null;
let leaveAnswer = "save"; // save | discard | stay
const common = { const common = {
"../A00_Common/b_page_tabs": { "../A00_Common/b_page_tabs": {
// 탭 줄 부품 흉내 — 칸마다 dataset[data] = 열쇠 · 지금 칸 누름은 안 부름 // 탭 줄 부품 흉내 — 칸마다 dataset[data] = 열쇠 · 지금 칸 누름은 안 부름
@@ -129,7 +131,18 @@ const common = {
}, },
}, },
// 572-2 지금 프로젝트 = 탭마다 · 떠남 가드는 test_560_4 // 572-2 지금 프로젝트 = 탭마다 · 떠남 가드는 test_560_4
"../A00_Common/b_project_switch": { currentProjectId: () => "P1", guardProjectLeave() {} }, // 572-2 지금 프로젝트 = 탭마다 · 떠남 가드는 test_560_4 · 702-3 B09 단계 이동 물음 = 가드 흉내(leaveAnswer)
"../A00_Common/b_project_switch": {
currentProjectId: () => "P1",
guardProjectLeave: (_root, g) => (leaveGuard = g),
askProjectLeave: async () => {
if (!leaveGuard || !leaveGuard.dirty()) return true;
calls.push("ask");
if (leaveAnswer === "stay") return false;
if (leaveAnswer === "discard") return (await leaveGuard.discard?.(), true);
return (await leaveGuard.save()) !== false;
},
},
"../A00_Common/b_page_state": { "../A00_Common/b_page_state": {
readState: (name) => store.get(name) ?? null, readState: (name) => store.get(name) ?? null,
writeState: (name, v) => store.set(name, v), writeState: (name, v) => store.set(name, v),
@@ -223,6 +236,15 @@ async function b09() {
out.fail = await step(5); out.fail = await step(5);
fail = false; fail = false;
out.ok = await step(5); out.ok = await step(5);
// 702-3 물음 [버림] = 캐시 지우고 이동 · [머묾] = 요청 없이 머묾
store.set("b09-estimation", { x: 1 });
leaveAnswer = "discard";
out.discard = [await step(5), store.has("b09-estimation")];
store.set("b09-estimation", { x: 1 });
leaveAnswer = "stay";
out.stay = [await step(5), store.has("b09-estimation")];
leaveAnswer = "save";
store.delete("b09-estimation");
return out; return out;
} }
+102
View File
@@ -0,0 +1,102 @@
/* 702-3 B09 조작 중 서버 셈 — 진짜 `B09_Estimation_UI_Live.ts` 를 트랜스파일해 가짜 fetch 위에서 돌림 ·
* 차례 번호 · 옛 요청 끊기 · 같은 지문 다시 안 보냄 · 기다리는 줄(옛 산근 안 씀 · 「셈 중」) · 못 받음 = 한 번만.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const toasts = [];
function load(rel, stubs) {
const src = fs.readFileSync(path.join(ROOT, rel), "utf8");
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const mod = { exports: {} };
new Function("exports", "module", "require", js)(mod.exports, mod, (id) => stubs[id] ?? {});
return mod.exports;
}
const live = load("B09_Estimation/B09_Estimation_UI_Live.ts", {
"@config/config_frontend": { API_BASE_URL: "/api" },
"@ui/ui_template_elements": { showToast: (text, kind) => toasts.push(kind) },
});
// 가짜 fetch — 보낸 차례대로 쥐고 있다가 손으로 답함 · 끊김 = AbortError
const pending = [];
global.fetch = (url, init) =>
new Promise((resolve, reject) => {
const one = { url, body: JSON.parse(init.body), resolve, reject, aborted: false };
init.signal.addEventListener("abort", () => {
one.aborted = true;
reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
});
pending.push(one);
});
const answer = (one, ok = true) =>
one.resolve({
ok,
status: ok ? 200 : 500,
json: async () => (ok ? { tag: one.body.user.tag } : {}),
});
const flush = async () => {
for (let k = 0; k < 8; k++) await new Promise((r) => setTimeout(r, 0));
};
const logic = (given) => ({
bill: { rows: [{ key: "hand:a", ref: { tab: "logic", code: "GF1", given } }, { key: "b" }] },
});
(async () => {
const out = {};
const settled = [];
const calc = live.createLiveCalc("P1", (next, sent) =>
settled.push([next && next.tag, sent.tag]),
);
const base = { user: logic({ 높이: 3 }), sheets: { sg_hand: { "hand:a": "SG:old" } } };
// 기다리는 줄 = 서버 기준과 다른 로직 연결만
const u1 = { ...logic({ 높이: 4 }), tag: 1 };
const wait = live.waitingKeys(base, u1);
out.wait = [...wait];
out.same = [...live.waitingKeys(base, base.user)];
out.strip = live.withoutWaiting(base, wait).sheets.sg_hand;
const sheets = {
bill: {
rows: [
{ key: "hand:a", sum: null },
{ key: "b", sum: null },
],
},
};
live.markWaiting(sheets, wait);
out.mark = sheets.bill.rows.map((r) => r.sum);
// 차례 — 첫 요청 가는 중 같은 지문 = 안 보냄 · 바뀐 지문 = 옛 것 끊고 새로
calc.kick(wait, u1);
calc.kick(wait, u1);
out.sentAfterSame = pending.length;
const u2 = { ...logic({ 높이: 5 }), tag: 2 };
calc.kick(live.waitingKeys(base, u2), u2);
await flush();
out.firstAborted = pending[0].aborted;
out.url = pending[1].url;
answer(pending[1]);
await flush();
out.settled = settled.slice();
out.busyAfter = calc.busy();
// 기다릴 줄 없음 = 가던 것 끊음
const u3 = { ...logic({ 높이: 6 }), tag: 3 };
calc.kick(live.waitingKeys(base, u3), u3);
calc.kick(new Set(), base.user);
await flush();
out.cancelled = [pending[2].aborted, calc.busy()];
// 못 받음 = 알림 한 번 · 같은 지문 다시 안 보냄
const u4 = { ...logic({ 높이: 7 }), tag: 4 };
calc.kick(live.waitingKeys(base, u4), u4);
answer(pending[3], false);
await flush();
calc.kick(live.waitingKeys(base, u4), u4);
out.failed = [toasts, settled[settled.length - 1], pending.length];
console.log(JSON.stringify(out));
})().catch((e) => {
console.error(e && e.stack ? e.stack : e);
process.exit(1);
});
+1 -1
View File
@@ -286,4 +286,4 @@ def test_초기화_배선():
assert 'variant: "danger-ghost"' in block and "showConfirmDialog(" in block assert 'variant: "danger-ghost"' in block and "showConfirmDialog(" in block
assert 'if (ok) void run("/estimation/reset", "POST"' in block assert 'if (ok) void run("/estimation/reset", "POST"' in block
assert "내역 초기화" not in src and "b09BillReset" not in src assert "내역 초기화" not in src and "b09BillReset" not in src
assert "createDock([reset]," in src # 303-1 하단 고정 줄 맨 아래 줄 · [저장] 없음(요구 301) assert "createDock([save, reset]," in src # 303-1 하단 고정 줄 맨 아래 줄 · 702-3 [저장] 생김
@@ -202,7 +202,8 @@ def test_셸_배선():
) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나) ) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나)
assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄 assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄
assert ( assert (
"panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in src "panel.replaceChildren(...banner, table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : []))"
in src
) # 203 레벨 안내 + 탭 안내 = 한 영역 ) # 203 레벨 안내 + 탭 안내 = 한 영역
lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8")
assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수 assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수
@@ -238,6 +238,7 @@ def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS():
"key": "SS000011"} # fmt: skip "key": "SS000011"} # fmt: skip
ref = {"tab": "dg", "key": "DG:new", "lib": "precombo@company@7@SC-000002"} ref = {"tab": "dg", "key": "DG:new", "lib": "precombo@company@7@SC-000002"}
assert out["applied"][0] == [ref, "회사 조합"] assert out["applied"][0] == [ref, "회사 조합"]
# 원가 자료만 바꿔 끼움 — 사용자 값(서버 user)은 안 받음 # 원가 자료 바꿔 끼움 — 702-3 user = 서버가 그 셈에 쓴 기준(화면 캐시는 그대로 · 다른 로직 줄 = 뒤 셈)
assert "user" not in out["payload"] and out["payload"]["inputs"] == {"daega": {"libs": {}}} assert out["payload"]["user"] == {"bill": "서버 것"}
assert out["payload"]["inputs"] == {"daega": {"libs": {}}}
assert out["toasts"][-1][0] == "success" assert out["toasts"][-1][0] == "success"
@@ -63,8 +63,8 @@ def test_B08_다음_단계_완료_뒤_이동_실패면_머묾(r):
assert p["fail"] == ["req:POST:/projects/P1/quantity/complete", "toast:error"] assert p["fail"] == ["req:POST:/projects/P1/quantity/complete", "toast:error"]
def test_B09_단추_줄_초기화_하나_저장_없음(r): def test_B09_단추_줄_저장_초기화(r):
assert r["b09"]["dock"] == ["danger-ghost"] assert r["b09"]["dock"] == ["filled", "danger-ghost"] # 702-3 [저장] = B08 과 같은 꼴 · 자리
assert r["b09"]["nextEnabled"] is True assert r["b09"]["nextEnabled"] is True
@@ -74,6 +74,7 @@ def test_B09_다음_저장_완료_인쇄(r):
assert b["changed"] == [ assert b["changed"] == [
True, True,
[ [
"ask", # 702-3 저장 안 한 고침 = 물음([저장] 고름)
"lock", "lock",
"fetch:PUT:/api/projects/P1/estimation", "fetch:PUT:/api/projects/P1/estimation",
"unlock", "unlock",
@@ -101,7 +102,11 @@ def test_B09_초기화(r):
def test_B09_초안_없으면_다음_흐림(r): def test_B09_초안_없으면_다음_흐림(r):
assert r["b09NoDraft"] == {"dock": ["danger-ghost"], "nextEnabled": False, "nav": [False, []]} assert r["b09NoDraft"] == {
"dock": ["filled", "danger-ghost"],
"nextEnabled": False,
"nav": [False, []],
}
def test_초안_다음_길_등록(): def test_초안_다음_길_등록():
@@ -98,6 +98,6 @@ def test_잇기():
groups = (ROOT / GROUPS).read_text("utf-8") groups = (ROOT / GROUPS).read_text("utf-8")
assert "toggle" not in groups and "redraw" not in groups and "createButton" not in groups assert "toggle" not in groups and "redraw" not in groups and "createButton" not in groups
shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "createDock([reset], () => isGroupTab(current) && show(current))" in shell assert "createDock([save, reset], () => isGroupTab(current) && show(current))" in shell
state = (ROOT / "A00_Common/b_page_state.ts").read_text("utf-8") state = (ROOT / "A00_Common/b_page_state.ts").read_text("utf-8")
assert '"b09-group-view": { bucket: "pref", scope: "global" },' in state assert '"b09-group-view": { bucket: "pref", scope: "global" },' in state
+8 -4
View File
@@ -59,12 +59,16 @@ def test_B08_좌측_옵션_save(r):
assert o["keptOnFail"] is True assert o["keptOnFail"] is True
def test_B09_고친_값_저장_성공_뒤_이동(r): def test_B09_고친_값_있으면_물음_뒤_이동(r):
# 702-3 B09 [저장] 단추 생김 — 단계 이동 = 저장 안 한 고침 있으면 물음(저장 · 버림 · 머묾)
b = r["b09"] b = r["b09"]
assert b["beforeLoad"] == ["go:b08"] # 불러오기 전 = 저장 없이(캐시 남음) assert b["beforeLoad"] == ["go:b08"] # 불러오기 전 = 물음 · 저장 없이(캐시 남음)
assert b["clean"] == ["go:b08"] # 고침 없음 = 요청 없이 assert b["clean"] == ["go:b08"] # 고침 없음 = 요청 없이
assert b["fail"] == ["fetch:PUT:/api/projects/P1/estimation"] # 실패 = 머묾 put = "fetch:PUT:/api/projects/P1/estimation"
assert b["ok"] == ["fetch:PUT:/api/projects/P1/estimation", "go:b08"] assert b["fail"] == ["ask", put] # [저장] 실패 = 머묾
assert b["ok"] == ["ask", put, "go:b08"]
assert b["discard"] == [["ask", "go:b08"], False] # [버림] = 캐시 지움 · 저장 없이
assert b["stay"] == [["ask"], True]
def test_자재_수리_탭_저장_등록(): def test_자재_수리_탭_저장_등록():
@@ -7,6 +7,8 @@
임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀. 임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀.
""" """
import json
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@@ -43,3 +45,38 @@ def test_서고_사본_산근_목록에_듦_대가_단가_채움(world):
labor = compute_labor(daega, {**base, "gaps": []}, by_key) labor = compute_labor(daega, {**base, "gaps": []}, by_key)
row = next(r for r in labor["daega"] if r["key"] == block["id"]) row = next(r for r in labor["daega"] if r["key"] == block["id"])
assert row["sum"] and not row.get("blank") assert row["sum"] and not row.get("blank")
def test_화면_연결_뒤_셈_저장_단추_옮김_물음_TS():
shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
live = (B09 / "B09_Estimation_UI_Live.ts").read_text("utf-8")
router = (B09 / "B09_Estimation_Router.py").read_text("utf-8")
assert '"/{project_id}/estimation/calc"' in router
assert "AbortController" in live and "seq" in live # 차례 번호 · 옛 요청 끊기
assert "Common_Btn_Save" in shell and "b09Save" in shell
assert "askProjectLeave" in shell # 단계 이동 = 물음(저장 · 폐기 · 취소)
def test_뒤_셈_차례_번호_옛_요청_끊기_셈_중():
proc = subprocess.run(
["node", str(Path(__file__).parent / "helper_702_3_live.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
o = json.loads(proc.stdout)
assert o["wait"] == ["hand:a"] and o["same"] == [] # 로직 연결이 서버 기준과 다른 줄만
assert o["strip"] == {} # 기다리는 줄 = 옛 산근 단가 안 씀
assert o["mark"] == ["셈 중", None] # 그 줄 금액 칸만
assert o["sentAfterSame"] == 1 # 가는 중 같은 지문 = 다시 안 보냄
assert o["firstAborted"] is True # 바뀐 지문 = 옛 요청 끊음
assert o["url"] == "/api/projects/P1/estimation/calc"
assert o["settled"] == [[2, 2]] and o["busyAfter"] is False # 마지막 것만 받음
assert o["cancelled"] == [True, False] # 기다릴 줄 없어짐 = 가던 것 끊음
assert o["failed"] == [
["warning"],
[None, 4],
4,
] # 못 받음 = 알림 한 번 · 같은 지문 다시 안 보냄