diff --git a/.scannerwork/.sonar_lock b/.scannerwork/.sonar_lock new file mode 100644 index 00000000..e69de29b diff --git a/.scannerwork/report-task.txt b/.scannerwork/report-task.txt new file mode 100644 index 00000000..363424f2 --- /dev/null +++ b/.scannerwork/report-task.txt @@ -0,0 +1,6 @@ +projectKey=vexplor +serverUrl=http://localhost:9000 +serverVersion=26.3.0.120487 +dashboardUrl=http://localhost:9000/dashboard?id=vexplor +ceTaskId=f2c72369-4d50-4483-bf76-b03788385757 +ceTaskUrl=http://localhost:9000/api/ce/task?id=f2c72369-4d50-4483-bf76-b03788385757 diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index b4b942a0..7e77974c 100644 --- a/backend-node/src/controllers/outboundController.ts +++ b/backend-node/src/controllers/outboundController.ts @@ -477,8 +477,21 @@ export async function getItems(req: AuthenticatedRequest, res: Response) { export async function generateNumber(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; - const pool = getPool(); + const ruleId = (req.query.ruleId as string) || (req.query.rule_id as string); + // 1순위: POP 화면설정에서 선택한 채번규칙 사용 + if (ruleId && ruleId !== "__none__") { + try { + const { numberingRuleService } = await import("../services/numberingRuleService"); + const newNumber = await numberingRuleService.allocateCode(ruleId, companyCode); + return res.json({ success: true, data: newNumber }); + } catch (e: any) { + logger.warn("선택한 채번규칙 사용 실패, 기본 채번으로 폴백", { ruleId, error: e.message }); + } + } + + // 2순위: 기본 하드코딩 채번 (OUT-YYYY-XXXX) + const pool = getPool(); const today = new Date(); const yyyy = today.getFullYear(); const prefix = `OUT-${yyyy}-`; diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index 8f9c9863..fb358a06 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -881,8 +881,22 @@ export async function getItems(req: AuthenticatedRequest, res: Response) { export async function generateNumber(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; - const pool = getPool(); + const ruleId = (req.query.ruleId as string) || (req.query.rule_id as string); + // 1순위: POP 화면설정에서 선택한 채번규칙 사용 + if (ruleId && ruleId !== "__none__") { + try { + const { numberingRuleService } = await import("../services/numberingRuleService"); + const newNumber = await numberingRuleService.allocateCode(ruleId, companyCode); + return res.json({ success: true, data: newNumber }); + } catch (e: any) { + logger.warn("선택한 채번규칙 사용 실패, 기본 채번으로 폴백", { ruleId, error: e.message }); + // 폴백 + } + } + + // 2순위: 기본 하드코딩 채번 (RCV-YYYY-XXXX) + const pool = getPool(); const today = new Date(); const yyyy = today.getFullYear(); const prefix = `RCV-${yyyy}-`; diff --git a/frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx b/frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx index b0d0b9be..619c3675 100644 --- a/frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx +++ b/frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx @@ -121,6 +121,7 @@ const SCREEN_GROUPS: ScreenGroup[] = [ screens: [ { id: "sales-outbound", name: "판매출고", url: "/pop/outbound/sales", settingsKey: "outbound", screenId: 5 }, { id: "outbound-type", name: "출고유형선택", url: "/pop/outbound", settingsKey: "outbound", screenId: 6 }, + { id: "outbound-cart", name: "출고 장바구니", url: "/pop/outbound/cart", settingsKey: "outbound", screenId: 7010 }, ], }, { @@ -165,61 +166,65 @@ interface SettingField { key: string; label: string; description: string; - type: "toggle" | "text" | "number" | "select" | "color" | "tags" | "array-object"; + type: "toggle" | "text" | "number" | "select" | "color" | "tags" | "array-object" | "numbering-rule"; defaultValue?: unknown; options?: { value: string; label: string }[]; fields?: { key: string; label: string; type: string }[]; + tableFilter?: string; // numbering-rule용: inbound/outbound 등 + showOnlyForScreens?: string[]; // 특정 화면 ID에서만 표시 (예: ["inbound-cart"]) } const SETTINGS_SCHEMA: Record = { inbound: [ - { key: "barcodeEnabled", label: "바코드 스캔", description: "바코드/QR 스캔 기능을 사용합니다", type: "toggle" }, - { key: "inspectionRequired", label: "검사 필수", description: "입고 시 검사 항목을 필수로 표시합니다", type: "toggle" }, - { key: "photoUpload", label: "사진 첨부", description: "입고 확정 시 사진 첨부를 허용합니다", type: "toggle" }, - { key: "packagingRecord", label: "포장 기록", description: "포장/적재 상세 기록을 사용합니다", type: "toggle" }, - { key: "defectSeparation", label: "불량 분리", description: "양품/불량 수량을 분리 입력합니다", type: "toggle" }, + { key: "numberingRuleId", label: "📋 입고번호 채번규칙", description: "입고 확정 시 사용할 채번규칙을 선택합니다", type: "numbering-rule", tableFilter: "inbound", showOnlyForScreens: ["inbound-cart"] }, + { key: "barcodeEnabled", label: "바코드 스캔 (미구현)", description: "바코드/QR 스캔 기능을 사용합니다", type: "toggle" }, + { key: "inspectionRequired", label: "검사 필수 (미구현)", description: "입고 시 검사 항목을 필수로 표시합니다", type: "toggle" }, + { key: "photoUpload", label: "사진 첨부 (미구현)", description: "입고 확정 시 사진 첨부를 허용합니다", type: "toggle" }, + { key: "packagingRecord", label: "포장 기록 (미구현)", description: "포장/적재 상세 기록을 사용합니다", type: "toggle" }, + { key: "defectSeparation", label: "불량 분리 (미구현)", description: "양품/불량 수량을 분리 입력합니다", type: "toggle" }, ], outbound: [ - { key: "barcodeEnabled", label: "바코드 스캔", description: "바코드/QR 스캔 기능을 사용합니다", type: "toggle" }, - { key: "photoUpload", label: "사진 첨부", description: "출고 시 사진 첨부를 허용합니다", type: "toggle" }, + { key: "numberingRuleId", label: "📋 출고번호 채번규칙", description: "출고 확정 시 사용할 채번규칙을 선택합니다", type: "numbering-rule", tableFilter: "outbound", showOnlyForScreens: ["outbound-cart"] }, + { key: "barcodeEnabled", label: "바코드 스캔 (미구현)", description: "바코드/QR 스캔 기능을 사용합니다", type: "toggle" }, + { key: "photoUpload", label: "사진 첨부 (미구현)", description: "출고 시 사진 첨부를 허용합니다", type: "toggle" }, ], processExecution: [ { key: "materialInput", label: "자재 투입", description: "BOM 기반 자재 투입 탭을 표시합니다", type: "toggle" }, - { key: "bomFlexible", label: "BOM 유동 투입", description: "기준과 다른 수량 투입을 허용합니다", type: "toggle" }, - { key: "photoUpload", label: "사진 첨부", description: "실적 입력 시 사진 첨부를 허용합니다", type: "toggle" }, + { key: "bomFlexible", label: "BOM 유동 투입 (미구현)", description: "기준과 다른 수량 투입을 허용합니다", type: "toggle" }, + { key: "photoUpload", label: "사진 첨부 (미구현)", description: "실적 입력 시 사진 첨부를 허용합니다", type: "toggle" }, { key: "groupPhotoEnabled", label: "그룹별 사진", description: "체크리스트 그룹마다 사진을 첨부합니다", type: "toggle" }, - { key: "plcEnabled", label: "PLC 연동", description: "설비 PLC 데이터를 자동 연동합니다", type: "toggle" }, - { key: "reworkTargetSelection", label: "재작업 공정 지정", description: "불량 처리 시 특정 공정을 선택할 수 있습니다", type: "toggle" }, - { key: "dateFilter", label: "날짜 필터", description: "작업지시 목록에 날짜 필터를 표시합니다", type: "toggle" }, + { key: "plcEnabled", label: "PLC 연동 (미구현)", description: "설비 PLC 데이터를 자동 연동합니다", type: "toggle" }, + { key: "reworkTargetSelection", label: "재작업 공정 지정 (미구현)", description: "불량 처리 시 특정 공정을 선택할 수 있습니다", type: "toggle" }, + { key: "dateFilter", label: "날짜 필터 (미구현)", description: "작업지시 목록에 날짜 필터를 표시합니다", type: "toggle" }, { - key: "lastProcessInventory", label: "마지막 공정 입고", description: "마지막 공정 완료 시 재고 입고 방식", type: "select", options: [ + key: "lastProcessInventory", label: "마지막 공정 입고 (미구현)", description: "마지막 공정 완료 시 재고 입고 방식", type: "select", options: [ { value: "auto", label: "자동 입고" }, { value: "manual", label: "수동 선택" }, { value: "button", label: "버튼 활성화" }, ], }, - { key: "defaultWarehouse", label: "기본 창고 기억", description: "선택한 창고를 다음에도 자동 선택합니다", type: "toggle" }, + { key: "defaultWarehouse", label: "기본 창고 기억 (미구현)", description: "선택한 창고를 다음에도 자동 선택합니다", type: "toggle" }, { - key: "inspectionAutoJudge", label: "검사 자동 판정", description: "수치 검사 시 상/하한 초과 처리 방식", type: "select", options: [ + key: "inspectionAutoJudge", label: "검사 자동 판정 (미구현)", description: "수치 검사 시 상/하한 초과 처리 방식", type: "select", options: [ { value: "off", label: "사용 안 함" }, { value: "warn", label: "경고만 표시" }, { value: "fail", label: "자동 불량" }, ], }, - { key: "standardTimeDisplay", label: "표준시간 비교", description: "표준시간 대비 실제시간을 표시합니다", type: "toggle" }, - { key: "progressDisplay", label: "진행률 표시", description: "작업지시 전체 진행률을 표시합니다", type: "toggle" }, - { key: "packagingOptions", label: "포장 옵션", description: "포장 단위 선택지를 관리합니다", type: "tags" }, - { key: "defectTypes", label: "불량 유형", description: "불량 유형 선택지를 관리합니다", type: "tags" }, + { key: "standardTimeDisplay", label: "표준시간 비교 (미구현)", description: "표준시간 대비 실제시간을 표시합니다", type: "toggle" }, + { key: "progressDisplay", label: "진행률 표시 (미구현)", description: "작업지시 전체 진행률을 표시합니다", type: "toggle" }, + { key: "packagingOptions", label: "포장 옵션 (미구현)", description: "포장 단위 선택지를 관리합니다", type: "tags" }, + { key: "defectTypes", label: "불량 유형 (미구현)", description: "불량 유형 선택지를 관리합니다", type: "tags" }, ], home: [ - { key: "kpiCarousel", label: "KPI 캐러셀", description: "오늘의 현황 캐러셀을 표시합니다", type: "toggle" }, - { key: "recentActivity", label: "최근 활동", description: "최근 입출고 활동을 표시합니다", type: "toggle" }, + { key: "kpiCarousel", label: "KPI 캐러셀 (미구현)", description: "오늘의 현황 캐러셀을 표시합니다", type: "toggle" }, + { key: "recentActivity", label: "최근 활동 (미구현)", description: "최근 입출고 활동을 표시합니다", type: "toggle" }, { key: "bannerEnabled", label: "공지 배너", description: "상단에 공지 배너를 표시합니다", type: "toggle" }, { key: "bannerText", label: "배너 텍스트", description: "공지 배너에 표시할 텍스트", type: "text" }, - { key: "iconThemeColor", label: "아이콘 테마색", description: "메뉴 아이콘의 테마 색상", type: "color" }, - { key: "iconCustomImages", label: "아이콘 커스텀", description: "메뉴 아이콘 이미지를 커스터마이즈합니다", type: "toggle" }, + { key: "iconThemeColor", label: "아이콘 테마색 (미구현)", description: "메뉴 아이콘의 테마 색상", type: "color" }, + { key: "iconCustomImages", label: "아이콘 커스텀 (미구현)", description: "메뉴 아이콘 이미지를 커스터마이즈합니다", type: "toggle" }, { - key: "dashboardLayout", label: "대시보드 구성", description: "홈 대시보드 레이아웃", type: "select", options: [ + key: "dashboardLayout", label: "대시보드 구성 (미구현)", description: "홈 대시보드 레이아웃", type: "select", options: [ { value: "default", label: "기본" }, { value: "compact", label: "컴팩트" }, { value: "detailed", label: "상세" }, @@ -254,6 +259,81 @@ const SETTINGS_SCHEMA: Record = { ], }; +// ============================================================ +// 채번규칙 셀렉트 컴포넌트 +// ============================================================ + +function NumberingRuleSelect({ + field, + value, + onChange, +}: { + field: SettingField; + value: unknown; + onChange: (value: unknown) => void; +}) { + const { user } = useAuth(); + const [rules, setRules] = useState<{ value: string; label: string }[]>([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const loadRules = async () => { + setLoading(true); + try { + const companyCode = user?.companyCode || "COMPANY_7"; + const res = await apiClient.get(`/numbering-rules?company_code=${companyCode}`); + const data = res.data?.data || res.data || []; + const allRules: any[] = Array.isArray(data) ? data : (data.rules || []); + // tableFilter로 필터링 (inbound/outbound 등) + const filtered = field.tableFilter + ? allRules.filter((r: any) => { + const t = (r.tableName || "").toLowerCase(); + const c = (r.columnName || "").toLowerCase(); + const n = (r.ruleName || "").toLowerCase(); + const f = field.tableFilter!.toLowerCase(); + return t.includes(f) || c.includes(f) || n.includes(f); + }) + : allRules; + const finalRules = filtered.length > 0 ? filtered : allRules; + setRules( + finalRules.map((r: any) => ({ + value: r.ruleId, + label: `${r.ruleName || r.tableName + "." + r.columnName}`, + })) + ); + } catch { + setRules([]); + } + setLoading(false); + }; + loadRules(); + }, [user?.companyCode, field.tableFilter]); + + return ( +
+ +

