From 9a035bc18856228637e44134116160d1c9daf8f6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 07:19:41 +0900 Subject: [PATCH 01/17] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20192=20=EC=9B=90=EB=AC=B8=20=E2=80=94=20=EB=AC=BC?= =?UTF-8?q?=EB=84=98=EC=9D=B4=20=EA=B4=80=20=EC=97=86=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EB=8F=84=EC=88=98=EB=A1=9C=20=EB=A9=B4=EC=A0=81=20=EC=88=98?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=9D=B8=EC=87=84=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=C2=B7=20B09=20=ED=96=89=20=ED=8C=9D?= =?UTF-8?q?=EC=97=85=20=C2=B7=20=EB=B0=95=EC=8A=A4=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=ED=98=B8=ED=91=9C=EB=B3=B4=EA=B8=B0=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=EA=B3=B5=EC=9A=A9=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 76ce578ab..9b187f75a 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -220,3 +220,4 @@ 189. (원문 · 10-12) 도수로/돌붙임 생성의 경우 성토 사면 선택이 잘 안됨. 잘고려하여 잘 선택되게 반영 190. (원문 · 10-12) 1. 횡단도와 상세 버튼으로 접근하는 모달창에서의 선과 면적을 선택할때는 선택면적이 좁은 순으로 마우스커서로 잡게 하여야 됨. 화면상에 위에 배치되어야 선택이 용이해짐. 2. 횡단도의 상세버튼으로 접근하고 모달창의 횡단도는 초기에 사용자에게 보여줄때 중심점이 센터에 오게 그림을 배치 3. 물넘의 포장의 월류폭에 의해 인접 횡단도에 그림이 그려진다면 흙막이처럼 연결된 상태라는걸 알리기 위해 별도 표기 4. B09의 좌측 패널에서 업데이트 버튼으로 불러온 정보를 업데이트 하게 되는데 이때는 만약 복사해온 정보가 삭제되었다면 업데이트 항목에서 제외 5. 횡단도의 상세버튼 모달창에서 구조물은 선들을 나누는 기준이 될수 선이 될수 있지만 구조물들의 선을 나눌필요는 없음. 6. 나는 이제 쉬러갈거니 너가 다른워크트리들을 잘 제어해서 요청업무에 대해 잘 완료하고 깃 싱크할것. 7. 깃 싱크전 너가 브라우져로 사용자가 된것처럼 제어하고 검증한 결과를 아티팩트로 보여줄것. 191. (원문 · 10-12) 추가 사항 있음. 신규 횡단배수군들 중 배관, 박스암거, 세월교, 물넘이포장의 경우 배수유역을 재분석을 해야할텐데 추가버튼을 누르면 횡단도 생성하고 배수유역도은 몰래 업데이트 되게 했으면 함. 몰래가 힘들면 로딩도 무관함. +192. (원문 · 10-13) 1. 물넘이에는 배관이 없는게 맞음. 절토면의 물이내려오면 지대가 낮은 성토부로 콘크리트 노면을 이용하여 배수 2. 배수로의 면적은 수기입력이 맞은 실수임. 길이는 현상구현에만 사용. 면적계산에서는 제외 3. 인쇄미리보기 버튼 및 기능 추가. 초안프로그램 로직으로 draft폴더에 코드 생성. b09 좌측패널 하단에 버튼생성. 신규페이지로 이동하고 b07의 도면과 b08, b09의 수량산출, 원가계산영역으로 구분하여 미리보기함. 이때 구조물 조합에 묶여있는 도면은 도면측으로 전달하여 미리보기. 미리보기는 이전 페이지에서 생성한 결과물을 인쇄 전 결과물을 미리보기위한 페이지로 도면은 pdf로 수량산출과 원가계산은 pdf와 엑셀로 출력 및 다운로드됨. 이때 원가계산의 페이지순서는 실무문서의 3공구 내역서 참조. 순서만 참조 4. b09 행선택시 페이지 이동 후 하이라이트에서 하이라이트 부분만 모달창으로 보여주는 기능 구현. 이 기능은 좌측 패널의 하단에 하이퍼링크 버튼과 팝업 버튼을 스위치하며 선택가능 기본값은 팝업 버튼. 하이퍼링크 버튼 화성화 시 현재처럼 테이블 행을 선택하면 해당페이지로 이동. 팝업버튼 활성화시 테이블 행을 선택시 하이라이트 되는 행 전체와 열제목을 지정한 행주변에 보여줌. 이때 조심할 내용은 모달창의 내용도 선택하면 다시 연결된 모달창을 추가로 보여줘야함. 5. b09의 박스보기 및 호표보기의 섬택은 좌측 패널로 옮기고 공용 세팅화 함. 6. 랩탑 메인프로젝트는 너와 내가 같이 사용하는 프로젝트임. 모르는 구조물이 나오더라도 문제 없음. 7. 상세 검토 후 신규 계획서를 작성하여 수행할고 브라우져 검증을 이번 아티팩트를 만든것처럼 수행 From d7ba07832cffcb52e3274f9038f734bd1e15462e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 07:32:34 +0900 Subject: [PATCH 02/17] =?UTF-8?q?fix(b05):=20=EB=8F=84=EC=88=98=EB=A1=9C/?= =?UTF-8?q?=EB=8F=8C=EB=B6=99=EC=9E=84=20=EB=A9=B4=EC=A0=81=20=3D=20?= =?UTF-8?q?=EB=8A=98=20=EC=86=90=20=EC=9E=85=EB=A0=A5=20=E2=80=94=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20=C2=B7=20=EC=9B=94=EB=A5=98=20=ED=8F=AD?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=A9=B4=EC=A0=81=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=85=88(syncChuteArea=20=C2=B7=20chuteAreaM2)=20=EA=B1=B7?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EB=AC=BC=EB=84=98=EC=9D=B4=20=C2=B7=20?= =?UTF-8?q?=EC=84=B8=EC=9B=94=EA=B5=90=20=ED=8F=BC=20onChange=20=EC=9B=90?= =?UTF-8?q?=EB=9E=98=20=ED=9D=90=EB=A6=84=20=EC=83=81=EC=9E=90=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B8=EC=9D=B4=20=3D=20=EA=B7=B8=EB=A6=BC=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20(301-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- .../design/design_structure_ford_calc.ts | 38 ---------------- .../B05_Profile_UI_Drainage_Facility.ts | 9 ++-- resources/tester/helper_287_ford_pavement.cjs | 45 ++++++++++++------- .../tester/helper_294_3_ford_extend_chute.cjs | 5 --- .../tester/test_287_ford_pavement_followup.py | 22 ++++----- .../tester/test_294_3_ford_extend_chute.py | 7 ++- 6 files changed, 47 insertions(+), 79 deletions(-) diff --git a/A00_Common/design/design_structure_ford_calc.ts b/A00_Common/design/design_structure_ford_calc.ts index 474f37a92..0cfa6c52d 100644 --- a/A00_Common/design/design_structure_ford_calc.ts +++ b/A00_Common/design/design_structure_ford_calc.ts @@ -15,10 +15,8 @@ * (같은 풀이 · n · S 를 받음) · 거울 시험 test_73_11_b08_hydro_box_ford. * ========================================================================== */ -import { FORD_PAVEMENT_DEFAULT_WIDTH_M } from "@config/config_frontend"; import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; import type { SpecForm } from "./design_structure_form"; -import { CHUTE_ITEMS } from "./design_structure_guard"; import type { SpecContainer, SpecItem } from "./design_structure_spec"; /** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */ @@ -81,39 +79,3 @@ export function fordHeightHint(designFlowM3s: number | null, widthM: number): st if (need !== null) return `필요 수심 ${need.toFixed(2)} 이상`; return designFlowM3s ? "월류 폭을 넣으면 셈" : "설계유량 없음 — 수기 입력"; } - -/** 도수로 · 돌붙임 면적(㎡ · 287-1) = 폭 × 길이 · 0.01 반올림. */ -export function chuteAreaM2(widthM: number, lengthM: number): number { - return Math.round(widthM * lengthM * 100) / 100; -} - -/** - * 물넘이 포장 · 세월교 도수로/돌붙임 길이 ↔ 면적(287-1 · 294-4) — 길이를 넣으면 면적 = 폭 × 길이(폭 = 띠가 - * 도로 방향으로 걸치는 월류 폭 · 비면 물넘이 기본 폭 · 배관 · 박스는 폭 정의 없어 자동 안 함) · 길이를 비우면 면적 = 칸 기본값 · 월류 폭을 바꾸면 길이가 있을 때만 - * 다시 셈. 길이는 그림(사면 따라 그 길이) · 면적은 수량(B08 돌붙임)이 씀. - */ -export function syncChuteArea( - form: Pick, - c: SpecContainer, - item: SpecItem, - /** 도수로 상자 · 월류 폭 상자 — 물넘이 `chute` · `slope` · 세월교 `chute_add` · `ford`(294-4). */ - chuteBox = "chute", - widthBox = "slope", -): void { - const byLength = c.id === chuteBox && item.id === "length_m"; - if (!byLength && !(c.id === widthBox && item.id === "width")) return; - const chute = form.state()[chuteBox]?.[0]; - if (!chute) return; - const length = Number(chute.length_m ?? ""); - if (!(chute.length_m ?? "").trim() || !(length > 0)) { - if (byLength) - form.setValue(chuteBox, "area", CHUTE_ITEMS.find((i) => i.id === "area")?.default ?? ""); - return; - } - const width = Number(form.state()[widthBox]?.[0]?.width ?? ""); - form.setValue( - chuteBox, - "area", - String(chuteAreaM2(width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M, length)), - ); -} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index a9a65e47b..7a1b435ea 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -64,7 +64,7 @@ import { boxOptionsIn, } from "../A00_Common/design/design_structure_box"; import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford"; -import { overflowSync, syncChuteArea } from "../A00_Common/design/design_structure_ford_calc"; +import { overflowSync } from "../A00_Common/design/design_structure_ford_calc"; import { pipeDrawn, pipeExtraCount, @@ -384,10 +384,7 @@ export function createFacilityOptionsForm( ); // 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1) const fordForm: SpecForm = createSpecForm(specLinks, { - onChange: (c, item) => ( - syncChuteArea(fordForm, c, item, "chute_add", "ford"), - fordFlowBox.onChange(c, item) - ), + onChange: fordFlowBox.onChange, fields: (c, values) => [...guardFields(c, values), ...fordFlowBox.fields(c)], count: guardSoilCount( () => chainage, @@ -477,7 +474,7 @@ export function createFacilityOptionsForm( emit, ); const paveForm: SpecForm = createSpecForm(specLinks, { - onChange: (c, item) => (syncChuteArea(paveForm, c, item), paveFlowBox.onChange(c, item)), + onChange: paveFlowBox.onChange, fields: paveFlowBox.fields, }); diff --git a/resources/tester/helper_287_ford_pavement.cjs b/resources/tester/helper_287_ford_pavement.cjs index 9b0b2fb48..be81c96c4 100644 --- a/resources/tester/helper_287_ford_pavement.cjs +++ b/resources/tester/helper_287_ford_pavement.cjs @@ -26,16 +26,6 @@ const load = (file, stubs = {}) => { const D = (f) => path.join(ROOT, "A00_Common", "design", f); const calc = load(D("design_structure_ford_calc.ts")); -/** 폼 흉내 — 상태 · setValue 만. */ -const formOf = (slope, chute) => { - const state = { slope: [{ ...slope }], chute: [{ ...chute }] }; - return { state: () => state, setValue: (c, i, v) => (state[c][0][i] = v) }; -}; -const change = (form, c, i) => { - calc.syncChuteArea(form, { id: c }, { id: i }); - return form.state().chute[0].area; -}; - // 287-2 · 287-4 그림 — DOM 흉내(속성 · 자식만) class Node { constructor(tag) { @@ -209,6 +199,32 @@ const overflowRun = (flowValue) => { }; }; +// 301-1 폼 훅 그대로(B05 물넘이 paveForm · 세월교 fordForm onChange = overflowSync onChange) +const areaRun = (widthBox, chuteBox) => { + const state = { [widthBox]: [{ width: "6" }], [chuteBox]: [{ length_m: "", area: "17" }] }; + const form = { + state: () => state, + setValue: (c, i, v) => (state[c][0][i] = v), + refresh: () => undefined, + }; + const sync = calc.overflowSync( + () => form, + widthBox, + () => 14.5776, + () => undefined, + ); + const edit = (box, item, value) => { + state[box][0][item] = value; + sync.onChange({ id: box }, { id: item }); + return state[chuteBox][0].area; + }; + return [ + edit(chuteBox, "length_m", "4"), + edit(widthBox, "width", "8"), + edit(chuteBox, "length_m", ""), + ]; +}; + const out = { overflowFlow: overflowRun(14.5776), overflowNoFlow: overflowRun(null), @@ -235,11 +251,8 @@ const out = { paveSlopeItems: load(D("design_structure_ford_pavement.ts")) .FORD_PAVEMENT_SPEC.containers.find((c) => c.id === "slope") .items.map((i) => i.id), - // 287-1 길이 넣음 = 월류 폭 × 길이 · 비움 = 기본값 · 폭 바꿈 = 길이 있을 때만 다시 셈 · 폭 비면 기본 폭 - areaByLength: change(formOf({ width: "6" }, { length_m: "4", area: "30" }), "chute", "length_m"), - areaCleared: change(formOf({ width: "6" }, { length_m: "", area: "24" }), "chute", "length_m"), - areaByWidth: change(formOf({ width: "8" }, { length_m: "2.5", area: "15" }), "slope", "width"), - areaWidthNoLength: change(formOf({ width: "8" }, { length_m: "", area: "17" }), "slope", "width"), - areaNoWidth: change(formOf({ width: "" }, { length_m: "3", area: "30" }), "chute", "length_m"), + // 301-1 면적 = 늘 손 입력 — 길이 · 월류 폭을 바꾸거나 길이를 비워도 면적 그대로(물넘이 · 세월교 폼 훅) + areaKeptPave: areaRun("slope", "chute"), + areaKeptFord: areaRun("ford", "chute_add"), }; process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_294_3_ford_extend_chute.cjs b/resources/tester/helper_294_3_ford_extend_chute.cjs index 0a3dc1bf9..c7ea1bf0b 100644 --- a/resources/tester/helper_294_3_ford_extend_chute.cjs +++ b/resources/tester/helper_294_3_ford_extend_chute.cjs @@ -64,10 +64,6 @@ const pts = (lines) => lines.map((l) => l.points.map((p) => [+p.offset.toFixed(4), +p.elevation.toFixed(4)])); const dipGround = hatch.groundLine(input.dipSamples); const fillGround = hatch.groundLine(input.fillSamples); -const calc = load(path.join(ROOT, "A00_Common", "design", "design_structure_ford_calc.ts")); -const formOf = (state) => ({ state: () => state, setValue: (c, i, v) => (state[c][0][i] = v) }); -const fordState = { ford: [{ width: "8" }], chute_add: [{ length_m: "2.5", area: "30" }] }; -calc.syncChuteArea(formOf(fordState), { id: "chute_add" }, { id: "length_m" }, "chute_add", "ford"); const guard = load(path.join(ROOT, "A00_Common", "design", "design_structure_guard.ts")); const fordPave = load(path.join(ROOT, "A00_Common", "design", "design_structure_ford_pavement.ts")); @@ -85,7 +81,6 @@ const out = { fillLine: pave.outletFillLine(input.fillDesign, fillGround), fillLineOld: pave.outletFillLine(input.fillDesign), fillChuteLength: pave.fordChuteLine(input.fillDesign, { length_m: 1 }, fillGround), - fordArea: fordState.chute_add[0].area, chuteItems: guard.CHUTE_ITEMS.map((i) => i.id), fordPaveChuteSame: fordPave.FORD_PAVEMENT_SPEC.containers.find((c) => c.id === "chute").items === diff --git a/resources/tester/test_287_ford_pavement_followup.py b/resources/tester/test_287_ford_pavement_followup.py index 80a7a70c8..01700dfde 100644 --- a/resources/tester/test_287_ford_pavement_followup.py +++ b/resources/tester/test_287_ford_pavement_followup.py @@ -1,8 +1,8 @@ """287 물넘이포장 후속(요구 183-2 · 4 · 6 · 7 · 8 · 9). 못박는 것 - - 287-1 도수로/돌붙임 길이 ↔ 면적 — 길이 넣음 = 월류 폭 × 길이 · 비움 = 칸 기본값 · 월류 폭을 바꾸면 - 길이가 있을 때만 다시 셈 · 폭이 비면 물넘이 기본 폭. + - 301-1 도수로/돌붙임 면적 = 늘 손 입력(요구 192-2 · 287-1 자동 셈 걷음) — 길이 · 월류 폭을 바꾸거나 + 길이를 비워도 면적 그대로 · 길이 = 그림 전용(294-4). - 287-3 월류 폭 → 높이 자동 = 세월교 한 벌(`fordHeightM` · 폼 `overflow(…, "slope")`) · 유량 없으면 손 입력. - 287-5 물넘이포장 형상 누름 = 다른 시설과 같은 고름 창구(`toggleRevet("pipe")` → 폼 · 알약 · 측점선) · 판정면은 보라 손잡이 아래(요구 185 ①) · 끌기(3 px 넘게 움직임) = 고름 아님(②) · 노면공 포장층 @@ -51,17 +51,19 @@ def ts() -> dict: return json.loads(proc.stdout) -def test_도수로_길이_면적(ts): - assert ts["areaByLength"] == "24" # 6 m × 4 m - assert ts["areaCleared"] == "30" # 길이 비움 = 기본값 - assert ts["areaByWidth"] == "20" # 폭 8 m × 2.5 m - assert ts["areaWidthNoLength"] == "17" # 길이 없으면 폭을 바꿔도 그대로 - assert ts["areaNoWidth"] == "30" # 폭 비움 = 기본 폭 10 m × 3 m +def test_도수로_면적은_손_입력_그대로(ts): + # 길이 4 넣음 · 월류 폭 8 · 길이 비움 — 면적은 손 입력 17 그대로 + assert ts["areaKeptPave"] == ["17", "17", "17"] + assert ts["areaKeptFord"] == ["17", "17", "17"] -def test_물넘이_폼이_길이_면적을_잇는다(): +def test_폼이_길이_면적을_잇지_않는다(): form = _read("B05_Profile", "B05_Profile_UI_Drainage_Facility.ts") - assert "syncChuteArea(paveForm, c, item), paveFlowBox.onChange(c, item)" in form + assert "syncChuteArea" not in form + assert "onChange: fordFlowBox.onChange," in form + assert "onChange: paveFlowBox.onChange," in form + calc = _read("A00_Common", "design", "design_structure_ford_calc.ts") + assert "syncChuteArea" not in calc and "chuteAreaM2" not in calc def _pts(text: str) -> list[float]: diff --git a/resources/tester/test_294_3_ford_extend_chute.py b/resources/tester/test_294_3_ford_extend_chute.py index e20bb8560..4d0d4a9a7 100644 --- a/resources/tester/test_294_3_ford_extend_chute.py +++ b/resources/tester/test_294_3_ford_extend_chute.py @@ -7,8 +7,8 @@ 연장」) · 그림 = 콘크리트(포장층)가 늘인 끝까지. - 294-4 도수로/돌붙임 띠 = 낮은 쪽 끝단(늘인 끝 · 아니면 유출 쪽 노견 끝)에서 성토 사면 따라 · 길이 비움 = 원지반 닿으면 끝 · 양측 절토 물넘이 = 띠 없음 · 길이 = 그 길이(사면보다 길면 원지반 따라) · 다른 구조물 - (배관 · 박스 · 세월교) 같은 규칙(`chuteRun` · 원지반 `layout.ground`) + 같은 「길이 (m)」 칸 · 면적 = 세월교만 - 월류 폭 × 길이(배관 · 박스 = 폭 정의 없어 자동 안 함) · 길이는 집계 · B08 수량에 안 실림. + (배관 · 박스 · 세월교) 같은 규칙(`chuteRun` · 원지반 `layout.ground`) + 같은 「길이 (m)」 칸 · 면적 = 늘 + 손 입력(301-1 · 길이 · 폭으로 안 셈) · 길이는 집계 · B08 수량에 안 실림. 진짜 TS 는 `helper_294_3_ford_extend_chute.cjs` 가 Node 로 돌림. 화면은 사용자 확인. """ @@ -155,7 +155,6 @@ def test_성토_쪽_띠는_원지반_닿으면_끝(ts): def test_다른_구조물_같은_규칙과_길이_칸(ts): assert ts["chuteItems"][-1] == "length_m" assert ts["fordPaveChuteSame"] is True - assert ts["fordArea"] == "20" # 세월교 월류 폭 8 × 길이 2.5 hatch = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Wall_Hatch.ts").read_text( encoding="utf-8" ) @@ -168,7 +167,7 @@ def test_다른_구조물_같은_규칙과_길이_칸(ts): form = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts").read_text( encoding="utf-8" ) - assert form.count("syncChuteArea(") == 2 # 물넘이 · 세월교만(배관 · 박스 = 폭 정의 없음) + assert "syncChuteArea" not in form # 면적 = 손 입력(301-1) def test_길이는_집계_수량에_안_실림(): From 5034fa4ea86010cdf2f047fed35ef07eecd7c538 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 07:44:07 +0900 Subject: [PATCH 03/17] =?UTF-8?q?feat(b09-print):=20=EC=9D=B8=EC=87=84=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=95=BD=EC=86=8D=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=E2=80=94=20PrintSheet=20=C2=B7=20CollectS?= =?UTF-8?q?heets=20=C2=B7=20DrawingItem=20=C2=B7=20FetchDrawingList=20?= =?UTF-8?q?=C2=B7=20FetchDrawingsPdf=20(302-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- B09_Estimation/draft/B09_Draft_Print_Types.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 B09_Estimation/draft/B09_Draft_Print_Types.ts diff --git a/B09_Estimation/draft/B09_Draft_Print_Types.ts b/B09_Estimation/draft/B09_Draft_Print_Types.ts new file mode 100644 index 000000000..8d096f5c0 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Types.ts @@ -0,0 +1,32 @@ +/* ============================================================================= + * B09_Draft_Print_Types.ts + * 인쇄 미리보기(PLAN 302 · 초안) 약속 — 영역 셋이 페이지(`B09_Draft_Print_Page`)에 넘기는 꼴. + * 수량산출 = `B09_Draft_Print_Quantity.collectQuantitySheets` · 원가계산 = `B09_Draft_Print_Cost.collectCostSheets` + * → PrintSheet[](쪽 = 시트 · 차례 그대로) → 서버 표 PDF · 엑셀 + * 도면 = `B09_Draft_Print_Drawings.fetchDrawingList` · `fetchDrawingsPdf`(서버가 만든 PDF 그대로) + * ========================================================================== */ + +import type { DataTableOptions } from "@ui/ui_template_data_table"; + +/** 표 한 쪽(시트) — 화면과 같은 표 짜기 + 쪽 방향 · 붙일 그림 */ +export interface PrintSheet { + id: string; + name: string; + table: DataTableOptions; + orientation?: "portrait" | "landscape"; + images?: { src: string; caption?: string }[]; +} + +/** 수량산출 · 원가계산 영역 — 프로젝트의 쪽 목록(차례 그대로) */ +export type CollectSheets = (projectId: string) => Promise; + +/** 도면 영역 한 장 — id = fetchDrawingsPdf 의 ids 로 돌려줌 */ +export interface DrawingItem { + id: string; + name: string; +} + +export type FetchDrawingList = (projectId: string) => Promise; + +/** ids 없음 = 목록 전부 · 있으면 그 차례대로 */ +export type FetchDrawingsPdf = (projectId: string, ids?: string[]) => Promise; From bc92038071c57e11ee2af8e3e457653524eae8f2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 07:48:02 +0900 Subject: [PATCH 04/17] =?UTF-8?q?refactor(b09):=20=ED=83=AD=20=ED=91=9C=20?= =?UTF-8?q?=EC=A7=9C=EA=B8=B0=20=EA=BA=BC=EB=83=84=20=E2=80=94=20B09=5FEst?= =?UTF-8?q?imation=5FUI=5FBuilders.ts(BUILDERS=20=C2=B7=20tabTableOf=20?= =?UTF-8?q?=C2=B7=20tableOptionsOf)=20=C2=B7=20Shell=20=EC=9D=B4=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EC=94=80=20=C2=B7=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20(303-2=20=EC=95=9E=20?= =?UTF-8?q?=EC=A1=B0=EA=B0=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- B09_Estimation/B09_Estimation_UI_Builders.ts | 85 +++++++++++++++++++ B09_Estimation/B09_Estimation_UI_Shell.ts | 62 +------------- resources/tester/test_145_1_b09_sangeun.py | 3 +- .../tester/test_183_3_b09_applied_pick.py | 10 ++- .../tester/test_201_1_table_level_merge.py | 2 +- resources/tester/test_235_1_b09_bill_tree.py | 3 +- .../tester/test_243_1_double_click_tables.py | 6 +- 7 files changed, 104 insertions(+), 67 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Builders.ts diff --git a/B09_Estimation/B09_Estimation_UI_Builders.ts b/B09_Estimation/B09_Estimation_UI_Builders.ts new file mode 100644 index 000000000..fb846bcde --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Builders.ts @@ -0,0 +1,85 @@ +/* ============================================================================= + * B09_Estimation_UI_Builders.ts + * B09 탭 표 짜기(PLAN 303-2) — 탭 → 표(`TabTable`) · 표 → 공용 표 옵션(DOM · 누름 없이). + * + * 화면(Shell) · 팝업 창(303-2) · 인쇄(302-4)가 같은 표 짜기를 씀 — 보는 값 = 찍는 값 + * 초안 탭 = `DRAFT.builders`(draft 폴더가 넣음 · 없으면 빈 채로) + * ========================================================================== */ + +import type { DataTableOptions } from "@ui/ui_template_data_table"; +import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor"; +import { + DRAFT, + TABS, + type TabBuild, + type TabContext, + type TabId, + type TabTable, +} from "./B09_Estimation_UI_Tabs"; +import { + buildBasis, + buildCompare, + buildExpense, + buildLabor, + buildMachine, + buildMachineBlocks, + buildMaterial, +} from "./B09_Estimation_UI_Tabs_Base"; +import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; +import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { + buildSumExpense, + buildSumLabor, + buildSumMachine, + buildSumMaterial, +} from "./B09_Estimation_UI_Tabs_Summary"; +import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; +import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; + +const BUILDERS: Partial> = { + dg_list: buildDaegaList, + dg: buildDaegaBlocks, + bill: buildBill, + basis: buildBasis, + machine: buildMachine, + machine_blocks: buildMachineBlocks, + material: buildMaterial, + labor: buildLabor, + expense: buildExpense, + compare: buildCompare, + cost: buildCost, + total: buildTotal, + sum_material: buildSumMaterial, + sum_labor: buildSumLabor, + sum_expense: buildSumExpense, + sum_machine: buildSumMachine, + sg_list: buildSgList, + sg: buildSg, +}; + +/** 탭 짜기 — 없는 탭 = null(「다음 단계에서 채움」) · 글 = 안내만 */ +export function tabTableOf(tab: TabId, ctx: TabContext): TabTable | string | null { + const build = BUILDERS[tab] ?? DRAFT.builders[tab]; + return build ? build(ctx) : null; +} + +/** 표 → 공용 표 옵션(화면과 같은 값 · 꼴) — 누름 · 메뉴 · 풍선은 그리는 쪽 몫 */ +export function tableOptionsOf(spec: TabTable, tab?: TabId): DataTableOptions { + return { + columns: spec.columns, + rows: spec.rows, + total: spec.total ?? null, + frozen: spec.frozen ?? 0, + empty: "", + caption: TABS.find(([id]) => id === tab)?.[1], + editable: spec.editable, + // 243-1 고칠 수 있는 칸 = 모든 표 더블클릭 · Enter · F2 (설계내역서 키 = 레벨 키 몫) + editOnDouble: spec.editOnDouble ?? true, + keyEdit: !(spec as BillTable).menu, + onEdit: spec.onEdit, + rowClass: spec.rowClass, + cellClass: spec.cellClass, + level: spec.level, + mergeSame: spec.mergeSame, + }; +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 1b331e139..c84584803 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -32,36 +32,17 @@ import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor"; import { computeTotal } from "./B09_Estimation_Calc_Total"; import { createTabBars } from "./B09_Estimation_UI_TabBar"; import { createCheckPanel } from "./B09_Estimation_UI_Check"; -import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor"; +import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders"; import { DRAFT, rowIndex, TABS, type Link, - type TabBuild, type TabContext, type TabId, } from "./B09_Estimation_UI_Tabs"; -import { - buildBasis, - buildCompare, - buildExpense, - buildLabor, - buildMachine, - buildMachineBlocks, - buildMaterial, -} from "./B09_Estimation_UI_Tabs_Base"; -import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; -import { - buildSumExpense, - buildSumLabor, - buildSumMachine, - buildSumMaterial, -} from "./B09_Estimation_UI_Tabs_Summary"; -import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; -import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; import { isGroupTab, lineHint, @@ -79,27 +60,6 @@ function L(key: keyof typeof ui_locales): string { const LATER: Record = {}; -const BUILDERS: Partial> = { - dg_list: buildDaegaList, - dg: buildDaegaBlocks, - bill: buildBill, - basis: buildBasis, - machine: buildMachine, - machine_blocks: buildMachineBlocks, - material: buildMaterial, - labor: buildLabor, - expense: buildExpense, - compare: buildCompare, - cost: buildCost, - total: buildTotal, - sum_material: buildSumMaterial, - sum_labor: buildSumLabor, - sum_expense: buildSumExpense, - sum_machine: buildSumMachine, - sg_list: buildSgList, - sg: buildSg, -}; - async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", @@ -198,10 +158,9 @@ export function renderB09Estimation(root: HTMLElement): void { bars.setTools([]); // 보기 단추 · 열 수는 묶음 탭만 다시 채움(202-1) if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요.")); if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…")); - const build = BUILDERS[tab] ?? DRAFT.builders[tab]; - if (!build) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움")); const ctx: TabContext = { sheets, payload, user, setUser }; - const spec = build(ctx); + const spec = tabTableOf(tab, ctx); + if (!spec) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움")); if (typeof spec === "string") return void panel.replaceChildren(note(spec)); const goto = (link: Link | null): void => { if (link) show(link.tab, link.key); @@ -209,23 +168,10 @@ export function renderB09Estimation(root: HTMLElement): void { // extra = 줄 class 곁값(181-2 표 보기 호표 구간) const makeTable = (extra?: (index: number) => string | null): DataTable => { const made = createDataTable({ - columns: spec.columns, - rows: spec.rows, - total: spec.total ?? null, - frozen: spec.frozen ?? 0, - empty: "", - caption: TABS.find(([id]) => id === tab)?.[1], - editable: spec.editable, - // 243-1 고칠 수 있는 칸 = 모든 표 더블클릭 · Enter · F2 (설계내역서 키 = 레벨 키 몫) - editOnDouble: spec.editOnDouble ?? true, - keyEdit: !(spec as BillTable).menu, - onEdit: spec.onEdit, + ...tableOptionsOf(spec, tab), rowClass: extra ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null : spec.rowClass, - cellClass: spec.cellClass, - level: spec.level, - mergeSame: spec.mergeSame, onCellClick: (i, key) => { if (spec.onCell) spec.onCell(i, key); if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); diff --git a/resources/tester/test_145_1_b09_sangeun.py b/resources/tester/test_145_1_b09_sangeun.py index 3c1af96ab..087203723 100644 --- a/resources/tester/test_145_1_b09_sangeun.py +++ b/resources/tester/test_145_1_b09_sangeun.py @@ -178,7 +178,8 @@ def test_작업량_Q는_소수_둘(): def test_화면_탭_둘_서버_산근_그대로(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "sg_list: buildSgList," in shell and "sg: buildSg," in shell + builders = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Builders.ts").read_text("utf-8") + assert "sg_list: buildSgList," in builders and "sg: buildSg," in builders # 303-2 표 짜기 assert 'const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const;' in shell assert "gaps: [...base.gaps, ...(server.sg_gaps || [])" in shell # 뒤에 대가(145-2) gaps 이음 tabs = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Sangeun.ts").read_text("utf-8") diff --git a/resources/tester/test_183_3_b09_applied_pick.py b/resources/tester/test_183_3_b09_applied_pick.py index 3e4f90e27..533f89669 100644 --- a/resources/tester/test_183_3_b09_applied_pick.py +++ b/resources/tester/test_183_3_b09_applied_pick.py @@ -101,12 +101,14 @@ def test_배선_수기_적용_칸_출처_돌림_없앰(): assert 'editable: (_i, key) => key === "p:견적",' in compare assert "nextPick" not in base and 'key === "applied_from"' not in compare assert 'key === `p:${rows[i]?.applied_from}` ? "is-picked"' in compare - assert 'key === "applied" && rows[i] ? chooseApplied(rows[i]) : null' in compare # 257-3 메뉴 둘 공용 + assert ( + 'key === "applied" && rows[i] ? chooseApplied(rows[i]) : null' in compare + ) # 257-3 메뉴 둘 공용 assert "delete cell.applied" in compare shell = _src("B09_Estimation/B09_Estimation_UI_Shell.ts") - assert ( - "cellClass: spec.cellClass," in shell and "openMenuAt(box.left, box.bottom, items)" in shell - ) + builders = _src("B09_Estimation/B09_Estimation_UI_Builders.ts") # 303-2 옵션 짜기 + assert "cellClass: spec.cellClass," in builders and "...tableOptionsOf(spec, tab)," in shell + assert "openMenuAt(box.left, box.bottom, items)" in shell edit = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts") assert "export function openMenuAt(" in edit and "const ctxMenu = sharedMenu();" in edit css = _src("B09_Estimation/B09_Estimation_UI_Page.css") diff --git a/resources/tester/test_201_1_table_level_merge.py b/resources/tester/test_201_1_table_level_merge.py index c2e564442..efe891570 100644 --- a/resources/tester/test_201_1_table_level_merge.py +++ b/resources/tester/test_201_1_table_level_merge.py @@ -120,7 +120,7 @@ APPLIED = [ ("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["group", "part"]'), ("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["part"]'), ("B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts", 'group: (i) => rows[i]?.kind !== "item"'), - ("B09_Estimation/B09_Estimation_UI_Shell.ts", "mergeSame: spec.mergeSame"), + ("B09_Estimation/B09_Estimation_UI_Builders.ts", "mergeSame: spec.mergeSame"), ] diff --git a/resources/tester/test_235_1_b09_bill_tree.py b/resources/tester/test_235_1_b09_bill_tree.py index 46b549af0..72f567665 100644 --- a/resources/tester/test_235_1_b09_bill_tree.py +++ b/resources/tester/test_235_1_b09_bill_tree.py @@ -345,7 +345,8 @@ def test_고른_줄_상자_테두리_테마_토큰(): def test_Shell_배선(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "editOnDouble: spec.editOnDouble ?? true," in shell # 243-1 기본 = 더블클릭 + builders = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Builders.ts").read_text("utf-8") + assert "editOnDouble: spec.editOnDouble ?? true," in builders # 243-1 기본 = 더블클릭 assert "levelBar(table.root, bill, (i) => table.edit(i))" in shell for name in [ "ui_template/ui_template_data_table.ts", diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index a261089b1..e8e03aadd 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -114,8 +114,10 @@ def test_B09_표_만드는_길_한_곳_더블클릭_기본(): ] assert calls == ["B09_Estimation_UI_Shell.ts"] shell = (ROOT / "B09_Estimation" / calls[0]).read_text("utf-8") - assert "editOnDouble: spec.editOnDouble ?? true," in shell - assert "keyEdit: !(spec as BillTable).menu," in shell # 설계내역서 Enter · F2 = 레벨 키 몫 + assert "...tableOptionsOf(spec, tab)," in shell # 303-2 옵션 짜기 = Builders 한 곳 + builders = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Builders.ts").read_text("utf-8") + assert "editOnDouble: spec.editOnDouble ?? true," in builders + assert "keyEdit: !(spec as BillTable).menu," in builders # 설계내역서 Enter · F2 = 레벨 키 몫 def test_B08_고칠_칸_있는_표_모두_editOnDouble(): From 6c3831ce43e4ec24a7f5eb951f0bb05d11bcdb52 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 07:51:02 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat(b09-print):=20=EC=9D=B8=EC=87=84=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=ED=91=9C=20=EB=84=98=EA=B9=80=20=E2=80=94?= =?UTF-8?q?=20B09=2018=EC=9E=A5=20=3D=20=EA=B8=B0=EB=B2=883=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=EC=8B=9C=ED=8A=B8=20=EC=B0=A8=EB=A1=80=20?= =?UTF-8?q?=C2=B7=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=C2=B7=20=ED=99=94=EB=A9=B4=20=ED=91=9C=20=EC=A7=9C=EA=B8=B0(Bu?= =?UTF-8?q?ilders)=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EC=97=B0=EA=B2=B0=20=EC=B9=B8=20=C2=B7=20=EC=86=90=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EA=B0=95=EC=A1=B0=20=C2=B7=20=EB=88=84?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20=EA=B3=A0=EC=B9=A8=20=EB=BA=8C=20(302-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- B09_Estimation/draft/B09_Draft_Print_Cost.ts | 96 ++++++++++ .../tester/print_cost/print_cost_entry.ts | 44 +++++ resources/tester/test_302_4_print_cost.py | 178 ++++++++++++++++++ 3 files changed, 318 insertions(+) create mode 100644 B09_Estimation/draft/B09_Draft_Print_Cost.ts create mode 100644 resources/tester/print_cost/print_cost_entry.ts create mode 100644 resources/tester/test_302_4_print_cost.py diff --git a/B09_Estimation/draft/B09_Draft_Print_Cost.ts b/B09_Estimation/draft/B09_Draft_Print_Cost.ts new file mode 100644 index 000000000..a05fc2abd --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Cost.ts @@ -0,0 +1,96 @@ +/* ============================================================================= + * B09_Draft_Print_Cost.ts + * 인쇄 미리보기(PLAN 302-4 · 초안) 「원가계산」 영역 — B09 18장을 실무 내역서 차례로 모음. + * 차례 · 이름 · 방향 = 기번3 내역서 xlsx 시트(목차 · 안내 빼고 · page_setup.orientation) + * 값 = GET …/estimation + 화면 캐시(`b09-estimation`) → `computeSheets` = B09 화면과 같은 값 + * 표 = 화면 표 짜기(`…_UI_Builders`) 그대로 · 링크 · 강조 · 고칠 칸 표시 · 단가연결 칸만 뺌 + * 공기 초안 탭(공기집계 · 공기산출) = 내역서 차례 밖 → 안 넣음 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DataTableOptions } from "@ui/ui_template_data_table"; +import { readState } from "../../A00_Common/b_page_state"; +import type { Row } from "../B09_Estimation_Calc_Base"; +import { tableOptionsOf, tabTableOf } from "../B09_Estimation_UI_Builders"; +import { computeSheets } from "../B09_Estimation_UI_Shell"; +import type { TabContext, TabId } from "../B09_Estimation_UI_Tabs"; +import type { CollectSheets, PrintSheet } from "./B09_Draft_Print_Types"; + +type Orientation = NonNullable; + +/** 기번3 내역서 시트 차례 — [탭, 시트 이름, 인쇄 방향] */ +export const COST_SHEETS: Array<[TabId, string, Orientation]> = [ + ["cost", "공사원가계산서", "landscape"], + ["total", "총괄설계내역서", "landscape"], + ["bill", "설계내역서", "landscape"], + ["dg_list", "일위대가목록표", "landscape"], + ["dg", "일위대가표", "landscape"], + ["sg_list", "단가산출근거목록표", "landscape"], + ["sg", "단가산출근거", "landscape"], + ["basis", "환율및기초자료", "landscape"], + ["machine", "중기목록표", "landscape"], + ["machine_blocks", "중기사용료", "landscape"], + ["material", "재료비목록표", "landscape"], + ["labor", "노무비목록표", "landscape"], + ["expense", "경비목록표", "landscape"], + ["compare", "자재단가대비표", "landscape"], + ["sum_material", "재료비수량금액집계표", "landscape"], + ["sum_labor", "노무비수량금액집계표", "landscape"], + ["sum_expense", "경비수량금액집계표", "landscape"], + ["sum_machine", "중기시간금액집계표", "landscape"], +]; + +/** 화면 조작 전용 칸(설계내역서 「단가연결 ▾」) */ +const UI_COLUMNS = new Set(["connect"]); +/** 인쇄에 남길 줄 꼴(제목 줄 굵게) — 손 줄 · 고른 줄 표시는 뺌 */ +const KEEP_ROW = new Set(["is-title"]); + +/** 화면 표 옵션 → 인쇄 표(값 · 머리 · 레벨 · 합계 · 병합 그대로 · 누름 · 고침 · 강조 뺌) */ +export function printTable(options: DataTableOptions, caption: string): DataTableOptions { + const { columns, rows, total, frozen, empty, level, mergeSame, rowClass } = options; + return { + columns: columns.filter((c) => !UI_COLUMNS.has(c.key)), + rows, + total, + frozen, + empty, + caption, + mergeSame, + level: level && { ...level, tree: false }, + rowClass: + rowClass && + ((i) => + rowClass(i) + ?.split(" ") + .filter((c) => KEEP_ROW.has(c)) + .join(" ") || null), + }; +} + +/** 받은 자료 + 사용자 값 → 18장(차례 그대로 · 표가 없는 탭 = 빈 표) */ +export function costSheetsOf(payload: Row, user: Row): PrintSheet[] { + const ctx: TabContext = { + sheets: computeSheets(payload, user), + payload, + user, + setUser: () => {}, + }; + return COST_SHEETS.map(([id, name, orientation]) => { + const spec = tabTableOf(id, ctx); + const table = + !spec || typeof spec === "string" + ? { columns: [], rows: [], caption: name } + : printTable(tableOptionsOf(spec, id), name); + return { id, name, orientation, table }; + }); +} + +export const collectCostSheets: CollectSheets = async (projectId) => { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation`, { + credentials: "include", + }); + if (!response.ok) throw new Error(`원가 자료를 불러오지 못했습니다 — HTTP ${response.status}`); + const payload = (await response.json()) as Row; + const user = readState("b09-estimation", projectId) ?? payload.user ?? {}; + return costSheetsOf(payload, user); +}; diff --git a/resources/tester/print_cost/print_cost_entry.ts b/resources/tester/print_cost/print_cost_entry.ts new file mode 100644 index 000000000..67bb250a7 --- /dev/null +++ b/resources/tester/print_cost/print_cost_entry.ts @@ -0,0 +1,44 @@ +/* ============================================================================= + * print_cost_entry.ts (PLAN 302-4 시험 진입) + * 원가계산 인쇄 쪽(`B09_Draft_Print_Cost.costSheetsOf`)을 화면 표 짜기(`tabTableOf`)와 대조. + * vite `--ssr` 로 번들해 node 로 실행 — 인자 = payload JSON 경로 · 출력 = 쪽마다 대조 JSON. + * ========================================================================== */ + +import { readFileSync } from "node:fs"; +import { costSheetsOf } from "../../../B09_Estimation/draft/B09_Draft_Print_Cost"; +import { tabTableOf } from "../../../B09_Estimation/B09_Estimation_UI_Builders"; +import { computeSheets } from "../../../B09_Estimation/B09_Estimation_UI_Shell"; +import type { TabId } from "../../../B09_Estimation/B09_Estimation_UI_Tabs"; + +const payload = JSON.parse(readFileSync(process.argv[2], "utf-8")); +const user = payload.user ?? {}; +const ctx = { sheets: computeSheets(payload, user), payload, user, setUser: () => {} }; + +const out = costSheetsOf(payload, user).map((sheet) => { + const spec = tabTableOf(sheet.id as TabId, ctx); + const screen = !spec || typeof spec === "string" ? null : spec; + const t = sheet.table; + const n = t.rows.length; + return { + id: sheet.id, + name: sheet.name, + orientation: sheet.orientation, + rows: n, + screenRows: screen ? screen.rows.length : 0, + sameRows: JSON.stringify(t.rows) === JSON.stringify(screen?.rows ?? []), + total: t.total ?? null, + screenTotal: screen?.total ?? null, + columns: t.columns.map((c) => c.key), + screenColumns: screen ? screen.columns.map((c) => c.key) : [], + groups: t.columns.filter((c) => c.group?.length).length, + level: t.level ? { key: t.level.key, tree: t.level.tree ?? false } : null, + levels: t.level ? Array.from({ length: n }, (_, i) => t.level?.of(i) ?? null) : [], + rowClass: t.rowClass ? Array.from({ length: n }, (_, i) => t.rowClass?.(i) ?? null) : [], + mergeSame: t.mergeSame ?? [], + caption: t.caption, + interactive: ["editable", "onEdit", "onCellClick", "onRowClick", "cellClass", "focus"].filter( + (k) => k in t, + ), + }; +}); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_302_4_print_cost.py b/resources/tester/test_302_4_print_cost.py new file mode 100644 index 000000000..658c7c010 --- /dev/null +++ b/resources/tester/test_302_4_print_cost.py @@ -0,0 +1,178 @@ +"""PLAN 302-4 인쇄 미리보기 원가계산 — B09 18장 = 기번3 내역서 시트 차례 · 이름 · 방향 · +쪽 표 = 화면 표 짜기와 같은 줄 · 합계(링크 · 강조 · 고칠 칸 · 단가연결 칸만 뺌). + +차례 = 실무 xlsx(openpyxl · 목차 · 안내 · 숨김 시트 빼고) · 견본 = 임시 폴더 프로젝트(145-2 한 바퀴 꼴) +→ 서버 셈 payload → `print_cost_entry.ts` 를 vite `--ssr` 로 번들해 node 로 대조. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +import tempfile +import warnings +from pathlib import Path + +import openpyxl +import pytest + +ROOT = Path(__file__).resolve().parents[2] +for path in (ROOT, ROOT / "resources" / "tester"): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +import test_145_2_b09_labor_calc as t145 # noqa: E402 + +from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 +from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402 +from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table # noqa: E402 +from B09_Estimation.B09_Estimation_Router import compute_twice # noqa: E402 + +COST = ROOT / "B09_Estimation" / "draft" / "B09_Draft_Print_Cost.ts" +TABS = ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs.ts" +ENTRY = ROOT / "resources" / "tester" / "print_cost" / "print_cost_entry.ts" +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" +XLSX = ( + ROOT + / "resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(공통)" + / "1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx" +) +#: 내역서 앞 시트(차례 밖) +FRONT = ("〓 목 차 〓", "※※안내※※") + +ENTRY_RE = re.compile(r'\["(\w+)", "([^"]+)", "(landscape|portrait)"\]') + + +def _cost_sheets() -> list[tuple[str, str, str]]: + src = COST.read_text("utf-8") + body = src[src.index("COST_SHEETS") : src.index("];", src.index("COST_SHEETS"))] + return ENTRY_RE.findall(body) + + +def _practical() -> list[tuple[str, str]]: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # 인쇄 영역 정의 이름 경고 + wb = openpyxl.load_workbook(XLSX) + return [ + (ws.title, ws.page_setup.orientation or "landscape") + for ws in wb.worksheets + if ws.sheet_state == "visible" and ws.title not in FRONT + ] + + +def test_차례_이름_방향_기번3_내역서(): + ours = _cost_sheets() + assert len(ours) == 18 + assert [(name, o) for _id, name, o in ours] == _practical() + assert all(len(name) <= 31 for _id, name, _o in ours) # 엑셀 시트 이름 한도 + # 탭 이름 = 화면 탭 이름(B09 TABS 앞 18장 · 같은 차례) + tabs = re.findall(r'^ \["(\w+)", "([^"]+)"\],$', TABS.read_text("utf-8"), re.MULTILINE) + assert [(i, n) for i, n, _o in ours] == tabs[:18] + assert not {"schedule_sum", "schedule_calc"} & {i for i, _n, _o in ours} # 공기 초안 뺌 + + +def _payload(root: Path) -> dict: + """145-2 한 바퀴 견본 — 관 둘(대가 · 산근 · 중기 · 사전조합) · 설계내역 한 줄 → 라우터 꼴 payload.""" + t145._snap(root, "pipe@100.0", "SS000901", t145.LINES, t145._values(t145.STONE)) + promoted = [ + {"로직": "GF000119", "승격": True, "수량": {"손": 1}}, + {"참조": {"층": "system", "주인": None, "키": "SC-T"}, "수량": {"손": 4}}, + ] + t145._snap(root, "pipe@400.0", "SS000902", promoted, t145._values(t145.STONE), t145.SC) + inputs = prices.build_inputs(None, linked_resource_keys(root)) + inputs["daega"] = daega_inputs(root) + inputs["hopyo"] = {} + inputs["bill"] = { + "items": [ + {"key": "st:revet", "section": "2.1", "name": "돌기슭막이", "spec": "", "unit": "m", + "qty": 10, "ref": {"tab": "dg_list", "key": "revet"}, "note": "", + "owners": {"pipe@100.0": 4, "pipe@400.0": 6}}, + ] + } # fmt: skip + sheets, summary = compute_twice(root, inputs, {}) + return { + "inputs": inputs, + "user": {}, + "book": None, + "stale": None, + "sheets": sheets, + "rate_candidates": rate_candidates(), + "rate_table": rate_table(), + "notes": [], + "summary": summary, + } + + +@pytest.fixture(scope="module") +def pages(tmp_path_factory) -> list[dict]: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + work = tmp_path_factory.mktemp("p302_4") + root = t145._project(work / "proj", [(100, "SS000901"), (400, "SS000902")]) + data = work / "payload.json" + data.write_text(json.dumps(_payload(root), ensure_ascii=False, default=str), "utf-8") + with tempfile.TemporaryDirectory(prefix="print_cost_test_") as outdir: + entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/") + build = subprocess.run( + [ + "node", + str(VITE), + "build", + "--configLoader", + "runner", + "--ssr", + entry_rel, + "--outDir", + outdir, + ], + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=180, + ) + assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}" + run = subprocess.run( + ["node", str(Path(outdir) / "print_cost_entry.js"), str(data)], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + assert run.stdout.strip(), f"시험 진입이 아무 결과도 안 냄:\n{run.stderr}" + return json.loads(run.stdout) + + +def test_쪽_차례_이름_방향(pages): + assert [(p["id"], p["name"], p["orientation"]) for p in pages] == _cost_sheets() + assert all(p["caption"] == p["name"] for p in pages) + + +def test_쪽_표_화면과_같은_줄_합계(pages): + for p in pages: + assert p["sameRows"] and p["rows"] == p["screenRows"], p["id"] + assert p["total"] == p["screenTotal"], p["id"] + assert p["columns"] == [k for k in p["screenColumns"] if k != "connect"], p["id"] + assert p["interactive"] == [], p["id"] # 누름 · 고침 · 강조 뺌 + by = {p["id"]: p for p in pages} + # 견본이 표를 채움 — 내역 · 대가 · 산근 · 중기 · 집계 + for tab in ("bill", "dg_list", "dg", "sg_list", "sg", "machine", "sum_labor", "cost", "total"): + assert by[tab]["rows"] > 0, tab + + +def test_머리_여러_줄_레벨_병합_꼴(pages): + by = {p["id"]: p for p in pages} + bill = by["bill"] + assert bill["groups"] > 0 # 머리 두 줄(재료비 · 노무비 · 경비 묶음) + assert "connect" not in bill["columns"] # 단가연결 메뉴 칸 뺌 + assert bill["level"] == {"key": "name", "tree": False} # 들여쓰기만 · 나무 아이콘 뺌 + assert max(v for v in bill["levels"] if v) >= 2 + assert {c for c in bill["rowClass"] if c} <= {"is-title"} # 손 줄 · 고른 줄 표시 뺌 + assert "is-title" in bill["rowClass"] + assert by["total"]["mergeSame"] == ["part"] # 총괄 공종 칸 세로 병합 From ed473062096e01384c86d6bce844f184c0f84a3d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 07:52:49 +0900 Subject: [PATCH 06/17] =?UTF-8?q?feat(b09-print):=20=EC=9D=B8=EC=87=84=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=ED=8B=80=20=E2=80=94=20?= =?UTF-8?q?draft=20=EA=B8=B8=20=EC=84=A0=EC=96=B8=20glob=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=ED=8E=98=EC=9D=B4=EC=A7=80(=EC=98=81?= =?UTF-8?q?=EC=97=AD=20=EC=85=8B=20=C2=B7=20=EC=AA=BD=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=20=C2=B7=20PDF=20=EB=B3=B4=EA=B8=B0=20=C2=B7=20=EB=82=B4?= =?UTF-8?q?=EB=A0=A4=EB=B0=9B=EA=B8=B0)=20=C2=B7=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=ED=91=9C=20PDF(Playwright)=20=C2=B7=20=EC=97=91=EC=85=80(openp?= =?UTF-8?q?yxl)=20=C2=B7=20B09=20Router=20=EC=B4=88=EC=95=88=20=EA=B8=B8?= =?UTF-8?q?=20=EB=81=BC=EC=9A=B0=EA=B8=B0=20(302-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- A00_Common/main.ts | 4 +- A00_Common/router.ts | 33 +- B09_Estimation/B09_Estimation_Router.py | 18 + B09_Estimation/draft/B09_Draft_Print_Page.css | 52 +++ B09_Estimation/draft/B09_Draft_Print_Page.ts | 309 ++++++++++++++++ B09_Estimation/draft/B09_Draft_Print_Route.ts | 15 + .../draft/B09_Draft_Print_Router.py | 334 ++++++++++++++++++ B09_Estimation/draft/B09_Draft_Print_Table.ts | 146 ++++++++ resources/tester/test_302_1_print_frame.py | 251 +++++++++++++ 9 files changed, 1157 insertions(+), 5 deletions(-) create mode 100644 B09_Estimation/draft/B09_Draft_Print_Page.css create mode 100644 B09_Estimation/draft/B09_Draft_Print_Page.ts create mode 100644 B09_Estimation/draft/B09_Draft_Print_Route.ts create mode 100644 B09_Estimation/draft/B09_Draft_Print_Router.py create mode 100644 B09_Estimation/draft/B09_Draft_Print_Table.ts create mode 100644 resources/tester/test_302_1_print_frame.py diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 56ae1a236..571f02c10 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -20,6 +20,7 @@ import { renderCurrentRoute, ensureInitialHash, setLeaveGuard, + draftRoute, showWithinPage, takeLeaveGuard, } from "./router"; @@ -70,7 +71,8 @@ function bootstrap(): void { shown = currentRoute(); shownHash = window.location.hash; if (within) return; - const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute()); + const showFooter = + !FOOTERLESS_ROUTES.includes(currentRoute()) && !draftRoute(currentRoute())?.footerless; const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신 void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더 }; diff --git a/A00_Common/router.ts b/A00_Common/router.ts index f41d74244..c5680ec2d 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -74,6 +74,29 @@ const routeTable: Partial Promise>> = { (await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate, }; +/** 초안 길(302-1) — 페이지 폴더 `draft/*_Route.ts` 의 `ROUTE` 선언 · 폴더째 지우면 길도 없음. + * 정적 표에 이름을 박지 않음 — 보호 · 단계 화면(로그인 · 회사) · 프로젝트 · 꼬리말 없음도 선언에서. */ +export interface DraftRoute { + path: string; + load: () => Promise; + /** 로그인 · 회사 필요(B 단계 화면) — 로그인만이면 protected */ + workflow?: boolean; + protected?: boolean; + /** 현재 프로젝트로 여는 화면(지워진 프로젝트 안내) */ + project?: boolean; + footerless?: boolean; +} + +const DRAFT_ROUTES = new Map( + Object.values(import.meta.glob<{ ROUTE: DraftRoute }>("../*/draft/*_Route.ts", { eager: true })) + .map((m) => m.ROUTE) + .filter(Boolean) + .map((r) => [r.path, r] as const), +); + +/** 초안 길 선언(없으면 undefined) */ +export const draftRoute = (route: string): DraftRoute | undefined => DRAFT_ROUTES.get(route); + /** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */ let leaveGuard: (() => Promise) | null = null; export const setLeaveGuard = (guard: (() => Promise) | null): void => { @@ -159,7 +182,9 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { mounted?.dispose(); mounted = null; - if (PROTECTED_ROUTES.includes(route) && !(await isAuthenticated())) { + const draft = draftRoute(route); + const guarded = PROTECTED_ROUTES.includes(route) || !!(draft?.protected || draft?.workflow); + if (guarded && !(await isAuthenticated())) { navigateTo(ROUTES.A06_LOGIN); return; } @@ -178,7 +203,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { ROUTES.B11_LOADING, ]; - if (workflowRoutes.includes(route)) { + if (workflowRoutes.includes(route) || draft?.workflow) { const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch"); try { const user = await fetchSessionUser(); @@ -197,7 +222,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { } // 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 B 단계 화면이 여기 한 곳을 지난다. - if (PROJECT_ROUTES.includes(route)) { + if (PROJECT_ROUTES.includes(route) || draft?.project) { const { dropGoneProject } = await import("./b_workflow_nav"); if (await dropGoneProject()) { if (seq !== renderSeq) return; @@ -208,7 +233,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { } } - const loader = routeTable[route]; + const loader = routeTable[route] ?? draft?.load; if (!loader) { renderPlaceholder(outlet, route); return; diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 10e53f93e..392b11005 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -29,6 +29,8 @@ from __future__ import annotations import asyncio +import importlib +import logging from pathlib import Path from typing import Any from uuid import UUID @@ -341,3 +343,19 @@ async def library_inputs( # 166-4 견적서 파일 길 — 같은 머리 · 같은 권한 router.include_router(quotes_router) + + +# 302 초안 길(draft/*_Router.py 의 router · 폴더째 지우면 길도 없음) — 같은 머리 · 같은 권한 +def include_drafts(target: APIRouter, folder: Path, package: str) -> list[str]: + """folder 의 *_Router.py 마다 router 를 끼움 → 끼운 파일 이름(못 읽은 초안은 건너뜀).""" + done = [] + for path in sorted(folder.glob("*_Router.py")): + try: + target.include_router(importlib.import_module(f"{package}.{path.stem}").router) + done.append(path.stem) + except (ImportError, AttributeError) as error: # 한 초안이 막혀도 B09 는 뜸 + logging.getLogger(__name__).warning("초안 길 %s 못 읽음 — %s", path.name, error) + return done + + +include_drafts(router, Path(__file__).parent / "draft", "B09_Estimation.draft") diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.css b/B09_Estimation/draft/B09_Draft_Print_Page.css new file mode 100644 index 000000000..0b7a5e242 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Page.css @@ -0,0 +1,52 @@ +/* 인쇄 미리보기(PLAN 302-1 · 초안) — 상태 한 줄 + PDF 보기 칸(창 아래 끝까지). 색은 테마 변수만. */ + +.b09-print { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + height: calc(100vh - var(--spacing-64)); + padding: var(--spacing-16) var(--spacing-24); + box-sizing: border-box; +} + +.b09-print__status { + margin: 0; + min-height: 1.4em; + font-size: var(--text-caption); + color: var(--color-text-secondary); +} + +.b09-print__status.is-error { + color: var(--color-danger); +} + +.b09-print__frame { + flex: 1; + width: 100%; + min-height: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface, var(--color-canvas)); +} + +.b09-print__areas { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); +} + +.b09-print__list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + max-height: 50vh; + overflow: auto; +} + +.b09-print__item { + display: flex; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-body-sm); + cursor: pointer; +} diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.ts b/B09_Estimation/draft/B09_Draft_Print_Page.ts new file mode 100644 index 000000000..247cfcf8a --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Page.ts @@ -0,0 +1,309 @@ +/* ============================================================================= + * B09_Draft_Print_Page.ts + * 인쇄 미리보기 페이지(PLAN 302-1 · 요구 192-3 · 초안) — 주소 #/b09-print(`…_Print_Route`). + * 좌측 = 영역 셋(도면 · 수량산출 · 원가계산) 고르기 + 그 영역 쪽 목록(체크로 빼고 넣기 · 차례 그대로) + + * [PDF 내려받기] [엑셀 내려받기](도면 = PDF 만) + [B09 로 돌아가기] + * 본문 = 고른 쪽들의 PDF 를 브라우저 PDF 보기로(보는 것 = 받는 것) · 만드는 동안 작은 표시(잠금 없음) · + * 실패 = 까닭 한 줄 · 체크를 바꾸면 다시 만듦(최신 요청만 채택) + * 영역 = 다른 창 파일(`…_Print_Quantity` collectQuantitySheets · `…_Print_Cost` collectCostSheets · + * `…_Print_Drawings` fetchDrawingList · fetchDrawingsPdf) — 없으면 「준비 중」 + * 표 영역 PDF · 엑셀 = 서버(`B09_Draft_Print_Router` table-pdf · table-xlsx) · 서버는 꼴만 바꿈 + * ========================================================================== */ + +import "@ui/ui_template_general_layout.css"; +import "./B09_Draft_Print_Page.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { createButton } from "@ui/ui_template_elements"; +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import type { MountedPage } from "../../A00_Common/router"; +import { navigateTo } from "../../A00_Common/router"; +import { workflowSteps } from "../../A00_Common/b_page_scaffold"; +import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../../A00_Common/b_workflow_nav"; +import { inlineImages, sheetGrid, sheetHtml } from "./B09_Draft_Print_Table"; +import type { + CollectSheets, + DrawingItem, + FetchDrawingList, + FetchDrawingsPdf, + PrintSheet, +} from "./B09_Draft_Print_Types"; + +type AreaId = "drawings" | "quantity" | "cost"; + +const AREAS: { id: AreaId; label: string; file: string }[] = [ + { id: "drawings", label: "도면", file: "./B09_Draft_Print_Drawings.ts" }, + { id: "quantity", label: "수량산출", file: "./B09_Draft_Print_Quantity.ts" }, + { id: "cost", label: "원가계산", file: "./B09_Draft_Print_Cost.ts" }, +]; + +/** 영역 파일(다른 창 몫) — 없는 파일은 목록에 없음 = 「준비 중」 */ +const MODS = import.meta.glob([ + "./B09_Draft_Print_Drawings.ts", + "./B09_Draft_Print_Quantity.ts", + "./B09_Draft_Print_Cost.ts", +]); + +/** 영역 한 벌 — 쪽 목록 · 고른 쪽 PDF · 엑셀(도면 없음) */ +interface Area { + items: DrawingItem[]; + pdf: (ids: string[]) => Promise; + xlsx?: (ids: string[]) => Promise; +} + +async function post(projectId: string, path: string, body: unknown): Promise { + const res = await fetch(`${API_BASE_URL}/projects/${projectId}/print/${path}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const detail = ((await res.json().catch(() => null)) as { detail?: unknown } | null)?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${res.status}`); + } + return res.blob(); +} + +/** 표 영역 — 쪽 = PrintSheet · PDF · 엑셀 = 서버 꼴 바꿈 */ +function tableArea(projectId: string, title: string, sheets: PrintSheet[]): Area { + const pick = (ids: string[]) => ids.map((id) => sheets.find((s) => s.id === id)!); + return { + items: sheets.map(({ id, name }) => ({ id, name })), + pdf: async (ids) => { + const picked = await Promise.all(pick(ids).map(inlineImages)); + return post(projectId, "table-pdf", { + title, + sheets: picked.map((s) => ({ + name: s.name, + orientation: s.orientation ?? "portrait", + html: sheetHtml(s), + })), + }); + }, + xlsx: (ids) => + post(projectId, "table-xlsx", { + title, + sheets: pick(ids).map((s) => ({ name: s.name, grid: sheetGrid(s.table) })), + }), + }; +} + +/** 영역 파일 읽기 — 없음 = null(준비 중) */ +async function loadArea(projectId: string, id: AreaId): Promise { + const def = AREAS.find((a) => a.id === id)!; + const load = MODS[def.file]; + if (!load) return null; + const mod = (await load()) as Record; + if (id === "drawings") { + const list = mod.fetchDrawingList as FetchDrawingList | undefined; + const pdf = mod.fetchDrawingsPdf as FetchDrawingsPdf | undefined; + if (!list || !pdf) return null; + return { items: await list(projectId), pdf: (ids) => pdf(projectId, ids) }; + } + const collect = mod[id === "quantity" ? "collectQuantitySheets" : "collectCostSheets"] as + CollectSheets | undefined; + return collect ? tableArea(projectId, def.label, await collect(projectId)) : null; +} + +function save(blob: Blob, name: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +export function renderB09Print(root: HTMLElement): MountedPage { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + + const status = document.createElement("p"); + status.className = "b09-print__status"; + status.dataset.b09PrintStatus = ""; + const frame = document.createElement("iframe"); + frame.className = "b09-print__frame"; + frame.title = "인쇄 미리보기"; + const body = document.createElement("div"); + body.className = "b09-print"; + body.append(status, frame); + + const areaCard = createPanelCard("영역"); + const areaRow = document.createElement("div"); + areaRow.className = "b09-print__areas"; + areaCard.append(areaRow); + const sheetCard = createPanelCard("쪽 목록"); + const list = document.createElement("div"); + list.className = "b09-print__list"; + sheetCard.append(list); + + let area: Area | null = null; + let areaId: AreaId = AREAS[0].id; + let off = new Set(); // 체크 뺀 쪽 + let seq = 0; + let shown: { url: string; blob: Promise } | null = null; + let timer = 0; + + const label = () => AREAS.find((a) => a.id === areaId)!.label; + const say = (text: string, error = false) => { + status.textContent = text; + status.classList.toggle("is-error", error); + }; + const picked = () => (area?.items ?? []).map((it) => it.id).filter((id) => !off.has(id)); + const clearFrame = () => { + if (shown?.url) URL.revokeObjectURL(shown.url); + shown = null; + frame.removeAttribute("src"); + }; + + /** 고른 쪽 PDF 다시 만듦 — 최신 요청만 보임 · 내려받기는 같은 PDF */ + const rebuild = () => { + const my = ++seq; + const ids = picked(); + clearFrame(); + if (!area) return; + if (!ids.length) return say("고른 쪽이 없습니다."); + say(`${label()} PDF 만드는 중…`); + const blob = area.pdf(ids); + shown = { url: "", blob }; + blob.then( + (b) => { + if (my !== seq) return; + shown!.url = URL.createObjectURL(b); + frame.src = shown!.url; + say(`${label()} · ${ids.length}쪽 묶음`); + }, + (error: unknown) => my === seq && say(`PDF 를 못 만들었습니다 — ${errText(error)}`, true), + ); + }; + const later = () => { + clearTimeout(timer); + timer = window.setTimeout(rebuild, 300); + }; + + const pdfBtn = createButton({ + label: "PDF 내려받기", + onClick: () => { + if (!shown) return; + const name = `${label()}.pdf`; + shown.blob.then( + (b) => save(b, name), + () => undefined, + ); + }, + }); + pdfBtn.dataset.b09PrintPdf = ""; + const xlsxBtn = createButton({ + label: "엑셀 내려받기", + variant: "ghost", + onClick: () => { + const ids = picked(); + if (!area?.xlsx || !ids.length) return; + const name = `${label()}.xlsx`; + say(`${label()} 엑셀 만드는 중…`); + area.xlsx(ids).then( + (b) => (save(b, name), say(`${label()} 엑셀을 내려받았습니다.`)), + (error: unknown) => say(`엑셀을 못 만들었습니다 — ${errText(error)}`, true), + ); + }, + }); + xlsxBtn.dataset.b09PrintXlsx = ""; + const back = createButton({ + label: "B09 로 돌아가기", + variant: "ghost", + onClick: () => navigateTo(ROUTES.B09_ESTIMATION), + }); + + const drawList = () => { + list.replaceChildren( + ...(area?.items ?? []).map((it) => { + const row = document.createElement("label"); + row.className = "b09-print__item"; + const box = document.createElement("input"); + box.type = "checkbox"; + box.checked = !off.has(it.id); + box.dataset.b09PrintItem = it.id; + box.addEventListener("change", () => { + if (box.checked) off.delete(it.id); + else off.add(it.id); + later(); + }); + const name = document.createElement("span"); + name.textContent = it.name; + row.append(box, name); + return row; + }), + ); + }; + + const pickArea = (id: AreaId) => { + areaId = id; + area = null; + off = new Set(); + const my = ++seq; + clearFrame(); + areaRow.replaceChildren( + ...AREAS.map((a) => { + const b = createButton({ + label: a.label, + size: "sm", + variant: a.id === id ? "filled" : "ghost", + onClick: () => a.id !== areaId && pickArea(a.id), + }); + b.dataset.b09PrintArea = a.id; + return b; + }), + ); + xlsxBtn.hidden = id === "drawings"; + drawList(); + if (!projectId) return say("현재 프로젝트가 없습니다.", true); + say(`${label()} 쪽 목록 읽는 중…`); + loadArea(projectId, id).then( + (got) => { + if (my !== seq) return; + if (!got) return say(`${label()} — 준비 중`); + area = got; + drawList(); + if (!got.items.length) return say(`${label()} — 쪽이 없습니다.`); + rebuild(); + }, + (error: unknown) => + my === seq && say(`${label()} 목록을 못 읽었습니다 — ${errText(error)}`, true), + ); + }; + + const actions = document.createElement("div"); + actions.className = "ui-sidebar-actions"; + actions.append(pdfBtn, xlsxBtn, back); + const left = document.createElement("div"); + left.className = "ui-sidebar-panel b09-print__panel"; + left.append(areaCard, sheetCard, actions); + attachCollapsible(left); + + const layout = createWorkflowLayout({ + title: "인쇄 미리보기", + steps: workflowSteps(), + activeStep: 6, + mainContent: body, + leftPanel: left, + routes: WORKFLOW_STEP_ROUTES, + onStepClick: (stepIndex: number) => { + if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + }, + }); + root.append(layout.root); + pickArea(areaId); + + return { + covers: () => false, + show: () => undefined, + dispose: () => { + seq++; + clearTimeout(timer); + clearFrame(); + }, + }; +} + +const errText = (error: unknown): string => + error instanceof Error ? error.message : String(error); diff --git a/B09_Estimation/draft/B09_Draft_Print_Route.ts b/B09_Estimation/draft/B09_Draft_Print_Route.ts new file mode 100644 index 000000000..de86318d3 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Route.ts @@ -0,0 +1,15 @@ +/* ============================================================================= + * B09_Draft_Print_Route.ts + * 인쇄 미리보기 길 선언(PLAN 302-1 · 초안) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 주소 = #/b09-print · 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 — 폴더째 지우면 길도 없음 + * ========================================================================== */ + +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: "b09-print", + workflow: true, + project: true, + footerless: true, + load: async () => (await import("./B09_Draft_Print_Page")).renderB09Print, +}; diff --git a/B09_Estimation/draft/B09_Draft_Print_Router.py b/B09_Estimation/draft/B09_Draft_Print_Router.py new file mode 100644 index 000000000..ab7c347f0 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Router.py @@ -0,0 +1,334 @@ +"""인쇄 미리보기 표 출력 길(PLAN 302-1 · 초안) — 서버는 꼴만 바꿈(값 셈 · 저장 없음 · 정본 아님). + + POST /api/projects/{id}/print/table-pdf {title, sheets:[{name, orientation, html}]} + html = 브라우저가 `renderDataTableHtml` 로 만든 표(화면과 같은 꼴) → 인쇄 CSS 를 입혀 + 헤드리스 Edge(없으면 Chrome)로 A4 PDF 한 벌 · 시트 = 차례대로 새 쪽 · 방향 = 시트마다 · + 쪽 머리 = 시트 이름(표 머리와 함께 쪽마다 되풀이) · 쪽 바닥 = 쪽 번호 · + 넓은 표 = 쪽 폭에 맞춰 줄임 + POST /api/projects/{id}/print/table-xlsx {title, sheets:[{name, grid}]} + grid(`B09_Draft_Print_Table.sheetGrid`) → 엑셀 — 시트 = 차례대로 · 이름 31자 · + 금지 글자 바꿈 · 머리 굵게 · 병합 · 테두리 · 숫자 = 숫자 칸(열 소수 서식) · + 레벨 = 들여쓰기 · 열 폭 + +`B09_Estimation_Router` 가 draft/*_Router.py 의 router 를 끼워 넣음 +(같은 머리 · 같은 권한 = 회사 · 프로젝트). +PDF 는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 JSON stdin · 결과 JSON 한 줄) — +서버 안 스레드에선 Playwright 가 드라이버를 못 띄움(`B08_Quantity_Milestone_Capture` 와 같은 까닭). +헤드리스 쪽은 스크립트 끔 · 바깥 요청 모두 막음(그림 = data: 주소만). +""" + +from __future__ import annotations + +import html as htmlmod +import io +import json +import re +import subprocess +import sys +import tempfile +import threading +from pathlib import Path +from typing import Any, Literal + +TIMEOUT_S = 90 +CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 +NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움" +#: A4 인쇄 칸 폭(px · 96dpi) — 210 · 297mm − 좌우 여백 10mm 둘 +PRINT_W = {"portrait": 190 / 25.4 * 96, "landscape": 277 / 25.4 * 96} +MIN_ZOOM = 0.3 + +PRINT_CSS = """ +@page { size: A4 portrait; margin: 10mm 10mm 14mm; } +@page wide { size: A4 landscape; margin: 10mm 10mm 14mm; } +html, body { margin: 0; } +body { font-family: "Malgun Gothic", "맑은 고딕", sans-serif; font-size: 11px; color: #000; } +.sheet { break-after: page; } +.sheet:last-child { break-after: auto; } +.sheet.is-landscape { page: wide; } +.ui-data-table__table { + width: var(--dt-w); table-layout: fixed; border-collapse: collapse; line-height: 1.3; +} +.ui-data-table__table caption { display: none; } +.ui-data-table__table th, .ui-data-table__table td { + height: 20px; padding: 0 4px; overflow: hidden; border: 0.5pt solid #666; + white-space: nowrap; white-space-collapse: preserve-spaces; text-overflow: clip; +} +.ui-data-table__table [data-num] { text-align: right; font-variant-numeric: tabular-nums; } +.ui-data-table__table td.is-empty { text-align: center; color: #555; } +.ui-data-table__table thead { display: table-header-group; } +.ui-data-table__table thead th { background: #eee; font-weight: 600; text-align: center; } +.ui-data-table__table thead th.sheet-title { + background: #fff; border: 0; text-align: left; font-size: 13px; font-weight: 700; height: 26px; +} +.ui-data-table__table tfoot { display: table-row-group; } +.ui-data-table__table tfoot td { background: #f3f3f3; font-weight: 700; } +.ui-data-table__table tbody tr.is-title td { font-weight: 700; } +.ui-data-table__table td.is-merged { vertical-align: middle; } +.ui-data-table__table tr { break-inside: avoid; } +.ui-data-table__table td.ui-data-table__lv { + text-align: left; padding-left: calc(4px + var(--dt-lv, 0) * 1em); +} +.ui-data-table__table td.ui-data-table__lv.is-node { font-weight: 700; } +.ui-data-table__guide, .ui-data-table__icon { display: none; } +.sheet-figure { margin: 8px 0 0; break-inside: avoid; } +.sheet-figure img { max-width: 100%; max-height: 240mm; } +.sheet-figure figcaption { font-size: 11px; margin-top: 2px; } +""" +FOOTER = ( + '
' + ' /
' +) + +_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나 + + +class PrintError(Exception): + """까닭 한 줄(예외 원문 없음).""" + + +def _titled(table: str, name: str) -> str: + """표 머리 맨 위에 시트 이름 줄 — thead 가 쪽마다 되풀이되므로 쪽 머리가 됨.""" + span = max(1, table.count("{htmlmod.escape(name)}' + return table.replace("", f"{row}", 1) if "" in table else table + + +def document_html(title: str, sheets: list[dict[str, Any]]) -> str: + """시트마다 한 덩이(새 쪽 · 방향) — 표 머리에 시트 이름.""" + parts = [ + f'
' + f"{_titled(s['html'], s['name'])}
" + for s in sheets + ] + return ( + f'{htmlmod.escape(title)}' + f"{''.join(parts)}" + ) + + +#: 시트 덩이마다 쪽 폭에 맞춰 줄임(zoom · 넓은 표만) +FIT_JS = ( + """(w) => { for (const s of document.querySelectorAll('.sheet')) { + const width = s.scrollWidth, max = w[s.classList.contains('is-landscape') ? 1 : 0]; + if (width > max) s.style.zoom = String(Math.max(%s, max / width)); } }""" + % MIN_ZOOM +) + + +def render_pdf(doc: str) -> bytes: + """헤드리스 브라우저 → PDF 바이트. 실패 = PrintError.""" + try: + from playwright.sync_api import Error as PwError + from playwright.sync_api import sync_playwright + except ImportError as error: + raise PrintError("서버에 Playwright 가 없습니다(pip install playwright)") from error + try: + with sync_playwright() as p: + browser = None + for channel in CHANNELS: + try: + browser = p.chromium.launch(channel=channel, headless=True) + break + except PwError: + continue + if browser is None: + raise PrintError(NO_BROWSER) + try: + context = browser.new_context(java_script_enabled=False) + context.route("**/*", lambda route: route.abort()) # 바깥 요청 없음(data: 만) + page = context.new_page() + page.set_content(doc, wait_until="load") + page.evaluate(FIT_JS, [PRINT_W["portrait"], PRINT_W["landscape"]]) + return page.pdf( + prefer_css_page_size=True, + print_background=True, + display_header_footer=True, + header_template="", + footer_template=FOOTER, + ) + finally: + browser.close() + except PrintError: + raise + except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음 + raise PrintError(NO_BROWSER) from error + + +def spawn_pdf(doc: str) -> bytes: + """자식 파이썬 프로세스로 render_pdf — 결과 PDF 는 임시 파일로 받음.""" + with _lock, tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "out.pdf" + try: + done = subprocess.run( + [sys.executable, __file__], + input=json.dumps({"doc": doc, "out": str(out)}).encode(), + capture_output=True, + timeout=TIMEOUT_S, + ) + except subprocess.TimeoutExpired as error: + raise PrintError(f"PDF 만들기가 {TIMEOUT_S}초 안에 안 끝남") from error + except OSError as error: + raise PrintError(NO_BROWSER) from error + try: + result = json.loads(done.stdout.decode().strip().splitlines()[-1]) + except (ValueError, IndexError): # 자식이 죽음 — stderr(예외 글)는 내지 않음 + result = {"reason": NO_BROWSER} + if result.get("reason"): + raise PrintError(result["reason"]) + return out.read_bytes() + + +# ── 엑셀 ────────────────────────────────────────────────────────────────── + +BAD_TITLE = re.compile(r"[\[\]:*?/\\]") + + +def sheet_title(name: str, used: set[str]) -> str: + """엑셀 시트 이름 — 금지 글자 = 「_」 · 31자 · 같은 이름 = 뒤에 (2)…""" + base = BAD_TITLE.sub("_", name).strip().strip("'") or "시트" + title, n = base[:31], 1 + while title.lower() in used: + n += 1 + tail = f" ({n})" + title = base[: 31 - len(tail)] + tail + used.add(title.lower()) + return title + + +def number_format(decimals: int | None) -> str: + return "#,##0" + ("." + "0" * decimals if decimals else "") + + +def build_xlsx(sheets: list[dict[str, Any]]) -> bytes: + """grid 시트들 → 엑셀 바이트(차례 그대로).""" + from openpyxl import Workbook + from openpyxl.styles import Alignment, Border, Font, PatternFill, Side + from openpyxl.utils import get_column_letter + + thin = Side(style="thin", color="666666") + box = Border(left=thin, right=thin, top=thin, bottom=thin) + head_fill = PatternFill("solid", fgColor="EEEEEE") + wb = Workbook() + wb.remove(wb.active) + used: set[str] = set() + for sheet in sheets: + ws = wb.create_sheet(sheet_title(sheet["name"], used)) + g = sheet["grid"] + cols, top = g["cols"], g["head_rows"] + for j, col in enumerate(cols, 1): + ws.column_dimensions[get_column_letter(j)].width = max(4, round(col["w"] / 7, 1)) + for r in range(1, top + 1): + for j in range(1, len(cols) + 1): + cell = ws.cell(r, j) + cell.font, cell.fill, cell.border = Font(bold=True), head_fill, box + cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + for h in g["head"]: + ws.cell(h["r"] + 1, h["c"] + 1, h["text"]) + if h["rs"] > 1 or h["cs"] > 1: + ws.merge_cells( + start_row=h["r"] + 1, + start_column=h["c"] + 1, + end_row=h["r"] + h["rs"], + end_column=h["c"] + h["cs"], + ) + lv_col = g.get("level_col") + for i, row in enumerate(g["rows"]): + r = top + 1 + i + bold = bool(g["bold"][i]) if i < len(g["bold"]) else False + level = g["levels"][i] if i < len(g["levels"]) else None + for j, value in enumerate(row): + cell = ws.cell(r, j + 1, value) + cell.border = box + if bold: + cell.font = Font(bold=True) + if isinstance(value, (int, float)) and not isinstance(value, bool): + cell.number_format = number_format(cols[j].get("dec")) + if j == lv_col and level: + cell.alignment = Alignment(indent=max(0, int(level) - 1)) + for m in g.get("merges", []): + ws.merge_cells( + start_row=top + 1 + m["r"], + start_column=m["c"] + 1, + end_row=top + m["r"] + m["rs"], + end_column=m["c"] + 1, + ) + ws.cell(top + 1 + m["r"], m["c"] + 1).alignment = Alignment(vertical="center") + if top: + ws.freeze_panes = ws.cell(top + 1, (g.get("frozen") or 0) + 1) + out = io.BytesIO() + wb.save(out) + return out.getvalue() + + +if __name__ == "__main__": # 자식 프로세스 몸통 — fastapi 를 읽지 않음 + job = json.loads(sys.stdin.buffer.read()) + try: + Path(job["out"]).write_bytes(render_pdf(job["doc"])) + print(json.dumps({"reason": None})) + except PrintError as error: + print(json.dumps({"reason": str(error)}, ensure_ascii=False)) + sys.exit(0) + + +# ── 길 ──────────────────────────────────────────────────────────────────── + +import asyncio # noqa: E402 +from urllib.parse import quote # noqa: E402 +from uuid import UUID # noqa: E402 + +from fastapi import APIRouter, HTTPException # noqa: E402 +from fastapi.responses import Response # noqa: E402 +from pydantic import BaseModel, Field # noqa: E402 + +router = APIRouter() +MAX_SHEETS = 200 + + +class PdfSheet(BaseModel): + name: str = Field(max_length=200) + orientation: Literal["portrait", "landscape"] = "portrait" + html: str + + +class XlsxSheet(BaseModel): + name: str = Field(max_length=200) + grid: dict[str, Any] + + +class PdfBody(BaseModel): + title: str = Field(default="인쇄", max_length=200) + sheets: list[PdfSheet] = Field(min_length=1, max_length=MAX_SHEETS) + + +class XlsxBody(BaseModel): + title: str = Field(default="인쇄", max_length=200) + sheets: list[XlsxSheet] = Field(min_length=1, max_length=MAX_SHEETS) + + +def _file(data: bytes, media: str, title: str, ext: str) -> Response: + name = quote(f"{title}.{ext}") + return Response( + content=data, + media_type=media, + headers={"Content-Disposition": f"attachment; filename*=UTF-8''{name}"}, + ) + + +@router.post("/{project_id}/print/table-pdf") +async def table_pdf(project_id: UUID, body: PdfBody) -> Response: + doc = document_html(body.title, [s.model_dump() for s in body.sheets]) + try: + data = await asyncio.to_thread(spawn_pdf, doc) + except PrintError as error: + raise HTTPException(status_code=503, detail=str(error)) from error + return _file(data, "application/pdf", body.title, "pdf") + + +@router.post("/{project_id}/print/table-xlsx") +async def table_xlsx(project_id: UUID, body: XlsxBody) -> Response: + try: + data = await asyncio.to_thread(build_xlsx, [s.model_dump() for s in body.sheets]) + except (KeyError, TypeError, ValueError, IndexError) as error: + raise HTTPException(status_code=400, detail=f"표 꼴이 맞지 않음 — {error}") from error + media = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + return _file(data, media, body.title, "xlsx") diff --git a/B09_Estimation/draft/B09_Draft_Print_Table.ts b/B09_Estimation/draft/B09_Draft_Print_Table.ts new file mode 100644 index 000000000..8f5255cc3 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Table.ts @@ -0,0 +1,146 @@ +/* ============================================================================= + * B09_Draft_Print_Table.ts + * 인쇄 시트 → 서버 몸(PLAN 302-1 · 초안) — 화면과 같은 표 짜기(DataTableOptions)를 그대로 씀. + * sheetHtml = `renderDataTableHtml`(누름 · 입력 뺀 보기 전용) + 그림(data: 주소) → 서버 표 PDF + * sheetGrid = 머리 줄(묶음 병합) · 값 줄(숫자 = 숫자 · 합계 = 끝 줄) · 열 폭 · 소수 · 레벨 · 세로 병합 → 서버 엑셀 + * 그림 = 엑셀엔 안 넣음(PDF 만) + * ========================================================================== */ + +import { + mergeSpans, + renderDataTableHtml, + SCALE, + type DataTableOptions, +} from "@ui/ui_template_data_table"; +import type { PrintSheet } from "./B09_Draft_Print_Types"; + +export interface GridHead { + r: number; + c: number; + rs: number; + cs: number; + text: string; +} + +export interface SheetGrid { + head_rows: number; + head: GridHead[]; + /** 열 폭 px · 소수 자리(글 열 = null) */ + cols: { w: number; dec: number | null }[]; + rows: (string | number | null)[][]; + bold: boolean[]; + levels: (number | null)[]; + level_col: number | null; + /** 본문 세로 병합(mergeSame) — r = 값 줄 번호 */ + merges: { r: number; c: number; rs: number }[]; + frozen: number; +} + +const ESC_HTML: Record = { "&": "&", "<": "<", ">": ">", '"': """ }; +const escHtml = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC_HTML[c]); + +/** 보기 전용 — 누름 · 입력 · 강조를 뺀 같은 표 */ +const still = (t: DataTableOptions): DataTableOptions => ({ + ...t, + onRowClick: undefined, + onCellClick: undefined, + editable: undefined, + onEdit: undefined, + editOnDouble: false, + editing: null, + focus: null, + fillViewport: false, +}); + +export function sheetHtml(s: PrintSheet): string { + const figs = (s.images ?? []) + .map( + (im) => + `
` + + (im.caption ? `
${escHtml(im.caption)}
` : "") + + `
`, + ) + .join(""); + return renderDataTableHtml(still(s.table)) + figs; +} + +function cellValue(v: unknown): string | number | null { + if (v === null || v === undefined || v === "") return null; + if (typeof v === "number") return Number.isFinite(v) ? v : null; + return String(v); +} + +export function sheetGrid(table: DataTableOptions): SheetGrid { + const t = still(table); + const cols = t.columns; + const depth = Math.max(0, ...cols.map((c) => c.group?.length ?? 0)); + const head: GridHead[] = []; + const same = (a: string[] | undefined, b: string[] | undefined, n: number) => + (b?.length ?? 0) > n && a!.slice(0, n + 1).join("\u0000") === b!.slice(0, n + 1).join("\u0000"); + // 화면 머리(`headHtml`)와 같은 칸 나눔 — 묶음 = 옆으로 합침 · 맨 아래 머리 = 아래로 합침 + for (let lv = 0; lv <= depth; lv++) { + for (let j = 0; j < cols.length; j++) { + const g = cols[j].group?.length ?? 0; + if (g > lv) { + let k = j; + while (k + 1 < cols.length && same(cols[j].group, cols[k + 1].group, lv)) k++; + head.push({ r: lv, c: j, rs: 1, cs: k - j + 1, text: cols[j].group![lv] }); + j = k; + } else if (g === lv) { + head.push({ r: lv, c: j, rs: depth - lv + 1, cs: 1, text: cols[j].label }); + } + } + } + const n = t.rows.length; + const rows = t.rows.map((r) => cols.map((c) => cellValue(r[c.key]))); + const bold = t.rows.map((_r, i) => /\bis-title\b/.test(t.rowClass?.(i) ?? "")); + const levels = t.rows.map((_r, i) => (t.level ? (t.level.of(i) ?? 1) : null)); + if (t.total) { + rows.push(cols.map((c) => cellValue(t.total![c.key]))); + bold.push(true); + levels.push(null); + } + const merges: SheetGrid["merges"] = []; + if (t.mergeSame?.length) + mergeSpans(t, 0, n).forEach((spans, i) => + spans.forEach((rs, c) => rs > 1 && merges.push({ r: i, c, rs })), + ); + const lvCol = t.level ? cols.findIndex((c) => c.key === t.level!.key) : -1; + return { + head_rows: depth + 1, + head, + cols: cols.map((c) => ({ + w: Math.round((c.width ?? (c.kind === "number" ? 84 : 96)) * SCALE), + dec: c.kind === "number" ? (c.decimals ?? 0) : null, + })), + rows, + bold, + levels, + level_col: lvCol >= 0 ? lvCol : null, + merges, + frozen: Math.min(t.frozen ?? 0, cols.length), + }; +} + +/** 그림 주소 → data: 주소(서버 헤드리스는 바깥 요청을 막음 · 로그인 쿠키로 브라우저가 받아 둠) */ +export async function inlineImages(s: PrintSheet): Promise { + if (!s.images?.some((im) => !im.src.startsWith("data:"))) return s; + const images = await Promise.all( + s.images.map(async (im) => { + if (im.src.startsWith("data:")) return im; + try { + const blob = await (await fetch(im.src, { credentials: "include" })).blob(); + const src = await new Promise((done, fail) => { + const reader = new FileReader(); + reader.onload = () => done(String(reader.result)); + reader.onerror = () => fail(reader.error); + reader.readAsDataURL(blob); + }); + return { ...im, src }; + } catch { + return { ...im, src: "" }; + } + }), + ); + return { ...s, images: images.filter((im) => im.src) }; +} diff --git a/resources/tester/test_302_1_print_frame.py b/resources/tester/test_302_1_print_frame.py new file mode 100644 index 000000000..654e48399 --- /dev/null +++ b/resources/tester/test_302_1_print_frame.py @@ -0,0 +1,251 @@ +"""PLAN 302-1 — 인쇄 미리보기 틀(초안 길 등록 · 서버 표 PDF · 엑셀 · 표 → 서버 이음매).""" + +import io +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi import APIRouter, FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Router as b09 # noqa: E402 +from B09_Estimation.draft import B09_Draft_Print_Router as pr # noqa: E402 + +DRAFT = ROOT / "B09_Estimation/draft" +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +PID = "00000000-0000-0000-0000-000000000001" + + +def _text(path: Path) -> str: + return path.read_text("utf-8") + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(pr.router, prefix="/api/projects") + return TestClient(app) + + +def _ts(expr: str) -> object: + """표 짜기(공용 표 + 인쇄 표) TS 를 노드로 — expr 결과 JSON.""" + js = "".join( + IMPORTS.sub("", _text(p)) + for p in ( + ROOT / "ui_template/ui_template_data_table.ts", + DRAFT / "B09_Draft_Print_Table.ts", + ) + ) + run = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=js + f"\nconsole.log(JSON.stringify({expr}));", + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +#: 머리 두 줄(묶음) · 숫자 열 · 레벨 · 세로 병합 · 합계 · 제목 줄 +TABLE = """{ + columns: [ + { key: "kind", label: "공종" }, + { key: "name", label: "이름", width: 120 }, + { key: "q", label: "수량", group: ["물량"], kind: "number", decimals: 2 }, + { key: "a", label: "금액", group: ["물량"], kind: "number" }, + ], + rows: [ + { kind: "토공", name: "토공", q: null, a: null }, + { kind: "토공", name: "터파기", q: 12.5, a: 1500 }, + { kind: "토공", name: "되메우기 ", q: 3, a: 300 }, + { kind: "포장", name: "포장", q: 1234.567, a: 2000000 }, + ], + total: { name: "합계", a: 2001800 }, + frozen: 1, + mergeSame: ["kind"], + level: { key: "name", of: (i) => (i === 0 ? 1 : 2) }, + rowClass: (i) => (i === 0 ? "is-title" : null), + onRowClick: () => {}, +}""" + + +def _sheets(): + return _ts( + f"(() => {{ const t = {TABLE}; return [" + "{name: '갑 / 수량[1]', orientation: 'portrait', html: sheetHtml({id:'a', name:'갑', table:t})," + " grid: sheetGrid(t)}," + "{name: '을: 원가표가 아주 아주 길어 서른한 자를 넘는 이름입니다만', orientation: 'landscape'," + " html: sheetHtml({id:'b', name:'을', table:t, images:[{src:'data:image/png;base64,iVBORw0KGgo='," + " caption:'그림 <1>'}]}), grid: sheetGrid(t)}]; })()" + ) + + +# ── 길 등록 ────────────────────────────────────────────────────────────── + + +def test_초안_길은_선언으로_등록_정적_표엔_이름_없음(): + router = _text(ROOT / "A00_Common/router.ts") + assert ( + 'import.meta.glob<{ ROUTE: DraftRoute }>("../*/draft/*_Route.ts", { eager: true })' + in router + ) + assert "routeTable[route] ?? draft?.load" in router + assert "draft?.workflow" in router and "draft?.project" in router + assert "draftRoute(currentRoute())?.footerless" in _text(ROOT / "A00_Common/main.ts") + route = _text(DRAFT / "B09_Draft_Print_Route.ts") + assert 'path: "b09-print"' in route and "workflow: true" in route and "project: true" in route + # 가짜 대조 — 정적 표 · 셸 · 문구에 초안 이름이 없음(폴더째 지우면 길이 함께 사라짐) + for rel in ( + "config/config_frontend.ts", "A00_Common/router.ts", "A00_Common/main.ts", + "ui_template/ui_template_loupe.ts", "B09_Estimation/B09_Estimation_UI_Shell.ts", + "B09_Estimation/B09_Estimation_UI_Tabs.ts", + ): # fmt: skip + body = _text(ROOT / rel) + assert "b09-print" not in body and "Draft_Print" not in body, rel + # 돋보기 · 기본 메뉴 막음 = 주소 꼴(b0x-)로 함께 걸림 + assert re.search(r"/\^\(b0\[1-9\]", _text(ROOT / "ui_template/ui_template_loupe.ts")) + + +def test_서버_초안_길_끼우기_폴더_없으면_길_없음(tmp_path, monkeypatch): + paths = {r.path for r in b09.router.routes} + assert "/api/projects/{project_id}/print/table-pdf" in paths + assert "/api/projects/{project_id}/print/table-xlsx" in paths + # 가짜 폴더 — 비면 아무것도 안 끼움 · *_Router.py 가 있으면 그 길만 · 깨진 초안은 건너뜀 + pkg = tmp_path / "fakepkg" + (pkg / "draft").mkdir(parents=True) + (pkg / "__init__.py").write_text("") + (pkg / "draft/__init__.py").write_text("") + monkeypatch.syspath_prepend(str(tmp_path)) + empty = APIRouter() + assert b09.include_drafts(empty, pkg / "draft", "fakepkg.draft") == [] + assert empty.routes == [] + (pkg / "draft/X_Router.py").write_text( + "from fastapi import APIRouter\nrouter = APIRouter()\n" + "@router.get('/x')\ndef x():\n return 1\n" + ) + (pkg / "draft/Y_Router.py").write_text("import 없는_모듈\n") + got = APIRouter() + assert b09.include_drafts(got, pkg / "draft", "fakepkg.draft") == ["X_Router"] + assert [r.path for r in got.routes] == ["/x"] + + +def test_로그인_회사_권한은_B09_길과_같음(): + main = _text(ROOT / "main.py") + assert "app.include_router(b09_estimation_router, dependencies=protected_with_company)" in main + assert "Depends(require_project_access)" in main + # 초안 router 는 머리 · 의존성 없이 B09 router 안으로만 들어감 + assert "APIRouter()" in _text(DRAFT / "B09_Draft_Print_Router.py") + + +# ── 서버 표 출력 ────────────────────────────────────────────────────────── + + +def test_table_pdf_쪽_방향_시트_이름_쪽_번호(): + pymupdf = pytest.importorskip("pymupdf") + sheets = _sheets() + res = _client().post( + f"/api/projects/{PID}/print/table-pdf", + json={"title": "수량산출", "sheets": [{k: s[k] for k in ("name", "orientation", "html")} + for s in sheets]}, + ) # fmt: skip + if res.status_code == 503: + pytest.skip(res.json()["detail"]) # 서버에 Edge · Chrome 없음 + assert res.status_code == 200, res.text + assert res.content[:5] == b"%PDF-" and res.headers["content-type"] == "application/pdf" + doc = pymupdf.open(stream=res.content) + assert doc.page_count == 2 + p1, p2 = doc[0], doc[1] + assert p1.rect.width < p1.rect.height and p2.rect.width > p2.rect.height # 시트마다 방향 + t1, t2 = p1.get_text(), p2.get_text() + assert "갑 / 수량[1]" in t1 and "을: 원가표" in t2 # 쪽 머리 = 시트 이름 + assert "1 / 2" in t1 and "2 / 2" in t2 # 쪽 번호 + assert "되메우기 " in t1 and "1,234.57" in t1 and "2,001,800" in t1 # 화면과 같은 글 + assert "그림 <1>" in t2 + + +def test_table_pdf_넓은_표는_쪽_폭에_맞춤_긴_표는_머리_되풀이(): + pymupdf = pytest.importorskip("pymupdf") + rows = "".join(f"줄{i}{i}" for i in range(120)) + html = ( + '' + '' + f"{rows}
이름값
" + ) + doc = pr.document_html("t", [{"name": "넓은 표", "orientation": "portrait", "html": html}]) + try: + data = pr.spawn_pdf(doc) + except pr.PrintError as error: + pytest.skip(str(error)) + pdf = pymupdf.open(stream=data) + words = [w for p in pdf for w in p.get_text("words")] + assert max(w[2] for w in words) <= pdf[0].rect.width # 오른쪽이 쪽 밖으로 안 나감 + texts = [p.get_text() for p in pdf] + assert "줄119" in texts[-1] + assert all("넓은 표" in t and "이름" in t for t in texts) # 쪽마다 머리 + + +def test_table_pdf_바깥_요청_막음(): + html = '
' + doc = pr.document_html("t", [{"name": "s", "orientation": "portrait", "html": html}]) + assert "" and ws["C3"].value is None + assert ws["B4"].alignment.indent == 1 and ws["B3"].font.b # 레벨 2 들여씀 · 제목 줄 굵게 + assert ( + ws["B7"].value == "합계" and ws["D7"].value == 2001800 and ws["D7"].font.b + ) # 합계 = 끝 줄 + assert ws["A3"].border.left.style == "thin" + assert ws.freeze_panes == "B3" + + +def test_table_xlsx_꼴_틀리면_400(): + res = _client().post( + f"/api/projects/{PID}/print/table-xlsx", json={"sheets": [{"name": "a", "grid": {}}]} + ) + assert res.status_code == 400 + res = _client().post(f"/api/projects/{PID}/print/table-pdf", json={"sheets": []}) + assert res.status_code == 422 + + +def test_시트_이름_다듬기(): + used: set[str] = set() + assert pr.sheet_title("a/b\\c?d*e[f]g:h", used) == "a_b_c_d_e_f_g_h" + assert pr.sheet_title("", used) == "시트" + assert pr.sheet_title("A_B_C_D_E_F_G_H", used) == "A_B_C_D_E_F_G_H (2)" # 대소문자 같은 이름 From deab5a348f4ae98cb77a05871426410e8f5d0754 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 07:55:19 +0900 Subject: [PATCH 07/17] =?UTF-8?q?feat(b09-print):=20=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EC=98=81=EC=97=AD=20=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EB=B6=99=EC=9E=84=20=E2=80=94=20=EC=84=9C=EB=B2=84=20=EB=AA=B8?= =?UTF-8?q?(pdfBody=20=C2=B7=20xlsxBody)=EC=9D=84=20=ED=91=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=A1=9C=20=C2=B7=20=EC=9B=90=EA=B0=80=2018=EC=9E=A5?= =?UTF-8?q?=20=E2=86=92=20=ED=91=9C=20PDF=20=C2=B7=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=EC=9D=B4=EC=9D=8C=EB=A7=A4=20=EC=8B=9C=ED=97=98=20(302-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- B09_Estimation/draft/B09_Draft_Print_Page.ts | 17 +---- B09_Estimation/draft/B09_Draft_Print_Table.ts | 15 ++++ .../tester/print_frame/print_frame_entry.ts | 15 ++++ resources/tester/test_302_1_print_frame.py | 70 ++++++++++++++++++- 4 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 resources/tester/print_frame/print_frame_entry.ts diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.ts b/B09_Estimation/draft/B09_Draft_Print_Page.ts index 247cfcf8a..c22a9b6dd 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Page.ts +++ b/B09_Estimation/draft/B09_Draft_Print_Page.ts @@ -20,7 +20,7 @@ import type { MountedPage } from "../../A00_Common/router"; import { navigateTo } from "../../A00_Common/router"; import { workflowSteps } from "../../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../../A00_Common/b_workflow_nav"; -import { inlineImages, sheetGrid, sheetHtml } from "./B09_Draft_Print_Table"; +import { inlineImages, pdfBody, xlsxBody } from "./B09_Draft_Print_Table"; import type { CollectSheets, DrawingItem, @@ -72,20 +72,9 @@ function tableArea(projectId: string, title: string, sheets: PrintSheet[]): Area items: sheets.map(({ id, name }) => ({ id, name })), pdf: async (ids) => { const picked = await Promise.all(pick(ids).map(inlineImages)); - return post(projectId, "table-pdf", { - title, - sheets: picked.map((s) => ({ - name: s.name, - orientation: s.orientation ?? "portrait", - html: sheetHtml(s), - })), - }); + return post(projectId, "table-pdf", pdfBody(title, picked)); }, - xlsx: (ids) => - post(projectId, "table-xlsx", { - title, - sheets: pick(ids).map((s) => ({ name: s.name, grid: sheetGrid(s.table) })), - }), + xlsx: (ids) => post(projectId, "table-xlsx", xlsxBody(title, pick(ids))), }; } diff --git a/B09_Estimation/draft/B09_Draft_Print_Table.ts b/B09_Estimation/draft/B09_Draft_Print_Table.ts index 8f5255cc3..43c3db7c3 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Table.ts +++ b/B09_Estimation/draft/B09_Draft_Print_Table.ts @@ -122,6 +122,21 @@ export function sheetGrid(table: DataTableOptions): SheetGrid { }; } +/** 서버 몸 — 함수 칸(rowClass · level.of …)은 여기서 펼쳐 글 · 값으로만 보냄 */ +export const pdfBody = (title: string, sheets: PrintSheet[]) => ({ + title, + sheets: sheets.map((s) => ({ + name: s.name, + orientation: s.orientation ?? "portrait", + html: sheetHtml(s), + })), +}); + +export const xlsxBody = (title: string, sheets: PrintSheet[]) => ({ + title, + sheets: sheets.map((s) => ({ name: s.name, grid: sheetGrid(s.table) })), +}); + /** 그림 주소 → data: 주소(서버 헤드리스는 바깥 요청을 막음 · 로그인 쿠키로 브라우저가 받아 둠) */ export async function inlineImages(s: PrintSheet): Promise { if (!s.images?.some((im) => !im.src.startsWith("data:"))) return s; diff --git a/resources/tester/print_frame/print_frame_entry.ts b/resources/tester/print_frame/print_frame_entry.ts new file mode 100644 index 000000000..f56bfb438 --- /dev/null +++ b/resources/tester/print_frame/print_frame_entry.ts @@ -0,0 +1,15 @@ +/* ============================================================================= + * print_frame_entry.ts (PLAN 302-1 시험 진입) + * 원가계산 영역(`collectCostSheets` 몸통 `costSheetsOf`) → 인쇄 페이지와 같은 서버 몸(`pdfBody` · `xlsxBody`). + * vite `--ssr` 로 번들해 node 로 실행 — 인자 = payload JSON 경로 · 출력 = {pdf, xlsx} JSON. + * ========================================================================== */ + +import { readFileSync } from "node:fs"; +import { costSheetsOf } from "../../../B09_Estimation/draft/B09_Draft_Print_Cost"; +import { pdfBody, xlsxBody } from "../../../B09_Estimation/draft/B09_Draft_Print_Table"; + +const payload = JSON.parse(readFileSync(process.argv[2], "utf-8")); +const sheets = costSheetsOf(payload, payload.user ?? {}); +console.log( + JSON.stringify({ pdf: pdfBody("원가계산", sheets), xlsx: xlsxBody("원가계산", sheets) }), +); diff --git a/resources/tester/test_302_1_print_frame.py b/resources/tester/test_302_1_print_frame.py index 654e48399..e64df7bb9 100644 --- a/resources/tester/test_302_1_print_frame.py +++ b/resources/tester/test_302_1_print_frame.py @@ -3,6 +3,7 @@ import io import json import re +import shutil import subprocess import sys from pathlib import Path @@ -76,10 +77,10 @@ TABLE = """{ def _sheets(): return _ts( f"(() => {{ const t = {TABLE}; return [" - "{name: '갑 / 수량[1]', orientation: 'portrait', html: sheetHtml({id:'a', name:'갑', table:t})," + "{name: '갑 / 수량[1]', orientation: 'portrait', html: sheetHtml({id:'a', name:'갑', table:t})," # noqa: E501 " grid: sheetGrid(t)}," - "{name: '을: 원가표가 아주 아주 길어 서른한 자를 넘는 이름입니다만', orientation: 'landscape'," - " html: sheetHtml({id:'b', name:'을', table:t, images:[{src:'data:image/png;base64,iVBORw0KGgo='," + "{name: '을: 원가표가 아주 아주 길어 서른한 자를 넘는 이름입니다만', orientation: 'landscape'," # noqa: E501 + " html: sheetHtml({id:'b', name:'을', table:t, images:[{src:'data:image/png;base64,iVBORw0KGgo='," # noqa: E501 " caption:'그림 <1>'}]}), grid: sheetGrid(t)}]; })()" ) @@ -249,3 +250,66 @@ def test_시트_이름_다듬기(): assert pr.sheet_title("a/b\\c?d*e[f]g:h", used) == "a_b_c_d_e_f_g_h" assert pr.sheet_title("", used) == "시트" assert pr.sheet_title("A_B_C_D_E_F_G_H", used) == "A_B_C_D_E_F_G_H (2)" # 대소문자 같은 이름 + + +# ── 영역 이음매 — 원가계산(302-4) 쪽 → 페이지와 같은 서버 몸 → 표 PDF · 엑셀 ──────────── + +ENTRY = ROOT / "resources/tester/print_frame/print_frame_entry.ts" +VITE = ROOT / "config/node_modules/vite/bin/vite.js" + + +@pytest.fixture(scope="module") +def cost_bodies(tmp_path_factory) -> dict: + sys.path.insert(0, str(ROOT / "resources/tester")) + import test_302_4_print_cost as t302_4 + + if shutil.which("node") is None: + pytest.skip("node 가 없음") + work = tmp_path_factory.mktemp("p302_1") + root = t302_4.t145._project(work / "proj", [(100, "SS000901"), (400, "SS000902")]) + data = work / "payload.json" + data.write_text(json.dumps(t302_4._payload(root), ensure_ascii=False, default=str), "utf-8") + out = work / "build" + entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/") + build = subprocess.run( + ["node", str(VITE), "build", "--configLoader", "runner", "--ssr", entry_rel, + "--outDir", str(out)], + cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=180, + ) # fmt: skip + assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}" + run = subprocess.run( + ["node", str(out / "print_frame_entry.js"), str(data)], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, + ) # fmt: skip + assert run.stdout.strip(), run.stderr + return json.loads(run.stdout) + + +def test_원가_영역_엑셀_18장_차례_그대로(cost_bodies): + from openpyxl import load_workbook + + body = cost_bodies["xlsx"] + assert len(body["sheets"]) == 18 + res = _client().post(f"/api/projects/{PID}/print/table-xlsx", json=body) + assert res.status_code == 200, res.text + wb = load_workbook(io.BytesIO(res.content)) + assert wb.sheetnames == [s["name"] for s in body["sheets"]] # 이름 31자 안 · 차례 그대로 + first = wb[wb.sheetnames[0]] + assert first.max_row > body["sheets"][0]["grid"]["head_rows"] # 값 줄이 들어감 + assert any(c.data_type == "n" for row in first.iter_rows() for c in row if c.value is not None) + + +def test_원가_영역_PDF_시트_이름_가로(cost_bodies): + pymupdf = pytest.importorskip("pymupdf") + body = cost_bodies["pdf"] + assert all(s["orientation"] == "landscape" and "= 18 + text = "".join(p.get_text() for p in doc) + assert all(s["name"] in text for s in body["sheets"]) + assert all(p.rect.width > p.rect.height for p in doc) From 55e565f3bf6e65ae2d579e9dff38d2a47b8a2cda Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 07:56:53 +0900 Subject: [PATCH 08/17] =?UTF-8?q?feat(b09):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=ED=95=98=EB=8B=A8=20=EA=B3=A0=EC=A0=95=20=EC=A4=84=20=E2=80=94?= =?UTF-8?q?=20[=EB=B0=95=EC=8A=A4=EB=B3=B4=EA=B8=B0|=ED=98=B8=ED=91=9C?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0]=20=ED=95=9C=20=EC=84=A4=EC=A0=95(=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=ED=83=AD=20=EC=85=8B=20=C2=B7=20=EC=98=9B=20?= =?UTF-8?q?=ED=83=AD=EB=B3=84=20=EA=B0=92=20=ED=95=98=EB=82=98=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=ED=83=AD=20=EC=A4=84=20=ED=86=A0=EA=B8=80=20=EA=B1=B7?= =?UTF-8?q?=EC=9D=8C)=20=C2=B7=20[=ED=95=98=EC=9D=B4=ED=8D=BC=EB=A7=81?= =?UTF-8?q?=ED=81=AC|=ED=8C=9D=EC=97=85]=20=EC=8A=A4=EC=9C=84=EC=B9=98(pre?= =?UTF-8?q?f=20global=20=C2=B7=20=EA=B8=B0=EB=B3=B8=20=ED=8C=9D=EC=97=85)?= =?UTF-8?q?=20=C2=B7=20DRAFT.actions=20=EC=9E=90=EB=A6=AC=20=C2=B7=20[=3F]?= =?UTF-8?q?[=EC=A0=80=EC=9E=A5][=EB=82=B4=EC=97=AD=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94]=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20(303-1=20=C2=B7=2030?= =?UTF-8?q?4-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- A00_Common/b_page_state.ts | 4 +- B09_Estimation/B09_Estimation_UI_Dock.ts | 106 +++++++++++++ B09_Estimation/B09_Estimation_UI_Page.css | 21 +++ B09_Estimation/B09_Estimation_UI_Shell.ts | 10 +- B09_Estimation/B09_Estimation_UI_Tabs.ts | 6 +- .../B09_Estimation_UI_Tabs_Groups.ts | 78 +++++----- .../tester/test_157_2_b09_sangeun_views.py | 15 +- resources/tester/test_171_3_b09_group_tabs.py | 29 ++-- resources/tester/test_181_2_b09_box_pick.py | 6 +- resources/tester/test_184_3_b09_bill_menu.py | 2 +- resources/tester/test_185_2_b09_view_bar.py | 16 +- .../tester/test_200_1_b09_box_tool_line.py | 2 +- .../tester/test_202_1_table_hint_bottom.py | 7 +- .../test_248_1_b09_group_jump_unpick.py | 2 +- .../tester/test_264_2_b09_schedule_options.py | 2 +- resources/tester/test_303_b09_row_popup.py | 139 ++++++++++++++++++ resources/tester/test_304_b09_view_setting.py | 103 +++++++++++++ 17 files changed, 460 insertions(+), 88 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Dock.ts create mode 100644 resources/tester/test_303_b09_row_popup.py create mode 100644 resources/tester/test_304_b09_view_setting.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 1a3396462..72e2f5a61 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -64,9 +64,11 @@ export const STATE_REGISTRY = { "m02-card-pills": { bucket: "pref", scope: "global" }, /** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */ "m01-logic-panel": { bucket: "pref", scope: "global" }, - /** B09 묶음 탭(171-3) 보기 `{탭: cards · table}` · 가로 열 수 `{탭: 1~4}` */ + /** B09 묶음 탭(171-3) 보기 `cards · table`(304-1 셋 탭 한 값) · 가로 열 수 `{탭: 1~4}` */ "b09-group-view": { bucket: "pref", scope: "global" }, "b09-group-cols": { bucket: "pref", scope: "global" }, + /** B09 행 누름(303-1) `popup · link` */ + "b09-link-mode": { bucket: "pref", scope: "global" }, /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ "routeedit-view": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ diff --git a/B09_Estimation/B09_Estimation_UI_Dock.ts b/B09_Estimation/B09_Estimation_UI_Dock.ts new file mode 100644 index 000000000..c71514586 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Dock.ts @@ -0,0 +1,106 @@ +/* ============================================================================= + * B09_Estimation_UI_Dock.ts + * B09 좌측 하단 고정 줄(PLAN 303-1 · 304-1) — 위에서부터 + * ① [박스보기|호표보기] 묶음 탭 셋 보기(304-1 · 한 값) + * ② [하이퍼링크|팝업] 행 누름(303 · 기본 팝업) + * ③ `DRAFT.actions` 단추(초안 · 비면 줄 없음) + * ④ [?][저장][내역 초기화](도움말 줄 `data-help-row`) + * + * 스위치 값 = pref global(계정 공용 · [저장] 필요 없음) · 바닥 고정 = 공용 `.ui-sidebar-actions` 한 벌 + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_elements"; +import { readState, writeState } from "../A00_Common/b_page_state"; +import { DRAFT } from "./B09_Estimation_UI_Tabs"; +import { + chooseGroupView, + groupView, + viewLabel, + type GroupView, +} from "./B09_Estimation_UI_Tabs_Groups"; + +export type LinkMode = "popup" | "link"; + +const LINK_LABEL: Record = { link: "하이퍼링크", popup: "팝업" }; + +/** 행 누름 길 — 모르는 값 · 못 읽음 = 팝업 */ +export function linkMode(): LinkMode { + try { + return readState("b09-link-mode") === "link" ? "link" : "popup"; + } catch { + return "popup"; + } +} + +function setLinkMode(mode: LinkMode): void { + try { + writeState("b09-link-mode", mode); + } catch { + // 기억 못 하면 기본(팝업)으로 돌아감 + } +} + +/** 단추 둘 스위치 — 켜진 쪽 filled · aria-pressed */ +function switchRow( + name: string, + values: T[], + label: (value: T) => string, + current: () => T, + pick: (value: T) => void, +): HTMLElement { + const row = document.createElement("div"); + row.className = "ui-btn-group b09-dock__row"; + row.dataset.b09Switch = name; + const buttons = values.map((value) => { + const button = createButton({ + label: label(value), + variant: "ghost", + onClick: () => { + if (value === current()) return; + pick(value); + sync(); + }, + }); + button.dataset.value = value; + return button; + }); + const sync = (): void => + buttons.forEach((button, k) => { + const on = values[k] === current(); + button.classList.toggle("ui-btn--filled", on); + button.classList.toggle("ui-btn--ghost", !on); + button.setAttribute("aria-pressed", String(on)); + }); + sync(); + row.append(...buttons); + return row; +} + +/** 하단 고정 줄 — onView = 보기 바뀜(묶음 탭이면 다시 그림) */ +export function createDock(buttons: HTMLElement[], onView: () => void): HTMLElement { + const views: GroupView[] = ["cards", "table"]; + const view = switchRow("view", views, viewLabel, groupView, (value) => { + chooseGroupView(value); + onView(); + }); + const link = switchRow( + "link", + ["link", "popup"], + (mode) => LINK_LABEL[mode], + linkMode, + setLinkMode, + ); + const extra = DRAFT.actions.map((make) => make()); + const draft = document.createElement("div"); + draft.className = "b09-dock__row"; + draft.dataset.b09DraftActions = ""; + draft.append(...extra); + const main = document.createElement("div"); + main.className = "b09-dock__row"; + main.dataset.helpRow = ""; + main.append(...buttons); + const dock = document.createElement("div"); + dock.className = "ui-sidebar-actions b09-dock"; + dock.append(view, link, ...(extra.length ? [draft] : []), main); + return dock; +} diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 81137921b..a891f16bc 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -248,3 +248,24 @@ .b09-estimation [data-b09-table="bill"] tbody tr.is-drag-src td { opacity: 0.45; } + +/* 303-1 · 304-1 좌측 하단 고정 줄 — 줄 여럿 세로(보기 · 행 누름 스위치 · 초안 단추 · [?][저장][내역 초기화]) */ +.b09-dock.ui-sidebar-actions { + flex-direction: column; +} + +.b09-dock.ui-sidebar-actions > * { + flex: none; +} + +.b09-dock .b09-dock__row { + display: flex; + gap: var(--btn-group-gap); +} + +.b09-dock .b09-dock__row > .ui-btn:not(.ui-help-btn) { + flex: 1 1 0; + min-width: auto; + padding-inline: var(--spacing-4); + white-space: nowrap; +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index c84584803..948dc4b12 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -33,6 +33,7 @@ import { computeTotal } from "./B09_Estimation_Calc_Total"; import { createTabBars } from "./B09_Estimation_UI_TabBar"; import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders"; +import { createDock } from "./B09_Estimation_UI_Dock"; import { DRAFT, rowIndex, @@ -216,9 +217,8 @@ export function renderB09Estimation(root: HTMLElement): void { return made; }; const hint = spec.hint ? lineHint(note(spec.hint)) : null; // 236-1 안내 글 = 한 줄 - // 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 머리 단추로 표 + // 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 좌측 하단 스위치로 표(304-1) if (isGroupTab(tab)) { - const redraw = (): void => show(tab); return void panel.replaceChildren( ...renderGroupPanel({ tab, @@ -227,7 +227,6 @@ export function renderB09Estimation(root: HTMLElement): void { focus, goto, makeTable, - redraw, setTools: bars.setTools, }), ); @@ -321,9 +320,8 @@ export function renderB09Estimation(root: HTMLElement): void { // 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식) const section = createPanelCard("데이터 점검"); section.append(refresh, check.list); - const actions = document.createElement("div"); - actions.className = "ui-sidebar-actions"; - actions.append(save, reset); + // 303-1 · 304-1 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · 초안 단추 · [?][저장][내역 초기화] + const actions = createDock([save, reset], () => isGroupTab(current) && show(current)); const left = document.createElement("div"); left.className = "ui-sidebar-panel b09-options"; left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), actions); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 97d937b6e..b17177b4b 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -123,12 +123,14 @@ export type TabBuild = (ctx: TabContext) => TabTable | string; /** 초안 탭(252 · `draft/*_UI.ts` 가 TABS · TAB_GROUPS 와 함께 넣음 · 폴더째 지우면 빈 채로) — * builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) · - * panels = 왼쪽 패널 카드(264 · 「데이터 점검」 아래 · 지금 사용자 값 · 고침 → 캐시 · 다시 셈) */ + * panels = 왼쪽 패널 카드(264 · 「데이터 점검」 아래 · 지금 사용자 값 · 고침 → 캐시 · 다시 셈) · + * actions = 좌측 하단 고정 줄 단추(303-1 · [저장] 줄 위 한 줄 · 비면 줄 없음) */ export const DRAFT: { builders: Record; extend: Array<(payload: Row, user: Row, sheets: Row) => void>; panels: Array<(user: () => Row, setUser: (next: Row) => void) => HTMLElement>; -} = { builders: {}, extend: [], panels: [] }; + actions: Array<() => HTMLElement>; +} = { builders: {}, extend: [], panels: [], actions: [] }; /** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index 641818e31..5ffe20bb2 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -1,22 +1,22 @@ /* ============================================================================= * B09_Estimation_UI_Tabs_Groups.ts * B09 묶음 탭 셋(PLAN 171-3) — 일위대가표 · 중기사용료 · 단가산출근거 = 공용 묶음 카드(171-2) 기본 · - * [표로 보기] = 공용 표 그대로(맞은편 단추 = [박스보기]). + * 호표보기 = 공용 표 그대로 · 고름 = 좌측 하단 [박스보기|호표보기] 한 벌(304-1 · 셋 탭 함께). * * 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음 * 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로) * 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선 * 248-1 빨강 하나(공용 고름 강조 안 붙임 · 스크롤만) · 줄 · 카드 누름 · 방향키 · Esc = 빨강 풂 · * 누른 줄만 고름(is-focus) · Esc = 고름도 풂 - * 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2 - * 보기 단추 · 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래 + * 보기 기억 = `b_page_state`(`b09-group-view` · 한 값 · 옛 탭별 값은 첫 읽기에 하나로) · 가로 열 수 = + * `b09-group-cols`(탭마다) · 못 읽으면 카드 · 2 + * 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래 * 안내 글 길면 한 줄 줄임표 + 공용 호버로 전문(200-1) * 278-2 단가산출근거 박스 = 머리 줄(`|머리n`)을 운반 단가 창 ③ 칸 꼴로(`headSteps` 한 벌 · 몸 위 칸) · * 출처 = 그 칸 맨 위 · 호표 줄 · 총계 = 그대로 · 묶음 자료(`tabGroups`) · 표는 안 바뀜 * ========================================================================== */ import type { DataRow, DataTable } from "@ui/ui_template_data_table"; -import { createButton } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { clampColumns, @@ -38,10 +38,8 @@ export const GROUP_TABS: TabId[] = ["dg", "machine_blocks", "sg"]; export const isGroupTab = (tab: TabId): boolean => GROUP_TABS.includes(tab); -/** 단추 글 = 바꿀 보기 이름 */ -export const viewLabel = (view: GroupView): string => (view === "cards" ? "표로 보기" : "박스보기"); - -export const flipView = (view: GroupView): GroupView => (view === "cards" ? "table" : "cards"); +/** 단추 글 = 그 보기 이름(304-1 좌측 스위치) */ +export const viewLabel = (view: GroupView): string => (view === "cards" ? "박스보기" : "호표보기"); type Saved = Record | null; @@ -53,19 +51,39 @@ const saved = (name: "b09-group-view" | "b09-group-cols"): Saved => { } }; -/** 고른 보기 — 모르는 값 · 못 읽음 = 카드 */ -export const readGroupView = (tab: TabId, kept: Saved = saved("b09-group-view")): GroupView => - kept?.[tab] === "table" ? "table" : "cards"; +/** 고른 보기(셋 탭 한 값) — 옛 탭별 값 = dg · 다른 탭 차례 · 모르는 값 · 못 읽음 = 카드 */ +export function readGroupView(kept: unknown = saved("b09-group-view")): GroupView { + const old = kept && typeof kept === "object" ? (kept as Record) : null; + const value = old ? (old.dg ?? old.machine_blocks ?? old.sg) : kept; + return value === "table" ? "table" : "cards"; +} + +/** 이 화면 안 보기(기억이 안 돼도 고른 보기 유지) */ +let chosen: GroupView | null = null; + +/** 지금 보기 — 첫 읽기에 옛 탭별 값을 한 값으로 옮겨 적음 */ +export function groupView(): GroupView { + if (chosen) return chosen; + const kept = saved("b09-group-view"); + chosen = readGroupView(kept); + if (kept && typeof kept === "object") chooseGroupView(chosen); + return chosen; +} + +export function chooseGroupView(view: GroupView): void { + chosen = view; + try { + writeState("b09-group-view", view); + } catch { + // 기억 못 해도 이 화면 안에서는 고른 값 그대로 + } +} /** 고른 가로 열 수 — 1~4 · 모르는 값 = 2 */ export const readGroupCols = (tab: TabId, kept: Saved = saved("b09-group-cols")): number => clampColumns(kept?.[tab]); -export function rememberGroup( - name: "b09-group-view" | "b09-group-cols", - tab: TabId, - value: unknown, -): void { +export function rememberGroup(name: "b09-group-cols", tab: TabId, value: unknown): void { try { writeState(name, { ...(saved(name) ?? {}), [tab]: value }); } catch { @@ -244,33 +262,18 @@ export function unpickOnRelease(root: HTMLElement): void { listenKeys(); } -/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */ -const chosen = new Map(); - -/** 묶음 탭 — 카드 = 카드 판 + 안내 글 · 표 = 표 + 안내 글 · 보기 단추 · 열 수 = `setTools`(보조 탭 줄 오른쪽) */ +/** 묶음 탭 — 카드 = 카드 판 + 안내 글 · 표 = 표 + 안내 글 · 열 수 = `setTools`(보조 탭 줄 오른쪽) */ export function renderGroupPanel(o: { tab: TabId; spec: TabTable; hint: HTMLElement | null; focus?: string; - goto: (link: Link | null) => void; + goto: (link: Link | null, anchor?: Element | null) => void; makeTable: (rowClass?: (index: number) => string | null) => DataTable; - redraw: () => void; setTools: (tools: HTMLElement[]) => void; }): HTMLElement[] { - const view = chosen.get(o.tab) ?? readGroupView(o.tab); + const view = groupView(); const lead = lineHint(o.hint); - const toggle = createButton({ - label: viewLabel(view), - variant: "ghost", - size: "sm", - onClick: () => { - chosen.set(o.tab, flipView(view)); - rememberGroup("b09-group-view", o.tab, flipView(view)); - o.redraw(); - }, - }); - toggle.dataset.b09GroupView = view; const at = o.focus === undefined ? -1 : rowIndex(o.spec.rows, o.focus); if (view === "cards") { const first = o.spec.columns[0]?.key ?? ""; @@ -281,19 +284,18 @@ export function renderGroupPanel(o: { : tabGroups(o.tab, o.spec), columns: readGroupCols(o.tab), onColumns: (n) => rememberGroup("b09-group-cols", o.tab, n), - onPick: (i) => o.goto(o.spec.link?.(i, first) ?? null), + onPick: (i) => + o.goto(o.spec.link?.(i, first) ?? null, cards.querySelector(`[data-at="${i}"]`)), focus: at, money: numberFormat(o.spec, "sum"), qty: numberFormat(o.spec, "qty"), - actions: [toggle], - placeTools: o.setTools, // 202-1 가로 열 수 · 보기 단추 = 보조 탭 줄 오른쪽 + placeTools: o.setTools, // 202-1 가로 열 수 = 보조 탭 줄 오른쪽 }); cards.dataset.b09Table = o.tab; live = { root: cards, unpick: (esc) => unpickCards(cards, esc) }; listenKeys(); return [cards, ...(lead ? [lead] : [])]; } - o.setTools([toggle]); let range = pickRange(o.spec.rows, o.focus); let chose = -1; // 누른 줄(다시 그려도 유지) const table = o.makeTable((i) => (i === chose ? "is-focus" : pickClass(range, i))); diff --git a/resources/tester/test_157_2_b09_sangeun_views.py b/resources/tester/test_157_2_b09_sangeun_views.py index b8cbb03d4..068632436 100644 --- a/resources/tester/test_157_2_b09_sangeun_views.py +++ b/resources/tester/test_157_2_b09_sangeun_views.py @@ -62,20 +62,19 @@ def test_157_2_두_보기_같은_값(): def test_157_2_단추_바꿈(): - out = _node( - "\nconsole.log(JSON.stringify([viewLabel('table'), viewLabel('cards')," - " flipView('table'), flipView(flipView('table'))]));" - ) - assert out == ["박스보기", "표로 보기", "cards", "table"] + out = _node("\nconsole.log(JSON.stringify([viewLabel('table'), viewLabel('cards')]));") + assert out == ["호표보기", "박스보기"] # 304-1 단추 글 = 그 보기 이름 def test_157_2_기억_실패_때_카드(): out = _node( - "\nconst a = [readGroupView('sg', null), readGroupView('sg', { sg: 'x' })," - " readGroupView('sg', { dg: 'table' }), readGroupView('sg', { sg: 'table' })];" + "\nconst a = [readGroupView(null), readGroupView({ sg: 'x' }), readGroupView({ dg: 'table' })," + " readGroupView({ sg: 'table' }), readGroupView('table'), readGroupView('x')," + " readGroupView({ dg: 'cards', sg: 'table' })];" "\nconsole.log(JSON.stringify(a));" ) - assert out == ["cards", "cards", "cards", "table"] + # 304-1 한 값 · 옛 탭별 값 = dg 먼저 · 없으면 다른 탭 · 모르는 값 = 카드 + assert out == ["cards", "cards", "table", "table", "table", "cards", "cards"] def test_157_2_화면_잇기(): diff --git a/resources/tester/test_171_3_b09_group_tabs.py b/resources/tester/test_171_3_b09_group_tabs.py index 139aab8d6..36668ade1 100644 --- a/resources/tester/test_171_3_b09_group_tabs.py +++ b/resources/tester/test_171_3_b09_group_tabs.py @@ -118,27 +118,27 @@ def test_단가산출근거_카드(): assert out["links"] == [None, {"tab": "labor", "key": "LB2"}] -def test_보기_기억_탭마다(): +def test_보기_한_값_열_수_탭마다(): out = run( [COMMON, TABS, GROUPS], - "\nconst a = [readGroupView('dg'), readGroupCols('dg')];" - "\nrememberGroup('b09-group-view', 'dg', 'table');" + "\nconst a = [readGroupView(), readGroupCols('dg')];" + "\nkept.set('b09-group-view', JSON.stringify({ machine_blocks: 'table' }));" # 옛 탭별 값 "\nrememberGroup('b09-group-cols', 'sg', 3);" - "\nconst b = [readGroupView('dg'), readGroupView('sg'), readGroupView('machine_blocks')," + "\nconst b = [groupView(), readGroupView(), readGroupView()," " readGroupCols('sg'), readGroupCols('dg'), readGroupCols('dg', { dg: 9 })];" "\nconsole.log(JSON.stringify({ a, b, kept: Object.fromEntries(kept)," " tabs: GROUP_TABS, other: ['bill', 'sum_material', 'sum_labor', 'sum_expense'," " 'sum_machine', 'dg_list'].map(isGroupTab) }));", ) assert out["a"] == ["cards", 2] # 기본 = 카드 · 가로 2 - assert out["b"] == ["table", "cards", "cards", 3, 2, 2] - assert json.loads(out["kept"]["b09-group-view"]) == {"dg": "table"} + assert out["b"] == ["table", "table", "table", 3, 2, 2] # 304-1 셋 탭 한 값 + assert json.loads(out["kept"]["b09-group-view"]) == "table" # 첫 읽기에 옛 값 → 한 값 assert json.loads(out["kept"]["b09-group-cols"]) == {"sg": 3} assert out["tabs"] == ["dg", "machine_blocks", "sg"] assert out["other"] == [False] * 6 # 집계표 넷 · 설계내역서 · 목록표 = 대상 밖 -def test_화면_보기_바꿈_누름_이동(): +def test_화면_보기_고름_누름_이동(): out = run( [COMMON, TABS, f"{B09}Labor.ts", GROUPS], f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" @@ -147,26 +147,25 @@ def test_화면_보기_바꿈_누름_이동(): " redraw: () => redraws++, setTools: (t) => calls.push(t), makeTable: () => ({ root: document.createElement('table')," " focusRow: (i) => focused.push(i) }) };" "\nconst [view] = renderGroupPanel(o);" - "\nconst btn = calls[0].find((e) => e.dataset.b09GroupView);" - "\nconst label = btn.textContent;" + "\nconst label = calls.flat().filter((e) => e.dataset.b09GroupView).length;" "\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');" "\nconst box = view.querySelector('.ui-group-card').classList.contains('is-pick');" "\nview.querySelector('[data-at=\"2\"]').click();" "\nview.querySelector('[data-at=\"0\"]').click();" - "\nbtn.click();" + "\nconst before = calls.length; chooseGroupView('table');" "\nconst [view2] = renderGroupPanel(o);" "\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws," - " label2: calls[1].find((e) => e.dataset.b09GroupView).textContent, tag2: view2.tagName, focused," + " tools2: calls.length - before, tag2: view2.tagName, focused," " kept: Object.fromEntries(kept) }));", RAW, ) - assert out["label"] == "표로 보기" and out["table"] == "dg" # 기본 = 카드 + assert out["label"] == 0 and out["table"] == "dg" # 기본 = 카드 · 304-1 탭 줄 보기 단추 없음 assert out["gone"] == [{"tab": "labor", "key": "L00005"}, None] # 줄 누름 = 표와 같은 이동 assert out["lit"] is False # 248-1 이동해 온 줄 = 빨강 하나(줄 강조 안 붙임) assert out["box"] is True # 그 호표 박스 = 붉은 외곽선(181-2) - assert out["redraws"] == 1 - assert out["label2"] == "박스보기" and out["tag2"] == "TABLE" and out["focused"] == [3] - assert json.loads(out["kept"]["b09-group-view"]) == {"dg": "table"} + assert out["redraws"] == 0 + assert out["tools2"] == 0 and out["tag2"] == "TABLE" and out["focused"] == [3] + assert json.loads(out["kept"]["b09-group-view"]) == "table" def test_잇기(): diff --git a/resources/tester/test_181_2_b09_box_pick.py b/resources/tester/test_181_2_b09_box_pick.py index 0233c6458..211dd7009 100644 --- a/resources/tester/test_181_2_b09_box_pick.py +++ b/resources/tester/test_181_2_b09_box_pick.py @@ -1,6 +1,6 @@ """181-2 박스보기 — 이름 · 접기 없음 · 목록표에서 고른 호표 = 박스 붉은 외곽선 · 표 보기 = 줄 구간 전체. -- 단추 글 「박스보기」 ↔ 「표로 보기」 · 요약(접기) 보기 지움 +- 단추 글 「박스보기」 · 「호표보기」(304-1 좌측 스위치) · 요약(접기) 보기 지움 - 표 보기 = 고른 호표 줄 구간(키 `|` 앞머리 같은 줄) 모두 `is-pick` · 첫 줄 `is-pick-top` · 끝 줄 `is-pick-end` - 붉은 색 = `--color-danger`(B06 횡단 카드 고름 꼴) """ @@ -52,7 +52,7 @@ def test_표_보기_줄_class_넘김(): "\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}, setTools: () => {}," " makeTable: (e) => { extra = e; return { root: document.createElement('table')," " focusRow: () => {} }; } };" - "\nchosen.set('dg', 'table');" + "\nchooseGroupView('table');" "\nrenderGroupPanel(o);" "\nconsole.log(JSON.stringify(spec.rows.map((_, i) => extra(i))));", ) @@ -63,7 +63,7 @@ def test_표_보기_줄_class_넘김(): def test_글_접기_없음_CSS_잇기(): groups = (ROOT / GROUPS).read_text("utf-8") - assert '"표로 보기" : "박스보기"' in groups and "호표별 보기" not in groups + assert '"박스보기" : "호표보기"' in groups and "호표별 보기" not in groups # 304-1 글 common = (ROOT / COMMON).read_text("utf-8") for gone in ("isSummary", "GROUP_SUMMARY_OVER", "is-closed", "모두 펼침", "모두 접기"): assert gone not in common diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index b3daf1fba..8fbb62580 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -286,4 +286,4 @@ def test_내역_초기화_배선(): assert "showConfirmDialog(" in block and "if (!ok) return;" in block assert "const { bill: _bill, ...rest } = user;" in block assert 'run("/estimation", "PUT"' in block and "{ user: rest }" in block - assert "actions.append(save, reset);" in src + assert "createDock([save, reset]," in src # 303-1 하단 고정 줄 맨 아래 줄 diff --git a/resources/tester/test_185_2_b09_view_bar.py b/resources/tester/test_185_2_b09_view_bar.py index 4aebd6501..e18aa71c4 100644 --- a/resources/tester/test_185_2_b09_view_bar.py +++ b/resources/tester/test_185_2_b09_view_bar.py @@ -1,6 +1,6 @@ -"""185-2 B09 묶음 탭 보기 바꿈 단추 글 한 줄 · 가로 열 수 단추와 같은 줄. +"""185-2 B09 묶음 탭 가로 열 수 단추 한 줄 · 보기 고름 = 좌측 하단(304-1 · 탭 줄에 없음). -- 보기 바꿈 단추 · 가로 열 수 = 도구 묶음(`setTools` · 202-1 로 페이지 보조 탭 줄 오른쪽) · 카드 판 머리 띠 = 묶음 수만 +- 가로 열 수 = 도구 묶음(`setTools` · 202-1 로 페이지 보조 탭 줄 오른쪽) · 카드 판 머리 띠 = 묶음 수만 - 단추 · 라벨 = `white-space: nowrap` · 도구 묶음 = 줄바꿈 없음(`flex-wrap: nowrap`) """ @@ -31,7 +31,7 @@ def _panel(view: str, hint: str): return run( [COMMON, TABS, f"{B09}Labor.ts", GROUPS], f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" - f"\nchosen.set('dg', '{view}');" + f"\nchooseGroupView('{view}');" "\nconst tools = [];" f"\nconst o = {{ tab: 'dg', spec, hint: {hint}, goto: () => {{}}, redraw: () => {{}}," " setTools: (t) => tools.push(...t)," @@ -39,7 +39,7 @@ def _panel(view: str, hint: str): "\nconst out = renderGroupPanel(o);" "\nconst btn = tools.find((e) => e.dataset.b09GroupView);" "\nconst bar = out[0].all('.ui-group-cards__bar')[0] ?? null;" - "\nconsole.log(JSON.stringify({ n: out.length, first: out[0].className, label: btn.textContent," + "\nconsole.log(JSON.stringify({ n: out.length, first: out[0].className, label: btn ? btn.textContent : null, nTools: tools.length," " cols: tools.flatMap((e) => e.all('[data-group-cols]')).map((b) => b.dataset.groupCols)," " barBtn: bar ? bar.all('[data-b09-group-view]').length : null," " barCols: bar ? bar.all('[data-group-cols]').length : null }));", @@ -49,7 +49,7 @@ def _panel(view: str, hint: str): def test_카드_보기_단추가_열수_단추와_같은_도구_묶음에(): out = _panel("cards", "null") - assert out["label"] == "표로 보기" + assert out["label"] is None # 304-1 보기 단추 = 좌측 하단(탭 줄에 없음) assert out["cols"] == ["1", "2", "3", "4"] # 같은 묶음에 열 수 단추 넷 assert out["barBtn"] == 0 and out["barCols"] == 0 # 카드 판 머리 띠에는 없음 assert out["n"] == 1 # 안내 글 없으면 카드 판 하나뿐 @@ -63,8 +63,8 @@ def test_카드_보기_안내_글은_카드_판_아래에(): def test_표_보기_단추는_도구_묶음에(): out = _panel("table", "null") - assert out["label"] == "박스보기" and out["n"] == 1 - assert out["cols"] == [] # 표 보기 = 열 수 단추 없음 + assert out["label"] is None and out["n"] == 1 + assert out["cols"] == [] and out["nTools"] == 0 # 표 보기 = 도구 없음 def test_CSS_한_줄_nowrap(): @@ -81,4 +81,4 @@ def test_CSS_한_줄_nowrap(): page, ".b09-estimation__tools .ui-btn,\n.b09-estimation__tools .ui-group-cards__label" ) assert "white-space: nowrap" in tools - assert "actions: [toggle]" in (ROOT / GROUPS).read_text("utf-8") + assert "toggle" not in (ROOT / GROUPS).read_text("utf-8") # 304-1 탭 줄 토글 걷음 diff --git a/resources/tester/test_200_1_b09_box_tool_line.py b/resources/tester/test_200_1_b09_box_tool_line.py index e2ac75c0f..203d08e9b 100644 --- a/resources/tester/test_200_1_b09_box_tool_line.py +++ b/resources/tester/test_200_1_b09_box_tool_line.py @@ -36,7 +36,7 @@ def _panel(view: str, hint: str): return run( [COMMON, TABS, f"{B09}Labor.ts", GROUPS], f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" - f"\nchosen.set('dg', '{view}');" + f"\nchooseGroupView('{view}');" f"\nconst hint = {hint};" "\nconst o = { tab: 'dg', spec, hint, goto: () => {}, redraw: () => {}, setTools: () => {}," " makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) };" diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index 35bacaafc..8cbe24bb4 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -80,7 +80,7 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): "\nconst hint = document.createElement('p');" "\nconst res = {};" "\nfor (const view of ['cards', 'table']) {" - "\n chosen.set('dg', view);" + "\n chooseGroupView(view);" "\n bars.draw('dg'); bars.setTools([]);" "\n const panel = renderGroupPanel({ tab: 'dg', spec, hint, goto: () => {}, redraw: () => {}," " makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} })," @@ -95,8 +95,9 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): "\nconsole.log(JSON.stringify(res));", RAW, ) - assert out["cards"]["inRow"] == ["표로 보기"] and out["cards"]["cols"] == 4 - assert out["table"]["inRow"] == ["박스보기"] and out["table"]["cols"] == 0 + # 304-1 보기 고름 = 좌측 하단 스위치(보조 줄에 없음) · 가로 열 수 = 보조 줄 그대로 + assert out["cards"]["inRow"] == [] and out["cards"]["cols"] == 4 + assert out["table"]["inRow"] == [] and out["table"]["cols"] == 0 for view in ("cards", "table"): assert out[view]["inPanel"] == 0 # 판 안에는 단추 없음 assert out[view]["hintLast"] and out[view]["n"] == 2 # 판 + 안내 줄(맨 아래) diff --git a/resources/tester/test_248_1_b09_group_jump_unpick.py b/resources/tester/test_248_1_b09_group_jump_unpick.py index 0ebb03c45..5c555a3b9 100644 --- a/resources/tester/test_248_1_b09_group_jump_unpick.py +++ b/resources/tester/test_248_1_b09_group_jump_unpick.py @@ -60,7 +60,7 @@ def test_표_빨강_하나_누름_키_풂(): " return { root, focusRow: (i, mark) => scrolled.push([i, mark]) }; };" "\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}," " setTools: () => {}, makeTable };" - "\nchosen.set('dg', 'table');" + "\nchooseGroupView('table');" "\nconst marks = () => [root.children.map((tr) => tr.className || null)," " spec.rows.map((_, i) => extra(i))];" "\nconst out = {};" diff --git a/resources/tester/test_264_2_b09_schedule_options.py b/resources/tester/test_264_2_b09_schedule_options.py index 2266968f5..93163e72f 100644 --- a/resources/tester/test_264_2_b09_schedule_options.py +++ b/resources/tester/test_264_2_b09_schedule_options.py @@ -205,6 +205,6 @@ def test_패널_훅_draft_없으면_빈칸(): tabs = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs.ts").read_text("utf-8") shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") ui = (ROOT / "B09_Estimation/draft/B09_Draft_Schedule_UI.ts").read_text("utf-8") - assert "{ builders: {}, extend: [], panels: [] }" in tabs # draft 없으면 카드 없음 + assert "{ builders: {}, extend: [], panels: [], actions: [] }" in tabs # draft 없으면 카드 없음 assert "...DRAFT.panels.map((make) => make(() => user, setUser))" in shell assert "DRAFT.panels.push(" in ui diff --git a/resources/tester/test_303_b09_row_popup.py b/resources/tester/test_303_b09_row_popup.py new file mode 100644 index 000000000..43be21b02 --- /dev/null +++ b/resources/tester/test_303_b09_row_popup.py @@ -0,0 +1,139 @@ +"""303 B09 행 고름 = 팝업 · 하이퍼링크 스위치 · 좌측 하단 줄. + +303-1 좌측 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · `DRAFT.actions`(비면 줄 없음) · + [?][저장][내역 초기화](도움말 줄) · 스위치 = pref global · 기본 = 팝업 +303-2 표 짜기 = `…_UI_Builders`(화면 · 팝업 · 인쇄 같은 옵션) +""" + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import COMMON, GROUPS, TABS, run # noqa: E402 + +B09 = ROOT / "B09_Estimation" +DOCK = "B09_Estimation/B09_Estimation_UI_Dock.ts" +BUILDERS = "B09_Estimation/B09_Estimation_UI_Builders.ts" + +DOCK_JS = ( + "\nconst save = document.createElement('button'); save.textContent = '저장';" + "\nconst reset = document.createElement('button'); reset.textContent = '내역 초기화';" + "\nconst shape = (dock) => dock.children.map((r) => ({ sw: r.dataset.b09Switch ?? null," + " draft: 'b09DraftActions' in r.dataset, help: 'helpRow' in r.dataset," + " text: r.children.map((b) => b.textContent) }));" +) + + +def test_하단_줄_차례_초안_단추_없으면_줄_없음(): + out = run( + [COMMON, TABS, GROUPS, DOCK], + DOCK_JS + "\nconst a = createDock([save, reset], () => {});" + "\nDRAFT.actions.push(() => { const b = document.createElement('button');" + " b.textContent = '인쇄 미리보기'; return b; });" + "\nconst b = createDock([save, reset], () => {});" + "\nconsole.log(JSON.stringify({ cls: a.className, a: shape(a), b: shape(b) }));", + ) + assert out["cls"] == "ui-sidebar-actions b09-dock" # 바닥 고정 = 공용 액션 줄 한 벌 + view = {"sw": "view", "draft": False, "help": False, "text": ["박스보기", "호표보기"]} + link = {"sw": "link", "draft": False, "help": False, "text": ["하이퍼링크", "팝업"]} + main = {"sw": None, "draft": False, "help": True, "text": ["저장", "내역 초기화"]} + assert out["a"] == [view, link, main] # 초안 단추 없음 = 줄 없음 + draft = {"sw": None, "draft": True, "help": False, "text": ["인쇄 미리보기"]} + assert out["b"] == [view, link, draft, main] # 초안 단추 = [저장] 줄 위 + + +def test_스위치_기본_팝업_누름_pref(): + out = run( + [COMMON, TABS, GROUPS, DOCK], + DOCK_JS + "\nconst dock = createDock([save, reset], () => {});" + "\nconst row = dock.children[1];" + "\nconst pressed = () => row.children.map((b) => b.attrs['aria-pressed']);" + "\nconst a = [linkMode(), pressed()];" + "\nrow.children[0].click();" + "\nconst b = [linkMode(), pressed(), kept.get('b09-link-mode')];" + "\nrow.children[1].click();" + "\nconst c = [linkMode(), pressed(), kept.get('b09-link-mode')];" + "\nkept.set('b09-link-mode', '\"x\"');" + "\nconsole.log(JSON.stringify({ a, b, c, odd: linkMode() }));", + ) + assert out["a"] == ["popup", ["false", "true"]] # 기본 = 팝업 + assert out["b"] == ["link", ["true", "false"], '"link"'] + assert out["c"] == ["popup", ["false", "true"], '"popup"'] + assert out["odd"] == "popup" # 모르는 값 = 팝업 + + +def test_DRAFT_actions_자리(): + tabs = (B09 / "B09_Estimation_UI_Tabs.ts").read_text("utf-8") + assert "actions: Array<() => HTMLElement>;" in tabs + assert "{ builders: {}, extend: [], panels: [], actions: [] }" in tabs + state = (ROOT / "A00_Common/b_page_state.ts").read_text("utf-8") + assert '"b09-link-mode": { bucket: "pref", scope: "global" },' in state + css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8") + m = re.search(r"\.b09-dock\.ui-sidebar-actions\s*\{([^}]*)\}", css) + assert m and "flex-direction: column" in m.group(1) # 줄 여럿 세로 + + +# 표 짜기 — 탭 짜기 18 개 대신 가짜(줄 하나) · 옵션 = 표 그대로 넘김 +FAKE_BUILDS = "".join( + f"\nconst {name} = () => ({{ columns: [], rows: [{{ key: '{name}' }}] }});" + for name in re.findall( + r":\s*(build\w+),", + (ROOT / BUILDERS).read_text("utf-8"), + ) +) + + +def test_Builders_화면_같은_옵션(): + out = run( + [TABS, BUILDERS], + "\nDRAFT.builders.draft_x = () => '안내';" + "\nconst spec = { columns: [{ key: 'a', label: 'A' }], rows: [{ key: 'r' }], frozen: 2," + " editable: () => true, onEdit: () => {}, rowClass: () => 'x', cellClass: () => 'y'," + " level: { key: 'a', of: () => 1 }, mergeSame: ['a'], link: () => null, menu: () => [] };" + "\nconst o = tableOptionsOf(spec, 'bill');" + "\nconst plain = tableOptionsOf({ columns: [], rows: [], editOnDouble: false });" + "\nconsole.log(JSON.stringify({ keys: Object.keys(o).sort(), caption: o.caption," + " same: ['columns', 'rows', 'editable', 'onEdit', 'rowClass', 'cellClass', 'level', 'mergeSame']" + ".every((k) => o[k] === spec[k]), frozen: o.frozen, total: o.total, empty: o.empty," + " dbl: [o.editOnDouble, plain.editOnDouble], key: [o.keyEdit, plain.keyEdit]," + " plainCaption: plain.caption ?? null, plainFrozen: plain.frozen," + " bill: tabTableOf('bill', {}).rows[0].key, draft: tabTableOf('draft_x', {})," + " none: tabTableOf('nope', {}) }));", + FAKE_BUILDS, + ) + assert out["keys"] == sorted( + [ + "columns", + "rows", + "total", + "frozen", + "empty", + "caption", + "editable", + "editOnDouble", + "keyEdit", + "onEdit", + "rowClass", + "cellClass", + "level", + "mergeSame", + ] + ) # 누름 · 메뉴 · 풍선 없음(그리는 쪽 몫) + assert out["caption"] == "설계내역서" and out["same"] is True + assert out["frozen"] == 2 and out["total"] is None and out["empty"] == "" + assert out["dbl"] == [True, False] # 243-1 기본 = 더블클릭 + assert out["key"] == [False, True] # 설계내역서(메뉴) Enter · F2 = 레벨 키 몫 + assert out["plainCaption"] is None and out["plainFrozen"] == 0 + assert out["bill"] == "buildBill" and out["draft"] == "안내" and out["none"] is None + + +def test_화면이_Builders_옵션을_씀(): + shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "...tableOptionsOf(spec, tab)," in shell + assert "const spec = tabTableOf(tab, ctx);" in shell + assert "BUILDERS" not in shell diff --git a/resources/tester/test_304_b09_view_setting.py b/resources/tester/test_304_b09_view_setting.py new file mode 100644 index 000000000..5f29ce13b --- /dev/null +++ b/resources/tester/test_304_b09_view_setting.py @@ -0,0 +1,103 @@ +"""304-1 B09 박스보기 · 호표보기 = 좌측 하단 한 설정(묶음 탭 셋 함께). + +- 한 값(`b09-group-view` · pref global)이 dg · machine_blocks · sg 셋에 함께 · 기본 = 박스(카드) +- 옛 탭별 값(`{탭: 보기}`) = 첫 읽기에 한 값으로(dg · 없으면 다른 탭 · 없으면 기본) 옮겨 적음 +- 보조 탭 줄 보기 토글 걷음 · 가로 열 수 단추(박스 보기)는 그대로 +- 좌측 하단 스위치 [박스보기|호표보기] 누름 = 값 바꿈 · 다시 그림 부름 +""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402 + +LABOR = "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts" +DOCK = "B09_Estimation/B09_Estimation_UI_Dock.ts" +DG = {"id": "DG1", "no": 1, "name": "찰쌓기", "unit": "m2", "lines": [], "sum": 0} +SPEC = ( + "\nconst spec = buildDaegaBlocks({ sheets: { daega_blocks: [" + + json.dumps(DG, ensure_ascii=False) + + "] } });" + "\nconst tools = [];" + "\nconst draw = (tab) => renderGroupPanel({ tab, spec, hint: null, goto: () => {}," + " setTools: (t) => tools.push(...t)," + " makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) })[0];" + "\nconst kinds = () => ['dg', 'machine_blocks', 'sg'].map((t) => draw(t).tagName);" +) + + +def test_한_설정_셋_탭_함께(): + out = run( + [COMMON, TABS, LABOR, GROUPS], + SPEC + "\nconst a = kinds();" + "\nchooseGroupView('table');" + "\nconst b = kinds();" + "\nconsole.log(JSON.stringify({ a, b, kept: Object.fromEntries(kept)," + " toggles: tools.filter((e) => e.dataset.b09GroupView).length," + " cols: tools.flatMap((e) => e.all('[data-group-cols]')).length }));", + RAW, + ) + assert out["a"] == ["DIV"] * 3 # 기본 = 박스 + assert out["b"] == ["TABLE"] * 3 # 한 값이 셋 탭 함께 + assert json.loads(out["kept"]["b09-group-view"]) == "table" + assert out["toggles"] == 0 # 탭 줄 보기 토글 없음 + assert out["cols"] == 4 * 3 # 박스 보기 가로 열 수 단추 = 탭 줄 그대로 + + +def test_옛_탭별_값_첫_읽기에_한_값으로(): + cases = [ + ({"dg": "table", "sg": "cards"}, "table"), + ({"dg": "cards", "sg": "table"}, "cards"), # dg 먼저 + ({"sg": "table"}, "table"), # dg 없으면 다른 탭 + ({"machine_blocks": "table", "sg": "cards"}, "table"), + ({}, "cards"), # 없으면 기본 + ] + for old, want in cases: + out = run( + [COMMON, TABS, LABOR, GROUPS], + "\nkept.set('b09-group-view', " + + json.dumps(json.dumps(old)) + + ");" + + SPEC + + "\nconst got = kinds();" + "\nconsole.log(JSON.stringify({ got, kept: Object.fromEntries(kept) }));", + RAW, + ) + assert out["got"] == ["TABLE" if want == "table" else "DIV"] * 3, old + assert json.loads(out["kept"]["b09-group-view"]) == want, old # 옮겨 적음 + + +def test_좌측_스위치_누름(): + out = run( + [COMMON, TABS, GROUPS, DOCK], + "\nlet redraws = 0;" + "\nconst dock = createDock([document.createElement('button')], () => redraws++);" + "\nconst row = dock.children[0];" + "\nconst pressed = () => row.children.map((b) => b.attrs['aria-pressed']);" + "\nconst labels = row.children.map((b) => b.textContent);" + "\nconst a = pressed();" + "\nrow.children[0].click();" # 이미 박스 = 그대로 + "\nconst b = [redraws, pressed()];" + "\nrow.children[1].click();" + "\nconsole.log(JSON.stringify({ name: row.dataset.b09Switch, labels, a, b, c: [redraws, pressed()]," + " view: groupView(), kept: Object.fromEntries(kept) }));", + ) + assert out["name"] == "view" and out["labels"] == ["박스보기", "호표보기"] + assert out["a"] == ["true", "false"] # 기본 = 박스보기 + assert out["b"] == [0, ["true", "false"]] + assert out["c"] == [1, ["false", "true"]] and out["view"] == "table" + assert json.loads(out["kept"]["b09-group-view"]) == "table" # [저장] 없이 pref + + +def test_잇기(): + groups = (ROOT / GROUPS).read_text("utf-8") + 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") + assert "createDock([save, reset], () => isGroupTab(current) && show(current))" in shell + state = (ROOT / "A00_Common/b_page_state.ts").read_text("utf-8") + assert '"b09-group-view": { bucket: "pref", scope: "global" },' in state From 8230788e967defa7efc67bc622bb4a9ffb4569c8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 08:01:33 +0900 Subject: [PATCH 09/17] =?UTF-8?q?refactor(b07):=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=96=B9=EA=B8=B0=20=EB=AA=B8?= =?UTF-8?q?=ED=86=B5=20=3D=20Cross=5FOverlay.placeStructureEntities=20?= =?UTF-8?q?=C2=B7=20Node=20=EA=B0=80=EC=A7=9C=20DOM=20=3D=20Node=5FDom=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20(302-2=20=EC=95=9E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면 appendStructureEntities = 정본 상세 읽기 + placeStructureEntities 부름 · 동작 그대로 - 가짜 문서에 addEventListener · removeEventListener 빈 함수 — 화면 모듈이 불러올 때 거는 문서 이벤트로 덧그림 범위 Node 번들이 시작부터 멈추던 흠 (10-04 무렵부터 서버 장 배치가 덧그림 범위 없이 섬) - 시험 212 = 옮긴 자리에서 글 찾음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../B07_DesignDetail_Cross_Extent_Node.ts | 152 +---------------- .../B07_DesignDetail_Cross_Overlay.ts | 82 +++++++++ B07_DesignDetail/B07_DesignDetail_Node_Dom.ts | 161 ++++++++++++++++++ .../B07_DesignDetail_UI_Cad_Structures.ts | 81 +-------- .../test_212_b07_cross_station_layers.py | 3 +- 5 files changed, 250 insertions(+), 229 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Node_Dom.ts diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts index 8ed1dab83..9a4917a1c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts @@ -14,163 +14,13 @@ import { readFileSync, writeFileSync } from "node:fs"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; +import "./B07_DesignDetail_Node_Dom"; // 최소 DOM(globalThis.document) — 그리기 코드보다 먼저 import { overlayEntities, type Entity } from "./B07_DesignDetail_Cross_Overlay"; /** 화면과 같은 종이 축척(1/100 → 1 m = 10 mm) — 공선점 허용 오차가 종이 mm 라 같아야 한다. */ const MM_PER_M = 10; const OPEN_FRAME = [-1e12, -1e12, 1e12, 1e12]; -/* ── 최소 DOM: 그리기 코드가 쓰는 만큼만 ─────────────────────────────────── */ - -type Child = FakeElement | string; - -/** 선택자 하나(`tag`, `tag[attr]`)가 이 요소에 맞나 — 그리기·수확이 쓰는 꼴만 안다. */ -function matches(element: FakeElement, selectors: string): boolean { - return selectors.split(",").some((raw) => { - const found = /^([\w-]*)(?:\[([\w-]+)\])?$/.exec(raw.trim()); - if (!found) return false; - const [, tag, attribute] = found; - if (tag && element.tagName !== tag) return false; - return !attribute || element.attributes.has(attribute); - }); -} - -class FakeElement { - readonly attributes = new Map(); - readonly childNodes: Child[] = []; - parentNode: FakeElement | null = null; - readonly dataset: Record = {}; - readonly style: Record = { setProperty: () => undefined }; - readonly classList = { - add: (...names: string[]) => this.setClasses([...this.classes(), ...names]), - remove: (...names: string[]) => - this.setClasses(this.classes().filter((name) => !names.includes(name))), - toggle: (name: string, force?: boolean) => { - const on = force ?? !this.classes().includes(name); - this.setClasses(on ? [...this.classes(), name] : this.classes().filter((c) => c !== name)); - return on; - }, - contains: (name: string) => this.classes().includes(name), - }; - - constructor(readonly tagName: string) {} - - get ownerDocument(): typeof fakeDocument { - return fakeDocument; - } - get parentElement(): FakeElement | null { - return this.parentNode; - } - get children(): FakeElement[] { - return this.childNodes.filter((child): child is FakeElement => typeof child !== "string"); - } - get firstChild(): Child | null { - return this.childNodes[0] ?? null; - } - get lastChild(): Child | null { - return this.childNodes[this.childNodes.length - 1] ?? null; - } - get textContent(): string { - return this.childNodes - .map((child) => (typeof child === "string" ? child : child.textContent)) - .join(""); - } - set textContent(value: string | null) { - this.replaceChildren(...(value ? [String(value)] : [])); - } - - private classes(): string[] { - return (this.attributes.get("class") ?? "").split(/\s+/).filter(Boolean); - } - private setClasses(names: string[]): void { - this.attributes.set("class", [...new Set(names)].join(" ")); - } - private adopt(child: Child): Child { - if (typeof child !== "string") { - child.parentNode?.removeChild(child); - child.parentNode = this; - } - return child; - } - - setAttribute(name: string, value: unknown): void { - this.attributes.set(name, String(value)); - } - setAttributeNS(_ns: string | null, name: string, value: unknown): void { - this.setAttribute(name, value); - } - getAttribute(name: string): string | null { - return this.attributes.get(name) ?? null; - } - hasAttribute(name: string): boolean { - return this.attributes.has(name); - } - removeAttribute(name: string): void { - this.attributes.delete(name); - } - append(...nodes: Child[]): void { - for (const node of nodes) this.childNodes.push(this.adopt(node)); - } - appendChild(node: T): T { - this.append(node); - return node; - } - prepend(...nodes: Child[]): void { - this.childNodes.unshift(...nodes.map((node) => this.adopt(node))); - } - insertBefore(node: T, reference: Child | null): T { - const index = reference ? this.childNodes.indexOf(reference) : -1; - if (index < 0) return this.appendChild(node); - this.childNodes.splice(index, 0, this.adopt(node)); - return node; - } - removeChild(node: T): T { - const index = this.childNodes.indexOf(node); - if (index >= 0) this.childNodes.splice(index, 1); - if (typeof node !== "string") node.parentNode = null; - return node; - } - replaceChildren(...nodes: Child[]): void { - for (const child of [...this.childNodes]) this.removeChild(child); - this.append(...nodes); - } - remove(): void { - this.parentNode?.removeChild(this); - } - closest(selectors: string): FakeElement | null { - for (let node: FakeElement | null = this; node; node = node.parentNode) { - if (matches(node, selectors)) return node; - } - return null; - } - querySelectorAll(selectors: string): FakeElement[] { - const found: FakeElement[] = []; - const walk = (node: FakeElement): void => { - for (const child of node.children) { - if (matches(child, selectors)) found.push(child); - walk(child); - } - }; - walk(this); - return found; - } - querySelector(selectors: string): FakeElement | null { - return this.querySelectorAll(selectors)[0] ?? null; - } - addEventListener(): void {} - removeEventListener(): void {} -} - -const fakeDocument = { - createElementNS: (_ns: string, tag: string) => new FakeElement(tag), - createElement: (tag: string) => new FakeElement(tag), - createTextNode: (text: string) => text, - // 오프스크린 SVG 는 문서에 안 붙어 있다 — 브라우저도 여기서 null 을 낸다(클립 해칭은 안 자름). - getElementById: () => null, -}; - -(globalThis as Record).document = fakeDocument; - /* ── 범위 재기 ─────────────────────────────────────────────────────────── */ /** diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts index dbce9f48d..8fd7616f8 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -552,3 +552,85 @@ export function overlayEntities( ...harvest(pipeSvg, PIPE_STYLE, frame), ]; } + +/** 서버가 도면에 실어 보내는 측점별 실좌표(m) → 종이(mm) 변환값. */ +export interface CrossPlacement { + chainage_m: number; + ox: number; + oy: number; + dy: number; + mm_per_m: number; + x0: number; + x1: number; + /** 블록 테두리(종이 mm) [x0, y0, x1, y1] — 그림을 이 안으로 자른다. */ + frame?: number[]; + /** 측점 한 벌 묶음(180-1) — 덧그림도 같은 묶음으로 함께 골라짐. */ + group_id?: string; + /** 측점 도면층(212-2) — 덧그림도 그 층에. */ + layer_id?: string; + /** 갈아 끼울 서버 설계선 id(212-2) — 측점 층이면 층으로는 못 가림. */ + design_ids?: string[]; +} + +/** 개체(자식 선까지)를 측점 층으로(212-2). */ +function moveToLayer(entity: Entity, layerId: string): void { + entity.layerId = layerId; + for (const child of (entity.children as Entity[] | undefined) ?? []) moveToLayer(child, layerId); +} + +/** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */ +const CHAINAGE_TOLERANCE_M = 0.02; + +/** + * 도면에 구조물 엔티티를 얹는다 (제자리 수정) — 화면(`_UI_Cad_Structures`)과 인쇄 Node(302-2)가 같이 씀. + * 배치 메타가 없으면 아무것도 하지 않는다 — 구조물이 빠져도 도면 자체는 열려야 한다. + */ +export function placeStructureEntities( + sections: CrossSection[], + drawing: { entities: Record[]; cross_placements?: CrossPlacement[] }, +): number { + const placements = drawing.cross_placements ?? []; + let added = 0; + const fresh: Entity[] = []; + const replaced: number[][] = []; + const designIds = new Set(); + for (const placement of placements) { + const section = sections.find( + (item) => Math.abs(item.chainage_m - placement.chainage_m) <= CHAINAGE_TOLERANCE_M, + ); + const frame = placement.frame; + if (!section || !frame) continue; + const x = (offset: number): number => offset * placement.mm_per_m + placement.ox; + const y = (elevation: number): number => + -((elevation - placement.dy) * placement.mm_per_m + placement.oy); + let entities: Entity[]; + try { + const drawn = overlayEntities(section, sections, x, y, frame); + if (!drawn) continue; + entities = drawn; + } catch { + continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다. + } + if (!entities.length) continue; + if (placement.group_id) for (const entity of entities) entity.groupId = placement.group_id; + if (placement.layer_id) for (const entity of entities) moveToLayer(entity, placement.layer_id); + for (const id of placement.design_ids ?? []) designIds.add(id); + // 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다. + replaced.push(frame); + fresh.push(...entities); + added += entities.length; + } + if (replaced.length) { + // 걷어내기는 새 엔티티를 넣기 **전에** 한다 — 뒤에 하면 우리 것까지 같이 지운다. + drawing.entities = drawing.entities.filter((entity) => { + if (entity.layerId !== DESIGN_LAYER_ID && !designIds.has(entity.id as string)) return true; + const box = entityBox(entity); + if (!box) return true; + return !replaced.some( + ([fx0, fy0, fx1, fy1]) => box[0] >= fx0 && box[2] <= fx1 && box[1] >= fy0 && box[3] <= fy1, + ); + }); + } + drawing.entities.push(...fresh); + return added; +} diff --git a/B07_DesignDetail/B07_DesignDetail_Node_Dom.ts b/B07_DesignDetail/B07_DesignDetail_Node_Dom.ts new file mode 100644 index 000000000..c9183de79 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Node_Dom.ts @@ -0,0 +1,161 @@ +/* ============================================================================= + * B07_DesignDetail_Node_Dom.ts + * 서버 Node 진입점이 쓰는 **최소 DOM** — 덧그림 그리기 코드가 오프스크린 SVG 에 쓰는 만큼만. + * 불러오기만 하면 `globalThis.document` 가 선다(`_Cross_Extent_Node` · 인쇄 302-2 `B09_Draft_Print_Drawings_Node`). + * ========================================================================== */ + +/* ── 최소 DOM: 그리기 코드가 쓰는 만큼만 ─────────────────────────────────── */ + +type Child = FakeElement | string; + +/** 선택자 하나(`tag`, `tag[attr]`)가 이 요소에 맞나 — 그리기·수확이 쓰는 꼴만 안다. */ +function matches(element: FakeElement, selectors: string): boolean { + return selectors.split(",").some((raw) => { + const found = /^([\w-]*)(?:\[([\w-]+)\])?$/.exec(raw.trim()); + if (!found) return false; + const [, tag, attribute] = found; + if (tag && element.tagName !== tag) return false; + return !attribute || element.attributes.has(attribute); + }); +} + +class FakeElement { + readonly attributes = new Map(); + readonly childNodes: Child[] = []; + parentNode: FakeElement | null = null; + readonly dataset: Record = {}; + readonly style: Record = { setProperty: () => undefined }; + readonly classList = { + add: (...names: string[]) => this.setClasses([...this.classes(), ...names]), + remove: (...names: string[]) => + this.setClasses(this.classes().filter((name) => !names.includes(name))), + toggle: (name: string, force?: boolean) => { + const on = force ?? !this.classes().includes(name); + this.setClasses(on ? [...this.classes(), name] : this.classes().filter((c) => c !== name)); + return on; + }, + contains: (name: string) => this.classes().includes(name), + }; + + constructor(readonly tagName: string) {} + + get ownerDocument(): typeof fakeDocument { + return fakeDocument; + } + get parentElement(): FakeElement | null { + return this.parentNode; + } + get children(): FakeElement[] { + return this.childNodes.filter((child): child is FakeElement => typeof child !== "string"); + } + get firstChild(): Child | null { + return this.childNodes[0] ?? null; + } + get lastChild(): Child | null { + return this.childNodes[this.childNodes.length - 1] ?? null; + } + get textContent(): string { + return this.childNodes + .map((child) => (typeof child === "string" ? child : child.textContent)) + .join(""); + } + set textContent(value: string | null) { + this.replaceChildren(...(value ? [String(value)] : [])); + } + + private classes(): string[] { + return (this.attributes.get("class") ?? "").split(/\s+/).filter(Boolean); + } + private setClasses(names: string[]): void { + this.attributes.set("class", [...new Set(names)].join(" ")); + } + private adopt(child: Child): Child { + if (typeof child !== "string") { + child.parentNode?.removeChild(child); + child.parentNode = this; + } + return child; + } + + setAttribute(name: string, value: unknown): void { + this.attributes.set(name, String(value)); + } + setAttributeNS(_ns: string | null, name: string, value: unknown): void { + this.setAttribute(name, value); + } + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + hasAttribute(name: string): boolean { + return this.attributes.has(name); + } + removeAttribute(name: string): void { + this.attributes.delete(name); + } + append(...nodes: Child[]): void { + for (const node of nodes) this.childNodes.push(this.adopt(node)); + } + appendChild(node: T): T { + this.append(node); + return node; + } + prepend(...nodes: Child[]): void { + this.childNodes.unshift(...nodes.map((node) => this.adopt(node))); + } + insertBefore(node: T, reference: Child | null): T { + const index = reference ? this.childNodes.indexOf(reference) : -1; + if (index < 0) return this.appendChild(node); + this.childNodes.splice(index, 0, this.adopt(node)); + return node; + } + removeChild(node: T): T { + const index = this.childNodes.indexOf(node); + if (index >= 0) this.childNodes.splice(index, 1); + if (typeof node !== "string") node.parentNode = null; + return node; + } + replaceChildren(...nodes: Child[]): void { + for (const child of [...this.childNodes]) this.removeChild(child); + this.append(...nodes); + } + remove(): void { + this.parentNode?.removeChild(this); + } + closest(selectors: string): FakeElement | null { + for (let node: FakeElement | null = this; node; node = node.parentNode) { + if (matches(node, selectors)) return node; + } + return null; + } + querySelectorAll(selectors: string): FakeElement[] { + const found: FakeElement[] = []; + const walk = (node: FakeElement): void => { + for (const child of node.children) { + if (matches(child, selectors)) found.push(child); + walk(child); + } + }; + walk(this); + return found; + } + querySelector(selectors: string): FakeElement | null { + return this.querySelectorAll(selectors)[0] ?? null; + } + addEventListener(): void {} + removeEventListener(): void {} +} + +const fakeDocument = { + createElementNS: (_ns: string, tag: string) => new FakeElement(tag), + createElement: (tag: string) => new FakeElement(tag), + createTextNode: (text: string) => text, + // 오프스크린 SVG 는 문서에 안 붙어 있다 — 브라우저도 여기서 null 을 낸다(클립 해칭은 안 자름). + getElementById: () => null, + // 화면 모듈이 불러올 때 거는 문서 이벤트(누름 등) — 서버에는 사람이 없어 빈 함수 + addEventListener: () => undefined, + removeEventListener: () => undefined, +}; + +(globalThis as Record).document = fakeDocument; + +export {}; // 모듈로 둠 — 이름이 전역에 새지 않게 diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index c4e446c65..20180f015 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -14,40 +14,9 @@ import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store"; -import { - DESIGN_LAYER_ID, - entityBox, - overlayEntities, - type Entity, -} from "./B07_DesignDetail_Cross_Overlay"; +import { placeStructureEntities, type CrossPlacement } from "./B07_DesignDetail_Cross_Overlay"; -/** 서버가 도면에 실어 보내는 측점별 실좌표(m) → 종이(mm) 변환값. */ -export interface CrossPlacement { - chainage_m: number; - ox: number; - oy: number; - dy: number; - mm_per_m: number; - x0: number; - x1: number; - /** 블록 테두리(종이 mm) [x0, y0, x1, y1] — 그림을 이 안으로 자른다. */ - frame?: number[]; - /** 측점 한 벌 묶음(180-1) — 덧그림도 같은 묶음으로 함께 골라짐. */ - group_id?: string; - /** 측점 도면층(212-2) — 덧그림도 그 층에. */ - layer_id?: string; - /** 갈아 끼울 서버 설계선 id(212-2) — 측점 층이면 층으로는 못 가림. */ - design_ids?: string[]; -} - -/** 개체(자식 선까지)를 측점 층으로(212-2). */ -function moveToLayer(entity: Entity, layerId: string): void { - entity.layerId = layerId; - for (const child of (entity.children as Entity[] | undefined) ?? []) moveToLayer(child, layerId); -} - -/** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */ -const CHAINAGE_TOLERANCE_M = 0.02; +export type { CrossPlacement }; /** * 도면에 구조물 엔티티를 얹는다 (제자리 수정). 배치 메타가 없거나 자료를 못 읽으면 @@ -68,48 +37,6 @@ export async function appendStructureEntities( } catch { return 0; } - - let added = 0; - const fresh: Entity[] = []; - const replaced: number[][] = []; - const designIds = new Set(); - for (const placement of placements) { - const section = sections.find( - (item) => Math.abs(item.chainage_m - placement.chainage_m) <= CHAINAGE_TOLERANCE_M, - ); - const frame = placement.frame; - if (!section || !frame) continue; - const x = (offset: number): number => offset * placement.mm_per_m + placement.ox; - const y = (elevation: number): number => - -((elevation - placement.dy) * placement.mm_per_m + placement.oy); - let entities: Entity[]; - try { - const drawn = overlayEntities(section, sections, x, y, frame); - if (!drawn) continue; - entities = drawn; - } catch { - continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다. - } - if (!entities.length) continue; - if (placement.group_id) for (const entity of entities) entity.groupId = placement.group_id; - if (placement.layer_id) for (const entity of entities) moveToLayer(entity, placement.layer_id); - for (const id of placement.design_ids ?? []) designIds.add(id); - // 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다. - replaced.push(frame); - fresh.push(...entities); - added += entities.length; - } - if (replaced.length) { - // 걷어내기는 새 엔티티를 넣기 **전에** 한다 — 뒤에 하면 우리 것까지 같이 지운다. - drawing.entities = drawing.entities.filter((entity) => { - if (entity.layerId !== DESIGN_LAYER_ID && !designIds.has(entity.id as string)) return true; - const box = entityBox(entity); - if (!box) return true; - return !replaced.some( - ([fx0, fy0, fx1, fy1]) => box[0] >= fx0 && box[2] <= fx1 && box[1] >= fy0 && box[3] <= fy1, - ); - }); - } - drawing.entities.push(...fresh); - return added; + // 얹는 몸통 = `_Cross_Overlay.placeStructureEntities`(인쇄 Node 302-2 와 한 벌) + return placeStructureEntities(sections, drawing); } diff --git a/resources/tester/test_212_b07_cross_station_layers.py b/resources/tester/test_212_b07_cross_station_layers.py index baa293a31..b875abb81 100644 --- a/resources/tester/test_212_b07_cross_station_layers.py +++ b/resources/tester/test_212_b07_cross_station_layers.py @@ -175,7 +175,8 @@ def test_옛_확정_장_원지반_잠금_풂_측점_층_안내(tmp_path: Path): def test_화면_덧그림_측점_층_design_ids(): - struct = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts").read_text( + # 얹는 몸통 = Cross_Overlay.placeStructureEntities(302-2 · 화면 · 인쇄 Node 한 벌) + struct = (ROOT / "B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts").read_text( encoding="utf-8" ) assert "layer_id?: string;" in struct and "design_ids?: string[];" in struct From f4889352f942bd9f58bcb510913457738d278b2a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 08:01:47 +0900 Subject: [PATCH 10/17] =?UTF-8?q?feat(b09-print):=20=EC=9D=B8=EC=87=84=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EB=8F=84=EB=A9=B4=20PDF?= =?UTF-8?q?=20=E2=80=94=20B07=20=EB=8F=84=EB=A9=B4=20+=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=C2=B7=20=EB=8F=84=EA=B0=81=20=ED=81=AC=EA=B8=B0=20=EC=AA=BD=20?= =?UTF-8?q?=C2=B7=20=EC=88=A8=EA=B8=B4=20=EC=B8=B5=20=EB=BA=8C=20=C2=B7=20?= =?UTF-8?q?=EB=B2=A1=ED=84=B0=20=C2=B7=20=ED=95=9C=EA=B8=80=20(302-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 길 = GET /api/projects/{id}/print/drawings · POST /api/projects/{id}/print/drawings-pdf {ids?} (draft 라우터 · 로그인 · 회사 · 프로젝트 권한 스스로) - 차례 = B07 도면번호 → 구조물 조합 도면(도번 차례 · 도번마다 한 번 · 더한장) - B07 = B07 단건 조회 그대로(확정본 · 미확정 조립) · 미확정 횡단 장 구조물 = 화면 TS 를 서버 Node 로(번들 tmp · package.json 안 건드림) - 쪽 = 도각 양식 실치수(A1 840×594) · 색 = openwebcad 밝은 바탕 규칙 · 글 = 맑은 고딕 심음(subset) · 그림 = data URL 넣음 · 못 넣은 수 = 응답 머리 - 화면 쪽 = fetchDrawingList · fetchDrawingsPdf(약속 꼴) · skippedImagesOf - requirements = PyMuPDF · ezdxf Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../draft/B09_Draft_Print_Drawings.ts | 59 +++ .../draft/B09_Draft_Print_Drawings_Node.ts | 34 ++ .../draft/B09_Draft_Print_Drawings_Pdf.py | 337 ++++++++++++++++++ .../draft/B09_Draft_Print_Drawings_Router.py | 99 +++++ .../draft/B09_Draft_Print_Drawings_Source.py | 240 +++++++++++++ requirements.txt | 2 + resources/tester/test_302_2_print_drawings.py | 273 ++++++++++++++ 7 files changed, 1044 insertions(+) create mode 100644 B09_Estimation/draft/B09_Draft_Print_Drawings.ts create mode 100644 B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts create mode 100644 B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py create mode 100644 B09_Estimation/draft/B09_Draft_Print_Drawings_Router.py create mode 100644 B09_Estimation/draft/B09_Draft_Print_Drawings_Source.py create mode 100644 resources/tester/test_302_2_print_drawings.py diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings.ts b/B09_Estimation/draft/B09_Draft_Print_Drawings.ts new file mode 100644 index 000000000..7f3907355 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings.ts @@ -0,0 +1,59 @@ +/* ============================================================================= + * B09_Draft_Print_Drawings.ts + * 인쇄 미리보기 도면 영역(PLAN 302-2) — 서버가 만든 목록 · PDF 를 그대로 받음(보는 것 = 받는 것). + * 차례 = B07 도면번호 → 구조물 조합 도면(도번마다 한 번) · 서버 `B09_Draft_Print_Drawings_Router.py` + * PDF 머리 X-Aislo-Skipped-Images = 못 넣은 그림 {id: 수} → `skippedImagesOf(blob)` + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DrawingItem, FetchDrawingList, FetchDrawingsPdf } from "./B09_Draft_Print_Types"; + +/** 도면 한 장 — 약속 꼴(id · name = label) + 서버가 주는 칸 */ +export interface PrintDrawingItem extends DrawingItem { + source: "b07" | "structure"; + kind: string; + label: string; + number: string; + confirmed: boolean; + paper: string | null; + /** PDF 를 만들어야 셈 — 목록에서는 null */ + images_skipped: number | null; +} + +async function failure(response: Response): Promise { + const body = (await response.json().catch(() => null)) as { message?: string } | null; + return new Error(body?.message ?? `HTTP ${response.status}`); +} + +export const fetchDrawingList: FetchDrawingList = async (projectId) => { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/print/drawings`, { + credentials: "include", + }); + if (!response.ok) throw await failure(response); + const items = (await response.json()) as Omit[]; + return items.map((item): PrintDrawingItem => ({ ...item, name: item.label })); +}; + +const skipped = new WeakMap>(); + +export const fetchDrawingsPdf: FetchDrawingsPdf = async (projectId, ids) => { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/print/drawings-pdf`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(ids ? { ids } : {}), + }); + if (!response.ok) throw await failure(response); + const blob = await response.blob(); + try { + skipped.set(blob, JSON.parse(response.headers.get("X-Aislo-Skipped-Images") ?? "{}")); + } catch { + /* 머리가 깨졌으면 빠진 그림 없음으로 */ + } + return blob; +}; + +/** 그 PDF 에서 못 넣은 그림 {도면 id: 수} — 없으면 빈 꼴 */ +export function skippedImagesOf(blob: Blob): Record { + return skipped.get(blob) ?? {}; +} diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts b/B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts new file mode 100644 index 000000000..c3b936efe --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts @@ -0,0 +1,34 @@ +/* ============================================================================= + * B09_Draft_Print_Drawings_Node.ts + * 인쇄 미리보기 도면(PLAN 302-2) — 미확정 횡단 장에 구조물을 얹는 Node 진입점(CLAUDE.md 5장 ②). + * + * B07 화면이 미확정 장을 열 때 얹는 것과 **같은 코드**(`placeStructureEntities`)를 서버에서 돌린다. + * 입력 상세 = 정본(`get_section_detail`) — 화면 캐시의 저장 안 한 편집은 안 실림(인쇄 = 저장본). + * + * 실행: node <번들> <입력.json> <출력.json> + * 입력 { detail: 종횡단 상세, drawings: { [도면 id]: CAD 도면 } } + * 출력 { drawings: { [도면 id]: 구조물을 얹은 CAD 도면 } } + * 끝 코드: 0 성공 / 2 인자 오류 + * ========================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; +import "../../B07_DesignDetail/B07_DesignDetail_Node_Dom"; // 최소 DOM — 그리기 코드보다 먼저 +import { placeStructureEntities } from "../../B07_DesignDetail/B07_DesignDetail_Cross_Overlay"; + +type Drawing = Parameters[1]; + +const [inputPath, outputPath] = process.argv.slice(2); +if (!inputPath || !outputPath) { + console.error("사용법: node <번들> <입력.json> <출력.json>"); + process.exit(2); +} + +const input = JSON.parse(readFileSync(inputPath, "utf8")) as { + detail?: SectionDetailResponse; + drawings?: Record; +}; +const sections = input.detail?.cross_sections ?? []; +const drawings = input.drawings ?? {}; +for (const drawing of Object.values(drawings)) placeStructureEntities(sections, drawing); +writeFileSync(outputPath, JSON.stringify({ drawings }), "utf8"); diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py b/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py new file mode 100644 index 000000000..117ccfb53 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py @@ -0,0 +1,337 @@ +"""인쇄 미리보기 도면(PLAN 302-2) — CAD 도면(openwebcad JSON) 한 장 → PDF 한 쪽(벡터). + +쪽 = 도각 — 도각 층 범위를 그 양식 실치수(A1 840×594 · 회사 양식이면 그 크기) 종이에 맞춤 · +도각이 없으면 그림 범위 1:1. 숨긴 층 · 동결 층은 뺌(화면에 안 보이는 것 = 안 나감). + +그리기 규칙 = openwebcad 화면(밝은 바탕)과 같게: + 색 = `theme.ts` `paintColor` 밝은 바탕 짝(자동색 → 검정 · 휘도 0.58 넘는 색 → 0.3 으로 누름) + 글 = 기준점 세로 가운데(textBaseline middle) · 정렬 left/center/right(기본 center) · + 방향 벡터 회전 + 호 = 세계 좌표 반시계(counterClockwise 거짓이면 시계) · 점 = 반지름 5 원 · + 그림 = 칸 안 비율 지켜 가운데 + 표 = `B07 Frame_Export.table_parts`(openwebcad table-geometry 거울) 그대로 +한글 = 시스템 TTF(맑은 고딕 → Noto Sans KR → 나눔고딕) 심음 · 끝에 문서가 글꼴을 줄임(subset). +""" + +from __future__ import annotations + +import base64 +import math +import re +from functools import lru_cache +from pathlib import Path +from typing import Any + +import pymupdf + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import FRAME_LAYER_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import table_parts +from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, template_entities + +PT_PER_MM = 72 / 25.4 +#: 화면 선 굵기(px) → 종이(pt) — ponytail: 고정 배율, 출력물 보고 고칠 손잡이 +LINE_PT_PER_PX = 0.5 +DASH_PT_PER_PX = 1.0 +POINT_RADIUS = 5.0 +DEFAULT_FONT_MM = 3.0 +#: 구조물 조합 도면에 얹는 도각 층(M02 `STRUCTURE_FRAME_LAYER`) +STRUCTURE_FRAME_LAYER = "m02-structure-frame" +#: 쪽을 잡는 도각 층 — 앞이 먼저 +#: (구조물 도면은 불러온 DXF 도각 `b08-frame` 이 그림 안에 있어도 얹은 도각) +FRAME_LAYERS = (STRUCTURE_FRAME_LAYER, FRAME_LAYER_ID) +PAPERS = {"A1": (840.0, 594.0), "A3": (420.0, 297.0)} + +FONT_FILES = ( + "C:/Windows/Fonts/malgun.ttf", + "C:/Windows/Fonts/NotoSansKR-VF.ttf", + "/usr/share/fonts/truetype/nanum/NanumGothic.ttf", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", +) +_AUTO_COLORS = {"#000000", "#ffffff", "#f5f7fa", "black", "white"} + + +@lru_cache(maxsize=1) +def _font() -> pymupdf.Font: + for path in FONT_FILES: + if Path(path).is_file(): + return pymupdf.Font(fontfile=path) + raise RuntimeError("한글 글꼴(맑은 고딕 · Noto Sans KR · 나눔고딕)을 찾지 못했습니다.") + + +def _hex(color: str) -> str: + value = color.strip().lower() + short = re.fullmatch(r"#([0-9a-f])([0-9a-f])([0-9a-f])", value) + return "#" + "".join(c * 2 for c in short.groups()) if short else value + + +def paint_color(color: Any) -> tuple[float, float, float]: + """`theme.ts` `paintColor`(밝은 바탕) 짝 — 거울 시험 `test_302_2_print_drawings`.""" + value = _hex(str(color or "#ffffff")) + if value in _AUTO_COLORS or not re.fullmatch(r"#[0-9a-f]{6}", value): + return (0.0, 0.0, 0.0) + r, g, b = (int(value[i : i + 2], 16) for i in (1, 3, 5)) + luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 + if luminance > 0.58: + scale = 0.3 / luminance + r, g, b = (round(min(255, v * scale)) for v in (r, g, b)) + return (r / 255, g / 255, b / 255) + + +def _xy(point: Any) -> tuple[float, float] | None: + if isinstance(point, dict) and isinstance(point.get("x"), (int, float)): + return float(point["x"]), float(point["y"]) + return None + + +def hidden_layers(drawing: dict[str, Any]) -> set[str]: + return { + str(layer.get("id")) + for layer in drawing.get("layers") or [] + if isinstance(layer, dict) and (layer.get("isVisible") is False or layer.get("isFrozen")) + } + + +def visible_entities(drawing: dict[str, Any]) -> list[dict[str, Any]]: + """숨긴 · 동결 층 개체를 (자식까지) 뺀 목록.""" + hidden = hidden_layers(drawing) + + def keep(entity: Any) -> dict[str, Any] | None: + if not isinstance(entity, dict) or entity.get("layerId") in hidden: + return None + children = entity.get("children") + if not children: + return entity + return {**entity, "children": [c for c in map(keep, children) if c]} + + return [e for e in map(keep, drawing.get("entities") or []) if e] + + +def paper_name(width: float, height: float) -> str: + for name, (w, h) in PAPERS.items(): + if abs(width - w) < 5 and abs(height - h) < 5: + return name + return f"{width:.0f}×{height:.0f}mm" + + +def page_frame(entities: list[dict[str, Any]]) -> tuple[tuple[float, ...], tuple[float, float]]: + """(세계 범위 x0, y0, x1, y1 · 종이 mm (가로, 세로)) — 도각 층 범위 → 양식 실치수 종이.""" + for layer in FRAME_LAYERS: + frame = entities_bbox([e for e in entities if e.get("layerId") == layer]) + if not frame: + continue + source = entities_bbox(template_entities()) + if source: + width, height = source[2] - source[0], source[3] - source[1] + return frame, (width, height) + box = entities_bbox(entities) or (0.0, 0.0, 840.0, 594.0) + return box, (max(box[2] - box[0], 1.0), max(box[3] - box[1], 1.0)) + + +_KAPPA = 0.5523 # 원 = 베지어 넷 + + +def _g(value: float) -> str: + return f"{value:.3f}".rstrip("0").rstrip(".") + + +class _Painter: + """세계 좌표(mm · y 위로) → PDF 그리기 명령(pt). + + 같은 선 모양 = 한 묶음 · 글 = 색마다 TextWriter. + + 선은 명령 글을 목록에 모아 한 번에 붙임 — + pymupdf Shape 를 개체마다 부르면 장 하나 수만 줄에 수십 초. + """ + + def __init__(self, page: pymupdf.Page, world: tuple[float, ...], font: pymupdf.Font): + self.page = page + self.height = page.rect.height + self.x0, self.y1 = world[0], world[3] + width, height = max(world[2] - world[0], 1e-9), max(world[3] - world[1], 1e-9) + self.k = min(page.rect.width / width, page.rect.height / height) + # 도각 비율이 종이와 다르면 가운데 + self.ox = (page.rect.width - width * self.k) / 2 + self.oy = (page.rect.height - height * self.k) / 2 + self.font = font + self.parts: list[str] = [] + self.ops: list[str] = [] + self.style: tuple | None = None + self.writers: dict[tuple, pymupdf.TextWriter] = {} + self.turned: list[tuple[pymupdf.TextWriter, pymupdf.Point, float]] = [] + self.skipped = 0 + + def p(self, x: float, y: float) -> pymupdf.Point: + """쪽 좌표(y 아래로) — 글 · 그림 자리.""" + return pymupdf.Point((x - self.x0) * self.k + self.ox, (self.y1 - y) * self.k + self.oy) + + def q(self, x: float, y: float) -> str: + """PDF 좌표(y 위로) 글 — 선 명령.""" + page_y = (self.y1 - y) * self.k + self.oy + return f"{_g((x - self.x0) * self.k + self.ox)} {_g(self.height - page_y)}" + + def stroke(self, entity: dict[str, Any], fill: tuple | None = None) -> None: + width = max(float(entity.get("lineWidth") or 1), 0.1) * LINE_PT_PER_PX + dash = tuple(float(d) * DASH_PT_PER_PX for d in entity.get("lineDash") or []) + style = (paint_color(entity.get("lineColor")), width, dash, fill) + if style != self.style: + self.flush() + self.style = style + + def flush(self) -> None: + if self.style is None or not self.ops: + return + (r, g, b), width, dash, fill = self.style + head = f"q 1 J 1 j {_g(r)} {_g(g)} {_g(b)} RG {_g(width)} w [{' '.join(map(_g, dash))}] 0 d" + if fill: + head += f" {_g(fill[0])} {_g(fill[1])} {_g(fill[2])} rg" + self.parts.append(f"{head}\n{''.join(self.ops)}{'B' if fill else 'S'} Q\n") + self.ops = [] + + def line(self, points: list[tuple[float, float]]) -> None: + self.ops.append( + f"{self.q(*points[0])} m " + " ".join(f"{self.q(*xy)} l" for xy in points[1:]) + "\n" + ) + + def circle(self, center: tuple[float, float], radius: float) -> None: + cx, cy = center + c = radius * _KAPPA + q = self.q + self.ops.append( + f"{q(cx + radius, cy)} m " + f"{q(cx + radius, cy + c)} {q(cx + c, cy + radius)} {q(cx, cy + radius)} c " + f"{q(cx - c, cy + radius)} {q(cx - radius, cy + c)} {q(cx - radius, cy)} c " + f"{q(cx - radius, cy - c)} {q(cx - c, cy - radius)} {q(cx, cy - radius)} c " + f"{q(cx + c, cy - radius)} {q(cx + radius, cy - c)} {q(cx + radius, cy)} c\n" + ) + + def text(self, label: str, base: tuple[float, float], options: dict[str, Any]) -> None: + if not label.strip(): + return + size = float(options.get("fontSize") or DEFAULT_FONT_MM) * self.k + width = self.font.text_length(label, fontsize=size) + align = options.get("textAlign") or "center" + shift = {"left": 0.0, "right": width}.get(align, width / 2) + direction = _xy(options.get("textDirection")) or (1.0, 0.0) + angle = math.degrees(math.atan2(direction[1], direction[0])) + anchor = self.p(*base) + color = paint_color(options.get("textColor")) + # 기준점 = 세로 가운데 → 글자 바닥선은 (올림 + 내림) / 2 만큼 아래 + drop = (self.font.ascender + self.font.descender) / 2 * size + if abs(angle) > 1e-6: + writer = pymupdf.TextWriter(self.page.rect, color=color) + self.turned.append((writer, anchor, angle)) + else: + writer = self.writers.setdefault(color, pymupdf.TextWriter(self.page.rect, color=color)) + writer.append(anchor + (-shift, drop), label, font=self.font, fontsize=size) + + def finish(self) -> None: + """선 → 글 차례로 붙임(글이 선 위).""" + self.flush() + if self.parts: + shape = self.page.new_shape() + shape.totalcont = "".join(self.parts) + shape.commit() + for writer in self.writers.values(): + writer.write_text(self.page) + for writer, anchor, angle in self.turned: + writer.write_text(self.page, morph=(anchor, pymupdf.Matrix(-angle))) + + def image(self, entity: dict[str, Any]) -> None: + shape = entity.get("shapeData") or {} + points = [xy for v in shape.get("points") or [] if (xy := _xy(v))] + data = str(shape.get("imageData") or "") + found = re.match(r"data:([\w/+.-]+);base64,(.*)", data, re.S) + if len(points) < 2 or not found: + self.skipped += 1 + return + xs, ys = [x for x, _ in points], [y for _, y in points] + rect = pymupdf.Rect(self.p(min(xs), max(ys)), self.p(max(xs), min(ys))) + blob = base64.b64decode(found.group(2)) + try: + if "svg" in found.group(1): + with pymupdf.open(stream=blob, filetype="svg") as svg: + with pymupdf.open("pdf", svg.convert_to_pdf()) as pdf: + self.page.show_pdf_page(rect, pdf, 0, keep_proportion=True) + else: + self.page.insert_image(rect, stream=blob, keep_proportion=True) + except (RuntimeError, ValueError): + self.skipped += 1 + + def arc(self, center: tuple[float, float], radius: float, shape: dict[str, Any]) -> None: + start, end = float(shape["startAngle"]), float(shape["endAngle"]) + if shape.get("counterClockwise") is False: + start, end = end, start + sweep = (end - start) % (2 * math.pi) or 2 * math.pi + steps = max(8, int(sweep / (math.pi / 36))) + self.line( + [ + ( + center[0] + radius * math.cos(start + sweep * i / steps), + center[1] + radius * math.sin(start + sweep * i / steps), + ) + for i in range(steps + 1) + ] + ) + + def entity(self, entity: dict[str, Any]) -> None: + shape = entity.get("shapeData") or {} + kind = entity.get("type") + if kind == "Image": + self.image(entity) + return + if kind == "Table": + self.stroke(entity) + lines, texts = table_parts(shape) + for x1, y1, x2, y2 in lines: + self.line([(x1, y1), (x2, y2)]) + color = (shape.get("style") or {}).get("textColor") + for one in texts: # ponytail: 칸 글색 · 굵게는 표 글색 하나로 + options = {"fontSize": one["height"], "textAlign": one["align"], "textColor": color} + self.text(one["text"], (one["x"], one["y"]), options) + return + if kind == "Hatch": + ring = [xy for v in shape.get("points") or [] if (xy := _xy(v))] + if len(ring) >= 3: + options = shape.get("options") or {} + # ponytail: 무늬(선 · 격자) 해치는 색 테두리만 — 칸 채움 무늬가 필요하면 clip 경로로 + fill = ( + paint_color(options.get("color")) if options.get("style") == "solid" else None + ) + self.stroke(entity, fill) + self.line([*ring, ring[0]]) + return + start, end = _xy(shape.get("startPoint")), _xy(shape.get("endPoint")) + if start and end: + self.stroke(entity) + self.line([start, end]) + elif (base := _xy(shape.get("basePoint"))) and isinstance(shape.get("label"), str): + self.text(shape["label"], base, shape.get("options") or {}) + elif point := _xy(shape.get("point")): + self.stroke(entity) + self.circle(point, POINT_RADIUS) + elif (center := _xy(shape.get("center"))) and isinstance(shape.get("radius"), (int, float)): + self.stroke(entity) + angles = (shape.get("startAngle"), shape.get("endAngle")) + if all(isinstance(a, (int, float)) for a in angles): + self.arc(center, float(shape["radius"]), shape) + else: + self.circle(center, float(shape["radius"])) + else: + vertices = [xy for v in shape.get("points") or [] if (xy := _xy(v))] + if len(vertices) >= 2: + self.stroke(entity) + self.line([*vertices, vertices[0]]) + for child in entity.get("children") or []: + self.entity(child) + + +def add_drawing_page(doc: pymupdf.Document, drawing: dict[str, Any]) -> tuple[str, int]: + """도면 한 장을 쪽 하나로 덧붙임 → (종이 이름, 못 넣은 그림 수).""" + entities = visible_entities(drawing) + world, (width_mm, height_mm) = page_frame(entities) + page = doc.new_page(width=width_mm * PT_PER_MM, height=height_mm * PT_PER_MM) + painter = _Painter(page, world, _font()) + for entity in entities: + painter.entity(entity) + painter.finish() + return paper_name(width_mm, height_mm), painter.skipped diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Router.py b/B09_Estimation/draft/B09_Draft_Print_Drawings_Router.py new file mode 100644 index 000000000..e87803cb7 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Router.py @@ -0,0 +1,99 @@ +"""인쇄 미리보기 도면(PLAN 302-2) — 목록 · PDF 길. + + GET /api/projects/{id}/print/drawings + → [{id, source, kind, label, number, confirmed, paper, images_skipped}] + POST /api/projects/{id}/print/drawings-pdf {ids?} → application/pdf + 목록 차례 · 장마다 한 쪽 · 머리 X-Aislo-Pages · + X-Aislo-Skipped-Images(못 넣은 그림 {id: 수} JSON) + +로그인 · 회사 · 프로젝트 권한은 이 라우터가 스스로 검(B09 라우터가 include 하든 앱이 바로 붙이든). +저장 안 함 — 매번 만듦 · B07 정본 · 확정 상태 그대로. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from uuid import UUID + +import pymupdf +from fastapi import APIRouter, Depends, Response +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from B09_Estimation.draft import B09_Draft_Print_Drawings_Source as source +from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import add_drawing_page +from common_util.common_util_auth import require_company, require_project_access, verify_session + +logger = logging.getLogger(__name__) +router = APIRouter( + prefix="/api/projects", + tags=["B09 Print Drawings (draft)"], + dependencies=[ + Depends(verify_session), + Depends(require_company), + Depends(require_project_access), + ], +) + + +class DrawingsPdfRequest(BaseModel): + ids: list[str] | None = None + + +def _error(status: int, message: str) -> JSONResponse: + return JSONResponse(status_code=status, content={"status": "error", "message": message}) + + +@router.get("/{project_id}/print/drawings") +async def get_print_drawings(project_id: UUID) -> JSONResponse: + try: + items, _ = await source.drawing_items(project_id) + except Exception: + logger.exception("인쇄 도면 목록 실패: %s", project_id) + return _error(500, "도면 목록을 읽지 못했습니다.") + return JSONResponse(items) + + +def _pdf(drawings: list[dict], ids: list[str]) -> tuple[bytes, dict[str, int]]: + doc = pymupdf.open() + skipped: dict[str, int] = {} + for drawing_id, drawing in zip(ids, drawings, strict=True): + _, missing = add_drawing_page(doc, drawing) + if missing: + skipped[drawing_id] = missing + doc.subset_fonts() # 한글 글꼴 = 쓴 글자만 + return doc.tobytes(garbage=3, deflate=True), skipped + + +@router.post("/{project_id}/print/drawings-pdf") +async def post_print_drawings_pdf( + project_id: UUID, request: DrawingsPdfRequest | None = None +) -> Response: + try: + items, context = await source.drawing_items(project_id) + by_id = {item["id"]: item for item in items} + ids = request.ids if request and request.ids is not None else list(by_id) + unknown = [one for one in ids if one not in by_id] + if unknown: + return _error(400, f"목록에 없는 도면입니다: {', '.join(unknown)}") + if not ids: + return _error(404, "인쇄할 도면이 없습니다.") + chosen = [by_id[one] for one in ids] + drawings = await source.drawings_in_order(project_id, chosen, context) + body, skipped = await asyncio.to_thread(_pdf, drawings, ids) + except ValueError as exc: + return _error(400, str(exc)) + except Exception: + logger.exception("인쇄 도면 PDF 실패: %s", project_id) + return _error(500, "도면 PDF 를 만들지 못했습니다.") + return Response( + body, + media_type="application/pdf", + headers={ + "Content-Disposition": 'inline; filename="drawings.pdf"', + "X-Aislo-Pages": str(len(ids)), + "X-Aislo-Skipped-Images": json.dumps(skipped), + }, + ) diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Source.py b/B09_Estimation/draft/B09_Draft_Print_Drawings_Source.py new file mode 100644 index 000000000..20beef2f6 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Source.py @@ -0,0 +1,240 @@ +"""인쇄 미리보기 도면(PLAN 302-2) — 목록 · 도면 모으기. + +차례 = B07 도면번호(B07 목록 그대로) → +구조물 조합 도면(도번 차례 · 도번마다 한 번 · `도면` 다음 `더한장`). +B07 도면 = B07 단건 조회 그대로(확정본 = 저장 JSON · 미확정 = 서버 조립) + 미확정 횡단 장 구조물 = +B07 화면과 같은 TS 를 Node 로(`B09_Draft_Print_Drawings_Node.ts` · CLAUDE.md 5장 ②). +구조물 도면 = 프로젝트 사본 `도면.json` + 얹는 도각(M02 `structure_frame` · 장 도번). +저장 · 확정 상태는 손대지 않음 — 매번 읽어 만듦. +""" + +from __future__ import annotations + +import asyncio +import logging +import subprocess +from pathlib import Path +from typing import Any +from uuid import UUID + +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + entities_bbox, + template_entities, + use_project_templates, + use_title_fields, +) +from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import paper_name +from common_util.common_util_node_bundle import ROOT, bundle_stale, node_env, run_bundle_json + +logger = logging.getLogger(__name__) + +B07_PREFIX = "b07:" +STRUCTURE_PREFIX = "structure:" +_ENTRY = Path(__file__).with_name("B09_Draft_Print_Drawings_Node.ts") +# 번들은 git 밖(tmp) — package.json 을 안 건드리려고 vite 를 바로 부름 +BUNDLE = ROOT / "tmp" / "print_drawings_node" / "B09_Draft_Print_Drawings_Node.js" +_BUILD = [ + "node", + "./config/node_modules/vite/bin/vite.js", + "build", + "--configLoader", + "runner", + "--ssr", + "../B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts", + "--outDir", + "../tmp/print_drawings_node", +] + + +async def project_root(project_id: UUID) -> Path: + from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path + from common_util.common_util_storage import resolve_stored_project_path + from config.config_db import get_db_pool + + async with get_db_pool().acquire() as connection: + stored = await get_project_storage_relative_path(connection, project_id) + return Path(resolve_stored_project_path(stored)).resolve() + + +async def title_fields(project_id: UUID) -> dict[str, str]: + from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields + + return await title_block_fields(project_id) + + +async def b07_list(project_id: UUID) -> tuple[int | None, list[dict[str, Any]]]: + """(노선 id, B07 목록) — 노선 미확정 · 자료 없음이면 (None, []).""" + from B07_DesignDetail.B07_DesignDetail_Router import get_design_drawing_list + + response = await get_design_drawing_list(project_id) + if not hasattr(response, "drawings"): # JSONResponse = 404 · 409 · 500 + return None, [] + return response.route_id, [item.model_dump() for item in response.drawings] + + +async def b07_drawing(project_id: UUID, drawing_id: str) -> dict[str, Any]: + from B07_DesignDetail.B07_DesignDetail_Router import get_design_drawing + + response = await get_design_drawing(project_id, drawing_id) + if not hasattr(response, "drawing"): + raise ValueError(f"B07 도면 「{drawing_id}」 을 읽지 못했습니다.") + return response.model_dump() + + +async def section_detail(project_id: UUID, route_id: int) -> dict[str, Any] | None: + from B06_Section.B06_Section_Router import get_section_detail + + response = await get_section_detail(project_id, route_id) + dump = getattr(response, "model_dump", None) + return dump(mode="json") if dump else None + + +def _bundle_ready() -> bool: + stale = bundle_stale(BUNDLE) or _ENTRY.stat().st_mtime > BUNDLE.stat().st_mtime + if not stale: + return True + result = subprocess.run( # noqa: S603 — 고정 명령 + _BUILD, cwd=str(ROOT), env=node_env(), capture_output=True, text=True, timeout=300 + ) + if result.returncode != 0: + logger.error("인쇄 도면 Node 번들 빌드 실패:\n%s", result.stderr) + return result.returncode == 0 + + +def place_structures(detail: dict[str, Any], drawings: dict[str, dict[str, Any]]) -> bool: + """미확정 횡단 장에 구조물을 얹음(제자리) — 실패면 거짓(서버 그림만 나감).""" + if not drawings or not _bundle_ready(): + return False + output = run_bundle_json(BUNDLE, "", {"detail": detail, "drawings": drawings}) + placed = (output or {}).get("drawings") if isinstance(output, dict) else None + if not isinstance(placed, dict): + return False + for key, drawing in placed.items(): + if key in drawings and isinstance(drawing, dict): + drawings[key].clear() + drawings[key].update(drawing) + return True + + +def sheet_number(number: str, index: int) -> str: + """장 도번 — 1장 = 도번 · n 째 장 = 도번-n(M02 `sheetNumber` 와 같음).""" + return "" if not number else number if index == 0 else f"{number}-{index + 1}" + + +def structure_sheets(root: Path) -> list[dict[str, Any]]: + """구조물 조합 도면 장 — 도번 차례 · 같은 도번은 한 번 · {id, label, number, drawing}.""" + from M02_MasterTemplete.M02_Item_Snapshot import linked_snapshots, structures_dir + from M02_MasterTemplete.M02_Item_Source import read_json + + docs: dict[str, tuple[str, dict[str, Any]]] = {} + for owner, copy in linked_snapshots(root).items(): + if "도면" not in copy["부분"]: + continue + doc = read_json(structures_dir(root) / copy["폴더"] / "도면.json") + if not isinstance(doc, dict) or not isinstance(doc.get("도면"), dict): + continue + number = str(doc.get("도번") or "") + name = str((copy.get("항목") or {}).get("name") or copy["폴더"]) + docs.setdefault(number or f"~{owner}", (name, doc)) + sheets = [] + for key in sorted(docs): + name, doc = docs[key] + number = "" if key.startswith("~") else key + pages = [{"이름": "", "도면": doc["도면"]}, *(doc.get("더한장") or [])] + for index, page in enumerate(pages): + if not isinstance(page, dict) or not isinstance(page.get("도면"), dict): + continue + label = f"{name} · {page['이름']}" if page.get("이름") else name + numbered = sheet_number(number, index) or f"{key.lstrip('~')}-{index + 1}" + sheets.append( + { + "id": f"{STRUCTURE_PREFIX}{numbered}", + "label": label, + "number": sheet_number(number, index), + "drawing": page["도면"], + } + ) + return sheets + + +def with_frame(drawing: dict[str, Any], number: str) -> dict[str, Any]: + """구조물 도면 + 얹는 도각(M02 화면과 같은 `structure_frame` · 표제란 = 프로젝트 값).""" + from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import structure_frame + + frame = structure_frame(number) + layers = [*(drawing.get("layers") or []), *([frame["layer"]] if frame["layer"] else [])] + return { + **drawing, + "entities": [*frame["entities"], *drawing.get("entities", [])], + "layers": layers, + } + + +async def drawing_items(project_id: UUID) -> tuple[list[dict[str, Any]], dict[str, Any]]: + """(목록, 만들 때 쓸 문맥). + + 목록 칸 = {id, source, kind, label, number, confirmed, paper, images_skipped}. + """ + root = await project_root(project_id) + use_project_templates(root) + template = entities_bbox(template_entities()) + paper = paper_name(template[2] - template[0], template[3] - template[1]) if template else None + route_id, b07 = await b07_list(project_id) + items = [ + { + "id": f"{B07_PREFIX}{item['id']}", + "source": "b07", + "kind": item["kind"], + "label": item["label"], + "number": str(number), + "confirmed": bool(item.get("confirmed")), + "paper": paper, + "images_skipped": None, # PDF 만들 때 셈 — 응답 머리 X-Aislo-Skipped-Images + } + for number, item in enumerate(b07, start=1) + ] + sheets = await asyncio.to_thread(structure_sheets, root) + items += [ + { + "id": sheet["id"], + "source": "structure", + "kind": "structure", + "label": sheet["label"], + "number": sheet["number"], + "confirmed": True, + "paper": paper, + "images_skipped": None, + } + for sheet in sheets + ] + return items, {"root": root, "route_id": route_id, "sheets": {s["id"]: s for s in sheets}} + + +async def drawings_in_order( + project_id: UUID, items: list[dict[str, Any]], context: dict[str, Any] +) -> list[dict[str, Any]]: + """고른 목록 차례대로 CAD 도면 — 미확정 횡단 장은 구조물을 얹어서.""" + base_fields = await title_fields(project_id) + drawings: list[dict[str, Any]] = [] + unconfirmed_cross: dict[str, dict[str, Any]] = {} + for item in items: + if item["source"] == "b07": + response = await b07_drawing(project_id, item["id"][len(B07_PREFIX) :]) + drawing = response["drawing"] + if item["kind"] == "cross" and not response.get("confirmed"): + unconfirmed_cross[item["id"]] = drawing + else: + sheet = context["sheets"][item["id"]] + use_project_templates(context["root"]) + use_title_fields(base_fields) + drawing = with_frame(sheet["drawing"], sheet["number"]) + drawings.append(drawing) + if unconfirmed_cross and context.get("route_id") is not None: + detail = await section_detail(project_id, int(context["route_id"])) + if detail is None or not await asyncio.to_thread( + place_structures, detail, unconfirmed_cross + ): + logger.warning( + "인쇄 도면 — 미확정 횡단 장 구조물을 못 얹음(서버 그림만): %s", project_id + ) + return drawings diff --git a/requirements.txt b/requirements.txt index 903388fd7..09d89ee34 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ et_xmlfile==2.0.0 fastapi==0.104.1 bcrypt>=4.1,<5 email-validator>=2.1,<3 +ezdxf==1.4.4 fiona==1.10.1 geopandas==1.1.4 h11==0.16.0 @@ -32,6 +33,7 @@ playwright==1.62.0 psutil>=6.1,<7 pydantic==2.5.0 pydantic_core==2.14.1 +PyMuPDF==1.28.2 PyMySQL==1.2.0 pyparsing==3.3.2 pyproj==3.7.2 diff --git a/resources/tester/test_302_2_print_drawings.py b/resources/tester/test_302_2_print_drawings.py new file mode 100644 index 000000000..27e722b59 --- /dev/null +++ b/resources/tester/test_302_2_print_drawings.py @@ -0,0 +1,273 @@ +"""PLAN 302-2 인쇄 미리보기 도면 PDF — 목록 차례 · 쪽 수 · 쪽 크기 · 한글 · 숨긴 층 · 권한. + +임시 폴더 견본만 씀(공용 storage 안 씀) — B07 목록 · 단건 · 표제란 값은 바꿔 끼움. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from uuid import uuid4 + +import pymupdf +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + frame_entities, + usable_bbox, + use_project_templates, + use_title_fields, +) +from B09_Estimation.draft import B09_Draft_Print_Drawings_Router as router_module +from B09_Estimation.draft import B09_Draft_Print_Drawings_Source as source +from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import ( + PT_PER_MM, + add_drawing_page, + paint_color, +) +from common_util import common_util_auth as auth + +TITLE = {"공사명": "가나다임도개설공사", "용역회사": "라마바설계"} + + +def _text(label: str, x: float, y: float, layer: str) -> dict: + return { + "id": str(uuid4()), + "type": "Text", + "lineColor": "#ffffff", + "lineWidth": 1, + "layerId": layer, + "shapeData": { + "basePoint": {"x": x, "y": y}, + "label": label, + "options": {"fontSize": 10, "textAlign": "left", "textColor": "#ffffff"}, + }, + } + + +def _b07_drawing(drawing_id: str) -> dict: + """도각(시스템 A1 · 표제란 = TITLE) + 보이는 글 + 숨긴 층 · 동결 층 글.""" + use_project_templates(None) + use_title_fields(TITLE) + entities = frame_entities(drawing_id, usable_bbox(), fit=False, fields={"도면명": "종단면도"}) + entities += [ + _text("보이는글", 200, 300, "draw"), + _text("숨은글", 200, 320, "hidden"), + _text("얼린글", 200, 340, "frozen"), + ] + layers = [ + {"id": "draw", "name": "그림", "isVisible": True, "isLocked": False}, + {"id": "hidden", "name": "숨김", "isVisible": False, "isLocked": False}, + {"id": "frozen", "name": "동결", "isVisible": True, "isLocked": False, "isFrozen": True}, + ] + return {"format": "openwebcad", "entities": entities, "layers": layers} + + +def _structure_doc(number: str, more: list[str] = ()) -> dict: + sheet = { + "format": "openwebcad", + "entities": [_text("구조물그림", 300, 300, "기본")], + "layers": [], + } + return { + "도번": number, + "도면": sheet, + "더한장": [{"장id": f"s{i}", "이름": name, "도면": sheet} for i, name in enumerate(more)], + } + + +@pytest.fixture +def sample(tmp_path, monkeypatch): + """B07 둘(종단 · 표지) + 구조물 사본 셋(SD-2 둘 = 한 번 · 더한장 하나 · SD-1 하나).""" + from M02_MasterTemplete import M02_Item_Snapshot as snapshot + + base = tmp_path / "structures" + copies = { + "pipe@10__a": ("A", _structure_doc("SD-000002", ["상세"])), + "pipe@20__b": ("B", _structure_doc("SD-000002", ["상세"])), + "pipe@30__c": ("C", _structure_doc("SD-000001")), + } + linked = {} + for owner, (folder, doc) in copies.items(): + (base / folder).mkdir(parents=True) + (base / folder / "도면.json").write_text(json.dumps(doc, ensure_ascii=False), "utf-8") + linked[owner] = {"항목": {"name": f"구조물{folder}"}, "폴더": folder, "부분": ["도면"]} + monkeypatch.setattr(snapshot, "linked_snapshots", lambda root: linked) + monkeypatch.setattr(snapshot, "structures_dir", lambda root: base) + + async def root(_pid): + return tmp_path + + async def fields(_pid): + return dict(TITLE) + + async def b07_list(_pid): + return 7, [ + { + "id": "longitudinal_1", + "kind": "longitudinal", + "label": "종단도(1)", + "confirmed": True, + }, + {"id": "cover", "kind": "cover", "label": "표지", "confirmed": False}, + ] + + async def b07_drawing(_pid, drawing_id): + return {"drawing": _b07_drawing(drawing_id), "confirmed": True} + + monkeypatch.setattr(source, "project_root", root) + monkeypatch.setattr(source, "title_fields", fields) + monkeypatch.setattr(source, "b07_list", b07_list) + monkeypatch.setattr(source, "b07_drawing", b07_drawing) + return tmp_path + + +def _client(session: dict | None = None, project_company: int | None = None) -> TestClient: + app = FastAPI() + app.include_router(router_module.router) + if session is not None: + app.dependency_overrides[auth.verify_session] = lambda: session + if project_company is None: + app.dependency_overrides[auth.require_project_access] = lambda: session + return TestClient(app) + + +SESSION = {"user_id": 1, "company_id": 1, "role": "USER", "is_master": False} + + +def test_paint_color_theme_짝(): + """openwebcad theme.ts paintColor(밝은 바탕) 손셈. + + 자동색 검정 · 밝은 노랑 휘도 0.3 으로 · 어두운 색 그대로. + """ + assert paint_color("#ffffff") == paint_color("#FFF") == paint_color("#f5f7fa") == (0, 0, 0) + assert paint_color("#f6d55c") == (89 / 255, 77 / 255, 33 / 255) + assert paint_color("#336699") == (0x33 / 255, 0x66 / 255, 0x99 / 255) + + +def test_목록_차례_B07_번호_다음_도번_한_번(sample): + pid = uuid4() + body = _client(SESSION).get(f"/api/projects/{pid}/print/drawings").json() + assert [item["id"] for item in body] == [ + "b07:longitudinal_1", + "b07:cover", + "structure:SD-000001", + "structure:SD-000002", + "structure:SD-000002-2", + ] + assert [item["number"] for item in body][:2] == ["1", "2"] + assert body[3]["label"] == "구조물A" and body[4]["label"] == "구조물A · 상세" + assert {item["paper"] for item in body} == {"A1"} + assert {item["source"] for item in body} == {"b07", "structure"} + + +def test_PDF_쪽수_크기_한글_숨긴층(sample): + pid = uuid4() + response = _client(SESSION).post(f"/api/projects/{pid}/print/drawings-pdf", json={}) + assert response.status_code == 200, response.text + assert response.headers["content-type"] == "application/pdf" + assert response.headers["x-aislo-pages"] == "5" + assert json.loads(response.headers["x-aislo-skipped-images"]) == {} + doc = pymupdf.open(stream=response.content, filetype="pdf") + assert doc.page_count == 5 + for page in doc: + assert abs(page.rect.width / PT_PER_MM - 840) < 0.5 + assert abs(page.rect.height / PT_PER_MM - 594) < 0.5 + first = doc[0].get_text() + assert "가나다임도개설공사" in first and "라마바설계" in first # 표제란 한글 그대로 + assert "보이는글" in first + assert "숨은글" not in first and "얼린글" not in first + # 구조물 장 = 얹은 도각 + 장 도번 · 표제란 = 프로젝트 값 + assert "구조물그림" in doc[2].get_text() and "SD-000001" in doc[2].get_text() + assert "SD-000002-2" in doc[4].get_text() and "가나다임도개설공사" in doc[4].get_text() + + +def test_PDF_고른_차례_모르는_id(sample): + pid = uuid4() + client = _client(SESSION) + picked = ["structure:SD-000001", "b07:cover"] + response = client.post(f"/api/projects/{pid}/print/drawings-pdf", json={"ids": picked}) + doc = pymupdf.open(stream=response.content, filetype="pdf") + assert doc.page_count == 2 and "구조물그림" in doc[0].get_text() + bad = client.post(f"/api/projects/{pid}/print/drawings-pdf", json={"ids": ["b07:없음"]}) + assert bad.status_code == 400 + + +class _FakePool: + """require_project_access 가 읽는 프로젝트 회사 한 줄.""" + + def __init__(self, company_id: int): + self.row = (company_id,) + + def acquire(self): + pool = self + + class _Cursor: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def execute(self, *_): + return None + + async def fetchone(self): + return pool.row + + class _Connection: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + def cursor(self): + return _Cursor() + + return _Connection() + + +def test_로그인_회사_권한(sample, monkeypatch): + pid = uuid4() + path = f"/api/projects/{pid}/print/drawings" + assert _client().get(path).status_code == 401 # 쿠키 없음 + no_company = {**SESSION, "company_id": None} + assert _client(no_company, project_company=0).get(path).status_code == 403 + monkeypatch.setattr(auth, "get_db_pool", lambda: _FakePool(2)) + other = _client(SESSION, project_company=2) + assert other.get(path).status_code == 403 # 남의 회사 프로젝트 + assert other.post(f"/api/projects/{pid}/print/drawings-pdf", json={}).status_code == 403 + monkeypatch.setattr(auth, "get_db_pool", lambda: _FakePool(1)) + assert _client(SESSION, project_company=1).get(path).status_code == 200 + + +def test_그림_넣기_못_넣은_수(): + png = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 4, 4), 0).tobytes("png") + import base64 + + def image(data: str) -> dict: + points = [{"x": 10, "y": 10}, {"x": 50, "y": 10}, {"x": 50, "y": 50}, {"x": 10, "y": 50}] + return {"type": "Image", "layerId": "a", "shapeData": {"points": points, "imageData": data}} + + drawing = { + "entities": [ + image("data:image/png;base64," + base64.b64encode(png).decode()), + image("/api/assets/없는그림.png"), + ], + "layers": [], + } + doc = pymupdf.open() + paper, skipped = add_drawing_page(doc, drawing) + assert skipped == 1 and len(doc[0].get_images()) == 1 + + +def test_미확정_횡단_구조물_Node_번들이_돎(): + """서버 Node(화면 TS 그대로) — 번들 빌드 · 실행 · 배치 없는 장은 그대로.""" + entity = _text("서버그림", 0, 0, "draw") + drawings = {"b07:cross_x": {"entities": [entity], "cross_placements": []}} + assert source.place_structures({"cross_sections": []}, drawings) is True + assert drawings["b07:cross_x"]["entities"] == [entity] + assert Path(source.BUNDLE).is_file() From a4f55c3748244ae707455215057409008e4d90a8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 08:02:26 +0900 Subject: [PATCH 11/17] =?UTF-8?q?feat(b09):=20=ED=96=89=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=ED=8C=9D=EC=97=85=20=E2=80=94=20=EB=A7=81=ED=81=AC?= =?UTF-8?q?=20=EC=A4=84=20=C2=B7=20=EB=B0=95=EC=8A=A4=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=20=EB=88=84=EB=A6=84=20=3D=20=EB=8C=80=EC=83=81=20=ED=83=AD=20?= =?UTF-8?q?=ED=91=9C=20=EC=A1=B0=EA=B0=81=20=EC=B0=BD(=EC=97=B4=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=20+=20=EA=B0=95=EC=A1=B0=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=A4=84=20=C2=B7=20=EB=88=84=EB=A5=B8=20=EC=A4=84?= =?UTF-8?q?=20=EA=B3=81=20=C2=B7=20=EC=9D=B4=EC=96=B4=EC=A7=84=20=EC=B0=BD?= =?UTF-8?q?=20=EA=B2=B9=EC=B9=A8=20=C2=B7=20Esc=20=C2=B7=20=C3=97=20=3D=20?= =?UTF-8?q?=EB=A7=A8=20=EC=9C=84=20=C2=B7=20=ED=83=AD=20=EB=B0=94=EA=BF=88?= =?UTF-8?q?=20=C2=B7=20=EC=A3=BC=EC=86=8C=20=EB=B0=94=EB=80=9C=20=3D=20?= =?UTF-8?q?=EB=AA=A8=EB=91=90=20=C2=B7=20[=ED=83=AD=EC=9C=BC=EB=A1=9C])=20?= =?UTF-8?q?=C2=B7=20=ED=95=98=EC=9D=B4=ED=8D=BC=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=3D=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20(303-2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- B09_Estimation/B09_Estimation_UI_Page.css | 33 +++ B09_Estimation/B09_Estimation_UI_Popup.ts | 175 ++++++++++++++++ B09_Estimation/B09_Estimation_UI_Shell.ts | 22 +- .../tester/test_243_1_double_click_tables.py | 8 +- resources/tester/test_303_b09_row_popup.py | 190 ++++++++++++++++++ 5 files changed, 421 insertions(+), 7 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Popup.ts diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index a891f16bc..43773307e 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -269,3 +269,36 @@ padding-inline: var(--spacing-4); white-space: nowrap; } + +/* 303-2 행 고름 팝업 — 누른 줄 곁 표 조각 창(겹쳐 쌓임 · 창 안 b09 표 꼴 그대로 · 빨강 테두리) */ +.b09-estimation.b09-popup { + position: fixed; + z-index: var(--z-modal); + gap: var(--spacing-4); + width: max-content; + max-width: 90vw; + max-height: 80vh; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} + +.b09-popup__head { + display: flex; + flex: none; + align-items: center; + gap: var(--spacing-4); +} + +.b09-popup__title { + margin-right: auto; + font-weight: var(--font-weight-bold); +} + +.b09-popup .ui-data-table { + flex: 1 1 auto; + min-height: 0; + max-height: none; +} diff --git a/B09_Estimation/B09_Estimation_UI_Popup.ts b/B09_Estimation/B09_Estimation_UI_Popup.ts new file mode 100644 index 000000000..62c9ee360 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Popup.ts @@ -0,0 +1,175 @@ +/* ============================================================================= + * B09_Estimation_UI_Popup.ts + * B09 행 고름 팝업(PLAN 303-2) — 링크 있는 줄 · 박스 카드 누름 = 대상 탭 표 조각 창(옮기지 않음). + * + * 창 = 열 제목(머리 여러 줄 그대로) + 강조 범위 줄 전체 · 표 짜기 = `…_UI_Builders`(화면과 같은 옵션) + * 강조 범위 = 설계내역서 그 줄 + 자손 · 묶음 탭 호표 구간 · 그 밖 한 줄(하이퍼링크 강조와 같음) + * 자리 = 누른 줄 아래(모자라면 위 · 화면 안) · 너비 = 표 너비(최대 화면 90%) · 넘치면 창 안 스크롤 + * 창 안 링크 줄 누름 = 이어진 창(겹쳐 위에 · 그 줄 곁) · Esc · × = 맨 위 하나 · 탭 바꿈 · 주소 바뀜 = 모두 + * 머리 [탭으로] = 하이퍼링크처럼 옮김(모두 닫음) · 창 안 = 보기 전용(고칠 칸 · 메뉴 없음) + * ========================================================================== */ + +import { createDataTable, type DataTableOptions } from "@ui/ui_template_data_table"; +import { createButton } from "@ui/ui_template_elements"; +import { placeHoverInfo } from "@ui/ui_template_hover_info"; +import { tableOptionsOf } from "./B09_Estimation_UI_Builders"; +import { rowIndex, tabName, type Link, type TabId, type TabTable } from "./B09_Estimation_UI_Tabs"; +import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { subtreeEnd } from "./B09_Estimation_UI_Tabs_Bill_Level"; +import { isGroupTab, pickClass, pickRange } from "./B09_Estimation_UI_Tabs_Groups"; + +const EDGE = 8; + +/** 강조 범위 [첫 줄, 끝 줄] — 대상 줄 없음 = null */ +export function popupRange(tab: TabId, spec: TabTable, key: string): [number, number] | null { + if (isGroupTab(tab)) return pickRange(spec.rows, key); + const at = rowIndex(spec.rows, key); + if (at < 0) return null; + return (spec as BillTable).menu ? [at, subtreeEnd(spec.rows, at)] : [at, at]; +} + +/** 창 표 옵션 — 범위 줄만 · 줄 번호 곁값은 원래 줄 번호로 · 빨강 테두리 · 합계 · 고칠 칸 없음 */ +export function popupOptions( + spec: TabTable, + tab: TabId, + range: [number, number], +): DataTableOptions { + const [from, to] = range; + const at = (i: number): number => i + from; + const level = spec.level; + return { + ...tableOptionsOf(spec, tab), + rows: spec.rows.slice(from, to + 1), + total: null, + editable: undefined, + onEdit: undefined, + rowClass: (i) => + [spec.rowClass?.(at(i)), pickClass(range, at(i))].filter(Boolean).join(" ") || null, + cellClass: spec.cellClass && ((i, key) => spec.cellClass?.(at(i), key)), + level: level && { + ...level, + of: (i) => level.of(at(i)), + group: level.group && ((i) => level.group?.(at(i)) ?? false), + }, + }; +} + +/** 창 자리 — 누른 줄 아래(모자라면 위) · 화면 밖으로 안 나감 */ +export function popupPlace( + anchor: { left: number; top: number; right: number; bottom: number }, + size: { width: number; height: number }, + view: { width: number; height: number }, +): { x: number; y: number } { + const { x, y } = placeHoverInfo(anchor, size, view); + return { x, y: Math.max(EDGE, Math.min(y, view.height - EDGE - size.height)) }; +} + +/** 열린 창(아래 → 위) */ +const stack: HTMLElement[] = []; +let listening = false; + +/** from 번째 창부터 위로 모두 닫음(0 = 모두) */ +export function closePopups(from = 0): void { + for (const box of stack.splice(from)) box.remove(); +} + +export const popupCount = (): number => stack.length; + +function listen(): void { + if (listening) return; + listening = true; + // Esc = 맨 위 하나(뒤 화면의 Esc 풂보다 먼저 · 창 있을 때만 가로챔) + window.addEventListener( + "keydown", + (event) => { + if (event.key !== "Escape" || !stack.length) return; + event.preventDefault(); + event.stopPropagation(); + closePopups(stack.length - 1); + }, + true, + ); + window.addEventListener("hashchange", () => closePopups()); +} + +export interface PopupOpen { + link: Link; + /** 누른 줄 · 카드 줄(자리 기준) — 없으면 화면 가운데 위 */ + anchor: Element | null; + /** 대상 탭 표(화면과 같은 표 짜기) — 없으면 null */ + specOf: (tab: TabId) => TabTable | null; + /** 하이퍼링크처럼 옮김(대상 줄 없음 · [탭으로]) */ + go: (link: Link) => void; + /** 참 = 창 안 누름(이어진 창) · 거짓 = 화면 누름(있던 창 모두 닫고 새로) */ + chain?: boolean; +} + +export function openRowPopup(o: PopupOpen): void { + if (!o.chain) closePopups(); + const { tab, key } = o.link; + const spec = o.specOf(tab); + const range = spec && popupRange(tab, spec, key); + if (!spec || !range) return o.go(o.link); // 보일 줄 없음 = 옮김 + listen(); + const level = stack.length; + const box = document.createElement("div"); + box.className = "b09-estimation b09-popup"; + box.setAttribute("role", "dialog"); + box.setAttribute("aria-label", tabName(tab)); + box.dataset.b09Popup = tab; + const title = document.createElement("span"); + title.className = "b09-popup__title"; + title.textContent = tabName(tab); + const goButton = createButton({ + label: "탭으로", + variant: "ghost", + size: "sm", + onClick: () => { + closePopups(); + o.go(o.link); + }, + }); + goButton.dataset.b09PopupGo = ""; + const closeButton = createButton({ + label: "×", + variant: "ghost", + size: "sm", + onClick: () => closePopups(level), + }); + closeButton.dataset.b09PopupClose = ""; + closeButton.setAttribute("aria-label", "닫기"); + const head = document.createElement("div"); + head.className = "b09-popup__head"; + head.append(title, goButton, closeButton); + const from = range[0]; + const table = createDataTable({ + ...popupOptions(spec, tab, range), + // 창 안 링크 줄 = 이어진 창(고칠 칸 · 「단가연결」 칸은 화면처럼 안 엶) + onCellClick: (i, cellKey) => { + if (spec.editable?.(i + from, cellKey)) return; + const next = spec.link?.(i + from, cellKey); + if (next) + openRowPopup({ + ...o, + link: next, + anchor: table.root.querySelector(`tr[data-i="${i}"]`), + chain: true, + }); + }, + }); + table.root.dataset.b09Table = tab; + box.append(head, table.root); + document.body.append(box); + stack.push(box); + const view = { width: window.innerWidth, height: window.innerHeight }; + const size = box.getBoundingClientRect(); + const anchor = o.anchor?.getBoundingClientRect() ?? { + left: (view.width - size.width) / 2, + right: (view.width + size.width) / 2, + top: view.height / 4, + bottom: view.height / 4, + }; + const { x, y } = popupPlace(anchor, size, view); + box.style.left = `${x}px`; + box.style.top = `${y}px`; +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 948dc4b12..81dc5157f 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -33,7 +33,8 @@ import { computeTotal } from "./B09_Estimation_Calc_Total"; import { createTabBars } from "./B09_Estimation_UI_TabBar"; import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders"; -import { createDock } from "./B09_Estimation_UI_Dock"; +import { createDock, linkMode } from "./B09_Estimation_UI_Dock"; +import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup"; import { DRAFT, rowIndex, @@ -155,6 +156,7 @@ export function renderB09Estimation(root: HTMLElement): void { /** 탭 하나 그림 · focus = 이동해 온 줄 키 */ const show = (tab: TabId, focus?: string): void => { current = tab; + closePopups(); // 303-2 탭 바꿈 · 다시 그림 = 창 모두 닫음 bars.draw(tab); bars.setTools([]); // 보기 단추 · 열 수는 묶음 탭만 다시 채움(202-1) if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요.")); @@ -163,8 +165,19 @@ export function renderB09Estimation(root: HTMLElement): void { const spec = tabTableOf(tab, ctx); if (!spec) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움")); if (typeof spec === "string") return void panel.replaceChildren(note(spec)); - const goto = (link: Link | null): void => { - if (link) show(link.tab, link.key); + // 303 행 누름 = 팝업(기본 · 누른 줄 곁 표 조각) · 하이퍼링크(그 탭 그 줄로) + const goto = (link: Link | null, anchor?: Element | null): void => { + if (!link) return; + if (linkMode() === "link") return show(link.tab, link.key); + openRowPopup({ + link, + anchor: anchor ?? null, + specOf: (t) => { + const made = tabTableOf(t, ctx); + return made && typeof made === "object" ? made : null; + }, + go: (to) => show(to.tab, to.key), + }); }; // extra = 줄 class 곁값(181-2 표 보기 호표 구간) const makeTable = (extra?: (index: number) => string | null): DataTable => { @@ -175,7 +188,8 @@ export function renderB09Estimation(root: HTMLElement): void { : spec.rowClass, onCellClick: (i, key) => { if (spec.onCell) spec.onCell(i, key); - if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); + if (spec.link && !spec.editable?.(i, key)) + goto(spec.link(i, key), made.root.querySelector(`tr[data-i="${i}"]`)); }, }); made.root.dataset.b09Table = tab; diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index e8e03aadd..f03bdb57e 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -112,8 +112,9 @@ def test_B09_표_만드는_길_한_곳_더블클릭_기본(): calls = [ p.name for p in _sources("B09_Estimation") if "createDataTable({" in p.read_text("utf-8") ] - assert calls == ["B09_Estimation_UI_Shell.ts"] - shell = (ROOT / "B09_Estimation" / calls[0]).read_text("utf-8") + # 303-2 팝업 창 = 보기 전용(고칠 칸 없음) · 옵션 = 같은 표 짜기(Builders) + assert calls == ["B09_Estimation_UI_Popup.ts", "B09_Estimation_UI_Shell.ts"] + shell = (ROOT / "B09_Estimation" / calls[1]).read_text("utf-8") assert "...tableOptionsOf(spec, tab)," in shell # 303-2 옵션 짜기 = Builders 한 곳 builders = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Builders.ts").read_text("utf-8") assert "editOnDouble: spec.editOnDouble ?? true," in builders @@ -135,5 +136,6 @@ def test_B08_고칠_칸_있는_표_모두_editOnDouble(): def test_고르기_메뉴_이동_그대로(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));" in shell + assert "if (spec.link && !spec.editable?.(i, key))" in shell # 303-2 누른 줄 = 팝업 자리 + assert 'goto(spec.link(i, key), made.root.querySelector(`tr[data-i="${i}"]`));' in shell assert "spec.choices?.(Number(cell.parentElement?.dataset.i), column.key)" in shell diff --git a/resources/tester/test_303_b09_row_popup.py b/resources/tester/test_303_b09_row_popup.py index 43be21b02..f1aa069ed 100644 --- a/resources/tester/test_303_b09_row_popup.py +++ b/resources/tester/test_303_b09_row_popup.py @@ -137,3 +137,193 @@ def test_화면이_Builders_옵션을_씀(): assert "...tableOptionsOf(spec, tab)," in shell assert "const spec = tabTableOf(tab, ctx);" in shell assert "BUILDERS" not in shell + + +# ── 303-2 팝업 창 ────────────────────────────────────────────────────────── +POPUP = "B09_Estimation/B09_Estimation_UI_Popup.ts" + + +def _fn(path: str, name: str) -> str: + """파일에서 함수 하나 꺼냄(맨 앞 `export` 뗌) — 이어 붙이면 이름이 부딪히는 파일 대신.""" + src = (ROOT / path).read_text("utf-8") + m = re.search(rf"export function {name}\(.*?\n}}\n", src, re.S) + assert m, name + return m.group(0).replace("export ", "", 1) + + +POPUP_PRELUDE = ( + FAKE_BUILDS + + "\nconst GAP = 6;\n" + + _fn("ui_template/ui_template_hover_info.ts", "placeHoverInfo") + + _fn("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts", "subtreeEnd") + + r""" +E.prototype.remove = function () { + if (this.parent) this.parent.children = this.parent.children.filter((c) => c !== this); + this.parent = null; +}; +E.prototype.getBoundingClientRect = function () { + return this.rect ?? { left: 0, top: 0, right: 0, bottom: 0, width: 400, height: 300 }; +}; +document.body = new E("body"); +const winKeys = []; +const hashFns = []; +globalThis.window = { innerWidth: 1200, innerHeight: 800, + addEventListener: (t, f) => (t === "keydown" ? winKeys : hashFns).push(f) }; +const esc = () => { + const ev = { key: "Escape", stopped: false, preventDefault() {}, stopPropagation() { ev.stopped = true; } }; + winKeys.forEach((f) => f(ev)); + return ev.stopped; +}; +const tables = []; +const createDataTable = (o) => { + const root = document.createElement("div"); + o.rows.forEach((r, i) => { + const tr = document.createElement("tr"); + tr.dataset.i = String(i); + tr.rect = { left: 40, top: 300 + i * 28, right: 900, bottom: 328 + i * 28 }; + root.append(tr); + }); + tables.push(o); + return { root }; +}; +""" +) + +SCENE = r""" +const col = [{ key: "name", label: "명칭", group: ["머리"] }]; +const specs = { + bill: { columns: col, menu: () => [], level: { key: "name", of: (i) => [1, 2, 2, 1][i] }, + rows: [{ key: "b1", level: 1 }, { key: "b2", level: 2 }, { key: "b3", level: 2 }, { key: "b4", level: 1 }], + rowClass: (i) => (i === 0 ? "is-title" : null), + editable: (i, k) => k === "qty", + link: (i) => ({ tab: "labor", key: "L1" }) }, + labor: { columns: col, rows: [{ key: "L0" }, { key: "L1" }, { key: "L2" }], + link: (i) => (i === 1 ? { tab: "dg", key: "D1|1" } : null) }, + dg: { columns: col, rows: [{ key: "D1" }, { key: "D1|1" }, { key: "D1|합계" }, { key: "D2" }] }, +}; +const gone = []; +const base = { specOf: (t) => specs[t] ?? null, go: (l) => gone.push(l) }; +const anchor = document.createElement("tr"); +anchor.rect = { left: 40, top: 72, right: 900, bottom: 100 }; +const boxes = () => document.body.children.map((b) => b.dataset.b09Popup); +const rows = (k) => tables[k].rows.map((r) => r.key); +const cls = (k) => tables[k].rows.map((_, i) => tables[k].rowClass(i)); +""" + + +def _popup(body: str): + return run([TABS, GROUPS, BUILDERS, POPUP], SCENE + body, POPUP_PRELUDE) + + +def test_강조_범위_줄_수(): + out = _popup( + "\nconsole.log(JSON.stringify([" + "popupRange('bill', specs.bill, 'b1'), popupRange('bill', specs.bill, 'b4')," + "popupRange('labor', specs.labor, 'L1'), popupRange('dg', specs.dg, 'D1|1')," + "popupRange('labor', specs.labor, 'X')]));" + ) + # 설계내역서 = 그 줄 + 자손 · 그 밖 = 한 줄 · 묶음 = 호표 구간 · 없음 = null + assert out == [[0, 2], [3, 3], [1, 1], [0, 2], None] + + +def test_창_열_제목_강조_줄_자리(): + out = _popup( + "\nopenRowPopup({ ...base, link: { tab: 'bill', key: 'b1' }, anchor });" + "\nconst box = document.body.children[0];" + "\nconst o = tables[0];" + "\nconsole.log(JSON.stringify({ boxes: boxes(), n: popupCount(), cls: box.className," + " head: box.children[0].children.map((e) => e.textContent), rows: rows(0), rowCls: cls(0)," + " columns: o.columns === specs.bill.columns, caption: o.caption, total: o.total," + " editable: o.editable ?? null, lv: [0, 1, 2].map((i) => o.level.of(i))," + " table: box.children[1].dataset.b09Table, left: box.style.left, top: box.style.top }));" + ) + assert out["boxes"] == ["bill"] and out["n"] == 1 + assert out["cls"] == "b09-estimation b09-popup" # 창 안 = b09 표 꼴(빨강 테두리) + assert out["head"] == ["설계내역서", "탭으로", "×"] + assert out["rows"] == ["b1", "b2", "b3"] # 그 줄 + 자손 + assert out["rowCls"] == ["is-title is-pick is-pick-top", "is-pick", "is-pick is-pick-end"] + assert out["columns"] is True and out["caption"] == "설계내역서" # 열 제목(머리 여러 줄) 그대로 + assert out["total"] is None and out["editable"] is None # 합계 · 고칠 칸 없음 + assert out["lv"] == [1, 2, 2] and out["table"] == "bill" + assert out["left"] == "40px" and out["top"] == "106px" # 누른 줄 아래(간격 6) + + +def test_이어진_창_겹침_Esc_차례(): + out = _popup( + "\nopenRowPopup({ ...base, link: { tab: 'bill', key: 'b1' }, anchor });" + "\ntables[0].onCellClick(1, 'qty');" # 고칠 칸 = 안 엶 + "\nconst a = boxes();" + "\ntables[0].onCellClick(1, 'name');" # 창 안 링크 줄 = 이어진 창 + "\nconst second = document.body.children[1];" + "\ntables[1].onCellClick(0, 'name');" # 창 안 줄 0 = 원래 줄 1(L1) → dg + "\nconst b = boxes();" + "\nconst dg = rows(2);" + "\nconst e1 = esc(); const c = boxes();" + "\nconst e2 = esc(); const e3 = esc(); const e4 = esc();" + "\nconsole.log(JSON.stringify({ a, b, dg, top2: second.style.top, c, e: [e1, e2, e3, e4]," + " d: boxes(), gone }));" + ) + assert out["a"] == ["bill"] + assert out["b"] == ["bill", "labor", "dg"] # 겹쳐 위에(뒤 DOM = 위) + assert out["dg"] == ["D1", "D1|1", "D1|합계"] # 호표 구간 + assert out["top2"] == "362px" # 그 줄(창 안 2번째 줄) 곁 + assert out["c"] == ["bill", "labor"] # Esc = 맨 위 하나 + assert out["e"] == [True, True, True, False] # 창 없으면 Esc 안 가로챔 + assert out["d"] == [] and out["gone"] == [] + + +def test_닫기_탭으로_새_누름_주소(): + out = _popup( + "\nconst open = (key = 'b1') => openRowPopup({ ...base, link: { tab: 'bill', key }, anchor });" + "\nopen(); tables[0].onCellClick(0, 'name');" + "\nconst head = (k) => document.body.children[k].children[0].children;" + "\nhead(0)[2].click();" # 아래 창 × = 그 창 + 위 창 + "\nconst a = boxes();" + "\nopen(); tables[2].onCellClick(0, 'name'); open('b4');" # 화면 새 누름 = 있던 창 모두 닫고 새로 + "\nconst b = [boxes(), rows(4)];" + "\nhead(0)[1].click();" # [탭으로] = 옮기고 모두 닫음 + "\nconst c = [boxes(), [...gone]];" + "\nopen(); hashFns.forEach((f) => f());" + "\nconst d = boxes();" + "\nopenRowPopup({ ...base, link: { tab: 'labor', key: 'X' }, anchor });" # 보일 줄 없음 = 옮김 + "\nconsole.log(JSON.stringify({ a, b, c, d, e: [boxes(), gone.length] }));" + ) + assert out["a"] == [] + assert out["b"] == [["bill"], ["b4"]] + assert out["c"] == [[], [{"tab": "bill", "key": "b4"}]] + assert out["d"] == [] # 주소 바뀜 = 모두 닫음 + assert out["e"] == [[], 2] + + +def test_창_자리_화면_안(): + out = _popup( + "\nconst v = { width: 1200, height: 800 };" + "\nconsole.log(JSON.stringify([" + "popupPlace({ left: 40, top: 700, right: 900, bottom: 728 }, { width: 400, height: 300 }, v)," + "popupPlace({ left: 1000, top: 100, right: 1190, bottom: 128 }, { width: 400, height: 300 }, v)," + "popupPlace({ left: 40, top: 400, right: 900, bottom: 428 }, { width: 400, height: 700 }, v)]));" + ) + assert out[0] == {"x": 40, "y": 394} # 아래 모자람 = 위 + assert out[1] == {"x": 794, "y": 134} # 오른쪽 화면 안 + assert out[2] == {"x": 40, "y": 92} # 위아래 다 모자람 = 화면 안으로 밀어 넣음 + + +def test_화면_잇기_모드별_누름_길(): + shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert ( + 'if (linkMode() === "link") return show(link.tab, link.key);' in shell + ) # 하이퍼링크 그대로 + assert "openRowPopup({" in shell and "go: (to) => show(to.tab, to.key)," in shell + assert "closePopups(); // 303-2 탭 바꿈" in shell # 탭 바꿈 = 모두 닫음 + assert 'goto(spec.link(i, key), made.root.querySelector(`tr[data-i="${i}"]`));' in shell + assert "if (spec.link && !spec.editable?.(i, key))" in shell # 고칠 칸 = 안 옮김 + assert ( + "createCheckPanel(refresh, (link) => show(link.tab, link.key));" in shell + ) # 연결 없음 = 옮김 + groups = (B09 / "B09_Estimation_UI_Tabs_Groups.ts").read_text("utf-8") + assert 'cards.querySelector(`[data-at="${i}"]`)' in groups # 박스 카드 누름도 같은 길 + popup = (ROOT / POPUP).read_text("utf-8") + assert "...tableOptionsOf(spec, tab)," in popup # 화면과 같은 표 짜기 + css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8") + m = re.search(r"\.b09-estimation\.b09-popup\s*\{([^}]*)\}", css) + assert m and "max-width: 90vw" in m.group(1) and "position: fixed" in m.group(1) From 5e25f418cc0a082aed1aec943e3a0d27590c35a5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 08:03:23 +0900 Subject: [PATCH 12/17] =?UTF-8?q?refactor(b08):=20=ED=83=AD=20=ED=91=9C=20?= =?UTF-8?q?=EC=9E=90=EB=A3=8C=20=EC=9D=BD=EA=B8=B0=20=C2=B7=20=ED=91=9C=20?= =?UTF-8?q?=EC=A7=9C=EA=B8=B0=EB=A5=BC=20=ED=99=94=EB=A9=B4=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=ED=8C=8C=EC=9D=BC=EB=A1=9C=20=EA=BA=BC=EB=83=84=20?= =?UTF-8?q?=E2=80=94=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A7=91=EA=B3=84?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=EC=9E=90=EC=9E=AC=20=C2=B7=20=ED=86=A0?= =?UTF-8?q?=EA=B3=B5=20=C2=B7=20=EC=88=98=EB=A6=AC=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=C2=B7=20=EA=B1=B0=EB=A6=AC=EC=9D=B4=EC=A0=95=ED=91=9C=20=C2=B7?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=EC=9D=80=20=EA=B0=99=EC=9D=80=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EB=A5=BC=20=EC=94=80=20=C2=B7=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C=20(302-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 파일 = B08_Quantity_Summary_Table · Material_Tables · Earthwork_Tables · Hydro_Table · Milestone_Table - 화면 UI 파일 = 불러오기 · 그리기만 · 옛 B08 시험 = 옮긴 글 자리 따라 읽는 파일 더함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B08_Quantity/B08_Quantity_Earthwork_Tables.ts | 327 ++++++++++++++++++ B08_Quantity/B08_Quantity_Hydro_Table.ts | 188 ++++++++++ B08_Quantity/B08_Quantity_Material_Tables.ts | 261 ++++++++++++++ B08_Quantity/B08_Quantity_Milestone_Table.ts | 68 ++++ B08_Quantity/B08_Quantity_Summary_Table.ts | 245 +++++++++++++ B08_Quantity/B08_Quantity_UI_Earthwork.ts | 237 +------------ .../B08_Quantity_UI_Earthwork_Sheets.ts | 108 +----- B08_Quantity/B08_Quantity_UI_Hydro.ts | 86 +---- B08_Quantity/B08_Quantity_UI_Material.ts | 245 ++----------- B08_Quantity/B08_Quantity_UI_Milestone.ts | 46 +-- .../B08_Quantity_UI_Milestone_Route.ts | 5 +- B08_Quantity/B08_Quantity_UI_Summary.ts | 216 +----------- resources/tester/test_110_b08_bed_slope.py | 4 +- .../tester/test_120_b08_drain_summary.py | 4 +- .../tester/test_121_b08_material_sheets.py | 4 +- resources/tester/test_122_b08_options.py | 8 +- .../tester/test_124_b08_surcharge_panel.py | 4 +- .../tester/test_143_1_b08_linked_snapshots.py | 4 +- .../tester/test_143_3_b08_material_cards.py | 8 +- .../tester/test_154_2_b08_direct_link.py | 4 +- resources/tester/test_160_2_b08_unassigned.py | 4 +- resources/tester/test_163_b08_modal.py | 4 +- .../tester/test_167_1_b08_pipe_owner_id.py | 4 +- .../tester/test_198_1_b08_summary_lamp.py | 4 +- .../tester/test_201_1_table_level_merge.py | 8 +- resources/tester/test_236_1_hint_one_line.py | 4 +- .../test_244_13_b08_milestone_place_leg.py | 2 +- resources/tester/test_244_1_b08_milestone.py | 5 +- .../test_244_6_b08_milestone_pan_client.py | 3 +- .../tester/test_61_1_b08_summary_table.py | 4 +- .../tester/test_69_b08_earthwork_tabs.py | 4 +- resources/tester/test_98_b08_pipe_summary.py | 4 +- 32 files changed, 1252 insertions(+), 870 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Earthwork_Tables.ts create mode 100644 B08_Quantity/B08_Quantity_Hydro_Table.ts create mode 100644 B08_Quantity/B08_Quantity_Material_Tables.ts create mode 100644 B08_Quantity/B08_Quantity_Milestone_Table.ts create mode 100644 B08_Quantity/B08_Quantity_Summary_Table.ts diff --git a/B08_Quantity/B08_Quantity_Earthwork_Tables.ts b/B08_Quantity/B08_Quantity_Earthwork_Tables.ts new file mode 100644 index 000000000..efa789d0e --- /dev/null +++ b/B08_Quantity/B08_Quantity_Earthwork_Tables.ts @@ -0,0 +1,327 @@ +/* ============================================================================= + * B08_Quantity_Earthwork_Tables.ts + * 토공 보조 탭 넷(PLAN 61-3 · 69-2) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3). + * 화면(`…_UI_Earthwork` · `…_UI_Earthwork_Sheets`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀. + * + * `loadEarthwork` = GET …/quantity/earthwork + 측점 간격 · `applyRock` = 연암 · 경암 = 암 × 좌측 패널 비율 · + * 암 C = 비율 가중(캐시 → 저장본 · `…_Options_Calc` · PLAN 122 · 125-1) + * 토적표 = 견본 차례 32칸 · 합계 줄 · 첫 측점은 앞이 없어 입적 · 면적이 빈 값 + * 토공집계표 · 평균운반거리 · 종방향 무대운반거리 = 거창 실무 워크북 같은 이름 시트 칸 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table"; +import { stationLabel } from "../common_util/common_util_station"; +import { + mixedRockC, + recountAdjustedSummary, + recountRockC, + splitRockColumns, + splitRockSummary, +} from "./B08_Quantity_Options_Calc"; +import { stationInterval } from "./B08_Quantity_Summary_Table"; +import { liveSoftRock } from "./B08_Quantity_UI_Options"; + +type Face = { length_m: number | null; area_m2: number | null }; + +interface EarthworkRow { + chainage_m: number; + distance_m: number; + cut_soil_area_m2: number; + cut_soil_volume_m3: number; + cut_soil_adjusted_m3: number; + cut_rock_area_m2: number; + cut_rock_volume_m3: number; + cut_rock_adjusted_m3: number; + cut_rock_kind: string; + ditch_soil_area_m2: number; + ditch_soil_volume_m3: number; + ditch_soil_adjusted_m3: number; + ditch_rock_area_m2: number; + ditch_rock_volume_m3: number; + ditch_rock_adjusted_m3: number; + adjusted_total_m3: number; + fill_area_m2: number; + fill_volume_m3: number; + diverted_m3: number; + balance_m3: number; + cumulative_m3: number; + notes: string[]; + faces: Record; +} + +export interface HaulRow { + q: number; + l: number; + ql: number; +} + +export interface HaulList { + rows: HaulRow[]; + q: number; + ql: number; + average_m: number | null; +} + +export interface SummaryRow { + group: string; + kind: string; + spec: string; + unit: string; + value: number | null; + note: string; +} + +export interface EarthworkTable { + rows: EarthworkRow[]; + totals: Record; + conversion_factors: Record; + station_count: number; + route_id: number; + confirmed: boolean; + summary: SummaryRow[]; + haul: Record<"dump_truck" | "dozer" | "free_haul", HaulList>; + haul_saved: boolean; + /** 저장된 연암 · 경암 %(암 전체 기준) */ + rock_ratios_pct: { 연암: number; 경암: number }; + /** 가중 전 리핑암(연암) · 발파암(경암) C — 비율로 다시 가중(125-1) */ + rock_c: { ripping_rock: number; blasting_rock: number }; +} + +/** 응답 + 측점 간격 · applyRock = 지금 좌측 패널 비율로 표를 고쳐 씀(조작마다 다시) */ +export async function loadEarthwork( + projectId: string, +): Promise<{ table: EarthworkTable; interval: number; applyRock: () => void }> { + const [response, interval] = await Promise.all([ + fetch(`${API_BASE_URL}/projects/${projectId}/quantity/earthwork`, { credentials: "include" }), + stationInterval(projectId).catch(() => 20), + ]); + const body = (await response.json().catch(() => null)) as + (EarthworkTable & { detail?: unknown }) | null; + if (!response.ok || !body) { + const detail = body?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + const table: EarthworkTable = body; + // 연암 · 경암 칸 · 집계표 줄 = 암 × 지금 비율 · 암 C = 비율 가중(캐시 → 저장본 · 125-1) + const summary = table.summary; + const applyRock = (): void => { + const soft = liveSoftRock(projectId, table.rock_ratios_pct?.연암 ?? 90); + const c = mixedRockC(soft, table.rock_c.ripping_rock, table.rock_c.blasting_rock); + for (const kind of ["ripping_rock", "blasting_rock"]) + table.conversion_factors[kind].compacted = c; + recountRockC(table.rows as unknown as Array>, table.totals, c); + for (const record of [...table.rows, table.totals]) + splitRockColumns(record as unknown as Record, soft); + table.summary = recountAdjustedSummary( + splitRockSummary(summary, table.totals, soft), + table.totals, + table.haul.free_haul.q, + ); + }; + applyRock(); + return { table, interval, applyRock }; +} + +/** 보조 탭 넷 — 화면 탭 줄 · 인쇄 쪽 차례 */ +export const EARTHWORK_SHEETS = [ + { id: "summary", label: "토공집계표" }, + { id: "sheet", label: "토적표" }, + { id: "haul", label: "평균운반거리" }, + { id: "free", label: "종방향 무대운반거리" }, +] as const; + +/** 칸 묶음 — [머리 묶음, 키 앞머리, 풍선 글] · 단면적 · 입적 · 보정량 셋 · 머리 글자는 사용자 견본대로 */ +const TRIPLES: Array<[string[], string, string?]> = [ + [["절토", "토사"], "cut_soil"], + [["절토", "연암"], "cut_soft_rock", "암 × 연암 %"], + [["절토", "경암"], "cut_hard_rock", "암 × 경암 %"], + [["측구파기", "토사"], "ditch_soil"], + [["측구파기", "암"], "ditch_rock", "리핑암 · 발파암 합"], +]; +/** 면 계열 — [머리 묶음, 키] · 거리 · 면적 둘 */ +const FACES: Array<[string[], string]> = [ + [["면고르기", "성토면"], "grading_fill"], + [["면고르기", "절토면"], "grading_cut"], + [["지장목제거", "성토"], "tree_removal_fill"], + [["지장목제거", "절토"], "tree_removal_cut"], + [["제근"], "grubbing"], + [["노면정리"], "road_surface"], +]; + +const n2 = (key: string, label: string, group?: string[]): DataColumn => ({ + key, + label, + group, + kind: "number", + decimals: 2, +}); + +/** 견본 차례 32칸 */ +const COLUMNS: DataColumn[] = [ + { key: "station", label: "측점", width: 72 }, + n2("distance", "거리"), + ...TRIPLES.flatMap(([group, key, tip]) => + [ + n2(`${key}_area`, "단면적", group), + n2(`${key}_volume`, "입적", group), + n2(`${key}_adjusted`, "보정량", group), + ].map((c) => (tip ? { ...c, title: `${group.at(-1)} ${c.label} — ${tip}` } : c)), + ), + n2("adjusted_total", "보정량계"), + n2("fill_area", "단면적", ["성토"]), + n2("fill_volume", "입적", ["성토"]), + n2("diverted", "유용토"), + n2("balance", "차인토량"), + n2("cumulative", "누가토량"), + ...FACES.flatMap(([group, key]) => [ + n2(`${key}_length`, "거리", group), + n2(`${key}_area`, "면적", group), + ]), +]; + +function stationRow(row: EarthworkRow, first: boolean, interval: number): DataRow { + // 첫 측점은 앞이 없어 입적 · 보정량 · 면적이 없음(빈 값) + const v = (value: number): number | null => (first ? null : value); + const out: DataRow = { + station: stationLabel(row.chainage_m, interval) + (row.notes.length ? " *" : ""), + distance: row.distance_m, + adjusted_total: v(row.adjusted_total_m3), + fill_area: row.fill_area_m2, + fill_volume: v(row.fill_volume_m3), + diverted: v(row.diverted_m3), + balance: v(row.balance_m3), + cumulative: v(row.cumulative_m3), + }; + const r = row as unknown as Record; + for (const [, key] of TRIPLES) { + out[`${key}_area`] = r[`${key}_area_m2`]; + out[`${key}_volume`] = v(r[`${key}_volume_m3`]); + out[`${key}_adjusted`] = v(r[`${key}_adjusted_m3`]); + } + for (const [, key] of FACES) { + out[`${key}_length`] = row.faces?.[key]?.length_m ?? null; + out[`${key}_area`] = first ? null : (row.faces?.[key]?.area_m2 ?? null); + } + return out; +} + +/** 합계 줄 — 단면적 · 거리는 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */ +function totalRow(table: EarthworkTable): DataRow { + const t = table.totals; + const out: DataRow = { + station: "합계", + distance: t.distance_m, + adjusted_total: t.adjusted_total_m3, + fill_volume: t.fill_volume_m3, + diverted: t.diverted_m3, + balance: t.balance_m3, + cumulative: table.rows[table.rows.length - 1]?.cumulative_m3, + }; + for (const [, key] of TRIPLES) { + out[`${key}_volume`] = t[`${key}_volume_m3`]; + out[`${key}_adjusted`] = t[`${key}_adjusted_m3`]; + } + // 값이 하나도 없는 칸(제근 — B07 에 값 없음)은 합계도 비움 + for (const [, key] of FACES) { + const any = table.rows.some((row) => row.faces?.[key]?.area_m2 != null); + out[`${key}_area`] = any ? t[`${key}_area_m2`] : null; + } + return out; +} + +/** 토적표 */ +export const earthworkSheetOptions = ( + table: EarthworkTable, + interval: number, +): DataTableOptions => ({ + columns: COLUMNS, + rows: table.rows.map((row, i) => stationRow(row, i === 0, interval)), + frozen: 1, + total: totalRow(table), + caption: "토적표", +}); + +const SUMMARY_COLUMNS: DataColumn[] = [ + { key: "group", label: "구분", width: 120 }, + { key: "kind", label: "공종", width: 72 }, + { key: "spec", label: "규격", width: 128 }, + { key: "unit", label: "단위", width: 56 }, + { key: "value", label: "계", kind: "number", decimals: 2, width: 112 }, + { key: "note", label: "비고", width: 200 }, +]; + +/** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 */ +export const earthworkSummaryOptions = (table: EarthworkTable): DataTableOptions => ({ + columns: SUMMARY_COLUMNS, + // 칸이 없는 글(공종 · 규격 · 비고)은 빈칸 — 공용 표는 "" 를 「-」(값 없음)로 그림 + rows: table.summary.map((row) => ({ + ...row, + kind: row.kind || " ", + spec: row.spec || " ", + note: row.note || " ", + })), + frozen: 1, + mergeSame: ["group", "spec"], // 201-1 같은 구분 · 규격 이어지면 세로 병합(빈칸은 안 합침) + caption: "토공집계표", +}); + +const fmt = (value: number | null | undefined): string => + value == null || !Number.isFinite(value) + ? "-" + : value.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); + +/** 운반 시트 둘 — [묶음 이름, 목록] · 표 이름 */ +export const HAUL_SHEETS = { + haul: (table: EarthworkTable) => ({ + groups: [ + ["덤프트럭", table.haul.dump_truck], + ["불도우저", table.haul.dozer], + ] as Array<[string, HaulList]>, + caption: "덤프트럭 · 불도우저 평균운반거리 — 단위 ㎥ · m", + }), + free: (table: EarthworkTable) => ({ + groups: [["종방향무대", table.haul.free_haul]] as Array<[string, HaulList]>, + caption: "종방향 무대운반거리 — 단위 ㎥ · m", + }), +}; + +/** 운반 시트 한 장 — 묶음마다 토량(Q) · 거리(L) · Q×L · 맨 아래 합계 · averages = 아래 평균운반거리 줄 */ +export function haulOptions( + groups: Array<[string, HaulList]>, + caption: string, +): { options: DataTableOptions; averages: string[] } { + const columns: DataColumn[] = [ + { key: "no", label: "번호", width: 56 }, + ...groups.flatMap(([label], g) => + [ + ["q", "토량(Q)"], + ["l", "거리(L)"], + ["ql", "Q×L"], + ].map(([key, text]) => ({ + key: `${key}${g}`, + label: text, + group: [label], + kind: "number" as const, + decimals: 2, + width: 104, + })), + ), + ]; + const count = Math.max(0, ...groups.map(([, list]) => list.rows.length)); + const rows: DataRow[] = Array.from({ length: count }, (_, i) => { + const out: DataRow = { no: String(i + 1) }; + groups.forEach(([, list], g) => { + const item = list.rows[i]; + if (item) Object.assign(out, { [`q${g}`]: item.q, [`l${g}`]: item.l, [`ql${g}`]: item.ql }); + }); + return out; + }); + const total: DataRow = { no: "합계" }; + groups.forEach(([, list], g) => Object.assign(total, { [`q${g}`]: list.q, [`ql${g}`]: list.ql })); + const averages = groups.map( + ([label, list], i) => + `${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`, + ); + return { options: { columns, rows, frozen: 1, total, caption }, averages }; +} diff --git a/B08_Quantity/B08_Quantity_Hydro_Table.ts b/B08_Quantity/B08_Quantity_Hydro_Table.ts new file mode 100644 index 000000000..6444aa257 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Hydro_Table.ts @@ -0,0 +1,188 @@ +/* ============================================================================= + * B08_Quantity_Hydro_Table.ts + * 수리집수면적표(PLAN 62-2 · 69-4) 자료 읽기 · 측점 입력 겹치기 · 인쇄 표 — 화면 없음(PLAN 302-3). + * 화면(`B08_Quantity_UI_Hydro`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 입력 · 같은 서버 계산을 씀. + * + * 값 차례 = 기본 < 장 기본 < 연결 < 저장 < 초안(캐시 `b08-hydro`) · 연결값과 같은 칸은 null 로 저장(연동 유지) + * 계산 = 서버 단독(POST …/quantity/hydro/calc) + * `hydroTable` = 계산서 한 장(화면 두 카드 칸 차례 그대로)을 구분 · 항목 · 값 표로 — 인쇄 전용 + * ========================================================================== */ + +import type { DataTableOptions } from "@ui/ui_template_data_table"; +import { readState } from "../A00_Common/b_page_state"; +import { request } from "./B08_Quantity_Material_Tables"; +import { stationInterval } from "./B08_Quantity_Summary_Table"; +import type { + HydroInputs, + HydroLink, + HydroOptions, + HydroResult, +} from "./B08_Quantity_UI_Hydro_Sheet"; + +export interface HydroStation { + chainage_m: number; + facility: string; + /** 박스암거 · 세월교 · 물넘이포장(관이면 "") */ + kind: string; + /** 계산서 장 — 시설이 정함(서버 station_rows) */ + sheet: HydroInputs["sheet"]; + /** B05 연결값(값이 있는 칸만) · 비교용 B05 Qd · T — 서버 station_rows */ + linked: HydroLink["values"] | null; + b05: HydroLink["b05"]; +} + +export type Drafts = Record>; +export type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions; + +export const keyOf = (station: HydroStation): string => station.chainage_m.toFixed(3); + +const linkedOf = (station: HydroStation): Record => station.linked ?? {}; + +/** 저장본 · 초안 한 겹 — 연결값이 있는 칸의 null 은 건너뜀(연결값을 덮지 않게) */ +const layer = (station: HydroStation, values: Partial = {}) => + Object.fromEntries( + Object.entries(values).filter(([k, v]) => v != null || !(k in linkedOf(station))), + ); + +/** 측점 입력 — 기본 < 장 기본 < 연결 < 저장 < 초안 */ +export const hydroInputs = ( + sheet: HydroPayload, + station: HydroStation, + drafts: Drafts, +): HydroInputs => + ({ + ...sheet.defaults, + ...(station.sheet === "pipe" ? {} : sheet.sheet_defaults[station.sheet]), + ...station.linked, + // 초안은 측점 입력 통째 — 있으면 저장본 대신(초안의 null = 연결값으로 되돌림) + ...layer(station, drafts[keyOf(station)] ?? sheet.saved[keyOf(station)]), + sheet: station.sheet, + }) as HydroInputs; + +/** 연결값과 같은 칸은 null — 저장 · 초안이 연동을 유지(B05 값이 바뀌면 따라옴) · 다르면 손 값 */ +export const unlinkedInputs = (station: HydroStation, inputs: HydroInputs): HydroInputs => + Object.fromEntries( + Object.entries(inputs).map(([k, v]) => [k, linkedOf(station)[k] === v ? null : v]), + ) as unknown as HydroInputs; + +export async function loadHydro( + projectId: string, +): Promise<{ sheet: HydroPayload; interval: number; drafts: Drafts }> { + const [sheet, interval] = await Promise.all([ + request(`/projects/${projectId}/quantity/hydro`), + stationInterval(projectId).catch(() => 20), + ]); + return { sheet, interval, drafts: readState("b08-hydro", projectId) ?? {} }; +} + +export const hydroCalc = (projectId: string, inputs: HydroInputs): Promise => + request(`/projects/${projectId}/quantity/hydro/calc`, { + method: "POST", + body: JSON.stringify(inputs), + }); + +const DASH = "-"; +const fmt = (value: number | null | undefined, digits: number, unit = ""): string => + value == null || !Number.isFinite(value) ? DASH : `${value.toFixed(digits)}${unit}`; +const raw = (value: number | string | null | undefined, unit = ""): string => + value == null || value === "" ? DASH : `${value}${unit}`; + +/** 계산서 한 장 → 표(구분 · 항목 · 값) — 칸 이름 · 자리수 = 화면 계산서(`…_UI_Hydro_Sheet`) */ +export function hydroTable( + stationText: string, + kind: string, + inputs: HydroInputs, + r: HydroResult | null, + options: HydroOptions, +): DataTableOptions { + const site = options.site; + const flood = "1. 홍수량 계산"; + const sheetDefaults = inputs.sheet === "pipe" ? {} : options.sheet_defaults[inputs.sheet]; + const n = + inputs.n ?? (inputs.sheet === "pipe" ? options.pipe_kinds[inputs.pipe_kind] : sheetDefaults.n); + const slope = [ + "하상경사 S = 1 /", + `${raw(inputs.slope_denominator)} (${fmt(r?.slope_bed == null ? null : r.slope_bed * 100, 2, " %")})`, + ]; + const section: Array<[string, string]> = + inputs.sheet === "box" + ? [ + ["내공 폭 B (m)", raw(inputs.box_width_m)], + ["내공 높이 H (m)", raw(inputs.box_height_m)], + ["수심비 h / H", raw(inputs.depth_ratio)], + ["수심 h (m)", fmt(r?.depth, 2)], + ["단면적", fmt(r?.section_area, 3, " ㎡")], + ["윤변", fmt(r?.perimeter, 3, " m")], + ["경심", fmt(r?.radius, 3)], + slope as [string, string], + ["조도계수 n", raw(n)], + ] + : inputs.sheet === "ford" + ? [ + ["월류 폭 B (m)", raw(inputs.overflow_width_m)], + slope as [string, string], + ["조도계수 n", raw(n)], + ["월류 높이 h (m)", fmt(r?.depth, 2)], + ["단면적", fmt(r?.section_area, 3, " ㎡")], + ["윤변", fmt(r?.perimeter, 3, " m")], + ["경심", fmt(r?.radius, 3)], + ] + : [ + ["관 종류", inputs.pipe_kind], + ["단면 가정", options.sections[inputs.section] ?? inputs.section], + ["단면 D (mm)", raw(inputs.diameter_mm)], + ["단면적", fmt(r?.section_area, 3, " ㎡")], + ["윤변", fmt(r?.perimeter, 3, " m")], + ["경심", fmt(r?.radius, 3)], + slope as [string, string], + ["조도계수 n", raw(n)], + ]; + const judged = r != null && r.ok != null; + const check = + inputs.sheet === "box" + ? `2. 단면 검토 — ${kind}` + : inputs.sheet === "ford" + ? `2. 월류 높이 — ${kind}` + : "2. 단면 검토"; + const rows: Array<[string, string, string]> = [ + [flood, `Qd : 설계 홍수량 (㎥/s) = Q × ${inputs.factor.toFixed(1)}`, fmt(r?.design_flow, 2)], + [flood, "Q : 홍수량 (㎥/s)", fmt(r?.flow, 2)], + [flood, "f : 유출계수", raw(inputs.runoff)], + [flood, "rt : 평균 강우강도 (mm/hr)", fmt(r?.intensity, 2)], + [flood, "A : 유역면적 (㎢)", `${raw(inputs.area_km2)} (${fmt(r?.area_ha, 1, " ha")})`], + [flood, "T : 홍수 도달시간 (hr)", fmt(r?.tc_hr, 3)], + [flood, "L : 산정에서 하천시점까지 거리 (m)", raw(inputs.length_m)], + [flood, "H : 산정에서 하천시점까지 표고차 (m)", raw(inputs.height_m)], + [flood, "s : 지표면 경사 (H / L)", fmt(r?.slope_ground, 3)], + [flood, "최소 도달시간 (분)", raw(inputs.tc_min_minutes)], + [flood, "지역", site.region.name || raw(inputs.region)], + [flood, "재현기간", `${inputs.return_period} 년`], + [ + flood, + "R24 : 확률강우량 (mm/day)", + fmt(r?.r24_mm ?? site.r24[String(inputs.return_period)], 1), + ], + [check, "총 홍수량 Qd (㎥/s)", fmt(r?.design_flow, 2)], + ...section.map(([label, value]) => [check, label, value] as [string, string, string]), + [check, "유속 (m/s)", fmt(r?.velocity, 3)], + [check, "유출량 (㎥/s)", fmt(r?.capacity, 3)], + [ + check, + "판정", + judged + ? `${fmt(r.capacity, 3)} ㎥/s ${r.ok ? "≥" : "<"} Qd ${fmt(r.design_flow, 2)} ㎥/s — ${r.ok ? "O K" : "N G"}` + : DASH, + ], + [check, "유출율 (%)", fmt(r?.ratio_pct, 0)], + ]; + return { + columns: [ + { key: "group", label: "구분", width: 120 }, + { key: "item", label: "항목", width: 260 }, + { key: "value", label: "값", width: 240 }, + ], + rows: rows.map(([group, item, value]) => ({ group, item, value })), + mergeSame: ["group"], + caption: `홍수량 계산서 ${stationText}${kind ? ` ${kind}` : ""}`, + }; +} diff --git a/B08_Quantity/B08_Quantity_Material_Tables.ts b/B08_Quantity/B08_Quantity_Material_Tables.ts new file mode 100644 index 000000000..c81c546e4 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Material_Tables.ts @@ -0,0 +1,261 @@ +/* ============================================================================= + * B08_Quantity_Material_Tables.ts + * 자재 보조 탭 셋(PLAN 121-1) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3). + * 화면(`B08_Quantity_UI_Material`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀. + * + * `loadMaterials` = GET …/quantity/materials + 캐시(`b08-materials`) + 좌측 패널 할증 → 짝 계산 + * `materialTable` = 보조 탭 한 장의 열 · 줄 · 계 · 고칠 칸 · 고친 칸 자리 + * `materialOptions` = 그 장의 공용 표 옵션(화면은 고치기 · 높이만 더함) + * 값 없는 줄은 숨김(143-3) · 콘크리트소요는 5줄 늘 보임(163-5) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table"; +import { readState } from "../A00_Common/b_page_state"; +import { + computeMaterialSheets, + DEDUCT_KEY, + MIX_FIELDS, + type Cells, + type MaterialOverrides, + type MaterialSheets, + type SummarySource, +} from "./B08_Quantity_Material_Calc"; +import { liveSurcharge } from "./B08_Quantity_UI_Options"; + +export type MaterialSheetId = "material" | "concrete" | "quantity"; + +export interface Payload { + source: SummarySource; + overrides: MaterialOverrides; + /** 좌측 패널 할증 저장본(기본 채움) */ + surcharge_pct: Cells; + /** 구조물 집계표 알림(「집수정 모양 기록 없음 n곳」 등) — 총괄 · 콘크리트 위 빈 까닭(131) */ + notes?: string[]; + sheets: MaterialSheets; +} + +const BASIS = + "구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)"; + +/** 보일 줄 차례(원래 줄 번호) — 값 없는 줄은 숨김(143-3) */ +const shown = (rows: readonly T[], has: (row: T) => boolean): number[] => + rows.flatMap((row, i) => (has(row) ? [i] : [])); +const anyValue = (cells: Cells): boolean => Object.values(cells).some((v) => v != null); + +export async function request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; + if (!response.ok) { + const detail = body?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return body as T; +} + +/** 저장본 + 캐시(조작 중 값) — calc = 지금 좌측 패널 할증으로 세 표를 다시 셈 */ +export async function loadMaterials(projectId: string): Promise<{ + payload: Payload; + overrides: MaterialOverrides; + calc: (payload: Payload, overrides: MaterialOverrides) => MaterialSheets; +}> { + const payload = await request(`/projects/${projectId}/quantity/materials`); + const draft = readState("b08-materials", projectId); + return { + payload, + overrides: draft ?? payload.overrides, + calc: (payload, overrides) => + computeMaterialSheets( + payload.source, + overrides, + liveSurcharge(projectId, payload.surcharge_pct), + ), + }; +} + +/** 숫자 열 — 최대 3 자리 · 끝 0 지움(고친 값 자리가 줄마다 다름) */ +const numberColumn = (column: DataColumn): DataColumn => ({ + kind: "number", + decimals: 3, + trim: true, + ...column, +}); + +export interface Table { + columns: DataColumn[]; + rows: DataRow[]; + total: DataRow | null; + frozen: number; + editable: (index: number, key: string) => boolean; + /** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */ + target: (index: number, key: string) => [Record | Cells, string, string | null]; + /** 같은 값 세로 병합 열(201-1 · 공용 표 mergeSame) */ + mergeSame?: string[]; +} + +function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table { + const { materials, rows, total } = s.quantity; + const keep = shown(rows, (row) => row.qty != null || anyValue(row.units)); + const rowsOut: DataRow[] = keep.flatMap((at) => { + const row = rows[at]; + const unit: DataRow = { + name: row.name, + spec: row.spec, + qty: row.qty, + unit: row.unit, + kind: "단위수량", + }; + const amount: DataRow = { kind: "수량" }; + for (const m of materials) { + unit[`m:${m.key}`] = row.units[m.key]; + amount[`m:${m.key}`] = row.amounts[m.key]; + } + return [unit, amount]; + }); + const totalRow: DataRow = { name: "계", kind: "수량" }; + for (const m of materials) totalRow[`m:${m.key}`] = total[m.key]; + return { + columns: [ + { key: "name", label: "구분", width: 120 }, + { key: "spec", label: "규격", width: 120 }, + numberColumn({ key: "qty", label: "수량", width: 72 }), + { key: "unit", label: "단위", width: 56 }, + { key: "kind", label: "구분", width: 72 }, + ...materials.map((m) => + numberColumn({ + key: `m:${m.key}`, + label: m.unit, + group: m.spec ? [m.name, m.spec] : [m.name], + title: [m.name, m.spec, m.unit].filter(Boolean).join(" › "), + width: 76, + }), + ), + ], + rows: rowsOut, + total: totalRow, + frozen: 5, + editable: (i, key) => i % 2 === 0 && key.startsWith("m:"), + target: (i, key) => [o.units, rows[keep[i / 2]].id, key.slice(2)], + }; +} + +export const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> = { + cement: ["시멘트", "㎥당(kg)", "수량(kg)"], + sand: ["모래", "㎥당(㎥)", "수량(㎥)"], + gravel: ["자갈", "㎥당(㎥)", "수량(㎥)"], +}; + +function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { + const { rows, total } = s.concrete; + const keep = rows.map((_row, i) => i); // 5줄 늘 보임(163-5) — 수량 없으면 빈칸 · 배합은 늘 고침 + const rowsOut: DataRow[] = keep.map((at) => { + const row = rows[at]; + const out: DataRow = { + name: row.name, // 위 줄과 같으면 세로 병합(201-1) + spec: row.spec, + qty: row.qty, + }; + for (const f of MIX_FIELDS) { + out[`per:${f}`] = row.per[f]; + out[`amt:${f}`] = row.amounts[f]; + } + return out; + }); + const totalRow: DataRow = { name: "계" }; + for (const f of MIX_FIELDS) totalRow[`amt:${f}`] = total[f]; + return { + columns: [ + { key: "name", label: "구분", width: 88 }, + { key: "spec", label: "규격", width: 110 }, + numberColumn({ key: "qty", label: "수량(㎥)", width: 80 }), + ...MIX_FIELDS.flatMap((f) => [ + numberColumn({ + key: `per:${f}`, + label: MIX_LABEL[f][1], + group: [MIX_LABEL[f][0]], + width: 84, + }), + numberColumn({ + key: `amt:${f}`, + label: MIX_LABEL[f][2], + group: [MIX_LABEL[f][0]], + width: 96, + }), + ]), + ], + rows: rowsOut, + total: totalRow, + frozen: 2, + mergeSame: ["name"], + editable: (_i, key) => key.startsWith("per:"), + target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)], + }; +} + +function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { + const { rows } = s.material; + const lost = new Set(s.quantity.unmatched); + const keep = shown(rows, (row) => row.qty != null || row.amount != null); + return { + columns: [ + { key: "group", label: "구분", width: 96 }, + { key: "spec", label: "규격", width: 120 }, + { key: "unit", label: "단위", width: 56 }, + numberColumn({ key: "qty", label: "수량", width: 96 }), + numberColumn({ key: "surcharge", label: "할증(%)", width: 72 }), + numberColumn({ key: "amount", label: "합계", width: 96 }), + { key: "note", label: "비고", width: 96 }, + ], + rows: keep.map((at) => { + const row = rows[at]; + return { + group: row.group, // 위 줄과 같으면 세로 병합(201-1) + spec: row.spec, + unit: row.unit, + qty: row.qty, + surcharge: row.surcharge, + amount: row.amount, + note: row.panel + ? "할증 = 좌측 패널" + : row.key === DEDUCT_KEY + ? "토공 잔토처리에서 뺌" + : lost.has(row.key) + ? "2공구 맞춤 없음" + : "", + }; + }), + total: null, + frozen: 2, + mergeSame: ["group"], + editable: (i, key) => + key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY, + target: (i) => [o.surcharge, rows[keep[i]].key, null], + }; +} + +const BUILD: Record Table> = { + material: totalTable, + concrete: concreteTable, + quantity: quantityTable, +}; + +export const materialTable = ( + sheet: MaterialSheetId, + s: MaterialSheets, + o: MaterialOverrides, +): Table => BUILD[sheet](s, o); + +/** 공용 표 옵션 — 고치기 · 높이는 화면이 더함 */ +export const materialOptions = (built: Table): DataTableOptions => ({ + columns: built.columns, + rows: built.rows, + total: built.total, + frozen: built.frozen, + mergeSame: built.mergeSame, + empty: "", + caption: BASIS, +}); diff --git a/B08_Quantity/B08_Quantity_Milestone_Table.ts b/B08_Quantity/B08_Quantity_Milestone_Table.ts new file mode 100644 index 000000000..b35e69047 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Milestone_Table.ts @@ -0,0 +1,68 @@ +/* ============================================================================= + * B08_Quantity_Milestone_Table.ts + * 거리이정표 표(PLAN 244-1 · 244-13) 짜기 — 화면 없음(PLAN 302-3). + * 화면(`B08_Quantity_UI_Milestone`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀. + * + * 열 = 기준점 있음 = 2차포장 · 1차포장 (· 포장) · 비포장 + 계 포장 · 비포장 · 합계 / 없음 = 포장 · 비포장 · 합계 + * 줄 = TS 짝(`…_Milestone_Calc.computeMilestone`) · 목적지 = 글(화면은 고름 표 ▾ 를 덧붙임) + * ========================================================================== */ + +import type { DataColumn, DataTableOptions } from "@ui/ui_template_data_table"; +import { + computeMilestone, + type Dest, + type MilestoneData, + type MilestoneTableRow, +} from "./B08_Quantity_Milestone_Calc"; + +export const DEST_TEXT: Record = { start: "시점", mid: "중점" }; + +const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const; +const ROAD = ["도로 구분 (km)"]; +const SUM = ["계 (km)"]; + +/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 · + * 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */ +export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColumn[] { + const roads: DataColumn[] = withBase + ? [ + { key: "s2", label: "2차포장", group: ROAD, ...KM }, + { key: "s1", label: "1차포장", group: ROAD, ...KM }, + ...(anyPaved ? [{ key: "pv", label: "포장", group: ROAD, ...KM }] : []), + { key: "un", label: "비포장", group: ROAD, ...KM }, + { key: "paved", label: "포장", group: SUM, ...KM }, + { key: "unpaved", label: "비포장", group: SUM, ...KM }, + ] + : [ + { key: "paved", label: "포장", group: ROAD, ...KM }, + { key: "un", label: "비포장", group: ROAD, ...KM }, + ]; + return [ + { key: "item", label: "항목", width: 134 }, + { key: "company", label: "거래처 이름", width: 120 }, + { key: "dest", label: "목적지", width: 72 }, + ...roads, + { key: "total", label: "합계", group: SUM, ...KM, width: 108 }, + { key: "note", label: "비고", width: 160 }, + ]; +} + +/** 표 한 벌 — options(열 · 줄 · 이름) · rows = 짝 계산 줄(경로 id · 줄 차례 · km) · mode = 열 꼴 표 */ +export function milestoneTable( + data: MilestoneData, + midDefaultM: number | null, +): { options: DataTableOptions; rows: MilestoneTableRow[]; mode: string } { + const rows = computeMilestone(data, midDefaultM); + const withBase = data.bases.length > 0; + const anyPaved = rows.some((r) => r.pv > 0); + return { + options: { + columns: milestoneColumns(withBase, anyPaved), + rows: rows.map((r) => ({ ...r, dest: DEST_TEXT[r.dest] })), + caption: "거리이정표", + empty: "", + }, + rows, + mode: `${withBase}:${anyPaved}`, + }; +} diff --git a/B08_Quantity/B08_Quantity_Summary_Table.ts b/B08_Quantity/B08_Quantity_Summary_Table.ts new file mode 100644 index 000000000..01a4ea70a --- /dev/null +++ b/B08_Quantity/B08_Quantity_Summary_Table.ts @@ -0,0 +1,245 @@ +/* ============================================================================= + * B08_Quantity_Summary_Table.ts + * 구조물 집계표(PLAN 61-1) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3). + * 화면(`B08_Quantity_UI_Summary`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀. + * + * `loadSummary` = 구조물 · 배관 · 측점 간격 · 배관 집계(서버) → 줄 측점 · 조합 열 · 사본 경로 + * `summaryTable` = 공용 표 옵션(머리 · 측점 열 · 조합 열 · 배관 열 · 계 줄 · 램프 칸 class) + * 커플링 열 = 좌측 패널 1본 길이로 다시 셈(`view.live()` · 캐시 → 저장본 · PLAN 122) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table"; +import { pipeStructureId } from "@ui/ui_template_item_picker_project"; +import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; +import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; +import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { stationLabel } from "../common_util/common_util_station"; +import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; +import { + comboId, + linkedOnly, + pipeOwnerId, + summaryColumns, + summaryStations, + type ComboColumn, + type ComboLabel, + type LinkedSnapshot, + type PlacedItem, + type UnlinkedFacility, + unassignedText, + unlinkedAt, + widthOf, +} from "./B08_Quantity_Summary_Data"; +import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc"; +import { liveOnePipe } from "./B08_Quantity_UI_Options"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 측점 간격 — 노선 설정값 · 못 읽으면 20m. */ +export async function stationInterval(projectId: string): Promise { + const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId)); + return latest.route_params?.station_interval_m || 20; +} + +/** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */ +interface PipeColumn { + id: string; + group: string[]; + label: string; + combos: string[]; +} +export interface PipeSummary { + columns: PipeColumn[]; + rows: Array<{ chainage_m: number; values: Record }>; + total: Record; + notes: string[]; + /** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */ + types: string[]; + one_length_m: number | null; + pipes: PipeLength[]; + /** 연결된 사본 `{구조물 id: 사본}`(143-1) */ + linked: Record; + /** 미연결 시설(154-1) */ + unlinked: UnlinkedFacility[]; + /** 모든 시설 칸(모달 「조합 연결」 · 156-1) */ + facilities: UnlinkedFacility[]; +} + +async function fetchPipeSummary(projectId: string): Promise { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/quantity/summary`, { + credentials: "include", + }); + if (!response.ok) throw new Error(`${response.status} ${await response.text()}`); + return response.json(); +} + +const PIPE_TOP = "관 공"; +/** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */ +export const LAMP_CLASS = "b08-summary__lamp"; +/** 모두 연결 = 같은 램프 초록(247-1) */ +const LINKED_CLASS = `${LAMP_CLASS} is-linked`; +const LAMP_W = 14; +const stationKey = (station: number): number => Math.round(station * 100); +/** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */ +const placesOf = (values: number[]): number => + Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length)); + +/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 98). */ +function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] { + const top = + summary.columns + .filter((column) => column.group[0] === PIPE_TOP) + .flatMap((column) => column.combos) + .map(comboPath) + .find((path) => !!path) ?? PIPE_TOP; + return summary.columns.map((column) => { + const group = column.group[0] === PIPE_TOP ? [top, ...column.group.slice(1)] : column.group; + const values = [ + ...summary.rows.map((row) => row.values[column.id]), + summary.total[column.id], + ].filter((value): value is number => typeof value === "number"); + const decimals = placesOf(values); + const texts = values.map((value) => + value.toLocaleString("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }), + ); + return { + key: column.id, + label: column.label, + group, + kind: "number", + decimals, + title: [...group, column.label].join(" › "), + width: widthOf(column.label, ...texts), + }; + }); +} + +/** 집계표 한 벌 — 화면 · 인쇄가 함께 씀 */ +export interface SummaryView { + stations: number[]; + /** 조합 열(횡단배수 = 서버 배관 열이라 뺌) */ + columns: ComboColumn[]; + /** 모달용 구조물 · 관 지점(사본 있는 것) */ + things: PlacedItem[]; + linked: Record; + describe: (item: SummaryItem) => ComboLabel | null; + comboPath: (id: string) => string | null; + interval: number; + unlinked: UnlinkedFacility[]; + saved: PipeSummary | null; + /** 배관 집계 실패 까닭 — 나머지 표는 그대로 */ + error: string | null; + /** 지금 배관 집계 — 커플링 = 좌측 패널 1본 길이(캐시 → 저장본) */ + live: () => PipeSummary | null; +} + +export async function loadSummary(projectId: string): Promise { + const [structures, pipes, interval, pipe] = await Promise.all([ + fetchStructures(projectId), + // 배수 분석 전이면 관이 없음 — 구조물만으로 그림 + fetchDetailPipePoints(projectId).then( + (response) => response.pipe_points, + () => [], + ), + stationInterval(projectId).catch(() => 20), + // 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로 + fetchPipeSummary(projectId).catch((error: unknown) => String(error)), + ]); + const saved = typeof pipe === "string" ? null : pipe; + // 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1) + const linked = saved?.linked ?? {}; + const paths = new Map( + Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const), + ); + const describe = (item: SummaryItem): ComboLabel | null => { + const [group = "", ...rest] = paths.get(comboId(item)) ?? []; + return group ? { group, name: rest.join(" ") || group } : null; + }; + const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null; + // 관 지점 id = 서버가 낸 id 먼저(응답 측점은 둘째 자리 · id 는 셋째 자리 · 167-1) + const ids = [...Object.keys(linked), ...(saved?.facilities ?? []).map((f) => f.id)]; + const placed = linkedOnly( + pipes.map((point) => ({ + ...point, + structure_id: pipeOwnerId(point.chainage_m, ids) ?? pipeStructureId(point.chainage_m), + })), + linked, + ); + const mine = linkedOnly(structures.structures, linked); + const things: PlacedItem[] = [...mine, ...placed]; + const unlinked = saved?.unlinked ?? []; + // 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부) + const served = new Set(saved?.types ?? []); + const others = [ + ...mine.filter((item) => !served.has(item.type_id)), + ...placed.filter((point) => !served.has(point.facility ?? "pipe")), + ]; + return { + stations: summaryStations([...things, ...unlinked]), + columns: summaryColumns(others, describe), + things, + linked, + describe, + comboPath, + interval, + unlinked, + saved, + error: typeof pipe === "string" ? pipe : null, + live: () => saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m)), + }; +} + +/** 표 옵션 — 줄 측점 · 측점 칸 램프 class · labels = 측점 글 · missing = 줄마다 빈 조합 칸 글(호버) */ +export function summaryTable(view: SummaryView): { + options: DataTableOptions; + labels: string[]; + missing: string[]; +} { + const { stations, columns, interval, unlinked, comboPath } = view; + const pipe = view.live(); + const values = new Map((pipe?.rows ?? []).map((row) => [stationKey(row.chainage_m), row.values])); + const total: DataRow | null = pipe?.columns.length + ? { station: L("B08_Quantity_Summary_Total"), ...pipe.total } + : null; + const labels = stations.map((station) => stationLabel(station, interval)); + const stationHead = L("B08_Quantity_Summary_Station"); + // 198-1 — 미연결 시설 측점 = 측점 칸 왼쪽 노랑 램프(값 빈칸 · 누르면 바로 연결 · 154) · 호버 = 빈 조합 칸(160-2) + const missing = stations.map((station) => + unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")), + ); + // 247-1 — 시설이 있고 빈 칸 없음 = 초록 램프 + const linked = stations.map((station) => unlinkedAt(pipe?.facilities ?? [], station).length > 0); + const options: DataTableOptions = { + columns: [ + { key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W }, + ...columns.map((column) => ({ + key: column.id, + label: column.name, + group: [column.group || L("B08_Quantity_Summary_MissingGroup")], + title: summaryTooltip(column.item), + className: column.missing ? "is-missing" : undefined, + width: widthOf(column.name), + })), + ...(pipe ? pipeColumns(pipe, comboPath) : []), + ], + // 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값 + rows: stations.map((station, i) => ({ + station: labels[i], + ...values.get(stationKey(station)), + })), + total, + cellClass: (i, key) => + key !== "station" ? null : missing[i] ? LAMP_CLASS : linked[i] ? LINKED_CLASS : null, + frozen: 1, + empty: "", + caption: L("B08_Quantity_Tab_StructureSummary"), + }; + return { options, labels, missing }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index c29233cc3..071d23d3c 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -9,142 +9,24 @@ * 표 = 공용 많은 자료 표(`ui_template_data_table` · PLAN 70) · 카드 안 · 나머지 셋은 `…_Earthwork_Sheets`. * 절토 연암 · 경암 = 암 × 좌측 패널 비율 · 암 C = 비율 가중 — 조작 중 같은 식으로 바로 다시 셈 * (`…_Options_Calc` · PLAN 122 · 125-1). + * 자료 읽기 · 표 짜기 = `B08_Quantity_Earthwork_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ -import { API_BASE_URL } from "@config/config_frontend"; import { createButton, createCard } from "@ui/ui_template_elements"; -import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; -import { stationLabel } from "../common_util/common_util_station"; +import { createDataTable } from "@ui/ui_template_data_table"; import { footNote } from "./B08_Quantity_UI_Foot"; -import { stationInterval } from "./B08_Quantity_UI_Summary"; +import { + EARTHWORK_SHEETS, + earthworkSheetOptions, + loadEarthwork, + type EarthworkTable, +} from "./B08_Quantity_Earthwork_Tables"; import { renderEarthworkSummary, renderFreeHaul, renderHaulDistance, } from "./B08_Quantity_UI_Earthwork_Sheets"; -import { - mixedRockC, - recountAdjustedSummary, - recountRockC, - splitRockColumns, - splitRockSummary, -} from "./B08_Quantity_Options_Calc"; -import { liveSoftRock, onOptionsChange } from "./B08_Quantity_UI_Options"; - -type Face = { length_m: number | null; area_m2: number | null }; - -interface EarthworkRow { - chainage_m: number; - distance_m: number; - cut_soil_area_m2: number; - cut_soil_volume_m3: number; - cut_soil_adjusted_m3: number; - cut_rock_area_m2: number; - cut_rock_volume_m3: number; - cut_rock_adjusted_m3: number; - cut_rock_kind: string; - ditch_soil_area_m2: number; - ditch_soil_volume_m3: number; - ditch_soil_adjusted_m3: number; - ditch_rock_area_m2: number; - ditch_rock_volume_m3: number; - ditch_rock_adjusted_m3: number; - adjusted_total_m3: number; - fill_area_m2: number; - fill_volume_m3: number; - diverted_m3: number; - balance_m3: number; - cumulative_m3: number; - notes: string[]; - faces: Record; -} - -export interface HaulRow { - q: number; - l: number; - ql: number; -} - -export interface HaulList { - rows: HaulRow[]; - q: number; - ql: number; - average_m: number | null; -} - -export interface SummaryRow { - group: string; - kind: string; - spec: string; - unit: string; - value: number | null; - note: string; -} - -export interface EarthworkTable { - rows: EarthworkRow[]; - totals: Record; - conversion_factors: Record; - station_count: number; - route_id: number; - confirmed: boolean; - summary: SummaryRow[]; - haul: Record<"dump_truck" | "dozer" | "free_haul", HaulList>; - haul_saved: boolean; - /** 저장된 연암 · 경암 %(암 전체 기준) */ - rock_ratios_pct: { 연암: number; 경암: number }; - /** 가중 전 리핑암(연암) · 발파암(경암) C — 비율로 다시 가중(125-1) */ - rock_c: { ripping_rock: number; blasting_rock: number }; -} - -/** 칸 묶음 — [머리 묶음, 키 앞머리, 풍선 글] · 단면적 · 입적 · 보정량 셋 · 머리 글자는 사용자 견본대로 */ -const TRIPLES: Array<[string[], string, string?]> = [ - [["절토", "토사"], "cut_soil"], - [["절토", "연암"], "cut_soft_rock", "암 × 연암 %"], - [["절토", "경암"], "cut_hard_rock", "암 × 경암 %"], - [["측구파기", "토사"], "ditch_soil"], - [["측구파기", "암"], "ditch_rock", "리핑암 · 발파암 합"], -]; -/** 면 계열 — [머리 묶음, 키] · 거리 · 면적 둘 */ -const FACES: Array<[string[], string]> = [ - [["면고르기", "성토면"], "grading_fill"], - [["면고르기", "절토면"], "grading_cut"], - [["지장목제거", "성토"], "tree_removal_fill"], - [["지장목제거", "절토"], "tree_removal_cut"], - [["제근"], "grubbing"], - [["노면정리"], "road_surface"], -]; - -const n2 = (key: string, label: string, group?: string[]): DataColumn => ({ - key, - label, - group, - kind: "number", - decimals: 2, -}); - -/** 견본 차례 32칸 */ -const COLUMNS: DataColumn[] = [ - { key: "station", label: "측점", width: 72 }, - n2("distance", "거리"), - ...TRIPLES.flatMap(([group, key, tip]) => - [ - n2(`${key}_area`, "단면적", group), - n2(`${key}_volume`, "입적", group), - n2(`${key}_adjusted`, "보정량", group), - ].map((c) => (tip ? { ...c, title: `${group.at(-1)} ${c.label} — ${tip}` } : c)), - ), - n2("adjusted_total", "보정량계"), - n2("fill_area", "단면적", ["성토"]), - n2("fill_volume", "입적", ["성토"]), - n2("diverted", "유용토"), - n2("balance", "차인토량"), - n2("cumulative", "누가토량"), - ...FACES.flatMap(([group, key]) => [ - n2(`${key}_length`, "거리", group), - n2(`${key}_area`, "면적", group), - ]), -]; +import { onOptionsChange } from "./B08_Quantity_UI_Options"; export function note(text: string): HTMLElement { const p = document.createElement("p"); @@ -162,56 +44,6 @@ export function tableCard( return createCard({ body: [table, ...after, ...info] }); } -function stationRow(row: EarthworkRow, first: boolean, interval: number): DataRow { - // 첫 측점은 앞이 없어 입적 · 보정량 · 면적이 없음(빈 값) - const v = (value: number): number | null => (first ? null : value); - const out: DataRow = { - station: stationLabel(row.chainage_m, interval) + (row.notes.length ? " *" : ""), - distance: row.distance_m, - adjusted_total: v(row.adjusted_total_m3), - fill_area: row.fill_area_m2, - fill_volume: v(row.fill_volume_m3), - diverted: v(row.diverted_m3), - balance: v(row.balance_m3), - cumulative: v(row.cumulative_m3), - }; - const r = row as unknown as Record; - for (const [, key] of TRIPLES) { - out[`${key}_area`] = r[`${key}_area_m2`]; - out[`${key}_volume`] = v(r[`${key}_volume_m3`]); - out[`${key}_adjusted`] = v(r[`${key}_adjusted_m3`]); - } - for (const [, key] of FACES) { - out[`${key}_length`] = row.faces?.[key]?.length_m ?? null; - out[`${key}_area`] = first ? null : (row.faces?.[key]?.area_m2 ?? null); - } - return out; -} - -/** 합계 줄 — 단면적 · 거리는 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */ -function totalRow(table: EarthworkTable): DataRow { - const t = table.totals; - const out: DataRow = { - station: "합계", - distance: t.distance_m, - adjusted_total: t.adjusted_total_m3, - fill_volume: t.fill_volume_m3, - diverted: t.diverted_m3, - balance: t.balance_m3, - cumulative: table.rows[table.rows.length - 1]?.cumulative_m3, - }; - for (const [, key] of TRIPLES) { - out[`${key}_volume`] = t[`${key}_volume_m3`]; - out[`${key}_adjusted`] = t[`${key}_adjusted_m3`]; - } - // 값이 하나도 없는 칸(제근 — B07 에 값 없음)은 합계도 비움 - for (const [, key] of FACES) { - const any = table.rows.some((row) => row.faces?.[key]?.area_m2 != null); - out[`${key}_area`] = any ? t[`${key}_area_m2`] : null; - } - return out; -} - function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number): void { if (!table.rows.length) { host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요.")); @@ -225,66 +57,25 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number) ), ...(flagged.length ? [footNote(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []), ]; - const grid = createDataTable({ - columns: COLUMNS, - rows: table.rows.map((row, i) => stationRow(row, i === 0, interval)), - frozen: 1, - total: totalRow(table), - caption: "토적표", - fillViewport: true, - }); + const grid = createDataTable({ ...earthworkSheetOptions(table, interval), fillViewport: true }); grid.root.dataset.b08Earthwork = "table"; host.replaceChildren(tableCard(info, grid.root)); } -const SHEETS = [ - { id: "summary", label: "토공집계표" }, - { id: "sheet", label: "토적표" }, - { id: "haul", label: "평균운반거리" }, - { id: "free", label: "종방향 무대운반거리" }, -] as const; +const SHEETS = EARTHWORK_SHEETS; type SheetId = (typeof SHEETS)[number]["id"]; export async function renderEarthwork(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note("토공 수량을 만드는 중…")); - let table: EarthworkTable; - let interval = 20; + let loaded: Awaited>; try { - const [response, gap] = await Promise.all([ - fetch(`${API_BASE_URL}/projects/${projectId}/quantity/earthwork`, { credentials: "include" }), - stationInterval(projectId).catch(() => 20), - ]); - const body = (await response.json().catch(() => null)) as - (EarthworkTable & { detail?: unknown }) | null; - if (!response.ok || !body) { - const detail = body?.detail; - throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); - } - table = body; - interval = gap; + loaded = await loadEarthwork(projectId); } catch (error) { host.replaceChildren(note(`토공 수량을 불러오지 못했습니다 — ${String(error)}`)); return; } - - // 연암 · 경암 칸 · 집계표 줄 = 암 × 지금 비율 · 암 C = 비율 가중(캐시 → 저장본 · 125-1) - const summary = table.summary; - const applyRock = (): void => { - const soft = liveSoftRock(projectId, table.rock_ratios_pct?.연암 ?? 90); - const c = mixedRockC(soft, table.rock_c.ripping_rock, table.rock_c.blasting_rock); - for (const kind of ["ripping_rock", "blasting_rock"]) - table.conversion_factors[kind].compacted = c; - recountRockC(table.rows as unknown as Array>, table.totals, c); - for (const record of [...table.rows, table.totals]) - splitRockColumns(record as unknown as Record, soft); - table.summary = recountAdjustedSummary( - splitRockSummary(summary, table.totals, soft), - table.totals, - table.haul.free_haul.q, - ); - }; - applyRock(); + const { table, interval, applyRock } = loaded; const tabs = document.createElement("div"); tabs.className = "b08-quantity__tabs ui-subtabs b08-earthwork__tabs"; diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index ee9e58c22..372561c31 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -5,45 +5,25 @@ * 칸 · 식 = 거창 실무 워크북 같은 이름 시트 그대로 · 값은 서버가 셈(`…_Engine_Earthwork_Summary`). * 운반 Q · L = 저장된 유토곡선 배분(B07 토적도와 같은 정본) — 없으면 그 사유만 보임. * 표 = 공용 많은 자료 표(`ui_template_data_table`). + * 표 짜기 = `B08_Quantity_Earthwork_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ -import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; -import { note, tableCard, type EarthworkTable, type HaulList } from "./B08_Quantity_UI_Earthwork"; +import { createDataTable } from "@ui/ui_template_data_table"; +import { note, tableCard } from "./B08_Quantity_UI_Earthwork"; +import { + earthworkSummaryOptions, + HAUL_SHEETS, + haulOptions, + type EarthworkTable, +} from "./B08_Quantity_Earthwork_Tables"; import { footNote } from "./B08_Quantity_UI_Foot"; -const fmt = (value: number | null | undefined): string => - value == null || !Number.isFinite(value) - ? "-" - : value.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); - -const SUMMARY_COLUMNS: DataColumn[] = [ - { key: "group", label: "구분", width: 120 }, - { key: "kind", label: "공종", width: 72 }, - { key: "spec", label: "규격", width: 128 }, - { key: "unit", label: "단위", width: 56 }, - { key: "value", label: "계", kind: "number", decimals: 2, width: 112 }, - { key: "note", label: "비고", width: 200 }, -]; - /** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 */ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void { const info = footNote( "연암 · 경암은 좌측 패널 비율대로 나눈 암 · 「비율 미정」은 비율 확인 뒤 채움", ); - const grid = createDataTable({ - columns: SUMMARY_COLUMNS, - // 칸이 없는 글(공종 · 규격 · 비고)은 빈칸 — 공용 표는 "" 를 「-」(값 없음)로 그림 - rows: table.summary.map((row) => ({ - ...row, - kind: row.kind || " ", - spec: row.spec || " ", - note: row.note || " ", - })), - frozen: 1, - mergeSame: ["group", "spec"], // 201-1 같은 구분 · 규격 이어지면 세로 병합(빈칸은 안 합침) - caption: "토공집계표", - fillViewport: true, - }); + const grid = createDataTable({ ...earthworkSummaryOptions(table), fillViewport: true }); host.replaceChildren(tableCard([info], grid.root)); } @@ -53,74 +33,22 @@ function haulNote(table: EarthworkTable): HTMLElement[] { : [footNote("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")]; } -/** 운반 시트 한 장 — 묶음마다 토량(Q) · 거리(L) · Q×L · 맨 아래 합계 · 아래 평균운반거리 줄 */ +/** 운반 시트 한 장 — 표 + 아래 평균운반거리 줄 */ function haulSheet( host: HTMLElement, table: EarthworkTable, - groups: Array<[string, HaulList]>, - caption: string, + sheet: keyof typeof HAUL_SHEETS, ): void { - const columns: DataColumn[] = [ - { key: "no", label: "번호", width: 56 }, - ...groups.flatMap(([label], g) => - [ - ["q", "토량(Q)"], - ["l", "거리(L)"], - ["ql", "Q×L"], - ].map(([key, text]) => ({ - key: `${key}${g}`, - label: text, - group: [label], - kind: "number" as const, - decimals: 2, - width: 104, - })), - ), - ]; - const count = Math.max(0, ...groups.map(([, list]) => list.rows.length)); - const rows: DataRow[] = Array.from({ length: count }, (_, i) => { - const out: DataRow = { no: String(i + 1) }; - groups.forEach(([, list], g) => { - const item = list.rows[i]; - if (item) Object.assign(out, { [`q${g}`]: item.q, [`l${g}`]: item.l, [`ql${g}`]: item.ql }); - }); - return out; - }); - const total: DataRow = { no: "합계" }; - groups.forEach(([, list], g) => Object.assign(total, { [`q${g}`]: list.q, [`ql${g}`]: list.ql })); - const averages = groups.map(([label, list], i) => - note( - `${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`, - ), - ); - const grid = createDataTable({ - columns, - rows, - frozen: 1, - total, - caption, - fillViewport: true, - }); - host.replaceChildren(tableCard(haulNote(table), grid.root, averages)); + const { groups, caption } = HAUL_SHEETS[sheet](table); + const { options, averages } = haulOptions(groups, caption); + const grid = createDataTable({ ...options, fillViewport: true }); + host.replaceChildren(tableCard(haulNote(table), grid.root, averages.map(note))); } export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): void { - haulSheet( - host, - table, - [ - ["덤프트럭", table.haul.dump_truck], - ["불도우저", table.haul.dozer], - ], - "덤프트럭 · 불도우저 평균운반거리 — 단위 ㎥ · m", - ); + haulSheet(host, table, "haul"); } export function renderFreeHaul(host: HTMLElement, table: EarthworkTable): void { - haulSheet( - host, - table, - [["종방향무대", table.haul.free_haul]], - "종방향 무대운반거리 — 단위 ㎥ · m", - ); + haulSheet(host, table, "free"); } diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.ts b/B08_Quantity/B08_Quantity_UI_Hydro.ts index e33d2009b..c835d8c85 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro.ts @@ -8,55 +8,32 @@ * 처음 값 = 표준 값 · 측점 고유 값은 빈칸 · 지역 · R24 = 공사지 자료(서버) * 조작 = 캐시(sessionStorage) · [저장] = B08 자기 파일(PUT …/quantity/hydro) · 뒤 캐시 비움 * 계산 = 서버 단독(POST …/quantity/hydro/calc · 최신 응답만 채택) + * 자료 읽기 · 측점 입력 겹치기 = `B08_Quantity_Hydro_Table`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ import "./B08_Quantity_UI_Hydro.css"; -import { API_BASE_URL } from "@config/config_frontend"; import { createButton, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; -import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { clearState, writeState } from "../A00_Common/b_page_state"; import { stationLabel } from "../common_util/common_util_station"; -import { stationInterval } from "./B08_Quantity_UI_Summary"; import { - buildHydroSheet, - type HydroInputs, - type HydroLink, - type HydroOptions, - type HydroResult, -} from "./B08_Quantity_UI_Hydro_Sheet"; + hydroCalc, + hydroInputs, + keyOf, + loadHydro, + unlinkedInputs as unlinked, + type Drafts, + type HydroPayload, + type HydroStation, +} from "./B08_Quantity_Hydro_Table"; +import { request } from "./B08_Quantity_Material_Tables"; +import { buildHydroSheet, type HydroInputs, type HydroResult } from "./B08_Quantity_UI_Hydro_Sheet"; -interface HydroStation { - chainage_m: number; - facility: string; - /** 박스암거 · 세월교 · 물넘이포장(관이면 "") */ - kind: string; - /** 계산서 장 — 시설이 정함(서버 station_rows) */ - sheet: HydroInputs["sheet"]; - /** B05 연결값(값이 있는 칸만) · 비교용 B05 Qd · T — 서버 station_rows */ - linked: HydroLink["values"] | null; - b05: HydroLink["b05"]; -} - -type Drafts = Record>; -type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions; - -export async function request(path: string, init: RequestInit = {}): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { - credentials: "include", - headers: { "Content-Type": "application/json" }, - ...init, - }); - const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; - if (!response.ok) { - const detail = body?.detail; - throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); - } - return body as T; -} +export { request }; function note(text: string): HTMLElement { const p = document.createElement("p"); @@ -65,17 +42,13 @@ function note(text: string): HTMLElement { return p; } -const keyOf = (station: HydroStation): string => station.chainage_m.toFixed(3); - export async function renderHydro(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note("홍수량 계산서를 불러오는 중…")); let sheet: HydroPayload; let interval = 20; + let drafts: Drafts = {}; try { - [sheet, interval] = await Promise.all([ - request(`/projects/${projectId}/quantity/hydro`), - stationInterval(projectId).catch(() => 20), - ]); + ({ sheet, interval, drafts } = await loadHydro(projectId)); } catch (error) { host.replaceChildren(note(`홍수량 계산서를 불러오지 못했습니다 — ${String(error)}`)); return; @@ -87,27 +60,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise return; } - let drafts: Drafts = readState("b08-hydro", projectId) ?? {}; - const linkedOf = (station: HydroStation): Record => station.linked ?? {}; - /** 저장본 · 초안 한 겹 — 연결값이 있는 칸의 null 은 건너뜀(연결값을 덮지 않게) */ - const layer = (station: HydroStation, values: Partial = {}) => - Object.fromEntries( - Object.entries(values).filter(([k, v]) => v != null || !(k in linkedOf(station))), - ); - const inputsOf = (station: HydroStation): HydroInputs => - ({ - ...sheet.defaults, - ...(station.sheet === "pipe" ? {} : sheet.sheet_defaults[station.sheet]), - ...station.linked, - // 초안은 측점 입력 통째 — 있으면 저장본 대신(초안의 null = 연결값으로 되돌림) - ...layer(station, drafts[keyOf(station)] ?? sheet.saved[keyOf(station)]), - sheet: station.sheet, - }) as HydroInputs; - /** 연결값과 같은 칸은 null — 저장 · 초안이 연동을 유지(B05 값이 바뀌면 따라옴) · 다르면 손 값 */ - const unlinked = (station: HydroStation, inputs: HydroInputs): HydroInputs => - Object.fromEntries( - Object.entries(inputs).map(([k, v]) => [k, linkedOf(station)[k] === v ? null : v]), - ) as unknown as HydroInputs; + const inputsOf = (station: HydroStation): HydroInputs => hydroInputs(sheet, station, drafts); const tabs = document.createElement("div"); tabs.className = "b08-quantity__tabs ui-subtabs b08-hydro__tabs"; @@ -172,10 +125,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise ): Promise => { const mine = ++seq; try { - const result = await request(`/projects/${projectId}/quantity/hydro/calc`, { - method: "POST", - body: JSON.stringify(inputs), - }); + const result = await hydroCalc(projectId, inputs); if (mine === seq) show(result); } catch (error) { if (mine === seq) show(null, `계산하지 못했습니다 — ${String(error)}`); diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 849d7b6ea..b7f7c984f 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -11,9 +11,9 @@ * 콘크리트소요는 5줄 늘 보임(수량 빈칸 · 배합 늘 고침) + 콘크리트 전용 빈 까닭 한 줄(163-5) * 콘크리트(기계) · 모르터 1:3 열 = 늘 보임 · 손 단위수량 → 콘크리트소요(160-4) · 엑셀 손값과 다른 * 배합 · 할증은 표 아래 알림 한 줄만 · 맞춤 없는 사본 자재 · 채집석 공제 = 총괄 끝 줄(160-3 · 5) + * 자료 읽기 · 표 짜기 = `B08_Quantity_Material_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ -import { API_BASE_URL } from "@config/config_frontend"; import { createButton, createCard, @@ -21,36 +21,30 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; -import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; -import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { createDataTable } from "@ui/ui_template_data_table"; +import { clearState, writeState } from "../A00_Common/b_page_state"; import { blankUnitRows, - computeMaterialSheets, - DEDUCT_KEY, matched, MIX_FIELDS, type Cells, - type MaterialOverrides, type MaterialSheets, type SummarySource, } from "./B08_Quantity_Material_Calc"; +import { + loadMaterials, + materialOptions, + materialTable, + MIX_LABEL, + request, + type MaterialSheetId, + type Payload, +} from "./B08_Quantity_Material_Tables"; import { footNote } from "./B08_Quantity_UI_Foot"; -import { liveSurcharge, onOptionsChange } from "./B08_Quantity_UI_Options"; +import { onOptionsChange } from "./B08_Quantity_UI_Options"; -export type MaterialSheetId = "material" | "concrete" | "quantity"; +export type { MaterialSheetId }; -interface Payload { - source: SummarySource; - overrides: MaterialOverrides; - /** 좌측 패널 할증 저장본(기본 채움) */ - surcharge_pct: Cells; - /** 구조물 집계표 알림(「집수정 모양 기록 없음 n곳」 등) — 총괄 · 콘크리트 위 빈 까닭(131) */ - notes?: string[]; - sheets: MaterialSheets; -} - -const BASIS = - "구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)"; const HINTS: Record = { material: "할증 %는 칸에서 고침 · 모래 · 자갈 · 시멘트는 좌측 패널", concrete: "㎥당 배합은 칸에서 고침", @@ -58,25 +52,6 @@ const HINTS: Record = { }; const HIDDEN = "값 없는 줄은 숨김"; -/** 보일 줄 차례(원래 줄 번호) — 값 없는 줄은 숨김(143-3) */ -const shown = (rows: readonly T[], has: (row: T) => boolean): number[] => - rows.flatMap((row, i) => (has(row) ? [i] : [])); -const anyValue = (cells: Cells): boolean => Object.values(cells).some((v) => v != null); - -async function request(path: string, init: RequestInit = {}): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { - credentials: "include", - headers: { "Content-Type": "application/json" }, - ...init, - }); - const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; - if (!response.ok) { - const detail = body?.detail; - throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); - } - return body as T; -} - function note(text: string): HTMLElement { const p = document.createElement("p"); p.className = "ui-general-block__empty"; @@ -84,14 +59,6 @@ function note(text: string): HTMLElement { return p; } -/** 숫자 열 — 최대 3 자리 · 끝 0 지움(고친 값 자리가 줄마다 다름) */ -const numberColumn = (column: DataColumn): DataColumn => ({ - kind: "number", - decimals: 3, - trim: true, - ...column, -}); - /** 글 → 값 · 빈칸 = null(일부러 비움) · 수가 아니면 undefined(무시) */ function parse(text: string): number | null | undefined { const t = text.replace(/,/g, "").trim(); @@ -100,70 +67,6 @@ function parse(text: string): number | null | undefined { return Number.isFinite(v) ? v : undefined; } -interface Table { - columns: DataColumn[]; - rows: DataRow[]; - total: DataRow | null; - frozen: number; - editable: (index: number, key: string) => boolean; - /** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */ - target: (index: number, key: string) => [Record | Cells, string, string | null]; - /** 같은 값 세로 병합 열(201-1 · 공용 표 mergeSame) */ - mergeSame?: string[]; -} - -function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table { - const { materials, rows, total } = s.quantity; - const keep = shown(rows, (row) => row.qty != null || anyValue(row.units)); - const rowsOut: DataRow[] = keep.flatMap((at) => { - const row = rows[at]; - const unit: DataRow = { - name: row.name, - spec: row.spec, - qty: row.qty, - unit: row.unit, - kind: "단위수량", - }; - const amount: DataRow = { kind: "수량" }; - for (const m of materials) { - unit[`m:${m.key}`] = row.units[m.key]; - amount[`m:${m.key}`] = row.amounts[m.key]; - } - return [unit, amount]; - }); - const totalRow: DataRow = { name: "계", kind: "수량" }; - for (const m of materials) totalRow[`m:${m.key}`] = total[m.key]; - return { - columns: [ - { key: "name", label: "구분", width: 120 }, - { key: "spec", label: "규격", width: 120 }, - numberColumn({ key: "qty", label: "수량", width: 72 }), - { key: "unit", label: "단위", width: 56 }, - { key: "kind", label: "구분", width: 72 }, - ...materials.map((m) => - numberColumn({ - key: `m:${m.key}`, - label: m.unit, - group: m.spec ? [m.name, m.spec] : [m.name], - title: [m.name, m.spec, m.unit].filter(Boolean).join(" › "), - width: 76, - }), - ), - ], - rows: rowsOut, - total: totalRow, - frozen: 5, - editable: (i, key) => i % 2 === 0 && key.startsWith("m:"), - target: (i, key) => [o.units, rows[keep[i / 2]].id, key.slice(2)], - }; -} - -const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> = { - cement: ["시멘트", "㎥당(kg)", "수량(kg)"], - sand: ["모래", "㎥당(㎥)", "수량(㎥)"], - gravel: ["자갈", "㎥당(㎥)", "수량(㎥)"], -}; - /** 2공구 엑셀 손값(조사 I) — 지금 값(품셈 근거 · 124)과 다르면 표 아래 알림 한 줄만(160-4) */ const EXCEL_MIX: Record = { c25: { cement: 346, sand: 0.51, gravel: 0.59 }, @@ -214,100 +117,6 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string { : ""; } -function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { - const { rows, total } = s.concrete; - const keep = rows.map((_row, i) => i); // 5줄 늘 보임(163-5) — 수량 없으면 빈칸 · 배합은 늘 고침 - const rowsOut: DataRow[] = keep.map((at) => { - const row = rows[at]; - const out: DataRow = { - name: row.name, // 위 줄과 같으면 세로 병합(201-1) - spec: row.spec, - qty: row.qty, - }; - for (const f of MIX_FIELDS) { - out[`per:${f}`] = row.per[f]; - out[`amt:${f}`] = row.amounts[f]; - } - return out; - }); - const totalRow: DataRow = { name: "계" }; - for (const f of MIX_FIELDS) totalRow[`amt:${f}`] = total[f]; - return { - columns: [ - { key: "name", label: "구분", width: 88 }, - { key: "spec", label: "규격", width: 110 }, - numberColumn({ key: "qty", label: "수량(㎥)", width: 80 }), - ...MIX_FIELDS.flatMap((f) => [ - numberColumn({ - key: `per:${f}`, - label: MIX_LABEL[f][1], - group: [MIX_LABEL[f][0]], - width: 84, - }), - numberColumn({ - key: `amt:${f}`, - label: MIX_LABEL[f][2], - group: [MIX_LABEL[f][0]], - width: 96, - }), - ]), - ], - rows: rowsOut, - total: totalRow, - frozen: 2, - mergeSame: ["name"], - editable: (_i, key) => key.startsWith("per:"), - target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)], - }; -} - -function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { - const { rows } = s.material; - const lost = new Set(s.quantity.unmatched); - const keep = shown(rows, (row) => row.qty != null || row.amount != null); - return { - columns: [ - { key: "group", label: "구분", width: 96 }, - { key: "spec", label: "규격", width: 120 }, - { key: "unit", label: "단위", width: 56 }, - numberColumn({ key: "qty", label: "수량", width: 96 }), - numberColumn({ key: "surcharge", label: "할증(%)", width: 72 }), - numberColumn({ key: "amount", label: "합계", width: 96 }), - { key: "note", label: "비고", width: 96 }, - ], - rows: keep.map((at) => { - const row = rows[at]; - return { - group: row.group, // 위 줄과 같으면 세로 병합(201-1) - spec: row.spec, - unit: row.unit, - qty: row.qty, - surcharge: row.surcharge, - amount: row.amount, - note: row.panel - ? "할증 = 좌측 패널" - : row.key === DEDUCT_KEY - ? "토공 잔토처리에서 뺌" - : lost.has(row.key) - ? "2공구 맞춤 없음" - : "", - }; - }), - total: null, - frozen: 2, - mergeSame: ["group"], - editable: (i, key) => - key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY, - target: (i) => [o.surcharge, rows[keep[i]].key, null], - }; -} - -const BUILD: Record Table> = { - material: totalTable, - concrete: concreteTable, - quantity: quantityTable, -}; - /** 보조 탭 한 장 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */ export async function renderMaterialSheet( host: HTMLElement, @@ -315,21 +124,15 @@ export async function renderMaterialSheet( sheet: MaterialSheetId, ): Promise { host.replaceChildren(note("자재 집계표를 불러오는 중…")); - let payload: Payload; + let loaded: Awaited>; try { - payload = await request(`/projects/${projectId}/quantity/materials`); + loaded = await loadMaterials(projectId); } catch (error) { host.replaceChildren(note(`자재 집계표를 불러오지 못했습니다 — ${String(error)}`)); return; } - const draft = readState("b08-materials", projectId); - let overrides: MaterialOverrides = draft ?? payload.overrides; - const calc = (): MaterialSheets => - computeMaterialSheets( - payload.source, - overrides, - liveSurcharge(projectId, payload.surcharge_pct), - ); + let { payload, overrides } = loaded; + const calc = (): MaterialSheets => loaded.calc(payload, overrides); let sheets = calc(); const save = createButton({ label: "저장", variant: "filled", onClick: () => void saveAll() }); @@ -356,15 +159,9 @@ export async function renderMaterialSheet( if (sheet === "concrete") notes.append(concreteNote); toolbar.append(save); - let built = BUILD[sheet](sheets, overrides); + let built = materialTable(sheet, sheets, overrides); const table = createDataTable({ - columns: built.columns, - rows: built.rows, - total: built.total, - frozen: built.frozen, - mergeSame: built.mergeSame, - empty: "", - caption: BASIS, + ...materialOptions(built), fillViewport: true, editable: (i, key) => built.editable(i, key), editOnDouble: true, // 243-1 값은 더블클릭 · Enter · F2 로만 고침 @@ -388,7 +185,7 @@ export async function renderMaterialSheet( const excel = footNote(""); excel.dataset.b08MaterialExcel = ""; const redraw = (): void => { - built = BUILD[sheet](sheets, overrides); + built = materialTable(sheet, sheets, overrides); table.setRows(built.rows, built.total); excel.textContent = sheet === "quantity" ? "" : excelDiffs(sheets); concreteNote.textContent = concreteWhy(sheets, payload.source); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index 09a8f979d..1db32d83e 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -12,6 +12,7 @@ * 단가연결(251-4) = 항목 칸 앞 램프(초록 = 연결 · 노랑 = 없음 · 호버 = 무엇) · 줄 우클릭 [단가연결] · * [단가연결 풀기] — 창 = 연결 없음 → 단가연결 목록 → 옵션 값 창(276 · 옛 길) · 연결 있음 → * 「운반 단가」 모달(253-3 · B09 운반공 줄과 같음 · 그 행 거리) · 경로 `ref` · B09 운반공 줄이 받음 + * 표 열 · 줄 짜기 = `B08_Quantity_Milestone_Table`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ import "./B08_Quantity_UI_Milestone.css"; @@ -21,7 +22,8 @@ import { attachHoverInfo } from "@ui/ui_template_hover_info"; import type { Row } from "../B09_Estimation/B09_Estimation_Calc_Base"; import { openHaulModal } from "../B09_Estimation/B09_Estimation_UI_Haul_Modal"; import { openPriceLink } from "../B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link"; -import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import type { MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { DEST_TEXT, milestoneColumns, milestoneTable } from "./B08_Quantity_Milestone_Table"; import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram"; import { liveMilestone, @@ -31,7 +33,6 @@ import { siteInfo, } from "./B08_Quantity_UI_Milestone_State"; import { - DEST_TEXT, editRoute, pickAtCell, routeView, @@ -39,9 +40,6 @@ import { type MilestoneView, } from "./B08_Quantity_UI_Milestone_Route"; -const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const; -const ROAD = ["도로 구분 (km)"]; -const SUM = ["계 (km)"]; /** 단가연결 램프 칸 class(`B08_Quantity_UI_Milestone.css`) */ const LAMP = "b08-milestone__lamp"; @@ -55,32 +53,6 @@ export function refText(ref: Record | null | undefined): string return `단가연결 — ${what.trim()}`; } -/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 · - * 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */ -export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColumn[] { - const roads: DataColumn[] = withBase - ? [ - { key: "s2", label: "2차포장", group: ROAD, ...KM }, - { key: "s1", label: "1차포장", group: ROAD, ...KM }, - ...(anyPaved ? [{ key: "pv", label: "포장", group: ROAD, ...KM }] : []), - { key: "un", label: "비포장", group: ROAD, ...KM }, - { key: "paved", label: "포장", group: SUM, ...KM }, - { key: "unpaved", label: "비포장", group: SUM, ...KM }, - ] - : [ - { key: "paved", label: "포장", group: ROAD, ...KM }, - { key: "un", label: "비포장", group: ROAD, ...KM }, - ]; - return [ - { key: "item", label: "항목", width: 134 }, - { key: "company", label: "거래처 이름", width: 120 }, - { key: "dest", label: "목적지", width: 72 }, - ...roads, - { key: "total", label: "합계", group: SUM, ...KM, width: 108 }, - { key: "note", label: "비고", width: 160 }, - ]; -} - /** 새 경로(거래처) — 캐시에 넣고 id 를 돌려줌 */ export function addRoute(projectId: string, init: Partial = {}): string { const data = liveMilestone(projectId); @@ -180,17 +152,11 @@ function tableView(projectId: string): MilestoneView { }); const refresh = (): void => { const data = liveMilestone(projectId); - const rows = computeMilestone(data, siteInfo()?.mid_default_m ?? null); + const { options, rows, mode: next } = milestoneTable(data, siteInfo()?.mid_default_m ?? null); // 기준점 생김 · 없앰 = 열 바뀜 → 표 새로 - const next = `${data.bases.length > 0}:${rows.some((r) => r.pv > 0)}`; if (next !== mode) { mode = next; - const fresh = makeTable( - milestoneColumns( - data.bases.length > 0, - rows.some((r) => r.pv > 0), - ), - ); + const fresh = makeTable(options.columns); table.root.replaceWith(fresh.root); table = fresh; } @@ -201,7 +167,7 @@ function tableView(projectId: string): MilestoneView { L2: unpaved, })); none.hidden = rows.length > 0; - table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` }))); + table.setRows(options.rows.map((r) => ({ ...r, dest: `${r.dest} ▾` }))); diagram.refresh(); }; refresh(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index f7ea25721..cc02d0d09 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -15,8 +15,9 @@ import { type MapContextMenuItem, } from "@ui/ui_template_context_menu"; import { createButton, el, showToast } from "@ui/ui_template_elements"; -import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import type { MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { chainOf, ownerOf } from "./B08_Quantity_Milestone_Layout"; +import { DEST_TEXT } from "./B08_Quantity_Milestone_Table"; import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; import { @@ -54,8 +55,6 @@ export function pickAtCell(items: MapContextMenuItem[]): void { menu.open(box.left, box.bottom, items); } -export const DEST_TEXT: Record = { start: "시점", mid: "중점" }; - /** 그 경로를 고쳐 캐시에 */ export function editRoute( projectId: string, diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 4d74c1927..34b70dce8 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -16,50 +16,28 @@ * 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`) * 미연결 시설 = 모달 맨 위 「조합 연결」 → 바로 저장 → 탭 몸통을 새로 그림(154-2) * 커플링 열 = 좌측 패널 1본 길이로 바로 다시 셈(관마다 길이 `pipes` · `recountCouplings` · PLAN 122) + * 자료 읽기 · 표 짜기 = `B08_Quantity_Summary_Table`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ -import { API_BASE_URL } from "@config/config_frontend"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createCard, showToast } from "@ui/ui_template_elements"; -import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; -import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; -import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; +import { createDataTable } from "@ui/ui_template_data_table"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; -import { pipeStructureId } from "@ui/ui_template_item_picker_project"; -import { stationLabel } from "../common_util/common_util_station"; -import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; +import { combosAt, unlinkedAt } from "./B08_Quantity_Summary_Data"; import { - combosAt, - comboId, - linkedOnly, - pipeOwnerId, - summaryColumns, - summaryStations, - type ComboColumn, - type ComboLabel, - type LinkedSnapshot, - type PlacedItem, - type UnlinkedFacility, - unassignedText, - unlinkedAt, - widthOf, -} from "./B08_Quantity_Summary_Data"; -import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc"; + LAMP_CLASS, + loadSummary, + summaryTable, + type SummaryView, +} from "./B08_Quantity_Summary_Table"; import type { SheetsRequest } from "./B08_Quantity_UI_Drawing"; import { footNote } from "./B08_Quantity_UI_Foot"; -import { liveOnePipe, onOptionsChange } from "./B08_Quantity_UI_Options"; +import { onOptionsChange } from "./B08_Quantity_UI_Options"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/** 측점 간격 — 노선 설정값 · 못 읽으면 20m. */ -export async function stationInterval(projectId: string): Promise { - const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId)); - return latest.route_params?.station_interval_m || 20; -} - function note(text: string): HTMLElement { const node = document.createElement("p"); node.className = "ui-general-block__empty"; @@ -67,128 +45,14 @@ function note(text: string): HTMLElement { return node; } -/** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */ -interface PipeColumn { - id: string; - group: string[]; - label: string; - combos: string[]; -} -interface PipeSummary { - columns: PipeColumn[]; - rows: Array<{ chainage_m: number; values: Record }>; - total: Record; - notes: string[]; - /** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */ - types: string[]; - one_length_m: number | null; - pipes: PipeLength[]; - /** 연결된 사본 `{구조물 id: 사본}`(143-1) */ - linked: Record; - /** 미연결 시설(154-1) */ - unlinked: UnlinkedFacility[]; - /** 모든 시설 칸(모달 「조합 연결」 · 156-1) */ - facilities: UnlinkedFacility[]; -} - -async function fetchPipeSummary(projectId: string): Promise { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/quantity/summary`, { - credentials: "include", - }); - if (!response.ok) throw new Error(`${response.status} ${await response.text()}`); - return response.json(); -} - -const PIPE_TOP = "관 공"; -/** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */ -const LAMP_CLASS = "b08-summary__lamp"; -/** 모두 연결 = 같은 램프 초록(247-1) */ -const LINKED_CLASS = `${LAMP_CLASS} is-linked`; -const LAMP_W = 14; -const stationKey = (station: number): number => Math.round(station * 100); -/** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */ -const placesOf = (values: number[]): number => - Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length)); - -/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 98). */ -function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] { - const top = - summary.columns - .filter((column) => column.group[0] === PIPE_TOP) - .flatMap((column) => column.combos) - .map(comboPath) - .find((path) => !!path) ?? PIPE_TOP; - return summary.columns.map((column) => { - const group = column.group[0] === PIPE_TOP ? [top, ...column.group.slice(1)] : column.group; - const values = [ - ...summary.rows.map((row) => row.values[column.id]), - summary.total[column.id], - ].filter((value): value is number => typeof value === "number"); - const decimals = placesOf(values); - const texts = values.map((value) => - value.toLocaleString("en-US", { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, - }), - ); - return { - key: column.id, - label: column.label, - group, - kind: "number", - decimals, - title: [...group, column.label].join(" › "), - width: widthOf(column.label, ...texts), - }; - }); -} - function buildTable( - stations: number[], - columns: ComboColumn[], - pipe: PipeSummary | null, - comboPath: (id: string) => string | null, - interval: number, - unlinked: readonly UnlinkedFacility[], + view: SummaryView, onRowClick: (station: number, label: string) => void, ): HTMLElement { - const values = new Map((pipe?.rows ?? []).map((row) => [stationKey(row.chainage_m), row.values])); - const total: DataRow | null = pipe?.columns.length - ? { station: L("B08_Quantity_Summary_Total"), ...pipe.total } - : null; - const labels = stations.map((station) => stationLabel(station, interval)); - const stationHead = L("B08_Quantity_Summary_Station"); - // 198-1 — 미연결 시설 측점 = 측점 칸 왼쪽 노랑 램프(값 빈칸 · 누르면 바로 연결 · 154) · 호버 = 빈 조합 칸(160-2) - const missing = stations.map((station) => - unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")), - ); - // 247-1 — 시설이 있고 빈 칸 없음 = 초록 램프 - const linked = stations.map((station) => unlinkedAt(pipe?.facilities ?? [], station).length > 0); + const { options, labels, missing } = summaryTable(view); const table = createDataTable({ - columns: [ - { key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W }, - ...columns.map((column) => ({ - key: column.id, - label: column.name, - group: [column.group || L("B08_Quantity_Summary_MissingGroup")], - title: summaryTooltip(column.item), - className: column.missing ? "is-missing" : undefined, - width: widthOf(column.name), - })), - ...(pipe ? pipeColumns(pipe, comboPath) : []), - ], - // 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값 - rows: stations.map((station, i) => ({ - station: labels[i], - ...values.get(stationKey(station)), - })), - total, - onRowClick: (i) => onRowClick(stations[i], labels[i]), - cellClass: (i, key) => - key !== "station" ? null : missing[i] ? LAMP_CLASS : linked[i] ? LINKED_CLASS : null, - frozen: 1, - empty: "", - caption: L("B08_Quantity_Tab_StructureSummary"), + ...options, + onRowClick: (i) => onRowClick(view.stations[i], labels[i]), fillViewport: true, }); table.root.dataset.b08Summary = "table"; @@ -220,59 +84,17 @@ async function openSheets(request: SheetsRequest): Promise { export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note(L("B08_Quantity_Summary_Loading"))); try { - const [structures, pipes, interval, pipe] = await Promise.all([ - fetchStructures(projectId), - // 배수 분석 전이면 관이 없음 — 구조물만으로 그림 - fetchDetailPipePoints(projectId).then( - (response) => response.pipe_points, - () => [], - ), - stationInterval(projectId).catch(() => 20), - // 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로 - fetchPipeSummary(projectId).catch((error: unknown) => String(error)), - ]); - const saved = typeof pipe === "string" ? null : pipe; - // 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1) - const linked = saved?.linked ?? {}; - const paths = new Map( - Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const), - ); - const describe = (item: SummaryItem): ComboLabel | null => { - const [group = "", ...rest] = paths.get(comboId(item)) ?? []; - return group ? { group, name: rest.join(" ") || group } : null; - }; - const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null; - // 관 지점 id = 서버가 낸 id 먼저(응답 측점은 둘째 자리 · id 는 셋째 자리 · 167-1) - const ids = [...Object.keys(linked), ...(saved?.facilities ?? []).map((f) => f.id)]; - const placed = linkedOnly( - pipes.map((point) => ({ - ...point, - structure_id: pipeOwnerId(point.chainage_m, ids) ?? pipeStructureId(point.chainage_m), - })), - linked, - ); - const mine = linkedOnly(structures.structures, linked); - const things: PlacedItem[] = [...mine, ...placed]; - const unlinked = saved?.unlinked ?? []; - const stations = summaryStations([...things, ...unlinked]); - // 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부) - const served = new Set(saved?.types ?? []); - const others = [ - ...mine.filter((item) => !served.has(item.type_id)), - ...placed.filter((point) => !served.has(point.facility ?? "pipe")), - ]; - const columns = summaryColumns(others, describe); - const live = (): PipeSummary | null => - saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m)); - const summary = live(); + const view = await loadSummary(projectId); + const { stations, things, linked, describe, saved } = view; + const summary = view.live(); const parts: HTMLElement[] = []; - if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`)); + if (view.error) parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${view.error}`)); if (summary?.notes.length) parts.push(footNote(summary.notes.join(" · "))); // 236-1 한 줄 - if (!columns.length && !summary?.columns.length) + if (!view.columns.length && !summary?.columns.length) parts.push(note(L("B08_Quantity_Summary_NoCombo"))); const draw = (): HTMLElement => stations.length - ? buildTable(stations, columns, live(), comboPath, interval, unlinked, (station, label) => + ? buildTable(view, (station, label) => openSheets({ projectId, station: label, diff --git a/resources/tester/test_110_b08_bed_slope.py b/resources/tester/test_110_b08_bed_slope.py index 27e57b27d..875c4e906 100644 --- a/resources/tester/test_110_b08_bed_slope.py +++ b/resources/tester/test_110_b08_bed_slope.py @@ -88,7 +88,9 @@ def test_연결값으로_판정이_나옴() -> None: def test_저장된_측점_null_이_연결값을_덮지_않음() -> None: """화면은 저장본 · 초안의 null 을 연결값 칸에서 건너뜀 — 연결값으로 실어야 저장된 측점도 바뀜.""" - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity" / "B08_Quantity_Hydro_Table.ts" + ).read_text(encoding="utf-8") assert "v != null || !(k in linkedOf(station))" in ui assert "...station.linked," in ui diff --git a/resources/tester/test_120_b08_drain_summary.py b/resources/tester/test_120_b08_drain_summary.py index 2979af220..ca621d888 100644 --- a/resources/tester/test_120_b08_drain_summary.py +++ b/resources/tester/test_120_b08_drain_summary.py @@ -270,7 +270,9 @@ def test_열_폭_맨_아래_단_글만_44_100(): def test_화면_조합_열_빼기_폭_머리_줄바꿈(): - ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "served.has(point.facility" in ui and "served.has(item.type_id)" in ui assert "widthOf(column.label, ...texts)" in ui and "widthOf(column.name)" in ui assert "widthOf(stationHead, ...labels)" in ui diff --git a/resources/tester/test_121_b08_material_sheets.py b/resources/tester/test_121_b08_material_sheets.py index 8cbcd0a18..e789ec9d1 100644 --- a/resources/tester/test_121_b08_material_sheets.py +++ b/resources/tester/test_121_b08_material_sheets.py @@ -225,7 +225,9 @@ def test_저장은_고친_칸만_서버가_다시_셈(tmp_path, monkeypatch): def test_화면_연결(): page = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text(encoding="utf-8") assert "renderMaterialSheet(body, projectId, picked)" in page - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity" / "B08_Quantity_Material_Tables.ts" + ).read_text(encoding="utf-8") assert "computeMaterialSheets(" in ui # 조작 중 = 짝 계산 assert 'method: "PUT"' in ui and "setTimeout" not in ui # [저장]만 · 자동 저장 없음 assert "일위대가 재료와 별개" in ui diff --git a/resources/tester/test_122_b08_options.py b/resources/tester/test_122_b08_options.py index 6db3d80ed..e063a0454 100644 --- a/resources/tester/test_122_b08_options.py +++ b/resources/tester/test_122_b08_options.py @@ -169,10 +169,14 @@ def test_좌측_패널_컨테이너_셋과_저장_줄(): def test_표가_옵션_바뀜에_바로_다시_셈(): - ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m))" in ui assert "onOptionsChange(host," in ui - earth = (ROOT / "B08_Quantity/B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + earth = (ROOT / "B08_Quantity/B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Earthwork_Tables.ts" + ).read_text(encoding="utf-8") assert "splitRockSummary(summary, table.totals, soft)" in earth assert "onOptionsChange(host," in earth assert ( diff --git a/resources/tester/test_124_b08_surcharge_panel.py b/resources/tester/test_124_b08_surcharge_panel.py index 52faf1c38..2789ad7b5 100644 --- a/resources/tester/test_124_b08_surcharge_panel.py +++ b/resources/tester/test_124_b08_surcharge_panel.py @@ -123,7 +123,9 @@ def test_범위_밖_모르는_칸은_거절(): def test_화면_연결(): - ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Material_Tables.ts" + ).read_text(encoding="utf-8") assert "liveSurcharge(projectId, payload.surcharge_pct)" in ui assert "onOptionsChange(host," in ui assert 'key === "surcharge" && !rows[keep[i]].panel' in ui and "할증 = 좌측 패널" in ui diff --git a/resources/tester/test_143_1_b08_linked_snapshots.py b/resources/tester/test_143_1_b08_linked_snapshots.py index 6eb698a8f..360925cb0 100644 --- a/resources/tester/test_143_1_b08_linked_snapshots.py +++ b/resources/tester/test_143_1_b08_linked_snapshots.py @@ -184,7 +184,9 @@ def test_미연결_시설_칸_전부_연결_여부(root): def test_화면_조합_이름_구조물도는_사본에서(): - ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "loadPickerRows" not in ui # 층 목록 GET 안 씀 assert "linkedOnly(" in ui assert "unlinkedAt(" in ui and "B08_Quantity_Summary_LampTip" in ui # 154-1 · 198-1 미연결 램프 diff --git a/resources/tester/test_143_3_b08_material_cards.py b/resources/tester/test_143_3_b08_material_cards.py index 5253fcfa4..926c7e346 100644 --- a/resources/tester/test_143_3_b08_material_cards.py +++ b/resources/tester/test_143_3_b08_material_cards.py @@ -7,8 +7,12 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") -SUMMARY = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") +MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Material_Tables.ts" +).read_text(encoding="utf-8") +SUMMARY = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" +).read_text(encoding="utf-8") def test_자재_셋_값_없는_줄_숨김(): diff --git a/resources/tester/test_154_2_b08_direct_link.py b/resources/tester/test_154_2_b08_direct_link.py index 12bd79719..8b9e34f65 100644 --- a/resources/tester/test_154_2_b08_direct_link.py +++ b/resources/tester/test_154_2_b08_direct_link.py @@ -157,5 +157,7 @@ def test_화면_초안이면_막고_고르기_모달_다시_그림(): assert ".link(slot.copy, facility.facility, item)" in ui api = (ROOT / "ui_template/ui_template_item_picker_project.ts").read_text(encoding="utf-8") assert "/link`" in api - page = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + page = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "host.replaceWith(fresh)" in page and "unlinkedAt(unlinked, station)" in page diff --git a/resources/tester/test_160_2_b08_unassigned.py b/resources/tester/test_160_2_b08_unassigned.py index e2e351912..c9fe2b6b1 100644 --- a/resources/tester/test_160_2_b08_unassigned.py +++ b/resources/tester/test_160_2_b08_unassigned.py @@ -60,7 +60,9 @@ def test_빈_조합_칸_글(): def test_화면_연결(): - summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert ( 'unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body"))' in summary ) # 칸 이름 = 모달과 같은 규칙 diff --git a/resources/tester/test_163_b08_modal.py b/resources/tester/test_163_b08_modal.py index 732c7c7a5..bc44600bd 100644 --- a/resources/tester/test_163_b08_modal.py +++ b/resources/tester/test_163_b08_modal.py @@ -11,7 +11,9 @@ ROOT = Path(__file__).resolve().parents[2] LINK = (ROOT / "B08_Quantity/B08_Quantity_UI_Link.ts").read_text(encoding="utf-8") DRAWING = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8") CSS = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.css").read_text(encoding="utf-8") -MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") +MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Material_Tables.ts" +).read_text(encoding="utf-8") LOCALE = (ROOT / "ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8") diff --git a/resources/tester/test_167_1_b08_pipe_owner_id.py b/resources/tester/test_167_1_b08_pipe_owner_id.py index f56bf9186..aaa34ac00 100644 --- a/resources/tester/test_167_1_b08_pipe_owner_id.py +++ b/resources/tester/test_167_1_b08_pipe_owner_id.py @@ -51,6 +51,8 @@ def test_한_칸만_연결_때_조합_하나(): def test_화면_연결(): - summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "...(saved?.facilities ?? []).map((f) => f.id)" in summary assert "pipeOwnerId(point.chainage_m, ids) ?? pipeStructureId(point.chainage_m)" in summary diff --git a/resources/tester/test_198_1_b08_summary_lamp.py b/resources/tester/test_198_1_b08_summary_lamp.py index 2088141f8..1937ba50a 100644 --- a/resources/tester/test_198_1_b08_summary_lamp.py +++ b/resources/tester/test_198_1_b08_summary_lamp.py @@ -13,7 +13,9 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] TS = ROOT / "config" / "node_modules" / "typescript" -UI = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") +UI = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity" / "B08_Quantity_Summary_Table.ts" +).read_text(encoding="utf-8") PRELUDE = f""" const fs = require("fs"); diff --git a/resources/tester/test_201_1_table_level_merge.py b/resources/tester/test_201_1_table_level_merge.py index c2e564442..26b520cf9 100644 --- a/resources/tester/test_201_1_table_level_merge.py +++ b/resources/tester/test_201_1_table_level_merge.py @@ -108,10 +108,10 @@ def test_level_보기_전용_들여쓰기(): #: 적용 표 — [파일, 들어 있어야 할 글] APPLIED = [ - ("B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts", 'mergeSame: ["group", "spec"]'), - ("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["name"]'), - ("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["group"]'), - ("B08_Quantity/B08_Quantity_UI_Material.ts", "mergeSame: built.mergeSame"), + ("B08_Quantity/B08_Quantity_Earthwork_Tables.ts", 'mergeSame: ["group", "spec"]'), + ("B08_Quantity/B08_Quantity_Material_Tables.ts", 'mergeSame: ["name"]'), + ("B08_Quantity/B08_Quantity_Material_Tables.ts", 'mergeSame: ["group"]'), + ("B08_Quantity/B08_Quantity_Material_Tables.ts", "mergeSame: built.mergeSame"), ("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'mergeSame: ["part"]'), ("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'level: blockLevel("name"'), ("B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts", 'level: blockLevel("name"'), diff --git a/resources/tester/test_236_1_hint_one_line.py b/resources/tester/test_236_1_hint_one_line.py index edd8941c1..1e7ba7e56 100644 --- a/resources/tester/test_236_1_hint_one_line.py +++ b/resources/tester/test_236_1_hint_one_line.py @@ -138,7 +138,9 @@ def test_B08_표_아래_안내는_footNote만(): summary = _read(B08 / "B08_Quantity_UI_Summary.ts") assert 'footNote(summary.notes.join(" · "))' in summary # 서버 알림 = 한 줄로 이어 붙임 sheets = _read(B08 / "B08_Quantity_UI_Earthwork_Sheets.ts") - assert "tableCard(haulNote(table), grid.root, averages)" in sheets # 제작 설명 줄 없음 + assert ( + "tableCard(haulNote(table), grid.root, averages.map(note))" in sheets + ) # 제작 설명 줄 없음 # ── 창 끝까지 셈 ──────────────────────────────────────────────────────────── diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py index e462cbf04..ea87c7a73 100644 --- a/resources/tester/test_244_13_b08_milestone_place_leg.py +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -120,7 +120,7 @@ console.log(JSON.stringify([["g", "d", "b1", "start"].map((f) => PAVED[segKind(D def test_표_열_기준점_따라(): got = _node( - [B08 + "UI_Milestone.ts"], + [B08 + "Milestone_Table.ts"], "console.log(JSON.stringify([[false, false], [true, false], [true, true]].map(([b, p]) =>" " milestoneColumns(b, p).map((c) => `${(c.group ?? [''])[0]}|${c.label}|${c.key}`))));", ) diff --git a/resources/tester/test_244_1_b08_milestone.py b/resources/tester/test_244_1_b08_milestone.py index d7013901c..6ee6d9d0f 100644 --- a/resources/tester/test_244_1_b08_milestone.py +++ b/resources/tester/test_244_1_b08_milestone.py @@ -166,7 +166,10 @@ def test_메인_탭_하위_탭_추가_삭제(): assert 'label: "+ 추가"' not in ui and "removeCurrent" not in ui modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") assert "fresh.routes = fresh.routes.filter((r) => r.id !== route!.id);" in modal - assert "editOnDouble: true" in ui and "computeMilestone(data, siteInfo()" in ui + assert "editOnDouble: true" in ui and "milestoneTable(data, siteInfo()" in ui + assert "computeMilestone(data, midDefaultM)" in _read( + "B08_Quantity/B08_Quantity_Milestone_Table.ts" + ) route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") assert "createDataTable" not in route and "uploadImage(projectId, routeId" in route # 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음 diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index cef51aaa2..a7844fee8 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -136,6 +136,7 @@ def test_화면_글_거래처(): modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") assert 'label: "거래처 이름"' in modal page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") - assert '{ key: "company", label: "거래처 이름"' in page and "`거래처 ${" in page + table = _read("B08_Quantity/B08_Quantity_Milestone_Table.ts") + assert '{ key: "company", label: "거래처 이름"' in table and "`거래처 ${" in page for name in UI: assert len(_read(f"B08_Quantity/B08_Quantity_{name}").splitlines()) <= 700 diff --git a/resources/tester/test_61_1_b08_summary_table.py b/resources/tester/test_61_1_b08_summary_table.py index c7454065a..002f80fb0 100644 --- a/resources/tester/test_61_1_b08_summary_table.py +++ b/resources/tester/test_61_1_b08_summary_table.py @@ -94,7 +94,9 @@ def test_측점_조합들(): def test_집계표는_공용_표(): # PLAN 70-2 — 공용 많은 자료 표 · 측점 열 고정 · 칸은 빈칸(「—」 아님) - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity" / "B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "createDataTable(" in ui and "@ui/ui_template_data_table" in ui assert "frozen: 1," in ui and 'empty: ""' in ui and "onRowClick" in ui assert 'document.createElement("table")' not in ui diff --git a/resources/tester/test_69_b08_earthwork_tabs.py b/resources/tester/test_69_b08_earthwork_tabs.py index 8b533cf56..7463fcaca 100644 --- a/resources/tester/test_69_b08_earthwork_tabs.py +++ b/resources/tester/test_69_b08_earthwork_tabs.py @@ -209,7 +209,9 @@ def test_토공집계표_식() -> None: def test_토적표_머리_글자는_사용자_견본대로(): # 「[절토] <토사> · <연암> · 측구파기 <토사> · <암>」 — 값 · 계산은 그대로 - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity" / "B08_Quantity_Earthwork_Tables.ts" + ).read_text(encoding="utf-8") for head in ( '["절토", "연암"]', '["절토", "경암"]', diff --git a/resources/tester/test_98_b08_pipe_summary.py b/resources/tester/test_98_b08_pipe_summary.py index 87884abab..ecc2964a4 100644 --- a/resources/tester/test_98_b08_pipe_summary.py +++ b/resources/tester/test_98_b08_pipe_summary.py @@ -249,7 +249,9 @@ def test_라우터_등록(): def test_화면이_서버_칸을_그림(): """98-4 — 화면은 서버 배관 열 · 칸 · 계만 그림(계산 없음) · 배관 조합 열은 서버 열로 바뀜.""" base = Path(__file__).resolve().parents[2] - ui = (base / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ui = (base / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + base / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "/quantity/summary" in ui and "pipeColumns(pipe, comboPath)" in ui # 배관 · 횡단배수 조합 열 대신 서버 열(120 — 서버가 펼친 종류 `types` 를 뺌) assert '!served.has(point.facility ?? "pipe")' in ui From c7a438138d7f77d3ce21caa7e6d012e943106752 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 08:03:31 +0900 Subject: [PATCH 13/17] =?UTF-8?q?feat(b09-print):=20=EC=9D=B8=EC=87=84=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=88=98=EB=9F=89?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EC=98=81=EC=97=AD=20=E2=80=94=20collectQu?= =?UTF-8?q?antitySheets=20=C2=B7=20B08=20=ED=83=AD=20=EC=B0=A8=EB=A1=80=20?= =?UTF-8?q?PrintSheet(=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=C2=B7=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20=EC=85=8B=20=C2=B7=20=ED=86=A0=EA=B3=B5=20=EB=84=B7?= =?UTF-8?q?=20=C2=B7=20=EC=88=98=EB=A6=AC=20=EC=B8=A1=EC=A0=90=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=C2=B7=20=EA=B1=B0=EB=A6=AC=EC=9D=B4=EC=A0=95?= =?UTF-8?q?=ED=91=9C=20+=20=EC=A7=80=EB=8F=84=20=EA=B7=B8=EB=A6=BC=20data:?= =?UTF-8?q?)=20=C2=B7=20=ED=99=94=EB=A9=B4=EA=B3=BC=20=EA=B0=99=EC=9D=80?= =?UTF-8?q?=20=ED=91=9C=20=EC=A7=9C=EA=B8=B0=20=C2=B7=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=EA=B0=92=20=C2=B7=20=EC=98=81=EC=97=AD=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=20=3D=20=EA=B9=8C=EB=8B=AD=20=ED=95=9C=20=EC=9E=A5=20(302-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- .../draft/B09_Draft_Print_Quantity.ts | 185 ++ .../fixtures/b08_302_3_print_quantity.json | 2268 +++++++++++++++++ .../tester/helper_302_3_print_quantity.mjs | 152 ++ .../helper_302_3_print_quantity_entry.ts | 25 + resources/tester/test_302_3_print_quantity.py | 189 ++ 5 files changed, 2819 insertions(+) create mode 100644 B09_Estimation/draft/B09_Draft_Print_Quantity.ts create mode 100644 resources/tester/fixtures/b08_302_3_print_quantity.json create mode 100644 resources/tester/helper_302_3_print_quantity.mjs create mode 100644 resources/tester/helper_302_3_print_quantity_entry.ts create mode 100644 resources/tester/test_302_3_print_quantity.py diff --git a/B09_Estimation/draft/B09_Draft_Print_Quantity.ts b/B09_Estimation/draft/B09_Draft_Print_Quantity.ts new file mode 100644 index 000000000..96a06d90f --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Quantity.ts @@ -0,0 +1,185 @@ +/* ============================================================================= + * B09_Draft_Print_Quantity.ts + * 인쇄 미리보기(PLAN 302-3 · 초안) 「수량산출」 영역 — B08 탭 표를 화면 없이 PrintSheet 로 모음. + * + * 차례 = B08 탭 차례 — 구조물 집계표 → 자재 셋(총괄 · 콘크리트소요 · 자재수량) → 토공 넷(토공집계표 · + * 토적표 · 평균운반거리 · 종방향 무대운반거리) → 수리집수면적표(배수지점 측점마다 한 장) → 거리이정표 + * 값 = B08 화면과 같은 길(서버 GET + 화면 캐시 · [저장] 전 값이면 화면에 보이는 그 값) · + * 표 짜기 = B08 화면이 쓰는 같은 함수(`B08_Quantity_*_Table(s)`) + * 수리 = 화면 계산서(두 카드)를 구분 · 항목 · 값 표로 · 계산 = 서버(화면과 같은 POST) + * 거리이정표 = 표 + 경로 지도 그림(사용자 그림 우선 · 없으면 서버 캡처 PNG · data: 주소) + * 영역 하나가 못 읽히면 그 자리에 까닭 한 장(나머지는 그대로) + * 구조물 조합 도면은 여기 없음(도면 영역 · 302-2) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DataTableOptions } from "@ui/ui_template_data_table"; +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { stationLabel } from "../../common_util/common_util_station"; +import { + EARTHWORK_SHEETS, + earthworkSheetOptions, + earthworkSummaryOptions, + HAUL_SHEETS, + haulOptions, + loadEarthwork, +} from "../../B08_Quantity/B08_Quantity_Earthwork_Tables"; +import { + hydroCalc, + hydroInputs, + hydroTable, + loadHydro, +} from "../../B08_Quantity/B08_Quantity_Hydro_Table"; +import { + loadMaterials, + materialOptions, + materialTable, + type MaterialSheetId, +} from "../../B08_Quantity/B08_Quantity_Material_Tables"; +import { milestoneTable } from "../../B08_Quantity/B08_Quantity_Milestone_Table"; +import { loadSummary, summaryTable } from "../../B08_Quantity/B08_Quantity_Summary_Table"; +import { + liveMilestone, + loadMilestone, + routeImages, + siteInfo, +} from "../../B08_Quantity/B08_Quantity_UI_Milestone_State"; +import type { CollectSheets, PrintSheet } from "./B09_Draft_Print_Types"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 자재 보조 탭 차례(B08 화면 `SHEETS`) */ +const MATERIAL_SHEETS: Array<[MaterialSheetId, keyof typeof ui_locales]> = [ + ["material", "B08_Quantity_Tab_MaterialTotal"], + ["concrete", "B08_Quantity_Tab_ConcreteMaterial"], + ["quantity", "B08_Quantity_Tab_MaterialQuantity"], +]; + +/** 표 한 장 — 열이 많으면 가로 쪽 */ +const sheet = (id: string, name: string, table: DataTableOptions): PrintSheet => ({ + id, + name, + table, + orientation: table.columns.length > 8 ? "landscape" : "portrait", +}); + +/** 까닭 한 장 — 못 읽은 영역 · 빈 표 자리 */ +const noteSheet = (id: string, name: string, text: string): PrintSheet => + sheet(id, name, { + columns: [{ key: "note", label: "알림", width: 480 }], + rows: [{ note: text }], + }); + +/** 영역 하나 — 실패하면 까닭 한 장 */ +const area = (id: string, name: string, make: () => Promise): Promise => + make().catch((error: unknown) => [ + noteSheet( + id, + name, + `불러오지 못했습니다 — ${error instanceof Error ? error.message : String(error)}`, + ), + ]); + +async function summarySheets(projectId: string): Promise { + const name = L("B08_Quantity_Tab_StructureSummary"); + const view = await loadSummary(projectId); + if (!view.stations.length) + return [noteSheet("summary", name, L("B08_Quantity_Summary_NoStation"))]; + return [sheet("summary", name, summaryTable(view).options)]; +} + +async function materialSheets(projectId: string): Promise { + const { payload, overrides, calc } = await loadMaterials(projectId); + const sheets = calc(payload, overrides); + return MATERIAL_SHEETS.map(([id, label]) => + sheet(`material:${id}`, L(label), materialOptions(materialTable(id, sheets, overrides))), + ); +} + +async function earthworkSheets(projectId: string): Promise { + const { table, interval } = await loadEarthwork(projectId); + return EARTHWORK_SHEETS.map(({ id, label }) => { + const key = `earthwork:${id}`; + if (id === "summary") return sheet(key, label, earthworkSummaryOptions(table)); + if (id === "sheet") + return table.rows.length + ? sheet(key, label, earthworkSheetOptions(table, interval)) + : noteSheet(key, label, "측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요."); + const { groups, caption } = HAUL_SHEETS[id](table); + const { options, averages } = haulOptions(groups, caption); + // 화면 표 아래 평균운반거리 줄 = 쪽엔 본문 끝 줄(번호 칸 글) + return sheet(key, label, { + ...options, + rows: [...options.rows, ...averages.map((no) => ({ no }))], + }); + }); +} + +async function hydroSheets(projectId: string): Promise { + const head = L("B08_Quantity_Tab_Hydrology"); + const { sheet: payload, interval, drafts } = await loadHydro(projectId); + if (!payload.stations.length) + return [ + noteSheet( + "hydro", + head, + "저장된 배수지점(관 지점)이 없습니다 — 배수유역 · 종단에서 관을 저장하세요.", + ), + ]; + return Promise.all( + payload.stations.map(async (station) => { + const inputs = hydroInputs(payload, station, drafts); + const result = await hydroCalc(projectId, inputs).catch(() => null); + const label = stationLabel(station.chainage_m, interval); + return sheet( + `hydro:${station.chainage_m.toFixed(3)}`, + `${head} ${label}${station.kind ? ` ${station.kind}` : ""}`, + hydroTable(label, station.kind, inputs, result, payload), + ); + }), + ); +} + +/** 그림 → data: 주소(서버 PDF 가 쿠키 없이 그림) */ +async function dataUrl(url: string): Promise { + const response = await fetch(url, { credentials: "include" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + const bytes = new Uint8Array(await blob.arrayBuffer()); + let text = ""; + for (let i = 0; i < bytes.length; i += 0x8000) + text += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return `data:${blob.type || "image/png"};base64,${btoa(text)}`; +} + +async function milestoneSheets(projectId: string): Promise { + const name = L("B08_Quantity_Tab_Milestone"); + await loadMilestone(projectId, true); + const data = liveMilestone(projectId); + const { options } = milestoneTable(data, siteInfo()?.mid_default_m ?? null); + const images = routeImages(); + const pictures = await Promise.all( + data.routes + .filter((route) => images[route.id]) + .map(async (route, i) => ({ + src: await dataUrl( + `${API_BASE_URL}/projects/${projectId}/quantity/milestone/image?route=${encodeURIComponent(route.id)}`, + ).catch(() => ""), + caption: route.name.trim() || route.rows[0]?.item || `거래처 ${i + 1}`, + })), + ); + return [{ ...sheet("milestone", name, options), images: pictures.filter((p) => p.src) }]; +} + +export const collectQuantitySheets: CollectSheets = async (projectId) => { + const parts = await Promise.all([ + area("summary", L("B08_Quantity_Tab_StructureSummary"), () => summarySheets(projectId)), + area("material", L("B08_Quantity_Tab_Materials"), () => materialSheets(projectId)), + area("earthwork", L("B08_Quantity_Tab_Earthwork"), () => earthworkSheets(projectId)), + area("hydro", L("B08_Quantity_Tab_Hydrology"), () => hydroSheets(projectId)), + area("milestone", L("B08_Quantity_Tab_Milestone"), () => milestoneSheets(projectId)), + ]); + return parts.flat(); +}; diff --git a/resources/tester/fixtures/b08_302_3_print_quantity.json b/resources/tester/fixtures/b08_302_3_print_quantity.json new file mode 100644 index 000000000..3e14de82f --- /dev/null +++ b/resources/tester/fixtures/b08_302_3_print_quantity.json @@ -0,0 +1,2268 @@ +{ + "_from": "검증_sub8 GET 응답(구조물 집계표만 손 견본) — test_302_3_print_quantity", + "/route/structures": { + "status": "success", + "project_id": "1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a", + "revision": 0, + "structures": [] + }, + "/drainage/pipe-points": { + "pipe_points": [ + { + "chainage_m": 85.05, + "source": "spacing", + "options": { + "pipe_diameter_mm": 1000, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 173.09, + "source": "spacing", + "options": { + "pipe_kind": "파형강관", + "pipe_diameter_mm": "1000", + "ford_width_m": 10, + "wing_in": "있음", + "wing_in_height_m": 1, + "wing_in_length_m": 2, + "wing_in_angle_deg": 45, + "wing_out": "있음", + "wing_out_height_m": 1, + "wing_out_length_m": 2, + "wing_out_angle_deg": 45 + } + }, + { + "chainage_m": 258.12, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 352.14, + "source": "stream", + "options": { + "pipe_kind": "파형강관", + "pipe_diameter_mm": "1000", + "ford_width_m": 10, + "wing_in": "있음", + "wing_in_height_m": 1, + "wing_in_length_m": 2, + "wing_in_angle_deg": 45, + "wing_out": "있음", + "wing_out_height_m": 1, + "wing_out_length_m": 2, + "wing_out_angle_deg": 45 + } + }, + { + "chainage_m": 440.24, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 533.98, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 619.71, + "source": "spacing", + "options": { + "pipe_diameter_mm": 1500, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 720.44, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 804.18, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 899.91, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 982.64, + "source": "spacing", + "options": { + "pipe_diameter_mm": 1500, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + } + ] + }, + "/route/latest": { + "route_params": { + "station_interval_m": 20 + } + }, + "/quantity/summary": { + "columns": [ + { + "id": "pipe:len|800", + "group": [ + "관 공", + "Φ800m/m", + "관매설" + ], + "label": "m", + "combos": [] + }, + { + "id": "pipe:cp|800", + "group": [ + "관 공", + "Φ800m/m", + "커플링" + ], + "label": "ea", + "combos": [] + } + ], + "rows": [ + { + "chainage_m": 20.0, + "values": { + "pipe:len|800": 8, + "pipe:cp|800": 1 + } + }, + { + "chainage_m": 60.0, + "values": { + "pipe:len|800": 12.5, + "pipe:cp|800": 2 + } + } + ], + "total": { + "pipe:len|800": 20.5, + "pipe:cp|800": 3 + }, + "sources": [], + "notes": [ + "견본 알림" + ], + "one_length_m": null, + "types": [ + "pipe" + ], + "pipes": [], + "linked": {}, + "unlinked": [ + { + "id": "pipe@20.000", + "type_id": "pipe", + "name": "횡단배수관", + "facility": "pipe", + "chainage_m": 20.0, + "slots": [ + { + "copy": "pipe@20.000", + "group": null, + "item": null, + "linked": false + } + ] + }, + { + "id": "pipe@100.000", + "type_id": "pipe", + "name": "횡단배수관", + "facility": "pipe", + "chainage_m": 100.0, + "slots": [ + { + "copy": "pipe@100.000", + "group": null, + "item": null, + "linked": false + } + ] + } + ], + "facilities": [ + { + "id": "pipe@20.000", + "type_id": "pipe", + "name": "횡단배수관", + "facility": "pipe", + "chainage_m": 20.0, + "slots": [ + { + "copy": "pipe@20.000", + "group": null, + "item": null, + "linked": false + } + ] + }, + { + "id": "pipe@60.000", + "type_id": "pipe", + "name": "횡단배수관", + "facility": "pipe", + "chainage_m": 60.0, + "slots": [ + { + "copy": "pipe@60.000", + "group": null, + "item": null, + "linked": false + } + ] + }, + { + "id": "pipe@100.000", + "type_id": "pipe", + "name": "횡단배수관", + "facility": "pipe", + "chainage_m": 100.0, + "slots": [ + { + "copy": "pipe@100.000", + "group": null, + "item": null, + "linked": false + } + ] + } + ] + }, + "/quantity/materials": { + "source": { + "columns": [], + "total": {}, + "snapshot": { + "materials": [], + "units": {} + } + }, + "overrides": { + "units": {}, + "mix": {}, + "surcharge": {} + }, + "surcharge_pct": { + "sand": 12, + "gravel": 5, + "cement": 3 + }, + "notes": [ + "조합 사본 없는 시설 11곳 — 노랑 램프 줄을 눌러 조합 연결" + ], + "sheets": { + "quantity": { + "materials": [ + { + "key": "concrete_machine", + "name": "콘크리트", + "spec": "기계", + "unit": "㎥" + }, + { + "key": "mortar_1_3", + "name": "모르터", + "spec": "1:3", + "unit": "㎥" + } + ], + "rows": [], + "total": {}, + "unmatched": [] + }, + "concrete": { + "rows": [ + { + "key": "c25", + "name": "콘크리트", + "spec": "Φ25mm", + "qty": null, + "per": { + "cement": 346.0, + "sand": 0.487, + "gravel": 0.562 + }, + "amounts": { + "cement": null, + "sand": null, + "gravel": null + } + }, + { + "key": "c40", + "name": "콘크리트", + "spec": "Φ40mm", + "qty": null, + "per": { + "cement": 323.0, + "sand": 0.456, + "gravel": 0.612 + }, + "amounts": { + "cement": null, + "sand": null, + "gravel": null + } + }, + { + "key": "c40_plain", + "name": "콘크리트", + "spec": "Φ40mm(무근)", + "qty": null, + "per": { + "cement": null, + "sand": null, + "gravel": null + }, + "amounts": { + "cement": null, + "sand": null, + "gravel": null + } + }, + { + "key": "m12", + "name": "모르터", + "spec": "1:2", + "qty": null, + "per": { + "cement": 680.0, + "sand": 0.98, + "gravel": null + }, + "amounts": { + "cement": null, + "sand": null, + "gravel": null + } + }, + { + "key": "m13", + "name": "모르터", + "spec": "1:3", + "qty": null, + "per": { + "cement": 510.0, + "sand": 1.1, + "gravel": null + }, + "amounts": { + "cement": null, + "sand": null, + "gravel": null + } + } + ], + "total": {} + }, + "material": { + "rows": [ + { + "key": "sand", + "group": "골재", + "spec": "모래", + "unit": "㎥", + "qty": null, + "surcharge": 12.0, + "amount": null, + "places": 0, + "rounding": "up", + "panel": true + }, + { + "key": "gravel", + "group": "골재", + "spec": "자갈", + "unit": "㎥", + "qty": null, + "surcharge": 5.0, + "amount": null, + "places": 0, + "rounding": "up", + "panel": true + }, + { + "key": "rubble", + "group": "골재", + "spec": "막자갈", + "unit": "㎥", + "qty": null, + "surcharge": 4.0, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "mixed_aggregate", + "group": "골재", + "spec": "혼합석", + "unit": "㎥", + "qty": null, + "surcharge": 4.0, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "remicon_25_24_15", + "group": "레미콘", + "spec": "25-24-15", + "unit": "㎥", + "qty": null, + "surcharge": 1.0, + "amount": null, + "places": 2, + "rounding": "round", + "panel": false + }, + { + "key": "remicon_25_21_80", + "group": "레미콘", + "spec": "25-21-80", + "unit": "㎥", + "qty": null, + "surcharge": 2.0, + "amount": null, + "places": 2, + "rounding": "round", + "panel": false + }, + { + "key": "remicon_25_18_80", + "group": "레미콘", + "spec": "25-18-80", + "unit": "㎥", + "qty": null, + "surcharge": 2.0, + "amount": null, + "places": 2, + "rounding": "round", + "panel": false + }, + { + "key": "rebar_d13", + "group": "이형철근", + "spec": "D13mm", + "unit": "ton", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "rebar_d16", + "group": "이형철근", + "spec": "D16mm", + "unit": "ton", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "rebar_d19", + "group": "이형철근", + "spec": "D19mm", + "unit": "ton", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "rebar_d22", + "group": "이형철근", + "spec": "D22mm", + "unit": "ton", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "rebar_d25", + "group": "이형철근", + "spec": "D25mm", + "unit": "ton", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "wire_mesh", + "group": "와이어메쉬", + "spec": "#6, 100×100", + "unit": "㎡", + "qty": null, + "surcharge": null, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "cement", + "group": "시멘트", + "spec": "40kg/대", + "unit": "대", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 0, + "rounding": "round", + "panel": true + }, + { + "key": "sod", + "group": "떼", + "spec": "", + "unit": "㎡", + "qty": null, + "surcharge": 10.0, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "stone_buy_35", + "group": "깬돌", + "spec": "L3=35cm", + "unit": "ton", + "qty": null, + "surcharge": null, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "stone_buy_45", + "group": "깬돌", + "spec": "L3=45cm", + "unit": "ton", + "qty": null, + "surcharge": null, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "revet_block", + "group": "호안블럭", + "spec": "", + "unit": "㎡", + "qty": null, + "surcharge": null, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "flume_400c", + "group": "수로관", + "spec": "400C", + "unit": "본", + "qty": null, + "surcharge": null, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "steel_grating", + "group": "스틸그레이팅", + "spec": "", + "unit": "EA", + "qty": null, + "surcharge": null, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "picked_deduct", + "group": "채집석 공제", + "spec": "돌(채집) · 고임돌 · 막자갈", + "unit": "㎥", + "qty": null, + "surcharge": null, + "amount": null, + "places": 6, + "rounding": "round", + "panel": false + } + ] + } + } + }, + "/quantity/earthwork": { + "method": "average_end_area", + "conversion_factors": { + "soil": { + "loose": 1.25, + "compacted": 0.9 + }, + "ripping_rock": { + "loose": 1.35, + "compacted": 1.1649999999999998 + }, + "blasting_rock": { + "loose": 1.6, + "compacted": 1.1649999999999998 + } + }, + "rows": [ + { + "chainage_m": 0.0, + "distance_m": 0.0, + "cut_soil_area_m2": 0.2467, + "cut_soil_volume_m3": 0.0, + "cut_soil_adjusted_m3": 0.0, + "cut_rock_area_m2": 0.0, + "cut_rock_volume_m3": 0.0, + "cut_rock_adjusted_m3": 0.0, + "ditch_soil_area_m2": 0.1485, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.0, + "ditch_rock_volume_m3": 0.0, + "ditch_rock_adjusted_m3": 0.0, + "adjusted_total_m3": 0.0, + "fill_area_m2": 0.0994, + "fill_volume_m3": 0.0, + "diverted_m3": 0.0, + "balance_m3": 0.0, + "cumulative_m3": 0.0, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 0.3133420016531395, + "area_m2": null + }, + "grading_cut": { + "length_m": 0, + "area_m2": null + }, + "tree_removal_fill": { + "length_m": 0.3133420016531395, + "area_m2": null + }, + "tree_removal_cut": { + "length_m": 0, + "area_m2": null + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": null + }, + "benching": { + "length_m": 0.2585, + "area_m2": null + } + }, + "cut_soft_rock_area_m2": 0.0, + "cut_hard_rock_area_m2": 0.0, + "cut_soft_rock_volume_m3": 0.0, + "cut_hard_rock_volume_m3": 0.0, + "cut_soft_rock_adjusted_m3": 0.0, + "cut_hard_rock_adjusted_m3": 0.0, + "ditch_soft_rock_area_m2": 0.0, + "ditch_hard_rock_area_m2": 0.0, + "ditch_soft_rock_volume_m3": 0.0, + "ditch_hard_rock_volume_m3": 0.0, + "ditch_soft_rock_adjusted_m3": 0.0, + "ditch_hard_rock_adjusted_m3": 0.0 + }, + { + "chainage_m": 20.0, + "distance_m": 20.0, + "cut_soil_area_m2": 1.7856, + "cut_soil_volume_m3": 20.323, + "cut_soil_adjusted_m3": 18.2907, + "cut_rock_area_m2": 1.5424, + "cut_rock_volume_m3": 15.424, + "cut_rock_adjusted_m3": 17.968959999999996, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 1.4849999999999999, + "ditch_soil_adjusted_m3": 1.3365, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 1.4849999999999999, + "ditch_rock_adjusted_m3": 1.7300249999999995, + "adjusted_total_m3": 36.25966, + "fill_area_m2": 6.6947, + "fill_volume_m3": 67.941, + "diverted_m3": 36.25966, + "balance_m3": -31.681340000000006, + "cumulative_m3": -31.681340000000006, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 8.905743700766662, + "area_m2": 92.19085702419802 + }, + "grading_cut": { + "length_m": 4.23533123091777, + "area_m2": 42.353312309177696 + }, + "tree_removal_fill": { + "length_m": 8.905743700766662, + "area_m2": 92.19085702419802 + }, + "tree_removal_cut": { + "length_m": 4.23533123091777, + "area_m2": 42.353312309177696 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": 80.0 + }, + "benching": { + "length_m": 10.6474, + "area_m2": 109.059 + } + }, + "cut_soft_rock_area_m2": 1.38816, + "cut_hard_rock_area_m2": 0.15423999999999993, + "cut_soft_rock_volume_m3": 13.881599999999999, + "cut_hard_rock_volume_m3": 1.5424000000000007, + "cut_soft_rock_adjusted_m3": 16.172063999999995, + "cut_hard_rock_adjusted_m3": 1.7968960000000003, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 1.3364999999999998, + "ditch_hard_rock_volume_m3": 0.14850000000000008, + "ditch_soft_rock_adjusted_m3": 1.5570224999999995, + "ditch_hard_rock_adjusted_m3": 0.17300249999999995 + }, + { + "chainage_m": 40.0, + "distance_m": 20.0, + "cut_soil_area_m2": 2.5631, + "cut_soil_volume_m3": 43.487, + "cut_soil_adjusted_m3": 39.1383, + "cut_rock_area_m2": 2.1052, + "cut_rock_volume_m3": 36.476, + "cut_rock_adjusted_m3": 42.49453999999999, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 2.9699999999999998, + "ditch_rock_adjusted_m3": 3.460049999999999, + "adjusted_total_m3": 81.63283999999999, + "fill_area_m2": 3.1265, + "fill_volume_m3": 98.21200000000002, + "diverted_m3": 81.63283999999999, + "balance_m3": -16.57916000000003, + "cumulative_m3": -48.260500000000036, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 5.531131936823837, + "area_m2": 144.368756375905 + }, + "grading_cut": { + "length_m": 7.171260215611091, + "area_m2": 114.06591446528861 + }, + "tree_removal_fill": { + "length_m": 5.531131936823837, + "area_m2": 144.368756375905 + }, + "tree_removal_cut": { + "length_m": 7.171260215611091, + "area_m2": 114.06591446528861 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": 80.0 + }, + "benching": { + "length_m": 6.2807, + "area_m2": 169.281 + } + }, + "cut_soft_rock_area_m2": 1.89468, + "cut_hard_rock_area_m2": 0.21052000000000004, + "cut_soft_rock_volume_m3": 32.8284, + "cut_hard_rock_volume_m3": 3.647599999999997, + "cut_soft_rock_adjusted_m3": 38.24508599999999, + "cut_hard_rock_adjusted_m3": 4.249454, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 2.6729999999999996, + "ditch_hard_rock_volume_m3": 0.29700000000000015, + "ditch_soft_rock_adjusted_m3": 3.114044999999999, + "ditch_hard_rock_adjusted_m3": 0.3460049999999999 + }, + { + "chainage_m": 60.0, + "distance_m": 20.0, + "cut_soil_area_m2": 1.9285, + "cut_soil_volume_m3": 44.916, + "cut_soil_adjusted_m3": 40.4244, + "cut_rock_area_m2": 2.5755, + "cut_rock_volume_m3": 46.807, + "cut_rock_adjusted_m3": 54.53015499999999, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 2.9699999999999998, + "ditch_rock_adjusted_m3": 3.460049999999999, + "adjusted_total_m3": 94.954555, + "fill_area_m2": 6.1454, + "fill_volume_m3": 92.71900000000001, + "diverted_m3": 92.71900000000001, + "balance_m3": 2.235554999999991, + "cumulative_m3": -46.024945000000045, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 7.089116136811305, + "area_m2": 126.20248073635142 + }, + "grading_cut": { + "length_m": 5.436580202518828, + "area_m2": 126.07840418129918 + }, + "tree_removal_fill": { + "length_m": 7.089116136811305, + "area_m2": 126.20248073635142 + }, + "tree_removal_cut": { + "length_m": 5.436580202518828, + "area_m2": 126.07840418129918 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": 80.0 + }, + "benching": { + "length_m": 7.421, + "area_m2": 137.017 + } + }, + "cut_soft_rock_area_m2": 2.3179499999999997, + "cut_hard_rock_area_m2": 0.25755000000000017, + "cut_soft_rock_volume_m3": 42.1263, + "cut_hard_rock_volume_m3": 4.680700000000002, + "cut_soft_rock_adjusted_m3": 49.077139499999994, + "cut_hard_rock_adjusted_m3": 5.453015499999999, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 2.6729999999999996, + "ditch_hard_rock_volume_m3": 0.29700000000000015, + "ditch_soft_rock_adjusted_m3": 3.114044999999999, + "ditch_hard_rock_adjusted_m3": 0.3460049999999999 + }, + { + "chainage_m": 80.0, + "distance_m": 20.0, + "cut_soil_area_m2": 3.3074, + "cut_soil_volume_m3": 52.359, + "cut_soil_adjusted_m3": 47.1231, + "cut_rock_area_m2": 8.608, + "cut_rock_volume_m3": 111.83500000000001, + "cut_rock_adjusted_m3": 130.28777499999998, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 2.9699999999999998, + "ditch_rock_adjusted_m3": 3.460049999999999, + "adjusted_total_m3": 177.41087499999998, + "fill_area_m2": 9.5931, + "fill_volume_m3": 157.385, + "diverted_m3": 157.385, + "balance_m3": 20.025874999999985, + "cumulative_m3": -25.99907000000006, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 10.41368758963897, + "area_m2": 175.02803726450276 + }, + "grading_cut": { + "length_m": 6.176411853142405, + "area_m2": 116.12992055661232 + }, + "tree_removal_fill": { + "length_m": 10.41368758963897, + "area_m2": 175.02803726450276 + }, + "tree_removal_cut": { + "length_m": 6.176411853142405, + "area_m2": 116.12992055661232 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 6.0, + "area_m2": 100.0 + }, + "benching": { + "length_m": 11.036, + "area_m2": 184.57 + } + }, + "cut_soft_rock_area_m2": 7.7472, + "cut_hard_rock_area_m2": 0.8608000000000002, + "cut_soft_rock_volume_m3": 100.65150000000001, + "cut_hard_rock_volume_m3": 11.183499999999995, + "cut_soft_rock_adjusted_m3": 117.25899749999999, + "cut_hard_rock_adjusted_m3": 13.02877749999999, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 2.6729999999999996, + "ditch_hard_rock_volume_m3": 0.29700000000000015, + "ditch_soft_rock_adjusted_m3": 3.114044999999999, + "ditch_hard_rock_adjusted_m3": 0.3460049999999999 + }, + { + "chainage_m": 85.052, + "distance_m": 5.052000000000007, + "cut_soil_area_m2": 2.3174, + "cut_soil_volume_m3": 14.20824480000002, + "cut_soil_adjusted_m3": 12.787420320000018, + "cut_rock_area_m2": 11.2426, + "cut_rock_volume_m3": 50.14261560000006, + "cut_rock_adjusted_m3": 58.416147174000066, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 0.7502220000000009, + "ditch_rock_adjusted_m3": 0.874008630000001, + "adjusted_total_m3": 71.20356749400008, + "fill_area_m2": 0.711, + "fill_volume_m3": 26.028156600000035, + "diverted_m3": 26.028156600000035, + "balance_m3": 45.17541089400005, + "cumulative_m3": 19.176340893999992, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 0.6508114627140598, + "area_m2": 27.94892460624379 + }, + "grading_cut": { + "length_m": 10.577233048615332, + "area_m2": 42.3197070218401 + }, + "tree_removal_fill": { + "length_m": 0.6508114627140598, + "area_m2": 27.94892460624379 + }, + "tree_removal_cut": { + "length_m": 10.577233048615332, + "area_m2": 42.3197070218401 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 6.0, + "area_m2": 30.31200000000004 + }, + "benching": { + "length_m": 1.7865, + "area_m2": 32.38963500000004 + } + }, + "cut_soft_rock_area_m2": 10.11834, + "cut_hard_rock_area_m2": 1.1242599999999996, + "cut_soft_rock_volume_m3": 45.12835404000006, + "cut_hard_rock_volume_m3": 5.014261560000001, + "cut_soft_rock_adjusted_m3": 52.57453245660006, + "cut_hard_rock_adjusted_m3": 5.841614717400006, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 0.6751998000000009, + "ditch_hard_rock_volume_m3": 0.07502220000000004, + "ditch_soft_rock_adjusted_m3": 0.7866077670000009, + "ditch_hard_rock_adjusted_m3": 0.08740086300000005 + }, + { + "chainage_m": 100.0, + "distance_m": 14.947999999999993, + "cut_soil_area_m2": 1.9296, + "cut_soil_volume_m3": 31.742077999999985, + "cut_soil_adjusted_m3": 28.567870199999987, + "cut_rock_area_m2": 3.8204, + "cut_rock_volume_m3": 112.58086199999994, + "cut_rock_adjusted_m3": 131.15670422999992, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 2.219777999999999, + "ditch_rock_adjusted_m3": 2.5860413699999985, + "adjusted_total_m3": 159.7245744299999, + "fill_area_m2": 6.678, + "fill_volume_m3": 55.22538599999998, + "diverted_m3": 55.22538599999998, + "balance_m3": 104.49918842999992, + "cumulative_m3": 123.67552932399991, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 7.473138706027457, + "area_m2": 60.718403561174064 + }, + "grading_cut": { + "length_m": 3.6186822406822006, + "area_m2": 106.10027087220972 + }, + "tree_removal_fill": { + "length_m": 7.473138706027457, + "area_m2": 60.718403561174064 + }, + "tree_removal_cut": { + "length_m": 3.6186822406822006, + "area_m2": 106.10027087220972 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": 74.73999999999997 + }, + "benching": { + "length_m": 7.3335, + "area_m2": 68.16287999999996 + } + }, + "cut_soft_rock_area_m2": 3.4383599999999994, + "cut_hard_rock_area_m2": 0.3820400000000004, + "cut_soft_rock_volume_m3": 101.32277579999995, + "cut_hard_rock_volume_m3": 11.258086199999994, + "cut_soft_rock_adjusted_m3": 118.04103380699993, + "cut_hard_rock_adjusted_m3": 13.115670422999983, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 1.997800199999999, + "ditch_hard_rock_volume_m3": 0.2219777999999999, + "ditch_soft_rock_adjusted_m3": 2.3274372329999986, + "ditch_hard_rock_adjusted_m3": 0.25860413699999985 + }, + { + "chainage_m": 120.0, + "distance_m": 20.0, + "cut_soil_area_m2": 1.1459, + "cut_soil_volume_m3": 30.755, + "cut_soil_adjusted_m3": 27.6795, + "cut_rock_area_m2": 0.5294, + "cut_rock_volume_m3": 43.498000000000005, + "cut_rock_adjusted_m3": 50.675169999999994, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 2.9699999999999998, + "ditch_rock_adjusted_m3": 3.460049999999999, + "adjusted_total_m3": 78.35467, + "fill_area_m2": 1.7035, + "fill_volume_m3": 83.815, + "diverted_m3": 78.35467, + "balance_m3": -5.460329999999999, + "cumulative_m3": 118.21519932399991, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 4.7441020248562396, + "area_m2": 122.17240730883695 + }, + "grading_cut": { + "length_m": 2.184396822180933, + "area_m2": 58.03079062863134 + }, + "tree_removal_fill": { + "length_m": 4.7441020248562396, + "area_m2": 122.17240730883695 + }, + "tree_removal_cut": { + "length_m": 2.184396822180933, + "area_m2": 58.03079062863134 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": 80.0 + }, + "benching": { + "length_m": 5.5333, + "area_m2": 128.668 + } + }, + "cut_soft_rock_area_m2": 0.47646, + "cut_hard_rock_area_m2": 0.05293999999999999, + "cut_soft_rock_volume_m3": 39.1482, + "cut_hard_rock_volume_m3": 4.349800000000002, + "cut_soft_rock_adjusted_m3": 45.60765299999999, + "cut_hard_rock_adjusted_m3": 5.067517000000002, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 2.6729999999999996, + "ditch_hard_rock_volume_m3": 0.29700000000000015, + "ditch_soft_rock_adjusted_m3": 3.114044999999999, + "ditch_hard_rock_adjusted_m3": 0.3460049999999999 + } + ], + "totals": { + "distance_m": 1078.01, + "cut_soil_volume_m3": 2500.31308435, + "cut_soil_adjusted_m3": 2250.2817759150003, + "cut_rock_volume_m3": 3763.35761765, + "cut_rock_adjusted_m3": 4384.31162456225, + "ditch_soil_volume_m3": 1.9709999999999999, + "ditch_soil_adjusted_m3": 1.7739, + "ditch_rock_volume_m3": 156.7762425, + "ditch_rock_adjusted_m3": 182.64432251249994, + "adjusted_total_m3": 6634.59340047725, + "fill_volume_m3": 19959.30884585, + "diverted_m3": 6217.9639954512495, + "balance_m3": -13324.71544537275, + "cut_ripping_rock_volume_m3": 3763.35761765, + "cut_ripping_rock_adjusted_m3": 4384.31162456225, + "ditch_ripping_rock_volume_m3": 156.7762425, + "cut_blasting_rock_volume_m3": 0, + "cut_blasting_rock_adjusted_m3": 0, + "ditch_blasting_rock_volume_m3": 0, + "grading_fill_area_m2": 16643.147934515546, + "grading_cut_area_m2": 5638.928516414395, + "tree_removal_fill_area_m2": 16643.147934515546, + "tree_removal_cut_area_m2": 5638.928516414395, + "grubbing_area_m2": 0.0, + "road_surface_area_m2": 4976.1622767, + "benching_area_m2": 17827.2799325, + "cut_soft_rock_volume_m3": 3387.021855885, + "cut_hard_rock_volume_m3": 376.3357617649999, + "cut_soft_rock_adjusted_m3": 3945.8804621060244, + "cut_hard_rock_adjusted_m3": 438.43116245622514, + "ditch_soft_rock_volume_m3": 141.09861825, + "ditch_hard_rock_volume_m3": 15.677624250000008, + "ditch_soft_rock_adjusted_m3": 164.37989026124993, + "ditch_hard_rock_adjusted_m3": 18.264432251250014 + }, + "station_count": 8, + "summary": [ + { + "group": "흙깍기", + "kind": "토사", + "spec": "기계(굴삭기)", + "unit": "㎥", + "value": 2500.0, + "note": "" + }, + { + "group": "흙깍기", + "kind": "풍화암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": null, + "note": "" + }, + { + "group": "흙깍기", + "kind": "연암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": 3387.0, + "note": "" + }, + { + "group": "흙깍기", + "kind": "보통암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": null, + "note": "" + }, + { + "group": "흙깍기", + "kind": "경암", + "spec": "발파 + 굴삭기", + "unit": "㎥", + "value": 376.0, + "note": "" + }, + { + "group": "측구 터파기", + "kind": "토사", + "spec": "기계", + "unit": "㎥", + "value": 2.0, + "note": "" + }, + { + "group": "측구 터파기", + "kind": "풍화암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": null, + "note": "" + }, + { + "group": "측구 터파기", + "kind": "연암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": 141.0, + "note": "" + }, + { + "group": "측구 터파기", + "kind": "보통암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": null, + "note": "" + }, + { + "group": "측구 터파기", + "kind": "경암", + "spec": "발파 + 굴삭기", + "unit": "㎥", + "value": 16.0, + "note": "" + }, + { + "group": "보정량계", + "kind": "", + "spec": "", + "unit": "㎥", + "value": 6635.0, + "note": "" + }, + { + "group": "성토", + "kind": "", + "spec": "", + "unit": "㎥", + "value": 19959.0, + "note": "" + }, + { + "group": "유용토 소계", + "kind": "", + "spec": "", + "unit": "㎥", + "value": 6296.98399545125, + "note": "" + }, + { + "group": "횡방향유용토", + "kind": "", + "spec": "백호우", + "unit": "㎥", + "value": 6217.9639954512495, + "note": "유용토" + }, + { + "group": "종방향유용토", + "kind": "", + "spec": "백호우", + "unit": "㎥", + "value": 79.02, + "note": "유용토" + }, + { + "group": "토사운반", + "kind": "", + "spec": "BH+DT+BH", + "unit": "㎥", + "value": 174.18, + "note": "" + }, + { + "group": "토사운반", + "kind": "", + "spec": "19 TON B/D", + "unit": "㎥", + "value": 152.29999999999998, + "note": "" + }, + { + "group": "잔토처리", + "kind": "", + "spec": "백호우", + "unit": "㎥", + "value": -13325.0, + "note": "" + }, + { + "group": "채집석 공제", + "kind": "", + "spec": "돌(채집) · 고임돌 · 막자갈", + "unit": "㎥", + "value": null, + "note": "잔토처리에서 뺌" + }, + { + "group": "층따기", + "kind": "", + "spec": "백호우", + "unit": "㎥", + "value": 17827.2799325, + "note": "" + }, + { + "group": "성토면다짐", + "kind": "", + "spec": "", + "unit": "㎡", + "value": null, + "note": "비율 미정" + }, + { + "group": "표토제거", + "kind": "", + "spec": "", + "unit": "㎡", + "value": null, + "note": "" + }, + { + "group": "초류종자살포", + "kind": "", + "spec": "씨드스프레이", + "unit": "㎡", + "value": null, + "note": "비율 미정" + }, + { + "group": "법면보호공", + "kind": "", + "spec": "", + "unit": "㎡", + "value": null, + "note": "" + }, + { + "group": "지장목제거", + "kind": "", + "spec": "", + "unit": "㎡", + "value": null, + "note": "비율 미정" + }, + { + "group": "운반성토", + "kind": "", + "spec": "BH+DT+BH", + "unit": "㎥", + "value": null, + "note": "" + } + ], + "rock_ratios_pct": { + "연암": 90.0, + "경암": 10.0 + }, + "rock_c": { + "ripping_rock": 1.15, + "blasting_rock": 1.3 + }, + "haul": { + "dump_truck": { + "rows": [ + { + "q": 30.16, + "l": 72.15, + "ql": 2176.0440000000003 + }, + { + "q": 29.89, + "l": 77.68, + "ql": 2321.8552000000004 + }, + { + "q": 2.17, + "l": 60.95, + "ql": 132.2615 + }, + { + "q": 111.96, + "l": 178.37, + "ql": 19970.3052 + } + ], + "q": 174.18, + "ql": 24600.4659, + "average_m": 141.24 + }, + "dozer": { + "rows": [ + { + "q": 17.47, + "l": 41.23, + "ql": 720.2880999999999 + }, + { + "q": 0.26, + "l": 20.44, + "ql": 5.314400000000001 + }, + { + "q": 30.55, + "l": 42.94, + "ql": 1311.817 + }, + { + "q": 24.85, + "l": 30.37, + "ql": 754.6945000000001 + }, + { + "q": 44.3, + "l": 35.01, + "ql": 1550.9429999999998 + }, + { + "q": 33.9, + "l": 41.37, + "ql": 1402.4429999999998 + }, + { + "q": 0.97, + "l": 20.9, + "ql": 20.273 + } + ], + "q": 152.29999999999998, + "ql": 5765.772999999999, + "average_m": 37.86 + }, + "free_haul": { + "rows": [ + { + "q": 1.41, + "l": 10.0, + "ql": 14.1 + }, + { + "q": 5.85, + "l": 10.0, + "ql": 58.5 + }, + { + "q": 7.15, + "l": 12.79, + "ql": 91.4485 + }, + { + "q": 14.44, + "l": 10.23, + "ql": 147.7212 + }, + { + "q": 33.11, + "l": 10.0, + "ql": 331.1 + }, + { + "q": 5.36, + "l": 10.0, + "ql": 53.6 + }, + { + "q": 0.97, + "l": 3.56, + "ql": 3.4532 + }, + { + "q": 10.73, + "l": 10.0, + "ql": 107.30000000000001 + } + ], + "q": 79.02, + "ql": 807.2229000000001, + "average_m": 10.22 + } + }, + "haul_saved": true, + "route_id": 237, + "confirmed": true + }, + "/quantity/hydro": { + "stations": [ + { + "chainage_m": 85.052, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.010717, + "length_m": 163.9, + "height_m": 120.0, + "diameter_mm": 1000.0 + }, + "b05": { + "design_flow_m3s": 1.077, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 173.087, + "facility": "ford_bridge", + "kind": "세월교", + "sheet": "ford", + "linked": { + "area_km2": 0.141145, + "length_m": 603.3, + "height_m": 320.0, + "overflow_width_m": 10.0 + }, + "b05": { + "design_flow_m3s": 14.1846, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 258.123, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.004298, + "length_m": 102.0, + "height_m": 80.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.4319, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 352.14, + "facility": "ford_bridge", + "kind": "세월교", + "sheet": "ford", + "linked": { + "area_km2": 0.232351, + "length_m": 697.9, + "height_m": 335.0, + "overflow_width_m": 10.0 + }, + "b05": { + "design_flow_m3s": 23.3505, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 440.241, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.005078, + "length_m": 180.0, + "height_m": 100.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.5103, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 533.975, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.000164, + "length_m": 15.0, + "height_m": 10.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.0165, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 619.708, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.02902, + "length_m": 268.4, + "height_m": 150.0, + "diameter_mm": 1500.0 + }, + "b05": { + "design_flow_m3s": 2.9164, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 720.442, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.007486, + "length_m": 110.1, + "height_m": 80.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.7523, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 804.176, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.0012, + "length_m": 42.4, + "height_m": 25.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.1206, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 899.91, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.001487, + "length_m": 45.8, + "height_m": 35.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.1494, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 982.644, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.027089, + "length_m": 231.9, + "height_m": 150.0, + "diameter_mm": 1500.0 + }, + "b05": { + "design_flow_m3s": 2.7224, + "tc_minutes": 5.0 + } + } + ], + "defaults": { + "factor": 2.0, + "runoff": 0.8, + "area_km2": null, + "length_m": null, + "height_m": null, + "tc_min_minutes": 5.0, + "r24_mm": null, + "region": "", + "return_period": 100, + "pipe_kind": "파형강관", + "n": null, + "diameter_mm": null, + "slope_denominator": null, + "section": "full" + }, + "factors": [ + 2.0, + 1.5, + 1.2 + ], + "sections": { + "full": "만관", + "arc240": "240° 유효단면", + "eff70": "유효 70 %" + }, + "pipe_kinds": { + "파형강관": 0.024, + "흄관": 0.013, + "콘크리트관": 0.015 + }, + "sheet_defaults": { + "box": { + "n": 0.013, + "depth_ratio": null, + "box_width_m": null, + "box_height_m": null + }, + "ford": { + "n": 0.017, + "overflow_width_m": 10.0, + "slope_denominator": 10.0 + } + }, + "site": { + "region": { + "name": "영양군", + "full": "경상북도 영양군 일월면 용화리", + "source": "전처리 행정구역" + }, + "station": "영양군(도곡리)", + "periods": [ + 2, + 3, + 5, + 10, + 20, + 30, + 50, + 70, + 80, + 100, + 200, + 300, + 500 + ], + "r24": { + "2": 92.73, + "3": 109.55, + "5": 128.54, + "10": 152.78, + "20": 176.45, + "30": 190.25, + "50": 207.69, + "70": 219.24, + "80": 223.85, + "100": 231.56, + "200": 255.74, + "300": 270.05, + "500": 288.27 + } + }, + "saved": {} + }, + "/quantity/milestone": { + "data": { + "site": { + "mid_station_m": null, + "mid_road": "비포장", + "start_pin": null + }, + "bases": [], + "routes": [ + { + "id": "h-equip_heavy", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "equip_heavy", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "중기", + "dest": "start", + "note": "" + } + ] + }, + { + "id": "h-equip_machine", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "equip_machine", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "기계", + "dest": "start", + "note": "" + } + ] + }, + { + "id": "h-sand", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "sand", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "모래", + "dest": "start", + "note": "" + } + ] + }, + { + "id": "h-gravel", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "gravel", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "자갈", + "dest": "start", + "note": "" + } + ] + }, + { + "id": "h-cement", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "cement", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "시멘트", + "dest": "start", + "note": "" + } + ] + }, + { + "id": "h-rebar", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "rebar", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "철근", + "dest": "start", + "note": "" + } + ] + } + ] + }, + "site": { + "length_m": 1078.011, + "mid_default_m": 539.005, + "start_lonlat": [ + 129.0947913, + 36.8126717 + ], + "mid_lonlat": [ + 129.0983925, + 36.8120423 + ] + }, + "table": [ + { + "route": "h-equip_heavy", + "index": 0, + "item": "중기", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + }, + { + "route": "h-equip_machine", + "index": 0, + "item": "기계", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + }, + { + "route": "h-sand", + "index": 0, + "item": "모래", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + }, + { + "route": "h-gravel", + "index": 0, + "item": "자갈", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + }, + { + "route": "h-cement", + "index": 0, + "item": "시멘트", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + }, + { + "route": "h-rebar", + "index": 0, + "item": "철근", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + } + ], + "images": {}, + "auto_images": {}, + "place_images": {}, + "place_times": {} + }, + "/quantity/hydro/calc": { + "area_ha": 1.0717, + "r24_mm": 231.56, + "slope_ground": 0.7321537522879804, + "tc_raw_hr": 0.01857237482923163, + "tc_hr": 0.08333333333333333, + "tc_floored": true, + "intensity": 226.11736447177503, + "flow": 0.5385110655653363, + "design_flow": 1.0770221311306727, + "n": 0.024, + "depth": null, + "section_area": 0.7853981633974483, + "perimeter": 3.141592653589793, + "radius": 0.25, + "slope_bed": null, + "velocity": null, + "capacity": null, + "ok": null, + "ratio_pct": null + } +} \ No newline at end of file diff --git a/resources/tester/helper_302_3_print_quantity.mjs b/resources/tester/helper_302_3_print_quantity.mjs new file mode 100644 index 000000000..9085c3003 --- /dev/null +++ b/resources/tester/helper_302_3_print_quantity.mjs @@ -0,0 +1,152 @@ +// 302-3 인쇄 미리보기 수량산출 — 화면 코드를 node 로 묶어 모으기 결과와 B08 화면 표 짜기를 나란히 냄. +// node helper_302_3_print_quantity.mjs <저장소 뿌리> +// 표준입력 = { project, responses: {"/quantity/…": 응답}, image_b64, drafts: [[이름, 값]], screen } +// (screen = false → 화면 표 짜기 생략 · 응답을 일부러 뺀 실패 견본) +// 표준출력 끝 줄 = { sheets: [모으기 쪽 요약], screen: {B08 화면 표 짜기 요약}, posts: [hydro/calc 본문] } +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const root = path.resolve(process.argv[2] ?? "."); +const { build } = await import( + pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href +); +const input = JSON.parse(fs.readFileSync(0, "utf-8")); + +// 화면 · 세션만 흉내 — 이 길은 DOM 을 안 그림 +class Node { + constructor() { + this.children = []; + this.classList = { toggle() {}, add() {}, remove() {}, contains: () => false }; + this.style = {}; + this.dataset = {}; + } + append(...kids) { + this.children.push(...kids); + } + appendChild(kid) { + this.children.push(kid); + return kid; + } + addEventListener() {} + setAttribute() {} +} +const session = new Map(); +const storage = { + getItem: (k) => (session.has(k) ? session.get(k) : null), + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.document = { + createElement: () => new Node(), + createElementNS: () => new Node(), + createTextNode: () => new Node(), + head: new Node(), + body: new Node(), + addEventListener() {}, + documentElement: new Node(), +}; +globalThis.window = { + sessionStorage: storage, + localStorage: storage, + location: { origin: "http://localhost", hash: "" }, + addEventListener() {}, + setTimeout: (...a) => setTimeout(...a), + clearTimeout: (id) => clearTimeout(id), +}; +globalThis.sessionStorage = storage; +globalThis.localStorage = storage; + +const posts = []; +const png = Buffer.from(input.image_b64, "base64"); +globalThis.fetch = async (url, init = {}) => { + const at = String(url) + .replace(/^.*\/projects\/[^/]+/, "") + .split("?")[0]; + if (at === "/quantity/hydro/calc") posts.push(JSON.parse(init.body)); + if (at === "/quantity/milestone/image") + return new Response(png, { status: 200, headers: { "Content-Type": "image/png" } }); + const body = input.responses[at]; + return body + ? new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + : new Response(JSON.stringify({ detail: `없음 ${at}` }), { status: 404 }); +}; + +/** 표 요약 — 열 머리(묶음 › 이름) · 줄 · 합계 */ +const shape = (t) => ({ + heads: t.columns.map((c) => [...(c.group ?? []), c.label].join("›")), + rows: t.rows, + total: t.total ?? null, + mergeSame: t.mergeSame ?? null, + frozen: t.frozen ?? 0, +}); + +const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-302-3-")); +try { + await build({ + configFile: false, + logLevel: "silent", + root, + resolve: { + alias: { + "@ui": path.join(root, "ui_template"), + "@config": path.join(root, "config"), + "@util": path.join(root, "common_util"), + }, + }, + build: { + outDir, + minify: false, + target: "es2022", + lib: { + entry: path.join(root, "resources/tester/helper_302_3_print_quantity_entry.ts"), + formats: ["es"], + fileName: "entry", + }, + }, + }); + const mod = await import(pathToFileURL(path.join(outDir, "entry.js")).href); + const p = input.project; + for (const [name, value] of input.drafts ?? []) mod.writeState(name, value, p); + + const sheets = (await mod.collectQuantitySheets(p)).map((s) => ({ + id: s.id, + name: s.name, + orientation: s.orientation ?? null, + images: s.images ?? [], + ...shape(s.table), + })); + + if (input.screen === false) { + console.log(JSON.stringify({ sheets, screen: null, posts })); + process.exit(0); + } + // B08 화면이 부르는 같은 함수 — 같은 응답 · 같은 캐시 + const screen = {}; + screen.summary = shape(mod.summaryTable(await mod.loadSummary(p)).options); + const m = await mod.loadMaterials(p); + const ms = m.calc(m.payload, m.overrides); + for (const id of ["material", "concrete", "quantity"]) + screen[`material:${id}`] = shape(mod.materialOptions(mod.materialTable(id, ms, m.overrides))); + const { table, interval } = await mod.loadEarthwork(p); + screen["earthwork:summary"] = shape(mod.earthworkSummaryOptions(table)); + screen["earthwork:sheet"] = shape(mod.earthworkSheetOptions(table, interval)); + for (const id of ["haul", "free"]) { + const { groups, caption } = mod.HAUL_SHEETS[id](table); + const { options, averages } = mod.haulOptions(groups, caption); + screen[`earthwork:${id}`] = { ...shape(options), averages }; + } + screen.earthworkNames = mod.EARTHWORK_SHEETS.map((s) => s.label); + const h = await mod.loadHydro(p); + screen.hydroInputs = h.sheet.stations.map((st) => mod.hydroInputs(h.sheet, st, h.drafts)); + screen.milestone = shape( + mod.milestoneTable(mod.liveMilestone(p), mod.siteInfo()?.mid_default_m ?? null).options, + ); + console.log(JSON.stringify({ sheets, screen, posts })); +} finally { + fs.rmSync(outDir, { recursive: true, force: true }); +} diff --git a/resources/tester/helper_302_3_print_quantity_entry.ts b/resources/tester/helper_302_3_print_quantity_entry.ts new file mode 100644 index 000000000..029e76fa7 --- /dev/null +++ b/resources/tester/helper_302_3_print_quantity_entry.ts @@ -0,0 +1,25 @@ +/* ============================================================================= + * helper_302_3_print_quantity_entry.ts + * 인쇄 미리보기 수량산출 시험(`test_302_3_print_quantity.py`)이 묶는 진입점 — + * 모으기(`collectQuantitySheets`) + B08 화면이 쓰는 같은 자료 읽기 · 표 짜기 함수 + 캐시 쓰기. + * ========================================================================== */ + +export { collectQuantitySheets } from "../../B09_Estimation/draft/B09_Draft_Print_Quantity"; +export { writeState } from "../../A00_Common/b_page_state"; +export { loadSummary, summaryTable } from "../../B08_Quantity/B08_Quantity_Summary_Table"; +export { + loadMaterials, + materialOptions, + materialTable, +} from "../../B08_Quantity/B08_Quantity_Material_Tables"; +export { + EARTHWORK_SHEETS, + earthworkSheetOptions, + earthworkSummaryOptions, + HAUL_SHEETS, + haulOptions, + loadEarthwork, +} from "../../B08_Quantity/B08_Quantity_Earthwork_Tables"; +export { hydroInputs, loadHydro } from "../../B08_Quantity/B08_Quantity_Hydro_Table"; +export { milestoneTable } from "../../B08_Quantity/B08_Quantity_Milestone_Table"; +export { liveMilestone, siteInfo } from "../../B08_Quantity/B08_Quantity_UI_Milestone_State"; diff --git a/resources/tester/test_302_3_print_quantity.py b/resources/tester/test_302_3_print_quantity.py new file mode 100644 index 000000000..4ee176b66 --- /dev/null +++ b/resources/tester/test_302_3_print_quantity.py @@ -0,0 +1,189 @@ +"""302-3 인쇄 미리보기 「수량산출」 — B08 탭 표를 화면 없이 PrintSheet 로 모음. + +견본 = 검증_sub8 서버 GET 응답(구조물 집계표만 손 견본 · `fixtures/b08_302_3_print_quantity.json`). +`helper_302_3_print_quantity.mjs` 가 화면 코드를 node 로 묶어 같은 응답 · 같은 캐시로 +모으기(`collectQuantitySheets`) 와 B08 화면이 부르는 표 짜기를 나란히 냄 → 쪽 차례 · 이름 · 열 머리 · 줄 대조. +""" + +from __future__ import annotations + +import base64 +import copy +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_302_3_print_quantity.mjs") +FIXTURE = json.loads( + (Path(__file__).with_name("fixtures") / "b08_302_3_print_quantity.json").read_text("utf-8") +) +PNG = b"\x89PNG\r\n\x1a\n302-3" +B08 = ROOT / "B08_Quantity" + + +def _run(responses: dict, drafts: list | None = None, screen: bool = True) -> dict: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + job = { + "project": "p302", + "responses": responses, + "image_b64": base64.b64encode(PNG).decode(), + "drafts": drafts or [], + "screen": screen, + } + out = subprocess.run( + ["node", str(HELPER), str(ROOT)], + input=json.dumps(job, ensure_ascii=False), + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + check=True, + ) + return json.loads(out.stdout.strip().splitlines()[-1]) + + +def _responses() -> dict: + data = copy.deepcopy(FIXTURE) + routes = data["/quantity/milestone"]["data"]["routes"] + # 경로 그림 둘 — 사용자 그림 · 서버 캡처(auto) 꼴 + data["/quantity/milestone"]["images"] = { + routes[0]["id"]: "a.png", + routes[2]["id"]: "auto/b.png", + } + return data + + +@pytest.fixture(scope="module") +def base() -> dict: + return _run(_responses()) + + +def _same(sheet: dict, screen: dict) -> None: + for key in ("heads", "rows", "total", "mergeSame", "frozen"): + assert sheet[key] == screen[key], (sheet["id"], key) + + +def test_쪽_차례_이름(base): + stations = FIXTURE["/quantity/hydro"]["stations"] + names = [s["name"] for s in base["sheets"]] + head = ["구조물 집계표", "총괄자재수량집계표", "콘크리트소요자재집계표", "자재수량집계표"] + assert names[:4] == head + assert names[4:8] == base["screen"]["earthworkNames"] == [ + "토공집계표", "토적표", "평균운반거리", "종방향 무대운반거리" + ] # fmt: skip + hydro = names[8:-1] + assert len(hydro) == len(stations) == 11 # 배수지점 측점마다 한 장 + assert all(n.startswith("수리집수면적표 ") for n in hydro) + kinds = [n for n, st in zip(hydro, stations) if st["kind"]] + assert kinds and all(n.endswith(" 세월교") for n in kinds) + assert names[-1] == "거리이정표" + assert len({s["id"] for s in base["sheets"]}) == len(names) + + +def test_표_짜기_B08_화면과_같음(base): + sheets = {s["id"]: s for s in base["sheets"]} + screen = base["screen"] + _same(sheets["summary"], screen["summary"]) + for sid in ("material:material", "material:concrete", "material:quantity", + "earthwork:summary", "earthwork:sheet"): # fmt: skip + _same(sheets[sid], screen[sid]) + # 운반 = 화면 표 + 아래 평균운반거리 줄(번호 칸 글) + for sid in ("earthwork:haul", "earthwork:free"): + want = screen[sid] + assert sheets[sid]["heads"] == want["heads"] and sheets[sid]["total"] == want["total"] + assert sheets[sid]["rows"] == want["rows"] + [{"no": a} for a in want["averages"]] + assert "평균운반거리" in want["averages"][0] + _same(sheets["milestone"], {**screen["milestone"]}) + + +def test_견본_수치(base): + sheets = {s["id"]: s for s in base["sheets"]} + summary = sheets["summary"] + assert summary["heads"] == ["측점", "관 공›Φ800m/m›관매설›m", "관 공›Φ800m/m›커플링›ea"] + assert len(summary["rows"]) == 2 and summary["total"]["pipe:len|800"] == 20.5 + sheet = sheets["earthwork:sheet"] + assert ( + len(sheet["heads"]) == 35 and len(sheet["rows"]) == 8 + ) # 견본 32칸 + 측점 · 거리 · 보정량계 + assert ( + sheet["orientation"] == "landscape" + and sheets["earthwork:summary"]["orientation"] == "portrait" + ) + assert sheets["material:concrete"]["rows"] and len(sheets["material:concrete"]["rows"]) == 5 + hydro = next(s for s in base["sheets"] if s["id"].startswith("hydro:")) + assert hydro["heads"] == ["구분", "항목", "값"] + value = {r["item"]: r["value"] for r in hydro["rows"]} + calc = FIXTURE["/quantity/hydro/calc"] + assert value["Q : 홍수량 (㎥/s)"] == f"{calc['flow']:.2f}" + assert value["Qd : 설계 홍수량 (㎥/s) = Q × 2.0"] == f"{calc['design_flow']:.2f}" + assert value["R24 : 확률강우량 (mm/day)"] == f"{calc['r24_mm']:.1f}" + # 서버 계산 = 화면과 같은 입력(기본 < 장 기본 < 연결 < 저장 < 초안) + assert base["posts"] == base["screen"]["hydroInputs"] + + +def test_거리이정표_지도_그림(base): + milestone = base["sheets"][-1] + routes = FIXTURE["/quantity/milestone"]["data"]["routes"] + assert len(milestone["images"]) == 2 + for image, route in zip(milestone["images"], (routes[0], routes[2])): + head, body = image["src"].split(",", 1) + assert head == "data:image/png;base64" and base64.b64decode(body) == PNG + assert image["caption"] == (route["name"].strip() or route["rows"][0]["item"]) + + +def test_캐시_값이면_화면에_보이는_그_값(): + responses = _responses() + stations = responses["/quantity/hydro"]["stations"] + key = f"{stations[0]['chainage_m']:.3f}" + milestone = copy.deepcopy(responses["/quantity/milestone"]["data"]) + milestone["routes"][0]["name"] = "가나상회" + got = _run(responses, [["b08-hydro", {key: {"area_km2": 0.5}}], ["b08-milestone", milestone]]) + assert got["posts"][0]["area_km2"] == 0.5 == got["screen"]["hydroInputs"][0]["area_km2"] + hydro = next(s for s in got["sheets"] if s["id"] == f"hydro:{key}") + assert any(r["value"].startswith("0.5 (") for r in hydro["rows"]) + sheet = got["sheets"][-1] + assert any(r.get("company") == "가나상회" for r in sheet["rows"]) + _same(sheet, got["screen"]["milestone"]) + + +def test_영역_하나_실패_까닭_한_장(): + responses = _responses() + del responses["/quantity/earthwork"] + got = _run(responses, screen=False) + earth = [s for s in got["sheets"] if s["id"].startswith("earthwork")] + assert len(earth) == 1 and earth[0]["name"] == "토공" + assert earth[0]["rows"][0]["note"].startswith("불러오지 못했습니다") + assert got["sheets"][0]["name"] == "구조물 집계표" and got["sheets"][-1]["name"] == "거리이정표" + + +def test_B08_화면이_같은_함수를_씀(): + read = lambda name: (B08 / f"B08_Quantity_{name}.ts").read_text("utf-8") # noqa: E731 + assert "summaryTable(view)" in read("UI_Summary") and "loadSummary(projectId)" in read( + "UI_Summary" + ) + material = read("UI_Material") + assert "materialOptions(built)" in material and "loadMaterials(projectId)" in material + assert "earthworkSheetOptions(table, interval)" in read("UI_Earthwork") + sheets = read("UI_Earthwork_Sheets") + assert "earthworkSummaryOptions(table)" in sheets and "haulOptions(groups, caption)" in sheets + assert "hydroInputs(sheet, station, drafts)" in read("UI_Hydro") + assert "milestoneTable(data, siteInfo()" in read("UI_Milestone") + # 표 짜기 파일은 화면 없음 + for name in ( + "Summary_Table", + "Material_Tables", + "Earthwork_Tables", + "Hydro_Table", + "Milestone_Table", + ): + text = read(name) + assert "document." not in text and "createDataTable" not in text, name + assert len(text.splitlines()) <= 700 + printer = (ROOT / "B09_Estimation/draft/B09_Draft_Print_Quantity.ts").read_text("utf-8") + assert 'from "./B09_Draft_Print_Types"' in printer and "createDataTable" not in printer From a53b49987847fae86869d7bf7d24996e33bb2b5d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 08:06:32 +0900 Subject: [PATCH 14/17] =?UTF-8?q?fix(b09-print):=20=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=EC=9D=B4=EC=96=B4=20=EB=B6=99=EC=9E=84=20?= =?UTF-8?q?=E2=80=94=20=EC=B4=88=EC=95=88=20router=20=EA=B0=80=20=EC=8A=A4?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20=EB=8B=A8=20/api/projects=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EC=82=AC=EB=B3=B8=20=EA=B8=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=96=BC=EA=B3=A0=20=EB=81=BC=EC=9B=80(=EC=95=9E?= =?UTF-8?q?=EB=A8=B8=EB=A6=AC=20=EB=91=90=20=EB=B2=88=20=EA=B2=B9=EC=B9=A8?= =?UTF-8?q?=20=EC=97=86=EC=95=B0)=20=C2=B7=20=EB=AA=BB=20=EB=84=A3?= =?UTF-8?q?=EC=9D=80=20=EA=B7=B8=EB=A6=BC=20=EC=88=98=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20(302-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- B09_Estimation/B09_Estimation_Router.py | 14 ++++++++++-- B09_Estimation/draft/B09_Draft_Print_Page.ts | 11 +++++++-- resources/tester/test_302_1_print_frame.py | 24 ++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 392b11005..0da23dee3 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -29,6 +29,7 @@ from __future__ import annotations import asyncio +import copy import importlib import logging from pathlib import Path @@ -347,11 +348,20 @@ router.include_router(quotes_router) # 302 초안 길(draft/*_Router.py 의 router · 폴더째 지우면 길도 없음) — 같은 머리 · 같은 권한 def include_drafts(target: APIRouter, folder: Path, package: str) -> list[str]: - """folder 의 *_Router.py 마다 router 를 끼움 → 끼운 파일 이름(못 읽은 초안은 건너뜀).""" + """folder 의 *_Router.py 마다 router 를 끼움 → 끼운 파일 이름(못 읽은 초안은 건너뜀). + 초안이 같은 머리(/api/projects)를 스스로 달았으면 떼고 끼움 — 머리가 두 번 붙지 않게.""" done = [] for path in sorted(folder.glob("*_Router.py")): try: - target.include_router(importlib.import_module(f"{package}.{path.stem}").router) + draft = importlib.import_module(f"{package}.{path.stem}").router + if target.prefix and draft.prefix.startswith(target.prefix): + # 길은 만들 때 머리가 박힘 — 사본 길에서 떼어 냄(원본은 앱에 바로 붙일 때 그대로) + shim, cut = copy.copy(draft), len(target.prefix) + shim.routes = [copy.copy(route) for route in draft.routes] + for route in shim.routes: + route.path = route.path[cut:] + draft = shim + target.include_router(draft) done.append(path.stem) except (ImportError, AttributeError) as error: # 한 초안이 막혀도 B09 는 뜸 logging.getLogger(__name__).warning("초안 길 %s 못 읽음 — %s", path.name, error) diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.ts b/B09_Estimation/draft/B09_Draft_Print_Page.ts index c22a9b6dd..5007b325c 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Page.ts +++ b/B09_Estimation/draft/B09_Draft_Print_Page.ts @@ -49,6 +49,8 @@ interface Area { items: DrawingItem[]; pdf: (ids: string[]) => Promise; xlsx?: (ids: string[]) => Promise; + /** 그 PDF 곁 알림(도면 = 못 넣은 그림 수) */ + note?: (pdf: Blob) => string; } async function post(projectId: string, path: string, body: unknown): Promise { @@ -88,7 +90,12 @@ async function loadArea(projectId: string, id: AreaId): Promise { const list = mod.fetchDrawingList as FetchDrawingList | undefined; const pdf = mod.fetchDrawingsPdf as FetchDrawingsPdf | undefined; if (!list || !pdf) return null; - return { items: await list(projectId), pdf: (ids) => pdf(projectId, ids) }; + const skipped = mod.skippedImagesOf as ((b: Blob) => Record) | undefined; + const note = (b: Blob) => { + const n = Object.values(skipped?.(b) ?? {}).reduce((a, v) => a + v, 0); + return n ? ` · 못 넣은 그림 ${n}개` : ""; + }; + return { items: await list(projectId), pdf: (ids) => pdf(projectId, ids), note }; } const collect = mod[id === "quantity" ? "collectQuantitySheets" : "collectCostSheets"] as CollectSheets | undefined; @@ -160,7 +167,7 @@ export function renderB09Print(root: HTMLElement): MountedPage { if (my !== seq) return; shown!.url = URL.createObjectURL(b); frame.src = shown!.url; - say(`${label()} · ${ids.length}쪽 묶음`); + say(`${label()} · ${ids.length}장 묶음${area?.note?.(b) ?? ""}`); }, (error: unknown) => my === seq && say(`PDF 를 못 만들었습니다 — ${errText(error)}`, true), ); diff --git a/resources/tester/test_302_1_print_frame.py b/resources/tester/test_302_1_print_frame.py index e64df7bb9..a5c4fe0ff 100644 --- a/resources/tester/test_302_1_print_frame.py +++ b/resources/tester/test_302_1_print_frame.py @@ -132,6 +132,21 @@ def test_서버_초안_길_끼우기_폴더_없으면_길_없음(tmp_path, monke got = APIRouter() assert b09.include_drafts(got, pkg / "draft", "fakepkg.draft") == ["X_Router"] assert [r.path for r in got.routes] == ["/x"] + # 초안이 같은 머리를 스스로 달았으면 한 번만(도면 302-2 꼴) + (pkg / "draft/Z_Router.py").write_text( + "from fastapi import APIRouter\nrouter = APIRouter(prefix='/api/projects')\n" + "@router.get('/{project_id}/z')\ndef z():\n return 1\n" + ) + head = APIRouter(prefix="/api/projects") + assert b09.include_drafts(head, pkg / "draft", "fakepkg.draft") == ["X_Router", "Z_Router"] + assert {r.path for r in head.routes} == {"/api/projects/x", "/api/projects/{project_id}/z"} + assert [r.path for r in sys.modules["fakepkg.draft.Z_Router"].router.routes] == [ + "/api/projects/{project_id}/z" + ] # 원본 그대로(앱에 바로 붙이는 시험) + assert ( + "/api/projects/{project_id}/print/drawings-pdf" in paths + or not (DRAFT / "B09_Draft_Print_Drawings_Router.py").exists() + ) def test_로그인_회사_권한은_B09_길과_같음(): @@ -313,3 +328,12 @@ def test_원가_영역_PDF_시트_이름_가로(cost_bodies): text = "".join(p.get_text() for p in doc) assert all(s["name"] in text for s in body["sheets"]) assert all(p.rect.width > p.rect.height for p in doc) + + +def test_앱_전체_길_목록에_겹친_앞머리_없음(): + import main + + paths = [getattr(r, "path", "") for r in main.app.routes] + assert not [p for p in paths if "/api/projects/api/projects" in p] + for tail in ("drawings", "drawings-pdf", "table-pdf", "table-xlsx"): + assert paths.count(f"/api/projects/{{project_id}}/print/{tail}") == 1, tail From cd4eabbf0e0489e18c7ee5fd8f17c73b02d10ca3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 08:07:41 +0900 Subject: [PATCH 15/17] =?UTF-8?q?test(b09-print):=20=EC=88=98=EB=9F=89?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EC=98=81=EC=97=AD=20=EC=9D=B4=EC=9D=8C?= =?UTF-8?q?=EB=A7=A4=20=E2=80=94=20302-3=20=EC=AA=BD=20=E2=86=92=20?= =?UTF-8?q?=EC=9D=B8=EC=87=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EB=AA=B8=20=E2=86=92=20=ED=91=9C=20PDF(=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EA=B7=B8=EB=A6=BC)=20=C2=B7=20=EC=97=91=EC=85=80?= =?UTF-8?q?=20=EC=B0=A8=EB=A1=80=20(302-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- .../print_frame/print_quantity_entry.ts | 8 ++ .../print_frame/print_quantity_seam.mjs | 110 ++++++++++++++++++ resources/tester/test_302_1_print_frame.py | 54 +++++++++ 3 files changed, 172 insertions(+) create mode 100644 resources/tester/print_frame/print_quantity_entry.ts create mode 100644 resources/tester/print_frame/print_quantity_seam.mjs diff --git a/resources/tester/print_frame/print_quantity_entry.ts b/resources/tester/print_frame/print_quantity_entry.ts new file mode 100644 index 000000000..de4ed8c16 --- /dev/null +++ b/resources/tester/print_frame/print_quantity_entry.ts @@ -0,0 +1,8 @@ +/* ============================================================================= + * print_quantity_entry.ts (PLAN 302-1 시험 진입) + * 수량산출 영역(`collectQuantitySheets`) → 인쇄 페이지와 같은 서버 몸(`pdfBody` · `xlsxBody`). + * `print_quantity_seam.mjs` 가 vite 로 묶어 씀. + * ========================================================================== */ + +export { collectQuantitySheets } from "../../../B09_Estimation/draft/B09_Draft_Print_Quantity"; +export { pdfBody, xlsxBody } from "../../../B09_Estimation/draft/B09_Draft_Print_Table"; diff --git a/resources/tester/print_frame/print_quantity_seam.mjs b/resources/tester/print_frame/print_quantity_seam.mjs new file mode 100644 index 000000000..f9f821be4 --- /dev/null +++ b/resources/tester/print_frame/print_quantity_seam.mjs @@ -0,0 +1,110 @@ +// 302-1 수량산출 이음매 — 영역 모으기 → 인쇄 페이지 서버 몸(pdfBody · xlsxBody) 을 node 로. +// node print_quantity_seam.mjs <저장소 뿌리> +// 표준입력 = { project, responses: {"/quantity/…": 응답}, image_b64 } (302-3 시험 자료 꼴) +// 표준출력 끝 줄 = { pdf: {title, sheets}, xlsx: {title, sheets} } +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const root = path.resolve(process.argv[2] ?? "."); +const { build } = await import( + pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href +); +const input = JSON.parse(fs.readFileSync(0, "utf-8")); + +// 화면 · 세션만 흉내 — 이 길은 DOM 을 안 그림 +class Node { + constructor() { + this.children = []; + this.classList = { toggle() {}, add() {}, remove() {}, contains: () => false }; + this.style = {}; + this.dataset = {}; + } + append(...kids) { + this.children.push(...kids); + } + appendChild(kid) { + this.children.push(kid); + return kid; + } + addEventListener() {} + setAttribute() {} +} +const session = new Map(); +const storage = { + getItem: (k) => (session.has(k) ? session.get(k) : null), + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.document = { + createElement: () => new Node(), + createElementNS: () => new Node(), + createTextNode: () => new Node(), + head: new Node(), + body: new Node(), + addEventListener() {}, + documentElement: new Node(), +}; +globalThis.window = { + sessionStorage: storage, + localStorage: storage, + location: { origin: "http://localhost", hash: "" }, + addEventListener() {}, + setTimeout: (...a) => setTimeout(...a), + clearTimeout: (id) => clearTimeout(id), +}; +globalThis.sessionStorage = storage; +globalThis.localStorage = storage; + +const png = Buffer.from(input.image_b64, "base64"); +globalThis.fetch = async (url) => { + const at = String(url) + .replace(/^.*\/projects\/[^/]+/, "") + .split("?")[0]; + if (at === "/quantity/milestone/image") + return new Response(png, { status: 200, headers: { "Content-Type": "image/png" } }); + const body = input.responses[at]; + return body + ? new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + : new Response(JSON.stringify({ detail: `없음 ${at}` }), { status: 404 }); +}; + +const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-302-1-")); +try { + await build({ + configFile: false, + logLevel: "silent", + root, + resolve: { + alias: { + "@ui": path.join(root, "ui_template"), + "@config": path.join(root, "config"), + "@util": path.join(root, "common_util"), + }, + }, + build: { + outDir, + minify: false, + target: "es2022", + lib: { + entry: path.join(root, "resources/tester/print_frame/print_quantity_entry.ts"), + formats: ["es"], + fileName: "entry", + }, + }, + }); + const mod = await import(pathToFileURL(path.join(outDir, "entry.js")).href); + const sheets = await mod.collectQuantitySheets(input.project); + console.log( + JSON.stringify({ + pdf: mod.pdfBody("수량산출", sheets), + xlsx: mod.xlsxBody("수량산출", sheets), + }), + ); +} finally { + fs.rmSync(outDir, { recursive: true, force: true }); +} diff --git a/resources/tester/test_302_1_print_frame.py b/resources/tester/test_302_1_print_frame.py index a5c4fe0ff..a4d81b557 100644 --- a/resources/tester/test_302_1_print_frame.py +++ b/resources/tester/test_302_1_print_frame.py @@ -337,3 +337,57 @@ def test_앱_전체_길_목록에_겹친_앞머리_없음(): assert not [p for p in paths if "/api/projects/api/projects" in p] for tail in ("drawings", "drawings-pdf", "table-pdf", "table-xlsx"): assert paths.count(f"/api/projects/{{project_id}}/print/{tail}") == 1, tail + + +# ── 영역 이음매 — 수량산출(302-3) 쪽 → 페이지와 같은 서버 몸 → 표 PDF · 엑셀 ──────────── + +#: 1×1 PNG — 거리이정표 지도 그림 자리 +TINY_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" # noqa: E501 + + +@pytest.fixture(scope="module") +def quantity_bodies() -> dict: + sys.path.insert(0, str(ROOT / "resources/tester")) + import test_302_3_print_quantity as t302_3 + + if shutil.which("node") is None: + pytest.skip("node 가 없음") + job = {"project": "p302", "responses": t302_3._responses(), "image_b64": TINY_PNG} + out = subprocess.run( + ["node", str(ROOT / "resources/tester/print_frame/print_quantity_seam.mjs"), str(ROOT)], + input=json.dumps(job, ensure_ascii=False), cwd=ROOT, capture_output=True, text=True, + encoding="utf-8", timeout=300, + ) # fmt: skip + assert out.returncode == 0, out.stderr + return json.loads(out.stdout.strip().splitlines()[-1]) + + +def test_수량_영역_엑셀_차례_그대로(quantity_bodies): + from openpyxl import load_workbook + + body = quantity_bodies["xlsx"] + assert len(body["sheets"]) >= 20 + res = _client().post(f"/api/projects/{PID}/print/table-xlsx", json=body) + assert res.status_code == 200, res.text + wb = load_workbook(io.BytesIO(res.content)) + used: set[str] = set() + assert wb.sheetnames == [pr.sheet_title(s["name"], used) for s in body["sheets"]] + assert all( + wb[n].max_row >= s["grid"]["head_rows"] for n, s in zip(wb.sheetnames, body["sheets"]) + ) + + +def test_수량_영역_PDF_시트_이름_지도_그림(quantity_bodies): + pymupdf = pytest.importorskip("pymupdf") + body = quantity_bodies["pdf"] + assert any('src="data:image/png' in s["html"] for s in body["sheets"]) # 그림 = data: 주소 + res = _client().post(f"/api/projects/{PID}/print/table-pdf", json=body) + if res.status_code == 503: + pytest.skip(res.json()["detail"]) + assert res.status_code == 200, res.text + doc = pymupdf.open(stream=res.content) + assert doc.page_count >= len(body["sheets"]) + text = "".join(p.get_text() for p in doc).replace("\n", " ") + missing = [s["name"] for s in body["sheets"] if s["name"] not in text] + assert not missing + assert sum(len(p.get_images()) for p in doc) >= 1 # 거리이정표 지도 그림이 들어감 From 13ba1fe29846ab2e565f3d8cf9582d93340d9609 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 08:10:38 +0900 Subject: [PATCH 16/17] =?UTF-8?q?feat(b09-print):=20B09=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=ED=95=98=EB=8B=A8=20[=EC=9D=B8=EC=87=84=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0]=20=EB=8B=A8=EC=B6=94=20?= =?UTF-8?q?=E2=80=94=20=EC=B4=88=EC=95=88=20=EC=9E=85=EA=B5=AC(B09=5FDraft?= =?UTF-8?q?=5FPrint=5FUI)=EA=B0=80=20DRAFT.actions=20=EC=97=90=20=EB=84=A3?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=98=81=EC=97=AD=20=EC=85=8B=20=EC=9D=B4?= =?UTF-8?q?=EC=9D=8C=20=EB=81=9D=20(302-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- B09_Estimation/draft/B09_Draft_Print_UI.ts | 22 ++++++++++++++++++++++ resources/tester/test_302_1_print_frame.py | 10 ++++++++++ 2 files changed, 32 insertions(+) create mode 100644 B09_Estimation/draft/B09_Draft_Print_UI.ts diff --git a/B09_Estimation/draft/B09_Draft_Print_UI.ts b/B09_Estimation/draft/B09_Draft_Print_UI.ts new file mode 100644 index 000000000..b4c1fc357 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_UI.ts @@ -0,0 +1,22 @@ +/* ============================================================================= + * B09_Draft_Print_UI.ts + * 인쇄 미리보기 등록 입구(PLAN 302-1 · 초안) — B09 셸이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때 + * 좌측 하단 고정 줄(`DRAFT.actions`)에 [인쇄 미리보기] 단추 → #/b09-print(`…_Print_Route`) + * draft 폴더째 지우면 단추 · 길 · 서버 길이 함께 사라짐 + * ========================================================================== */ + +import type { RoutePath } from "@config/config_frontend"; +import { createButton } from "@ui/ui_template_elements"; +import { navigateTo } from "../../A00_Common/router"; +import { DRAFT } from "../B09_Estimation_UI_Tabs"; +import { ROUTE } from "./B09_Draft_Print_Route"; + +DRAFT.actions.push(() => { + const button = createButton({ + label: "인쇄 미리보기", + variant: "ghost", + onClick: () => navigateTo(ROUTE.path as RoutePath), + }); + button.dataset.b09Print = ""; + return button; +}); diff --git a/resources/tester/test_302_1_print_frame.py b/resources/tester/test_302_1_print_frame.py index a4d81b557..d64b69631 100644 --- a/resources/tester/test_302_1_print_frame.py +++ b/resources/tester/test_302_1_print_frame.py @@ -391,3 +391,13 @@ def test_수량_영역_PDF_시트_이름_지도_그림(quantity_bodies): missing = [s["name"] for s in body["sheets"] if s["name"] not in text] assert not missing assert sum(len(p.get_images()) for p in doc) >= 1 # 거리이정표 지도 그림이 들어감 + + +def test_B09_좌측_하단_단추는_초안_입구에서만(): + ui = _text(DRAFT / "B09_Draft_Print_UI.ts") + assert "DRAFT.actions.push(" in ui and "navigateTo(ROUTE.path" in ui + assert 'label: "인쇄 미리보기"' in ui + shell = _text(ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts") + assert 'import.meta.glob("./draft/*_UI.ts", { eager: true })' in shell + dock = _text(ROOT / "B09_Estimation/B09_Estimation_UI_Dock.ts") + assert "DRAFT.actions.map((make) => make())" in dock and "Print" not in dock From d4ad0d6685d0935828c6659f422be835f9f6aabb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 08:24:05 +0900 Subject: [PATCH 17/17] =?UTF-8?q?fix(b09-print):=20=EC=9D=B8=EC=87=84=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=ED=95=98=EB=8B=A8=20=EB=91=90=20=EC=A4=84(=EA=B0=80=EB=A1=9C?= =?UTF-8?q?=20=EB=84=98=EC=B9=A8=20=EC=97=86=EC=9D=8C)=20=C2=B7=20?= =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=8B=9C=ED=8A=B8=20=EC=9D=B8=EC=87=84=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=3D=20PDF=20=EB=B0=A9=ED=96=A5=20=C2=B7=20?= =?UTF-8?q?A4=20=C2=B7=20=ED=8F=AD=201=EC=AA=BD=20=C2=B7=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20=EC=A4=84=20=EB=B0=98=EB=B3=B5=20(302-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- B09_Estimation/draft/B09_Draft_Print_Page.css | 28 +++++++++++++ B09_Estimation/draft/B09_Draft_Print_Page.ts | 12 +++++- .../draft/B09_Draft_Print_Router.py | 20 ++++++++- B09_Estimation/draft/B09_Draft_Print_Table.ts | 6 ++- resources/tester/test_302_1_print_frame.py | 41 ++++++++++++++++++- 5 files changed, 102 insertions(+), 5 deletions(-) diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.css b/B09_Estimation/draft/B09_Draft_Print_Page.css index 0b7a5e242..a9f0bfb1a 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Page.css +++ b/B09_Estimation/draft/B09_Draft_Print_Page.css @@ -50,3 +50,31 @@ font-size: var(--text-body-sm); cursor: pointer; } + +/* 좌측 하단 두 줄(302-5 · B09 Dock 꼴) — 단추는 줄 폭을 나눠 가짐 · 가로 넘침 없음 */ +.b09-print__actions.ui-sidebar-actions { + flex-direction: column; +} + +.b09-print__actions.ui-sidebar-actions > * { + flex: none; +} + +.b09-print__actions .b09-print__row { + display: flex; + gap: var(--btn-group-gap); + min-width: 0; +} + +.ui-sidebar-actions.b09-print__actions .b09-print__row > .ui-btn:not(.ui-help-btn) { + flex: 1 1 0; + min-width: 0; + padding-inline: var(--spacing-4); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.b09-print__actions .b09-print__row > .ui-btn[hidden] { + display: none; +} diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.ts b/B09_Estimation/draft/B09_Draft_Print_Page.ts index 5007b325c..913f74859 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Page.ts +++ b/B09_Estimation/draft/B09_Draft_Print_Page.ts @@ -268,9 +268,17 @@ export function renderB09Print(root: HTMLElement): MountedPage { ); }; + // 두 줄(302-5) — 위 = 내려받기 · 아래 = [?](도움말 줄) · 돌아가기 — 패널 폭 넘침 없음 + const files = document.createElement("div"); + files.className = "b09-print__row"; + files.append(pdfBtn, xlsxBtn); + const nav = document.createElement("div"); + nav.className = "b09-print__row"; + nav.dataset.helpRow = ""; + nav.append(back); const actions = document.createElement("div"); - actions.className = "ui-sidebar-actions"; - actions.append(pdfBtn, xlsxBtn, back); + actions.className = "ui-sidebar-actions b09-print__actions"; + actions.append(files, nav); const left = document.createElement("div"); left.className = "ui-sidebar-panel b09-print__panel"; left.append(areaCard, sheetCard, actions); diff --git a/B09_Estimation/draft/B09_Draft_Print_Router.py b/B09_Estimation/draft/B09_Draft_Print_Router.py index ab7c347f0..dd7841c95 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Router.py +++ b/B09_Estimation/draft/B09_Draft_Print_Router.py @@ -8,7 +8,7 @@ POST /api/projects/{id}/print/table-xlsx {title, sheets:[{name, grid}]} grid(`B09_Draft_Print_Table.sheetGrid`) → 엑셀 — 시트 = 차례대로 · 이름 31자 · 금지 글자 바꿈 · 머리 굵게 · 병합 · 테두리 · 숫자 = 숫자 칸(열 소수 서식) · - 레벨 = 들여쓰기 · 열 폭 + 레벨 = 들여쓰기 · 열 폭 · 인쇄 = 시트 방향 · A4 · 폭 1쪽 · 머리 줄 반복 `B09_Estimation_Router` 가 draft/*_Router.py 의 router 를 끼워 넣음 (같은 머리 · 같은 권한 = 회사 · 프로젝트). @@ -199,6 +199,22 @@ def number_format(decimals: int | None) -> str: return "#,##0" + ("." + "0" * decimals if decimals else "") +def print_setup(ws: Any, orientation: str, head_rows: int) -> None: + """인쇄 설정(302-5) — PDF 와 같은 방향 · A4 · 폭 1쪽 맞춤 · 머리 줄 쪽마다 · 여백 ≈ PDF.""" + from openpyxl.worksheet.page import PageMargins + + ws.page_setup.orientation = orientation + ws.page_setup.paperSize = ws.PAPERSIZE_A4 + ws.page_setup.fitToWidth = 1 + ws.page_setup.fitToHeight = 0 + ws.sheet_properties.pageSetUpPr.fitToPage = True + ws.page_margins = PageMargins(left=0.4, right=0.4, top=0.4, bottom=0.55, header=0.2, footer=0.2) + if head_rows: + ws.print_title_rows = f"1:{head_rows}" + ws.oddHeader.left.text = "&A" # 시트 이름 + ws.oddFooter.center.text = "&P / &N" # 쪽 번호 + + def build_xlsx(sheets: list[dict[str, Any]]) -> bytes: """grid 시트들 → 엑셀 바이트(차례 그대로).""" from openpyxl import Workbook @@ -255,6 +271,7 @@ def build_xlsx(sheets: list[dict[str, Any]]) -> bytes: ws.cell(top + 1 + m["r"], m["c"] + 1).alignment = Alignment(vertical="center") if top: ws.freeze_panes = ws.cell(top + 1, (g.get("frozen") or 0) + 1) + print_setup(ws, sheet.get("orientation") or "portrait", top) out = io.BytesIO() wb.save(out) return out.getvalue() @@ -292,6 +309,7 @@ class PdfSheet(BaseModel): class XlsxSheet(BaseModel): name: str = Field(max_length=200) + orientation: Literal["portrait", "landscape"] = "portrait" grid: dict[str, Any] diff --git a/B09_Estimation/draft/B09_Draft_Print_Table.ts b/B09_Estimation/draft/B09_Draft_Print_Table.ts index 43c3db7c3..0aaa5a710 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Table.ts +++ b/B09_Estimation/draft/B09_Draft_Print_Table.ts @@ -134,7 +134,11 @@ export const pdfBody = (title: string, sheets: PrintSheet[]) => ({ export const xlsxBody = (title: string, sheets: PrintSheet[]) => ({ title, - sheets: sheets.map((s) => ({ name: s.name, grid: sheetGrid(s.table) })), + sheets: sheets.map((s) => ({ + name: s.name, + orientation: s.orientation ?? "portrait", + grid: sheetGrid(s.table), + })), }); /** 그림 주소 → data: 주소(서버 헤드리스는 바깥 요청을 막음 · 로그인 쿠키로 브라우저가 받아 둠) */ diff --git a/resources/tester/test_302_1_print_frame.py b/resources/tester/test_302_1_print_frame.py index d64b69631..5c7b89347 100644 --- a/resources/tester/test_302_1_print_frame.py +++ b/resources/tester/test_302_1_print_frame.py @@ -214,15 +214,30 @@ def test_table_pdf_바깥_요청_막음(): pytest.skip(str(error)) +def _assert_print(wb, orientations: list[str], heads: list[int]) -> None: + """엑셀 인쇄 설정(302-5) — 시트 방향 = PDF · A4 · 폭 1쪽 · 높이 자유 · 머리 줄 반복.""" + assert len(wb.worksheets) == len(orientations) + for ws, orient, head in zip(wb.worksheets, orientations, heads): + ps = ws.page_setup + assert ps.orientation == orient, ws.title + assert int(ps.paperSize) == 9 # A4 + assert (int(ps.fitToWidth), int(ps.fitToHeight)) == (1, 0), ws.title + assert ws.sheet_properties.pageSetUpPr.fitToPage is True + assert ws.print_title_rows.replace("$", "") == f"1:{head}", ws.title + assert ws.page_margins.left < 0.5 and ws.page_margins.bottom < 0.6 + + def test_table_xlsx_시트_차례_이름_머리_병합_숫자_칸(): from openpyxl import load_workbook sheets = _sheets() - body = {"title": "수량", "sheets": [{"name": s["name"], "grid": s["grid"]} for s in sheets]} + keys = ("name", "orientation", "grid") + body = {"title": "수량", "sheets": [{k: s[k] for k in keys} for s in sheets]} body["sheets"].append({"name": "갑 _ 수량_1_", "grid": sheets[0]["grid"]}) # 같은 이름 res = _client().post(f"/api/projects/{PID}/print/table-xlsx", json=body) assert res.status_code == 200, res.text wb = load_workbook(io.BytesIO(res.content)) + _assert_print(wb, [*[s["orientation"] for s in sheets], "portrait"], [2, 2, 2]) names = wb.sheetnames assert names[0] == "갑 _ 수량_1_" # 금지 글자 = _ assert ( @@ -313,6 +328,11 @@ def test_원가_영역_엑셀_18장_차례_그대로(cost_bodies): first = wb[wb.sheetnames[0]] assert first.max_row > body["sheets"][0]["grid"]["head_rows"] # 값 줄이 들어감 assert any(c.data_type == "n" for row in first.iter_rows() for c in row if c.value is not None) + _assert_print( # 엑셀 인쇄 방향 = 그 시트 PDF 방향(302-5) + wb, + [s["orientation"] for s in cost_bodies["pdf"]["sheets"]], + [s["grid"]["head_rows"] for s in body["sheets"]], + ) def test_원가_영역_PDF_시트_이름_가로(cost_bodies): @@ -375,6 +395,11 @@ def test_수량_영역_엑셀_차례_그대로(quantity_bodies): assert all( wb[n].max_row >= s["grid"]["head_rows"] for n, s in zip(wb.sheetnames, body["sheets"]) ) + _assert_print( + wb, + [s["orientation"] for s in quantity_bodies["pdf"]["sheets"]], + [s["grid"]["head_rows"] for s in body["sheets"]], + ) def test_수량_영역_PDF_시트_이름_지도_그림(quantity_bodies): @@ -401,3 +426,17 @@ def test_B09_좌측_하단_단추는_초안_입구에서만(): assert 'import.meta.glob("./draft/*_UI.ts", { eager: true })' in shell dock = _text(ROOT / "B09_Estimation/B09_Estimation_UI_Dock.ts") assert "DRAFT.actions.map((make) => make())" in dock and "Print" not in dock + + +def test_좌측_하단_두_줄_가로_넘침_없음(): + """302-5 — 위 = [PDF][엑셀] · 아래 = [?](도움말 줄) [B09 로 돌아가기] · 줄 폭을 나눠 가짐.""" + page = _text(DRAFT / "B09_Draft_Print_Page.ts") + assert "files.append(pdfBtn, xlsxBtn)" in page + assert "nav.dataset.helpRow" in page and "nav.append(back)" in page + assert "actions.append(files, nav)" in page + css = re.sub(r"\s+", " ", _text(DRAFT / "B09_Draft_Print_Page.css")) + assert ".b09-print__actions.ui-sidebar-actions { flex-direction: column; }" in css + btn = css[css.index(".ui-sidebar-actions.b09-print__actions .b09-print__row > .ui-btn") :] + btn = btn[: btn.index("}")] + # 공용 단추 줄(min-width: auto = 글보다 안 좁아짐)보다 앞서 이기는 규칙 · 글 줄임표 + assert "flex: 1 1 0" in btn and "min-width: 0" in btn and "text-overflow: ellipsis" in btn