Merge branch 'main' into jskim-node

This commit is contained in:
jskim
2026-04-08 01:42:08 +00:00
9 changed files with 195 additions and 38 deletions
View File
+6
View File
@@ -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
@@ -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}-`;
@@ -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}-`;
@@ -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<string, SettingField[]> = {
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<string, SettingField[]> = {
],
};
// ============================================================
// 채번규칙 셀렉트 컴포넌트
// ============================================================
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 (
<div className="py-3 border-b last:border-0 space-y-1.5">
<Label className="text-sm font-medium">{field.label}</Label>
<p className="text-xs text-muted-foreground">{field.description}</p>
{loading ? (
<p className="text-xs text-muted-foreground"> ...</p>
) : rules.length === 0 ? (
<p className="text-xs text-amber-600"> . PC에서 .</p>
) : (
<Select value={(value as string) || ""} onValueChange={onChange}>
<SelectTrigger className="w-full h-9">
<SelectValue placeholder="채번규칙을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> ()</SelectItem>
{rules.map((r) => (
<SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
}
// ============================================================
// Sub-components: TagEditor, ArrayObjectEditor
// ============================================================
@@ -482,6 +562,8 @@ function SettingRow({
</Select>
</div>
);
case "numbering-rule":
return <NumberingRuleSelect field={field} value={value} onChange={onChange} />;
case "color":
return (
<div className="py-3 border-b last:border-0 space-y-1.5">
@@ -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<string, Record<string, unknown>>)[currentSettingsKey] || {};
return (
@@ -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 (
<div className="min-h-screen min-h-dvh flex flex-col" style={{ background: "#F5F5F5" }}>
@@ -296,7 +301,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false,
</header>
{/* ===== NOTICE BANNER (Marquee) ===== */}
{showBanner && <div className="bg-amber-50 border-b border-amber-200 px-4 py-2 flex items-center gap-3">
{showBanner && bannerEnabled && <div className="bg-amber-50 border-b border-amber-200 px-4 py-2 flex items-center gap-3">
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-amber-600 text-sm">📢</span>
<span className="text-xs font-bold text-amber-700"></span>
@@ -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
@@ -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: "출고완료",
})),
};
+7
View File
@@ -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