{field.description}

+ {loading ? ( +

채번규칙 로드 중...

+ ) : rules.length === 0 ? ( +

등록된 채번규칙이 없습니다. PC에서 먼저 채번규칙을 등록해주세요.

+ ) : ( + + )} +
+ ); +} + // ============================================================ // Sub-components: TagEditor, ArrayObjectEditor // ============================================================ @@ -482,6 +562,8 @@ function SettingRow({ ); + case "numbering-rule": + return ; case "color": return (
@@ -749,13 +831,24 @@ export default function PopSettingsMngPage() { const path = iframeRef.current?.contentWindow?.location.pathname; if (path && path !== lastPath) { setLastPath(path); + // 1순위: 정확 일치, 2순위: 길이 긴 url부터 startsWith 매칭 (구체적 경로 우선) + let bestMatch: ScreenItem | null = null; + let bestUrlLength = -1; for (const group of SCREEN_GROUPS) { - const found = group.screens.find((s) => path === s.url || path.startsWith(s.url + "/")); - if (found) { - setSelectedScreen(found); - break; + for (const s of group.screens) { + if (path === s.url) { + bestMatch = s; + bestUrlLength = Infinity; + break; + } + if (path.startsWith(s.url + "/") && s.url.length > bestUrlLength) { + bestMatch = s; + bestUrlLength = s.url.length; + } } + if (bestUrlLength === Infinity) break; } + if (bestMatch) setSelectedScreen(bestMatch); } } catch { // cross-origin: silently ignore @@ -852,7 +945,12 @@ export default function PopSettingsMngPage() { // ---- Current screen schema values ---- const currentSettingsKey = selectedScreen?.settingsKey || "inbound"; - const currentFields = SETTINGS_SCHEMA[currentSettingsKey] || []; + const allFields = SETTINGS_SCHEMA[currentSettingsKey] || []; + // showOnlyForScreens 옵션이 있으면 현재 화면 ID와 일치할 때만 표시 + const currentFields = allFields.filter((f) => { + if (!f.showOnlyForScreens) return true; + return selectedScreen?.id ? f.showOnlyForScreens.includes(selectedScreen.id) : false; + }); const currentValues = (settings.screens as Record>)[currentSettingsKey] || {}; return ( diff --git a/frontend/components/pop/hardcoded/PopShell.tsx b/frontend/components/pop/hardcoded/PopShell.tsx index 3d4b6ac6..7f538aef 100644 --- a/frontend/components/pop/hardcoded/PopShell.tsx +++ b/frontend/components/pop/hardcoded/PopShell.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect, useRef, ReactNode } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; +import { usePopSettings } from "@/hooks/pop/usePopSettings"; interface PopShellProps { children: ReactNode; @@ -98,8 +99,12 @@ export function PopShell({ children, showBanner = true, title, showBack = false, logout(); }; - const marqueeText = - "[공지] 금일 오후 3시 전체 안전교육 실시 예정입니다. 전 직원 필참 바랍니다. \u00a0\u00a0|\u00a0\u00a0 [알림] 내일 설비 정기점검으로 인한 3호기 가동 중지 예정 \u00a0\u00a0|\u00a0\u00a0 [안내] 4월 생산실적 우수팀 발표 - 생산1팀 축하드립니다!"; + // POP 설정에서 배너 텍스트 로드 (POP화면설정에서 관리) + const { settings: popSettings } = usePopSettings("/pop/home"); + const homeConfig = (popSettings as any)?.screens?.home; + const bannerEnabled = homeConfig?.bannerEnabled ?? true; + const bannerText = homeConfig?.bannerText; + const marqueeText = bannerText || "[공지] 금일 오후 3시 전체 안전교육 실시 예정입니다. 전 직원 필참 바랍니다. \u00a0\u00a0|\u00a0\u00a0 [알림] 내일 설비 정기점검으로 인한 3호기 가동 중지 예정 \u00a0\u00a0|\u00a0\u00a0 [안내] 4월 생산실적 우수팀 발표 - 생산1팀 축하드립니다!"; return (
@@ -296,7 +301,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false, {/* ===== NOTICE BANNER (Marquee) ===== */} - {showBanner &&
+ {showBanner && bannerEnabled &&
📢 공지 diff --git a/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx b/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx index 960794b4..d6a90ec3 100644 --- a/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx +++ b/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx @@ -310,9 +310,15 @@ export function InboundCartPage() { try { // 확정 시점에 채번 (동시접속 충돌 방지) + // POP 화면설정에서 선택한 채번규칙 사용 (없으면 기본) let finalNumber = ""; try { - const numRes = await apiClient.get("/receiving/generate-number"); + const settingsRes: any = await apiClient.get("/screen-management/screens/6527/layout-pop").catch(() => null); + const ruleId = settingsRes?.data?.data?.settings?.popConfig?.inbound?.numberingRuleId; + const url = ruleId && ruleId !== "__none__" + ? `/receiving/generate-number?ruleId=${encodeURIComponent(ruleId)}` + : "/receiving/generate-number"; + const numRes = await apiClient.get(url); if (numRes.data?.success && numRes.data?.data) { finalNumber = numRes.data.data; setInboundNumber(finalNumber); @@ -344,6 +350,7 @@ export function InboundCartPage() { reference_number: item.purchase_no, supplier_code: item.supplier_code, supplier_name: item.supplier_name, + inbound_status: "입고완료", inspection_status: inspResult?.completed ? "검사완료" : item.inspection_required diff --git a/frontend/components/pop/hardcoded/outbound/OutboundCartPage.tsx b/frontend/components/pop/hardcoded/outbound/OutboundCartPage.tsx index 918e0510..32bcc462 100644 --- a/frontend/components/pop/hardcoded/outbound/OutboundCartPage.tsx +++ b/frontend/components/pop/hardcoded/outbound/OutboundCartPage.tsx @@ -305,9 +305,16 @@ export function OutboundCartPage() { try { // Generate outbound number at confirm time + // POP 화면설정에서 선택한 채번규칙 사용 (없으면 기본) + // 출고 장바구니 전용 screen_id 7010 let finalNumber = ""; try { - const numRes = await apiClient.get("/outbound/generate-number"); + const settingsRes: any = await apiClient.get("/screen-management/screens/7010/layout-pop").catch(() => null); + const ruleId = settingsRes?.data?.data?.settings?.popConfig?.outbound?.numberingRuleId; + const url = ruleId && ruleId !== "__none__" + ? `/outbound/generate-number?ruleId=${encodeURIComponent(ruleId)}` + : "/outbound/generate-number"; + const numRes = await apiClient.get(url); if (numRes.data?.success && numRes.data?.data) { finalNumber = numRes.data.data; setOutboundNumber(finalNumber); @@ -337,7 +344,7 @@ export function OutboundCartPage() { customer_name: item.customer_name, source_type: "shipment_instruction_detail", source_id: item.source_id || item.id, - outbound_status: "대기", + outbound_status: "출고완료", })), }; diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..b08e2bd3 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,7 @@ +sonar.projectKey=vexplor +sonar.projectName=vexplor +sonar.sources=backend-node/src +sonar.exclusions=**/node_modules/**,**/dist/**,**/*.test.*,**/test-scenarios/**,**/build/**,**/.next/** +sonar.host.url=http://localhost:9000 +sonar.sourceEncoding=UTF-8 +sonar.scm.disabled=true