From 2bfe653ac0ef026ed208882e487a65f1806f7249 Mon Sep 17 00:00:00 2001 From: bicochan Date: Fri, 4 Sep 2026 15:18:32 +0900 Subject: [PATCH 01/10] =?UTF-8?q?[as]=20feat:=20'dcrag-to'=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/dcrag-to/api/dcrag-to-service.ts | 54 ++++++++++++++ apps/as/src/entities/dcrag-to/api/queries.ts | 70 +++++++++++++++++++ apps/as/src/entities/dcrag-to/index.ts | 27 +++++++ .../dcrag-to/model/dcrag-to-interface.ts | 52 ++++++++++++++ .../dcrag-to/model/dcrag-to-payload-schema.ts | 45 ++++++++++++ apps/as/src/shared/config/api.ts | 1 + 6 files changed, 249 insertions(+) create mode 100644 apps/as/src/entities/dcrag-to/api/dcrag-to-service.ts create mode 100644 apps/as/src/entities/dcrag-to/api/queries.ts create mode 100644 apps/as/src/entities/dcrag-to/index.ts create mode 100644 apps/as/src/entities/dcrag-to/model/dcrag-to-interface.ts create mode 100644 apps/as/src/entities/dcrag-to/model/dcrag-to-payload-schema.ts diff --git a/apps/as/src/entities/dcrag-to/api/dcrag-to-service.ts b/apps/as/src/entities/dcrag-to/api/dcrag-to-service.ts new file mode 100644 index 000000000..16c2ccdbd --- /dev/null +++ b/apps/as/src/entities/dcrag-to/api/dcrag-to-service.ts @@ -0,0 +1,54 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + DcRagApiToService, + DcRagCommonResponseDto, + DcRagDraftGenerateResponseDto, + DcRagDraftStatusResponseDto, +} from "../model/dcrag-to-interface"; +import type { + DcRagCostVerifyRequestDto, + DcRagDocumentTransferRequestDto, + DcRagDraftGenerateRequestDto, +} from "../model/dcrag-to-payload-schema"; + +const BASE = ENDPOINT.AS_SERVICE.DCRAG_TO; + +async function requestDocumentTransfer( + body: DcRagDocumentTransferRequestDto, +): Promise { + const response = await client.post(`${BASE}/documents/transfer`, body); + return response.data; +} + +async function requestDraftGeneration( + body: DcRagDraftGenerateRequestDto, +): Promise { + const response = await client.post( + `${BASE}/documents/draft/generate`, + body, + ); + return response.data; +} + +async function getDraftGenerationStatus(crtDmndUuid: string): Promise { + const response = await client.get( + `${BASE}/documents/draft/status/${crtDmndUuid}`, + ); + return response.data; +} + +async function requestCostVerification( + body: DcRagCostVerifyRequestDto, +): Promise { + const response = await client.post(`${BASE}/documents/cost/verify`, body); + return response.data; +} + +export const dcRagApiToService = { + requestDocumentTransfer, + requestDraftGeneration, + getDraftGenerationStatus, + requestCostVerification, +} satisfies DcRagApiToService; diff --git a/apps/as/src/entities/dcrag-to/api/queries.ts b/apps/as/src/entities/dcrag-to/api/queries.ts new file mode 100644 index 000000000..4e9fc4483 --- /dev/null +++ b/apps/as/src/entities/dcrag-to/api/queries.ts @@ -0,0 +1,70 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { DcRagDraftStatusResponseDto } from "../model/dcrag-to-interface"; +import type { + DcRagCostVerifyRequestDto, + DcRagDocumentTransferRequestDto, + DcRagDraftGenerateRequestDto, +} from "../model/dcrag-to-payload-schema"; + +import { dcRagApiToService } from "./dcrag-to-service"; + +export const dcRagApiToBaseKey = [ENDPOINT.AS_SERVICE.DCRAG_TO] as const; + +const DRAFT_STATUS_POLL_INTERVAL_MS = 5_000; + +function isDraftGenerationSettled(data: DcRagDraftStatusResponseDto | undefined): boolean { + if (!data) return false; + if (data.status === "COMPLETED" || data.status === "FAILED") return true; + return data.events?.some((event) => event.eventType === "COMPLETED") ?? false; +} + +export function useDraftGenerationStatus(crtDmndUuid: string | null | undefined) { + return useQuery({ + queryKey: [...dcRagApiToBaseKey, "getDraftGenerationStatus", crtDmndUuid], + queryFn: () => dcRagApiToService.getDraftGenerationStatus(crtDmndUuid!), + enabled: Boolean(crtDmndUuid), + refetchInterval: (query) => + isDraftGenerationSettled(query.state.data) ? false : DRAFT_STATUS_POLL_INTERVAL_MS, + }); +} + +export function useRequestDocumentTransfer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: DcRagDocumentTransferRequestDto) => + dcRagApiToService.requestDocumentTransfer(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: dcRagApiToBaseKey }); + }, + }); +} + +export function useRequestDraftGeneration() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: DcRagDraftGenerateRequestDto) => + dcRagApiToService.requestDraftGeneration(body), + onSuccess: (_data, body) => { + queryClient.invalidateQueries({ queryKey: dcRagApiToBaseKey }); + if (body.crtDmndUuid) { + queryClient.invalidateQueries({ + queryKey: [...dcRagApiToBaseKey, "getDraftGenerationStatus", body.crtDmndUuid], + }); + } + }, + }); +} + +export function useRequestCostVerification() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: DcRagCostVerifyRequestDto) => + dcRagApiToService.requestCostVerification(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: dcRagApiToBaseKey }); + }, + }); +} diff --git a/apps/as/src/entities/dcrag-to/index.ts b/apps/as/src/entities/dcrag-to/index.ts new file mode 100644 index 000000000..2944e98c6 --- /dev/null +++ b/apps/as/src/entities/dcrag-to/index.ts @@ -0,0 +1,27 @@ +export { dcRagApiToService } from "./api/dcrag-to-service"; +export { dcRagApiToBaseKey } from "./api/queries"; +export { + useDraftGenerationStatus, + useRequestCostVerification, + useRequestDocumentTransfer, + useRequestDraftGeneration, +} from "./api/queries"; +export type { + DcRagApiToService, + DcRagCommonResponseDto, + DcRagDraftGenerateResponseDto, + DcRagDraftStatusResponseDto, + EventInfoDto, +} from "./model/dcrag-to-interface"; +export type { + DcRagCostVerifyRequestDto, + DcRagDocumentTransferRequestDto, + DcRagDraftGenerateRequestDto, + DcRagFileInfoDto, +} from "./model/dcrag-to-payload-schema"; +export { + dcRagCostVerifyRequestSchema, + dcRagDocumentTransferRequestSchema, + dcRagDraftGenerateRequestSchema, + dcRagFileInfoSchema, +} from "./model/dcrag-to-payload-schema"; diff --git a/apps/as/src/entities/dcrag-to/model/dcrag-to-interface.ts b/apps/as/src/entities/dcrag-to/model/dcrag-to-interface.ts new file mode 100644 index 000000000..d80f91049 --- /dev/null +++ b/apps/as/src/entities/dcrag-to/model/dcrag-to-interface.ts @@ -0,0 +1,52 @@ +import type { + DcRagCostVerifyRequestDto, + DcRagDocumentTransferRequestDto, + DcRagDraftGenerateRequestDto, +} from "./dcrag-to-payload-schema"; + +/** 백엔드 DcRagCommonResponseDto */ +export interface DcRagCommonResponseDto { + resultCode?: string; + message?: string; + requestId?: string; +} + +/** 백엔드 DcRagDraftGenerateResponseDto */ +export interface DcRagDraftGenerateResponseDto { + resultCode?: string; + message?: string; + crtDmndUuid?: string; + /** R:생성요청, I:생성중, C:생성완료, F:생성실패 */ + crtSttsComCd?: "R" | "I" | "C" | "F"; + requestId?: string; + estimatedMinutes?: number; +} + +/** 백엔드 DcRagDraftStatusResponseDto.EventInfoDto */ +export interface EventInfoDto { + eventType?: string; + eventMessage?: string; + occurredDt?: string; +} + +/** 백엔드 DcRagDraftStatusResponseDto */ +export interface DcRagDraftStatusResponseDto { + crtDmndUuid?: string; + status?: "REQUESTED" | "DOWNLOADING" | "ANALYZING" | "GENERATING" | "COMPLETED" | "FAILED"; + statusName?: string; + progressPercent?: number; + events?: EventInfoDto[]; + startedDt?: string; + estimatedCompletionDt?: string; + errorMessage?: string; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface DcRagApiToService { + requestDocumentTransfer(body: DcRagDocumentTransferRequestDto): Promise; + requestDraftGeneration( + body: DcRagDraftGenerateRequestDto, + ): Promise; + getDraftGenerationStatus(crtDmndUuid: string): Promise; + requestCostVerification(body: DcRagCostVerifyRequestDto): Promise; +} diff --git a/apps/as/src/entities/dcrag-to/model/dcrag-to-payload-schema.ts b/apps/as/src/entities/dcrag-to/model/dcrag-to-payload-schema.ts new file mode 100644 index 000000000..7ad5827c2 --- /dev/null +++ b/apps/as/src/entities/dcrag-to/model/dcrag-to-payload-schema.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +/** 백엔드 DcRagFileInfoDto */ +export const dcRagFileInfoSchema = z.object({ + fileId: z.string().optional(), + fileGroupId: z.string().optional(), + fileName: z.string().optional(), + physicalPath: z.string().optional(), + downloadUrl: z.string().optional(), + fileSize: z.number().optional(), + esntlAtchFileId: z.string().optional(), + esntlAtchFileNm: z.string().optional(), +}); + +/** 백엔드 DcRagDocumentTransferRequestDto */ +export const dcRagDocumentTransferRequestSchema = z.object({ + fnrsCnsltnDocNo: z.string(), + taskSeComCd: z.string().optional(), + taskSeCdNm: z.string().optional(), + essentialFiles: z.array(dcRagFileInfoSchema).optional(), + etcFiles: z.array(dcRagFileInfoSchema).optional(), + callbackUrl: z.string().optional(), +}); + +/** 백엔드 DcRagDraftGenerateRequestDto */ +export const dcRagDraftGenerateRequestSchema = z.object({ + fnrsCnsltnDocNo: z.string(), + pwrkCnsltnDocNo: z.string(), + crtDmndUuid: z.string().optional(), + essentialFiles: z.array(dcRagFileInfoSchema).optional(), + etcFiles: z.array(dcRagFileInfoSchema).optional(), + rqstrUuid: z.string().optional(), + callbackUrl: z.string().optional(), +}); + +/** 백엔드 DcRagCostVerifyRequestDto */ +export const dcRagCostVerifyRequestSchema = z.object({ + fnrsCnsltnDocNo: z.string(), + pwrkCnsltnDocNo: z.string(), +}); + +export type DcRagFileInfoDto = z.infer; +export type DcRagDocumentTransferRequestDto = z.infer; +export type DcRagDraftGenerateRequestDto = z.infer; +export type DcRagCostVerifyRequestDto = z.infer; diff --git a/apps/as/src/shared/config/api.ts b/apps/as/src/shared/config/api.ts index 3dc97907d..fcceff141 100644 --- a/apps/as/src/shared/config/api.ts +++ b/apps/as/src/shared/config/api.ts @@ -25,6 +25,7 @@ export const ENDPOINT = { DESIGN_CNSTN_RVW_OPNN: "/as-service/api/v1/design-cnstn-rvw-opnn", SEWER_BSC_PLAN: "/as-service/api/v1/sewer-bsc-plans", DASHBOARD: "/as-service/api/v1/dashboard", + DCRAG_TO: "/as-service/api/v1/dcrag-to", }, USER_SERVICE: { LOGIN: "/user-service/login", From 94af73a810c311ae82aa08abd4169351ad5f0e7b Mon Sep 17 00:00:00 2001 From: bicochan Date: Mon, 7 Sep 2026 15:22:21 +0900 Subject: [PATCH 02/10] =?UTF-8?q?[as]=20docs:=20=EC=8B=A4=EB=AC=B4?= =?UTF-8?q?=ED=98=91=EC=9D=98=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/docs/pwrk-cnsltn/development-guide.md | 363 ++++++++ apps/as/docs/pwrk-cnsltn/ref-endpoint.md | 336 ++++++++ .../docs/pwrk-cnsltn/ref-frontend-entities.md | 343 ++++++++ .../as/docs/pwrk-cnsltn/ref-process-status.md | 246 ++++++ .../as/docs/pwrk-cnsltn/ref-screen-buttons.md | 173 ++++ apps/as/docs/pwrk-cnsltn/개발가이드.md | 800 ------------------ 6 files changed, 1461 insertions(+), 800 deletions(-) create mode 100644 apps/as/docs/pwrk-cnsltn/development-guide.md create mode 100644 apps/as/docs/pwrk-cnsltn/ref-endpoint.md create mode 100644 apps/as/docs/pwrk-cnsltn/ref-frontend-entities.md create mode 100644 apps/as/docs/pwrk-cnsltn/ref-process-status.md create mode 100644 apps/as/docs/pwrk-cnsltn/ref-screen-buttons.md delete mode 100644 apps/as/docs/pwrk-cnsltn/개발가이드.md diff --git a/apps/as/docs/pwrk-cnsltn/development-guide.md b/apps/as/docs/pwrk-cnsltn/development-guide.md new file mode 100644 index 000000000..863304cca --- /dev/null +++ b/apps/as/docs/pwrk-cnsltn/development-guide.md @@ -0,0 +1,363 @@ +# 재원협의·실무협의 프론트엔드 개발 가이드 + +> **대상 앱**: `frontend/apps/as` +> **작성일**: 2026-08-27 +> **개정일**: 2026-09-07 +> **버전**: 2.0 +> **Process**: PR-AS-003 (재원협의 관리 — 실무협의 및 AI 검증 통합) +> +> **SoT (2026-09-03 완결)**: +> +> - 프로세스·상태·버튼: `03.재원협의_실무협의_프로세스_통합_v완결.md` +> - REST 설계: `05.재원협의_상태별_endpoint_설계.md` +> - 공통코드: `FNRSSTTS` (`SV, ST, LE, FV, ER, SL, AC, TE, TP, RM, RB, RC, RA, AV, RK, RR, EP, TA`) +> +> **백엔드 서비스**: +> +> - `as-service` — `/api/v1/fnrs-cnsltn-mng`, `/api/v1/pwrk-cnsltn-mng`, `/api/v1/pwrk-cnsltn-doc-crt-rslt`, `/api/v1/dcrag-to` +> - `as-elapr-service` — `/api/v1/elapr-dmnd`, `/api/v1/elapr-prgrs-stts`, `/api/v1/elapr-apln` +> +> **현재 프론트 상태**: 재원협의(`fnrs-cnsltn`)·전자결재(`elapr`)·DC RAG TO(`dcrag-to`)는 존재한다. 실무협의(`pwrk-cnsltn-*`) 엔티티·화면은 **미구현**. 버튼 매트릭스·상태코드는 **v2.1 잔존**이며 v3.0(`FNRSSTTS`)과 불일치한다. [9절](#9-현재-코드-vs-to-be-갭) 참고. + +--- + +## 1. 문서 목적 + +재원협의 4대 주체(기초·광역·환경청·공단)의 **상태 전이·버튼·API**를 `apps/as`에 구현할 때 쓰는 프론트엔드 가이드이다. + +v1.0은 공단 접수자의 **자료검증 → 초안생성 → 웹기안기**만 다뤘다. v2.0은 2026-09-03 완결 프로세스 기준으로 **신청부터 최종종결(`TA`)까지**, 실무협의·AI 3파이프라인·결재선·보완 루프를 포함한다. + +상세 표·시퀀스·DTO는 본문에 넣지 않고 레퍼런스로 분리한다. 구현 중에는 본문의 규칙만 보고, 표가 필요하면 해당 파일을 연다. + +--- + +## 2. 문서 맵 + +| 파일 | 내용 | 언제 보는가 | +| -------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------- | +| [development-guide.md](./development-guide.md) (본 문서) | 도메인, 핵심 규칙, 화면 흐름, 갭, 구현 순서 | 일상 구현 | +| [ref-process-status.md](./ref-process-status.md) | 4-Lane 시퀀스, Activity, `FNRSSTTS`, 보완 3루프, AI 타임아웃, 결재선 복사 | 상태 전이·폴링 UX | +| [ref-screen-buttons.md](./ref-screen-buttons.md) | 38행 화면·역할·버튼 매트릭스, 버튼 가시성 | `button-config.ts` 개편 | +| [ref-endpoint.md](./ref-endpoint.md) | 버튼→API 총괄, 단계별 입출력, FE 호출 금지 API, Gap Analysis | 엔티티·service 매핑 | +| [ref-frontend-entities.md](./ref-frontend-entities.md) | ENDPOINT, 슬라이스, Service 시그니처, 폴링, 웹기안기, DTO | 엔티티 레이어 구현 | + +--- + +## 3. 도메인 개요 + +실무협의서(PWRK)는 재원협의서(FNRS)의 **하위 문서**이다. 재원협의 1건당 실무협의서는 **1건만** 생성된다. + +| 문서 | 번호 예시 | 필드 | +| -------- | ----------------- | ----------------------------- | +| 재원협의 | `F-20260824-0001` | `fnrsCnsltnDocNo` / `upDocNo` | +| 실무협의 | `P-20260827-0001` | `pwrkCnsltnDocNo` | + +진행상태는 **재원협의 마스터** `tb_as_fnrs_cnsltn_mng.prgrs_stts_com_cd` (`FNRSSTTS`)가 SSOT이다. 실무협의 문서의 `prgrsSttsComCd`는 연계 표시용이며, 화면 버튼은 재원협의 상태로 분기한다. + +### 3-1. 4대 주체 + +| 역할 | 코드 | 화면 소유 | 핵심 구간 | +| --------------- | ------------- | -------------- | ------------------------------------ | +| 기초자치단체 | `ROLE_USER05` | 작성/수정/상세 | 신청, 3종 보완, 조치결과, 최종확인 | +| 광역자치단체 | `ROLE_USER04` | 상세 | 경유 제출, 결과 전달 | +| 유역·지방환경청 | `ROLE_USER03` | 상세 | 의뢰/자체종결, 1·2차 회신 | +| 한국환경공단 | `ROLE_USER01` | 상세 + 결재선 | 파일검증, 검토자지정, 1·2차 기술검토 | + +### 3-2. 핵심 식별자 + +| 필드 | 의미 | 비고 | +| ----------------- | ---------------- | ----------------------------------------------------------- | +| `fnrsCnsltnDocNo` | 재원협의문서번호 | 상태 전이·목록 PK | +| `pwrkCnsltnDocNo` | 실무협의문서번호 | `P-YYYYMMDD-NNNN`. 검토자지정(`ER→AC`) 시 발급 | +| `upDocNo` | 상위문서번호 | 재원협의 번호. `upDocSeComCd = "FNRS"` | +| `crtDmndUuid` | 생성요청 UUID | 초안 폴링·웹기안기·다운로드 키 | +| `verSn` | 버전일련번호 | 복합 PK `(crtDmndUuid, verSn)`. 저장 시 서버가 `+1` | +| `orgnlOtptDocCn` | HWP 바이너리 | 목록/상세 응답은 항상 `null`. 다운로드·`webhwp/open`만 사용 | +| `elaprCycl` | 결재 차수 | 1차 `TE`, 2차 `RK`. 금액검증 완료 후 1차 결재선 복사 | + +### 3-3. IIPBS 약어 + +| 한글 | 약어 | 필드 예 | +| ---- | ------------ | --------------------------- | +| 재원 | FNRS | `fnrsCnsltnDocNo` | +| 실무 | PWRK | `pwrkCnsltnDocNo` | +| 협의 | CNSLTN | `fnrs-cnsltn-mng` | +| 생성 | CRT | `crtDmndUuid` | +| 요청 | DMND | `crtDmndUuid`, `elapr-dmnd` | +| 검증 | VRFC | `vrfcSttsComCd` | +| 보완 | SPLMNT | `splmntDmndList` | +| 결재 | ELAPR / ATRZ | `elaprCycl`, `atrzSeComCd` | + +--- + +## 4. 상태코드 한눈에 보기 + +상위코드 **`FNRSSTTS`**. v1.0의 `PWRKSTTS`(`DV`, `AR`, `TR` 등)는 **폐기**한다. + +정방향: + +``` +SV → ST → LE → FV → ER → AC → TE → TP → RM → RB → RC → RA → AV → RK → RR → EP → TA +``` + +자체종결: `LE → EP → TA` +보완: `ER|TE|RK → SL` 후 직전 상태로 복귀. + +| 코드 | 명칭 | FE에서 할 일 | +| ---- | ---------------------- | ------------------------------------------------- | +| `SV` | 저장 | 기초 작성화면 저장/신청/삭제 | +| `ST` | 신청완료 | 광역 환경청제출 | +| `LE` | 환경청 검토 | 자체종결(`EP`) 또는 기술검토 의뢰(`FV`) | +| `FV` | 파일검증 | AI 진행 표시. **30분** 후 `ER` Fallback | +| `ER` | 기술검토 의뢰 | 공단 접수자: 파일보완 / 수동파일검증 / 검토자지정 | +| `SL` | 보완요청 | 기초 수정화면. 복귀 대상은 이력의 직전 상태 | +| `AC` | 초안생성 | AI 진행 표시. **30분** 후 `TE` Fallback | +| `TE` | 기술검토 | 웹기안기·검토완료·보완. 결재선 1차 | +| `TP` | 기술검토 회신 | 환경청 → 광역 `RM` | +| `RM` | 재원협의결과회신 | 광역 → 기초 `RB` | +| `RB` | 지자체회신 | 기초 조치결과 저장/협의회신 | +| `RC` | 조치결과서 제출 | 광역 → 환경청 `RA` | +| `RA` | 조치결과서제출(환경청) | 환경청 2차 의뢰 `AV` | +| `AV` | 금액검증 | AI 진행 표시. **30분** 후 `RK` Fallback | +| `RK` | 조치결과서검토 | 웹기안기·금액검증 재실행·보완. 결재선 2차 | +| `RR` | 조치결과서검토 회신 | 환경청 → 광역 `EP` | +| `EP` | 검토결과 회신 | 광역 → 기초 `TA` | +| `TA` | 검토결과 회신(종료) | 기초 조회만. **고시(`TB`) 아님** | + +코드 의미·구버전 대조·보완 루프·AI Fallback은 [ref-process-status.md](./ref-process-status.md). + +--- + +## 5. 프론트가 지켜야 할 핵심 규칙 + +1. **상태 SSOT는 `FNRSSTTS`**. 버튼 가시성은 `(dtlInstCd, prgrsSttsComCd, atrzSeComCd)`로만 계산한다. 공단은 결재선 구분(`ATRZ01` 접수자, `ATRZ02` 검토자, `ATRZ03` 승인자, `ATRZ99` 최종승인자)을 추가로 본다. +2. **AI 콜백 API는 화면에서 호출하지 않는다.** `POST /dcrag/documents/verify-result`, `POST /dcrag/draft/complete`, `POST /dcrag/cost/verify-result`는 서버 전용이다. +3. **초안생성 1차 트리거는 검토자지정이다.** `POST /elapr-dmnd` (`ER → AC`). 예전처럼 `POST /pwrk-cnsltn-mng`로 초안을 시작하지 않는다. +4. **기술검토 의뢰는 `LE → FV` 전용 API** `POST /fnrs-cnsltn-mng/{docNo}/request-examine`. 현재 코드의 `updatePrgrsStts(ER)` 직행은 폐기. +5. **수동 AI 재실행은 상태를 바꾸지 않는다.** + - `ER` 파일검증 → `POST /dcrag-to/documents/transfer` + - `TE` 초안생성 → `POST /dcrag-to/documents/draft/generate` (타임아웃·실패 시에만) + - `RK` 금액검증 → `POST /dcrag-to/documents/cost/verify` +6. **진행상태만 바꾸는 버튼은 `POST /fnrs-cnsltn-mng/doc/updatePrgrsStts`를 재사용한다.** (`ST→LE`, `RM→RB`, `RC→RA`, `RR→EP`, `EP→TA`) +7. **보완요청/보완완료는 공통 API** `reqsupplement` / `submitsupplement`. 복귀 상태(`ER`/`TE`/`RK`)는 서버가 이력에서 결정한다. 프론트는 직전 상태를 추측해 보내지 않는다. +8. **결재 승인/회신은 `PUT /elapr-prgrs-stts/{id}/process` 하나**. 1차 최종승인자 → `TP`, 2차 → `RR`은 서버가 Feign으로 처리한다. +9. **2차 결재선 복사는 백엔드**. `POST /elapr-apln/copy-line`은 `AV→RK` 콜백에서 호출한다. 프론트는 `RK`에서 2차 결재선을 **조회·표시**만 한다. +10. **웹기안기는 URL을 직접 Open**. blob Object URL을 `HwpCtrl.Open`에 넘기지 않는다. `orgnlOtptDocCn`은 쓰지 않는다. + +--- + +## 6. 화면 데이터 흐름 + +재원협의 **목록 → 상세/작성**이 본체다. 실무협의 목록은 공단 검토 보조 화면이다. + +``` +BEGIN + CALL fnrsCnsltnMngService.findPage(...) + ON 행 CLICK → /fnrs-cnsltn/{fnrsCnsltnDocNo} + + CALL findById(fnrsCnsltnDocNo) + COMPUTE buttons = getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList) + + // ----- 기초 ROLE_USER05 ----- + ON [저장] (빈값/SV) → saveApply(prgrsSttsComCd: SV) + ON [신청완료] (SV) → saveApply(prgrsSttsComCd: ST) + ON [삭제] (SV) → remove + ON [파일보완완료] (SL←ER) → submitsupplement + ON [보완완료] (SL←TE|RK) → submitsupplement + ON [저장] (RB) → submit-actn-result { isSubmit: false } + ON [협의회신] (RB) → submit-actn-result { isSubmit: true } + ON [결과회신] (TA) → API 없음 (조회) + + // ----- 광역 ROLE_USER04 ----- + ON [환경청제출] (ST) → updatePrgrsStts(LE) + ON [지자체회신] (RM) → updatePrgrsStts(RB) + ON [조치결과서제출] (RC) → updatePrgrsStts(RA) + ON [결과회신] (EP) → updatePrgrsStts(TA) + + // ----- 환경청 ROLE_USER03 ----- + ON [결과회신] (LE 자체종결) → tech-review → EP + ON [기술검토 의뢰] (LE) → request-examine → FV → START POLL FV + ON [재원협의 결과 회신] (TP) → tech-review → RM + ON [조치결과서 검토요청] (RA) → request-actn-review → AV → START POLL AV + ON [결과회신] (RR) → updatePrgrsStts(EP) + + // ----- 공단 ROLE_USER01 ----- + ON [파일보완] (ER, ATRZ01) → GET file-vrfc-results → 팝업 → reqsupplement + ON [파일검증] (ER, ATRZ01) → dcrag-to transfer (상태 유지) + ON [검토자지정] (ER, ATRZ01) → POST elapr-dmnd (docPrgrsSttsComCd: AC) → START POLL AC + ON [실무협의서관리] (TE|RK) → HwpCtrl.Open(webhwp/open) + ON [실무협의서생성] (TE, 실패/타임아웃) → dcrag-to draft/generate + ON [검토결과조회] (TE) → pwrk-cnsltn-mng findById | findByUpDocNo + ON [저장] (TE|RK) → pwrk-cnsltn-mng save-apply + ON [검토완료] → elapr-prgrs-stts process (상신) + ON [보완요청] (TE|RK) → 보완 팝업 → reqsupplement + ON [금액검증] (RK) → dcrag-to cost/verify + ON [결재(승인)] → process (중간) + ON [결재(회신)] → process (최종 → TP 또는 RR) + + // ----- AI 진행 중 (FV / AC / AV) ----- + POLL 상태 또는 draft status. 30분 안내. 종료 후 상세 invalidate +END +``` + +38행 버튼 표는 [ref-screen-buttons.md](./ref-screen-buttons.md). API 상세는 [ref-endpoint.md](./ref-endpoint.md). + +--- + +## 7. 버튼 → API 축약 + +프론트가 치는 것만. 콜백·스케줄러는 제외. + +| 주체 | 버튼 | From → To | Method & URI | +| --------- | -------------------------- | ----------------------------- | --------------------------------------------------------------- | +| 기초 | 저장 / 신청완료 | →`SV` / `SV→ST` | `POST /fnrs-cnsltn-mng/save-apply` | +| 기초 | 삭제 | `SV` | `DELETE /fnrs-cnsltn-mng/{docNo}` | +| 광역 | 환경청제출 | `ST→LE` | `POST /fnrs-cnsltn-mng/doc/updatePrgrsStts` | +| 환경청 | 결과회신(자체종결) | `LE→EP` | `POST /fnrs-cnsltn-mng/{docNo}/tech-review` | +| 환경청 | 기술검토 의뢰 | `LE→FV` | **신규** `POST /fnrs-cnsltn-mng/{docNo}/request-examine` | +| 공단 접수 | 파일보완 팝업 | `ER` | `GET /fnrs-cnsltn-mng/{docNo}/file-vrfc-results` | +| 공단 접수 | 파일보완요청 | `ER→SL` | `POST /fnrs-cnsltn-mng/{docNo}/reqsupplement` | +| 공단 접수 | 파일검증 재실행 | `ER` 유지 | `POST /dcrag-to/documents/transfer` | +| 공단 접수 | 검토자지정 | `ER→AC` | `POST /elapr-dmnd` | +| 공단 검토 | 실무협의서관리 | `TE`/`RK` | `GET .../pwrk-cnsltn-doc-crt-rslt/{uuid}/webhwp/open` | +| 공단 검토 | 실무협의서생성 | `TE` 유지 | `POST /dcrag-to/documents/draft/generate` | +| 공단 검토 | 저장 | `TE`/`RK` | `POST /pwrk-cnsltn-mng/save-apply` | +| 공단 | 검토완료 / 결재(승인·회신) | `TE` 유지 / `TE→TP` / `RK→RR` | `PUT /elapr-prgrs-stts/{id}/process` | +| 공단 검토 | 보완요청 | `TE→SL` / `RK→SL` | `POST /fnrs-cnsltn-mng/{docNo}/reqsupplement` | +| 기초 | 파일보완완료 / 보완완료 | `SL→ER\|TE\|RK` | `POST /fnrs-cnsltn-mng/{docNo}/submitsupplement` | +| 환경청 | 재원협의 결과 회신 | `TP→RM` | `POST /fnrs-cnsltn-mng/{docNo}/tech-review` | +| 광역 | 지자체회신 | `RM→RB` | `updatePrgrsStts` | +| 기초 | 저장(조치) / 협의회신 | `RB` / `RB→RC` | **신규** `POST /pwrk-cnsltn-mng/{pwrkDocNo}/submit-actn-result` | +| 광역 | 조치결과서제출 | `RC→RA` | `updatePrgrsStts` | +| 환경청 | 조치결과서 검토요청 | `RA→AV` | **신규** `POST /fnrs-cnsltn-mng/{docNo}/request-actn-review` | +| 공단 검토 | 금액검증 재실행 | `RK` 유지 | `POST /dcrag-to/documents/cost/verify` | +| 환경청 | 결과회신 | `RR→EP` | `updatePrgrsStts` | +| 광역 | 결과회신 | `EP→TA` | `updatePrgrsStts` | +| 웹기안기 | 저장 | 버전 +1 | `POST /pwrk-cnsltn-doc-crt-rslt` multipart | +| 공통 | 다운로드 | — | `GET /pwrk-cnsltn-doc-crt-rslt/{uuid}/download` | + +신규 4개: `request-examine`, `submit-actn-result`, `request-actn-review`, (백엔드) `elapr-apln/copy-line`. + +--- + +## 8. 프론트엔드 구현 가이드 + +### 8-1. 기술 스택·원칙 + +- App Router + FSD (`app/` → `pages/` → `widgets/` → `entities/` → `shared/`) +- React Query. ad-hoc `useQuery` + service 직접 호출 금지 +- `@common/components`, `useDataTable`, `@tanstack/react-table` +- `'use client'`는 Web API(다운로드, 폴링, 웹기안기)가 있는 페이지만 +- 함수명 = Controller public 메서드명 (`delete` → `remove`) +- 타입명 = 백엔드 DTO명 +- Save/Apply Request = zod SSOT. Response는 `interface` + +참조 슬라이스: `entities/fnrs-cnsltn/`, `entities/elapr/`, `entities/dcrag-to/`, `entities/sewer-bsc-plan/` + +### 8-2. 모듈 배치 + +| 구분 | 경로 | +| ------------- | ------------------------------------------------------------------------------------------------- | +| ENDPOINT | `apps/as/src/shared/config/api.ts` | +| 재원협의 | `entities/fnrs-cnsltn/` — **확장** (`requestExamine`, `requestActnReview`, `findFileVrfcResults`) | +| 실무협의 관리 | `entities/pwrk-cnsltn-mng/` — **신규** | +| 생성결과 | `entities/pwrk-cnsltn-doc-crt-rslt/` — **신규** | +| DC RAG TO | `entities/dcrag-to/` — 구현됨 | +| 전자결재 | `entities/elapr/` — 구현됨. `copyLine`은 서버 전용 | +| 재원협의 화면 | `pages/fnrs-cnsltn/` — 버튼 매트릭스 전면 개편 | +| 실무협의 화면 | `pages/pwrk-cnsltn/` — 목록/상세 (선택, 공단 보조) | + +파일 트리·Service 시그니처·폴링 훅은 [ref-frontend-entities.md](./ref-frontend-entities.md). + +### 8-3. 버튼 매트릭스 구현 + +`pages/fnrs-cnsltn/lib/button-config.ts`를 v3.0 표로 교체한다. + +- 키: `Record>` +- 공단은 `Record>` (`ER`/`TE`/`RK`) +- AI 상태(`FV`, `AC`, `AV`)는 버튼 대신 **진행 패널** (진행률·경과시간·30분 안내) +- `SL`은 기초만 수정 버튼. 복귀 종류(파일/기술/금액)는 `sttsChgHstryList` 직전 코드로 라벨만 분기 +- `TA`는 조회 전용. **고시(`TB`) 액션 삭제** + +### 8-4. AI 3파이프라인 UX + +| 상태 | 진입 버튼 | 완료 상태 | 30분 Fallback | 수동 버튼 | +| ---- | ------------------- | --------- | ----------------------- | ----------- | +| `FV` | 기술검토 의뢰 | `ER` | `ER` + [파일검증] | 공단 접수자 | +| `AC` | 검토자지정 | `TE` | `TE` + [실무협의서생성] | 공단 검토자 | +| `AV` | 조치결과서 검토요청 | `RK` | `RK` + [금액검증] | 공단 검토자 | + +폴링: 초안은 `GET /dcrag-to/documents/draft/status/{crtDmndUuid}` (5~10초). 파일검증·금액검증은 상세 `prgrsSttsComCd` refetch로도 충분하다. `COMPLETED`/`FAILED` 또는 상태 이탈 시 중단하고 상세 invalidate. + +초안 `COMPLETED` 전에는 [실무협의서관리]·[다운로드] 비활성. Fallback으로 `TE`가 되었는데 초안이 없으면 [실무협의서생성]만 활성. + +### 8-5. 보완 팝업 3종 + +같은 `reqsupplement` / `submitsupplement`를 쓰고 팝업 UI만 나눈다. + +| 단계 | 팝업 | 요청 주체 | 복귀 | +| ---- | ------------------------- | ----------- | ---------------------- | +| `ER` | 보완 검토팝업(파일리스트) | 공단 접수자 | `SL→ER` [파일보완완료] | +| `TE` | 보완 검토팝업() | 공단 검토자 | `SL→TE` [보완완료] | +| `RK` | 보완 검토팝업(금액) | 공단 검토자 | `SL→RK` [보완완료] | + +파일 팝업 데이터: `GET /fnrs-cnsltn-mng/{docNo}/file-vrfc-results`. + +--- + +## 9. 현재 코드 vs To-Be 갭 + +`pages/fnrs-cnsltn` 기준. + +| 현재 | v3.0 | +| ------------------------------------------------------ | --------------------------------------------------------------------- | +| 기술검토 의뢰 → `updatePrgrsStts("ER")` | `request-examine` → **`FV`** | +| `ROLE_USER01` 결재 상태 `ER`, `TE`만 | `RK` 2차 결재선 추가. `ER`에 파일보완 | +| `TA` [고시] → `TB` | `TA`는 종료 조회. `TB` 없음 | +| `EP` [인가승인] → `TA` | `EP` [결과회신] → `TA` (광역) | +| `TP` 환경청 [검토결과 회신] → `EP` | `TP` → **`RM`** (1차). 최종은 `RR→EP` | +| `LE` 결과회신 → `EP` (자체종결만 맞음) | 자체종결 유지. 일반 건은 의뢰 | +| 조치결과 `RB/RC/RA/AV` 버튼 없음 | 기초 `submit-actn-result`, 광역 `RC→RA`, 환경청 `request-actn-review` | +| `SL` 수정화면 미연결 | 파일/기술/금액 3버튼 | +| 초안생성을 `pwrk-cnsltn-mng.save`로 가정 (가이드 v1.0) | **검토자지정**이 1차 트리거 | +| `pwrk-cnsltn-*` 엔티티 없음 | 신규 슬라이스 | +| `dcrag-to` 구현됨 | 수동 재실행 3종에 연결 | +| `elapr-dmnd.save` 구현됨 | `docPrgrsSttsComCd: "AC"` 로 지정 | + +--- + +## 10. 구현 순서 + +``` +Task Progress: +- [ ] 1. FNRSSTTS 상수·표시명. button-config / floating-action-buttons 를 38행 매트릭스로 교체 +- [ ] 2. fnrs-cnsltn 엔티티 확장: requestExamine, requestActnReview, findFileVrfcResults + (기존 saveApply, updatePrgrsStts, techReview, req/submit supplement 재사용) +- [ ] 3. entities/pwrk-cnsltn-mng (findPage, findById, findByUpDocNo, saveApply, submitActnResult) +- [ ] 4. entities/pwrk-cnsltn-doc-crt-rslt (download, webhwp open URL, saveMultipart) +- [ ] 5. AI 진행 패널: FV / AC / AV (draft status 폴링 + 상세 refetch, 30분 안내) +- [ ] 6. 환경청: 기술검토 의뢰(FV), 자체종결(EP), TP→RM, RA→AV, RR→EP +- [ ] 7. 공단 접수: 파일검증 결과 배지·파일보완 팝업, 검토자지정(AC) +- [ ] 8. 공단 검토 TE/RK: 웹기안기, 수동 초안/금액검증, 보완 팝업, 결재 process +- [ ] 9. 기초: SL 보완완료 3분기, RB 조치결과 submit-actn-result, TA 조회 +- [ ] 10. 광역: ST→LE, RM→RB, RC→RA, EP→TA +- [ ] 11. (선택) 실무협의 목록·상세 라우트 /pwrk-cnsltn +``` + +1~4는 엔티티·버튼 뼈대. 화면 전이는 5부터 역할 레인 단위로 닫는다. + +--- + +## 11. 공통코드 (화면 분기용) + +| 상위코드 | 필드 | 값 | 명칭 | +| ----------------------- | ---------------- | ----------------------------------- | ------------------------------ | +| `ASDOCSE` | `upDocSeComCd` | `FNRS` / `PWRK` | 재원협의 / 실무협의 | +| `FNRSSTTS` | `prgrsSttsComCd` | 18종 | [4절](#4-상태코드-한눈에-보기) | +| `AICRTSTTS` / `CRTSTTS` | `crtSttsComCd` | `R` `I` `C` `F` | 생성요청/중/완료/실패 | +| (검증) | `vrfcSttsComCd` | `PASS` / `FAIL` / `WARN` | 파일별 검증 | +| 결재선 구분 | `atrzSeComCd` | `ATRZ01` `ATRZ02` `ATRZ03` `ATRZ99` | 접수/검토/승인/최종 | + +표시명은 응답 `*Nm` / `*CdNm`. 분기는 코드값. + +초안 직후 `crtSttsComCd = R`, 폴링 `COMPLETED` 이후 `C`. 문서 상태는 `AC → TE`. diff --git a/apps/as/docs/pwrk-cnsltn/ref-endpoint.md b/apps/as/docs/pwrk-cnsltn/ref-endpoint.md new file mode 100644 index 000000000..2ff1e0ecb --- /dev/null +++ b/apps/as/docs/pwrk-cnsltn/ref-endpoint.md @@ -0,0 +1,336 @@ +# 레퍼런스 — 상태별 Endpoint + +> 상위: [개발가이드.md](./개발가이드.md) +> SoT: `05.재원협의_상태별_endpoint_설계.md` (v1.0, 2026-09-03) +> Gateway: `/as-service`, `/as-elapr-service` +> Auth: `Authorization`, `X-Site-Id`, `token-id` (기존 `client` 인터셉터) +> 날짜: `yyyy-MM-dd HH:mm:ss` + +프론트가 치는 API만 상세히 적는다. AI 콜백·스케줄러·Feign은 [§6 호출 금지](#6-프론트-호출-금지). + +--- + +## 1. 총괄 매트릭스 (38 + WebHWP) + +| 순번 | 버튼 | From → To | 서비스 | Method & URI | 구분 | +| :---: | --- | --- | --- | --- | --- | +| 1 | 저장 | →`SV` | as | `POST /api/v1/fnrs-cnsltn-mng/save-apply` | 기존 | +| 2 | 신청완료 | `SV→ST` | as | 동일 (`prgrsSttsComCd=ST`) | 기존 | +| 3 | 삭제 | `SV` | as | `DELETE /api/v1/fnrs-cnsltn-mng/{docNo}` | 기존 | +| 4 | 환경청제출 | `ST→LE` | as | `POST /api/v1/fnrs-cnsltn-mng/doc/updatePrgrsStts` | 기존 | +| 5 | 결과회신(자체종결) | `LE→EP` | as | `POST /api/v1/fnrs-cnsltn-mng/{docNo}/tech-review` | 기존 | +| 6 | 기술검토 의뢰 | `LE→FV` | as | `POST /api/v1/fnrs-cnsltn-mng/{docNo}/request-examine` | **신규** | +| 7 | 파일검증 콜백 | `FV→ER` | as | `POST /api/v1/dcrag/documents/verify-result` | 서버 | +| 8 | 파일보완 팝업 | `ER` | as | `GET /api/v1/fnrs-cnsltn-mng/{docNo}/file-vrfc-results` | 기존 | +| 9 | 파일보완요청 | `ER→SL` | as | `POST /api/v1/fnrs-cnsltn-mng/{docNo}/reqsupplement` | 기존 | +| 10 | 파일검증 재실행 | `ER` | as | `POST /api/v1/dcrag-to/documents/transfer` | 기존 | +| 11 | 검토자지정 | `ER→AC` | elapr | `POST /api/v1/elapr-dmnd` | 기존 | +| 12 | 초안 콜백 | `AC→TE` | as | `POST /api/v1/dcrag/draft/complete` | 서버 | +| 13 | 실무협의서관리 | `TE` | as | `GET /api/v1/pwrk-cnsltn-mng/updocno/{upDocNo}` | 기존 | +| 14 | 검토결과조회 | `TE` | as | `GET /api/v1/pwrk-cnsltn-mng/{pwrkDocNo}` | 기존 | +| 15 | 실무협의서생성 | `TE` | as | `POST /api/v1/dcrag-to/documents/draft/generate` | 기존 | +| 16 | 저장(실무) | `TE` | as | `POST /api/v1/pwrk-cnsltn-mng/save-apply` | 기존 | +| 17 | 검토완료 | `TE` | elapr | `PUT /api/v1/elapr-prgrs-stts/{id}/process` | 기존 | +| 18 | 보완요청 | `TE→SL` | as | `POST .../reqsupplement` | 기존 | +| 19 | 결재(승인) | `TE` | elapr | `PUT .../process` | 기존 | +| 20 | 결재(회신) | `TE→TP` | elapr | `PUT .../process` | 기존 | +| 21 | 파일보완완료 | `SL→ER` | as | `POST .../submitsupplement` | 기존 | +| 22 | 보완완료(기술) | `SL→TE` | as | 동일 | 기존 | +| 23 | 보완완료(조치) | `SL→RK` | as | 동일 | 기존 | +| 24 | 재원협의 결과 회신 | `TP→RM` | as | `POST .../{docNo}/tech-review` | 기존 | +| 25 | 지자체회신 | `RM→RB` | as | `POST .../doc/updatePrgrsStts` | 기존 | +| 26 | 저장(조치결과) | `RB` | as | `POST /api/v1/pwrk-cnsltn-mng/{pwrkDocNo}/submit-actn-result` | **신규** | +| 27 | 협의회신 | `RB→RC` | as | 동일 (`isSubmit=true`) | **신규** | +| 28 | 조치결과서제출 | `RC→RA` | as | `updatePrgrsStts` | 기존 | +| 29 | 조치결과서 검토요청 | `RA→AV` | as | `POST /api/v1/fnrs-cnsltn-mng/{docNo}/request-actn-review` | **신규** | +| 30 | 금액검증 콜백 | `AV→RK` | as | `POST /api/v1/dcrag/cost/verify-result` | 서버 | +| 31 | 실무협의서관리 | `RK` | as | `GET /pwrk-cnsltn-mng/{pwrkDocNo}` | 기존 | +| 32 | 금액검증 재실행 | `RK` | as | `POST /api/v1/dcrag-to/documents/cost/verify` | 기존 | +| 33 | 보완요청(금액) | `RK→SL` | as | `reqsupplement` | 기존 | +| 34 | 결재(승인) | `RK` | elapr | `process` | 기존 | +| 35 | 결재(회신) | `RK→RR` | elapr | `process` | 기존 | +| 36 | 결과회신 | `RR→EP` | as | `updatePrgrsStts` | 기존 | +| 37 | 결과회신 | `EP→TA` | as | `updatePrgrsStts` | 기존 | +| 38 | 결과회신(확인) | `TA` | — | 없음 | — | +| W1 | 웹기안기 Open | — | as | `GET /api/v1/pwrk-cnsltn-doc-crt-rslt/{uuid}/webhwp/open` | 기존 | +| W2 | HWPX 저장 | — | as | `POST /api/v1/pwrk-cnsltn-doc-crt-rslt` multipart | 기존 | +| W3 | 다운로드 | — | as | `GET /api/v1/pwrk-cnsltn-doc-crt-rslt/{uuid}/download` | 기존 | +| E-N | 결재선 복사 | `AV→RK` 부수 | elapr | `POST /api/v1/elapr-apln/copy-line` | **신규·서버** | + +조회 공통: `GET /fnrs-cnsltn-mng`, `GET /fnrs-cnsltn-mng/{docNo}`, `GET /pwrk-cnsltn-mng`, `GET /dcrag-to/documents/draft/status/{crtDmndUuid}`. + +--- + +## 2. 공통 요청 + +### `DocPrgrsUpdateRequestDto` + +`POST /fnrs-cnsltn-mng/doc/updatePrgrsStts` — `ST→LE`, `RM→RB`, `RC→RA`, `RR→EP`, `EP→TA`. + +```json +{ + "docNo": "F-20260824-0001", + "docSeComCd": "FNRS", + "prgrsSttsComCd": "LE", + "rgtrUuid": "optional-uuid", + "rmrk": "optional" +} +``` + +응답: `204/200` void. 프론트 엔티티: 기존 `changePrgrsSttsComCd`. + +### `FnrsCnsltnApplySaveRequestDto` + +`save-apply`, `tech-review`, `reqsupplement`, `submitsupplement`가 공유. 핵심은 `fnrsCnsltnMng.prgrsSttsComCd`와 중첩 리스트(`bzScaleList`, `bizCstList`, `cklstResultList`, `esntlAtchFileList`, `splmntDmndList`). + +--- + +## 3. 단계별 FE API + +### 3-1. `SV → ST → LE` + +**저장 / 신청완료** — `POST /fnrs-cnsltn-mng/save-apply` +권한: `ROLE_USER05`. 임시저장은 `prgrsSttsComCd=SV`, 신청은 `ST`. +응답: `string` (문서번호). 기존 `fnrsCnsltnMngService.saveApply`. + +**삭제** — `DELETE /fnrs-cnsltn-mng/{docNo}` (`SV`만). + +**환경청제출** — `updatePrgrsStts` `LE`. 권한: `ROLE_USER04`. + +### 3-2. `LE → FV | EP` + +**자체종결** — `POST /fnrs-cnsltn-mng/{docNo}/tech-review` +Body: `FnrsCnsltnApplySaveRequestDto` (환경청 의견·체크리스트). `LE→EP`. +기존 `techReview`. 현재 화면은 `TP`에서도 이 API를 쓰며 목표 상태를 `EP`로 넣고 있다 → **`TP`는 `RM`으로 바꿔야 함**. + +**기술검토 의뢰 (신규)** — `POST /fnrs-cnsltn-mng/{docNo}/request-examine` + +```json +{ "prgrsSttsComCd": "FV" } +``` + +응답: `string` (docNo). `LE→FV` + AI 파일검증 + 30분 타이머(서버). +프론트: `fnrsCnsltnMngService.requestExamine(docNo, body)` 추가. **`updatePrgrsStts("ER")` 금지.** + +### 3-3. `FV → ER → SL → ER` + +파일검증 콜백은 서버. FE는 `FV`에서 상세 refetch. + +**검증결과 목록** — `GET /fnrs-cnsltn-mng/{fnrsCnsltnDocNo}/file-vrfc-results` +응답: `FnrsCnsltnFileVrfcRsltResponseDto[]` + +| 필드 | 설명 | +| --- | --- | +| `fileSeComCd` | `ESSENTIAL` / `ADDITIONAL` | +| `fileSeNm` | 구분명 | +| `esntlAtchFileId` | 필수첨부 ID | +| `atchFileNm` | 파일명 | +| `vrfcRsltComCd` | `PASS` / `FAIL` / `WARN` | +| `vrfcOpnn` | 검증의견 | + +엔티티에 `findFileVrfcResults` 추가. 첨부 행 배지는 상세 `esntlFileList`/`etcFileList`의 `vrfcSttsComCd`, `vrfcRsltCn`, `vrfcDt`도 사용 (타입 확장 필요). + +**파일보완요청** — `POST .../{docNo}/reqsupplement` (`ER→SL`). +**파일보완완료** — `POST .../{docNo}/submitsupplement` (`SL→ER`). 복귀는 서버. + +**파일검증 재실행** — `POST /dcrag-to/documents/transfer` + +```json +{ "fnrsCnsltnDocNo": "F-20260824-0001", "taskSeComCd": "SF", "taskSeCdNm": "하수처리시설" } +``` + +응답: `{ resultCode, message, requestId }`. 상태 유지. 기존 `dcRagApiToService.requestDocumentTransfer`. + +### 3-4. `ER → AC → TE → TP` + +**검토자지정** — `POST /elapr-dmnd` +기존 `elaprDmndService.save`. + +| 필드 | 값 | +| --- | --- | +| `elaprDocNo` | 재원협의 번호 | +| `elaprCycl` | `1` | +| `docPrgrsSttsComCd` | **`AC`** (현행은 현재 상태 `ER`를 넣고 있음 → 수정) | +| `elaprPrgrsSttsList[].atrzOrdr` | 1 검토자, 2 승인자, 3 최종 | + +서버가 실무협의 문서 생성 + RAG 초안 트리거 + 30분 타이머. 응답의 문서번호/`crtDmndUuid`가 있으면 폴링 키로 쓴다. 없으면 `findByUpDocNo`로 복원. + +**초안 상태 폴링** — `GET /dcrag-to/documents/draft/status/{crtDmndUuid}` +기존 `getDraftGenerationStatus`. 주기 5~10초. 종료 `COMPLETED`/`FAILED`. + +**수동 초안생성** — `POST /dcrag-to/documents/draft/generate` (`TE` 유지) + +```json +{ + "fnrsCnsltnDocNo": "F-...", + "pwrkCnsltnDocNo": "P-...", + "crtDmndUuid": "optional" +} +``` + +202 + `DcRagDraftGenerateResponseDto` (`crtDmndUuid`, `crtSttsComCd`, `estimatedMinutes`). +v1.0 가이드의 “이 API를 프론트가 치면 안 된다”는 **폐기**. 1차 트리거는 검토자지정이고, 이 API는 Fallback 전용. + +**실무 저장** — `POST /pwrk-cnsltn-mng/save-apply` → `string`. +**보완요청/완료** — fnrs `reqsupplement` / `submitsupplement`. + +**결재** — `PUT /elapr-prgrs-stts/{elaprPrgrsId}/process` + +```json +{ + "atrzPrgrsSttsComCd": "ATRZ_PRGRS_003", + "atrzOpnnCn": "의견", + "finalApprovalYn": false +} +``` + +반려: `ATRZ_PRGRS_004`. 기존 `elaprPrgrsSttsService.process`. 최종 시 서버가 `TP`/`RR` 전이. + +### 3-5. `TP → RM → RB → RC` + +**환경청 1차 회신** — `tech-review` (`TP→RM`). +**광역 지자체회신** — `updatePrgrsStts` `RB`. + +**조치결과 저장/제출 (신규)** — `POST /pwrk-cnsltn-mng/{pwrkDocNo}/submit-actn-result` + +```json +{ + "isSubmit": false, + "actnList": [ + { + "rvwOpnnSn": 1, + "actnRsltCn": "수용 — 관로 연장 재산정", + "atchFileGroupId": "optional" + } + ], + "totActnSummary": "제출 시 필수", + "muniDocNo": "제출 시 필수" +} +``` + +- `isSubmit: false` → `RB` 유지 +- `isSubmit: true` → `RB→RC` +응답: `string` (pwrkDocNo). DTO명 `PwrkActnResultSubmitDto`. + +### 3-6. `RC → RA → AV → RK` + +**광역 제출** — `updatePrgrsStts` `RA`. + +**2차 의뢰 (신규)** — `POST /fnrs-cnsltn-mng/{docNo}/request-actn-review` + +```json +{ + "envoDocNo": "환경청 공문번호", + "specialRqstCn": "중점 검증 요청 (선택)" +} +``` + +`RA→AV`. 응답: `string` (pwrkDocNo). DTO명 `EnvoActnReviewReqDto`. + +금액검증 콜백은 서버. 콜백 후 서버가 `copy-line` 호출. + +**금액검증 재실행** — `POST /dcrag-to/documents/cost/verify` + +```json +{ + "fnrsCnsltnDocNo": "F-...", + "pwrkCnsltnDocNo": "P-..." +} +``` + +202. 기존 `requestCostVerification`. `RK` 유지. + +### 3-7. `RK → RR | SL` + +보완은 3-4와 동일 API. 팝업만 금액 체크. +결재 `process` 2차 최종 → 서버가 `RR`. + +### 3-8. `RR → EP → TA` + +둘 다 `updatePrgrsStts`. `TA` 확인은 API 없음. + +--- + +## 4. 웹기안기 + +설계서 기준 **기존**. 구현 시 컨트롤러 존재 여부를 확인한다. + +| ID | 용도 | URI | 응답 | +| --- | --- | --- | --- | +| W1 | `HwpCtrl.Open` | `GET .../pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/webhwp/open` | `inline` HWPX | +| W2 | 편집 저장 | `POST .../pwrk-cnsltn-doc-crt-rslt` multipart `file` + `crtDmndUuid` | 201, `verSn+1` | +| W3 | PC 저장 | `GET .../{crtDmndUuid}/download` | `attachment` blob | + +버전 지정: `GET .../{crtDmndUuid}/{verSn}/webhwp/open`, `.../{verSn}/download`. +`/{uuid}/latest`, `/{uuid}/download`가 `/{uuid}/{verSn}`보다 우선. path를 섞지 말 것. + +다운로드: + +- axios `responseType: "blob"` +- 파일명: `Content-Disposition` `filename*=UTF-8''...` (`parseFilenameFromResponse`) +- 서버 파일명: `재원협의 실무협의서({bizNm})_v{verSn}.hwp` +- Content-Type은 `application/x-hwp` 또는 `application/haansofthwpx`일 수 있음. 타입에 의존하지 말 것 + +Open은 **절대 URL**을 기안기에 전달. blob Object URL 금지. 쿠키/게이트웨이 인증 가능 여부는 백엔드 확인. + +multipart `Content-Type` 헤더를 수동 지정하지 않는다 (boundary). JSON `saveJson`은 UI에서 쓰지 않는다. + +--- + +## 5. 전자결재 (`as-elapr-service`) + +| API | URI | FE | +| --- | --- | --- | +| 검토자지정·1차 결재요청 | `POST /elapr-dmnd` | O | +| 결재 처리 | `PUT /elapr-prgrs-stts/{id}/process` | O | +| 결재선 복사 | `POST /elapr-apln/copy-line` | **X (서버)** | + +`copy-line` body (참고만): + +```json +{ + "docNo": "P-20260827-0001", + "sourceElaprCycl": 1, + "targetElaprCycl": 2, + "rgtrUuid": "system-uuid" +} +``` + +`RK`에서 2차 결재선은 기존 `findDocPrgrsByDocNo` / `elaprCycl=2` 조회로 표시. + +--- + +## 6. 프론트 호출 금지 + +| URI | 이유 | +| --- | --- | +| `POST /dcrag/documents/verify-result` | AI→AS 콜백 | +| `POST /dcrag/draft/complete` | AI→AS 콜백 | +| `POST /dcrag/cost/verify-result` | AI→AS 콜백. 여기서 copy-line도 호출 | +| `POST /elapr-apln/copy-line` | 콜백 Feign | +| `POST /pwrk-cnsltn-mng` (`save` 초안 트리거) | v3.0 1차 트리거는 `elapr-dmnd`. 컨트롤러가 남아 있어도 화면에서 쓰지 않음 | + +`POST /dcrag-to/documents/draft/generate`는 **Fallback에서만** 허용. + +--- + +## 7. Gap Analysis (프론트 엔티티) + +### 이미 있는 것 + +`fnrsCnsltnMngService`: `findPage`, `findById`, `save`, `saveApply`, `update`, `techReview`, `submitSupplement`, `submitReqSupplement`, `remove`, `changePrgrsSttsComCd` +`dcRagApiToService`: `requestDocumentTransfer`, `requestDraftGeneration`, `getDraftGenerationStatus`, `requestCostVerification` +`elaprDmndService.save`, `elaprPrgrsSttsService.process` + +### 추가할 것 + +| 메서드 | URI | +| --- | --- | +| `requestExamine` | `POST /fnrs-cnsltn-mng/{id}/request-examine` | +| `findFileVrfcResults` | `GET /fnrs-cnsltn-mng/{id}/file-vrfc-results` | +| `requestActnReview` | `POST /fnrs-cnsltn-mng/{id}/request-actn-review` | +| `pwrkCnsltnMngService.*` | 슬라이스 신규 | +| `submitActnResult` | `POST /pwrk-cnsltn-mng/{id}/submit-actn-result` | +| `pwrkCnsltnDocCrtRsltService.*` | 슬라이스 신규 | + +`copyLine`은 프론트 서비스에 넣지 않는다. diff --git a/apps/as/docs/pwrk-cnsltn/ref-frontend-entities.md b/apps/as/docs/pwrk-cnsltn/ref-frontend-entities.md new file mode 100644 index 000000000..1d24181f0 --- /dev/null +++ b/apps/as/docs/pwrk-cnsltn/ref-frontend-entities.md @@ -0,0 +1,343 @@ +# 레퍼런스 — 프론트 엔티티·DTO + +> 상위: [개발가이드.md](./개발가이드.md) +> 엔티티 규칙: `frontend/.cursor/skills/backend-controller-to-entity` + +--- + +## 1. ENDPOINT + +`apps/as/src/shared/config/api.ts` + +```ts +AS_SERVICE: { + // 기존 + FNRS_CNSLTN_MNG: "/as-service/api/v1/fnrs-cnsltn-mng", + DCRAG_TO: "/as-service/api/v1/dcrag-to", + // 추가 + PWRK_CNSLTN_MNG: "/as-service/api/v1/pwrk-cnsltn-mng", + PWRK_CNSLTN_DOC_CRT_RSLT: "/as-service/api/v1/pwrk-cnsltn-doc-crt-rslt", +} + +AS_ELAPR_SERVICE: { + ELAPR_TMPLT: "/as-elapr-service/api/v1/elapr-tmplt", + ELAPR_APLN: "/as-elapr-service/api/v1/elapr-apln", + ELAPR_DMND: "/as-elapr-service/api/v1/elapr-dmnd", + ELAPR_PRGRS_STTS: "/as-elapr-service/api/v1/elapr-prgrs-stts", +} +``` + +`copy-line`은 `ELAPR_APLN` 하위이나 FE에서 호출하지 않음. + +--- + +## 2. 슬라이스 배치 + +``` +entities/fnrs-cnsltn/ # 확장 +entities/dcrag-to/ # 구현됨 +entities/elapr/ # 구현됨 +entities/pwrk-cnsltn-mng/ # 신규 +entities/pwrk-cnsltn-doc-crt-rslt/ # 신규 +``` + +``` +entities/pwrk-cnsltn-mng/ +├── model/ +│ ├── pwrk-cnsltn-mng-interface.ts +│ └── pwrk-cnsltn-mng-payload-schema.ts +├── api/ +│ ├── pwrk-cnsltn-mng-service.ts +│ └── queries.ts +└── index.ts +``` + +`submit-actn-result` Request는 zod SSOT (`PwrkActnResultSubmitDto`). +`request-examine` / `request-actn-review`도 fnrs payload-schema에 추가. + +라우트: + +| 화면 | 경로 | +| --- | --- | +| 재원협의 목록/상세/작성 | 기존 `app/(main)/(with-base-layout)/fnrs-cnsltn/...` | +| 실무협의 목록 | `.../pwrk-cnsltn/page.tsx` (선택) | +| 실무협의 상세 | `.../pwrk-cnsltn/[id]/page.tsx` (선택) | +| 페이지 UI | `pages/fnrs-cnsltn/`, `pages/pwrk-cnsltn/` | + +--- + +## 3. Service 시그니처 + +`fnrsCnsltnMngService`에 추가: + +```ts +requestExamine(docNo: string, body: FnrsExamineRequestDto): Promise; +findFileVrfcResults(fnrsCnsltnDocNo: string): Promise; +requestActnReview(docNo: string, body: EnvoActnReviewReqDto): Promise; +``` + +실무협의: + +```ts +export interface PwrkCnsltnMngService { + findPage(searchDto: PwrkCnsltnMngSearchDto): Promise>; + findById(pwrkCnsltnDocNo: string): Promise; + findByUpDocNo(upDocNo: string): Promise; + saveApply(body: PwrkCnsltnApplySaveRequestDto): Promise; + submitActnResult( + pwrkCnsltnDocNo: string, + body: PwrkActnResultSubmitDto, + ): Promise; + update(pwrkCnsltnDocNo: string, body: PwrkCnsltnMngUpdateRequestDto): Promise; + remove(pwrkCnsltnDocNo: string): Promise; + changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise; +} +``` + +v1.0의 `save(): PwrkCnsltnMngSaveResponseDto`(초안 트리거)는 화면에서 사용하지 않는다. 컨트롤러가 남아 있으면 엔티티에 넣더라도 UI 연결 금지. + +생성결과: + +```ts +export interface PwrkCnsltnDocCrtRsltService { + findPage( + searchDto: PwrkCnsltnDocCrtRsltSearchDto, + ): Promise>; + findById(crtDmndUuid: string, verSn: number): Promise; + findLatestByCrtDmndUuid(crtDmndUuid: string): Promise; + download(crtDmndUuid: string, verSn: number): Promise>; + downloadLatest(crtDmndUuid: string): Promise>; + saveMultipart(formData: FormData): Promise; + updateMultipart( + crtDmndUuid: string, + verSn: number, + formData: FormData, + ): Promise; + remove(crtDmndUuid: string, verSn: number): Promise; +} +``` + +`webhwp/open`은 fetch하지 않고 URL 빌더만: + +```ts +function webhwpOpenUrl(crtDmndUuid: string, verSn?: number): string { + const base = `${ENDPOINT.AS_SERVICE.PWRK_CNSLTN_DOC_CRT_RSLT}/${crtDmndUuid}`; + return verSn == null ? `${base}/webhwp/open` : `${base}/${verSn}/webhwp/open`; +} +``` + +DC RAG TO (구현됨, 시그니처 유지): + +```ts +requestDocumentTransfer(body: DcRagDocumentTransferRequestDto): Promise; +requestDraftGeneration(body: DcRagDraftGenerateRequestDto): Promise; +getDraftGenerationStatus(crtDmndUuid: string): Promise; +requestCostVerification(body: DcRagCostVerifyRequestDto): Promise; +``` + +--- + +## 4. DTO + +타입명 = 백엔드 클래스명. Save/Apply는 zod. + +### 4-1. 재원협의 추가 + +**`FnrsExamineRequestDto`** + +| 필드 | 필수 | 값 | +| --- | --- | --- | +| `prgrsSttsComCd` | N | 기본 `FV` | + +**`EnvoActnReviewReqDto`** + +| 필드 | 필수 | +| --- | --- | +| `envoDocNo` | Y | +| `specialRqstCn` | N | + +**`FnrsCnsltnFileVrfcRsltResponseDto`**: [ref-endpoint.md](./ref-endpoint.md) §3-3. + +첨부 검증 배지용으로 `AtflMngClientResponseDto` / `EsntlAtchFileMngTreeResponseDto`에 `vrfcDt`, `vrfcRsltCn`, `vrfcSttsComCd` 확장. + +### 4-2. `PwrkActnResultSubmitDto` (zod) + +| 필드 | 필수 | 비고 | +| --- | --- | --- | +| `isSubmit` | Y | `false` 저장, `true` 협의회신 | +| `actnList` | Y | | +| `actnList[].rvwOpnnSn` | Y | | +| `actnList[].actnRsltCn` | Y | | +| `actnList[].atchFileGroupId` | N | | +| `totActnSummary` | 제출 시 | | +| `muniDocNo` | 제출 시 | | + +### 4-3. `PwrkCnsltnMng*` + +**Search**: `pwrkCnsltnDocNo`, `upDocNo`, `upDocSeComCd`, `prgrsSttsComCd`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `bizNm` + +**DetResponse 중첩** + +- `cnsltnList`: 협의내역 (`rvwOpnnSn`, `rvwOpnnClsfComCd`, `cnsltnCn`, `rfltYn`, …) +- `pjtcoAjmtList`: 사업비조정 +- `crtDmndList[].crtRslt`: 생성요청 + 최신 결과 +- `splmntDmndList`: 보완요청 +- `sttsChgHstryList`: 상태변경이력 — `SL` 복귀 라벨에 사용 +- `trsmFileList`: 전송파일 (Java Det DTO 확인 후 반영) + +마스터 조인: `bizNm`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `aplcntNm` 등. + +`crtRslt.orgnlOtptDocCn`은 항상 `null`. + +**상세 예** + +```json +{ + "pwrkCnsltnDocNo": "P-20260827-0001", + "upDocNo": "F-20260824-0001", + "upDocSeComCd": "FNRS", + "prgrsSttsComCd": "TE", + "cnsltnList": [], + "pjtcoAjmtList": [], + "crtDmndList": [ + { + "crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a", + "pwrkCnsltnDocNo": "P-20260827-0001", + "rqstrUuid": "...", + "rqstrNm": "관리자", + "crtSttsComCd": "C", + "crtRslt": { + "crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a", + "verSn": 1, + "rgtrNm": "관리자", + "orgnlOtptDocCn": null, + "crtCmptnDt": "2026-08-27 10:47:01" + } + } + ], + "splmntDmndList": [], + "sttsChgHstryList": [] +} +``` + +### 4-4. `PwrkCnsltnDocCrtRslt*` + +복합 PK: `crtDmndUuid` + `verSn` + +**Response** + +| 필드 | 타입 | 비고 | +| --- | --- | --- | +| `crtDmndUuid` | `string` | | +| `verSn` | `number` | | +| `rgtrUuid` / `rgtrNm` | `string` | | +| `orgnlOtptDocCn` | `null` | 목록/상세 미포함 | +| `crtCmptnDt` | `string` | | + +**Search** (`PageableParams`): `crtDmndUuid`(이력 조회 시 필수), `pwrkCnsltnDocNo`, `verSn`, `rgtrUuid` + +```http +GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt?page=0&size=10&crtDmndUuid={uuid}&sort=verSn,desc +``` + +**Save (zod)**: `crtDmndUuid` 필수. `rgtrUuid` 생략 시 서버 로그인 사용자. `file`은 multipart part (DTO 필드 아님). + +### 4-5. DC RAG (구현됨) + +```ts +interface DcRagDocumentTransferRequestDto { + fnrsCnsltnDocNo: string; + taskSeComCd?: string; + taskSeCdNm?: string; +} + +interface DcRagDraftStatusResponseDto { + crtDmndUuid: string; + status: DraftStatus; + statusName?: string; + progressPercent?: number; + events?: EventInfoDto[]; + startedDt?: string; + estimatedCompletionDt?: string; + errorMessage?: string | null; +} + +type DraftStatus = + | "REQUESTED" + | "DOWNLOADING" + | "ANALYZING" + | "GENERATING" + | "COMPLETED" + | "FAILED"; +``` + +`estimatedMinutes`는 초안 응답에 있을 수 있으나 UX 안내는 **30분 Fallback**을 따른다. + +--- + +## 5. 폴링 훅 + +초안 (`AC` 또는 `TE`이면서 생성 중): + +```ts +useQuery({ + queryKey: [...dcragToKeys.draftStatus(crtDmndUuid)], + queryFn: () => dcRagApiToService.getDraftGenerationStatus(crtDmndUuid), + enabled: Boolean(crtDmndUuid) && isPolling, + refetchInterval: (query) => { + const status = query.state.data?.status; + if (status === "COMPLETED" || status === "FAILED") return false; + return 5_000; + }, +}); +``` + +`COMPLETED` 시 `fnrs-cnsltn` 상세·`pwrk-cnsltn-mng` `findByUpDocNo` invalidate. + +`FV`/`AV`는 `findById`를 5~10초 refetch. `prgrsSttsComCd`가 `ER`/`RK`가 되면 중단. 시작 시각 기준 30분 경과 안내 문구. + +`crtDmndUuid` 복원: `findByUpDocNo(fnrsCnsltnDocNo).crtDmndList[0]`. + +--- + +## 6. 다운로드 + +```ts +const response = await client.get(url, { responseType: "blob" }); +const filename = parseFilenameFromResponse(response); +const objectUrl = window.URL.createObjectURL(response.data); +``` + +기존 `attachService`와 동일. + +--- + +## 7. 실무협의 목록 컬럼 (선택 화면) + +| 표시명 | Field | +| --- | --- | +| 실무협의번호 | `pwrkCnsltnDocNo` | +| 재원협의번호 | `upDocNo` | +| 진행상태 | `prgrsSttsComCdNm` | +| 사업명 | `bizNm` | +| 시도/구군 | `sido` / `gugun` | +| 신청자 | `aplcntNm` | +| 등록일 | `regDt` | + +행 클릭 → `/pwrk-cnsltn/{pwrkCnsltnDocNo}` 또는 재원협의 상세로 이동 (화면설계 확정 후). + +--- + +## 8. `elapr-dmnd` 저장 시 주의 + +현행 `floating-action-buttons.tsx`: + +```ts +elaprCycl: data.detDto?.prgrsSttsComCd === "RK" ? 2 : 1, +docPrgrsSttsComCd: data.detDto?.prgrsSttsComCd, // ER +``` + +To-Be: + +- 검토자지정은 **`ER`에서만**. `elaprCycl: 1`, `docPrgrsSttsComCd: "AC"` +- 2차 결재선은 서버 `copy-line`. `RK`에서 검토자지정 팝업을 다시 열지 않음 diff --git a/apps/as/docs/pwrk-cnsltn/ref-process-status.md b/apps/as/docs/pwrk-cnsltn/ref-process-status.md new file mode 100644 index 000000000..48cb8b6ab --- /dev/null +++ b/apps/as/docs/pwrk-cnsltn/ref-process-status.md @@ -0,0 +1,246 @@ +# 레퍼런스 — 프로세스·상태 전이 + +> 상위: [개발가이드.md](./개발가이드.md) +> SoT: `03.재원협의_실무협의_프로세스_통합_v완결.md` (v3.0, 2026-09-03) + +--- + +## 1. 프로세스 개요 + +| 항목 | 내용 | +| --- | --- | +| Process No. | PR-AS-003 | +| 메뉴 | 행정지원 > 2.2.2 재원협의 > 2.2.2.1 재원협의 관리 | +| 목적 | 기초 → 광역(경유) → 환경청(승인) → 공단(기술검토/AI) 4자 협의로 사업규모·국비·분담금을 확정 | + +2-Pass: + +- **Pass 1** 기술 타당성: `SV→ST→LE→FV→ER→AC→TE→TP→RM→RB` +- **Pass 2** 조치결과·금액: `RB→RC→RA→AV→RK→RR→EP→TA` + +자체종결(기술검토 생략): `LE→EP→TA` + +--- + +## 2. 4-Lane 시퀀스 + +```mermaid +sequenceDiagram + autonumber + actor 기초 as 기초 (ROLE_USER05) + actor 광역 as 광역 (ROLE_USER04) + actor 환경청 as 환경청 (ROLE_USER03) + actor 공단 as 공단 (ROLE_USER01) + actor AI as AI 엔진 + + rect rgb(240, 248, 255) + 기초->>기초: [저장] SV + 기초->>광역: [신청완료] ST + 광역->>환경청: [환경청제출] LE + end + + rect rgb(255, 250, 240) + alt 자체종결 + 환경청->>광역: [결과회신] LE → EP + 광역->>기초: [결과회신] EP → TA + else 기술검토 의뢰 + 환경청->>AI: [기술검토 의뢰] LE → FV + end + end + + rect rgb(245, 255, 245) + AI-->>공단: 파일검증 콜백 FV → ER + Note over 공단: 30분 미응답 시 ER + 수동 [파일검증] + alt 파일 미비 + 공단->>기초: [파일보완] ER → SL + 기초->>공단: [파일보완완료] SL → ER + else 적격 + 공단->>AI: [검토자지정] ER → AC + end + end + + rect rgb(255, 245, 245) + AI-->>공단: 초안 콜백 AC → TE + Note over 공단: 30분 미응답 시 TE + 수동 [실무협의서생성] + alt 설계 미비 + 공단->>기초: [보완요청] TE → SL + 기초->>공단: [보완완료] SL → TE + else 검토 완료 + 공단->>공단: [검토완료] 상신, [결재(승인)] TE 유지 + 공단->>환경청: [결재(회신)] TE → TP + end + end + + rect rgb(250, 240, 255) + 환경청->>광역: [재원협의 결과 회신] TP → RM + 광역->>기초: [지자체회신] RM → RB + 기초->>광역: [협의회신] RB → RC + end + + rect rgb(240, 255, 255) + 광역->>환경청: [조치결과서제출] RC → RA + 환경청->>AI: [조치결과서 검토요청] RA → AV + AI-->>공단: 금액검증 콜백 AV → RK + Note over 공단: 30분 미응답 시 RK + 수동 [금액검증] + Note over 공단: AV→RK 시 1차 결재선 → 2차 복사 (서버) + end + + rect rgb(255, 255, 230) + alt 소명 미비 + 공단->>기초: [보완요청] RK → SL + 기초->>공단: [보완완료] SL → RK + else 확정 + 공단->>환경청: [결재(회신)] RK → RR + end + end + + rect rgb(255, 240, 240) + 환경청->>광역: [결과회신] RR → EP + 광역->>기초: [결과회신] EP → TA + end +``` + +거버넌스: + +``` +기초 (USER05) --ST--> 광역 (USER04) --LE--> 환경청 (USER03) +기초 <-----TA------ 광역 <-----EP/RM------ 환경청 +환경청 --FV/ER--> 공단 (USER01) --TP/RR--> 환경청 +``` + +--- + +## 3. Activity 정의 (14) + +| No. | Activity | 주체 | From → To | FE 포인트 | +| --- | --- | --- | --- | --- | +| 1 | 재원협의 조회 | 기초 | — | 기본계획·자가점검 대조 | +| 2 | 등록 및 제출 | 기초·광역 | `SV→ST`, 광역 `LE` | `save-apply`, `updatePrgrsStts` | +| 3 | 접수 및 의뢰 | 환경청 | `LE→FV` / `EP` | 의뢰 vs 자체종결 분기 | +| 4 | AI 파일검증 | 공단·AI | `FV→ER` / `SL` | 30분 Fallback, 파일보완 팝업 | +| 5 | 자체종결 회신 | 환경청·광역 | `LE→EP→TA` | 공단 레인 없음 | +| 6 | 검토자지정·초안 | 공단·AI | `ER→AC→TE` | `elapr-dmnd`, 초안 폴링 | +| 7 | 1차 기술검토 | 공단 결재선 | `TE` / `SL` | 웹기안기, HWP 파싱은 서버 | +| 8 | 기술검토 회신 | 공단 최종 | `TE→TP` | `process` 최종 | +| 9 | 검토결과 회신 | 환경청·광역 | `TP→RM→RB` | 1차 회신. `EP`와 혼동 금지 | +| 10 | 조치결과 작성 | 기초 | `RB→RC` | `submit-actn-result` | +| 11 | 조치 제출·의뢰 | 광역·환경청 | `RC→RA→AV` | `request-actn-review` | +| 12 | AI 금액검증 | AI·공단 | `AV→RK` | 결재선 복사(서버) | +| 13 | 2차 검토·결재 | 공단 | `RK→RR` / `SL` | 2차 결재선 | +| 14 | 최종 회신 | 환경청·광역·기초 | `RR→EP→TA` | 종결 | + +--- + +## 4. `FNRSSTTS` 상세 + +관리: `tb_cs_lwpo_cd` / `tb_as_fnrs_cnsltn_mng.prgrs_stts_com_cd` + +| 코드 | 국문 | 영문 | 화면 주체 | 전이 | +| --- | --- | --- | --- | --- | +| `SV` | 저장 | Save | 기초 작성 | 신규/`SV` → `SV` | +| `ST` | 신청완료 | Submit to Province | 기초→광역 | `SV→ST` | +| `LE` | 환경청 검토 | Local Environment Review | 광역→환경청 | `ST→LE` | +| `FV` | 파일검증 | File Verification | AI | `LE→FV` | +| `ER` | 기술검토 의뢰 | Examine Request | 공단 접수자 | `FV→ER`, `SL→ER` | +| `SL` | 보완요청 | Supplement Loop | 공단→기초 | `ER\|TE\|RK → SL` | +| `AC` | 초안생성 | AI Auto Creation | AI | `ER→AC` | +| `TE` | 기술검토 | Technical Examine | 공단 1차 결재 | `AC→TE`, `SL→TE` | +| `TP` | 기술검토 회신 | Technical Pass to Basin | 공단→환경청 | `TE→TP` | +| `RM` | 재원협의결과회신 | Result Message to Province | 환경청→광역 | `TP→RM` | +| `RB` | 재원협의결과회신(지자체) | Result Back to Municipality | 광역→기초 | `RM→RB` | +| `RC` | 조치결과서 제출 | Result Complete | 기초→광역 | `RB→RC` | +| `RA` | 조치결과서 제출(환경청) | Result Ask to Basin | 광역→환경청 | `RC→RA` | +| `AV` | 금액검증 | Amount Verification | AI | `RA→AV` | +| `RK` | 조치결과서검토 | Result Check | 공단 2차 결재 | `AV→RK`, `SL→RK` | +| `RR` | 조치결과서검토 회신 | Result Return to Basin | 공단→환경청 | `RK→RR` | +| `EP` | 검토결과 회신 | Examine Pass to Province | 환경청→광역 | `LE→EP`(자체), `RR→EP`(최종) | +| `TA` | 검토결과 회신(종료) | Technical Approval | 광역→기초 | `EP→TA` | + +### 4-1. v2.1 → v3.0 대조 (프론트 폐기 대상) + +| 구간 | v2.1 | v3.0 | +| --- | --- | --- | +| 의뢰 직후 파일검증 | `ER` | **`FV` → `ER`** | +| 검토자지정 후 초안 | `EE` → `DV` | **`ER` → `AC` → `TE`** | +| 1차 내부 결재 | `TR` → `TP` | **`TE` 유지 → `TP`** | +| 환경청→광역 1차 회신 | `EP` | **`RM`** | +| 금액검증 | `RA` | **`RA` → `AV` → `RK`** | +| 공단 2차 회신 | `TP` | **`RR`** | +| 최종 완료 | `TP` / 고시 `TB` | **`TA`** (고시 없음) | + +가이드 v1.0 `PWRKSTTS`(`DV`, `AR`, `TR`, `AA` 등)도 동일하게 폐기. + +--- + +## 5. 보완(`SL`) 3루프 + +복귀 상태는 `tb_as_doc_stts_chg_hstry` 직전 코드. 프론트는 복귀 코드를 요청에 넣지 않고, 라벨만 이력으로 고른다. + +```mermaid +stateDiagram-v2 + ER --> SL: 파일보완 팝업(파일리스트) + SL --> ER: 파일보완완료 + TE --> SL: 보완 검토팝업() + SL --> TE: 보완완료 + RK --> SL: 보완 검토팝업(금액) + SL --> RK: 보완완료 +``` + +| 루프 | 팝업 | 요청 | 제출 버튼 | +| --- | --- | --- | --- | +| 파일 무결성 | 파일리스트 + 검증결과 | 공단 접수자 `ER` | [파일보완완료] | +| 1차 기술 | 설계 미달·사유 | 공단 검토자 `TE` | [보완완료] | +| 2차 금액 | 공종별 금액 체크 | 공단 검토자 `RK` | [보완완료] | + +--- + +## 6. AI 3파이프라인·30분 Fallback + +| ID | 단계 | 진입 → 완료 | 프론트 진입 | Fallback | 수동 재실행 | +| --- | --- | --- | --- | --- | --- | +| AI-1 | 파일 무결성 | `FV→ER` | 환경청 [기술검토 의뢰] | 30분 → `ER` + [파일검증] | `POST /dcrag-to/documents/transfer` | +| AI-2 | 초안 생성 | `AC→TE` | 공단 [검토자지정] | 30분 → `TE` + [실무협의서생성] | `POST /dcrag-to/documents/draft/generate` | +| AI-3 | 금액 검증 | `AV→RK` | 환경청 [조치결과서 검토요청] | 30분 → `RK` + [금액검증] | `POST /dcrag-to/documents/cost/verify` | + +- 예상 소요 안내는 **30분** (v1.0의 10분은 폐기). +- 콜백 수신·스케줄러 전이는 서버. FE는 상세 refetch / draft status 폴링만. +- `AC→TE` 성공 시에만 [실무협의서관리]·[검토결과조회] 활성. 실패·타임아웃이면 [실무협의서생성]. + +초안 폴링 이벤트 예: + +| `eventType` | `eventMessage` | +| --- | --- | +| `REQUESTED` | 요청을 접수했습니다. | +| `DOWNLOADING` | 신청서를 수신하는 중입니다. | +| `ANALYZING` | 자료 준비가 완료되었습니다. | +| `GENERATING` | 문서 저장이 완료되었습니다. | +| `COMPLETED` | 문서 생성이 완료되었습니다. | +| `FAILED` | `errorMessage` 표시 | + +종료: `status === "COMPLETED" | "FAILED"` 또는 `events`에 해당 `eventType`. + +--- + +## 7. 결재선 복사 + +규칙: `"금액검증후: 2차결재선 1차결재선 복사생성"` + +1. `TE`에서 1차 결재선(`elaprCycl = 1`) 완료. +2. `AV→RK` 콜백 시 서버가 `POST /elapr-apln/copy-line` (`source=1`, `target=2`). +3. `RK` 화면은 2차 결재선을 조회해 검토자/승인자/최종승인자를 표시. +4. 프론트는 copy-line을 **호출하지 않는다.** + +결재 `process`: + +- 상신·중간 승인: 문서 상태 `TE` 또는 `RK` 유지. +- 1차 최종: 서버가 `TE→TP`. +- 2차 최종: 서버가 `RK→RR`. +- 반려: 직전 결재자 복귀. 문서 상태 유지. + +--- + +## 8. 웹기안기 + +- `TE`/`RK` [실무협의서관리] → 팝업에서 `HwpCtrl.Open(webhwp/open URL)`. +- [검토완료] 시 HWP 파싱·DB 등재는 **서버**. FE는 결재 `process`만. +- 편집본 저장: `POST /pwrk-cnsltn-doc-crt-rslt` multipart. `verSn`은 클라이언트가 보내지 않음. diff --git a/apps/as/docs/pwrk-cnsltn/ref-screen-buttons.md b/apps/as/docs/pwrk-cnsltn/ref-screen-buttons.md new file mode 100644 index 000000000..556aab39b --- /dev/null +++ b/apps/as/docs/pwrk-cnsltn/ref-screen-buttons.md @@ -0,0 +1,173 @@ +# 레퍼런스 — 화면·버튼 매트릭스 + +> 상위: [개발가이드.md](./개발가이드.md) +> SoT: `03` §4 화면 및 버튼별 상세 상태전이 명세서 (Row 4~42, 38행) + +구현 위치: `pages/fnrs-cnsltn/lib/button-config.ts` + `ui/components/floating-action-buttons.tsx` + +--- + +## 1. 화면 구분 + +| 화면 | 소유 | 라우트 (현행) | 비고 | +| --- | --- | --- | --- | +| 작성화면 | 기초 `ROLE_USER05` | `/fnrs-cnsltn/new`, `/fnrs-cnsltn/{id}/edit` | `SV`, `RB` | +| 수정화면 | 기초 | `/fnrs-cnsltn/{id}/edit` | `SL` 보완 | +| 상세화면 | 광역·환경청·공단·기초(TA) | `/fnrs-cnsltn/{id}` | 조회 + 액션 버튼 | +| 자동화 | AI | 버튼 없음 | `FV`/`AC`/`AV` 진행 패널 | + +공단 상세는 결재선 구분으로 같은 상태라도 버튼이 다르다. + +| `atrzSeComCd` | 역할 | +| --- | --- | +| `ATRZ01` | 접수자 | +| `ATRZ02` | 검토자 | +| `ATRZ03` | 승인자 | +| `ATRZ99` | 최종승인자 | + +--- + +## 2. 38행 매트릭스 + +API 컬럼은 [ref-endpoint.md](./ref-endpoint.md)와 동일. + +| 순번 | 화면 | 권한 | 결재선 | 상태 | 버튼 | 전이 | 다음 담당 | FE 동작 | +| :---: | --- | --- | --- | --- | --- | --- | --- | --- | +| 1 | 작성 | USER05 | - | 빈값, `SV` | 저장 | `SV` | 본인 | `save-apply` `SV` | +| 2 | 작성 | USER05 | - | `SV` | 신청완료 | `ST` | 광역 | `save-apply` `ST` | +| 3 | 작성 | USER05 | - | `SV` | 삭제 | 삭제 | — | `DELETE` | +| 4 | 상세 | USER04 | - | `ST` | 환경청제출 | `LE` | 환경청 | `updatePrgrsStts` | +| 5 | 상세 | USER03 | - | `LE` | 결과회신 | `EP` | 광역 | `tech-review` 자체종결 | +| 6 | 상세 | USER03 | - | `LE` | 기술검토 의뢰 | `FV` | 공단 접수 | **`request-examine`** | +| 7 | 자동화 | AI | - | `FV` | (파일검증) | `ER` | 공단 접수 | FE 호출 없음. 진행 패널 | +| 8 | 상세 | USER01 | 접수자 | `ER` | 파일보완 | `SL` | 기초 | 팝업(파일리스트) → `reqsupplement` | +| 9 | 상세 | USER01 | 접수자 | `ER` | 파일검증 | `ER` | 본인 | `dcrag-to/transfer` 재실행 | +| 10 | 상세 | USER01 | 접수자 | `ER` | 검토자지정 | `AC` | 다음 결재자 | `POST /elapr-dmnd` | +| 11 | 자동화 | AI | - | `AC` | (초안생성) | `TE` | 공단 검토 | FE 호출 없음. 폴링 | +| 12 | 상세 | USER01 | 검토자 | `TE` | 실무협의서관리 | 유지 | — | 웹기안기 Open. **초안 성공 시만** | +| 13 | 상세 | USER01 | 검토자 | `TE` | 검토결과조회 | 유지 | — | 실무협의 상세 | +| 14 | 상세 | USER01 | 검토자 | `TE` | 실무협의서생성 | 유지 | — | **실패/타임아웃/초안 없음** 시 | +| 15 | 상세 | USER01 | 검토자 | `TE` | 저장 | `TE` | — | `pwrk save-apply` | +| 16 | 상세 | USER01 | 검토자 | `TE` | 검토완료 | `TE` | 다음 결재자 | `elapr process` 상신. 반려 시 직전 결재자 | +| 17 | 상세 | USER01 | 검토자 | `TE` | 보완요청 | `SL` | 기초 | 보완 팝업() → `reqsupplement` | +| 18 | 상세 | USER01 | 승인자 | `TE` | 결재(승인) | `TE` | 다음 결재자 | `process` | +| 19 | 상세 | USER01 | 최종승인자 | `TE` | 결재(회신) | `TP` | 환경청 | `process` 최종 | +| 20 | 수정 | USER05 | - | `SL` | 파일보완완료 | `ER` | 공단 접수 | `submitsupplement` (직전=`ER`) | +| 21 | 수정 | USER05 | - | `SL` | 보완완료 | `TE` | 공단 검토 | `submitsupplement` (직전=`TE`) | +| 22 | 수정 | USER05 | - | `SL` | 보완완료 | `RK` | 공단 검토 | `submitsupplement` (직전=`RK`) | +| 23 | 상세 | USER03 | - | `TP` | 재원협의 결과 회신 | `RM` | 광역 | `tech-review` | +| 24 | 상세 | USER04 | - | `RM` | 지자체회신 | `RB` | 기초 | `updatePrgrsStts` | +| 25 | 작성 | USER05 | - | `RB` | 저장 | `RB` | 본인 | **`submit-actn-result` `isSubmit:false`** | +| 26 | 작성 | USER05 | - | `RB` | 협의회신 | `RC` | 광역 | **`submit-actn-result` `isSubmit:true`** | +| 27 | 상세 | USER04 | - | `RC` | 조치결과서제출 | `RA` | 환경청 | `updatePrgrsStts` | +| 28 | 상세 | USER03 | - | `RA` | 조치결과서 검토요청 | `AV` | 공단 검토 | **`request-actn-review`** | +| 29 | 자동화 | AI | - | `AV` | (금액검증) | `RK` | 공단 검토 | FE 호출 없음. 진행 패널 | +| 30 | 상세 | USER01 | 검토자 | `RK` | 실무협의서관리 | 유지 | — | 웹기안기 | +| 31 | 상세 | USER01 | 검토자 | `RK` | 금액검증 | `RK` | — | `dcrag-to/cost/verify` | +| 32 | 상세 | USER01 | 검토자 | `RK` | 저장 | `RK` | — | `pwrk save-apply` | +| 33 | 상세 | USER01 | 검토자 | `RK` | 검토완료 | `RK` | 다음 결재자 | `process` | +| 34 | 상세 | USER01 | 승인자 | `RK` | 결재(승인) | `RK` | 다음 결재자 | `process` | +| 35 | 상세 | USER01 | 최종승인자 | `RK` | 결재(회신) | `RR` | 환경청 | `process` 최종 | +| 36 | 상세 | USER03 | - | `RR` | 결과회신 | `EP` | 광역 | `updatePrgrsStts` | +| 37 | 작성/상세 | USER04 | - | `EP` | 결과회신 | `TA` | 기초 | `updatePrgrsStts` | +| 38 | 상세 | USER05 | - | `TA` | 결과회신 | — | — | **API 없음. 조회** | + +`SL` 20~22행: 버튼은 직전 상태에 따라 **하나만** 노출. 파일 루프면 [파일보완완료], 그 외 [보완완료]. + +--- + +## 3. `button-config` 권장 구조 + +액션 키는 현행 `FNRS_CNSLTN_VIEW_BUTTON_ACTION`을 확장한다. 신규·이름 변경: + +| 신규/변경 액션 | 라벨 | 비고 | +| --- | --- | --- | +| `fileSplmnt` | 파일보완 | `ER` 접수자. 현행에 없음 | +| `fileVrfc` | 파일검증 | 유지. `ER` 재실행 | +| `dsgntRvwr` | 검토자지정 | 유지. 전이를 `AC`로 | +| `pwrkCnsltnDocMng` | 실무협의서관리 | 유지. `TE`·`RK` | +| `pwrkCnsltnDocCrt` | 실무협의서생성 | 유지. 실패/타임아웃만 | +| `rvwRsltInq` | 검토결과조회 | 신규 | +| `actnStrg` | 저장(조치결과) | `RB` | +| `cnsltnRply` | 협의회신 | `RB→RC` | +| `actnSbmsn` | 조치결과서제출 | 광역 `RC→RA` | +| `rqstActnRvw` | 조치결과서 검토요청 | 환경청 `RA→AV` | +| `costVrfc` | 금액검증 | `RK` | +| `lclRply` | 지자체회신 | 광역 `RM→RB` | +| `fnrsRsltRply` | 재원협의 결과 회신 | 환경청 `TP→RM` | +| `rsltRply` | 결과회신 | `LE→EP`, `RR→EP`, `EP→TA`, `TA` 조회 | +| `fileSplmntCmptn` | 파일보완완료 | `SL←ER` | +| `splmntCmptn` | 보완완료 | `SL←TE\|RK` | + +**삭제**: `fnrsCnsltnAncmnt`(고시→`TB`), `sncnAprv`(인가승인). `EP`/`TA`는 `rsltRply`로 통일. + +### 3-1. 역할 × 상태 (공단 제외) + +``` +ROLE_USER05 + "" | SV → 저장, 신청완료, 삭제 + SL → (직전 ER) 파일보완완료 | (직전 TE|RK) 보완완료 + RB → 저장(조치), 협의회신 + TA → 결과회신 (조회) + +ROLE_USER04 + ST → 환경청제출 // 현행 반려(rjct→SV)는 SoT 38행에 없음. 유지 시 별도 합의 + RM → 지자체회신 + RC → 조치결과서제출 + EP → 결과회신 + +ROLE_USER03 + LE → 결과회신(자체종결), 기술검토 의뢰 + TP → 재원협의 결과 회신 + RA → 조치결과서 검토요청 + RR → 결과회신 +``` + +### 3-2. 공단 결재선 × 상태 + +``` +ER / ATRZ01 접수자 + → 파일보완, 파일검증, 검토자지정 + +TE / ATRZ02 검토자 + → (초안 성공) 실무협의서관리, 검토결과조회, 저장, 검토완료, 보완요청 + → (초안 실패·타임아웃) 실무협의서생성 (+ 위 항목 중 관리/조회는 초안 생긴 뒤) + +TE / ATRZ03 승인자 → 결재(승인) +TE / ATRZ99 최종 → 결재(회신) + +RK / ATRZ02 검토자 + → 실무협의서관리, 금액검증, 저장, 검토완료, 보완요청 +RK / ATRZ03 → 결재(승인) +RK / ATRZ99 → 결재(회신) +``` + +`FV`, `AC`, `AV`: 액션 빈 배열 + 진행 패널. + +현행 `TE`에서 `hasPendingApprovalRequest === false`이면 버튼을 숨긴다. v3.0에서는 검토자 버튼(저장·웹기안기·보완)은 결재 대기와 무관하게 두고, **검토완료/결재**만 결재건 존재 여부에 묶는다. + +--- + +## 4. 초안 성공 vs 실패 버튼 + +`TE`에서 `crtDmndList[].crtSttsComCd` / 폴링 `status`로 분기. + +| 조건 | 노출 | +| --- | --- | +| `C` / `COMPLETED` | 실무협의서관리, 검토결과조회, 저장, 검토완료, 보완요청 | +| `F` / `FAILED` 또는 30분 Fallback 후 초안 없음 | 실무협의서생성 | +| `R`/`I` 생성 중 | 진행 패널. 위 버튼 비활성 (`AC`와 동일 UX, 상태가 이미 `TE`면 패널+생성 버튼) | + +`RK`는 금액검증 성공이 기본. Fallback 후 [금액검증] 재실행만 추가 노출. + +--- + +## 5. 현행 `button-config.ts`와의 차이 + +| 항목 | 현재 | To-Be | +| --- | --- | --- | +| USER05 | `""/SV/TA(고시)` | `SL`, `RB` 추가. `TA`는 조회 | +| USER04 | `ST`(제출+반려), `EP`(인가승인) | `RM/RC/EP` 추가. 인가승인 삭제 | +| USER03 | `LE`, `TP`(→EP로 회신) | `RA`, `RR` 추가. `TP`는 `RM` | +| USER01 | `ER`(파일검증, 검토자지정), `TE` | `ER`에 파일보완. `RK` 전면 추가 | +| USER01 ATRZ | `ER`, `TE`만 `needsRoleUser01AtrzResolve` | `RK` 포함 | diff --git a/apps/as/docs/pwrk-cnsltn/개발가이드.md b/apps/as/docs/pwrk-cnsltn/개발가이드.md deleted file mode 100644 index bedc4ef3b..000000000 --- a/apps/as/docs/pwrk-cnsltn/개발가이드.md +++ /dev/null @@ -1,800 +0,0 @@ -# 실무협의문서 개발 가이드 - -> **대상 앱**: `frontend/apps/as` -> **작성일**: 2026-08-27 -> **버전**: 1.0 -> **SoT (백엔드)**: -> -> - `PwrkCnsltnDocCrtRsltController` — `/api/v1/pwrk-cnsltn-doc-crt-rslt` -> - `PwrkCnsltnMngController` — `/api/v1/pwrk-cnsltn-mng` -> - `DcRagApiToController` — `/api/v1/dcrag-to` -> - 초안생성 데이터 설명 (`02.03.초안생성_데이터_설명.md`) -> -> **현재 프론트 상태**: `pwrk-cnsltn-`\* 엔티티·화면 **미구현**. 재원협의(`fnrs-cnsltn`)만 존재한다. - ---- - -## 1. 문서 목적 - -재원협의 접수자가 **자료 검증 → 실무협의서 초안 생성 → 한글 웹기안기 열람/다운로드 → 편집본 버전 저장**까지 구현할 때 사용하는 프론트엔드 개발 가이드이다. - -백엔드 컨트롤러·DTO를 **API source of truth**로 두고, 초안생성 데이터 설명의 업무 시퀀스를 화면 동작에 매핑한다. - ---- - -## 2. 도메인 개요 - -실무협의서(PWRK)는 재원협의서(FNRS)의 **하위 문서**이다. 문서번호 규칙: - -| 문서 | 번호 예시 | 필드 | -| -------- | ----------------- | ----------------------------- | -| 재원협의 | `F-20260824-0001` | `fnrsCnsltnDocNo` / `upDocNo` | -| 실무협의 | `P-20260827-0001` | `pwrkCnsltnDocNo` | - -재원협의 1건당 실무협의서는 **1건만** 생성된다. `POST /pwrk-cnsltn-mng`는 `upDocNo`로 기존 문서를 재사용하고, 없으면 문서번호를 발급한다. - -### 2-1. 핵심 식별자 - -| 필드 | 의미 | 비고 | -| ----------------- | ------------------------------ | --------------------------------------------------------- | -| `pwrkCnsltnDocNo` | 실무협의문서번호 | 문서 PK. `P-YYYYMMDD-NNNN` | -| `upDocNo` | 상위문서번호(재원협의) | `upDocSeComCd = "FNRS"` | -| `crtDmndUuid` | 생성요청 UUID | 초안 생성 작업·결과·다운로드의 키 | -| `verSn` | 버전일련번호 | 복합 PK `(crtDmndUuid, verSn)`. 저장 시 서버가 `+1` | -| `orgnlOtptDocCn` | 원본출력문서내용(HWP 바이너리) | **목록/상세 응답에서는 항상** `null`. 다운로드 API만 제공 | - -### 2-2. IIPBS 약어 - -| 한글 | 약어 | 필드 예 | -| -------- | ------ | ----------------------------- | -| 실무 | PWRK | `pwrkCnsltnDocNo` | -| 협의 | CNSLTN | `pwrk-cnsltn-mng` | -| 관리 | MNG | `PwrkCnsltnMng` | -| 문서 | DOC | `pwrkCnsltnDocNo` | -| 생성 | CRT | `crtDmndUuid`, `crtSttsComCd` | -| 요청 | DMND | `crtDmndUuid` | -| 결과 | RSLT | `crtRslt` | -| 버전 | VER | `verSn` | -| 일련번호 | SN | `verSn` | -| 원본 | ORGNL | `orgnlOtptDocCn` | -| 출력 | OTPT | `orgnlOtptDocCn` | -| 내용 | CN | `orgnlOtptDocCn` | -| 완료 | CMPTN | `crtCmptnDt` | -| 일시 | DT | `crtCmptnDt` | -| 검증 | VRFC | `vrfcSttsComCd` | -| 전송 | TRSM | `trsmFileList` | -| 보완 | SPLMNT | `splmntDmndList` | -| 요청자 | RQSTR | `rqstrUuid`, `rqstrNm` | - ---- - -## 3. 전체 업무 흐름 - -재원협의 **접수자**가 실무협의서 초안을 만드는 경로이다. 프론트는 DC RAG를 직접 호출하지 않고 **as-service**만 호출한다. - -```mermaid -sequenceDiagram - autonumber - participant User as 재원협의 접수자 - participant FE as AS Frontend - participant AS as as-service - participant RAG as DC RAG - - User->>FE: [자료 검증 요청] - FE->>AS: POST /dcrag-to/documents/transfer
{ fnrsCnsltnDocNo } - AS->>RAG: 자료전송 (IF-01) - AS-->>FE: { resultCode: SUCCESS, requestId } - Note over FE: 재원협의 상세 재조회 - AS-->>FE: esntlFileList / etcFileList 에
vrfcDt, vrfcRsltCn, vrfcSttsComCd - - User->>FE: [초안 생성] - FE->>AS: POST /pwrk-cnsltn-mng
{ upDocNo, upDocSeComCd: FNRS, prgrsSttsComCd: DV } - alt 실무협의서 번호 없음 - AS->>AS: P-번호 발급 + 문서 등록 - else 이미 존재 - AS->>AS: 기존 pwrkCnsltnDocNo 재사용 - end - AS->>RAG: 초안생성 비동기 요청 (IF-03-1) - AS-->>FE: 201 { pwrkCnsltnDocNo, crtDmndUuid, crtSttsComCd: R, estimatedMinutes: 10 } - - loop 약 10분, status !== COMPLETED/FAILED - FE->>AS: GET /dcrag-to/documents/draft/status/{crtDmndUuid} - AS-->>FE: { status, events[], progressPercent } - end - - Note over FE: events 에 COMPLETED 가 있으면
웹기안기 열기 / 다운로드 가능 - - User->>FE: [문서 열기] 또는 [다운로드] - FE->>AS: GET /pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/webhwp/open
또는 /download - - User->>FE: 웹기안기 편집 후 [저장] - FE->>AS: POST /pwrk-cnsltn-doc-crt-rslt (multipart)
crtDmndUuid + file - AS-->>FE: 201 { verSn: N+1, ... } -``` - -### 3-1. 프론트가 호출하면 안 되는 API - -| API | 이유 | -| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `POST /dcrag-to/documents/draft/generate` | 초안 생성은 `POST /pwrk-cnsltn-mng`가 문서번호 발급 + DC RAG 호출을 **한 트랜잭션**으로 처리한다. 프론트는 관리 API만 호출한다. | - ---- - -## 4. 화면 데이터 흐름 - -초안 생성은 **재원협의 상세**에서 시작하고, 실무협의 목록/상세는 별도 화면이다. - -``` -BEGIN - - // ===== 재원협의 상세 (기존 fnrs-cnsltn view) ===== - CALL findById(fnrsCnsltnDocNo) - DISPLAY 재원협의 상세 + 필수/기타 첨부 - - ON [자료 검증 요청] 버튼 CLICK DO - CALL requestDocumentTransfer({ fnrsCnsltnDocNo }) - CALL findById(fnrsCnsltnDocNo) // 검증결과 필드 반영 - DISPLAY 첨부 리스트 (vrfcSttsComCd: PASS | FAIL) - END ON - - ON [초안 생성] 버튼 CLICK DO - CALL pwrkCnsltnMngService.save({ - upDocNo: fnrsCnsltnDocNo, - upDocSeComCd: "FNRS", - prgrsSttsComCd: "DV" - }) - // → pwrkCnsltnDocNo, crtDmndUuid, estimatedMinutes - START POLL getDraftGenerationStatus(crtDmndUuid) - DISPLAY 초안 생성 진행률 (약 10분) - END ON - - ON POLL status = COMPLETED 또는 events.eventType 에 COMPLETED DO - ENABLE [웹기안기 열기] [다운로드] - END ON - - ON [웹기안기 열기] 버튼 CLICK DO - CALL HwpCtrl.Open( webhwpOpenUrl(crtDmndUuid) ) // TBD: 컨트롤러 미구현 - END ON - - ON [다운로드] 버튼 CLICK DO - CALL downloadLatest(crtDmndUuid) // blob + Content-Disposition - SAVE 로컬 파일 - END ON - - // ===== 실무협의 목록 ===== - CALL pwrkCnsltnMngService.findPage(searchDto + pageable) - DISPLAY 실무협의 문서 그리드 - - ON 행 CLICK DO - NAVIGATE 실무협의 상세 (pwrkCnsltnDocNo) - END ON - - // ===== 실무협의 상세 / 재원협의에서 연계 조회 ===== - CALL findById(pwrkCnsltnDocNo) - // 또는 재원협의 번호로: findByUpDocNo(fnrsCnsltnDocNo) - DISPLAY 마스터 + cnsltnList + pjtcoAjmtList + crtDmndList.crtRslt - // crtRslt.orgnlOtptDocCn 은 항상 null — 바이너리는 download API - - ON 버전 이력 조회 DO - CALL pwrkCnsltnDocCrtRsltService.findPage({ crtDmndUuid, sort: "verSn,desc" }) - DISPLAY 버전 목록 - END ON - - ON 웹기안기 편집 완료 [저장] DO - CALL saveMultipart({ crtDmndUuid, file }) // 서버가 verSn + 1 - INVALIDATE 상세·버전 목록 - END ON - -END -``` - ---- - -## 5. API 명세 - -### 5-1. 공통 - -| 항목 | 값 | -| ----------- | ----------------------------------------------------------------- | -| Gateway | `/as-service` | -| Base prefix | `ENDPOINT.AS_SERVICE.*` (`apps/as/src/shared/config/api.ts`) | -| Auth 헤더 | `Authorization`, `X-Site-Id`, `token-id` (기존 `client` 인터셉터) | -| 날짜 포맷 | `yyyy-MM-dd HH:mm:ss` | - -추가할 ENDPOINT: - -```ts -AS_SERVICE: { - // 기존 … - PWRK_CNSLTN_MNG: "/as-service/api/v1/pwrk-cnsltn-mng", - PWRK_CNSLTN_DOC_CRT_RSLT: "/as-service/api/v1/pwrk-cnsltn-doc-crt-rslt", - DCRAG_TO: "/as-service/api/v1/dcrag-to", -} -``` - ---- - -### 5-2. DC RAG 연계 (`DcRagApiToController`) - -프론트 엔티티 슬라이스: `entities/dcrag-to/` - -| 기능 | Controller | Method | URL | Request | Response | -| ----------------- | -------------------------- | ------ | ------------------------------------------------ | --------------------------------- | ------------------------------------- | -| 자료전송요청 | `requestDocumentTransfer` | POST | `/dcrag-to/documents/transfer` | `DcRagDocumentTransferRequestDto` | `DcRagCommonResponseDto` | -| 초안생성 진행상태 | `getDraftGenerationStatus` | GET | `/dcrag-to/documents/draft/status/{crtDmndUuid}` | path | `DcRagDraftStatusResponseDto` | -| 금액검증요청 | `requestCostVerification` | POST | `/dcrag-to/documents/cost/verify` | `DcRagCostVerifyRequestDto` | `DcRagCommonResponseDto` (202) | -| 초안생성요청 | `requestDraftGeneration` | POST | `/dcrag-to/documents/draft/generate` | `DcRagDraftGenerateRequestDto` | `DcRagDraftGenerateResponseDto` (202) | - -**화면에서 쓰는 것**: 자료전송 + 상태 폴링 (+ 추후 금액검증). -초안생성요청은 5-3의 `pwrkCnsltnMngService.save`로 대체한다. - -#### 자료전송 Request / Response - -```json -POST /as-service/api/v1/dcrag-to/documents/transfer -{ "fnrsCnsltnDocNo": "F-20260824-0001" } -``` - -```json -{ - "resultCode": "SUCCESS", - "message": "자료전송요청 접수 완료", - "requestId": "F-20260824-0001" -} -``` - -자료 검증이 **완료된 뒤** 재원협의 상세를 다시 조회하면, 필수첨부(`esntlFileList`)·기타첨부(`etcFileList`) 각 파일에 검증 결과가 붙는다. - -| 필드 | 예 | 의미 | -| --------------- | ----------------------------- | ------------ | -| `vrfcDt` | `2026-08-25 18:13:49` | 검증일시 | -| `vrfcRsltCn` | `정상 확인 (재원협의 신청서)` | 검증결과내용 | -| `vrfcSttsComCd` | `PASS` / `FAIL` | 검증상태 | - -프론트 `FnrsCnsltnMngDetResponseDto` / `AtflMngClientResponseDto` / `EsntlAtchFileMngTreeResponseDto`에 위 3필드가 **아직 없다**. 재원협의 상세 첨부 영역에 배지를 그리려면 타입을 확장해야 한다. - -#### 초안생성 상태 폴링 - -```http -GET /as-service/api/v1/dcrag-to/documents/draft/status/{crtDmndUuid} -``` - -| 필드 | 설명 | -| ----------------------- | --------------------------------------------------------------------------------- | -| `status` | `REQUESTED` / `DOWNLOADING` / `ANALYZING` / `GENERATING` / `COMPLETED` / `FAILED` | -| `statusName` | 표시명 (예: `생성 완료`) | -| `progressPercent` | 0~100 | -| `events[]` | `{ eventType, eventMessage, occurredDt }` | -| `startedDt` | 시작일시 | -| `estimatedCompletionDt` | 예상 완료일시 | -| `errorMessage` | 실패 시 메시지 | - -폴링 규칙: - -- 주기: **5~10초** (TBD, UX 확정). -- 예상 소요: **약 10분** (`estimatedMinutes`). -- 종료 조건: `status === "COMPLETED"` **또는** `events`에 `eventType === "COMPLETED"`. -- 실패: `status === "FAILED"` → 폴링 중단, `errorMessage` 표시. -- 완료 전에는 웹기안기 열기·다운로드 버튼을 **비활성**. - -이벤트 순서 예시: - -| eventType | eventMessage | -| ------------- | --------------------------- | -| `REQUESTED` | 요청을 접수했습니다. | -| `DOWNLOADING` | 신청서를 수신하는 중입니다. | -| `ANALYZING` | 자료 준비가 완료되었습니다. | -| `GENERATING` | 문서 저장이 완료되었습니다. | -| `COMPLETED` | 문서 생성이 완료되었습니다. | - ---- - -### 5-3. 실무협의관리 (`PwrkCnsltnMngController`) - -프론트 엔티티 슬라이스: `entities/pwrk-cnsltn-mng/` - -| 기능 | Controller | Method | URL | Request | Response | -| ------------------- | ---------------------- | ------ | ----------------------------------------------------- | ----------------------------------- | ------------------------------------ | -| 페이징 조회 | `findPage` | GET | `/pwrk-cnsltn-mng` | `PwrkCnsltnMngSearchDto` + Pageable | `Page` | -| 상세 (문서번호) | `findById` | GET | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}` | path | `PwrkCnsltnMngDetResponseDto` | -| 상세 (재원협의번호) | `findByUpDocNo` | GET | `/pwrk-cnsltn-mng/updocno/{upDocNo}` | path | `PwrkCnsltnMngDetResponseDto` | -| 등록 + 초안생성 | `save` | POST | `/pwrk-cnsltn-mng` | `PwrkCnsltnMngSaveRequestDto` | `PwrkCnsltnMngSaveResponseDto` (201) | -| 신청 통합 저장 | `saveApply` | POST | `/pwrk-cnsltn-mng/save-apply` | `PwrkCnsltnApplySaveRequestDto` | `string` (문서번호, 201) | -| 수정 | `update` | PUT | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}` | `PwrkCnsltnMngUpdateRequestDto` | `string` | -| 삭제 | `delete` → `remove` | DELETE | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}` | path | 204 | -| 보완완료 제출 | `submitSupplement` | POST | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}/submitsupplement` | `PwrkCnsltnApplySaveRequestDto` | `string` | -| 보완요청 등록 | `submitReqSupplement` | POST | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}/reqsupplement` | `PwrkCnsltnApplySaveRequestDto` | `string` | -| 진행상태 수정 | `changePrgrsSttsComCd` | POST | `/pwrk-cnsltn-mng/doc/updatePrgrsStts` | `DocPrgrsUpdateRequestDto` | 200 | - -목록 기본 호출: - -```http -GET /as-service/api/v1/pwrk-cnsltn-mng?page=0&size=10&sort=pwrkCnsltnDocNo,desc -``` - -#### 초안 생성 Request / Response (`save`) - -```json -POST /as-service/api/v1/pwrk-cnsltn-mng -{ - "upDocNo": "F-20260824-0001", - "upDocSeComCd": "FNRS", - "prgrsSttsComCd": "DV" -} -``` - -```json -{ - "pwrkCnsltnDocNo": "P-20260826-0001", - "upDocNo": "F-20260824-0001", - "upDocSeComCd": "FNRS", - "prgrsSttsComCd": "DV", - "resultCode": "ACCEPTED", - "message": "초안 생성 작업이 접수되었습니다.", - "crtDmndUuid": "382f2821-9231-42e6-8309-4a1718a2ead8", - "crtSttsComCd": "R", - "requestId": "382f2821-9231-42e6-8309-4a1718a2ead8", - "estimatedMinutes": 10 -} -``` - -백엔드 동작 (`PwrkCnsltnMngService.save`): - -1. `upDocNo`로 기존 실무협의서가 있으면 **그 번호를 재사용** (재원협의 1 : 실무협의 1). -2. 없으면 `docNoMngService.issueDocNo(PWRK)`로 `P-` 번호 발급 후 insert. -3. DC RAG 초안생성을 비동기로 호출하고 `crtDmndUuid`를 응답에 실어 반환. -4. DC RAG 실패 시에도 **문서는 롤백하지 않고** 로그만 남긴다. 프론트는 `resultCode`가 `ACCEPTED`/`SUCCESS`가 아니면 토스트로 실패를 알린다. - -#### 상세 응답 구조 (`PwrkCnsltnMngDetResponseDto`) - -재원협의 상세에서 `GET .../updocno/{fnrsCnsltnDocNo}`로 연계 데이터를 한 번에 가져온다. - -```json -{ - "pwrkCnsltnDocNo": "P-20260827-0001", - "upDocNo": "F-20260824-0001", - "upDocSeComCd": "FNRS", - "prgrsSttsComCd": "DV", - "cnsltnList": [], - "pjtcoAjmtList": [], - "crtDmndList": [ - { - "crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a", - "pwrkCnsltnDocNo": "P-20260827-0001", - "rqstrUuid": "65a00f65-8460-49af-98ec-042977e56f4b", - "rqstrNm": "관리자", - "crtSttsComCd": "C", - "crtRslt": { - "crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a", - "verSn": 1, - "rgtrUuid": "...", - "rgtrNm": "관리자", - "orgnlOtptDocCn": null, - "crtCmptnDt": "2026-08-27 10:47:01" - } - } - ], - "splmntDmndList": [], - "trsmFileList": [] -} -``` - -`crtRslt`는 최신 버전 메타만 포함한다. `orgnlOtptDocCn`은 절대 쓰지 말고 download API를 호출한다. - -마스터에 재원협의 사업정보가 조인된다: `bizNm`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `aplcntNm` 등. - ---- - -### 5-4. 문서생성결과 (`PwrkCnsltnDocCrtRsltController`) - -프론트 엔티티 슬라이스: `entities/pwrk-cnsltn-doc-crt-rslt/` -복합 PK: `crtDmndUuid` + `verSn` - -| 기능 | Controller | Method | URL | Request | Response | -| ------------------ | ------------------------- | ------ | ---------------------------------- | ----------------------------------------------- | --------------------------------------- | -| 버전 이력 페이징 | `findPage` | GET | `/pwrk-cnsltn-doc-crt-rslt` | `PwrkCnsltnDocCrtRsltSearchDto` + Pageable | `Page` | -| 특정 버전 상세 | `findById` | GET | `/{crtDmndUuid}/{verSn}` | path | `PwrkCnsltnDocCrtRsltResponseDto` | -| 최신 버전 상세 | `findLatestByCrtDmndUuid` | GET | `/{crtDmndUuid}/latest` | path | `PwrkCnsltnDocCrtRsltResponseDto` | -| 특정 버전 다운로드 | `download` | GET | `/{crtDmndUuid}/{verSn}/download` | path | `byte[]` (attachment) | -| 최신 버전 다운로드 | `downloadLatest` | GET | `/{crtDmndUuid}/download` | path | `byte[]` (attachment) | -| 등록 (multipart) | `saveMultipart` | POST | `/` `multipart/form-data` | `PwrkCnsltnDocCrtRsltSaveRequestDto` + `file` | `PwrkCnsltnDocCrtRsltResponseDto` (201) | -| 등록 (JSON) | `saveJson` | POST | `/` `application/json` | `PwrkCnsltnDocCrtRsltSaveRequestDto` | 201 | -| 수정 (multipart) | `updateMultipart` | PUT | `/{crtDmndUuid}/{verSn}` multipart | `PwrkCnsltnDocCrtRsltUpdateRequestDto` + `file` | ResponseDto | -| 수정 (JSON) | `updateJson` | PUT | `/{crtDmndUuid}/{verSn}` json | UpdateRequestDto | ResponseDto | -| 삭제 | `delete` → `remove` | DELETE | `/{crtDmndUuid}/{verSn}` | path | 204 | - -Spring 매핑 주의: `/{crtDmndUuid}/latest` 와 `/{crtDmndUuid}/download` 는 `/{crtDmndUuid}/{verSn}` 보다 **구체 경로가 우선**한다. 프론트 path를 섞지 말 것. - -버전 이력 조회 예: - -```http -GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt?page=0&size=10&crtDmndUuid={uuid}&sort=verSn,desc -``` - -#### 다운로드 (attachment) - -화면 [다운로드] 버튼 → PC 저장. - -```http -GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/download -GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/{verSn}/download -``` - -- axios: `responseType: "blob"`. -- 파일명: `Content-Disposition`의 `filename*=UTF-8''...` (`parseFilenameFromResponse` 재사용). -- 서버 파일명 규칙: `재원협의 실무협의서({bizNm})_v{verSn}.hwp`. -- **현재 컨트롤러 Content-Type**: `application/x-hwp`. - 데이터 설명 예시는 `application/haansofthwpx`. 프론트는 Content-Type에 의존하지 말고 blob으로 저장한다. - -기존 `attachService` 다운로드와 동일 패턴: - -```ts -const response = await client.get(url, { responseType: "blob" }); -const filename = parseFilenameFromResponse(response); -const objectUrl = window.URL.createObjectURL(response.data); -``` - -#### 웹기안기 열기 (inline) — TBD - -데이터 설명 기준 엔드포인트. **현재** `PwrkCnsltnDocCrtRsltController`**에는 없음.** 백엔드 추가 후 연결한다. - -```http -GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/webhwp/open -GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/{verSn}/webhwp/open -``` - -의도: - -- `Content-Disposition: inline` -- 한컴 웹기안기 `HwpCtrl.Open(url)` 이 이 URL을 직접 호출 -- 브라우저 다운로드 창이 뜨지 않고 캔버스에 로드 - -프론트는 **blob fetch 후 Object URL을 넘기지 않는다.** 인증 헤더가 필요한 절대 URL을 기안기에 전달하는 방식이므로, 쿠키/게이트웨이 인증 가능 여부를 백엔드와 확인한다 (TBD). - -#### 신규 버전 등록 (웹기안기 저장) - -서버가 최신 `verSn + 1`을 부여한다. 클라이언트가 `verSn`을 보내지 않는다. - -```http -POST /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt -Content-Type: multipart/form-data - -crtDmndUuid: {uuid} -file: 재원협의 실무협의서_v2.hwpx -``` - -화면에서는 **multipart만** 사용한다. JSON `saveJson`은 바이너리를 body에 넣는 경로라 UI에서 쓰지 않는다. - -`file` part 이름: `"file"`. DTO 필드는 `@ModelAttribute` 로 평문 필드(`crtDmndUuid`) 또는 `saveDto` part. - ---- - -## 6. DTO 매핑 (프론트 타입명 = 백엔드 클래스명) - -### 6-1. `PwrkCnsltnDocCrtRslt*` - -| 계층 | 파일 | 정의 | -| ------------------------------------ | -------------------------------------------- | ----------------------------------------------- | -| Save Request | `pwrk-cnsltn-doc-crt-rslt-payload-schema.ts` | zod SSOT → `PwrkCnsltnDocCrtRsltSaveRequestDto` | -| Response / Search / Update / Service | `pwrk-cnsltn-doc-crt-rslt-interface.ts` | `interface` | - -**Response** - -| 필드 | 타입 | 비고 | -| ---------------- | -------- | --------------------- | -| `crtDmndUuid` | `string` | UUID | -| `verSn` | `number` | BigDecimal | -| `rgtrUuid` | `string` | | -| `rgtrNm` | `string` | 서버 enrich | -| `orgnlOtptDocCn` | `null` | 목록/상세 미포함 | -| `crtCmptnDt` | `string` | `yyyy-MM-dd HH:mm:ss` | - -**Search** (`PageableParams` 확장) - -| 필드 | 비고 | -| ----------------- | ---------------------- | -| `crtDmndUuid` | 버전 이력 조회 시 필수 | -| `pwrkCnsltnDocNo` | | -| `verSn` | | -| `rgtrUuid` | | - -**Save Request (zod)** - -| 필드 | 필수 | 비고 | -| ------------- | ---- | ------------------------------------ | -| `crtDmndUuid` | Y | | -| `rgtrUuid` | N | 미전송 시 서버가 로그인 사용자 사용 | -| `file` | UI | multipart `file` part. DTO 필드 아님 | - -**Update Request** - -| 필드 | 비고 | -| ------------ | --------- | -| `rgtrUuid` | | -| `crtCmptnDt` | | -| `file` | multipart | - -### 6-2. `PwrkCnsltnMng*` - -**Search** - -`pwrkCnsltnDocNo`, `upDocNo`, `upDocSeComCd`, `prgrsSttsComCd`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `bizNm` - -**Save Request (초안 생성)** — 필드가 얇으므로 interface만으로도 가능. 권장은 zod. - -| 필드 | 필수 | 값 | -| ----------------- | ---- | ----------------- | -| `upDocNo` | Y | 재원협의 문서번호 | -| `upDocSeComCd` | | `"FNRS"` | -| `prgrsSttsComCd` | | `"DV"` (초안생성) | -| `pwrkCnsltnDocNo` | N | 서버 발급 | - -**Save Response** — `save`만 이 DTO. `fnrsCnsltnMngService.save`가 `string`을 반환하는 것과 **다름**. - -| 필드 | 의미 | -| ------------------ | --------------------------------- | -| `pwrkCnsltnDocNo` | 발급/재사용 번호 | -| `resultCode` | `ACCEPTED` / `SUCCESS` / 실패코드 | -| `crtDmndUuid` | 폴링 키 | -| `crtSttsComCd` | `R` 생성요청 | -| `estimatedMinutes` | 기본 10 | - -**DetResponse 중첩** - -- `cnsltnList`: 협의내역 (`rvwOpnnSn`, `rvwOpnnClsfComCd`, `cnsltnCn`, `rfltYn`, …) -- `pjtcoAjmtList`: 사업비조정 -- `crtDmndList[].crtRslt`: 생성요청 + 최신 결과 -- `splmntDmndList`: 보완요청 -- `sttsChgHstryList`: 상태변경이력 -- `trsmFileList`: 전송파일 (데이터 설명 기준. Det DTO Java 필드 확인 후 타입 반영) - -### 6-3. DC RAG - -```ts -interface DcRagDocumentTransferRequestDto { - fnrsCnsltnDocNo: string; - taskSeComCd?: string; - taskSeCdNm?: string; -} - -interface DcRagCommonResponseDto { - resultCode: string; - message?: string; - requestId?: string; -} - -interface DcRagDraftStatusResponseDto { - crtDmndUuid: string; - status: DraftStatus; - statusName?: string; - progressPercent?: number; - events?: EventInfoDto[]; - startedDt?: string; - estimatedCompletionDt?: string; - errorMessage?: string | null; -} - -type DraftStatus = - | "REQUESTED" - | "DOWNLOADING" - | "ANALYZING" - | "GENERATING" - | "COMPLETED" - | "FAILED"; -``` - ---- - -## 7. 공통코드 - -| 상위코드 | 필드 | 값 | 명칭 | -| ----------------------- | ---------------- | --------------- | ------------- | -| `ASDOCSE` | `upDocSeComCd` | `FNRS` | 재원협의 | -| `ASDOCSE` | 문서구분 | `PWRK` | 실무협의 | -| `PWRKSTTS` | `prgrsSttsComCd` | `DV` | 초안생성 | -| `PWRKSTTS` | | `AR` | 검토자지정 | -| `PWRKSTTS` | | `SV` | 작성중 | -| `PWRKSTTS` | | `RB` | 검토회신 | -| `PWRKSTTS` | | `RC` | 협의회신 | -| `PWRKSTTS` | | `TR` | 검토결과 회신 | -| `PWRKSTTS` | | `SL` | 보완요청 | -| `PWRKSTTS` | | `AA` | 금액조정중 | -| `AICRTSTTS` / `CRTSTTS` | `crtSttsComCd` | `R` | 생성요청 | -| | | `I` | 생성중 | -| | | `C` | 생성완료 | -| | | `F` | 생성실패 | -| (검증) | `vrfcSttsComCd` | `PASS` / `FAIL` | | - -`CrtDmndResponseDto`는 `@CodeName(upCd = "CRTSTTS")`, 생성요청 단건 DTO는 `AICRTSTTS`를 쓴다. 코드값이 `R/I/C/F`로 같으므로 UI는 코드값으로 분기하고, 표시명은 응답의 `*Nm` 필드를 쓴다. - -초안 생성 직후 `crtSttsComCd = R`, 폴링 `COMPLETED` 이후 상세의 `crtSttsComCd = C`. - ---- - -## 8. 프론트엔드 구현 가이드 - -### 8-1. 기술 스택·원칙 - -- App Router + FSD (`app/` → `pages/` → `widgets/` → `entities/` → `shared/`) -- React Query. ad-hoc `useQuery` + service 직접 호출 금지 -- `@common/components`, `useDataTable`, `@tanstack/react-table` -- `'use client'`는 Web API(다운로드, 폴링, 웹기안기)가 있는 페이지만 -- 함수명 = Controller public 메서드명 (`delete` → `remove`) -- 타입명 = 백엔드 DTO명. 프론트 전용 축약 금지 -- Save/Apply Request = zod SSOT. Response는 `interface` - -참조 슬라이스: `entities/fnrs-cnsltn/`, `entities/sewer-bsc-plan/` - -### 8-2. 모듈·파일 배치 - -| 구분 | 경로 | -| -------------------- | ------------------------------------------------------------------- | -| ENDPOINT | `apps/as/src/shared/config/api.ts` | -| 실무협의 관리 엔티티 | `entities/pwrk-cnsltn-mng/` | -| 생성결과 엔티티 | `entities/pwrk-cnsltn-doc-crt-rslt/` | -| DC RAG 엔티티 | `entities/dcrag-to/` | -| 목록 라우트 | `app/(main)/(with-base-layout)/pwrk-cnsltn/page.tsx` | -| 상세 라우트 | `app/(main)/(with-base-layout)/pwrk-cnsltn/[id]/page.tsx` | -| 페이지 UI | `pages/pwrk-cnsltn/` | -| 재원협의 상세 연계 | `pages/fnrs-cnsltn/ui/fnrs-view-page.tsx` (버튼·폴링·첨부 검증배지) | - -엔티티 내부: - -``` -entities/pwrk-cnsltn-mng/ -├── model/ -│ ├── pwrk-cnsltn-mng-interface.ts -│ └── pwrk-cnsltn-mng-payload-schema.ts -├── api/ -│ ├── pwrk-cnsltn-mng-service.ts -│ └── queries.ts -└── index.ts - -entities/pwrk-cnsltn-doc-crt-rslt/ -├── model/ -│ ├── pwrk-cnsltn-doc-crt-rslt-interface.ts -│ └── pwrk-cnsltn-doc-crt-rslt-payload-schema.ts -├── api/ -│ ├── pwrk-cnsltn-doc-crt-rslt-service.ts -│ └── queries.ts -└── index.ts -``` - -### 8-3. Service 시그니처 요지 - -`pwrkCnsltnMngService.save` 반환 타입이 `string`이 아니라 `PwrkCnsltnMngSaveResponseDto` 인 점을 `fnrs-cnsltn`과 혼동하지 말 것. - -```ts -export interface PwrkCnsltnMngService { - findPage(searchDto: PwrkCnsltnMngSearchDto): Promise>; - findById(pwrkCnsltnDocNo: string): Promise; - findByUpDocNo(upDocNo: string): Promise; - save(body: PwrkCnsltnMngSaveRequestDto): Promise; - saveApply(body: PwrkCnsltnApplySaveRequestDto): Promise; - update(pwrkCnsltnDocNo: string, body: PwrkCnsltnMngUpdateRequestDto): Promise; - submitSupplement(pwrkCnsltnDocNo: string, body: PwrkCnsltnApplySaveRequestDto): Promise; - submitReqSupplement( - pwrkCnsltnDocNo: string, - body: PwrkCnsltnApplySaveRequestDto, - ): Promise; - remove(pwrkCnsltnDocNo: string): Promise; - changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise; -} - -export interface PwrkCnsltnDocCrtRsltService { - findPage( - searchDto: PwrkCnsltnDocCrtRsltSearchDto, - ): Promise>; - findById(crtDmndUuid: string, verSn: number): Promise; - findLatestByCrtDmndUuid(crtDmndUuid: string): Promise; - download(crtDmndUuid: string, verSn: number): Promise>; - downloadLatest(crtDmndUuid: string): Promise>; - saveMultipart(formData: FormData): Promise; - updateMultipart( - crtDmndUuid: string, - verSn: number, - formData: FormData, - ): Promise; - remove(crtDmndUuid: string, verSn: number): Promise; -} -``` - -multipart `Content-Type`은 axios가 boundary를 붙이도록 **헤더를 수동 지정하지 않는다.** - -### 8-4. 폴링 훅 - -`useQuery` + `refetchInterval`: - -```ts -useQuery({ - queryKey: [...dcragToKeys.draftStatus(crtDmndUuid)], - queryFn: () => dcragToService.getDraftGenerationStatus(crtDmndUuid), - enabled: Boolean(crtDmndUuid) && isPolling, - refetchInterval: (query) => { - const status = query.state.data?.status; - if (status === "COMPLETED" || status === "FAILED") return false; - return 5_000; - }, -}); -``` - -`COMPLETED` 시 `pwrkCnsltnMng` 상세 쿼리를 invalidate 하여 `crtDmndList[].crtRslt`를 갱신한다. - -### 8-5. 재원협의 상세에 붙일 UI - -`pages/fnrs-cnsltn` 조회 모드: - -1. **자료 검증 요청** — `requestDocumentTransfer` → 상세 refetch → 첨부 행에 PASS/FAIL 배지 (`vrfcSttsComCd`, `vrfcRsltCn`, `vrfcDt`). -2. **초안 생성** — 검증이 끝난 뒤 활성. `pwrkCnsltnMngService.save`. -3. **진행 표시** — `progressPercent` + 이벤트 타임라인. `estimatedMinutes`(10)를 안내. -4. **웹기안기 열기 / 다운로드** — `COMPLETED` 이후에만. - -`findByUpDocNo(fnrsCnsltnDocNo)`로 이미 초안이 있으면 `crtDmndUuid`를 복원해 폴링·열기를 이어간다. - -### 8-6. 실무협의 목록 컬럼 (권장) - -마스터 응답 + 재원협의 조인 필드 기준. 화면설계 확정 전 TBD. - -| 표시명 | Field | -| ------------ | ------------------ | -| 실무협의번호 | `pwrkCnsltnDocNo` | -| 재원협의번호 | `upDocNo` | -| 진행상태 | `prgrsSttsComCdNm` | -| 사업명 | `bizNm` | -| 시도/구군 | `sido` / `gugun` | -| 신청자 | `aplcntNm` | -| 등록일 | `regDt` | - -행 클릭 → `/pwrk-cnsltn/{pwrkCnsltnDocNo}`. - ---- - -## 9. 구현 순서 - -``` -Task Progress: -- [ ] 1. ENDPOINT 3개 추가 (PWRK_CNSLTN_MNG, PWRK_CNSLTN_DOC_CRT_RSLT, DCRAG_TO) -- [ ] 2. entities/dcrag-to (transfer + draft status) -- [ ] 3. entities/pwrk-cnsltn-mng (findPage, findById, findByUpDocNo, save) -- [ ] 4. entities/pwrk-cnsltn-doc-crt-rslt (findPage, downloadLatest, saveMultipart) -- [ ] 5. 재원협의 상세: 검증결과 3필드 타입 확장 + 배지 -- [ ] 6. 재원협의 상세: 자료검증 / 초안생성 / 폴링 UI -- [ ] 7. 실무협의 목록·상세 라우트 -- [ ] 8. 다운로드 (blob) -- [ ] 9. 웹기안기 Open — 백엔드 webhwp/open 확인 후 -- [ ] 10. 웹기안기 저장 → saveMultipart (verSn + 1) -``` - -1~4는 엔티티 레이어만으로 먼저 닫는다. 화면은 5부터. - ---- - -## 10. TBD · 백엔드 확인 - -| 항목 | 내용 | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `webhwp/open` | 데이터 설명에는 있으나 **현재 Controller에 없음**. 추가 일정·인증 방식 확인 | -| Content-Type | Controller `application/x-hwp` vs 설명 `application/haansofthwpx` | -| 파일 확장자 | 다운로드 파일명이 `.hwp`로 고정. 실제 바이너리가 hwpx일 수 있음 | -| 폴링 주기 | 5초 vs 10초, 탭 비활성 시 중단 여부 | -| 검증 필드 타입 | `FnrsCnsltnMngDetResponseDto.esntlFileList` / `etcFileList` Java 타입이 아직 `FnrsCnsltnAtflResponseDto`가 아님. 런타임 JSON에 `vrfc*`가 오는지 확인 | -| `trsmFileList` | 데이터 설명 상세 예시에는 있으나 Det DTO Java 선언과 불일치 가능 | -| `crtSttsComCd` 상위코드 | `CRTSTTS` vs `AICRTSTTS` | -| 웹기안기 라이선스 URL | `HwpCtrl.Open`에 넘길 게이트웨이 절대 경로 (쿠키 인증 가능 여부) | -| 초안 생성 1회 제한 | `requestDraftGeneration` 주석에 “1회 제한”. 재클릭 UX (비활성 / 재요청 확인) | -| 자료전송 허용 확장자 | `.doc .docx .hwp .hwpx .md .pdf .txt .xls .xlsm .xlsx`. 이미지 첨부 시 DC RAG 실패 | - ---- - -## 11. 참고 - -| 자료 | 위치 | -| ---------------------- | ------------------------------------------------------------------------------ | -| 생성결과 컨트롤러 | `backend/.../pwrkcnsltndoccrtrslt/PwrkCnsltnDocCrtRsltController.java` | -| 실무협의관리 컨트롤러 | `backend/.../pwrkcnsltnmng/PwrkCnsltnMngController.java` | -| DC RAG TO 컨트롤러 | `backend/.../dcrag/DcRagApiToController.java` | -| 초안생성 데이터 설명 | `02.03.초안생성_데이터_설명.md` | -| 엔티티 구현 스킬 | `frontend/.cursor/skills/backend-controller-to-entity/` | -| 재원협의 엔티티 (패턴) | `apps/as/src/entities/fnrs-cnsltn/` | -| IIPBS 단어목록 | `frontend/docs/IIPBS_단어목록.md` | -| 진행상태 코드 | `backend/IIPBS_CS/docs/2026/05.행정지원/01.문서상태별_사용자_상태변경_이력.md` | From dbea497cc5370d7a2ba8bde651f18f17850c5360 Mon Sep 17 00:00:00 2001 From: bicochan Date: Mon, 7 Sep 2026 15:24:06 +0900 Subject: [PATCH 03/10] =?UTF-8?q?[as]=20feat:=20=EC=9B=B9=ED=95=9C?= =?UTF-8?q?=EA=B8=80=20=EA=B8=B0=EC=95=88=EA=B8=B0=20=EB=8D=B0=EB=AA=A8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(with-base-layout)/hangulgiangi/page.tsx | 3 + apps/as/next.config.ts | 10 + apps/as/public/hangulgiangi/demo/결재_표.hwpx | Bin 0 -> 14931 bytes .../as/public/hangulgiangi/demo/결재_표2.hwpx | Bin 0 -> 15445 bytes apps/as/public/hangulgiangi/demo/보고서.hwpx | Bin 0 -> 24434 bytes apps/as/public/webhwpctrl/hwpsdkmain.html | 41 +++ apps/as/public/webhwpctrl/index.html | 65 ++++ apps/as/src/pages/hangulgiangi/index.ts | 1 + .../ui/hangul-giangi-preview-page.tsx | 32 ++ apps/as/src/shared/config/env.ts | 4 + apps/as/src/widgets/hangulgiangi/index.ts | 23 ++ .../hangulgiangi/lib/fetch-demo-blob.ts | 21 ++ .../widgets/hangulgiangi/lib/get-hwp-ctrl.ts | 29 ++ .../lib/hangul-giangi-constants.ts | 25 ++ .../widgets/hangulgiangi/model/hwp-ctrl.ts | 58 ++++ .../hangulgiangi/ui/hangul-giangi-toolbar.tsx | 116 +++++++ .../widgets/hangulgiangi/ui/hangul-giangi.tsx | 321 ++++++++++++++++++ 17 files changed, 749 insertions(+) create mode 100644 apps/as/app/(main)/(with-base-layout)/hangulgiangi/page.tsx create mode 100644 apps/as/public/hangulgiangi/demo/결재_표.hwpx create mode 100644 apps/as/public/hangulgiangi/demo/결재_표2.hwpx create mode 100644 apps/as/public/hangulgiangi/demo/보고서.hwpx create mode 100644 apps/as/public/webhwpctrl/hwpsdkmain.html create mode 100644 apps/as/public/webhwpctrl/index.html create mode 100644 apps/as/src/pages/hangulgiangi/index.ts create mode 100644 apps/as/src/pages/hangulgiangi/ui/hangul-giangi-preview-page.tsx create mode 100644 apps/as/src/widgets/hangulgiangi/index.ts create mode 100644 apps/as/src/widgets/hangulgiangi/lib/fetch-demo-blob.ts create mode 100644 apps/as/src/widgets/hangulgiangi/lib/get-hwp-ctrl.ts create mode 100644 apps/as/src/widgets/hangulgiangi/lib/hangul-giangi-constants.ts create mode 100644 apps/as/src/widgets/hangulgiangi/model/hwp-ctrl.ts create mode 100644 apps/as/src/widgets/hangulgiangi/ui/hangul-giangi-toolbar.tsx create mode 100644 apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx diff --git a/apps/as/app/(main)/(with-base-layout)/hangulgiangi/page.tsx b/apps/as/app/(main)/(with-base-layout)/hangulgiangi/page.tsx new file mode 100644 index 000000000..95c057a58 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/hangulgiangi/page.tsx @@ -0,0 +1,3 @@ +import { HangulGiangiPreviewPage } from "@/pages/hangulgiangi"; + +export default HangulGiangiPreviewPage; diff --git a/apps/as/next.config.ts b/apps/as/next.config.ts index 606194380..e000661fb 100644 --- a/apps/as/next.config.ts +++ b/apps/as/next.config.ts @@ -2,6 +2,12 @@ import type { NextConfig } from "next"; const subDomain = process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000"; +const webhwpCtrlBase = ( + process.env.NEXT_PUBLIC_WEBHYP_CTRL_URL || "http://hwp.onasys.co.kr/webhwpctrl" +).replace(/\/$/, ""); + +const webhwpAssetPrefixes = ["js", "hwpsdk", "dist", "commonFrame", "wasm"] as const; + const nextConfig: NextConfig = { output: "standalone", reactStrictMode: true, @@ -30,6 +36,10 @@ const nextConfig: NextConfig = { source: "/cms/:path*", destination: `${subDomain}/cms/:path*`, }, + ...webhwpAssetPrefixes.map((prefix) => ({ + source: `/webhwpctrl/${prefix}/:path*`, + destination: `${webhwpCtrlBase}/${prefix}/:path*`, + })), ], fallback: [], }; diff --git a/apps/as/public/hangulgiangi/demo/결재_표.hwpx b/apps/as/public/hangulgiangi/demo/결재_표.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..3ace9b97ccffc954cac0b2c89d9b8658a5981447 GIT binary patch literal 14931 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 literal 0 HcmV?d00001 diff --git a/apps/as/public/hangulgiangi/demo/보고서.hwpx b/apps/as/public/hangulgiangi/demo/보고서.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..d6617699e6d0fc3e5190ca343b74ad3a0aecd09a GIT binary patch literal 24434 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 Jh1edv{VxWkH|YQX literal 0 HcmV?d00001 diff --git a/apps/as/public/webhwpctrl/hwpsdkmain.html b/apps/as/public/webhwpctrl/hwpsdkmain.html new file mode 100644 index 000000000..d24c7eee7 --- /dev/null +++ b/apps/as/public/webhwpctrl/hwpsdkmain.html @@ -0,0 +1,41 @@ + + + + + + + + Hwp SDK Wasm Sample + + + + + + + + +
+
+
+ 구성 중 ... 0% + +
+
+ +
+
+ + + + + + diff --git a/apps/as/public/webhwpctrl/index.html b/apps/as/public/webhwpctrl/index.html new file mode 100644 index 000000000..8dc0fc313 --- /dev/null +++ b/apps/as/public/webhwpctrl/index.html @@ -0,0 +1,65 @@ + + + + + + + 웹한글 기안기 브리지 + + + + + + +
+ + + diff --git a/apps/as/src/pages/hangulgiangi/index.ts b/apps/as/src/pages/hangulgiangi/index.ts new file mode 100644 index 000000000..f66bdbbb3 --- /dev/null +++ b/apps/as/src/pages/hangulgiangi/index.ts @@ -0,0 +1 @@ +export { HangulGiangiPreviewPage } from "./ui/hangul-giangi-preview-page"; diff --git a/apps/as/src/pages/hangulgiangi/ui/hangul-giangi-preview-page.tsx b/apps/as/src/pages/hangulgiangi/ui/hangul-giangi-preview-page.tsx new file mode 100644 index 000000000..0989b99ee --- /dev/null +++ b/apps/as/src/pages/hangulgiangi/ui/hangul-giangi-preview-page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { HangulGiangi } from "@/widgets/hangulgiangi"; + +function HangulGiangiPreviewPage() { + return ( +
+
+

웹한글 기안기 미리보기

+

+ 샘플 툴바·BuildWebHwpCtrl 브리지 연동 검증용 페이지입니다. 메뉴에는 등록하지 않습니다. +

+

+ Demo 파일( + 보고서.hwpx,{" "} + 결재_표.hwpx 등)은{" "} + public/hangulgiangi/demo/에 배치하세요. +

+

+ iframe은 로컬{" "} + /webhwpctrl/index.html 브리지를 로드하고,{" "} + BuildWebHwpCtrl로 같은 origin에서 HwpCtrl을 + 노출합니다. 초기화 스크립트는{" "} + hwp.onasys.co.kr/webhwpctrl에서 가져옵니다. +

+
+ +
+ ); +} + +export { HangulGiangiPreviewPage }; diff --git a/apps/as/src/shared/config/env.ts b/apps/as/src/shared/config/env.ts index f06c95d2b..542d546ad 100644 --- a/apps/as/src/shared/config/env.ts +++ b/apps/as/src/shared/config/env.ts @@ -10,3 +10,7 @@ export const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-t export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN; export const AS_DASHBOARD_BBS_ID = process.env.NEXT_PUBLIC_AS_DASHBOARD_BBS_ID; + +/** 웹한글 기안기(WebHWP) 컨트롤 URL */ +export const WEBHYP_CTRL_URL = + process.env.NEXT_PUBLIC_WEBHYP_CTRL_URL || "http://hwp.onasys.co.kr/webhwpctrl"; diff --git a/apps/as/src/widgets/hangulgiangi/index.ts b/apps/as/src/widgets/hangulgiangi/index.ts new file mode 100644 index 000000000..e61f86007 --- /dev/null +++ b/apps/as/src/widgets/hangulgiangi/index.ts @@ -0,0 +1,23 @@ +export { getHwpCtrl } from "./lib/get-hwp-ctrl"; +export { + APPROVAL_FIELD_NAME, + DEFAULT_BRIDGE_URL, + DEFAULT_CTRL_URL, + DEFAULT_DEMO_BASE_URL, + DEFAULT_IFRAME_HEIGHT_CLASS, + DEMO_FILES, + HANGULGIANGI_READY_MESSAGE_TYPE, + HWP_CTRL_IFRAME_NAME, + SIGN_FIELD_NAME, +} from "./lib/hangul-giangi-constants"; +export type { + HwpCtrl, + HwpCtrlCallback, + HwpCtrlCallbackResult, + HwpCtrlFrameWindow, +} from "./model/hwp-ctrl"; +export { HangulGiangi, type HangulGiangiProps } from "./ui/hangul-giangi"; +export { + HangulGiangiToolbar, + type HangulGiangiToolbarProps, +} from "./ui/hangul-giangi-toolbar"; diff --git a/apps/as/src/widgets/hangulgiangi/lib/fetch-demo-blob.ts b/apps/as/src/widgets/hangulgiangi/lib/fetch-demo-blob.ts new file mode 100644 index 000000000..3ccbb16b8 --- /dev/null +++ b/apps/as/src/widgets/hangulgiangi/lib/fetch-demo-blob.ts @@ -0,0 +1,21 @@ +/** + * Demo 정적 파일을 부모 origin에서 fetch해 Blob으로 반환한다. + * iframe origin에서 URL을 직접 Open하면 CORS로 실패할 수 있어 Blob Open을 우선한다. + */ +async function fetchDemoBlob(demoBaseUrl: string, fileName: string): Promise { + const base = demoBaseUrl.endsWith("/") ? demoBaseUrl : `${demoBaseUrl}/`; + const url = `${base}${encodeURIComponent(fileName)}`; + const response = await fetch(url); + + if (!response.ok) + throw new Error(`Demo 파일을 불러오지 못했습니다: ${fileName} (${response.status})`); + + return response.blob(); +} + +function getFormatFromFileName(fileName: string): string { + const ext = fileName.split(".").pop()?.toUpperCase(); + return ext ?? ""; +} + +export { fetchDemoBlob, getFormatFromFileName }; diff --git a/apps/as/src/widgets/hangulgiangi/lib/get-hwp-ctrl.ts b/apps/as/src/widgets/hangulgiangi/lib/get-hwp-ctrl.ts new file mode 100644 index 000000000..58757ee70 --- /dev/null +++ b/apps/as/src/widgets/hangulgiangi/lib/get-hwp-ctrl.ts @@ -0,0 +1,29 @@ +import type { HwpCtrl, HwpCtrlFrameWindow } from "../model/hwp-ctrl"; + +import { HWP_CTRL_IFRAME_NAME } from "./hangul-giangi-constants"; + +interface WindowWithNamedHwpFrame extends Window { + Document_HwpCtrl?: Window & HwpCtrlFrameWindow; +} + +/** + * iframe ref 우선, 없으면 named frame 폴백으로 HwpCtrl을 반환한다. + * 로컬 `/webhwpctrl` 브리지는 same-origin이라 contentWindow 접근이 가능하다. + */ +function getHwpCtrl(iframe: HTMLIFrameElement | null): HwpCtrl | null { + if (typeof window === "undefined") return null; + + try { + const fromRef = iframe?.contentWindow as HwpCtrlFrameWindow | null | undefined; + if (fromRef?.HwpCtrl) return fromRef.HwpCtrl; + + const namedFrame = (window as WindowWithNamedHwpFrame)[HWP_CTRL_IFRAME_NAME]; + if (namedFrame?.HwpCtrl) return namedFrame.HwpCtrl; + + return null; + } catch { + return null; + } +} + +export { getHwpCtrl }; diff --git a/apps/as/src/widgets/hangulgiangi/lib/hangul-giangi-constants.ts b/apps/as/src/widgets/hangulgiangi/lib/hangul-giangi-constants.ts new file mode 100644 index 000000000..15a330f81 --- /dev/null +++ b/apps/as/src/widgets/hangulgiangi/lib/hangul-giangi-constants.ts @@ -0,0 +1,25 @@ +import { WEBHYP_CTRL_URL } from "@/shared/config"; + +/** iframe name — 한컴 named-iframe / 브리지 contentWindow 접근용 */ +export const HWP_CTRL_IFRAME_NAME = "Document_HwpCtrl"; + +/** 로컬 BuildWebHwpCtrl 브리지 (same-origin) */ +export const DEFAULT_BRIDGE_URL = "/webhwpctrl/index.html"; + +/** 기안기 서버 base URL (브리지 BuildWebHwpCtrl baseurl / rewrite destination) */ +export const DEFAULT_CTRL_URL = WEBHYP_CTRL_URL; + +/** Demo 정적 파일 base path (`apps/as/public/hangulgiangi/demo/`) */ +export const DEFAULT_DEMO_BASE_URL = "/hangulgiangi/demo/"; + +export const DEMO_FILES = { + report: "보고서.hwpx", + approvalTable1: "결재_표.hwpx", + approvalTable2: "결재_표2.hwpx", + signImage: "sign.png", +} as const; + +export const APPROVAL_FIELD_NAME = "결재영역"; +export const SIGN_FIELD_NAME = "결재1"; + +export const HANGULGIANGI_READY_MESSAGE_TYPE = "hangulgiangi-ready"; diff --git a/apps/as/src/widgets/hangulgiangi/model/hwp-ctrl.ts b/apps/as/src/widgets/hangulgiangi/model/hwp-ctrl.ts new file mode 100644 index 000000000..435e4fa0d --- /dev/null +++ b/apps/as/src/widgets/hangulgiangi/model/hwp-ctrl.ts @@ -0,0 +1,58 @@ +/** 한컴 웹한글 기안기 HwpCtrl 콜백 결과 (문서화되지 않은 필드 허용) */ +export interface HwpCtrlCallbackResult { + result?: boolean | string | number; + message?: string; + [key: string]: unknown; +} + +export type HwpCtrlCallback = (res: HwpCtrlCallbackResult) => void; + +/** + * 샘플(v3_sample)에서 사용하는 HwpCtrl 메서드만 선언. + * 실제 런타임 객체는 iframe contentWindow에 존재한다. + */ +export interface HwpCtrl { + Open: ( + source: string | Blob | File, + format: string, + option: string, + callback?: HwpCtrlCallback, + extra?: Record, + ) => void; + Insert: ( + source: string | Blob | File, + format: string, + option: string, + callback?: HwpCtrlCallback, + ) => void; + InsertPicture: ( + source: string | Blob | File, + embedded: boolean, + sizeOption: number, + reverse: boolean, + watermark: boolean, + effect: number, + width: number, + height: number, + callback?: HwpCtrlCallback, + ) => void; + MoveToField: ( + fieldName: string, + text: boolean, + start: boolean, + select: boolean, + ) => void; + Run: (action: string) => void; + SaveAs: ( + fileName: string, + format: string, + option: string, + callback?: HwpCtrlCallback, + extra?: Record, + ) => void; + Clear: () => void; +} + +export interface HwpCtrlFrameWindow { + HwpCtrl?: HwpCtrl; +} diff --git a/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi-toolbar.tsx b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi-toolbar.tsx new file mode 100644 index 000000000..a28836a02 --- /dev/null +++ b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi-toolbar.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { Button, cn } from "@common/components"; + +interface HangulGiangiToolbarProps { + isApprovalMenuOpen: boolean; + isBusy?: boolean; + onOpen: () => void; + onToggleApprovalMenu: () => void; + onCloseApprovalMenu: () => void; + onApproval1: () => void; + onApproval2: () => void; + onSign: () => void; + onSave: () => void; + onClear: () => void; + onPickLocalFile: () => void; +} + +function HangulGiangiToolbar({ + isApprovalMenuOpen, + isBusy = false, + onOpen, + onToggleApprovalMenu, + onCloseApprovalMenu, + onApproval1, + onApproval2, + onSign, + onSave, + onClear, + onPickLocalFile, +}: HangulGiangiToolbarProps) { + const approvalMenuRef = useRef(null); + + useEffect(() => { + if (!isApprovalMenuOpen) return; + + function handlePointerDown(event: MouseEvent) { + const target = event.target as Node; + if (approvalMenuRef.current?.contains(target)) return; + onCloseApprovalMenu(); + } + + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, [isApprovalMenuOpen, onCloseApprovalMenu]); + + return ( +
+ + +
+ +
+ + +
+
+ + + + + +
+ ); +} + +export { HangulGiangiToolbar }; +export type { HangulGiangiToolbarProps }; diff --git a/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx new file mode 100644 index 000000000..7f2a7516e --- /dev/null +++ b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx @@ -0,0 +1,321 @@ +"use client"; + +import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { WEBHYP_CTRL_URL } from "@/shared/config"; +import { cn } from "@common/components"; + +import { fetchDemoBlob } from "../lib/fetch-demo-blob"; +import { getHwpCtrl } from "../lib/get-hwp-ctrl"; +import { + APPROVAL_FIELD_NAME, + DEFAULT_BRIDGE_URL, + DEFAULT_DEMO_BASE_URL, + DEMO_FILES, + HANGULGIANGI_READY_MESSAGE_TYPE, + HWP_CTRL_IFRAME_NAME, + SIGN_FIELD_NAME, +} from "../lib/hangul-giangi-constants"; +import type { HwpCtrl } from "../model/hwp-ctrl"; + +import { HangulGiangiToolbar } from "./hangul-giangi-toolbar"; + +interface HangulGiangiProps { + /** 로컬 BuildWebHwpCtrl 브리지 URL (기본 `/webhwpctrl/index.html`) */ + bridgeUrl?: string; + /** @deprecated bridgeUrl 사용. 하위 호환용 alias */ + ctrlUrl?: string; + /** 기안기 서버 base URL (브리지 `?baseurl=` 로 전달) */ + ctrlBaseUrl?: string; + demoBaseUrl?: string; + className?: string; + onReady?: () => void; +} + +function resolveHwpCtrl(iframe: HTMLIFrameElement | null, isCtrlReady: boolean): HwpCtrl | null { + const ctrl = getHwpCtrl(iframe); + if (!ctrl) { + alert( + isCtrlReady + ? "웹한글 기안기 컨트롤을 찾지 못했습니다. 브리지 페이지를 확인하세요." + : "웹한글 기안기가 아직 초기화되지 않았습니다. 잠시 후 다시 시도하세요.", + ); + return null; + } + return ctrl; +} + +function buildBridgeSrc(bridgeUrl: string, ctrlBaseUrl: string): string { + const base = ctrlBaseUrl.replace(/\/$/, ""); + const url = new URL( + bridgeUrl, + typeof window !== "undefined" ? window.location.origin : "http://localhost", + ); + url.searchParams.set("baseurl", base); + // URL()은 absolute path면 pathname만 쓰므로, 상대 경로 브리지는 pathname+search만 반환 + if (bridgeUrl.startsWith("/")) return `${url.pathname}${url.search}`; + return url.toString(); +} + +function HangulGiangi({ + bridgeUrl, + ctrlUrl, + ctrlBaseUrl = WEBHYP_CTRL_URL, + demoBaseUrl = DEFAULT_DEMO_BASE_URL, + className, + onReady, +}: HangulGiangiProps) { + const resolvedBridgeUrl = bridgeUrl ?? ctrlUrl ?? DEFAULT_BRIDGE_URL; + const iframeRef = useRef(null); + const fileInputRef = useRef(null); + + const [isCtrlReady, setIsCtrlReady] = useState(false); + const [isOpened, setIsOpened] = useState(false); + const [isApprovalAdded, setIsApprovalAdded] = useState(false); + const [isApprovalMenuOpen, setIsApprovalMenuOpen] = useState(false); + const [isBusy, setIsBusy] = useState(false); + const [statusMessage, setStatusMessage] = useState(null); + + const iframeSrc = useMemo( + () => buildBridgeSrc(resolvedBridgeUrl, ctrlBaseUrl), + [resolvedBridgeUrl, ctrlBaseUrl], + ); + + useEffect(() => { + function handleMessage(event: MessageEvent) { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== HANGULGIANGI_READY_MESSAGE_TYPE) return; + if (iframeRef.current && event.source !== iframeRef.current.contentWindow) return; + + setIsCtrlReady(true); + setStatusMessage("기안기 초기화가 완료되었습니다."); + onReady?.(); + } + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [onReady]); + + // postMessage 누락 대비: HwpCtrl 존재 poll + useEffect(() => { + if (isCtrlReady) return; + + const startedAt = Date.now(); + const timer = window.setInterval(() => { + if (getHwpCtrl(iframeRef.current)) { + setIsCtrlReady(true); + setStatusMessage("기안기 초기화가 완료되었습니다."); + onReady?.(); + window.clearInterval(timer); + return; + } + if (Date.now() - startedAt > 60_000) window.clearInterval(timer); + }, 500); + + return () => window.clearInterval(timer); + }, [isCtrlReady, onReady]); + + const handleOpen = useCallback(async () => { + const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady); + if (!ctrl) return; + + setIsBusy(true); + setStatusMessage(null); + try { + const blob = await fetchDemoBlob(demoBaseUrl, DEMO_FILES.report); + ctrl.Open(blob, "HWPX", "lock:false", (res) => { + console.log("Open 보고서.hwpx:", res); + }); + setIsOpened(true); + setIsApprovalAdded(false); + setStatusMessage("문서를 열었습니다."); + } catch (error) { + console.error(error); + alert( + error instanceof Error + ? error.message + : "문서 열기에 실패했습니다. Demo 파일 경로를 확인하세요.", + ); + } finally { + setIsBusy(false); + } + }, [demoBaseUrl, isCtrlReady]); + + const insertApprovalTable = useCallback( + async (fileName: string) => { + if (!isOpened) { + alert("먼저 '열기' 버튼을 눌러주세요."); + return; + } + + const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady); + if (!ctrl) return; + + setIsBusy(true); + setStatusMessage(null); + try { + ctrl.MoveToField(APPROVAL_FIELD_NAME, true, true, false); + ctrl.Run("SelectAll"); + ctrl.Run("Delete"); + ctrl.MoveToField(APPROVAL_FIELD_NAME, true, true, false); + + const blob = await fetchDemoBlob(demoBaseUrl, fileName); + ctrl.Insert(blob, "HWPX", "", (res) => { + console.log(`Insert ${fileName}:`, res); + }); + + setIsApprovalAdded(true); + setIsApprovalMenuOpen(false); + setStatusMessage(`${fileName} 결재 표를 삽입했습니다.`); + } catch (error) { + console.error(error); + alert( + error instanceof Error + ? error.message + : "결재 표 삽입에 실패했습니다. Demo 파일 경로를 확인하세요.", + ); + } finally { + setIsBusy(false); + } + }, + [demoBaseUrl, isCtrlReady, isOpened], + ); + + const handleSign = useCallback(async () => { + if (!isApprovalAdded) { + alert("먼저 '결재 추가' 버튼을 눌러주세요."); + return; + } + + const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady); + if (!ctrl) return; + + setIsBusy(true); + setStatusMessage(null); + try { + ctrl.MoveToField(SIGN_FIELD_NAME, true, true, false); + const blob = await fetchDemoBlob(demoBaseUrl, DEMO_FILES.signImage); + ctrl.InsertPicture(blob, true, 1, false, false, 0, 90, 30, (res) => { + console.log("Insert sign.png:", res); + }); + setStatusMessage("결재 이미지를 삽입했습니다."); + } catch (error) { + console.error(error); + alert( + error instanceof Error + ? error.message + : "결재 이미지 삽입에 실패했습니다. Demo 파일 경로를 확인하세요.", + ); + } finally { + setIsBusy(false); + } + }, [demoBaseUrl, isApprovalAdded, isCtrlReady]); + + const handleSave = useCallback(() => { + const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady); + if (!ctrl) return; + + ctrl.SaveAs( + "sample.hwp", + "HWP", + "download:true;", + (res) => { + console.log(res); + setStatusMessage("HWP 저장을 요청했습니다."); + }, + { tempUrl: typeof window !== "undefined" ? window.location.href : "" }, + ); + }, [isCtrlReady]); + + const handleClear = useCallback(() => { + const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady); + if (!ctrl) return; + + ctrl.Clear(); + setIsOpened(false); + setIsApprovalAdded(false); + setIsApprovalMenuOpen(false); + setStatusMessage("문서를 클리어했습니다."); + }, [isCtrlReady]); + + const handleLocalFileChange = useCallback( + (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady); + if (!ctrl) { + event.target.value = ""; + return; + } + + const blobData = new Blob([file], { type: file.type }); + ctrl.Open(blobData, "", "lock:false", (res) => { + ctrl.MoveToField(file.name, true, true, true); + console.log(res); + }); + + setIsOpened(true); + setIsApprovalAdded(false); + setStatusMessage(`${file.name} 파일을 열었습니다.`); + event.target.value = ""; + }, + [isCtrlReady], + ); + + const isToolbarBusy = isBusy || !isCtrlReady; + + return ( +
+ setIsApprovalMenuOpen((prev) => !prev)} + onCloseApprovalMenu={() => setIsApprovalMenuOpen(false)} + onApproval1={() => void insertApprovalTable(DEMO_FILES.approvalTable1)} + onApproval2={() => void insertApprovalTable(DEMO_FILES.approvalTable2)} + onSign={() => void handleSign()} + onSave={handleSave} + onClear={handleClear} + onPickLocalFile={() => fileInputRef.current?.click()} + /> + + {statusMessage ? ( +

+ {statusMessage} +

+ ) : !isCtrlReady ? ( +

+ 기안기 초기화 중… +

+ ) : null} + +