diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 8e771bad7..9229291f9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -49,8 +49,8 @@ font-variant-numeric: tabular-nums; } -/* 줄 표 — 갈래 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 입력값 · 환산 · 단추 = 줄바꿈 없음(글자가 세로로 안 - 갈라짐 · 폭 = 글 폭) · 품명 · 규격 · 대상이 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */ +/* 확인 창 줄 표(338) — 체크 · 갈래 · 수량 · 단위 · 상태 · 단추 = 줄바꿈 없음(글자가 세로로 안 갈라짐 · + 폭 = 글 폭) · 품명 · 규격 · 우리 로직 · 옵션 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */ .m02-hopyo__table.m02-hopyo__lines :is(th, td) { padding: var(--spacing-8); } @@ -58,19 +58,17 @@ .m02-hopyo__table.m02-hopyo__lines :is(th, td):is( :nth-child(1), - :nth-child(4), + :nth-child(2), :nth-child(5), :nth-child(6), :nth-child(7), - :nth-child(9), - :nth-child(10), :nth-child(11) ) { white-space: nowrap; } -/* 입력값 — 길면 줄임표(전체 글은 칸 풍선 도움말) */ -.m02-hopyo__table.m02-hopyo__lines td:nth-child(9) { +/* 옵션 · 품 비교 — 길면 줄임표(전체 글은 칸 풍선 도움말) */ +.m02-hopyo__table.m02-hopyo__lines td:is(:nth-child(9), :nth-child(10)) { max-width: 24em; overflow: hidden; text-overflow: ellipsis; @@ -89,23 +87,56 @@ white-space: nowrap; } -.m02-hopyo__state.is-기억, -.m02-hopyo__state.is-같음, +/* 상태 색(338) — 맞음 초록 · 별칭 파랑 · 다른 점 주황 · 후보 회색(테두리 기본) · 없음 빨강 */ +.m02-hopyo__state.is-맞음, .m02-hopyo__state.is-호표 { border-color: var(--color-success); color: var(--color-success); } -.m02-hopyo__state.is-후보 { +.m02-hopyo__state.is-별칭 { + border-color: var(--color-info); + color: var(--color-info); +} + +.m02-hopyo__state.is-다른점 { border-color: var(--color-warning); color: var(--color-warning); } +.m02-hopyo__state.is-후보, +.m02-hopyo__state.is-뺌, +.m02-hopyo__state.is-노임 { + color: var(--color-text-secondary); +} + .m02-hopyo__state.is-없음 { border-color: var(--color-danger); color: var(--color-danger); } +/* 우리 로직 칸 — 대상 여럿 = 칸 안 여러 줄(이름 · 키 · 몫 · 배 칸 · [옵션]) */ +.m02-hopyo__parts { + min-width: 14em; +} + +.m02-hopyo__part { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); +} + +.m02-hopyo__part + .m02-hopyo__part { + margin-top: var(--spacing-4); +} + +.m02-hopyo__whole { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + .m02-hopyo__times { width: 6em; } @@ -150,6 +181,22 @@ margin-bottom: var(--spacing-8); } +.m02-hopyo__cand { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); +} + +/* 별칭 장부(338) — 위 층 줄 = 회색 읽기만 · 고친 줄 = 왼쪽 띠 */ +.m02-hopyo-ledger__row.is-upper { + color: var(--color-text-muted); +} + +.m02-hopyo-ledger__row.is-edited > td:first-child { + box-shadow: inset 3px 0 0 var(--color-accent); +} + /* 엑셀 보기 창(329) — 왼쪽 읽기 전용 시트 · 오른쪽 호표 목록 · 불러오기 결과 */ .m02-hopyo-sheet__dialog > .ui-modal__body { overflow: hidden; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 12312fba2..9cd7fc52e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Api.ts - * 일위대가 실무자료(PLAN 324) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) · - * 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로). + * 일위대가 실무자료(PLAN 324 · 338) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) · + * 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로) · + * 맞춤 응답 꼴 = 설계 3-3(상태 · 체크 제안 · 대상 목록 · 품 비교) · 만들기 몸 = 확인한 줄만(3-6) · 별칭 장부(3-5). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -33,7 +34,7 @@ export interface HopyoBrief { 규격: string; 단위: string; 금액: Money; - /** [들어감 수, 줄 수] */ + /** [체크 제안 수, 줄 수] */ 맞춤: [number, number]; /** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */ 행?: [number, number]; @@ -75,7 +76,7 @@ export interface HopyoLineBasis { 행: [number, number]; } -/** 맞춤 대상 — 로직 · 자재 · 다른 호표 */ +/** 후보 · 고른 대상 글 — 로직 · 자재 · 다른 호표 */ export interface HopyoTarget { 로직?: string; 자재?: string; @@ -86,17 +87,55 @@ export interface HopyoTarget { 점수?: number; /** 근거 맞춤(330 · 옛 파일 · 근거 없는 줄은 없음) */ 근거?: HopyoBasisHit; + /** 후보 판정(338) — 맞음 · 다른 점 · 후보 */ + 판정?: "맞음" | "다른 점" | "후보"; + 글?: string; } +/** 맞춤 대상 하나(338 · 로직 여럿 가능) — 배 = 환산 × 몫 · 우리 수량 = 엑셀 수량 × 배 */ +export interface HopyoPart extends HopyoTarget { + 입력값: Record; + 못읽음?: string[]; + /** 칸마다 값 출처 — 별칭 · 블록 · 규격 · 자동값 */ + 출처?: Record; + 환산?: { 배: number | null; 갈래: string } | null; + 몫?: number | null; + 배: number | null; + /** 블록 절 제목(조합 덧 로직) */ + 어디?: string; +} + +/** 품 묶음 — 직종 품 · 장비 시간 · 자재 수량 */ +export interface HopyoQty { + 인력?: Record; + 장비?: { 갈래: string | string[]; 시간: number }[]; + 자재?: { 이름: string; 수량: number }[]; +} + +export type HopyoState = + "맞음" | "별칭" | "다른 점" | "후보" | "없음" | "뺌" | "호표" | "%" | "노임"; + export interface HopyoMatch { 열쇠: string; - 상태: "기억" | "같음" | "후보" | "없음" | "빼기" | "호표" | "%"; - 대상: HopyoTarget | null; - 입력값: Record; - 못읽음: string[]; - 환산: { 배: number | null; 갈래: string } | null; - 들어감: boolean; + 상태: HopyoState; + /** 처음 체크 제안(확인 대기 — 들어감 아님) */ + 체크: boolean; + 대상: HopyoPart[]; 후보?: HopyoTarget[]; + 글?: string; + 꼬리표?: string[]; + 품?: { 엑셀: HopyoQty; 우리: HopyoQty; 같음: Record }; + 갈리는칸?: string[]; + 별칭?: { 층: Layer; 주인: string | null; 규격다름?: boolean; 근거다름?: boolean }; + 뺌?: string; +} + +/** 숫자 판정 셈 — 끝 · 한 블록 · 모두 · 까닭(우리 로직 준비 중 등) */ +export interface HopyoCalc { + 끝: boolean; + 한: number; + 모두: number; + 까닭?: string; } export interface HopyoLine { @@ -115,14 +154,38 @@ export interface HopyoLine { } export interface HopyoOne extends HopyoFile { - 호표: HopyoBrief & { 줄: HopyoLine[] }; + 호표: HopyoBrief & { + 줄: HopyoLine[]; + /** 직접 줄 호표 통째 제안(노임 · 장비를 바로 적은 호표) */ + 통째?: { 대상: HopyoPart[]; 글?: string; 체크: boolean } | null; + 셈?: HopyoCalc; + /** 만들 층에 이 호표로 만든 사전조합 */ + 만든?: { 키: string; 확인: boolean } | null; + }; } +/** 미리 셈(저장 안 함) — 고른 대상 · 옵션으로 우리 품 */ export interface HopyoGuess { - 대상: HopyoTarget; - 입력값: Record; - 못읽음: string[]; - 환산: { 배: number | null; 갈래: string }; + 대상: HopyoPart[]; + 품?: HopyoMatch["품"]; + 글?: string; +} + +/** 만들기 몸 대상 하나 — 로직 · 자재 · 호표 중 하나(금액 · 품 칸 없음 — 서버가 셈) */ +export interface BuildTarget { + 로직?: string; + 자재?: string; + 호표?: number; + 입력값?: Record; + 배: number; + 몫?: number | null; +} + +/** 확인한 줄 — 대상 목록 또는 뺌 까닭 */ +export interface BuildLine { + 차례: number; + 대상?: BuildTarget[]; + 뺌?: "우리에 없음" | "안 씀"; } export interface HopyoMissing { @@ -141,22 +204,45 @@ export interface HopyoBuilt { 다시씀: boolean; 빠진줄: HopyoMissing[]; 비교: { 엑셀: number | null; 우리: number | null; 차이: number | null; 까닭: string | null }; + 장부?: { 씀: number }; } -/** [로직 추가] 「실무 호표」 줄 하나 */ +/** [로직 추가] 「실무 호표」 줄 하나 — 확인 = 문서 층에 확인된 사전조합 */ export interface HopyoPickRow extends Omit { 층: Layer; 주인: string | null; 키: string; 파일: string; + 확인?: LaborRef | null; } -/** 확정 한 줄 — 로직 · 자재 · 빼기 · null(지움) */ -export type HopyoMemory = - | { 로직: string; 입력값: Record; 환산: number } - | { 자재: string; 입력값: Record; 환산: number } - | { 빼기: true } - | null; +/** 별칭 장부 짝 하나 */ +export interface LedgerPair { + 로직?: string; + 자재?: string; + 입력값: Record; + 배: number; + 몫: number | null; +} + +/** 별칭 장부 한 줄(쓰기 꼴) */ +export interface LedgerEntry { + 원래: { 품명: string; 규격: string; 단위: string }; + 갈래: "짝" | "우리에 없음" | "안 씀"; + 짝: LedgerPair[]; + 엑셀조항?: unknown[]; +} + +/** 별칭 장부 보기 한 줄 — 층 · 고침(이 층 · 쓰기 권한) */ +export interface LedgerRow extends LedgerEntry { + 열쇠: string; + 누가: { id: number; 이름: string | null } | null; + 때: string | null; + 처음파일: string | null; + 층: Layer; + 주인: string | null; + 고침: boolean; +} const enc = encodeURIComponent; /** 지금 화면 층(구조물 조합 층) */ @@ -177,39 +263,58 @@ export const readHopyo = (scope: HopyoScope, key: string): Promise => export const readHopyoGrid = (scope: HopyoScope, key: string): Promise => call(at(scope, `/hopyo-grid/${enc(key)}`)); -export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise => - call(at(scope, `/hopyo/${enc(key)}/${no}`)); +/** 호표 하나 — `to` = 만들 층(호표 참조 줄 체크 · 만든 · 없으면 파일 층) */ +export const readHopyoOne = ( + scope: HopyoScope, + key: string, + no: number, + to?: HopyoScope, +): Promise => { + const want = to ? `?to=${to.layer}${to.owner ? `&to_owner=${enc(to.owner)}` : ""}` : ""; + return call(at(scope, `/hopyo/${enc(key)}/${no}${want}`)); +}; +/** 미리 셈(저장 안 함) — 고른 대상 목록 · 옵션 → 우리 품 */ export const guessHopyo = ( scope: HopyoScope, key: string, no: number, 차례: number, - 대상: HopyoTarget, + 대상: BuildTarget[], ): Promise => call(at(scope, `/hopyo/${enc(key)}/${no}/guess`), { method: "POST", body: JSON.stringify({ 차례, 대상 }), }); -export const saveHopyoMemory = ( +export const readLedger = async (scope: HopyoScope): Promise => + (await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"))).줄; + +/** 별칭 장부 [저장] — 고친 줄 · null(지움) · 이 층만 */ +export const saveLedger = async ( scope: HopyoScope, - 줄: Record, -): Promise => - call(at(scope, "/hopyo-memory"), { method: "POST", body: JSON.stringify({ 줄 }) }); + 줄: Record, +): Promise => + ( + await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"), { + method: "PUT", + body: JSON.stringify({ 줄 }), + }) + ).줄; export const deleteHopyo = (scope: HopyoScope, key: string): Promise => call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE" }); -/** 실무 호표 → `target` 층 사전조합(파일 층 = `from`) */ +/** [확인하고 사전조합 만들기] — 확인한 줄만 · 직접 줄 호표 = `통째` → `target` 층 사전조합(파일 층 = `from`) */ export const buildHopyo = ( target: HopyoScope, from: { 층: Layer; 주인: string | null; 키: string; 번: number }, - 새로 = false, + 줄: BuildLine[], + 통째?: { 대상: BuildTarget[] } | null, ): Promise => call(at(target, "/hopyo-build"), { method: "POST", - body: JSON.stringify({ ...from, 새로 }), + body: JSON.stringify(통째 ? { ...from, 통째 } : { ...from, 줄 }), }); export const listHopyoPicks = async (scope: HopyoScope): Promise => diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts new file mode 100644 index 000000000..7087db5b9 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -0,0 +1,680 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Check.ts + * 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 · + * 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [확인하고 사전조합 만들기]로 서버에 보냄. + * · 자리 = 실무자료 본 칸(호표 누름) · [로직 추가] 「실무 호표」 모달 · 엑셀 보기 창 펼침 [확인 창 열기] + * · 머리 = 호표 · 「제안 n / 줄 m」 · 숫자 판정 셈 중이면 3 초마다 다시 읽음(끝나면 멈춤 · 만진 줄은 그대로) + * · 줄 = [✓] · 갈래 · 품명 · 규격 · 수량 · 단위 · 상태(맞음 · 별칭 · 다른 점 · 후보) · 우리 로직(여럿 · 몫 · 배) · + * 옵션(칸=값 · 출처 · 못 가름) · 품 비교(엑셀 → 우리 ✓ / ≠) · [고르기] [원문 보기] [엑셀에서 보기] · 뺌 까닭 + * · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈 + * · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음 + * · 화면 잠금은 [확인하고 사전조합 만들기] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만. + * 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) — 화면은 보이기 · 체크 · 고름만. + * ========================================================================== */ + +import { + createButton, + el, + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { loadingError, showLoading } from "@ui/ui_template_loading"; +import { openModal } from "@ui/ui_template_modal"; +import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; +import { + buildHopyo, + guessHopyo, + readHopyoOne, + type BuildLine, + type BuildTarget, + type HopyoBuilt, + type HopyoLine, + type HopyoMatch, + type HopyoOne, + type HopyoPart, + type HopyoScope, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { + builtWord, + inputsText, + openHopyoChoose, + sourceText, + targetText, +} from "./M02_MasterTemplete_Hopyo_Choose"; +import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet"; +import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; +import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; +import "./M02_MasterTemplete_Hopyo.css"; + +export const POLL_MS = 3000; +const OUT = ["우리에 없음", "안 씀"] as const; + +/** 확인 창 캐시 — 사용자가 만진 것만(서버 제안 위에 덮어 보임) */ +export interface CheckDraft { + 체크: Record; + 대상: Record; + 뺌: Record; + 품: Record; + 통째?: boolean; +} + +const empty = (): CheckDraft => ({ 체크: {}, 대상: {}, 뺌: {}, 품: {} }); +export const draftKey = (scope: HopyoScope, key: string, no: number): string => + `m02-hopyo-check:${scope.layer}:${scope.owner ?? ""}:${key}:${no}`; + +export function loadDraft(k: string): CheckDraft { + try { + const raw = sessionStorage.getItem(k); + return raw ? { ...empty(), ...(JSON.parse(raw) as Partial) } : empty(); + } catch { + return empty(); + } +} +export function saveDraft(k: string, d: CheckDraft): void { + try { + sessionStorage.setItem(k, JSON.stringify(d)); + } catch { + /* 캐시 못 씀 = 이 화면에만 남음 */ + } +} +export function dropDraft(k: string): void { + try { + sessionStorage.removeItem(k); + } catch { + /* 없음 */ + } +} + +/** 줄의 지금 대상 — 사용자가 고른 것(캐시) · 없으면 서버 제안 */ +export const partsOf = (line: HopyoLine, d: CheckDraft): HopyoPart[] => { + const got = d.대상[line.차례] ?? line.맞춤.대상; + return Array.isArray(got) ? got : []; // 옛 응답(대상 하나) = 대상 없음 +}; +/** 체크 — 사용자가 만진 줄은 캐시 · 아니면 서버 제안 */ +export const isChecked = (line: HopyoLine, d: CheckDraft): boolean => + d.체크[line.차례] ?? !!line.맞춤.체크; +/** 체크할 수 있음 — % 줄 아님 · 뺌 까닭 또는 대상(배 0 넘음) */ +export function checkable(line: HopyoLine, d: CheckDraft): boolean { + if (line.맞춤.상태 === "%") return false; + if (d.뺌[line.차례]) return true; + const parts = partsOf(line, d); + return parts.length > 0 && parts.every((p) => p.배 !== null && p.배 > 0); +} + +/** 대상 → 만들기 몸 대상(이름 · 품 같은 보이기 칸은 안 보냄) */ +export function toTarget(p: HopyoPart): BuildTarget { + if (p.호표 !== undefined) return { 호표: p.호표, 배: p.배 ?? 1 }; + const head = p.로직 ? { 로직: p.로직, 입력값: p.입력값 ?? {} } : { 자재: p.자재 }; + return { ...head, 배: p.배 ?? 1, ...(p.몫 != null ? { 몫: p.몫 } : {}) }; +} + +/** 체크한 줄만 → 만들기 몸 `줄` */ +export const bodyOf = (lines: HopyoLine[], d: CheckDraft): BuildLine[] => + lines + .filter((l) => isChecked(l, d) && checkable(l, d)) + .map((l) => + d.뺌[l.차례] + ? { 차례: l.차례, 뺌: d.뺌[l.차례] as BuildLine["뺌"] } + : { 차례: l.차례, 대상: partsOf(l, d).map(toTarget) }, + ); + +/** 빠질 줄 까닭별 수 — 확인 안 함 · 뺌 · % 줄 */ +export function leftOut(lines: HopyoLine[], d: CheckDraft): Record { + const out: Record = {}; + for (const l of lines) { + const why = + l.맞춤.상태 === "%" + ? "% 줄" + : !isChecked(l, d) || !checkable(l, d) + ? "확인 안 함" + : d.뺌[l.차례] + ? "뺌" + : null; + if (why) out[why] = (out[why] ?? 0) + 1; + } + return out; +} + +const n4 = (v: number | undefined): string => + v === undefined ? "–" : String(Math.round(v * 10000) / 10000); + +/** 품 비교 글 — 「콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠」 */ +export function compareText(q: HopyoMatch["품"] | undefined): string { + if (!q) return ""; + const ex = q.엑셀.인력 ?? {}; + const ours = q.우리.인력 ?? {}; + const mark = (k: string): string => (q.같음[k] ? "✓" : "≠"); + const out = [...new Set([...Object.keys(ex), ...Object.keys(ours)])].map( + (k) => `${k} ${n4(ex[k])} → ${n4(ours[k])} ${mark(k)}`, + ); + const name = (g: string | string[]): string => [g].flat().join("+"); + for (const x of q.엑셀.장비 ?? []) { + const o = (q.우리.장비 ?? []).find((y) => name(y.갈래) === name(x.갈래)); + out.push(`${name(x.갈래)} ${n4(x.시간)} → ${n4(o?.시간)} ${mark([x.갈래].flat()[0])}`); + } + return out.join(" · "); +} + +/** 줄마다 최신 요청만 — `ask(줄)` 이 돌려준 함수가 그 뒤 같은 줄 요청이 없을 때만 true */ +export function newest(): (key: number) => () => boolean { + const last = new Map(); + return (key) => { + const my = (last.get(key) ?? 0) + 1; + last.set(key, my); + return () => last.get(key) === my; + }; +} + +/** [맞음 · 별칭 모두 체크] 대상 줄 */ +export const sureLines = (lines: HopyoLine[], d: CheckDraft): HopyoLine[] => + lines.filter((l) => ["맞음", "별칭"].includes(l.맞춤.상태) && checkable(l, d)); + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const won = (v: number | null | undefined): string => + v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); +const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error"); +const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); +const td = (text: string, cls = ""): HTMLElement => + el("td", { text, className: cls, attrs: { title: text } }); +const openSheet = (o: HopyoSheetOptions): void => + void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail); + +export interface CheckOptions { + /** 실무자료 파일 층 */ + scope: HopyoScope; + key: string; + no: number; + /** 사전조합을 만들 층(없으면 파일 층) */ + target?: HopyoScope; + /** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */ + canWrite: boolean; + /** [← 호표 목록](본 칸) */ + onBack?: () => void; + /** 만들었음 — [로직 추가] 참조 줄 · 모달 닫기 */ + onBuilt?: (got: HopyoBuilt, hopyo: HopyoOne["호표"]) => void; +} + +/** 확인 창 몸 — 본 칸 · 모달이 같이 씀 */ +export function buildHopyoCheck(opt: CheckOptions): HTMLElement { + const root = el("div", { + className: "m02-hopyo m02-hopyo-check", + attrs: { "data-hopyo-check": `${opt.key}|${opt.no}` }, + }); + const target = opt.target ?? opt.scope; + const k = draftKey(opt.scope, opt.key, opt.no); + let d = loadDraft(k); + let got: HopyoOne | null = null; + let built: HopyoBuilt | null = null; + let seq = 0; + let timer = 0; + const ask = newest(); // 줄마다 최신 미리 셈만 + const keep = (): void => saveDraft(k, d); + + async function load(quiet = false): Promise { + const my = ++seq; + const stop = quiet ? () => {} : showLoading(root); + try { + const one = await readHopyoOne(opt.scope, opt.key, opt.no, opt.target); + if (my !== seq) return; + got = one; + draw(); + } catch (error) { + if (my === seq && !quiet) root.replaceChildren(loadingError(why(error), () => void load())); + } finally { + stop(); + } + } + + /** 대상 바꿈 → 캐시 · 체크 · 서버 미리 셈(최신 응답만) */ + function setParts(line: HopyoLine, parts: HopyoPart[]): void { + d.대상[line.차례] = parts; + d.체크[line.차례] = true; + delete d.뺌[line.차례]; + keep(); + draw(); + const mine = ask(line.차례); + guessHopyo(opt.scope, opt.key, opt.no, line.차례, parts.map(toTarget)).then((g) => { + if (!mine()) return; + if (Array.isArray(g.대상)) d.대상[line.차례] = g.대상; + if (g.품) d.품[line.차례] = g.품; + keep(); + draw(); + }, fail); + } + + function editOptions(line: HopyoLine, at: number): void { + const parts = partsOf(line, d); + const p = parts[at]; + if (!p?.로직) return; + fetchLogicInputs([p.로직]).then( + (res) => + openLaborInput({ + title: `${p.로직} ${p.이름 ?? ""}`.trim(), + logic: res.로직[0] ?? null, + values: (p.입력값 ?? {}) as never, + memo: null, + readOnly: !opt.canWrite, + done: (r) => + setParts( + line, + parts.map((x, i) => (i === at ? { ...x, 입력값: r.입력값 } : x)), + ), + }), + fail, + ); + } + + /** [원문 보기] — 엑셀 인용 조항 · 우리 로직 출처 두 갈래 */ + async function showSource(line: HopyoLine): Promise { + const excel = (line.근거?.출처 ?? []).map((x) => x.join(" ").trim()).filter(Boolean); + const codes = partsOf(line, d).flatMap((p) => (p.로직 ? [p.로직] : [])); + const ours = codes.length + ? (await fetchLogicInputs(codes).catch(() => ({ 로직: [] }))).로직.flatMap((x) => + x.출처 ? [`${x.로직} — ${x.출처}`] : [], + ) + : []; + openModal({ + title: `원문 — ${line.이름}`, + closeLabel: "닫기", + wide: true, + dialogClass: "m02-modal m02-hopyo__src", + mount: (body) => { + const part = (head: string, list: string[], cut: (s: string) => string): void => { + body.append(el("h4", { text: head })); + if (!list.length) body.append(note("없음")); + for (const s of list) { + const view = createSourceView({ + path: "/m02/logic-lib/source", + pdf: "/m02/logic-lib/source-pdf", + }); + view.show(cut(s)); + body.append(note(s), view.root); + } + }; + part("엑셀 인용 조항", excel, (s) => s); + part("우리 로직 출처", ours, (s) => s.split(" — ").slice(1).join(" — ")); + }, + }); + } + + function partCell(line: HopyoLine): HTMLElement { + const parts = partsOf(line, d); + return el("td", { + className: "m02-hopyo__parts", + children: parts.map((p, at) => { + const times = el("input", { + className: "ui-input ui-input--sm m02-hopyo__times", + attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) }, + }) as HTMLInputElement; + times.title = "배 — 우리 수량 = 엑셀 수량 × 배"; + times.disabled = !opt.canWrite; + times.addEventListener("change", () => { + const v = times.value === "" ? null : Number(times.value); + d.대상[line.차례] = parts.map((x, i) => (i === at ? { ...x, 배: v } : x)); + keep(); + draw(); + }); + const share = p.몫 != null ? ` · 몫 ${p.몫}` : ""; + return el("div", { + className: "m02-hopyo__part", + children: [ + el("span", { + text: `${targetText(p)} ${p.로직 ?? p.자재 ?? ""}${share}`.trim(), + attrs: { title: p.어디 ?? "" }, + }), + times, + ...(p.로직 + ? [ + createButton({ + label: "옵션", + variant: "ghost", + size: "sm", + onClick: () => editOptions(line, at), + }), + ] + : []), + ], + }); + }), + }); + } + + function lineRow(line: HopyoLine): HTMLElement { + const m = line.맞춤; + const parts = partsOf(line, d); + const box = el("input", { attrs: { type: "checkbox", "data-check": String(line.차례) } }); + const input = box as HTMLInputElement; + input.checked = isChecked(line, d) && checkable(line, d); + input.disabled = !opt.canWrite || !checkable(line, d); + input.addEventListener("change", () => { + d.체크[line.차례] = input.checked; + keep(); + draw(); + }); + const out = el("select", { + className: "ui-input ui-input--sm", + attrs: { title: "뺌 — 사전조합에 안 넣고 장부에 남김" }, + children: [ + el("option", { text: "넣음", attrs: { value: "" } }), + ...OUT.map((x) => el("option", { text: `뺌 — ${x}`, attrs: { value: x } })), + ], + }) as HTMLSelectElement; + out.value = d.뺌[line.차례] ?? ""; + out.disabled = !opt.canWrite || m.상태 === "%"; + out.addEventListener("change", () => { + if (out.value) d.뺌[line.차례] = out.value; + else delete d.뺌[line.차례]; + d.체크[line.차례] = !!out.value || d.체크[line.차례]; + keep(); + draw(); + }); + const sheet = (행: [number, number]): void => + openSheet({ + scope: opt.scope, + key: opt.key, + 파일: got?.파일 ?? "", + canWrite: opt.canWrite, + target, + 단산행: 행, + }); + const acts = [ + ...(opt.canWrite && m.상태 !== "%" + ? [ + createButton({ + label: "고르기", + variant: "ghost", + size: "sm", + onClick: () => + openHopyoChoose({ + line, + openSheet: sheet, + done: (ts) => + setParts( + line, + ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })), + ), + }), + }), + ] + : []), + createButton({ + label: "원문 보기", + variant: "ghost", + size: "sm", + onClick: () => void showSource(line), + }), + ...(line.근거 + ? [ + createButton({ + label: "엑셀에서 보기", + variant: "ghost", + size: "sm", + onClick: () => sheet(line.근거!.행), + }), + ] + : []), + out, + ]; + const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n"); + const opts = parts + .filter((p) => p.로직) + .map((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처)) + .filter(Boolean); + if (m.갈리는칸?.length) opts.push(`⚠ 못 가름: ${m.갈리는칸.join(", ")}`); + const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : ""; + return el("tr", { + className: `m02-hopyo__line is-${input.checked ? "in" : "out"}`, + attrs: { "data-line": String(line.차례), "data-state": m.상태 }, + children: [ + el("td", { children: [box] }), + td(line.갈래), + td(line.이름), + td(line.규격), + td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"), + td(line.단위), + el("td", { + attrs: { title: tags }, + children: [ + el("span", { + className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`, + text: `${m.상태}${alias}`, + }), + ], + }), + partCell(line), + td(opts.join(" / ")), + td(compareText(d.품[line.차례] ?? m.품)), + el("td", { className: "m02-hopyo__acts", children: acts }), + ], + }); + } + + async function make(h: HopyoOne["호표"]): Promise { + const whole = !!h.통째 && (d.통째 ?? h.통째.체크); + const lines = whole ? [] : bodyOf(h.줄, d); + const out = leftOut(h.줄, d); + const put = lines.filter((x) => x.대상).length; + const outs = lines.length - put; + const ask = whole + ? `호표 통째로 만듦 — ${h.통째!.대상.map(targetText).join(" · ")}` + : `넣을 줄 ${put} · 뺌 ${outs} · 빠질 줄 ${Object.values(out).reduce((a, b) => a + b, 0)}` + + (Object.keys(out).length + ? `(${Object.entries(out) + .map(([a, b]) => `${a} ${b}`) + .join(" · ")})` + : "") + + " — 확인하고 사전조합을 만듦"; + if (!(await showConfirmDialog(ask, "확인하고 만들기"))) return; + const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no }; + showLoadingOverlay(); + try { + const res = await buildHopyo( + target, + from, + lines, + whole ? { 대상: h.통째!.대상.map(toTarget) } : null, + ); + dropDraft(k); + d = empty(); + built = res; + const miss = res.빠진줄.length; + showToast(`${res.참조.키} ${builtWord(res)}${miss ? ` — 빠진 줄 ${miss}` : ""}`, "success"); + opt.onBuilt?.(res, h); + await load(true); + } catch (error) { + fail(error); + } finally { + hideLoadingOverlay(); + } + } + + function builtBlock(res: HopyoBuilt): HTMLElement { + const c = res.비교; + const rows = res.빠진줄.map((x) => + el("tr", { + children: [ + td(x.이름), + td(x.규격), + td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"), + td(x.단위), + td(won(x.금액), "m02-hopyo__num"), + td(x.까닭), + ], + }), + ); + return el("div", { + className: "m02-hopyo__block m02-hopyo__built", + attrs: { "data-built": res.참조.키 }, + children: [ + note(res.다시씀 ? `있던 ${res.참조.키} 를 확인한 내용으로 덮음` : `${res.참조.키} 만듦`), + ...(rows.length + ? [ + el("h4", { text: `빠진 줄 ${rows.length}` }), + el("table", { + className: "ui-general-block__table m02-hopyo__table", + children: [ + el("thead", { + children: [ + el("tr", { + children: ["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"].map((t) => + el("th", { text: t }), + ), + }), + ], + }), + el("tbody", { children: rows }), + ], + }), + ] + : []), + note( + `값 비교(참고) — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` + + ` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`, + ), + ], + }); + } + + function draw(): void { + if (!got) return; + const h = got.호표; + const checked = h.줄.filter((l) => isChecked(l, d) && checkable(l, d)).length; + const calc = h.셈; + window.clearTimeout(timer); + if (calc && !calc.끝) + timer = window.setTimeout(() => root.isConnected && void load(true), POLL_MS); + const tools: HTMLElement[] = []; + if (opt.canWrite) { + const sure = sureLines(h.줄, d); + const all = createButton({ label: `맞음 · 별칭 모두 체크 (${sure.length})`, size: "sm" }); + all.disabled = !sure.length; + all.addEventListener("click", () => { + for (const l of sure) d.체크[l.차례] = true; + keep(); + draw(); + }); + const whole = !!h.통째 && (d.통째 ?? h.통째.체크); + const go = createButton({ label: "확인하고 사전조합 만들기", variant: "filled", size: "sm" }); + go.disabled = !whole && checked < 1; + go.setAttribute("data-hopyo-make", ""); + go.addEventListener("click", () => void make(h)); + tools.push(all, go); + } + const back = opt.onBack + ? [createButton({ label: "← 호표 목록", variant: "ghost", size: "sm", onClick: opt.onBack })] + : []; + const head = el("div", { + className: "m02-hopyo__bar", + children: [ + ...back, + el("strong", { text: `제${h.번}호표 ${h.이름}` }), + note(h.규격), + note(h.단위), + note(`엑셀 단가 ${won(h.금액.계)}`), + note(`제안 ${h.맞춤[0]} / 줄 ${h.맞춤[1]} · 체크 ${checked}`), + ...(calc && !calc.끝 + ? [note(`숫자 판정 셈 중 ${calc.한}/${calc.모두}${calc.까닭 ? ` · ${calc.까닭}` : ""}`)] + : []), + ...(h.만든 ? [note(`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}`)] : []), + createButton({ + label: "엑셀에서 보기", + size: "sm", + onClick: () => + openSheet({ + scope: opt.scope, + key: opt.key, + 파일: got!.파일, + canWrite: opt.canWrite, + target, + 호표: opt.no, + }), + }), + ...tools, + ], + }); + const whole = h.통째 + ? (() => { + const box = el("input", { + attrs: { type: "checkbox", "data-hopyo-whole": "" }, + }) as HTMLInputElement; + box.checked = d.통째 ?? h.통째.체크; + box.disabled = !opt.canWrite; + box.addEventListener("change", () => { + d.통째 = box.checked; + keep(); + draw(); + }); + return [ + el("label", { + className: "m02-hopyo__bar m02-hopyo__whole", + children: [ + box, + note( + `통째 = ${h.통째.대상.map(targetText).join(" · ")}${h.통째.글 ? ` — ${h.통째.글}` : ""}`, + ), + ], + }), + ]; + })() + : []; + const heads = [ + "", + "갈래", + "품명", + "규격", + "수량", + "단위", + "상태", + "우리 로직 · 배", + "옵션", + "품 비교", + "", + ]; + root.replaceChildren( + head, + ...whole, + h.줄.length + ? el("div", { + className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", + children: [ + el("table", { + className: "ui-general-block__table m02-hopyo__table m02-hopyo__lines", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: h.줄.map(lineRow) }), + ], + }), + ], + }) + : note("구성 줄 없음"), + ...(built ? [builtBlock(built)] : []), + ); + } + + void load(); + return root; +} + +/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 [확인 창 열기] · 만들면 닫음 */ +export function openHopyoCheck(opt: CheckOptions & { title?: string }): void { + openModal({ + title: opt.title ?? `확인 — 제${opt.no}호표`, + closeLabel: "닫기", + wide: true, + dialogClass: "m02-modal m02-hopyo__check-modal", + mount: (body, close) => + body.append( + buildHopyoCheck({ + ...opt, + onBuilt: (got, h) => { + opt.onBuilt?.(got, h); + close(); + }, + }), + ), + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts new file mode 100644 index 000000000..26046f7fc --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts @@ -0,0 +1,231 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Choose.ts + * 일위대가 실무자료 고르기 창(PLAN 338-3 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미. + * · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀에서 보기](단가산출 그 행) + * · 후보 다섯 = 이름 · 키 · 판정 꼬리표(맞음 / 다른 점 / 후보) · 글 · 근거 꼬리표(근거 맞음 초록) · 금액 비 없음 + * · 고름 = [고름](한 로직) · 또는 체크 + 몫 칸 → [고른 n개 함께](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움 + * · [다른 로직 찾기] = `openLogicPicker` 에 찾기 글 미리 넣음(엑셀 인용 조항 번호 → 없으면 품명 첫 뜻 낱말) + * 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감. + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { + BuildTarget, + HopyoBasisHit, + HopyoLine, + HopyoLineBasis, + HopyoQty, + HopyoTarget, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { pickMaterial } from "./M02_Material_Pick"; +import "./M02_MasterTemplete_Hopyo.css"; + +const MATERIAL = new Set(["자재", "별도계상"]); +const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); +const num = (v: number): string => String(Math.round(v * 10000) / 10000); + +/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */ +export const targetText = (t: HopyoTarget | null | undefined): string => { + if (!t) return ""; + if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim(); + const spec = t.규격 ? ` ${t.규격}` : ""; + return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`; +}; + +/** 입력값 글 — 「이름=값」 · 칸 출처(블록 · 규격 · 별칭) · 못 가르는 칸 표시 */ +export const inputsText = ( + values: Record, + unread: string[], + from: Record = {}, +): string => + [ + Object.entries(values) + .map(([k, v]) => `${k}=${String(v)}${from[k] && from[k] !== "자동값" ? `(${from[k]})` : ""}`) + .join(", "), + unread.length ? `⚠ 못 가름: ${unread.join(", ")}` : "", + ] + .filter(Boolean) + .join(" · "); + +/** 만들기 결과 낱말 — 있던 것을 덮음 · 새로 만듦 */ +export const builtWord = (got: { 다시씀: boolean }): string => + got.다시씀 ? "있던 것을 확인한 내용으로 덮음" : "만듦"; + +/** 근거 맞음 — 조항 같음(앞 마디 아님) · 단위 맞음 · 직종 겹침 + * (줄 근거 직종이 보통인부뿐이거나 없으면 — 기계 등 — 직종은 안 봄) */ +export const basisOk = (b: HopyoBasisHit | undefined, line?: HopyoLineBasis): boolean => + !!b && + b.까닭.startsWith("조항 같음") && + b.단위 && + (b.직종.length > 0 || !(line?.직종 ?? []).some((x) => x !== "보통인부")); + +/** 후보 근거 꼬리표 — 「조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음」 · 근거 없으면 "" */ +export const basisTag = (b: HopyoBasisHit | undefined): string => + b + ? [ + b.조항.length ? `조항 ${b.조항.join(", ")}` : "", + b.직종.length ? `직종 ${b.직종.join(", ")}` : "", + b.단위 ? "단위 같음" : "", + ] + .filter(Boolean) + .join(" · ") + : ""; + +/** 엑셀 근거 줄 — 「출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행」 · 없으면 "" */ +export const sourceText = (b: HopyoLineBasis | undefined): string => + b + ? [ + b.출처.length ? `출처 ${b.출처.map((x) => x.join(" ").trim()).join(", ")}` : "", + b.직종.length ? `직종 ${b.직종.join(", ")}` : "", + `단가산출 ${b.행[0] === b.행[1] ? b.행[0] : `${b.행[0]}–${b.행[1]}`}행`, + ] + .filter(Boolean) + .join(" · ") + : ""; + +/** 품 글 — 「콘크리트공 0.1091 · 굴착기 0.29hr · 레미콘 1.02」 */ +export const qtyText = (q: HopyoQty | undefined): string => + q + ? [ + ...Object.entries(q.인력 ?? {}).map(([k, v]) => `${k} ${num(v)}`), + ...(q.장비 ?? []).map((x) => `${[x.갈래].flat().join("+")} ${num(x.시간)}hr`), + ...(q.자재 ?? []).map((x) => `${x.이름} ${num(x.수량)}`), + ].join(" · ") + : ""; + +/** [다른 로직 찾기] 미리 넣을 글 — 엑셀 인용 조항 번호 → 없으면 품명 괄호 밖 첫 뜻 낱말 */ +export function searchHint(line: Pick): string { + const clause = line.근거?.출처?.find((x) => x[1])?.[1]; + if (clause) return clause; + return /[가-힣A-Za-z]{2,}/.exec(line.이름.replace(/\([^()]*\)/g, " "))?.[0] ?? ""; +} + +/** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */ +export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] => + picked.map(({ c, 몫 }) => ({ + ...(c.로직 ? { 로직: c.로직 } : { 자재: c.자재 }), + 입력값: {}, + 배: 몫 ?? 1, + 몫, + })); + +export interface ChooseOptions { + line: HopyoLine; + /** [엑셀에서 보기] — 단가산출 그 행 */ + openSheet?: (행: [number, number]) => void; + done: (targets: BuildTarget[]) => void; +} + +/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 다섯(하나 또는 여럿 · 몫) · 다른 로직 · 자재 찾기 */ +export function openHopyoChoose(opt: ChooseOptions): void { + const { line } = opt; + const material = MATERIAL.has(line.갈래); + openModal({ + title: `고르기 — ${line.이름} ${line.규격}`.trim(), + closeLabel: "닫기", + dialogClass: "m02-modal m02-hopyo__choose", + mount: (body, close) => { + const finish = (targets: BuildTarget[]): void => { + close(); + opt.done(targets); + }; + const picked = new Map(); + const together = createButton({ label: "고른 0개 함께", size: "sm", disabled: true }); + const paint = (): void => { + together.querySelector(".ui-btn__label")!.textContent = `고른 ${picked.size}개 함께`; + together.disabled = picked.size < 2; + }; + together.addEventListener("click", () => + finish( + pickedTargets( + [...picked].map(([c, box]) => ({ c, 몫: box.value === "" ? null : Number(box.value) })), + ), + ), + ); + const list = (line.맞춤.후보 ?? []).map((c) => { + const share = el("input", { + className: "ui-input ui-input--sm m02-hopyo__times", + attrs: { type: "number", step: "0.05", min: "0", max: "1", placeholder: "몫" }, + }) as HTMLInputElement; + const box = el("input", { attrs: { type: "checkbox", title: "함께 고름(조합)" } }); + box.addEventListener("change", () => { + if ((box as HTMLInputElement).checked) picked.set(c, share); + else picked.delete(c); + paint(); + }); + const tag = basisTag(c.근거); + return el("div", { + className: "m02-hopyo__cand", + attrs: { "data-cand": c.로직 ?? c.자재 ?? "" }, + children: [ + box, + createButton({ + label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`, + variant: "ghost", + size: "sm", + onClick: () => finish(pickedTargets([{ c, 몫: null }])), + }), + ...(c.판정 + ? [ + el("span", { + className: `m02-hopyo__state is-${c.판정.replace(" ", "")}`, + text: c.판정, + }), + ] + : []), + ...(c.글 ? [note(c.글)] : []), + ...(tag && c.근거 + ? [ + el("span", { + className: `m02-hopyo__basis${basisOk(c.근거, line.근거) ? " is-ok" : ""}`, + text: tag, + attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` }, + }), + ] + : []), + share, + ], + }); + }); + const excel = qtyText(line.맞춤.품?.엑셀); + const basis = line.근거; + const head = el("div", { + className: "m02-hopyo__bar m02-hopyo__source", + children: [ + ...(excel ? [note(`엑셀 품 — ${excel}`)] : []), + ...(basis ? [note(`엑셀 근거 — ${sourceText(basis)}`)] : []), + ...(basis && opt.openSheet + ? [ + createButton({ + label: "엑셀에서 보기", + variant: "ghost", + size: "sm", + onClick: () => opt.openSheet?.(basis.행), + }), + ] + : []), + ], + }); + const more = createButton({ + label: material ? "다른 자재 찾기…" : "다른 로직 찾기…", + size: "sm", + onClick: () => { + close(); + if (material) pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }])); + else + openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, { + query: searchHint(line), + }); + }, + }); + body.append( + ...(head.childElementCount ? [head] : []), + note(list.length ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [함께]" : "가까운 것 없음"), + el("div", { className: "m02-hopyo__cands", children: list }), + el("div", { className: "m02-hopyo__bar", children: [together, more] }), + ); + }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts new file mode 100644 index 000000000..73dd80f8c --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts @@ -0,0 +1,358 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Ledger.ts + * 일위대가 실무자료 별칭 장부 화면(PLAN 338-3 · 설계 4-4) — 본 칸 · 좌측 판 [별칭 장부] 로 엶. + * · 표 = 엑셀 품명 · 규격 · 단위 | 갈래 | 우리 로직(여럿) · 옵션 · 배 · 몫 | 누가 · 때 · 처음 파일 | 층(위 층 회색 읽기만) + * · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [로직 더함] · 배 · 몫 · [옵션] · [지우기] · + * 위 층 줄 [내 층으로 고쳐 씀] + * · 고친 것 = sessionStorage `m02-hopyo-ledger:{층}:{주인}` → [저장] 때만 `PUT`(서버가 짝을 다시 검사) + * · 안 둠 = 발주처 칸 · 자동 올림 · 자동 합침 · 쓰인 횟수 · 회사로 올리기. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { loadingError, showLoading } from "@ui/ui_template_loading"; +import { + readLedger, + saveLedger, + type HopyoScope, + type LedgerEntry, + type LedgerPair, + type LedgerRow, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; +import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import "./M02_MasterTemplete_Hopyo.css"; + +export const LEDGER_TITLE = "별칭 장부"; +const KINDS = ["짝", "우리에 없음", "안 씀"] as const; +const LAYER_TEXT: Record = { system: "마스터", company: "회사", personal: "개인" }; +type Edits = Record; +/** 화면 한 줄 — 서버 줄 위에 고친 것 */ +export type LedgerView = LedgerRow & { 고친?: boolean }; + +export const ledgerKey = (scope: HopyoScope): string => + `m02-hopyo-ledger:${scope.layer}:${scope.owner ?? ""}`; +function loadEdits(k: string): Edits { + try { + return JSON.parse(sessionStorage.getItem(k) ?? "{}") as Edits; + } catch { + return {}; + } +} +function saveEdits(k: string, edits: Edits): void { + try { + if (Object.keys(edits).length) sessionStorage.setItem(k, JSON.stringify(edits)); + else sessionStorage.removeItem(k); + } catch { + /* 캐시 못 씀 = 이 화면에만 남음 */ + } +} + +/** 쓰기 꼴 — 보기 칸(층 · 누가 · 때 …) 뺌 */ +export const entryOf = (row: LedgerEntry): LedgerEntry => ({ + 원래: row.원래, + 갈래: row.갈래, + 짝: + row.갈래 === "짝" + ? row.짝.map((p) => ({ ...p, 입력값: p.입력값 ?? {}, 몫: p.몫 ?? null })) + : [], + 엑셀조항: row.엑셀조항 ?? [], +}); + +/** 서버 줄 + 고친 것 → 화면 줄 — 이 층 줄은 고친 값 · 지운 것 뺌 · 위 층에서 옮긴 줄은 이 층 줄로 더함 */ +export function mergeRows(rows: LedgerRow[], edits: Edits, scope: HopyoScope): LedgerView[] { + const mine = rows.filter((r) => r.고침); + const out: LedgerView[] = []; + for (const r of mine) { + if (!(r.열쇠 in edits)) out.push(r); + else if (edits[r.열쇠]) out.push({ ...r, ...edits[r.열쇠]!, 고친: true }); + } + for (const [key, entry] of Object.entries(edits)) { + if (!entry || mine.some((r) => r.열쇠 === key)) continue; + out.push({ + ...entry, + 열쇠: key, + 누가: null, + 때: null, + 처음파일: null, + 층: scope.layer, + 주인: scope.owner, + 고침: true, + 고친: true, + }); + } + return [...out, ...rows.filter((r) => !r.고침)]; +} + +/** 짝 글 — 「GC000198 × 1 · 몫 0.1 · 소량감률=50」 */ +export const pairText = (p: LedgerPair): string => + [ + `${p.로직 ?? p.자재 ?? ""} × ${p.배}`, + p.몫 != null ? `몫 ${p.몫}` : "", + Object.entries(p.입력값 ?? {}) + .map(([k, v]) => `${k}=${String(v)}`) + .join(", "), + ] + .filter(Boolean) + .join(" · "); + +/** 찾기 · 갈래 거름 */ +export const filterRows = (rows: LedgerView[], q: string, kind: string): LedgerView[] => { + const want = q.trim().toLowerCase(); + return rows.filter( + (r) => + (!kind || r.갈래 === kind) && + (!want || + `${r.원래.품명} ${r.원래.규격} ${r.짝.map(pairText).join(" ")}` + .toLowerCase() + .includes(want)), + ); +}; + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const td = (text: string, cls = ""): HTMLElement => + el("td", { text, className: cls, attrs: { title: text } }); +const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); + +export interface LedgerOptions { + scope: HopyoScope; + /** 이 층 쓰기 권한 — 없으면 보기만 */ + canWrite: boolean; +} + +export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { + const root = el("div", { + className: "m02-hopyo m02-hopyo-ledger", + attrs: { "data-hopyo-ledger": "" }, + }); + const k = ledgerKey(opt.scope); + let edits = loadEdits(k); + let rows: LedgerRow[] = []; + const search = el("input", { + className: "ui-input ui-input--sm m02-hopyo__search", + attrs: { type: "search", placeholder: "품명 · 규격 · 로직 찾기" }, + }) as HTMLInputElement; + const kind = el("select", { + className: "ui-input ui-input--sm", + children: [ + el("option", { text: "갈래 전체", attrs: { value: "" } }), + ...KINDS.map((x) => el("option", { text: x, attrs: { value: x } })), + ], + }) as HTMLSelectElement; + const save = createButton({ label: "저장", variant: "filled", size: "sm" }); + const drop = createButton({ label: "고친 것 버림", variant: "ghost", size: "sm" }); + const body = el("tbody"); + const keep = (): void => { + saveEdits(k, edits); + paint(); + }; + const edit = (r: LedgerView, next: Partial): void => { + edits[r.열쇠] = entryOf({ ...entryOf(r), ...next }); + keep(); + }; + + function pairCell(r: LedgerView, mine: boolean): HTMLElement { + const pairs: HTMLElement[] = r.짝.map((p, at) => { + const parts: HTMLElement[] = [el("span", { text: pairText(p) })]; + if (mine) { + const times = el("input", { + className: "ui-input ui-input--sm m02-hopyo__times", + attrs: { type: "number", step: "any", min: "0", value: String(p.배), title: "배" }, + }) as HTMLInputElement; + times.addEventListener("change", () => + edit(r, { 짝: r.짝.map((x, i) => (i === at ? { ...x, 배: Number(times.value) } : x)) }), + ); + parts.push(times); + if (p.로직) + parts.push( + createButton({ + label: "옵션", + variant: "ghost", + size: "sm", + onClick: () => + void fetchLogicInputs([p.로직!]).then( + (got) => + openLaborInput({ + title: p.로직!, + logic: got.로직[0] ?? null, + values: p.입력값 as never, + memo: null, + readOnly: false, + done: (res) => + edit(r, { + 짝: r.짝.map((x, i) => (i === at ? { ...x, 입력값: res.입력값 } : x)), + }), + }), + (error) => showToast(why(error), "error"), + ), + }), + createButton({ + label: "×", + variant: "ghost", + size: "sm", + onClick: () => edit(r, { 짝: r.짝.filter((_, i) => i !== at) }), + }), + ); + } + return el("div", { className: "m02-hopyo__part", children: parts }); + }); + if (mine && r.갈래 === "짝") + pairs.push( + createButton({ + label: "로직 더함", + variant: "ghost", + size: "sm", + onClick: () => + openLogicPicker((x) => + edit(r, { 짝: [...r.짝, { 로직: x.키, 입력값: {}, 배: 1, 몫: null }] }), + ), + }), + ); + return el("td", { className: "m02-hopyo__parts", children: pairs }); + } + + function rowOf(r: LedgerView): HTMLElement { + const mine = opt.canWrite && r.고침; + const kindCell = mine + ? (() => { + const s = el("select", { + className: "ui-input ui-input--sm", + children: KINDS.map((x) => el("option", { text: x, attrs: { value: x } })), + }) as HTMLSelectElement; + s.value = r.갈래; + s.addEventListener("change", () => + edit(r, { 갈래: s.value as LedgerEntry["갈래"], 짝: s.value === "짝" ? r.짝 : [] }), + ); + return el("td", { children: [s] }); + })() + : td(r.갈래); + const acts: HTMLElement[] = []; + if (mine) + acts.push( + createButton({ + label: "지우기", + variant: "danger", + size: "sm", + onClick: () => { + edits[r.열쇠] = null; + keep(); + }, + }), + ); + else if (opt.canWrite && !rows.some((x) => x.고침 && x.열쇠 === r.열쇠) && !edits[r.열쇠]) + acts.push( + createButton({ + label: "내 층으로 고쳐 씀", + variant: "ghost", + size: "sm", + onClick: () => { + edits[r.열쇠] = entryOf(r); + keep(); + }, + }), + ); + const when = [r.누가?.이름, r.때?.replace("T", " ").slice(0, 16), r.처음파일] + .filter(Boolean) + .join(" · "); + return el("tr", { + className: `m02-hopyo-ledger__row${r.고침 ? "" : " is-upper"}${r.고친 ? " is-edited" : ""}`, + attrs: { "data-ledger": r.열쇠 }, + children: [ + td(r.원래.품명), + td(r.원래.규격), + td(r.원래.단위), + kindCell, + pairCell(r, mine), + td(when), + td(r.고침 ? "내 층" : (LAYER_TEXT[r.층] ?? r.층)), + el("td", { className: "m02-hopyo__acts", children: acts }), + ], + }); + } + + function paint(): void { + const all = mergeRows(rows, edits, opt.scope); + const shown = filterRows(all, search.value, kind.value); + const n = Object.keys(edits).length; + save.querySelector(".ui-btn__label")!.textContent = n ? `저장 (${n})` : "저장"; + save.disabled = drop.disabled = !n; + body.replaceChildren( + ...(shown.length + ? shown.map(rowOf) + : [el("tr", { children: [el("td", { text: "장부 줄 없음", attrs: { colspan: "8" } })] })]), + ); + } + search.addEventListener("input", paint); + kind.addEventListener("change", paint); + drop.addEventListener("click", () => { + edits = {}; + keep(); + }); + save.addEventListener("click", async () => { + save.disabled = true; + try { + rows = await saveLedger(opt.scope, edits); + edits = {}; + showToast("장부 저장", "success"); + keep(); + } catch (error) { + showToast(`저장 못 함 — ${why(error)}`, "error"); + paint(); + } + }); + + async function load(): Promise { + const stop = showLoading(root); + try { + rows = await readLedger(opt.scope); + } catch (error) { + root.replaceChildren(loadingError(why(error), () => void load())); + return; + } finally { + stop(); + } + root.replaceChildren( + el("div", { + className: "m02-hopyo__bar", + children: [ + el("strong", { text: LEDGER_TITLE }), + note("엑셀 이름 → 우리 로직 + 옵션 + 배 · 확인하고 만들 때 남음 · 아래 층이 이김"), + search, + kind, + ...(opt.canWrite ? [save, drop] : []), + ], + }), + el("div", { + className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", + children: [ + el("table", { + className: "ui-general-block__table m02-hopyo__table", + children: [ + el("thead", { + children: [ + el("tr", { + children: [ + "엑셀 품명", + "규격", + "단위", + "갈래", + "우리 로직 · 배 · 몫 · 옵션", + "누가 · 때 · 처음 파일", + "층", + "", + ].map((t) => el("th", { text: t })), + }), + ], + }), + body, + ], + }), + ], + }), + ); + paint(); + } + + void load(); + return root; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts index c460ce6a3..def3c122d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts @@ -1,13 +1,13 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Pick.ts - * [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(맞춤 n/m) · - * 고르면 서버가 그 호표를 문서 층 사전조합으로 만들고(`/hopyo-build` · 있던 것은 다시 씀) `lib.pick` 으로 - * 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림 · 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 · - * 고른 호표마다 참조 줄). 프로젝트 층엔 없음. + * [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4 · 338-3) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(제안 n/m) · + * 고름 = 문서 층에 확인된 사전조합이 있으면 그것을 바로 참조 · 없으면 확인 창 모달(`Hopyo_Check` · 늦게 불러옴) → + * [확인하고 사전조합 만들기] → `lib.pick` 참조 줄(별칭 = 호표 품명) · 확인 없이 만들지 않음 · + * 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 · 고른 호표마다 참조 줄). 프로젝트 층엔 없음. * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; -import { buildHopyo, listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api"; +import { listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api"; import type { LibraryPick, PickExtra } from "./M02_MasterTemplete_Labor_Pick"; import { tc } from "./M02_MasterTemplete_Labor_Text"; @@ -73,21 +73,29 @@ export function hopyoExtra(row: HopyoPickRow, lib: LibraryPick): PickExtra { tag: "실무 호표", name: [row.이름, row.규격].filter(Boolean).join(" "), sub: `${row.파일} · 제${row.번}호표 · ${LAYER_TEXT[row.층] ?? row.층}`, - unit: `${row.단위} · 맞춤 ${row.맞춤[0]}/${row.맞춤[1]}`, + unit: `${row.단위} · ${row.확인 ? `확인 ${row.확인.키}` : `제안 ${row.맞춤[0]}/${row.맞춤[1]}`}`, onPick: () => void pickHopyo(row, lib), }; } -/** 고름 — 서버가 사전조합을 만들고(다시 씀) 참조 줄을 더함 */ +/** 고름 — 확인된 사전조합이면 바로 참조 · 아니면 확인 창 모달 → 만든 뒤 참조 */ export async function pickHopyo(row: HopyoPickRow, lib: LibraryPick): Promise { + const alias = row.이름.slice(0, ALIAS_MAX).trim() || null; + if (row.확인) { + showToast(`「${row.이름}」 → 확인한 사전조합 ${row.확인.키}`, "success"); + return lib.pick(row.확인, row.이름, alias); + } try { - const from = { 층: row.층, 주인: row.주인, 키: row.키, 번: row.번 }; - const got = await buildHopyo({ layer: lib.layer, owner: lib.owner }, from); - const miss = got.빠진줄.length; - const head = `「${row.이름}」 → 사전조합 ${got.참조.키}${got.다시씀 ? "(있던 것)" : ""}`; - if (miss) showToast(`${head} · 빠진 줄 ${miss} — 실무자료 화면에서 맞춤`, "warning", 6000); - else showToast(head, "success"); - lib.pick(got.참조, got.이름, row.이름.slice(0, ALIAS_MAX).trim() || null); + const check = await import("./M02_MasterTemplete_Hopyo_Check"); + check.openHopyoCheck({ + title: `확인 — ${row.파일} 제${row.번}호표 ${row.이름}`, + scope: { layer: row.층, owner: row.주인 }, + key: row.키, + no: row.번, + target: { layer: lib.layer, owner: lib.owner }, + canWrite: true, + onBuilt: (got) => lib.pick(got.참조, got.이름, alias), + }); } catch (error) { showToast(error instanceof Error ? error.message : String(error), "error"); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index e547de48e..ab8f851b5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -1,15 +1,23 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Sheet.ts - * 일위대가 실무자료 엑셀 보기 창(PLAN 329) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 - * 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 맞춤 n/m) + [고른 n개 불러오기]. + * 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 + * 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 제안 n/m) + [고른 n개 확인하고 만들기]. * · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름 - * · 불러오기 = 고른 호표마다 차례로 사전조합 만들기(`/hopyo-build`) → 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · + * · ① [고른 n개 확인하고 만들기] = 고른 호표마다 서버 보기 → 펼침 표(넣을 줄 = 체크 제안 · 빠질 줄 · 셈 중 · + * [확인 창 열기]) ② [이대로 n개 만들기] = 하위 호표부터 차례로 `/hopyo-build`(몸 = 그 호표 체크 줄) — + * 펼쳐 보이고 한 번 누름 = 사용자 확인 · 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · * `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄) - * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 불러오기는 됨 + * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 확인하고 만들기는 됨 * ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게). * ========================================================================== */ -import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { + createButton, + el, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; @@ -17,13 +25,24 @@ import { buildHopyo, readHopyo, readHopyoGrid, + readHopyoOne, type HopyoBrief, type HopyoBuilt, type HopyoGrid, + type HopyoOne, type HopyoScope, type HopyoSpan, } from "./M02_MasterTemplete_Hopyo_Api"; -import { builtWord } from "./M02_MasterTemplete_Hopyo_View"; +import { + bodyOf, + draftKey, + leftOut, + loadDraft, + openHopyoCheck, + partsOf, + toTarget, +} from "./M02_MasterTemplete_Hopyo_Check"; +import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose"; import "./M02_MasterTemplete_Hopyo.css"; export interface HopyoSheetOptions { @@ -31,7 +50,7 @@ export interface HopyoSheetOptions { scope: HopyoScope; key: string; 파일: string; - /** 만들 층 쓰기 권한 — 없으면 체크 · 불러오기 없음(보기만) */ + /** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */ canWrite: boolean; /** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */ target?: HopyoScope; @@ -68,6 +87,19 @@ export function lastColumn(sheet: Pick): string { return best; } +/** 만들 차례 — 하위 호표(호표 참조 줄이 가리키는 고른 호표)부터 · 순환은 남은 차례대로 */ +export function buildOrder(ones: { 번: number; 아래: number[] }[]): number[] { + const left = new Map(ones.map((x) => [x.번, x.아래.filter((n) => n !== x.번)])); + const out: number[] = []; + while (left.size) { + const ready = [...left].find(([, kids]) => kids.every((n) => !left.has(n))); + const no = ready ? ready[0] : [...left.keys()][0]; + out.push(no); + left.delete(no); + } + return out; +} + /** 단가산출 행 → 행 전체 범위 A1(`4:9`) */ export const rowsText = (행: number | [number, number]): string => typeof 행 === "number" ? `${행}:${행}` : `${행[0]}:${행[1]}`; @@ -94,7 +126,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { }) as HTMLInputElement; const list = el("div", { className: "m02-hopyo-sheet__list" }); const count = note(""); - const go = createButton({ label: "고른 0개 불러오기", variant: "filled", size: "sm" }); + const go = createButton({ label: "고른 0개 확인하고 만들기", variant: "filled", size: "sm" }); go.disabled = true; const result = el("div", { className: "m02-hopyo-sheet__result" }); const right = el("div", { @@ -122,7 +154,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { }); const paintGo = (): void => { - go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 불러오기`; + go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 확인하고 만들기`; go.disabled = !checked.size; }; @@ -226,57 +258,147 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { } } - async function importPicked(): Promise { - const nos = [...checked].sort((a, b) => a - b); - const target = opt.target ?? opt.scope; - const rows: HTMLElement[] = []; - let ok = 0; - go.disabled = true; - for (const no of nos) { - const h = briefs.find((x) => x.번 === no); - if (!h || closed) continue; - const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() }); - try { - const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no }; - const got = await buildHopyo(target, from); - ok += 1; - opt.onImport?.(got, h); - rows.push( - el("tr", { - attrs: { "data-built": got.참조.키 }, - children: [ - head, - el("td", { text: `${got.참조.키} · ${builtWord(got, false)}` }), - el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }), - el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }), - ], - }), - ); - } catch (error) { - rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] })); - } - result.replaceChildren( - el("table", { - className: "ui-general-block__table m02-hopyo__table", - children: [ - el("thead", { - children: [ - el("tr", { - children: ["호표", "사전조합", "빠진 줄", "값 차이"].map((t) => - el("th", { text: t }), - ), - }), - ], - }), - el("tbody", { children: rows }), - ], + const table = (heads: string[], rows: HTMLElement[]): HTMLElement => + el("table", { + className: "ui-general-block__table m02-hopyo__table", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], }), - ); + el("tbody", { children: rows }), + ], + }); + + /** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */ + function bodyFor(one: HopyoOne): Parameters[2] { + const h = one.호표; + const d = loadDraft(draftKey(opt.scope, opt.key, h.번)); + return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d); + } + function wholeFor(one: HopyoOne): Parameters[3] { + const h = one.호표; + const d = loadDraft(draftKey(opt.scope, opt.key, h.번)); + return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null; + } + + /** ① 고른 호표마다 서버 보기 → 펼침 표 */ + async function review(): Promise { + const target = opt.target ?? opt.scope; + const nos = [...checked].sort((a, b) => a - b); + go.disabled = true; + const ones: HopyoOne[] = []; + for (const no of nos) { + if (closed) return; + try { + ones.push(await readHopyoOne(opt.scope, opt.key, no, target)); + } catch (error) { + showToast(`제${no}호표 — ${why(error)}`, "error"); + } } paintGo(); - showToast(`불러옴 ${ok} / ${nos.length}`, ok === nos.length ? "success" : "warning"); + const rows = ones.map((one) => { + const h = one.호표; + const d = loadDraft(draftKey(opt.scope, opt.key, h.번)); + const out = Object.entries(leftOut(h.줄, d)); + const calc = h.셈 && !h.셈.끝 ? `셈 중 ${h.셈.한}/${h.셈.모두}` : ""; + const brief = briefs.find((x) => x.번 === h.번) ?? h; + return el("tr", { + attrs: { "data-review": String(h.번) }, + children: [ + el("td", { text: `제${h.번}호표 ${h.이름}`.trim() }), + el("td", { + text: wholeFor(one) ? "통째" : String(bodyFor(one).filter((x) => x.대상).length), + className: "m02-hopyo__num", + }), + el("td", { text: out.map(([a, b]) => `${a} ${b}`).join(" · ") }), + el("td", { text: calc }), + el("td", { + children: [ + createButton({ + label: "확인 창 열기", + variant: "ghost", + size: "sm", + onClick: () => + openHopyoCheck({ + scope: opt.scope, + key: opt.key, + no: h.번, + target, + canWrite: opt.canWrite, + onBuilt: (got) => opt.onImport?.(got, brief), + }), + }), + ], + }), + ], + }); + }); + const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one)); + const all = createButton({ + label: `이대로 ${ready.length}개 만들기`, + variant: "filled", + size: "sm", + disabled: !ready.length, + onClick: () => void makeAll(ready), + }); + all.setAttribute("data-hopyo-make-all", ""); + result.replaceChildren( + table(["호표", "넣을 줄", "빠질 줄", "숫자 판정", ""], rows), + el("div", { + className: "m02-hopyo__bar", + children: [all, note("넣을 줄 = 체크 제안 · 확인 창에서 고친 것")], + }), + ); } - go.addEventListener("click", () => void importPicked()); + + /** ② 한 번 누름 = 사용자 확인 — 하위 호표부터 차례로 만들기 */ + async function makeAll(ones: HopyoOne[]): Promise { + const target = opt.target ?? opt.scope; + const by = new Map(ones.map((one) => [one.호표.번, one])); + const order = buildOrder( + ones.map((one) => { + const d = loadDraft(draftKey(opt.scope, opt.key, one.호표.번)); + const kids = one.호표.줄.flatMap((l) => + partsOf(l, d).flatMap((p) => (p.호표 !== undefined ? [p.호표] : [])), + ); + return { 번: one.호표.번, 아래: kids }; + }), + ); + const rows: HTMLElement[] = []; + let ok = 0; + showLoadingOverlay(); + try { + for (const no of order) { + const one = by.get(no)!; + const h = briefs.find((x) => x.번 === no) ?? one.호표; + const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() }); + try { + const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no }; + const got = await buildHopyo(target, from, bodyFor(one), wholeFor(one)); + ok += 1; + opt.onImport?.(got, h); + rows.push( + el("tr", { + attrs: { "data-built": got.참조.키 }, + children: [ + head, + el("td", { text: `${got.참조.키} · ${builtWord(got)}` }), + el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }), + el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }), + ], + }), + ); + } catch (error) { + rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] })); + } + } + } finally { + hideLoadingOverlay(); + } + result.replaceChildren(table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], rows)); + showToast(`만듦 ${ok} / ${order.length}`, ok === order.length ? "success" : "warning"); + } + go.addEventListener("click", () => void review()); void load(); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index 3bd583efb..ef48f51ca 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -3,11 +3,13 @@ * 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) · * [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면 * (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만 · - * 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶. + * 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶 · + * 파일 목록 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger`). * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api"; +import { buildHopyoLedger, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger"; import { buildHopyoView } from "./M02_MasterTemplete_Hopyo_View"; export const HOPYO_TITLE = "일위대가 실무자료"; @@ -48,9 +50,19 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { onClick: () => input.click(), }); upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음"; + const ledger = el("button", { + className: "m02-side__item ui-sidebar-row", + text: LEDGER_TITLE, + attrs: { + type: "button", + title: "엑셀 이름 → 우리 로직 짝 보기 · 고치기", + "data-hopyo-ledger-open": "", + }, + }); + ledger.addEventListener("click", () => void showLedger()); const body = el("div", { className: "m02-hopyo-side", - children: [el("div", { className: "m02-side__row", children: [upload, input] }), list], + children: [el("div", { className: "m02-side__row", children: [upload, input] }), list, ledger], }); let files: HopyoFile[] = []; let open: { key: string; view: HTMLElement } | null = null; @@ -75,6 +87,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { }); function paintList(): void { + ledger.classList.toggle("is-active", open?.key === LEDGER_TITLE); list.replaceChildren( ...(files.length ? files.map((f) => { @@ -107,6 +120,16 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { paintList(); } + async function showLedger(): Promise { + if (open?.key === LEDGER_TITLE && open.view.isConnected) return; + if (!(await opt.confirmLeave())) return; + const view = buildHopyoLedger({ scope, canWrite: opt.canWrite }); + opt.show(`${HOPYO_TITLE} — ${LEDGER_TITLE}`, view); + opt.onOpen(); + open = { key: LEDGER_TITLE, view }; + paintList(); + } + async function refresh(): Promise { const mine = ++seq; try { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts index 4af98e977..b7f58f5e4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts @@ -1,58 +1,31 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_View.ts - * 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · - * [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 · - * [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기] · - * [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채) · - * 고르기 창 후보 근거 꼬리표 · 엑셀 근거 줄 + [엑셀에서 보기](단가산출 그 행) · [근거 맞음 모두 확정](330). + * 일위대가 실무자료 본 화면(PLAN 324-4 · 338-3) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 제안 n/m · 호표 표 + 찾기 · + * [지우기] · [엑셀로 보기](329 엑셀 보기 창 · 늦게 불러옴) / 호표 하나 = 확인 창(`M02_MasterTemplete_Hopyo_Check` · + * 체크한 줄만 [확인하고 사전조합 만들기]) — 즉시 확정 · 바로 만들기 단추 없음. * 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만. * ========================================================================== */ import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; -import { openModal } from "@ui/ui_template_modal"; import { - buildHopyo, deleteHopyo, - guessHopyo, readHopyo, - readHopyoOne, - saveHopyoMemory, - type HopyoBasisHit, - type HopyoBuilt, - type HopyoGuess, - type HopyoLine, - type HopyoLineBasis, - type HopyoMemory, - type HopyoOne, type HopyoScope, - type HopyoTarget, type HopyoView, } from "./M02_MasterTemplete_Hopyo_Api"; +import { buildHopyoCheck } from "./M02_MasterTemplete_Hopyo_Check"; import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet"; -import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { pickMaterial } from "./M02_Material_Pick"; import "./M02_MasterTemplete_Hopyo.css"; export interface HopyoViewOptions { scope: HopyoScope; - /** 층 쓰기 권한 — 없으면 확정 · 만들기 · 지우기 단추 없음 */ + /** 층 쓰기 권한 — 없으면 체크 · 만들기 · 지우기 단추 없음 */ canWrite: boolean; /** 파일을 지웠음 — 좌측 목록 다시 */ onDeleted: () => void; } -const STATE: Record = { - 기억: "기억", - 같음: "같음", - 후보: "후보", - 없음: "없음", - 빼기: "뺌", - 호표: "호표", - "%": "% 줄", -}; -const MATERIAL = new Set(["자재", "별도계상"]); - const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const won = (v: number | null | undefined): string => v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); @@ -61,12 +34,12 @@ const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "er const openSheet = (o: HopyoSheetOptions): void => void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail); -const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement => +const grid = (heads: string[], body: HTMLElement): HTMLElement => el("div", { className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", children: [ el("table", { - className: `ui-general-block__table m02-hopyo__table ${cls}`.trim(), + className: "ui-general-block__table m02-hopyo__table", children: [ el("thead", { children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], @@ -76,87 +49,10 @@ const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement => }), ], }); -const table = (heads: string[], rows: HTMLElement[]): HTMLElement => - grid(heads, el("tbody", { children: rows })); const td = (text: string, cls = ""): HTMLElement => el("td", { text, className: cls, attrs: { title: text } }); const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); -/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */ -export const targetText = (t: HopyoTarget | null | undefined): string => { - if (!t) return ""; - if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim(); - const spec = t.규격 ? ` ${t.규격}` : ""; - return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`; -}; - -/** 입력값 글 — 「이름=값」 · 못 읽은 입력 표시 */ -export const inputsText = (values: Record, unread: string[]): string => - [ - Object.entries(values) - .map(([k, v]) => `${k}=${String(v)}`) - .join(", "), - unread.length ? `⚠ 못 읽음: ${unread.join(", ")}` : "", - ] - .filter(Boolean) - .join(" · "); - -/** 만들기 결과 낱말 — [새로 만들기] = 「새로 만들어 덮음」 · 있던 것 = 「다시 씀」 · 그 밖 「만듦」 */ -export const builtWord = (got: Pick, fresh: boolean): string => - fresh ? "새로 만들어 덮음" : got.다시씀 ? "다시 씀" : "만듦"; - -/** 확정 한 줄 몸 — 대상 · 입력값 · 환산(0 넘는 수) · 없으면 null */ -export function memoryOf( - target: HopyoTarget | null, - values: Record, - times: number | null, -): HopyoMemory { - if (!target || times === null || !(times > 0)) return null; - if (target.로직) return { 로직: target.로직, 입력값: values, 환산: times }; - if (target.자재) return { 자재: target.자재, 입력값: values, 환산: times }; - return null; -} - -/** 근거 맞음(330 [근거 맞음 모두 확정]) — 조항 같음(앞 마디 아님) · 단위 맞음 · 직종 겹침 - * (줄 근거 직종이 보통인부뿐이거나 없으면 — 기계 등 — 직종은 안 봄) */ -export const basisOk = (b: HopyoBasisHit | undefined, line?: HopyoLineBasis): boolean => - !!b && - b.까닭.startsWith("조항 같음") && - b.단위 && - (b.직종.length > 0 || !(line?.직종 ?? []).some((x) => x !== "보통인부")); - -/** 후보 근거 꼬리표 — 「조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음」 · 근거 없으면 "" */ -export const basisTag = (b: HopyoBasisHit | undefined): string => - b - ? [ - b.조항.length ? `조항 ${b.조항.join(", ")}` : "", - b.직종.length ? `직종 ${b.직종.join(", ")}` : "", - b.단위 ? "단위 같음" : "", - ] - .filter(Boolean) - .join(" · ") - : ""; - -/** 엑셀 근거 줄 — 「출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행」 · 없으면 "" */ -export const sourceText = (b: HopyoLineBasis | undefined): string => - b - ? [ - b.출처.length ? `출처 ${b.출처.map((x) => x.join(" ").trim()).join(", ")}` : "", - b.직종.length ? `직종 ${b.직종.join(", ")}` : "", - `단가산출 ${b.행[0] === b.행[1] ? b.행[0] : `${b.행[0]}–${b.행[1]}`}행`, - ] - .filter(Boolean) - .join(" · ") - : ""; - -/** [근거 맞음 모두 확정] 대상 — 「후보」 줄의 지금 대상이 근거 맞으면 그것 · 아니면 근거 맞는 첫 후보 · 없으면 null */ -export function basisPick(line: HopyoLine): HopyoTarget | null { - const m = line.맞춤; - if (m.상태 !== "후보") return null; - if (m.대상 && basisOk(m.대상.근거, line.근거)) return m.대상; - return (m.후보 ?? []).find((c) => basisOk(c.근거, line.근거)) ?? null; -} - export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement { const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } }); let nav = 0; // 늦게 온 응답 버림 @@ -223,7 +119,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement note(`시트 ${file.시트}`), note(`호표 ${file.호표수}`), note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"), - note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`), + note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`), createButton({ label: "엑셀로 보기", size: "sm", @@ -235,7 +131,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement }), ...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []), search, - grid(["호표", "품명", "규격", "단위", "엑셀 단가", "맞춤"], body), + grid(["호표", "품명", "규격", "단위", "엑셀 단가", "제안 / 줄"], body), ); } @@ -245,7 +141,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement variant: "danger", size: "sm", onClick: async () => { - const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 맞춤 기억은 그대로`; + const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`; if (!(await showConfirmDialog(ask, "지우기"))) return; try { await deleteHopyo(opt.scope, key); @@ -257,358 +153,20 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement }, }); - /* ── 호표 하나 — 줄 맞춤 ────────────────────────────────────── */ - async function drawOne(no: number, built?: HopyoBuilt & { 새로: boolean }): Promise { - const got = await load( - () => readHopyoOne(opt.scope, key, no), - () => void drawOne(no), - ); - if (!got) return; - const h = got.호표; - const picked = new Map(); // 고른 대상(확정 전) - const back = createButton({ label: "← 호표 목록", variant: "ghost", size: "sm" }); - back.addEventListener("click", () => void drawFile()); - const rows = el("tbody"); - const paintRows = (): void => { - rows.replaceChildren(...h.줄.map((line) => lineRow(got, line, picked, paintRows))); - }; - paintRows(); - const same = h.줄.filter((x) => x.맞춤.상태 === "같음" && x.맞춤.환산?.배 != null); - const tools: HTMLElement[] = []; - if (opt.canWrite) { - const all = createButton({ label: `같음 모두 확정 (${same.length})`, size: "sm" }); - all.disabled = !same.length; - all.addEventListener("click", () => void confirmAll(no, same)); - tools.push(all); - // 근거(330) — 옛 파일 · 근거 없는 호표면 단추 없음 - if (h.줄.some((x) => x.근거 || x.맞춤.후보?.some((c) => c.근거))) { - const fit = h.줄.filter((x) => basisPick(x)); - const ok = createButton({ label: `근거 맞음 모두 확정 (${fit.length})`, size: "sm" }); - ok.title = "조항 · 직종 · 단위가 모두 맞는 후보만"; - ok.disabled = !fit.length; - ok.addEventListener("click", () => void confirmBasis(no, fit)); - tools.push(ok); - } - const make = createButton({ label: "사전조합 만들기", variant: "filled", size: "sm" }); - make.addEventListener("click", () => void build(no, false)); - tools.push(make); - } - const missing = h.줄.filter((x) => !x.맞춤.들어감); + /* ── 호표 하나 — 확인 창 ────────────────────────────────────── */ + function drawOne(no: number): void { + nav += 1; // 파일 표 늦은 응답 버림 view.replaceChildren( - el("div", { - className: "m02-hopyo__bar", - children: [ - back, - el("strong", { text: `제${h.번}호표 ${h.이름}` }), - note(h.규격), - note(h.단위), - note(`엑셀 단가 ${won(h.금액.계)}`), - note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`), - createButton({ - label: "엑셀에서 보기", - size: "sm", - onClick: () => - openSheet({ - scope: opt.scope, - key, - 파일: got.파일, - canWrite: opt.canWrite, - 호표: no, - }), - }), - ...tools, - ], + buildHopyoCheck({ + scope: opt.scope, + key, + no, + canWrite: opt.canWrite, + onBack: () => void drawFile(), }), - h.줄.length - ? grid( - [ - "갈래", - "품명", - "규격", - "수량", - "단위", - "엑셀 단가", - "맞춤", - "대상", - "입력값", - "환산", - "", - ], - rows, - "m02-hopyo__lines", - ) - : el("p", { className: "m02-hopyo__note", text: "구성 줄 없음" }), - ...(missing.length ? [missingBlock(missing)] : []), - ...(built ? [builtBlock(no, built)] : []), ); } - function lineRow( - got: HopyoOne, - line: HopyoLine, - picked: Map, - repaint: () => void, - ): HTMLElement { - const m = line.맞춤; - const mine = picked.get(line.차례); - const target = mine?.대상 ?? m.대상; - const values = mine?.입력값 ?? m.입력값; - const conv = mine?.환산 ?? m.환산; - const fixed = ["%", "호표", "빼기"].includes(m.상태); - const times = el("input", { - className: "ui-input ui-input--sm m02-hopyo__times", - attrs: { - type: "number", - step: "any", - min: "0", - value: conv?.배 != null ? String(conv.배) : "", - }, - }) as HTMLInputElement; - times.disabled = !opt.canWrite || fixed || !target; - const state = mine ? "고름" : (STATE[m.상태] ?? m.상태); - const actions: HTMLElement[] = []; - if (opt.canWrite && m.상태 === "빼기") { - actions.push( - createButton({ - label: "되살림", - variant: "ghost", - size: "sm", - onClick: () => void remember(got.호표.번, { [m.열쇠]: null }), - }), - ); - } else if (opt.canWrite && !["%", "호표"].includes(m.상태)) { - actions.push( - createButton({ - label: "고르기", - variant: "ghost", - size: "sm", - onClick: () => - choose(line, got.파일, (t) => - guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then( - (g) => (picked.set(line.차례, g), repaint()), - fail, - ), - ), - }), - createButton({ - label: "확정", - size: "sm", - disabled: !target, - onClick: () => { - const body = memoryOf(target, values, times.value === "" ? null : Number(times.value)); - if (!body) return showToast("대상과 0 넘는 환산을 넣어야 확정", "error"); - void remember(got.호표.번, { [m.열쇠]: body }); - }, - }), - createButton({ - label: "빼기", - variant: "ghost", - size: "sm", - onClick: () => void remember(got.호표.번, { [m.열쇠]: { 빼기: true } }), - }), - ); - } - return el("tr", { - className: `m02-hopyo__line is-${m.들어감 ? "in" : "out"}`, - attrs: { "data-line": String(line.차례), "data-state": m.상태 }, - children: [ - td(line.갈래), - td(line.이름), - td(line.규격), - td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"), - td(line.단위), - td(won(line.단가.계), "m02-hopyo__num"), - el("td", { - children: [el("span", { className: `m02-hopyo__state is-${m.상태}`, text: state })], - }), - td(targetText(target)), - td(inputsText(values, mine?.못읽음 ?? m.못읽음)), - el("td", { - children: [times, note(conv?.갈래 ? ` ${conv.갈래}` : "")], - }), - el("td", { className: "m02-hopyo__acts", children: actions }), - ], - }); - } - - /** 대상 고르기 — 엑셀 근거 줄 · 후보 5(근거 꼬리표) + 다른 로직 · 자재 찾기 */ - function choose(line: HopyoLine, 파일: string, done: (t: HopyoTarget) => void): void { - const material = MATERIAL.has(line.갈래); - openModal({ - title: `고르기 — ${line.이름} ${line.규격}`.trim(), - closeLabel: "닫기", - dialogClass: "m02-modal m02-hopyo__choose", - mount: (body, close) => { - const pick = (t: HopyoTarget): void => { - close(); - done(t); - }; - const list = (line.맞춤.후보 ?? []).map((c) => { - const button = createButton({ - label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`, - variant: "ghost", - size: "sm", - onClick: () => pick(c), - }); - const tag = basisTag(c.근거); - if (tag && c.근거) - button.append( - el("span", { - className: `m02-hopyo__basis${basisOk(c.근거, line.근거) ? " is-ok" : ""}`, - text: tag, - attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` }, - }), - ); - return button; - }); - const basis = line.근거; - const source = basis - ? [ - el("div", { - className: "m02-hopyo__bar m02-hopyo__source", - children: [ - note(`엑셀 근거 — ${sourceText(basis)}`), - createButton({ - label: "엑셀에서 보기", - variant: "ghost", - size: "sm", - onClick: () => - openSheet({ - scope: opt.scope, - key, - 파일, - canWrite: opt.canWrite, - 단산행: basis.행, - }), - }), - ], - }), - ] - : []; - const more = createButton({ - label: material ? "다른 자재 찾기…" : "다른 로직 찾기…", - size: "sm", - onClick: () => { - close(); - if (material) pickMaterial((x) => done({ 자재: x.코드 })); - else openLogicPicker((x) => done({ 로직: x.키 })); - }, - }); - body.append( - ...source, - el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "가까운 것 없음" }), - el("div", { className: "m02-hopyo__cands", children: list }), - more, - ); - }, - }); - } - - async function remember(no: number, 줄: Record): Promise { - try { - await saveHopyoMemory(opt.scope, 줄); - await drawOne(no); - } catch (error) { - fail(error); - } - } - - async function confirmAll(no: number, lines: HopyoLine[]): Promise { - const body: Record = {}; - for (const x of lines) { - const one = memoryOf(x.맞춤.대상, x.맞춤.입력값, x.맞춤.환산?.배 ?? null); - if (one) body[x.맞춤.열쇠] = one; - } - await remember(no, body); - } - - /** 근거 맞는 후보로 확정 — 지금 대상이 아니면 입력값 · 환산을 서버에서 다시 읽음 */ - async function confirmBasis(no: number, lines: HopyoLine[]): Promise { - const body: Record = {}; - for (const x of lines) { - const c = basisPick(x); - const m = x.맞춤; - if (!c) continue; - const g = - c === m.대상 - ? { 대상: c, 입력값: m.입력값, 환산: m.환산 } - : await guessHopyo(opt.scope, key, no, x.차례, c).catch(() => null); - const one = g && memoryOf(g.대상, g.입력값, g.환산?.배 ?? null); - if (one) body[m.열쇠] = one; - } - if (!Object.keys(body).length) return showToast("확정할 줄 없음 — 환산을 못 읽음", "warning"); - await remember(no, body); - } - - async function build(no: number, fresh: boolean): Promise { - try { - const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no }; - const got = await buildHopyo(opt.scope, from, fresh); - const word = builtWord(got, fresh); - const miss = got.빠진줄.length; - showToast(miss ? `${word} — 빠진 줄 ${miss}` : word, miss ? "warning" : "success"); - await drawOne(no, { ...got, 새로: fresh }); - } catch (error) { - fail(error); - } - } - - const missingBlock = (lines: HopyoLine[]): HTMLElement => - el("div", { - className: "m02-hopyo__block", - children: [ - el("h4", { text: `빠진 줄 ${lines.length} — 사전조합에 안 들어감` }), - table( - ["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"], - lines.map((x) => - el("tr", { - children: [ - td(x.이름), - td(x.규격), - td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"), - td(x.단위), - td(won(x.금액.계), "m02-hopyo__num"), - td(STATE[x.맞춤.상태] ?? x.맞춤.상태), - ], - }), - ), - ), - ], - }); - - function builtBlock(no: number, got: HopyoBuilt & { 새로: boolean }): HTMLElement { - const c = got.비교; - const again = got.다시씀 - ? [ - note( - got.새로 - ? `사전조합 ${got.참조.키} 를 새로 만들어 덮음` - : `있던 사전조합 ${got.참조.키} 를 다시 씀`, - ), - createButton({ - label: "새로 만들기", - variant: "danger", - size: "sm", - onClick: async () => { - const ask = `사전조합 ${got.참조.키} 를 지금 맞춤으로 다시 만들어 덮음`; - if (await showConfirmDialog(ask, "새로 만들기")) await build(no, true); - }, - }), - ] - : [note(`새 사전조합 ${got.참조.키}`)]; - return el("div", { - className: "m02-hopyo__block m02-hopyo__built", - attrs: { "data-built": got.참조.키 }, - children: [ - el("div", { className: "m02-hopyo__bar", children: again }), - note( - `값 비교 — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` + - ` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`, - ), - ], - }); - } - void drawFile(); return view; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 1ac24376e..bccd33756 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -80,7 +80,12 @@ const LAYER_TEXT = { personal: "Lib_Personal", } as const; -export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: LibraryPick): void { +/** `pre.query` = 찾기 칸에 미리 넣을 글(338 실무자료 고르기 창 · 인용 조항 번호 · 품명 낱말) */ +export function openLogicPicker( + done: (logic: LogicSummary) => void, + lib?: LibraryPick, + pre?: { query?: string }, +): void { const subs: SubBrief[] = []; let classes: ReturnType = []; // 사전조합 새 분류(149-2) — 서고 줄이 있을 때만 let all: LogicSummary[] = []; @@ -112,6 +117,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra filters: [...(kindSel ? [kindSel.root] : []), sub.root, detail.root], extra: [layerBar], }); + if (pre?.query) finder.search.value = pre.query; const find = (): void => { const q = finder.search.value.trim().toLowerCase(); diff --git a/resources/tester/helper_m02_hopyo_check.cjs b/resources/tester/helper_m02_hopyo_check.cjs new file mode 100644 index 000000000..0af39f459 --- /dev/null +++ b/resources/tester/helper_m02_hopyo_check.cjs @@ -0,0 +1,184 @@ +/* M02 일위대가 실무자료 확인 창(338-3) 헬퍼 — `Hopyo_Check` · `Hopyo_Choose` · `Hopyo_Ledger` · `Hopyo_Sheet` + * 도우미와 `Hopyo_Api` 길을 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 · sessionStorage 대역 · + * stdin = {lines, rows, ledger} · stdout = {check, choose, ledger, sheet, calls, pick} JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } }; +// DOM · 모듈 메타를 쓰는 창은 대역(도우미 · 길만 시험) +const STUB_FILES = [ + "M02_MasterTemplete_Labor_Pick", + "M02_Material_Pick", + "M02_MasterTemplete_Labor_Api", + "M02_MasterTemplete_Labor_Input", + "M01_MasterData_UI_LogicLab_Source", +]; +const opened = []; +const stub = new Proxy( + {}, + { + get: (_t, k) => + k === "currentLanguageIndex" + ? () => 0 + : k === "openModal" + ? (o) => opened.push(o.title) + : () => {}, + }, +); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (STUB_FILES.some((name) => request.endsWith(name))) return stub; + if (request.startsWith(".") && !request.endsWith(".css")) + return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return stub; + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const store = new Map(); +global.sessionStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), +}; + +(async () => { + const input = JSON.parse(fs.readFileSync(0, "utf8")); + const calls = []; + global.fetch = async (url, init = {}) => { + calls.push({ + url, + method: init.method ?? "GET", + body: init.body ? JSON.parse(init.body) : null, + }); + const body = url.includes("/hopyo-picks") || url.includes("/hopyo-ledger") ? { 줄: [] } : {}; + return { status: 200, ok: true, json: async () => body }; + }; + const M02 = path.join(ROOT, "M02_MasterTemplete"); + const check = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Check.ts")); + const choose = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Choose.ts")); + const ledger = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Ledger.ts")); + const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts")); + const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts")); + const pick = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Pick.ts")); + const { lines } = input; + const scope = { layer: "personal", owner: null }; + const key = check.draftKey(scope, "HP-000001", 3); + + // 서버 제안 그대로 → 사용자가 만짐(체크 끔 · 뺌 · 고른 대상) → 캐시 다시 읽음 + const fresh = check.loadDraft(key); + const suggested = check.bodyOf(lines, fresh); + const d = check.loadDraft(key); + d.체크[1] = false; // 맞음 줄 체크 끔 + d.뺌[3] = "우리에 없음"; + d.체크[3] = true; + d.대상[4] = [{ 로직: "GF000211", 이름: "고임돌", 입력값: { 유형: "보통" }, 배: 0.1, 몫: 0.1 }]; + d.체크[4] = true; + check.saveDraft(key, d); + const again = check.loadDraft(key); + const ask = check.newest(); + const a = ask(1); + const b = ask(1); + const c = ask(2); + const out = { + check: { + key, + suggested, + edited: check.bodyOf(lines, again), + leftOut: check.leftOut(lines, again), + checkable: lines.map((l) => check.checkable(l, fresh)), + sure: check.sureLines(lines, fresh).map((l) => l.차례), + compare: check.compareText(lines[1].맞춤.품), + toTarget: [ + check.toTarget({ 호표: 46, 이름: "고화매트", 입력값: {}, 배: 2.3 }), + check.toTarget({ 자재: "MT1", 이름: "레미콘", 입력값: {}, 배: 1, 몫: null }), + ], + newest: [a(), b(), c()], + dropped: (check.dropDraft(key), store.has(key)), + }, + choose: { + hint: [ + choose.searchHint({ + 이름: "레미콘타설(장비사용)", + 근거: { 출처: [["건설품셈", "6-1-1"]], 직종: [], 행: [1, 1] }, + }), + choose.searchHint({ 이름: "(소)고임돌 채집 및 운반" }), + ], + picked: choose.pickedTargets([ + { c: { 로직: "GC000206" }, 몫: null }, + { c: { 로직: "GF000211" }, 몫: 0.1 }, + ]), + inputs: choose.inputsText({ 소량감률: 50, 지역: "서울" }, ["측압"], { + 소량감률: "블록", + 지역: "자동값", + }), + word: [choose.builtWord({ 다시씀: false }), choose.builtWord({ 다시씀: true })], + qty: choose.qtyText(lines[1].맞춤.품.엑셀), + target: choose.targetText({ 호표: 46, 단위: "㎡" }), + }, + ledger: (() => { + const rows = input.ledger; + const edits = { + "가|b|m3": null, // 지움 + "나||m3": { 원래: { 품명: "나", 규격: "", 단위: "m3" }, 갈래: "안 씀", 짝: [] }, + "다||개": ledger.entryOf(rows[2]), // 위 층 → 내 층 + }; + const merged = ledger.mergeRows(rows, edits, { layer: "personal", owner: "7/42" }); + return { + key: ledger.ledgerKey({ layer: "company", owner: "7" }), + merged: merged.map((r) => [r.열쇠, r.층, r.고침, !!r.고친, r.갈래]), + filter: ledger.filterRows(merged, "MT1", "").map((r) => r.열쇠), + kind: ledger.filterRows(merged, "", "안 씀").map((r) => r.열쇠), + pair: ledger.pairText({ 로직: "GC000198", 입력값: { 소량감률: 50 }, 배: 1, 몫: 0.1 }), + entry: ledger.entryOf({ ...rows[0], 갈래: "우리에 없음" }), + }; + })(), + sheet: { + order: sheet.buildOrder([ + { 번: 45, 아래: [46] }, + { 번: 46, 아래: [] }, + { 번: 3, 아래: [] }, + { 번: 7, 아래: [8] }, + { 번: 8, 아래: [7] }, + ]), + }, + }; + // 길 — 만들기(줄 · 통째) · 호표 하나(만들 층) · 미리 셈 · 장부 + const from = { 층: "system", 주인: null, 키: "HP-000001", 번: 3 }; + const target = { layer: "company", owner: "7" }; + await api.buildHopyo(target, from, suggested); + await api.buildHopyo(target, from, [], { 대상: [{ 로직: "GF000225", 배: 1 }] }); + await api.readHopyoOne({ layer: "system", owner: null }, "HP-000001", 3, target); + await api.guessHopyo(scope, "HP-000001", 3, 4, [ + { 로직: "GF000211", 입력값: {}, 배: 0.1, 몫: 0.1 }, + ]); + await api.readLedger(target); + await api.saveLedger(target, { "가|b|m3": null }); + out.calls = calls.splice(0); + // [로직 추가] 실무 호표 — 확인된 것 = 바로 참조 · 아닌 것 = 확인 창(만들기 길 안 부름) + const picked = []; + const lib = { + layer: "company", + owner: "7", + pick: (ref, name, alias) => picked.push({ ref, name, alias }), + }; + for (const row of input.rows) await pick.pickHopyo(row, lib); + await new Promise((r) => setTimeout(r, 0)); + out.pick = { picked, calls: calls.splice(0), opened }; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/helper_m02_hopyo_pick.cjs b/resources/tester/helper_m02_hopyo_pick.cjs index fb5daaa03..3434f4991 100644 --- a/resources/tester/helper_m02_hopyo_pick.cjs +++ b/resources/tester/helper_m02_hopyo_pick.cjs @@ -1,5 +1,5 @@ -/* M02 일위대가 실무자료(324-4) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) · - * `M02_MasterTemplete_Hopyo_View.ts` 글 도우미 · `M02_MasterTemplete_Hopyo_Sheet.ts` 엑셀 보기 창 도우미(329 · 행 → 호표)를 +/* M02 일위대가 실무자료(324-4 · 338-3) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) · + * `M02_MasterTemplete_Hopyo_Choose.ts` 글 도우미 · `M02_MasterTemplete_Hopyo_Sheet.ts` 엑셀 보기 창 도우미(329 · 행 → 호표)를 * 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 · * stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view, sheet} JSON. */ const fs = require("fs"); @@ -8,8 +8,14 @@ const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "t const ROOT = path.join(__dirname, "..", ".."); const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } }; -// 본 화면이 부르는 고르기 창 · 자재 창은 DOM · 모듈 메타를 씀 — 대역(글 도우미만 시험) -const STUB_FILES = ["M02_MasterTemplete_Labor_Pick", "M02_Material_Pick"]; +// 고르기 창 · 자재 창 · 입력 창 · 원문 창은 DOM · 모듈 메타를 씀 — 대역(글 도우미만 시험) +const STUB_FILES = [ + "M02_MasterTemplete_Labor_Pick", + "M02_Material_Pick", + "M02_MasterTemplete_Labor_Api", + "M02_MasterTemplete_Labor_Input", + "M01_MasterData_UI_LogicLab_Source", +]; const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) }); const cache = new Map(); function loadTs(file) { @@ -55,7 +61,7 @@ function loadTs(file) { const rows = (await wrapped.more()).map(({ onPick: _, ...r }) => r); await pick.pickHopyo(input.rows[0], lib); const project = pick.withHopyo({ ...lib, layer: "project" }); - const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts")); + const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Choose.ts")); const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts")); const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts")); await api.readHopyoGrid({ layer: "company", owner: "7" }, "HP-000001"); @@ -76,17 +82,6 @@ function loadTs(file) { view.targetText({ 호표: 46, 단위: "㎡" }), ], inputs: view.inputsText({ 횟수: "4회사용시", 지역: "서울" }, ["보정작업"]), - memory: [ - view.memoryOf({ 로직: "GF000101" }, { 지역: "서울" }, 1), - view.memoryOf({ 자재: "MT1" }, {}, 0.5), - view.memoryOf({ 로직: "GF000101" }, {}, 0), - view.memoryOf(null, {}, 1), - ], - word: [ - view.builtWord({ 다시씀: false }, false), - view.builtWord({ 다시씀: true }, false), - view.builtWord({ 다시씀: true }, true), - ], }, basis: (() => { const hit = { @@ -99,9 +94,6 @@ function loadTs(file) { const half = { ...hit, 직종: [] }; const trade = { 출처: [["건설품셈", "3-2-1"]], 직종: ["특별인부", "보통인부"], 행: [1, 1] }; const plain = { ...trade, 직종: ["보통인부"] }; - const line = (상태, 대상, 후보) => ({ 근거: trade, 맞춤: { 상태, 대상, 후보 } }); - const a = { 로직: "GF000101", 근거: half }; - const b = { 로직: "GC000083", 근거: hit }; return { ok: [hit, half, { ...hit, 단위: false }, { ...hit, 까닭: "앞 마디 같음" }, undefined].map( (b) => view.basisOk(b, trade), @@ -114,12 +106,6 @@ function loadTs(file) { view.sourceText({ 출처: [], 직종: [], 행: [7, 7] }), view.sourceText(undefined), ], - pick: [ - line("후보", a, [a, b]), - line("후보", b, [b]), - line("같음", b, [b]), - line("후보", a, [a]), - ].map((x) => view.basisPick(x)?.로직 ?? null), }; })(), sheet: { diff --git a/resources/tester/helper_m02_hopyo_seam.cjs b/resources/tester/helper_m02_hopyo_seam.cjs index 55616cebd..705d02497 100644 --- a/resources/tester/helper_m02_hopyo_seam.cjs +++ b/resources/tester/helper_m02_hopyo_seam.cjs @@ -1,15 +1,21 @@ /* M02 일위대가 실무자료 이음매(329 · 330) 헬퍼 — 실제 서버 응답(격자 · 호표 맞춤)을 - * `M02_MasterTemplete_Hopyo_Sheet.ts`(행 → 호표 · 영역 A1) · `M02_MasterTemplete_Hopyo_View.ts` - * (근거 꼬리표 · 엑셀 근거 줄 · 근거 맞음 대상)에 먹임 · - * stdin = {grid, lines, all} · stdout = {at, span, rows, lines: [{이름, source, tag, ok, pick}], - * count: {top, pick, half}} JSON — count = 「후보」 줄 1순위 근거 맞음 · 근거 맞음 대상 · 그 가운데 앞 마디. */ + * `M02_MasterTemplete_Hopyo_Sheet.ts`(행 → 호표 · 영역 A1) · `M02_MasterTemplete_Hopyo_Choose.ts` + * (근거 꼬리표 · 엑셀 근거 줄 · 근거 맞음)에 먹임 · + * stdin = {grid, lines, all} · stdout = {at, span, rows, lines: [{이름, source, tag, ok}], + * count: {top}} JSON — count = 「후보」 줄 1순위 근거 맞음. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const ROOT = path.join(__dirname, "..", ".."); const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } }; -const STUB_FILES = ["M02_MasterTemplete_Labor_Pick", "M02_Material_Pick"]; +const STUB_FILES = [ + "M02_MasterTemplete_Labor_Pick", + "M02_Material_Pick", + "M02_MasterTemplete_Labor_Api", + "M02_MasterTemplete_Labor_Input", + "M01_MasterData_UI_LogicLab_Source", +]; const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) }); const cache = new Map(); function loadTs(file) { @@ -34,7 +40,7 @@ function loadTs(file) { const input = JSON.parse(fs.readFileSync(0, "utf8")); const M02 = path.join(ROOT, "M02_MasterTemplete"); const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts")); -const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts")); +const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Choose.ts")); const { grid } = input; const ilwi = grid.책.시트.find((s) => s.이름 === "일위대가"); const three = grid.호표.find((h) => h.번 === 3); @@ -49,15 +55,11 @@ const out = { source: view.sourceText(line.근거), tag: view.basisTag(top?.근거), ok: view.basisOk(top?.근거, line.근거), - pick: view.basisPick(line)?.로직 ?? null, }; }), }; const open = (input.all ?? []).filter((line) => line.맞춤.상태 === "후보"); -const picks = open.map((line) => view.basisPick(line)).filter(Boolean); out.count = { top: open.filter((line) => view.basisOk((line.맞춤.후보 ?? [])[0]?.근거, line.근거)).length, - pick: picks.length, - half: picks.filter((c) => !c.근거.까닭.startsWith("조항 같음")).length, }; process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 3eef9079e..6492f4e37 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -1,12 +1,15 @@ -"""일위대가 실무자료 화면(PLAN 324-4) — 좌측 판 · 본 화면 · [로직 추가] 「실무 호표」 줄. +"""일위대가 실무자료 화면(PLAN 324-4 · 338-3) — 좌측 판 · 본 화면 · 확인 창 · 고르기 창 · +별칭 장부 · [로직 추가] 「실무 호표」 줄 · 엑셀 보기 창. -- 고르기 창 덧줄(Node · fetch 대역): 이 층 · 위 층 호표 줄(맞춤 n/m) · 종류 고르기 · - 고르면 문서 층에 만들기 길 → `lib.pick` 참조 줄(별칭 = 호표 품명) · 프로젝트 층엔 없음 · - 본 화면 글 도우미 -- 고르기 창 근거(330-3): 꼬리표 · 엑셀 근거 줄 · [근거 맞음 모두 확정] 대상 -- 엑셀 보기 창(329): 행 → 호표 · 호표 영역 A1 · 격자 길 · 여는 자리 넷 · 늦게 불러옴 -- 소스: 좌측 판 자리(사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 · - 숨은 file 입력) · Labor_Detail 줄 수 그대로 · 새 파일 이름 · 700 줄 · 색 = 공용 토큰 +- 확인 창 도우미(Node · fetch · sessionStorage 대역 · `helper_m02_hopyo_check.cjs`): + 체크 → 몸(체크한 줄만 · 뺌 · 고른 대상 · 몫 · 배 · 호표) · 빠질 줄 까닭 · 캐시 키 · + 최신 응답만 · 품 비교 · 만들기 · 통째 · 만들 층 · 미리 셈 · 장부 길 · 장부 화면 합침 · + 하위 호표 먼저 · 확인된 것 = 바로 참조 +- 고르기 창 덧줄(`helper_m02_hopyo_pick.cjs`): 이 층 · 위 층 호표 줄 · 종류 고르기 · + 프로젝트 층엔 없음 · 글 도우미 · 근거 꼬리표 · 엑셀 보기 창 행 → 호표 +- 소스: 옛 즉시 확정 · 바로 만들기 없음(saveHopyoMemory · [확정] · [같음 모두 확정] · + [근거 맞음 모두 확정] · [사전조합 만들기]) · `buildHopyo(` 는 확인 창 · 엑셀 보기 창 펼침에서만 · + 좌측 판 자리 · 700 줄 · 색 = 공용 토큰 """ import json @@ -17,45 +20,181 @@ from pathlib import Path HERE = os.path.dirname(os.path.abspath(__file__)) M02 = Path(HERE).parents[1] / "M02_MasterTemplete" -NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View", "Hopyo_Sheet") +NEW = ( + "Hopyo_Api", + "Hopyo_Pick", + "Hopyo_Side", + "Hopyo_View", + "Hopyo_Sheet", + "Hopyo_Check", + "Hopyo_Choose", + "Hopyo_Ledger", +) +ROW = { + "층": "system", + "주인": None, + "키": "HP-000001", + "파일": "북부청.xls", + "번": 3, + "이름": "임도표지석", + "규격": "910×600×150", + "단위": "개소", + "맞춤": [2, 4], +} +SURE = {"로직": "GC000198", "이름": "장비사용타설", "입력값": {"소량감률": 50}, "배": 1, "몫": None} +QTY = { + "엑셀": {"인력": {"콘크리트공": 0.1091}, "장비": [{"갈래": "굴착기", "시간": 0.29}]}, + "우리": {"인력": {"콘크리트공": 0.1091}, "장비": [{"갈래": "굴착기", "시간": 0.31}]}, + "같음": {"콘크리트공": True, "굴착기": False}, +} def _text(name: str) -> str: return (M02 / name).read_text(encoding="utf-8") -def _run() -> dict: - row = { - "층": "system", - "주인": None, - "키": "HP-000001", - "파일": "북부청.xls", - "번": 3, - "이름": "임도표지석", - "규격": "910×600×150", - "단위": "개소", - "맞춤": [2, 4], - } - built = { - "참조": {"층": "company", "주인": "7", "키": "SC-000009"}, - "이름": "임도표지석", - "다시씀": False, - "빠진줄": [{"차례": 0}], - "비교": {}, - } +def _node(helper: str, payload: dict) -> dict: proc = subprocess.run( - ["node", os.path.join(HERE, "helper_m02_hopyo_pick.cjs")], - input=json.dumps({"rows": [row], "built": built}, ensure_ascii=False), + ["node", os.path.join(HERE, helper)], + input=json.dumps(payload, ensure_ascii=False), capture_output=True, text=True, encoding="utf-8", - check=True, + check=False, ) + assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) -def test_고르기_창_실무_호표_줄과_고름(): - got = _run() +def _line(no, state, check, parts, **extra): + match = {"열쇠": f"줄{no}||m3", "상태": state, "체크": check, "대상": parts, **extra} + return {"차례": no, "이름": f"줄{no}", "규격": "", "수량": 1, "단위": "㎥", "갈래": "단가산출", + "맞춤": match} # fmt: skip + + +def _check() -> dict: + lines = [ + _line(0, "후보", False, [{"자재": "MT004258", "입력값": {}, "배": 1}]), + _line(1, "맞음", True, [SURE], 품=QTY), + _line(2, "별칭", True, [{"로직": "GF000101", "입력값": {}, "배": 1}]), + _line(3, "다른 점", False, [{"로직": "GF000103", "입력값": {}, "배": 1}]), + _line(4, "후보", False, []), + _line(5, "%", False, []), + _line(6, "호표", True, [{"호표": 46, "입력값": {}, "배": 2.3}]), + _line(7, "노임", False, [{"로직": "GF000300", "입력값": {}, "배": None}]), + ] + ledger = [ + {"열쇠": "가|b|m3", "원래": {"품명": "가", "규격": "b", "단위": "m3"}, "갈래": "짝", + "짝": [{"로직": "GC000198", "입력값": {}, "배": 1, "몫": None}], "층": "personal", + "주인": "7/42", "고침": True}, + {"열쇠": "나||m3", "원래": {"품명": "나", "규격": "", "단위": "m3"}, "갈래": "짝", + "짝": [{"로직": "GF000101", "입력값": {}, "배": 1, "몫": None}], "층": "personal", + "주인": "7/42", "고침": True}, + {"열쇠": "다||개", "원래": {"품명": "다", "규격": "", "단위": "개"}, "갈래": "짝", + "짝": [{"자재": "MT1", "입력값": {}, "배": 1, "몫": None}], "층": "system", + "주인": None, "고침": False}, + ] # fmt: skip + rows = [ + {**ROW, "확인": {"층": "company", "주인": "7", "키": "SC-000009"}}, + {**ROW, "번": 4, "이름": "배수관", "확인": None}, + ] + return _node("helper_m02_hopyo_check.cjs", {"lines": lines, "ledger": ledger, "rows": rows}) + + +def test_확인_창_체크한_줄만_몸(): + got = _check()["check"] + assert got["key"] == "m02-hopyo-check:personal::HP-000001:3" + assert got["suggested"] == [ # 서버 체크 제안 그대로 · 배 없는 줄 · % 줄 · 후보는 안 감 + {"차례": 1, "대상": [{"로직": "GC000198", "입력값": {"소량감률": 50}, "배": 1}]}, + {"차례": 2, "대상": [{"로직": "GF000101", "입력값": {}, "배": 1}]}, + {"차례": 6, "대상": [{"호표": 46, "배": 2.3}]}, + ] + assert got["edited"] == [ # 캐시 — 맞음 줄 체크 끔 · 뺌 · 고른 대상(몫 · 배) + {"차례": 2, "대상": [{"로직": "GF000101", "입력값": {}, "배": 1}]}, + {"차례": 3, "뺌": "우리에 없음"}, + { + "차례": 4, + "대상": [{"로직": "GF000211", "입력값": {"유형": "보통"}, "배": 0.1, "몫": 0.1}], + }, + {"차례": 6, "대상": [{"호표": 46, "배": 2.3}]}, + ] + assert got["leftOut"] == {"확인 안 함": 3, "뺌": 1, "% 줄": 1} + assert got["checkable"] == [True, True, True, True, False, False, True, False] + assert got["sure"] == [1, 2] # [맞음 · 별칭 모두 체크] + assert got["compare"] == "콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠" + assert got["toTarget"] == [{"호표": 46, "배": 2.3}, {"자재": "MT1", "배": 1}] + assert got["newest"] == [False, True, True] # 같은 줄 늦은 응답 버림 · 다른 줄은 따로 + assert got["dropped"] is False # 만들기 성공 = 캐시 지움 + + +def test_고르기_창_도우미(): + got = _check()["choose"] + assert got["hint"] == ["6-1-1", "고임돌"] # 인용 조항 번호 → 없으면 품명 첫 뜻 낱말 + assert got["picked"] == [ + {"로직": "GC000206", "입력값": {}, "배": 1, "몫": None}, + {"로직": "GF000211", "입력값": {}, "배": 0.1, "몫": 0.1}, + ] + assert got["inputs"] == "소량감률=50(블록), 지역=서울 · ⚠ 못 가름: 측압" + assert got["word"] == ["만듦", "있던 것을 확인한 내용으로 덮음"] + assert got["qty"] == "콘크리트공 0.1091 · 굴착기 0.29hr" + assert got["target"] == "제46호표 [㎡]" + + +def test_별칭_장부_화면_합침(): + got = _check()["ledger"] + assert got["key"] == "m02-hopyo-ledger:company:7" + assert got["merged"] == [ + ["나||m3", "personal", True, True, "안 씀"], # 고친 줄 + ["다||개", "personal", True, True, "짝"], # 위 층 → 내 층으로 고쳐 씀 + ["다||개", "system", False, False, "짝"], # 위 층 = 그대로 읽기만 + ] # 지운 줄(가) 없음 + assert got["filter"] == ["다||개", "다||개"] and got["kind"] == ["나||m3"] + assert got["pair"] == "GC000198 × 1 · 몫 0.1 · 소량감률=50" + assert got["entry"]["짝"] == [] and "층" not in got["entry"] + + +def test_길_만들기_통째_만들_층_미리_셈_장부(): + calls = _check()["calls"] + build = "/api/m02/layers/company/hopyo-build?owner=7" + head = {"층": "system", "주인": None, "키": "HP-000001", "번": 3} + assert calls[0]["url"] == build and calls[0]["body"]["줄"][0]["차례"] == 1 + assert {k: calls[0]["body"][k] for k in head} == head and "새로" not in calls[0]["body"] + assert calls[1]["body"] == {**head, "통째": {"대상": [{"로직": "GF000225", "배": 1}]}} + assert calls[2] == { + "url": "/api/m02/layers/system/hopyo/HP-000001/3?to=company&to_owner=7", + "method": "GET", + "body": None, + } + assert calls[3]["url"] == "/api/m02/layers/personal/hopyo/HP-000001/3/guess" + assert calls[3]["body"]["대상"][0]["몫"] == 0.1 + assert calls[4]["url"] == "/api/m02/layers/company/hopyo-ledger?owner=7" + assert calls[5]["method"] == "PUT" and calls[5]["body"] == {"줄": {"가|b|m3": None}} + + +def test_로직_추가_확인된_것은_바로_참조_아니면_확인_창(): + got = _check()["pick"] + assert got["picked"] == [ + { + "ref": {"층": "company", "주인": "7", "키": "SC-000009"}, + "name": "임도표지석", + "alias": "임도표지석", + } + ] + assert got["calls"] == [] # 확인 없이 만들지 않음 + assert got["opened"] == ["확인 — 북부청.xls 제4호표 배수관"] + + +def test_엑셀_보기_창_하위_호표_먼저(): + assert _check()["sheet"]["order"] == [46, 45, 3, 7, 8] # 순환은 남은 차례 + + +def _pick() -> dict: + row = {**ROW, "확인": {"층": "company", "주인": "7", "키": "SC-000009"}} + return _node("helper_m02_hopyo_pick.cjs", {"rows": [row], "built": {}}) + + +def test_고르기_창_실무_호표_줄(): + got = _pick() assert got["calls"][0] == { "url": "/api/m02/layers/company/hopyo-picks?owner=7", "method": "GET", @@ -69,7 +208,7 @@ def test_고르기_창_실무_호표_줄과_고름(): "tag": "실무 호표", "name": "임도표지석 910×600×150", "sub": "북부청.xls · 제3호표 · 마스터", - "unit": "개소 · 맞춤 2/4", + "unit": "개소 · 확인 SC-000009", }, { "kind": "hopyo", @@ -80,119 +219,16 @@ def test_고르기_창_실무_호표_줄과_고름(): "unit": "호표 1", }, ] - build = got["calls"][1] - assert build["url"] == "/api/m02/layers/company/hopyo-build?owner=7" # 만드는 층 = 문서 층 - assert build["body"] == { - "층": "system", - "주인": None, - "키": "HP-000001", - "번": 3, - "새로": False, - } - assert got["picked"] == [ - { - "ref": {"층": "company", "주인": "7", "키": "SC-000009"}, - "name": "임도표지석", - "alias": "임도표지석", - } - ] + assert got["picked"][0]["ref"]["키"] == "SC-000009" assert got["project"] is True # 프로젝트 층 = 덧줄 없음 -def test_본_화면_글_도우미(): - view = _run()["view"] +def test_글_도우미와_근거_꼬리표(): + got = _pick() + view = got["view"] assert view["target"] == ["구조물터파기 [㎥]", "레미콘 25-21-120 [㎥]", "제46호표 [㎡]"] - assert view["inputs"] == "횟수=4회사용시, 지역=서울 · ⚠ 못 읽음: 보정작업" - assert view["memory"] == [ - {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1}, - {"자재": "MT1", "입력값": {}, "환산": 0.5}, - None, # 환산 0 = 확정 못 함 - None, # 대상 없음 - ] - assert view["word"] == ["만듦", "다시 씀", "새로 만들어 덮음"] # 알림 · 판 글 - - -def test_줄_표_줄바꿈_없음과_고르기_창_머리(): - css = _text("M02_MasterTemplete_Hopyo.css") - rule = css.split(".m02-hopyo__table.m02-hopyo__lines\n :is(th, td):is(")[1].split("}")[0] - cols = [int(n) for n in re.findall(r":nth-child\((\d+)\)", rule)] - assert cols == [1, 4, 5, 6, 7, 9, 10, 11] and "white-space: nowrap;" in rule - heads = ["갈래", "품명", "규격", "수량", "단위", "엑셀 단가", "맞춤", "대상", "입력값", "환산"] - view = _text("M02_MasterTemplete_Hopyo_View.ts") - flat = re.sub(r"\s+", "", view) - assert re.sub(r"\s+", "", ",".join(f'"{h}"' for h in heads)) in flat # 열 차례 그대로 - top = css.split(".m02-hopyo__choose .ui-modal__top > .ui-btn {")[1].split("}")[0] - assert "flex-shrink: 0;" in top and "white-space: nowrap;" in top - assert '"가까운 것 없음"' in view and "builtWord(got, fresh)" in view - - -def test_좌측_판_자리와_권한(): - side = _text("M02_MasterTemplete_UI_Side.ts") - assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side - host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1] - assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),") - assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?") - assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한 - assert "hopyo.paint();" in side.split("function paint(): void {")[1] - names = set(re.findall(r"\b\w*[Pp]ractice\w*\b", side)) - assert names <= {"buildPracticeSection", "practice", "M02_MasterTemplete_Practice"} # 옛 이름만 - assert "LaborSection" not in side and "labor?." not in side and "picked" not in side - hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts") - assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side - assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side - - -def test_로직_추가_한_곳과_파일_규칙(): - detail = _text("M02_MasterTemplete_Labor_Detail.ts") - assert len(detail.splitlines()) == 693 # 줄 수 그대로(제자리 고침) - assert "withHopyo(library)," in detail - assert 'cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null' in detail - pick = _text("M02_MasterTemplete_Labor_Pick.ts") - assert "more?: () => Promise;" in pick and "...later" in pick - for name in NEW: - text = _text(f"M02_MasterTemplete_{name}.ts") - assert len(text.splitlines()) <= 700, name - css = _text("M02_MasterTemplete_Hopyo.css") - assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css) - assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_View.ts") - - -def test_엑셀_보기_창_행과_호표(): - got = _run() - assert got["calls"][2] == { - "url": "/api/m02/layers/company/hopyo-grid/HP-000001?owner=7", - "method": "GET", - "body": None, - } - sheet = got["sheet"] - assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음 - assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O) - assert sheet["last"] == ["AA", "A"] - assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체 - - -def test_엑셀_보기_창_여는_자리와_늦게_불러옴(): - lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")' - side = _text("M02_MasterTemplete_Hopyo_Side.ts") - assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동 - view = _text("M02_MasterTemplete_Hopyo_View.ts") - assert lazy in view and '"엑셀로 보기"' in view and '"엑셀에서 보기"' in view - assert "호표: no" in view # 호표 화면 = 그 호표 고른 채 - pick = _text("M02_MasterTemplete_Hopyo_Pick.ts") - assert lazy in pick and "lib.pick(got.참조" in pick - static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"' - for text in (side, view, pick): - assert not re.search(static, text, re.M) # 정적 import 없음(타입만) - sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts") - assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet - assert not re.search( - r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M - ) - assert "readOnly: true" in sheet and "wide: true" in sheet - - -def test_고르기_창_근거_꼬리표와_근거_맞음(): - basis = _run()["basis"] + assert view["inputs"] == "횟수=4회사용시, 지역=서울 · ⚠ 못 가름: 보정작업" + basis = got["basis"] assert basis["ok"] == [True, False, False, False, False] # 조항 같음 · 직종 · 단위 모두 assert basis["plain"] == [True, True] # 줄 직종이 보통인부뿐 · 없음 = 직종 안 봄 assert basis["tag"] == [ @@ -205,8 +241,104 @@ def test_고르기_창_근거_꼬리표와_근거_맞음(): "단가산출 7행", "", ] - # 지금 대상이 근거 안 맞으면 근거 맞는 후보 · 「후보」 줄만 · 근거 맞는 것 없으면 없음 - assert basis["pick"] == ["GC000083", "GC000083", None, None] + + +def test_엑셀_보기_창_행과_호표(): + got = _pick() + assert got["calls"][1] == { + "url": "/api/m02/layers/company/hopyo-grid/HP-000001?owner=7", + "method": "GET", + "body": None, + } + sheet = got["sheet"] + assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음 + assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O) + assert sheet["last"] == ["AA", "A"] + assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체 + + +# ── 소스 ────────────────────────────────────────────────────────────── +def test_옛_즉시_확정과_바로_만들기_없음(): + texts = {name: _text(f"M02_MasterTemplete_{name}.ts") for name in NEW} + every = "\n".join(texts.values()) + for gone in ("saveHopyoMemory", "hopyo-memory", "HopyoMemory", "memoryOf", "basisPick"): + assert gone not in every, gone + for label in ( + "같음 모두 확정", + "근거 맞음 모두 확정", + '"확정"', + '"사전조합 만들기"', + "불러오기", + ): + assert label not in every, label + users = sorted(name for name, text in texts.items() if "buildHopyo(" in text) + assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 펼침 뒤 한 번 + check = texts["Hopyo_Check"] + assert '"확인하고 사전조합 만들기"' in check and "맞음 · 별칭 모두 체크" in check + assert "showLoadingOverlay()" in check and "dropDraft(k)" in check + assert "m02-hopyo-check:" in check and "POLL_MS" in check + sheet = texts["Hopyo_Sheet"] + assert "확인하고 만들기" in sheet and "이대로 ${ready.length}개 만들기" in sheet + assert "확인 창 열기" in sheet and "buildOrder(" in sheet + pick = texts["Hopyo_Pick"] + assert "if (row.확인)" in pick and 'import("./M02_MasterTemplete_Hopyo_Check")' in pick + view = texts["Hopyo_View"] + assert "buildHopyoCheck(" in view and "choose(" not in view + ledger = texts["Hopyo_Ledger"] + assert "m02-hopyo-ledger:" in ledger and "saveLedger(" in ledger + for gone in ("발주처", "회사로 올리기", "쓰인 횟수"): + assert gone not in ledger.split("*/", 1)[1] # 머리 글 「안 둠」 밖 + + +def test_좌측_판_자리와_권한(): + side = _text("M02_MasterTemplete_UI_Side.ts") + assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side + host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1] + assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),") + assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?") + assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한 + assert "hopyo.paint();" in side.split("function paint(): void {")[1] + hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts") + assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side + assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side + assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부] + assert hopyo_side.index("list, ledger]") > 0 + + +def test_로직_추가_한_곳과_파일_규칙(): + detail = _text("M02_MasterTemplete_Labor_Detail.ts") + assert len(detail.splitlines()) == 693 # 줄 수 그대로 + assert "withHopyo(library)," in detail + pick = _text("M02_MasterTemplete_Labor_Pick.ts") + assert "more?: () => Promise;" in pick and "...later" in pick + assert "if (pre?.query) finder.search.value = pre.query;" in pick # 찾기 글 미리 넣음 + for name in NEW: + text = _text(f"M02_MasterTemplete_{name}.ts") + assert len(text.splitlines()) <= 700, name + css = _text("M02_MasterTemplete_Hopyo.css") + assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css) + for state in ("맞음", "별칭", "다른점", "후보"): + assert f".m02-hopyo__state.is-{state}" in css + assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_Check.ts") + + +def test_엑셀_보기_창_여는_자리와_늦게_불러옴(): + lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")' + side = _text("M02_MasterTemplete_Hopyo_Side.ts") + assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동 view = _text("M02_MasterTemplete_Hopyo_View.ts") - assert "단산행: basis.행" in view and "근거 맞음 모두 확정" in view - assert "x.근거 || x.맞춤.후보?.some((c) => c.근거)" in view # 옛 파일 = 단추 없음 + assert lazy in view and '"엑셀로 보기"' in view + check = _text("M02_MasterTemplete_Hopyo_Check.ts") + assert lazy in check and '"엑셀에서 보기"' in check and "호표: opt.no" in check + assert "단산행: 행" in check + pick = _text("M02_MasterTemplete_Hopyo_Pick.ts") + assert lazy in pick + static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"' + for text in (side, view, pick, check): + assert not re.search(static, text, re.M) # 정적 import 없음(타입만) + sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts") + assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet + assert not re.search( + r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M + ) + assert "readOnly: true" in sheet and "wide: true" in sheet diff --git a/resources/tester/test_m02_hopyo_seam.py b/resources/tester/test_m02_hopyo_seam.py index 54ac54462..58b1aa8a6 100644 --- a/resources/tester/test_m02_hopyo_seam.py +++ b/resources/tester/test_m02_hopyo_seam.py @@ -2,8 +2,8 @@ 화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet · Hopyo_View)에 먹임. 행 22 → 제3호표 · 25 빈 줄 · 26 → 제4호표 · 영역 B20:O24 · 단산93 행 4229:4244 · -엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 대상 · 북부청 「후보」 줄 1순위 근거 맞음 = 서버 기준 123 · -앞 마디 같음 0(330 덧고침 2). +엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 · 북부청 「후보」 줄 1순위 근거 맞음 = 서버 기준 123 +(338 — 근거 맞음 모두 확정 단추 없앰 · 도우미는 고르기 창 `Hopyo_Choose`). """ import json @@ -42,14 +42,12 @@ def test_실제_응답_화면_도우미(client): res = client.get(f"{URL.format('personal')}/HP-000001/{one['번']}").json() every += [x for x in res["호표"]["줄"] if x["갈래"] == "단가산출"] got = _node({"grid": grid, "lines": lines, "all": every}) - assert got["count"]["top"] == 123 and got["count"]["half"] == 0 - assert got["count"]["pick"] >= 123 + assert got["count"]["top"] == 123 assert got["at"] == [None, 3, 3, 3, None, 4] # 25행 = 호표 사이 빈 줄 assert got["span"] == "B20:O24" and got["rows"] == "4229:4244" by = {x["이름"]: x for x in got["lines"]} dig = by["구조물터파기(인력100%)"] assert dig["source"] == "출처 건설품셈 3-2-1 · 직종 특별인부 · 단가산출 4229–4244행" assert dig["tag"] == "조항 건설품셈 공통 3-2-1 · 직종 특별인부 · 단위 같음" - assert dig["ok"] is True and dig["pick"] == "GC000083" - assert by["비탈면 보호공"]["pick"] == "GC000143" + assert dig["ok"] is True assert by["흙깍기(토사)"]["ok"] is True # 엑셀 직종 없는 줄(기계) = 조항 같음 + 단위