dev #865

Merged
ysCha merged 16 commits from dev into prd-deploy 2026-05-18 17:00:30 +09:00
4 changed files with 238 additions and 0 deletions
Showing only changes of commit e74604d3f9 - Show all commits

View File

@ -7,6 +7,7 @@ import * as turf from '@turf/turf'
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import Big from 'big.js'
import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils'
import { logger } from '@/util/logger'
// ========================================================================
// [DEBUG] 캔버스 라인 라벨 — 로컬 전용 (NEXT_PUBLIC_RUN_MODE === 'local')
@ -134,6 +135,33 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
points = snapNearAxisEdges(points, 2)
}
// [PLAN-CORRUPT-COORD 2026-05-18] 손상 plan 로드 가드 — NaN 좌표가 JSON.stringify 로 null 직렬화되어
// DB 에 박힌 케이스(예: S241X135260518004 의 module/lines 60+ 좌표 null) 에서
// toFixed throw 로 페이지 전체 크래시되는 문제 차단. null/NaN/non-finite 좌표는 0 으로 치환 +
// __corrupted 플래그 설정 → useModuleBasicSetting 의 0-dim batch object skip 가드와 연쇄해 cascade 차단.
let __corruptedCoord = false
if (Array.isArray(points)) {
points.forEach((point) => {
if (!point) return
if (point.x == null || !Number.isFinite(Number(point.x))) {
point.x = 0
__corruptedCoord = true
}
if (point.y == null || !Number.isFinite(Number(point.y))) {
point.y = 0
__corruptedCoord = true
}
})
}
if (__corruptedCoord) {
this.__corrupted = true
logger.warn('[PLAN-CORRUPT-COORD 2026-05-18] corrupted polygon coords substituted with 0', {
id: options.id,
name: options.name,
parentId: options.parentId,
})
}
// 소수점 전부 제거
points.forEach((point) => {
point.x = Number(point.x.toFixed(this.toFixed))

View File

@ -8,6 +8,7 @@ import QContextMenu from '@/components/common/context-menu/QContextMenu'
import PanelBatchStatistics from '@/components/floor-plan/modal/panelBatch/PanelBatchStatistics'
import ImgLoad from '@/components/floor-plan/modal/ImgLoad'
import { useCanvas } from '@/hooks/useCanvas'
import { useModule } from '@/hooks/module/useModule'
import { usePlan } from '@/hooks/usePlan'
import { useContextMenu } from '@/hooks/useContextMenu'
import { useCanvasConfigInitialize } from '@/hooks/common/useCanvasConfigInitialize'
@ -60,6 +61,7 @@ export default function CanvasFrame() {
const { basicSetting, fetchBasicSettings } = useCanvasSetting()
const { selectedMenu, setSelectedMenu } = useCanvasMenu()
const { initEvent } = useEvent()
const { restoreCorruptedModules } = useModule()
const canvasSetting = useRecoilValue(canvasSettingState)
const loadCanvas = () => {
if (!canvas) return
@ -109,6 +111,11 @@ export default function CanvasFrame() {
})
initEvent()
// [PLAN-CORRUPT-RESTORE 2026-05-18] module QPolygon
// __corrupted module surface module marking.
// plan 0 (__corrupted early return).
restoreCorruptedModules()
})
} else {
setSelectedMenu(null)

View File

@ -12,6 +12,24 @@ import { selectedModuleState } from '@/store/selectedModuleOptions'
import { useCircuitTrestle } from '../useCirCuitTrestle'
import { useTrestle } from './useTrestle'
import { useTurf } from '@/hooks/common/useTurf'
import { logger } from '@/util/logger'
// [MODULE-NAN-DIAG 2026-05-18] NaN 좌표가 어디서 module 로 진입하는지 추적용.
// S241X135260518004 처럼 plan 에 좌표 null 박힌 케이스 재발 시 발견 즉시 stack trace 와 입력값 기록.
// production 빌드에선 logger 가 DCE 로 제거됨 → 개발/디버깅 환경 한정.
function __nanDiag(tag, payload) {
const flat = {}
let bad = false
for (const [k, v] of Object.entries(payload)) {
if (v == null || (typeof v === 'number' && !Number.isFinite(v))) {
bad = true
}
flat[k] = v
}
if (bad) {
logger.warn(`[MODULE-NAN-DIAG 2026-05-18] ${tag}`, flat)
}
}
export const MODULE_REMOVE_TYPE = {
LEFT: 'left',
@ -47,6 +65,17 @@ export function useModule() {
// clone() 대신 직접 새 QPolygon을 생성하여 Maximum call stack 방지
const createModuleCopy = (module, newLeft, newTop, overrides = {}) => {
// [MODULE-NAN-DIAG 2026-05-18] createModuleCopy 진입 시 입력 검증.
// 본 함수가 cascade NaN 의 가장 큰 진입점: newLeft/newTop 중 하나라도 NaN 이면
// deltaX/Y → newPoints 전부 NaN → 저장 시 null 직렬화.
__nanDiag('createModuleCopy input', {
moduleId: module?.id,
moduleLeft: module?.left,
moduleTop: module?.top,
newLeft,
newTop,
pointsLen: module?.points?.length,
})
const deltaX = newLeft - module.left
const deltaY = newTop - module.top
const newPoints = module.points.map((p) => ({
@ -1024,6 +1053,15 @@ export function useModule() {
}
const getPosotion = (target, direction, length, hasMargin = false) => {
// [MODULE-NAN-DIAG 2026-05-18] length 가 '' 또는 non-number, target.top/left 가 null 이면 NaN 반환 → createModuleCopy cascade
__nanDiag('getPosotion input', {
targetId: target?.id,
targetTop: target?.top,
targetLeft: target?.left,
direction,
length,
lengthType: typeof length,
})
let top = target.top
let left = target.left
@ -1040,6 +1078,7 @@ export function useModule() {
left = Number(target.left) + Number(length) / 10
left = hasMargin ? left + Number(target.width) : left
}
__nanDiag('getPosotion output', { targetId: target?.id, direction, length, top, left })
return { top, left }
}
@ -1091,6 +1130,130 @@ export function useModule() {
canvas.renderAll()
}
/**
* [PLAN-CORRUPT-RESTORE 2026-05-18] 손상 module 좌표 복원.
*
* QPolygon 로드 가드가 좌표 0 치환 + __corrupted 플래그 module 들을 대상으로,
* 같은 surface 정상 module 패턴(width / top row)에서 잠정 좌표 추론
* 빨강 stroke 으로 marking 사용자가 화면에서 즉시 식별하고 위치 조정/삭제 결정.
*
* 그림의 목적(지붕모듈견적사진) 보호: 자동 제거 대신 사용자에게 데이터를 돌려준다.
*
* @returns {{ restored: number, failed: number }}
*/
const restoreCorruptedModules = () => {
if (!canvas) return { restored: 0, failed: 0 }
const corrupted = canvas.getObjects().filter((obj) => obj.__corrupted && obj.name === POLYGON_TYPE.MODULE)
if (corrupted.length === 0) return { restored: 0, failed: 0 }
let restored = 0
let failed = 0
corrupted.forEach((target) => {
const siblings = canvas
.getObjects()
.filter(
(obj) =>
obj.name === POLYGON_TYPE.MODULE &&
obj.surfaceId === target.surfaceId &&
!obj.__corrupted &&
Number.isFinite(obj.width) &&
obj.width > 0 &&
Number.isFinite(obj.left),
)
if (siblings.length === 0) {
logger.warn('[PLAN-CORRUPT-RESTORE 2026-05-18] no sibling — cannot restore', {
id: target.id,
surfaceId: target.surfaceId,
top: target.top,
height: target.height,
})
failed++
return
}
// 정상 module 의 width 차용 (같은 moduleInfo 면 동일)
const refWidth = siblings[0].width
const refHeight = Number.isFinite(target.height) && target.height > 0 ? target.height : siblings[0].height
const top = Number.isFinite(target.top) ? target.top : siblings[0].top
// 같은 row 매칭 (top 차이가 높이의 절반 미만)
const sameRow = siblings.filter((s) => Math.abs(s.top - top) < refHeight / 2)
let left
if (sameRow.length > 0) {
// 같은 row 의 가장 오른쪽 module 옆 (잠정 — 사용자가 드래그로 조정)
const rightmost = sameRow.reduce((max, s) => (s.left > max.left ? s : max), sameRow[0])
left = rightmost.left + rightmost.width + 10
} else {
// row 매칭 실패 — 가장 가까운 top 의 module left 차용
const closest = siblings.reduce((nearest, s) => (Math.abs(s.top - top) < Math.abs(nearest.top - top) ? s : nearest), siblings[0])
left = closest.left
}
const points = [
{ x: left, y: top },
{ x: left + refWidth, y: top },
{ x: left + refWidth, y: top + refHeight },
{ x: left, y: top + refHeight },
]
// 새 QPolygon 으로 교체 (initialize 시 lines 재생성 + __corrupted 해제).
// id 유지로 surface.modules 등 외부 참조 호환.
const restoredModule = new QPolygon(points, {
fill: target.fill,
stroke: 'red',
strokeWidth: 2,
opacity: target.opacity,
selectable: true,
lockMovementX: false,
lockMovementY: false,
parentId: target.parentId,
surfaceId: target.surfaceId,
moduleInfo: target.moduleInfo,
direction: target.direction,
name: target.name,
left,
top,
width: refWidth,
height: refHeight,
toFixed: 2,
sort: false,
id: target.id,
})
restoredModule.__restored = true
canvas.remove(target)
canvas.add(restoredModule)
logger.warn('[PLAN-CORRUPT-RESTORE 2026-05-18] restored', {
id: target.id,
surfaceId: target.surfaceId,
left,
top,
refWidth,
refHeight,
siblingCount: siblings.length,
sameRowCount: sameRow.length,
})
restored++
})
canvas.requestRenderAll()
if (restored > 0 || failed > 0) {
swalFire({
icon: 'info',
title: '破損モジュール復元',
text: `復元: ${restored} 件 / 復元不可: ${failed}\n赤い枠線で表示されたモジュールを確認し、位置調整または削除後に保存してください。`,
})
}
return { restored, failed }
}
return {
moduleMove,
moduleMultiMove,
@ -1106,5 +1269,6 @@ export function useModule() {
moduleRoofRemove,
alignModule,
recalculateAllModulesCoords,
restoreCorruptedModules,
}
}

View File

@ -31,6 +31,7 @@ import { useCanvasMenu } from './common/useCanvasMenu'
import { QcastContext } from '@/app/QcastProvider'
import { unescapeString } from '@/util/common-utils'
import { useTrestle } from '@/hooks/module/useTrestle'
import { logger } from '@/util/logger'
// putCanvasStatus 의 in-flight 요청 — module-scope 로 두어 usePlan() 인스턴스 간 공유.
// 큰 fabric JSON 의 동시 PUT 이 SQL Server 데드락 victim 을 양산해 온 이력 때문에 직전 호출은 abort.
@ -177,6 +178,44 @@ export function usePlan(params = {}) {
* @param {boolean} saveAlert - 저장 완료 알림 표시 여부
*/
const saveCanvas = async (saveAlert = true) => {
// [PLAN-CORRUPT-COORD-SAVE-GUARD 2026-05-18] 손상 plan(예: S241X135260518004) 저장 차단형 가드.
// 그림의 목적: 지붕→모듈→견적→사진. 깨진 모듈도 사용자가 그린 데이터 → 자동 제거 금지.
// 손상 객체 발견 시 저장 차단 + swal 안내 → 사용자가 화면에서 복원/재배치/명시적 삭제 결정.
// 향후 [PLAN-CORRUPT-RESTORE] 작업으로 메타 데이터(top/height/surfaceId)에서 좌표 복원 예정.
const corruptedObjs = canvas.getObjects().filter((obj) => obj.__corrupted)
if (corruptedObjs.length > 0) {
// 자세한 객체별 진단 (top/height/fill/moduleInfo/points-sample 까지 — 복원 단서로 사용)
const detail = corruptedObjs.map((o) => ({
id: o.id,
name: o.name,
parentId: o.parentId,
surfaceId: o.surfaceId,
top: o.top,
left: o.left,
width: o.width,
height: o.height,
fill: typeof o.fill === 'string' ? o.fill : o.fill?.type,
opacity: o.opacity,
direction: o.direction,
moduleInfo: o.moduleInfo
? { itemId: o.moduleInfo.itemId, moduleTpCd: o.moduleInfo.moduleTpCd, capacity: o.moduleInfo.capacity }
: null,
pointsSample: Array.isArray(o.points) ? o.points.slice(0, 4) : null,
}))
logger.error('[PLAN-CORRUPT-COORD-SAVE-GUARD 2026-05-18] 保存失敗 — corrupted objects', {
count: corruptedObjs.length,
objectNo: currentCanvasPlan?.objectNo,
planNo: currentCanvasPlan?.planNo,
detail,
})
swalFire({
icon: 'error',
title: '保存に失敗しました',
text: `破損したモジュール ${corruptedObjs.length} 件が含まれています。\n再配置または明示的に削除してから再度保存してください。\n(詳細はコンソールログをご確認ください)`,
})
return
}
// [ROOF-MATERIAL-SAVE-GUARD 2026-05-08] 면(roof) 에 시각적 지붕재 패턴은 칠해져 있는데
// 데이터(roofMaterial) 가 비어있는 "불일치" 케이스만 저장 차단.
// - fill 비어있고 roofMaterial 도 없음 → 미선택 상태, 저장 허용