From 8a5689c400a8d8a2970777e3cbd52cc178d3c823 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 4 Nov 2026 18:01:06 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(780-2):=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=ED=83=AD=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20=3D=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EC=82=B0=EC=B6=9C=20=EB=AA=A9=EB=A1=9D=20=E2=80=94=20?= =?UTF-8?q?=ED=8C=90=20=EB=B0=94=EA=BE=B8=EA=B8=B0=20=C2=B7=20=EA=B1=B0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EB=AA=A9=EB=A1=9D=20=E2=86=94=20?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=C2=B7=20=EB=91=90=20=EB=B2=8C=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4(=EC=9A=94=EA=B5=AC=20627)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 Hopyo_SheetSk: 탭(역할) 따라 오른쪽 판 바꿈 · 일위대가표 = 호표 목록 그대로 · 단가산출표 · 상세 = 마법사 단가산출 칸 좁은 꼴 한 벌(처음 올 때 지음 · 다른 층 · 권한 없음 = 보기만) · 시트 칸 → 목록 강조 · 목록 줄 → 지금 탭 블록 자리 - WizardSk: 좁은 꼴 옵션 · 부름 칸(일위대가 n 곳 · 일위대가 밖) · 거르기(전체 · 안 · 밖 · 고를 줄 있음) · 같은 캐시 두 벌 맞춤 알림 · 옮김 뒤 HOPYO_BUILT(열린 엑셀 보기 칠 다시) - SkPick: 줄 0 블록 = 「줄 없음」 확인 막음 · 거르기 · 끝내기 글 재료에 일위대가 밖 수 - SheetNav: role · blockAt · spotOf 내보냄 · SheetMap: 판정 없음 글 → 「일위대가 밖」 - Wizard [끝내기]: 막지 않음 · 물음창에 안 옮긴 단가산출 n(일위대가 밖 m) - 시험: 새 test_627_sheet_sk(판 바꿈 · 목록 지음 · 줄 0 막음 · 캐시 공유) · test_778_5 끝내기 재료 꼴에 밖 칸 더함 Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_Hopyo.css | 25 +++ .../M02_MasterTemplete_Hopyo_Sheet.ts | 7 +- .../M02_MasterTemplete_Hopyo_SheetNav.ts | 18 ++ .../M02_MasterTemplete_Hopyo_SheetSk.ts | 68 +++++++ .../M02_MasterTemplete_Hopyo_Wizard.ts | 4 + .../M02_MasterTemplete_Hopyo_WizardSk.ts | 183 ++++++++++++++---- .../M02_MasterTemplete_Hopyo_SheetMap.ts | 4 +- .../logic/M02_MasterTemplete_Hopyo_SkPick.ts | 31 ++- resources/tester/helper_778_5_sk_pick.cjs | 90 ++++++++- resources/tester/test_627_sheet_sk.py | 99 ++++++++++ resources/tester/test_778_5_sk_pick_screen.py | 6 +- ui_template/ui_template_locale_m2.ts | 15 ++ 12 files changed, 503 insertions(+), 47 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts create mode 100644 resources/tester/test_627_sheet_sk.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 1874df552..289d75205 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -284,6 +284,31 @@ min-height: 0; } +/* 단가산출 탭 오른쪽 = 단가산출 목록(780-2) — 판 넓힘 · 숨김이 display 에 안 덮이게 · 강조 줄 */ +.m02-hopyo-sheet.is-sk { + grid-template-columns: minmax(0, 1fr) minmax(22rem, 42%); +} + +.m02-hopyo-sheet__side[hidden] { + display: none; +} + +.m02-hopyo-sheet__sk { + overflow: auto; +} + +.m02-hopyo-sheet__sk tr[data-sk-block] { + cursor: pointer; +} + +.m02-hopyo-sheet__sk tr.is-current > td { + background: var(--color-accent-soft); +} + +.m02-hopyo-sheet__sk tr.is-current > td:first-child { + box-shadow: inset 3px 0 0 var(--color-accent); +} + .m02-hopyo-sheet__list { flex: 1; min-height: 0; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index ce55b4251..15b01750a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -4,7 +4,8 @@ * 제안 n / 줄 m · 숫자 판정 셈 중 · [삭제](좌측 파일 줄 누름으로 바로 엶 · 옛 본 칸 표 몫) · * 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 셋 일위대가표 · 단가산출표 · 단가산출 상세 · * 칠 · 범례 · 뛰기 · 뒤로 = `Hopyo_SheetNav` · 779-4 · 같은 파일 창은 다시 씀 = 앞으로 꺼내 그 자리로 · 779-6) · - * 오른쪽 = 호표 목록(체크 · 제N호표 [수정] · 찾기 · 제안 n/m — 다 연결이면 녹색 · 뒤 셈 중이면 3 초마다 다시) + [저장]. + * 오른쪽 = 호표 목록(체크 · 제N호표 [수정] · 찾기 · 제안 n/m — 다 연결이면 녹색 · 뒤 셈 중이면 3 초마다 다시) + [저장] · + * 단가산출표 · 상세 탭이면 단가산출 목록으로 바뀜(780-2 · `Hopyo_SheetSk`). * · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 머리 행이면 영역 고름 * · [수정] = 그 호표 확인 창(`openHopyoCheck` · 줄 누름과 따로) * · [저장] = 체크한 호표 + 확인 안 된 하위 호표(777-1 · `Hopyo_Kids`)마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 @@ -55,6 +56,7 @@ import { import { bodyPair, kidsText, leftAt, readyFor, unmadeKids } from "./M02_MasterTemplete_Hopyo_Kids"; import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose"; import * as Nav from "./M02_MasterTemplete_Hopyo_SheetNav"; +import { createSheetSk, type SheetSk } from "./M02_MasterTemplete_Hopyo_SheetSk"; import { openRelink, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink"; import { leftButton } from "./M02_MasterTemplete_Hopyo_Left"; import { formatPrice } from "../common_util/common_util_money"; @@ -146,6 +148,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { if (opened.has(id)) return opened.get(id)!(opt); let sheet: SpreadsheetHandle | null = null; let nav: Nav.SheetNav | null = null; + let sk: SheetSk | null = null; // 단가산출 탭 오른쪽 판(780-2) let detail = ""; // 단가산출 상세 시트 id let above: Element[] = []; // 앞으로 꺼냈을 때 원래 위에 있던 창 let closed = false; @@ -369,12 +372,14 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { const more = { ...opt, target, grid, last: lastOf, sheet: () => sheet, host: left }; nav = ids.일위대가표 ? Nav.createSheetNav(more) : null; if (nav) warn.after(nav.root); + sk = nav ? createSheetSk({ ...opt, target, nav, side: right }) : null; sheet = createSpreadsheet(left, book, { readOnly: true, 칠: nav?.fill, onSelect: (s) => { at = s; nav?.at(s); + sk?.at(s); if (s.시트 !== ilwi) return; const no = hopyoAtRow(spans, Number(/\d+/.exec(s.칸)?.[0])); if (no !== null && no !== current) mark(no); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetNav.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetNav.ts index 45fa5c4b3..784edfa06 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetNav.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetNav.ts @@ -10,6 +10,7 @@ * · 779-6 — 마지막 격자 다시 씀(`gridOf`) · 열린 창 앞으로 꺼냄 · 닫기 = 제자리로(`raise` · `lower`) * · 778-5 D-6 — 머리 막대 아래 띠(부품 `createBanner`) 「엑셀 셈 어긋남 n」 + [보기 i/m] = 상세 시트 그 행으로 차례로 뛰기 · * 그 행 까닭은 막대 글에 덧붙임 · 칠은 안 바꿈 + * · 780-2 — `role(시트)` · `blockAt(시트, 행)`(단가산출표 목록 행 · 상세 블록 범위) · `spotOf(번, 역할)` 내보냄(오른쪽 단가산출 목록) * 블록 재료 = 서버 `GET …/hopyo-marks/{키}`(779-2 · 읽기만). * ========================================================================== */ @@ -32,6 +33,7 @@ import { indexOf, jumpsAt, missesOf, + numAt, paintOf, ROLES, type BlockMark, @@ -40,6 +42,7 @@ import { type Jump, type Mark, type Role, + type Spot, } from "./logic/M02_MasterTemplete_Hopyo_SheetMap"; /** 확인 창 캐시를 고쳤음(document 알림 · `Hopyo_Check.saveDraft` 가 같은 글로 냄) — 엑셀 보기 창 다시 칠 */ @@ -265,6 +268,21 @@ export function createSheetNav(opt: SheetNavOptions) { void readBlocks(); }, jumpTo, + /** 시트 id → 역할(세 보기 밖 = undefined) */ + role: (sheet: string): Role | undefined => roleOf.get(sheet), + /** 그 시트 · 행의 단산 블록 번 — 단가산출표 = 목록 행 · 상세 = 블록 범위 · 아니면 null */ + blockAt(sheet: string, row: number): number | null { + if (sheet === ix.ids.단가산출표) return ix.listAt.get(row) ?? null; + if (sheet === ix.ids.단가산출상세) return numAt(ix.block, row); + return null; + }, + /** 그 블록 자리 — 단가산출표 = 목록 행 · 상세 = 블록 범위 · 없으면 null */ + spotOf(no: number, role: Role): Spot | null { + const sheet = ix.ids[role]; + const at = role === "단가산출표" ? ix.listRow.get(no) : undefined; + const rows: [number, number] | undefined = at ? [at, at] : role === "단가산출상세" ? ix.block.get(no) : undefined; // prettier-ignore + return sheet && rows ? { 시트: sheet, 행: rows } : null; + }, destroy(): void { document.removeEventListener(HOPYO_DRAFT, onDraft); document.removeEventListener(HOPYO_BUILT, onBuilt); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts new file mode 100644 index 000000000..63c928cd1 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts @@ -0,0 +1,68 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_SheetSk.ts + * 엑셀 보기 창 오른쪽 판 바꾸기(PLAN 780-2 · 요구 627) — 탭 따라: + * · 일위대가표 = 호표 목록(`Hopyo_Sheet` 판 그대로) · 단가산출표 · 단가산출 상세 = 단가산출 목록 + * (마법사 단가산출 칸 `sangeunStep` 좁은 꼴 한 벌 · 같은 캐시 · 같은 [확인] → [확인한 것 옮기기] · 마법사 체크와 상관없음) + * · 목록 블록 줄 누름 → 지금 탭의 그 블록 자리(단가산출표 = 목록 행 · 상세 = 블록 범위 · [◂ 뒤로] 쌓임) + * · 시트 칸 고름 → 그 행 블록을 목록에서 강조(`SheetNav.blockAt`) + * · 단가산출 칸은 처음 단가산출 탭에 올 때 지음(판정 셈이 무거움) · 만들 층 ≠ 파일 층 · 쓰기 권한 없음 = 보기만 + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; +import type { SheetNav } from "./M02_MasterTemplete_Hopyo_SheetNav"; +import { sangeunStep, type SkStep } from "./M02_MasterTemplete_Hopyo_WizardSk"; + +export interface SheetSkOptions { + scope: HopyoScope; + key: string; + 파일: string; + target: HopyoScope; + canWrite: boolean; + nav: SheetNav; + /** 호표 목록 판 — 단가산출 탭이면 숨김 · 그 뒤에 단가산출 판을 붙임 */ + side: HTMLElement; +} + +const sameLayer = (a: HopyoScope, b: HopyoScope): boolean => + a.layer === b.layer && (a.owner ?? null) === (b.owner ?? null); + +export function createSheetSk(opt: SheetSkOptions) { + const root = el("div", { className: "m02-hopyo-sheet__side m02-hopyo-sheet__sk" }); + root.setAttribute("data-hopyo-sk-side", ""); + root.hidden = true; + opt.side.after(root); + let step: SkStep | null = null; + let sheet = ""; + + /** 블록 줄 누름 — 지금 탭에서 그 블록 자리 · 없으면 다른 단가산출 탭 */ + function onRow(no: number): void { + const list = opt.nav.role(sheet) === "단가산출표"; + const spot = list + ? (opt.nav.spotOf(no, "단가산출표") ?? opt.nav.spotOf(no, "단가산출상세")) + : (opt.nav.spotOf(no, "단가산출상세") ?? opt.nav.spotOf(no, "단가산출표")); + if (spot) opt.nav.jumpTo(spot.시트, spot.행); + } + + return { + root, + /** 시트 고름 · 탭 바꿈 — 판 바꾸기 · 강조 */ + at(sel: { 시트: string; 칸: string }): void { + sheet = sel.시트; + const role = opt.nav.role(sel.시트); + const sk = role === "단가산출표" || role === "단가산출상세"; + opt.side.hidden = sk; + root.hidden = !sk; + opt.side.parentElement?.classList.toggle("is-sk", sk); + if (!sk) return; + if (!step) { + const readOnly = !opt.canWrite || !sameLayer(opt.scope, opt.target); + step = sangeunStep(opt.scope, opt.key, opt.파일, undefined, { narrow: true, onRow, readOnly }); // prettier-ignore + root.append(step.root); + } + step.mark(opt.nav.blockAt(sel.시트, Number(/\d+/.exec(sel.칸)?.[0] ?? 0))); + }, + }; +} + +export type SheetSk = ReturnType; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts index 0885832b0..ee2de1a31 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts @@ -288,9 +288,13 @@ export function buildHopyoWizard(): HopyoWizard { const loose = [...got.values()].filter( (x) => !x.one.호표.만든?.확인 && hasDraft(draftKey(scope, file.키, x.one.호표.번)), ).length; + const w = sk?.count(); // 안 옮긴 단가산출(일위대가 밖 포함) — 묻기만 const ok = await showConfirmDialog( `「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출(출처만 남김)과 별칭 장부는 남음 · 같은 엑셀은 필요하면 다시 올림` + (loose ? ` · ${L("M02_LeftLoose").replace("{n}", String(loose))}` : ""), + w?.블록 + ? ` · ${L("M02_SkUnmoved").replace("{n}", String(w.블록)).replace("{m}", String(w.밖))}` + : "", // 780-2 막지 않음 "끝내기", ); if (!ok) return steps.set("2"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts index 6b44b044a..af7986578 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts @@ -7,7 +7,10 @@ * · 고름 · 확인 = 캐시 `m02-hopyo-sk:{층}:{주인}:{키}`(`SkPick` 꼴) — 누를 때 서버에 안 감 · 자동 저장 없음 * · [확인한 것 옮기기 (n)] = 몸 `{확인: [{번, 고름}]}` → 서버가 M01 지금 값으로 다시 셈 · 단위 SK(확인 안 한 블록 안 만듦) · * 만든 블록은 확인에서 뺌 · 못만듦 = 띠(까닭 그대로) · 「모두 확인」 단추 없음 - * · `count()` = 아직 안 옮긴 블록 · 고를 줄(마법사 [끝내기] 막힘 글) + * · `count()` = 아직 안 옮긴 블록 · 그중 일위대가 밖 · 고를 줄(마법사 [끝내기] 글) + * · 780-2(요구 627) — 부름 칸(「일위대가 n 곳」 · 「일위대가 밖」) · 거르기(전체 · 안 · 밖 · 고를 줄 있음) · + * 좁은 꼴(`narrow` = 엑셀 보기 오른쪽 · 칸 여섯 · 블록 줄 누름 = `onRow` · `mark(번)` 강조) · `readOnly` = 옮기기 숨김 · + * 두 벌 맞춤 = 알림 `SK_SYNC`(같은 캐시 열쇠 · 고름 바뀜 → 다시 그림 · 옮김 → 받은 판정 그대로) · 옮김 뒤 `HOPYO_BUILT` * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; @@ -17,6 +20,8 @@ import { t as L } from "@ui/ui_template_locale"; import { tableRow, tableShell, type Column } from "@ui/ui_template_table"; import { createText } from "@ui/ui_template_text"; import { createToggle } from "@ui/ui_template_toggle"; +import { createSegment } from "@ui/ui_template_segment"; +import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check"; import { formatPrice } from "../common_util/common_util_money"; import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; import { judgeSangeun } from "./M02_MasterTemplete_Hopyo_SkApi"; @@ -24,6 +29,8 @@ import { skLineTable } from "./M02_MasterTemplete_Hopyo_SkLines"; import { GOOD, canConfirm, + filterBlocks, + outside, picksOf, readDraft, setPick, @@ -34,6 +41,7 @@ import { waiting, type SkBlock, type SkDraft, + type SkFilter, type SkJudged, } from "./logic/M02_MasterTemplete_Hopyo_SkPick"; @@ -46,26 +54,63 @@ export const skDraftKey = (scope: HopyoScope, key: string): string => export interface SkStep { root: HTMLElement; - /** 아직 안 옮긴 블록 · 고를 줄 — 판정 전 null */ - count: () => { 블록: number; 줄: number } | null; + /** 아직 안 옮긴 블록 · 그중 일위대가 밖 · 고를 줄 — 판정 전 null */ + count: () => { 블록: number; 밖: number; 줄: number } | null; /** 그 블록 펼치고 화면에 보임(안 이은 줄 창 [단가산출 걸음에서 확인]) */ show: (번: number) => void; + /** 그 블록 줄 강조 · 보이게(펼치지 않음 · 거른 줄이면 거르기 풂) — null = 강조 지움 */ + mark: (번: number | null) => void; } +export interface SkStepOptions { + /** 좁은 꼴(엑셀 보기 오른쪽) — 칸 여섯 · 금액은 줄 title */ + narrow?: boolean; + /** 블록 줄 누름(단추 밖) */ + onRow?: (번: number) => void; + /** 옮기기 숨김(만들 층 ≠ 파일 층 · 쓰기 권한 없음) */ + readOnly?: boolean; +} + +/** 두 벌(마법사 칸 · 엑셀 보기 오른쪽) 맞춤 알림 — detail `{dk, from, got?}` */ +export const SK_SYNC = "m02-hopyo-sk"; +type SyncDetail = { dk: string; from: object; got?: SkJudged }; + /** ② 단가산출 칸 — `파일` = 엑셀 보기 창 머리 · `onChange` = 판정 · 옮김 뒤(끝내기 글) */ export function sangeunStep( scope: HopyoScope, key: string, 파일: string, onChange?: () => void, + o: SkStepOptions = {}, ): SkStep { const dk = skDraftKey(scope, key); + const me = {}; const load = (): SkDraft => readDraft(sessionStorage.getItem(dk)); const save = (d: SkDraft): void => sessionStorage.setItem(dk, JSON.stringify(d)); + const tell = (more: Partial = {}): void => + void document.dispatchEvent(new CustomEvent(SK_SYNC, { detail: { dk, from: me, ...more } })); // prettier-ignore let got: SkJudged | null = null; let fails: SkJudged["못만듦"] = []; + let current: number | null = null; // 강조 블록 + let filter: SkFilter = "all"; const open = new Set(); // 펼친 블록 const out = el("div", { className: "m02-hopyo", attrs: { "data-wiz-sk": "" } }); + const seg = createSegment({ + options: [ + { value: "all", label: L("M02_SkFilterAll") }, + { value: "in", label: L("M02_SkFilterIn") }, + { value: "out", label: L("M02_SkFilterOut") }, + { value: "open", label: L("M02_SkFilterOpen") }, + ], + value: filter, + size: 24, + ariaLabel: L("M02_SkFilter"), + onChange: (v) => { + filter = v as SkFilter; + draw(); + }, + }); + seg.root.dataset.skFilter = ""; const again = createButton({ label: L("M02_SkJudge"), variant: "ghost", size: 28, onClick: () => void judge() }); // prettier-ignore again.dataset.skJudge = ""; const move = createButton({ label: L("M02_SkMove").replace("{n}", "0"), variant: "filled", size: 28, disabled: true, onClick: () => void run() }); // prettier-ignore @@ -74,11 +119,24 @@ export function sangeunStep( const root = el("div", { className: "m02-hopyo", children: [ - createText(L("M02_SkHint"), "hint", 14), + ...(o.narrow ? [] : [createText(L("M02_SkHint"), "hint", 14)]), + seg.root, out, - el("div", { className: "ui-toolbar", children: [move, again] }), + el("div", { className: "ui-toolbar", children: o.readOnly ? [again] : [move, again] }), ], }); + /** 다른 벌이 고름 · 옮김을 알림 — 떨어진 벌은 듣기 그침 */ + const onSync = (e: Event): void => { + const x = (e as CustomEvent).detail; + if (!root.isConnected) return document.removeEventListener(SK_SYNC, onSync); + if (x.dk !== dk || x.from === me || !got) return; + if (x.got) { + got = x.got; + fails = []; + } + draw(); + }; + document.addEventListener(SK_SYNC, onSync); const sheet = (행: number): void => void import("./M02_MasterTemplete_Hopyo_Sheet") @@ -114,6 +172,8 @@ export function sangeunStep( save({ ...d, 확인: d.확인.filter((n) => !made.has(n)) }); got = next; fails = next.못만듦; + tell({ got: next }); + if (made.size) document.dispatchEvent(new Event(HOPYO_BUILT)); // 열린 엑셀 보기 칠 · 좌측 나무 다시 showToast( L("M02_SkMoved") .replace("{made}", String(made.size)) @@ -129,18 +189,39 @@ export function sangeunStep( } } - const cols: Column[] = [ - { label: L("M02_SkColBlock"), width: "72px" }, - { label: L("M02_SangeunColName") }, - { label: L("M02_SangeunColUnit"), width: "56px" }, - { label: L("M02_SkColVerdict") }, - { label: L("M02_SkColOurs"), align: "end" }, - { label: L("M02_SkColExcel"), align: "end" }, - { label: L("M02_SkColBasis") }, - { label: L("M02_SkColMade"), width: "110px" }, - { label: L("M02_SkSure"), width: "72px" }, - { label: "", width: "96px" }, - ]; + const cols: Column[] = o.narrow + ? [ + { label: L("M02_SkColBlock") }, + { label: L("M02_SkColCall"), width: "92px" }, + { label: L("M02_SkColVerdict"), width: "92px" }, + { label: L("M02_SkColMade"), width: "92px" }, + { label: L("M02_SkSure"), width: "64px" }, + { label: "", width: "60px" }, + ] + : [ + { label: L("M02_SkColBlock"), width: "72px" }, + { label: L("M02_SangeunColName") }, + { label: L("M02_SangeunColUnit"), width: "56px" }, + { label: L("M02_SkColCall"), width: "104px" }, + { label: L("M02_SkColVerdict") }, + { label: L("M02_SkColOurs"), align: "end" }, + { label: L("M02_SkColExcel"), align: "end" }, + { label: L("M02_SkColBasis") }, + { label: L("M02_SkColMade"), width: "110px" }, + { label: L("M02_SkSure"), width: "72px" }, + { label: "", width: "96px" }, + ]; + + /** 부름 칸 — 「일위대가 n 곳」(title 호표 번) · 「일위대가 밖」 · 옛 서버(칸 없음) = — */ + function callCell(b: SkBlock): HTMLElement | string { + if (!b.부름) return "—"; + const out = outside(b); + const word = out ? L("M02_SkCallOut") : L("M02_SkCallIn").replace("{n}", String(b.부름.length)); // prettier-ignore + const badge = createBadge(word, out ? "neutral" : "info"); + badge.title = b.부름.map((n) => `제${n}호표`).join(" · "); + badge.dataset.skCall = out ? "out" : "in"; + return badge; + } /** 블록 한 줄 + 펼친 줄 표 */ function blockRows(b: SkBlock, d: SkDraft): HTMLTableRowElement[] { @@ -161,10 +242,13 @@ export function sangeunStep( onChange: (on) => { save(setSure(load(), b, on)); draw(); + tell(); }, }); sure.root.dataset.skSure = String(b.번); - sure.root.title = ok ? L("M02_SkSureTip") : L("M02_SkSureOff"); + sure.root.title = ok + ? L("M02_SkSureTip") + : L(b.줄.length ? "M02_SkSureOff" : "M02_SkSureEmpty"); const lines = createButton({ label: `${L("M02_SkOpen")} ${b.줄.length}`, size: 24, @@ -177,20 +261,34 @@ export function sangeunStep( }); lines.dataset.skOpen = String(b.번); const first = b.줄.find((l) => l.행 !== null)?.행 ?? null; - const view = first !== null ? createButton({ label: L("M02_SkSheet"), size: 24, variant: "link", onClick: () => sheet(first) }) : ""; // prettier-ignore - const head = tableRow(cols, [ - `단산${b.번}`, - b.이름 ?? "", - b.단위 ?? "", - verdict, - won(b.우리금액), - won(b.엑셀금액), - basis, - made, - sure.root, - el("div", { className: "ui-toolbar", children: [lines, view] }), - ]); + const view = first !== null && !o.narrow ? createButton({ label: L("M02_SkSheet"), size: 24, variant: "link", onClick: () => sheet(first) }) : ""; // prettier-ignore + const name = `단산${b.번} ${b.이름 ?? ""}${b.단위 ? ` [${b.단위}]` : ""}`; + const head = tableRow( + cols, + o.narrow + ? [name, callCell(b), verdict, made, sure.root, lines] + : [ + `단산${b.번}`, + b.이름 ?? "", + b.단위 ?? "", + callCell(b), + verdict, + won(b.우리금액), + won(b.엑셀금액), + basis, + made, + sure.root, + el("div", { className: "ui-toolbar", children: [lines, view] }), + ], + ); head.dataset.skBlock = String(b.번); + if (b.번 === current) head.classList.add("is-current"); + if (o.narrow) { + head.title = `${name} · ${L("M02_SkColOurs")} ${won(b.우리금액)} · ${L("M02_SkColExcel")} ${won(b.엑셀금액)}`; // prettier-ignore + head.addEventListener("click", (e) => { + if (!(e.target as Element).closest("button, label, input")) o.onRow?.(b.번); + }); + } if (!open.has(b.번)) return [head]; const table = skLineTable({ scope, @@ -199,6 +297,7 @@ export function sangeunStep( onPick: (at, p) => { save(tidy(setPick(load(), b.번, at, p), got!.판정)); draw(); + tell(); }, onSheet: sheet, }); @@ -221,7 +320,8 @@ export function sangeunStep( .replace("{made}", String(made)) .replace("{sure}", String(d.확인.length)); const { root: table, tbody } = tableShell(cols, { density: "data" }); - for (const b of blocks) tbody.append(...blockRows(b, d)); + const shown = filterBlocks(blocks, d, filter); + for (const b of shown) tbody.append(...blockRows(b, d)); const band = fails.length ? [ createBanner({ @@ -231,10 +331,12 @@ export function sangeunStep( }), ] : []; + const far = blocks.filter(outside).length; + const where = blocks.some((b) => b.부름) ? ` · ${L("M02_SkSummaryIn").replace("{in}", String(blocks.length - far)).replace("{out}", String(far))}` : ""; // prettier-ignore out.replaceChildren( - createText(sum, "muted", 14), + createText(sum + where, "muted", 14), ...band, - blocks.length ? table : createText(L("M02_SkNone"), "muted", 14), + shown.length ? table : createText(L("M02_SkNone"), "muted", 14), ); move.disabled = !d.확인.length; move.querySelector(".ui-btn__label")!.textContent = L("M02_SkMove").replace( @@ -253,5 +355,18 @@ export function sangeunStep( draw(); out.querySelector(`[data-sk-block="${번}"]`)?.scrollIntoView({ block: "center" }); }, + mark: (번) => { + out.querySelector("tr.is-current")?.classList.remove("is-current"); + current = 번; + if (번 === null || !got) return; + if (!out.querySelector(`[data-sk-block="${번}"]`) && filter !== "all") { + filter = "all"; + seg.set("all"); + draw(); + } + const row = out.querySelector(`[data-sk-block="${번}"]`); + row?.classList.add("is-current"); + row?.scrollIntoView({ block: "nearest" }); + }, }; } diff --git a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts index 0c774195b..2bc8098bf 100644 --- a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts +++ b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts @@ -115,7 +115,7 @@ export function lineTone( export const worst = (tones: Tone[]): Tone => tones.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), "muted"); -/** 블록 칠 — 만든 SK 갈래 › 없으면 부르는 첫 줄 판정 「예상」 · 판정 없음 = 밖 */ +/** 블록 칠 — 만든 SK 갈래 › 없으면 부르는 첫 줄 판정 「예상」 · 판정 없음 = 「일위대가 밖」 */ export function blockTone(m: BlockMark | undefined): { tone: Tone; 글: string } { if (!m) return { tone: "muted", 글: "안 불림" }; if (m.SK) { @@ -123,7 +123,7 @@ export function blockTone(m: BlockMark | undefined): { tone: Tone; 글: string } const tone: Tone = lost ? "danger" : m.갈래 === "SK" ? "warning" : "success"; return { tone, 글: `${m.SK} · ${m.갈래 ?? ""}${lost ? ` · 못옮김 ${lost}` : ""}` }; } - if (!m.판정) return { tone: "muted", 글: "판정 없음" }; + if (!m.판정) return { tone: "muted", 글: "일위대가 밖" }; // 부르는 일위대가 줄 없음(780-2) const tone: Tone = m.판정 === "맞음" ? "success" : m.판정 === "다른 점" ? "warning" : "info"; return { tone, 글: `예상 — 부르는 첫 줄 ${m.판정}` }; } diff --git a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SkPick.ts b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SkPick.ts index 7b5da67b5..736103c41 100644 --- a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SkPick.ts +++ b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SkPick.ts @@ -5,6 +5,8 @@ * · 고름 캐시 `{고름 {번: {차례: 고름}}, 확인 [번]}` — 줄 고름이 서버 상태를 덮음(꼬리표 「내가 고름」) * · 블록 [확인] = 남은 고를 줄 0 일 때만 · 몸 = `{확인: [{번, 고름?}]}`(확인한 블록만 · 자동 확인 없음) * · 금액 · 판정 정본은 서버 — 여기는 캐시를 겹쳐 보이기 · 몸 짓기만 + * · 780-2(요구 627) — 블록 `부름 [호표 번]`(빈 = 일위대가 밖 · 칸 없음 = 옛 서버 = 안) · 줄 0 = 「줄 없음」 확인 막음 · + * 거르기(모두 · 안 · 밖 · 고를 줄 있음) * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ @@ -65,6 +67,8 @@ export interface SkBlock { 근거: { 로직: string; 이름: string | null; 판정: string | null } | null; 만든: string | null; 줄: SkLine[]; + /** 부르는 일위대가 호표 번(780-1) — [] = 일위대가 밖 · 없음 = 옛 서버(안) */ + 부름?: number[]; } export interface SkJudged { @@ -91,6 +95,7 @@ export interface SkDraft { export const MINE = "내가 고름"; export const GOOD = "옮길 수 있음"; +export const NO_LINES = "줄 없음"; export const LINKED = "이어짐"; export const PICK = "고를 것"; export const OUT = "밖"; @@ -143,16 +148,17 @@ export function lineView( /** 그 블록 캐시 고름 `{차례: 고름}` */ export const picksOf = (d: SkDraft, 번: number): Record => d.고름[String(번)] ?? {}; -/** 남은 고를 줄 수 — 이어짐 · 밖 · 뺌 아닌 줄(캐시 고름 겹침) */ +/** 남은 고를 줄 수 — 이어짐 · 밖 · 뺌 아닌 줄(캐시 고름 겹침) · 줄 0 블록 = 1(서버 「줄 없음」 고를줄 1) */ export const leftLines = (b: SkBlock, picks: Record): number => - b.줄.filter((l) => !DONE.has(lineView(l, picks[String(l.차례)]).상태)).length; + b.줄.length ? b.줄.filter((l) => !DONE.has(lineView(l, picks[String(l.차례)]).상태)).length : 1; /** [확인] 켜짐 — 고를 줄 0 */ export const canConfirm = (b: SkBlock, picks: Record): boolean => leftLines(b, picks) === 0; -/** 블록 판정 글 — 캐시 겹친 뒤 「옮길 수 있음」 · 「고를 줄 n」 */ +/** 블록 판정 글 — 캐시 겹친 뒤 「옮길 수 있음」 · 「고를 줄 n」 · 「줄 없음」 */ export function verdictOf(b: SkBlock, picks: Record): string { + if (!b.줄.length) return NO_LINES; const n = leftLines(b, picks); return n ? `고를 줄 ${n}` : GOOD; } @@ -201,10 +207,23 @@ export function sureBody(d: SkDraft, blocks: SkBlock[]): SkSure[] { }); } -/** 마법사 [끝내기] 글 재료 — 아직 안 옮긴 블록 수 · 그 블록 고를 줄 합 */ -export function waiting(d: SkDraft, blocks: SkBlock[]): { 블록: number; 줄: number } { +/** 일위대가 밖 블록 — `부름` 빈 배열 */ +export const outside = (b: SkBlock): boolean => !!b.부름 && !b.부름.length; + +/** 마법사 [끝내기] 글 재료 — 아직 안 옮긴 블록 수 · 그중 일위대가 밖 · 그 블록 고를 줄 합 */ +export function waiting(d: SkDraft, blocks: SkBlock[]): { 블록: number; 밖: number; 줄: number } { const open = blocks.filter((b) => !b.만든); - return { 블록: open.length, 줄: open.reduce((n, b) => n + leftLines(b, picksOf(d, b.번)), 0) }; + const 줄 = open.reduce((n, b) => n + leftLines(b, picksOf(d, b.번)), 0); + return { 블록: open.length, 밖: open.filter(outside).length, 줄 }; +} + +export type SkFilter = "all" | "in" | "out" | "open"; +/** 거르기 — 모두 · 일위대가 안 · 일위대가 밖 · 고를 줄 있음(안 옮긴 블록 · 캐시 겹침) */ +export function filterBlocks(blocks: SkBlock[], d: SkDraft, f: SkFilter): SkBlock[] { + if (f === "in") return blocks.filter((b) => !outside(b)); + if (f === "out") return blocks.filter(outside); + if (f === "open") return blocks.filter((b) => !b.만든 && leftLines(b, picksOf(d, b.번)) > 0); + return blocks; } /** 줄 고름 넣기 · 지움(null) — 새 캐시 */ diff --git a/resources/tester/helper_778_5_sk_pick.cjs b/resources/tester/helper_778_5_sk_pick.cjs index 3b8870fa7..a9b06906c 100644 --- a/resources/tester/helper_778_5_sk_pick.cjs +++ b/resources/tester/helper_778_5_sk_pick.cjs @@ -1,11 +1,40 @@ /* 778-5 D-5 마법사 단가산출 줄 고름 헬퍼 — `logic/Hopyo_SkPick`(순수) · `Hopyo_SkApi`(몸) 를 트랜스파일해 Node 로 돌림 · - * fetch 대역(부른 길 · 몸 모음) · stdin = {blocks} · stdout = JSON. */ + * fetch 대역(부른 길 · 몸 모음) · stdin = {blocks} · stdout = JSON. + * 780-2 — stdin `{sheetSk: {...}}` = 엑셀 보기 오른쪽 판 바꾸기(`Hopyo_SheetSk`) · 단가산출 칸 · 시트 막대 · 판은 가짜. */ const fs = require("fs"); const path = require("path"); const { makeLoader } = require("./helper_ts_stub_loader.cjs"); const ROOT = path.join(__dirname, "..", ".."); -const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api", ROUTES: {} } }; +const made = []; // 가짜 단가산출 칸 — 지은 인자 · 강조 +const fakeEl = () => ({ + hidden: false, + attrs: {}, + kids: [], + setAttribute(k, v) { + this.attrs[k] = v; + }, + append(x) { + this.kids.push(x); + }, +}); +const STUBS = { + "@config/config_frontend": { API_BASE_URL: "/api", ROUTES: {} }, + "@ui/ui_template_elements": { + el: (tag, o = {}) => ({ ...fakeEl(), tag, className: o.className }), + }, + "./M02_MasterTemplete_Hopyo_WizardSk": { + sangeunStep: (scope, key, file, onChange, o) => { + const step = { + args: { scope, key, file, o: { ...o, onRow: !!o.onRow } }, + marks: [], + onRow: o.onRow, + }; + made.push(step); + return { root: fakeEl(), mark: (n) => step.marks.push(n) }; + }, + }, +}; const stub = new Proxy({}, { get: () => () => {} }); const { loadTs } = makeLoader({ root: ROOT, @@ -19,7 +48,9 @@ const { loadTs } = makeLoader({ global.sessionStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; (async () => { - const { blocks } = JSON.parse(fs.readFileSync(0, "utf8")); + const input = JSON.parse(fs.readFileSync(0, "utf8")); + if (input.sheetSk) return process.stdout.write(JSON.stringify(sheetSk(input.sheetSk))); + const { blocks } = input; const calls = []; global.fetch = async (url, init = {}) => { calls.push({ @@ -77,8 +108,61 @@ global.sessionStorage = { getItem: () => null, setItem: () => {}, removeItem: () await api.judgeSangeun({ layer: "personal", owner: null }, "HP-000001"); await api.judgeSangeun({ layer: "personal", owner: null }, "HP-000001", out.body); out.calls = calls; + // 780-2 일위대가 안 · 밖 · 줄 0 블록 — 거르기 · 판정 · 확인 막음 · 끝내기 글 재료 + const more = [ + { ...a, 부름: [3, 7] }, + { ...b, 부름: [] }, + { ...b, 번: 9, 부름: [], 줄: [] }, + { ...c, 부름: [12], 만든: "SK-000001" }, + ]; + const e = more[2]; + let d2 = sk.setSure(sk.emptyDraft(), e, true); + out.empty = { + 판정: sk.verdictOf(e, {}), + 켜짐: sk.canConfirm(e, {}), + 확인: d2.확인, + 몸: sk.sureBody({ 고름: {}, 확인: [9] }, more), + }; + d2 = sk.emptyDraft(); + out.filter = Object.fromEntries( + ["all", "in", "out", "open"].map((f) => [f, sk.filterBlocks(more, d2, f).map((x) => x.번)]), + ); + out.old = sk.filterBlocks(blocks, d2, "out").length; // 옛 서버(부름 없음) = 모두 안 + out.wait2 = sk.waiting(d2, more); process.stdout.write(JSON.stringify(out)); })().catch((error) => { process.stderr.write(String(error?.stack ?? error)); process.exit(1); }); + +/** 780-2 판 바꾸기 — 탭(역할) 차례로 고름 · 판 숨김 · 칸 지음 한 번 · 강조 · 줄 누름 뛰기 */ +function sheetSk({ same, canWrite, picks }) { + const M02 = path.join(ROOT, "M02_MasterTemplete"); + const { createSheetSk } = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_SheetSk.ts")); + const roles = { s1: "일위대가표", s2: "단가산출표", s3: "단가산출상세" }; + const jumps = []; + const nav = { + role: (sheet) => roles[sheet], + blockAt: (sheet, row) => + sheet === "s2" ? row - 4 : sheet === "s3" ? Math.floor(row / 10) : null, + spotOf: (no, role) => ({ 시트: role === "단가산출표" ? "s2" : "s3", 행: [no, no + 1] }), + jumpTo: (sheet, rows) => jumps.push([sheet, rows]), + }; + const toggled = []; + const side = { + hidden: false, + after: () => {}, + parentElement: { classList: { toggle: (c, on) => toggled.push([c, on]) } }, + }; + const scope = { layer: "personal", owner: null }; + const target = same ? scope : { layer: "company", owner: null }; + const p = createSheetSk({ scope, key: "HP-000001", 파일: "a.xls", target, canWrite, nav, side }); + const seen = []; + for (const [sheet, cell] of picks) { + p.at({ 시트: sheet, 칸: cell }); + seen.push({ 호표판숨김: side.hidden, 단가판숨김: p.root.hidden, 지음: made.length }); + if (sheet === "s2" && made[0]) made[0].onRow(8); // 목록 줄 누름(단가산출표 탭) + if (sheet === "s3" && made[0]) made[0].onRow(8); // 상세 탭 + } + return { seen, toggled, made: made.map((m) => ({ args: m.args, marks: m.marks })), jumps }; +} diff --git a/resources/tester/test_627_sheet_sk.py b/resources/tester/test_627_sheet_sk.py new file mode 100644 index 000000000..610cfdefe --- /dev/null +++ b/resources/tester/test_627_sheet_sk.py @@ -0,0 +1,99 @@ +"""780-2 지킴 — 엑셀 보기 단가산출표 · 상세 탭 = 오른쪽 단가산출 목록(요구 627). + +서버 없이 잡음: 탭(역할) 따라 판 바꿈(일위대가표 = 호표 목록 · 단가산출 탭 = 단가산출 목록 · 칸은 처음 한 번만 지음) · +시트 행 → 목록 강조 · 목록 줄 → 지금 탭의 블록 자리 · 만들 층 ≠ 파일 층 = 보기만 · 거르기(전체 · 안 · 밖 · 고를 줄 있음) · +줄 0 블록 = 「줄 없음」 확인 막음 · 마법사 칸과 같은 캐시 열쇠(두 벌 고름 맞춤) · [끝내기] = 막지 않고 물음창 글만 · +화면 = 부품 상자만 · 700 줄. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +HERE = os.path.dirname(__file__) +M02 = Path(__file__).resolve().parents[2] / "M02_MasterTemplete" +from test_778_5_sk_pick_screen import BLOCKS as sys_blocks # noqa: E402 + + +def _node(payload: dict) -> dict: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_778_5_sk_pick.cjs")], + input=json.dumps(payload, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +PICKS = [["s1", "B5"], ["s2", "B12"], ["s1", "C3"], ["s3", "A85"], ["s3", "A2"]] + + +def test_판_바꿈_목록_지음_강조_뛰기(): + got = _node({"sheetSk": {"same": True, "canWrite": True, "picks": PICKS}}) + assert got["seen"] == [ + {"호표판숨김": False, "단가판숨김": True, "지음": 0}, # 일위대가표 = 호표 목록 · 칸 안 지음 + {"호표판숨김": True, "단가판숨김": False, "지음": 1}, # 단가산출표 = 단가산출 목록 + {"호표판숨김": False, "단가판숨김": True, "지음": 1}, + {"호표판숨김": True, "단가판숨김": False, "지음": 1}, # 다시 와도 한 번만 지음 + {"호표판숨김": True, "단가판숨김": False, "지음": 1}, + ] + assert [on for _, on in got["toggled"]] == [False, True, False, True, True] + (step,) = got["made"] + assert step["args"]["o"] == {"narrow": True, "onRow": True, "readOnly": False} + assert step["args"]["key"] == "HP-000001" + assert step["marks"] == [8, 8, 0] # 목록 행 12 → 단산8 · 상세 85 행 → 단산8 · 2 행 → 블록 0 + # 목록 줄 누름 = 지금 탭의 자리(단가산출표 = 목록 행 · 상세 = 블록 범위) + assert got["jumps"] == [["s2", [8, 9]], ["s3", [8, 9]], ["s3", [8, 9]]] + + +def test_다른_층_권한_없음_보기만(): + for same, can in ((False, True), (True, False)): + got = _node({"sheetSk": {"same": same, "canWrite": can, "picks": [["s2", "B5"]]}}) + assert got["made"][0]["args"]["o"]["readOnly"] is True + + +def test_거르기_줄0_막음_끝내기_글(): + got = _node({"blocks": sys_blocks}) + assert got["empty"] == {"판정": "줄 없음", "켜짐": False, "확인": [], "몸": []} + assert got["filter"] == {"all": [2, 5, 9, 7], "in": [2, 7], "out": [5, 9], "open": [2, 9]} + assert got["old"] == 0 # 옛 서버(부름 없음) = 모두 일위대가 안 + assert got["wait2"] == {"블록": 3, "밖": 2, "줄": 3} # 만든 블록 7 뺌 · 줄 0 블록 = 고를 줄 1 + + +def _src(name: str) -> str: + return (M02 / f"M02_MasterTemplete_Hopyo_{name}.ts").read_text(encoding="utf-8") + + +def test_캐시_공유_알림_칠_다시(): + wiz = _src("WizardSk") + assert wiz.count("skDraftKey(scope, key)") == 1 # 두 벌 모두 같은 열쇠(층 · 주인 · 파일 키) + assert "SK_SYNC" in wiz and "tell({ got: next })" in wiz # 옮김 = 받은 판정 그대로 다른 벌에 + assert wiz.count("tell();") == 2 # [확인] · 줄 고름 바뀜 + assert "new Event(HOPYO_BUILT)" in wiz # 옮김 뒤 열린 엑셀 보기 칠 다시 + sheet = _src("Sheet") + assert "createSheetSk(" in sheet and "sk?.at(s);" in sheet + side = _src("SheetSk") + assert "sangeunStep(opt.scope, opt.key" in side # 마법사와 같은 파일 층 · 키 + nav = _src("SheetNav") + assert "blockAt(sheet: string, row: number)" in nav and "spotOf(no: number, role: Role)" in nav + wizard = _src("Wizard") + assert 'L("M02_SkUnmoved")' in wizard and "finish.disabled = !!n?.줄;" in wizard # 밖 = 안 막음 + tone = (M02 / "logic" / "M02_MasterTemplete_Hopyo_SheetMap.ts").read_text(encoding="utf-8") + assert '글: "일위대가 밖"' in tone + + +def test_부품_상자만_700줄(): + for name in ("SheetSk", "Sheet", "SheetNav", "WizardSk", "Wizard"): + src = _src(name) + assert len(src.splitlines()) <= 700, name + assert not re.search(r"#[0-9a-fA-F]{3,6}\b|rgb\(", src), name + for name in ("SheetSk", "WizardSk"): + assert not re.search(r'el\("(button|input|select)"| Date: Wed, 4 Nov 2026 18:08:50 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(780-1):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=B0=96=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C?= =?UTF-8?q?=EB=8F=84=20=ED=8C=90=EC=A0=95=20=C2=B7=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=C2=B7=20=EC=B9=A0=20=E2=80=94=20=EB=AA=A8=EB=93=A0=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=EB=B6=80=EB=A6=84=20=EC=B9=B8=20=C2=B7=20?= =?UTF-8?q?=EB=B9=88=20=EB=B8=94=EB=A1=9D=20=E3=80=8C=EC=A4=84=20=EC=97=86?= =?UTF-8?q?=EC=9D=8C=E3=80=8D(=EC=9A=94=EA=B5=AC=20627)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sangeun: 판정 · 확인 몸 = 모든 블록(북부청 임도 49 → 95) · 줄마다 `부름 [호표 번]`([] = 일위대가 밖) · 밖 블록 = 이름 · 단위 블록 것 · 근거 없음 · 없는 번만 404 · 하위 밖 블록(울진소광 492 → 83)은 확인 목록에 함께 = 하위 먼저 - SkUnit: 줄 0 블록 = 판정 「줄 없음」 고를줄 1 → 확인해도 못만듦(빈 SK 없음) - Marks: 블록마다 `부름` · `줄` · `어긋남` = 모든 블록(밖 포함) · 맞춤셈 `판정` 밖 = None 그대로 - 시험 고침(낡은 기대 = 일위대가 안 블록만): 778-5 units 49 → 95(안 49 따로) · 779-2 어긋남 + 밖 4 · 44 · 48 · 77 · 78 · 779-5 줄 칠 모든 블록 · 778-5 excel misses 밖 블록 5 · 새 시험 test_780_1_sangeun_outside(밖 확인 · 빈 블록 · 울진소광 · 여덟 파일 459) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Marks.py | 11 +- M02_MasterTemplete/M02_Hopyo_Sangeun.py | 44 ++++++-- M02_MasterTemplete/M02_Hopyo_SkUnit.py | 7 +- .../tester/test_778_5_hopyo_excel_misses.py | 9 +- resources/tester/test_778_5_hopyo_units.py | 5 +- resources/tester/test_779_2_hopyo_marks.py | 4 +- .../tester/test_779_5_block_line_tone.py | 14 +-- .../tester/test_780_1_sangeun_outside.py | 101 ++++++++++++++++++ 8 files changed, 166 insertions(+), 29 deletions(-) create mode 100644 resources/tester/test_780_1_sangeun_outside.py diff --git a/M02_MasterTemplete/M02_Hopyo_Marks.py b/M02_MasterTemplete/M02_Hopyo_Marks.py index 77be78e77..5c852ca67 100644 --- a/M02_MasterTemplete/M02_Hopyo_Marks.py +++ b/M02_MasterTemplete/M02_Hopyo_Marks.py @@ -1,7 +1,8 @@ """M02 불러오기 엑셀 보기 하이라이트 재료(PLAN 779-2 · 요구서 621 3장 5) — 읽기만(파일 · storage 변화 0). -블록 `[{번, 판정, SK, 갈래, 못옮김}]` = 단가산출 만들기 결과(`Sangeun.made`) + 맞춤셈 캐시(`Fit.result`)를 읽기만. +블록 `[{번, 판정, SK, 갈래, 못옮김, 부름}]` = 단가산출 만들기 결과(`Sangeun.made`) + 맞춤셈 캐시(`Fit.result`)를 읽기만. - `판정` = 그 블록을 부르는 첫 줄의 맞춤셈 판정 상태(맞음 · 다른 점 · 후보 …) · 안 불리거나 셈 전이면 None +- `부름` = 부르는 호표 번 [] = 일위대가 밖(780-1) · `어긋남` · `줄` = 모든 블록(밖 포함) - `SK` = 만든 SK 키 · 없으면 None / `갈래` · `못옮김` = 그 SK 본뜬(출처별) · 없으면 None · [] 줄 상태는 `Match.view` 호표마다 `줄상태 [[차례, 상태, 체크]]` · `만든`(SC 키)로 실려 감. @@ -127,7 +128,8 @@ def blocks(folder: Path, key: str, to: Path | None = None) -> list[dict[str, Any cache = (fit.result(folder, key) or {}).get("블록") or {} have = sangeun.made(to, key, here) all_, first = sangeun.calls(hp) - units = hunits.Units() if first else None + units = hunits.Units() if all_ else None + call = sangeun.callers(hp) out = [] for no in sorted(all_): line = first.get(no) @@ -142,8 +144,9 @@ def blocks(folder: Path, key: str, to: Path | None = None) -> list[dict[str, Any "SK": sk, "갈래": origin.get("갈래"), "못옮김": origin.get("못옮김") or [], - "어긋남": misses(all_[no]) if line else None, - "줄": lines(all_[no], units) if line and units else [], + "부름": call.get(no, []), + "어긋남": misses(all_[no]), + "줄": lines(all_[no], units) if units else [], } ) return out diff --git a/M02_MasterTemplete/M02_Hopyo_Sangeun.py b/M02_MasterTemplete/M02_Hopyo_Sangeun.py index 1326cb3f5..e6846e744 100644 --- a/M02_MasterTemplete/M02_Hopyo_Sangeun.py +++ b/M02_MasterTemplete/M02_Hopyo_Sangeun.py @@ -7,6 +7,8 @@ SK = 이름 블록 이름(없으면 줄 품명) · 단위 = 줄 단위(없으면 블록 · 「식」) · 구분 「실무자료」 · 상세구분 = 파일 · 바탕 없음 · 본뜬 = {층 「실무자료」, 키 「HP-###### 단산N」, 파일, 원본, 확인, 갈래 「단위」, 근거 {로직, 이름}, 못옮김 []} · 같은 본뜬(키 · 원본)이 이 층에 있으면 그 키에 덮음 · 만드는 층 = 부르는 층. +판정 · 확인 = 모든 블록(780-1 · 요구 627) — 일위대가가 부르는 블록 + 일위대가 밖(내역서 등이 부름) · 줄마다 +`부름 [호표 번]`([] = 일위대가 밖) · 밖 블록 = 이름 · 단위 블록 것 · 근거 꼬리표 없음(맞춤셈 열쇠 = 부르는 줄). 같은 내용(733-1 · `M02_Library_Same` 지문) SK 가 이 층에 있으면 새로 안 만들고 그것을 씀 — 이름 = 먼저 만든 것 · 뒤 출처 = 그 SK `본뜬.또 [그 블록 본뜬 + 이름]`(`made` · `origin_of` 가 읽음) · 다른 출처가 쓰는 SK 는 내용을 안 바꿈(바뀌면 떼고 다시 찾음) · 응답 `같은것씀 [{번, 키}]` · 위 층 같은 것 = `위층같음 [{번, 층, 주인, 키}]`(알림만). @@ -89,6 +91,19 @@ def calls(hp: dict[str, Any]) -> tuple[dict[int, dict], dict[int, dict]]: return blocks, first +def callers(hp: dict[str, Any]) -> dict[int, list]: + """{단산 번: 부르는 호표 번 [차례대로 · 겹침 뺌]} — 없는 번 = 일위대가 밖.""" + out: dict[int, list] = {} + for h in hp.get("호표") or []: + for line in h.get("줄") or []: + ref = line.get("참조") or {} + if ref.get("종류") == "단산" and ref.get("번"): + got = out.setdefault(int(ref["번"]), []) + if h.get("번") not in got: + got.append(h.get("번")) + return out + + def _bad(text: str, status: int = 400) -> store.StoreError: return store.StoreError(status, text) @@ -155,6 +170,8 @@ class Maker: def basis(self, no: int) -> dict[str, Any] | None: """근거 꼬리표 = 맞춤셈 1위 기초(참고).""" + if no not in self.first: # 일위대가 밖 = 맞춤셈 없음 + return None entry = self.cache.get(fit.unit_key(self.first[no])) or {} top = (entry.get("차례") or [None])[0] if not top: @@ -175,7 +192,7 @@ class Maker: return {"층": layer, "주인": owner, "키": got} if got else None def judge(self, no: int, picks: dict | None = None) -> dict[str, Any]: - line, block = self.first[no], self.blocks[no] + line, block = self.first.get(no) or {}, self.blocks[no] unit = str(line.get("단위") or block.get("단위") or "").strip() try: got = skunit.judge(block, self.units(), self.ref_of, picks, unit) @@ -206,7 +223,7 @@ class Maker: plan = self.plan(no, sure[no]) got = self.judged[no] lost = [r for r in got["줄"] if r["상태"] == units.LINKED and r["차례"] not in got["담음"]] - name = self.blocks[no].get("이름") or self.first[no].get("이름") + name = self.blocks[no].get("이름") or (self.first.get(no) or {}).get("이름") if plan["갈래"] == skpatch.WAIT or lost: why = plan.get("까닭") or f"하위 「{lost[0]['이름']}」 — 그 블록 먼저 확인" out["못만듦"].append({"번": no, "이름": name, "까닭": why}) @@ -217,7 +234,7 @@ class Maker: def write(self, no: int, plan: dict, have: str | None) -> str: """그 블록 SK 를 씀 → 키 — 다른 출처(또)가 쓰는 SK 의 내용은 안 바꿈(바뀌면 떼고 다시 찾음).""" - doc = _doc(self.first[no], self.blocks[no], no, self.hp, plan, self.sure) + doc = _doc(self.first.get(no) or {}, self.blocks[no], no, self.hp, plan, self.sure) root, mine = store.root(), doc["본뜬"] self.above += [{"번": no, **r} for r in self.same.find_above(root, KIND, doc)] if have: @@ -267,24 +284,31 @@ class Maker: def build(folder: Path, key: str, who: Any = None, picks: Any = None) -> dict[str, Any]: """[단가산출] — 블록마다 줄 판정 · 확인한 블록만 단위 SK → `{블록, 판정 [{번, 이름, 단위, 판정, 고를줄, - 우리금액, 엑셀금액, 근거, 만든, 줄}], 만듦 [{번, 이름, 키, 갈래, 못옮김}], 못만듦 [{번, 이름, 까닭}], 그대로, + 우리금액, 엑셀금액, 근거, 만든, 줄, 부름}], 만듦 [{번, 이름, 키, 갈래, 못옮김}], 못만듦 [{번, 이름, 까닭}], 그대로, 같은것씀, 위층같음}` · `picks` = 확인 목록.""" sure = _sure(picks) maker = Maker(folder, key, who) - bad = sorted(set(sure) - set(maker.first)) + bad = sorted(set(sure) - set(maker.blocks)) if bad: - raise _bad(f"없는 블록 · 부르는 줄 없음 「단산{bad[0]}」", 404) + raise _bad(f"없는 블록 「단산{bad[0]}」", 404) if not maker.done: fit.ensure(folder, key) raise _bad("맞춤 셈 중 — 끝나면 다시 누름", 409) - out: dict[str, Any] = {"블록": len(maker.first), "만듦": [], "못만듦": []} + out: dict[str, Any] = {"블록": len(maker.blocks), "만듦": [], "못만듦": []} for no in sorted(sure): maker.make(no, sure, out) rows = [] - for no in sorted(maker.first): - line, block = maker.first[no], maker.blocks[no] + call = callers(maker.hp) + for no in sorted(maker.blocks): + line, block = maker.first.get(no), maker.blocks[no] got = maker.judged.get(no) or maker.judge(no) - head = {"번": no, "이름": block.get("이름") or line.get("이름"), "단위": line.get("단위")} + name = block.get("이름") or (line or {}).get("이름") + head = { + "번": no, + "이름": name, + "단위": (line or block).get("단위"), + "부름": call.get(no, []), + } keep = ("판정", "고를줄", "우리금액", "엑셀금액", "줄") head |= {k: got[k] for k in keep} | {"근거": maker.basis(no)} rows.append({**head, "만든": maker.keys.get(mark(key, no))}) diff --git a/M02_MasterTemplete/M02_Hopyo_SkUnit.py b/M02_MasterTemplete/M02_Hopyo_SkUnit.py index bb85300c2..a0476b158 100644 --- a/M02_MasterTemplete/M02_Hopyo_SkUnit.py +++ b/M02_MasterTemplete/M02_Hopyo_SkUnit.py @@ -4,7 +4,7 @@ 줄 판정 = `M02_Hopyo_Units` 잇기(상태 · 방법 · 단위) + 매개값(품 · 시간 · 수량 · 율) · 사용자 고름 `{차례: 단위 | {뺌: 까닭}}`(그 줄만 · 꼬리표 「내가 고름」) · 단가 = M01 지금 값으로 다시 셈(`우리금액`) · 엑셀 금액과의 `차` = 참고(엑셀 = 과거 값 · 판정에 안 씀) · 셈 밖 줄 = 상태 「밖」(SK 에 안 들임). -블록 판정 = 줄 모음 — 모두 이어짐 = 「옮길 수 있음」 · 아니면 「고를 줄 n」. +블록 판정 = 줄 모음 — 모두 이어짐 = 「옮길 수 있음」 · 아니면 「고를 줄 n」 · 줄 0 = 「줄 없음」(고를줄 1 · 빈 SK 막음). 단위 SK 더한줄(바탕 없음 · 778-4 꼴) = 인력 `{요소 LB, 수량 품}` · 기계경비 로직 `{로직 GC, 입력값 {기계}, 수량 시간 × 할증}` · 재료 `{요소 MT, 수량(손료율 든), 별도계상?}`(못 찾은 별도계상 = 0 원 고정 줄) · 율 `{고정 {비율 {기준, 비목}}, 수량 = 율 %}`(줄 목록 = 그 행 줄 `add:#k`) · 하위 `{참조 SK · SC}` · 값 고정 줄. @@ -24,6 +24,7 @@ from M02_MasterTemplete import M02_Sangeun_Doc as sd from M02_MasterTemplete.M02_Hopyo_Words import mtypes GOOD = "옮길 수 있음" +EMPTY = "줄 없음" OUT, DROP = "밖", "뺌" UNIT = "단위" # 본뜬 갈래 MINE = "내가 고름" @@ -85,7 +86,9 @@ def lines(block: dict[str, Any], us: units.Units, picks: dict | None = None) -> def verdict(rows: list[dict]) -> tuple[str, int]: - """블록 판정 — (「옮길 수 있음」 | 「고를 줄 n」, n).""" + """블록 판정 — (「옮길 수 있음」 | 「고를 줄 n」 | 「줄 없음」, n) · 줄 0 = 1(확인해도 못 만듦).""" + if not rows: + return EMPTY, 1 n = sum(1 for r in rows if r["상태"] not in (units.LINKED, OUT, DROP)) return (GOOD if not n else f"고를 줄 {n}"), n diff --git a/resources/tester/test_778_5_hopyo_excel_misses.py b/resources/tester/test_778_5_hopyo_excel_misses.py index 33378a3fe..70d51d867 100644 --- a/resources/tester/test_778_5_hopyo_excel_misses.py +++ b/resources/tester/test_778_5_hopyo_excel_misses.py @@ -1,6 +1,7 @@ """778-5 D-6 엑셀 셈 어긋남(`M02_Hopyo_Marks.misses` · `SheetMap.missesOf`) — 불리는 블록의 재현 못 한 줄 · 덮음 실패 · 금액 차 · 까닭 · 상세 시트 행 차례. 북부청 임도 2 · 33 = 엑셀 합이 진동로울러 노무를 뺌 · -81 = 이제 맞음 · 칠은 안 바꿈(블록 칠 같음) · `hopyo-marks` 길에 실림(읽기만).""" +81 = 이제 맞음 · 칠은 안 바꿈(블록 칠 같음) · `hopyo-marks` 길에 실림(읽기만) · 780-1 일위대가 밖 블록도 +(북부청 임도 4 · 44 · 48 · 77 · 78 · 읽기 흠은 보이는 대로).""" import warnings @@ -26,9 +27,9 @@ def north(): return parsed, grid -def _misses(parsed) -> dict[int, dict]: +def _misses(parsed, outside: bool = False) -> dict[int, dict]: blocks, first = sg.calls(parsed) - got = {n: mk.misses(blocks[n]) for n in first} + got = {n: mk.misses(blocks[n]) for n in (blocks if outside else first)} return {n: m for n, m in got.items() if m} @@ -49,6 +50,8 @@ def test_북부청_임도_어긋남(north): assert all("못 읽음" in x["까닭"] for x in m["줄"]) blocks, _ = sg.calls(north[0]) assert mk.misses(blocks[81]) is None # 이제 맞음 + out = {n: m for n, m in _misses(north[0], True).items() if n not in got} + assert sorted(out) == [4, 44, 48, 77, 78] and all(m["까닭"] for m in out.values()) def test_다른_파일_어긋남_수(): diff --git a/resources/tester/test_778_5_hopyo_units.py b/resources/tester/test_778_5_hopyo_units.py index 789a245ab..cec57d419 100644 --- a/resources/tester/test_778_5_hopyo_units.py +++ b/resources/tester/test_778_5_hopyo_units.py @@ -120,8 +120,9 @@ def test_확인한_블록만_단위_SK_고름_재사용(client): fit.run_job(folder, key) got = client.post(url, json={}).json() assert got["만듦"] == [] and not _sks(folder) # 확인 안 한 블록 = 안 만듦 - assert len(got["판정"]) == got["블록"] == 49 # 불리는 블록 - rows = {r["번"]: r for r in got["판정"]} + assert len(got["판정"]) == got["블록"] == 95 # 모든 블록(780-1 · 밖 포함) + rows = {r["번"]: r for r in got["판정"] if r["부름"]} # 일위대가 안 49 + assert len(rows) == 49 good = [n for n, r in rows.items() if r["판정"] == skunit.GOOD and r["근거"]] pick = next(n for n, r in rows.items() if r["고를줄"]) assert rows[pick]["판정"] == f"고를 줄 {rows[pick]['고를줄']}" diff --git a/resources/tester/test_779_2_hopyo_marks.py b/resources/tester/test_779_2_hopyo_marks.py index 4de2244fd..cb0d7e16b 100644 --- a/resources/tester/test_779_2_hopyo_marks.py +++ b/resources/tester/test_779_2_hopyo_marks.py @@ -64,8 +64,8 @@ def test_marks_읽기만_판정_캐시_같음_SK(client): assert judged and all(r["판정"] in ("맞음", "다른 점", "후보", "없음") for r in judged) assert {r["판정"] for r in judged} <= {e["판정"]["상태"] for e in cache.values()} assert all(r["SK"] is None and r["갈래"] is None and r["못옮김"] == [] for r in rows) - # 778-5 D-6 — 엑셀 셈 어긋남 덧칸(불리는 블록) · 2 · 33 = 엑셀 합이 줄 뺌 - assert {r["번"] for r in rows if r["어긋남"]} == {2, 33, 64, 95} + # 778-5 D-6 — 엑셀 셈 어긋남 덧칸(780-1 모든 블록) · 2 · 33 = 엑셀 합이 줄 뺌 · 4 … 78 = 일위대가 밖 + assert {r["번"] for r in rows if r["어긋남"]} == {2, 33, 64, 95, 4, 44, 48, 77, 78} # [단가산출 만들기] 뒤 = SK · 갈래 · 못옮김 실림(778-5 — 확인한 블록만) rows = client.post(f"{base}/{KEY}/sangeun").json()["판정"] sure = [{"번": r["번"]} for r in rows if not r["고를줄"]] diff --git a/resources/tester/test_779_5_block_line_tone.py b/resources/tester/test_779_5_block_line_tone.py index 005756e93..88a43222e 100644 --- a/resources/tester/test_779_5_block_line_tone.py +++ b/resources/tester/test_779_5_block_line_tone.py @@ -1,6 +1,7 @@ """779-5 블록 안 줄 칠(`logic/M02_MasterTemplete_Hopyo_SheetMap.ts` · Node) — 블록 `줄 [[행, 상태, 차]]` → 출처 행만 칠(이어짐 초록 · 차 있으면 주황 · 후보 파랑 · 못 이음 빨강 · 밖 흐림) · 머리 행 = 가장 나쁜 칠 · -목록 행 = 머리와 같음 · 줄 재료 없으면 옛 블록 칠(범위 통째) · 실물(북부청 임도 · 영월) 줄 칠 수 = 단위 줄 수 · +목록 행 = 머리와 같음 · 줄 재료 없으면 옛 블록 칠(범위 통째) · 실물(북부청 임도 · 영월) 줄 칠 수 = 단위 줄 수 +(780-1 모든 블록 · 일위대가 밖 포함) · 상태 = 줄 잇기(D-2) · 참고 차 = STmate 옛 노임만.""" import warnings @@ -63,21 +64,22 @@ def test_실물_줄_칠_수_단위_줄_수(tag, units): grid = hg.read(path.name, data, parsed) grid.pop("책") blocks, first = sg.calls(parsed) + assert len(first) < len(blocks) # 일위대가 밖 블록 있음 marks, want = [], 0 for no in sorted(blocks): - lines = mk.lines(blocks[no], units) if no in first else [] + lines = mk.lines(blocks[no], units) rows = [u["행"] for u in blocks[no]["항목"] if u.get("행") is not None] - want += len(rows) if no in first else 0 - assert [x[0] for x in lines] == (rows if no in first else []) + want += len(rows) + assert [x[0] for x in lines] == rows assert all(s in ("이어짐", "고를 것", "못 이음", "밖") for _, s, _ in lines) assert all(not d or s == "이어짐" for _, s, d in lines) # 차 = 이어진 줄만 marks.append( {"번": no, "판정": "맞음", "SK": None, "갈래": None, "못옮김": [], "줄": lines} ) detail = _node({"grid": grid, "blocks": marks})["paint"][grid["보기"]["단가산출상세"]] - heads = {b["행"][0] for b in grid["단산"] if b["번"] in first} + heads = {b["행"][0] for b in grid["단산"]} unit_rows = {x[0] for m in marks for x in m["줄"]} - spans = [b["행"] for b in grid["단산"] if b["번"] in first] + spans = [b["행"] for b in grid["단산"] if any(x["번"] == b["번"] and x["줄"] for x in marks)] painted = {int(r) for r in detail if any(x <= int(r) <= y for x, y in spans)} - heads assert unit_rows - heads == painted and want == sum(len(m["줄"]) for m in marks) diff = sum(d for m in marks for _, _, d in m["줄"]) diff --git a/resources/tester/test_780_1_sangeun_outside.py b/resources/tester/test_780_1_sangeun_outside.py new file mode 100644 index 000000000..0018b65d8 --- /dev/null +++ b/resources/tester/test_780_1_sangeun_outside.py @@ -0,0 +1,101 @@ +"""780-1 일위대가 밖 단가산출도 잇기(요구 627) — 판정 · 확인 = 모든 블록 · `부름 [호표 번]`([] = 일위대가 밖) · +밖 블록 확인 = 단위 SK(이름 · 단위 = 블록 것 · 근거 없음) · 빈 블록(줄 0) = 「줄 없음」 확인해도 못만듦 · +하위 밖 블록(울진소광 492 → 83) = 확인 목록에 함께 넣으면 하위 먼저 만듦 · 칠 재료 `줄` · `어긋남` 모든 블록 · +여덟 파일 블록 459 판정 오류 0(빈 블록은 모두 밖 = 일위대가 안 판정 그대로). 층 = 임시 폴더 · 마스터 읽기만. +""" + +from __future__ import annotations + +import json + +from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun +from M02_MasterTemplete import M02_Hopyo_SkUnit as skunit +from M02_MasterTemplete import M02_Hopyo_Units as units +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester import helper_m02_hopyo_golden as golden +from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_hash # noqa: F401 + +BLOCKS = {"북부청임도": (95, 49), "북부청사방": (67, 45), "거창": (61, 13), "봉화": (65, 31)} +BLOCKS |= {"동부청영월": (69, 35), "울진대흥": (47, 14), "영덕계류": (15, 2), "울진소광": (40, 17)} + + +def _sks(folder) -> dict[str, dict]: + return {p.stem: json.loads(p.read_bytes()) for p in (folder / "sangeun").glob("SK-*.json")} + + +def _ready(client, tag="북부청임도"): + path = golden.FILES[tag] + key = _post(client, data=path.read_bytes(), name=path.name).json()["키"] + folder = layers.personal_dir(7, 42) + fit.run_job(folder, key) + return folder, key, f"{URL.format('personal')}/{key}/sangeun" + + +def _first_picks(row: dict) -> dict: + """고를 줄마다 첫 후보(없으면 뺌).""" + picks = {} + for x in row["줄"]: + if x["상태"] in (units.PICK, units.NONE): + c = (x["후보"] or [{}])[0] + k = c.get("키") or c.get("자재") + field = {"L": "노임", "X": "기계", "M": "자재"}.get(x["묶음"]) + picks[str(x["차례"])] = {field: k} if k and field else c or {"뺌": "현장"} + return picks + + +def test_북부청_임도_밖_블록_판정_확인_빈_블록_칠(client): + before = master_hash() + folder, key, url = _ready(client) + got = client.post(url, json={}).json() + rows = {r["번"]: r for r in got["판정"]} + assert got["블록"] == len(rows) == 95 and sum(bool(r["부름"]) for r in rows.values()) == 49 + assert all(r["근거"] is None for r in rows.values() if not r["부름"]) # 밖 = 맞춤셈 없음 + inside = next(r for r in rows.values() if r["부름"]) + assert all(isinstance(n, int) for n in inside["부름"]) + eight, empty = rows[8], rows[1] + assert eight["부름"] == [] and eight["판정"] == skunit.GOOD and eight["줄"] + assert (empty["판정"], empty["고를줄"], empty["줄"]) == (skunit.EMPTY, 1, []) + # 밖 블록 확인 = 단위 SK(이름 · 단위 = 블록) · 빈 블록 = 못만듦(빈 SK 없음) + made = client.post(url, json={"확인": [{"번": 8}, {"번": 1}]}).json() + assert [x["번"] for x in made["만듦"]] == [8] + assert made["못만듦"] == [{"번": 1, "이름": empty["이름"], "까닭": skunit.EMPTY}] + docs = _sks(folder) + assert list(docs) == [made["만듦"][0]["키"]] + doc = docs[made["만듦"][0]["키"]] + assert (doc["이름"], doc["단위"]) == (eight["이름"], eight["단위"]) + assert doc["바탕"] is None and doc["더한줄"] and "근거" not in doc["본뜬"] + assert client.post(url, json={"확인": [{"번": 9999}]}).status_code == 404 + # 칠 재료 — 밖 블록도 줄 · 어긋남 · 부름 · 만든 SK + marks = {r["번"]: r for r in client.get(f"{URL.format('personal')}-marks/{key}").json()["블록"]} + assert len(marks) == 95 and marks[8]["SK"] == doc["키"] and marks[8]["부름"] == [] + assert marks[8]["판정"] is None and len(marks[8]["줄"]) == len(eight["줄"]) + assert all(s == units.LINKED for _, s, _ in marks[8]["줄"]) + assert {n for n, r in marks.items() if r["어긋남"]} == {2, 33, 64, 95, 4, 44, 48, 77, 78} + assert master_hash() == before + + +def test_울진소광_하위_밖_블록_함께(client): + folder, key, url = _ready(client, "울진소광") + rows = {r["번"]: r for r in client.post(url, json={}).json()["판정"]} + assert rows[83]["부름"] == [] and rows[492]["부름"] and rows[918]["부름"] + sure = [{"번": n, "고름": _first_picks(rows[n])} for n in (492, 83)] + made = client.post(url, json={"확인": sure}).json() + assert [x["번"] for x in made["만듦"]] == [83, 492] and made["못만듦"] == [] + sub = made["만듦"][0]["키"] + top = _sks(folder)[made["만듦"][1]["키"]] + assert any((x.get("본뜬") or {}).get("키") == sub for x in top["더한줄"]) # 하위 SK 줄 펼침 + + +def test_여덟_파일_모든_블록_판정(): + us = units.Units() + for tag, (n, called) in BLOCKS.items(): + hp = golden.read(tag) + blocks, first = sangeun.calls(hp) + call = sangeun.callers(hp) + assert (len(blocks), len(first)) == (n, called), tag + assert {b for b in blocks if call.get(b)} == set(first), tag # 부름 = 첫 줄과 같은 블록 + for no, block in blocks.items(): + got = skunit.judge(block, us, lambda sub: None, None, str(block.get("단위") or "")) + if not block.get("항목"): # 빈 블록 = 모두 밖 + assert no not in first and got["판정"] == skunit.EMPTY, (tag, no) From 9018227d71f3514064ba80fc2d06326b66c25326 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 4 Nov 2026 18:12:15 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(780-2):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=AA=A9=EB=A1=9D=20=ED=8C=90=20=E2=80=94=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EB=A7=8C=20=EA=B5=B4=EB=A6=BC(=EA=B1=B0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EC=98=AE=EA=B8=B0=EA=B8=B0=20?= =?UTF-8?q?=EB=A7=89=EB=8C=80=20=EB=8A=98=20=EB=B3=B4=EC=9E=84)=20=C2=B7?= =?UTF-8?q?=20=EC=A2=81=EC=9D=80=20=ED=99=94=EB=A9=B4(900=20=EC=9D=B4?= =?UTF-8?q?=ED=95=98)=20=EC=8C=93=EC=9E=84=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_MasterTemplete_Hopyo.css | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 289d75205..ddb2d50e6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -285,15 +285,25 @@ } /* 단가산출 탭 오른쪽 = 단가산출 목록(780-2) — 판 넓힘 · 숨김이 display 에 안 덮이게 · 강조 줄 */ -.m02-hopyo-sheet.is-sk { - grid-template-columns: minmax(0, 1fr) minmax(22rem, 42%); +@media (min-width: 901px) { + .m02-hopyo-sheet.is-sk { + grid-template-columns: minmax(0, 1fr) minmax(22rem, 42%); + } } .m02-hopyo-sheet__side[hidden] { display: none; } -.m02-hopyo-sheet__sk { +/* 목록만 굴림 — 거르기 · [확인한 것 옮기기] 막대는 늘 보임 */ +.m02-hopyo-sheet__sk > .m02-hopyo { + flex: 1; + min-height: 0; +} + +.m02-hopyo-sheet__sk [data-wiz-sk] { + flex: 1; + min-height: 0; overflow: auto; }