Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -151,6 +151,7 @@ import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석
|
||||
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
|
||||
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
|
||||
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
|
||||
import outboundRoutes from "./routes/outboundRoutes"; // 출고관리
|
||||
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
@@ -367,6 +368,7 @@ app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
|
||||
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
app.use("/api/design", designRoutes); // 설계 모듈
|
||||
app.use("/api/receiving", receivingRoutes); // 입고관리
|
||||
app.use("/api/outbound", outboundRoutes); // 출고관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
|
||||
@@ -2504,7 +2504,9 @@ export const changeUserStatus = async (
|
||||
// 필수 파라미터 검증
|
||||
if (!userId || !status) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 ID와 상태는 필수입니다.",
|
||||
msg: "사용자 ID와 상태는 필수입니다.",
|
||||
});
|
||||
return;
|
||||
@@ -2513,7 +2515,9 @@ export const changeUserStatus = async (
|
||||
// 상태 값 검증
|
||||
if (!["active", "inactive"].includes(status)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
|
||||
msg: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
|
||||
});
|
||||
return;
|
||||
@@ -2528,7 +2532,9 @@ export const changeUserStatus = async (
|
||||
|
||||
if (!currentUser) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
msg: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
@@ -2549,6 +2555,12 @@ export const changeUserStatus = async (
|
||||
if (updateResult.length > 0) {
|
||||
// 사용자 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
|
||||
|
||||
// inactive로 변경 시 기존 JWT 토큰 무효화
|
||||
if (status === "inactive") {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userId);
|
||||
}
|
||||
|
||||
logger.info("사용자 상태 변경 성공", {
|
||||
userId,
|
||||
oldStatus: currentUser.status,
|
||||
@@ -2571,12 +2583,16 @@ export const changeUserStatus = async (
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: true,
|
||||
message: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
|
||||
msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 상태 변경에 실패했습니다.",
|
||||
msg: "사용자 상태 변경에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
@@ -2587,7 +2603,9 @@ export const changeUserStatus = async (
|
||||
status: req.body.status,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "시스템 오류가 발생했습니다.",
|
||||
msg: "시스템 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
@@ -2627,12 +2645,214 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 추가 유효성 검증
|
||||
|
||||
// 1. email 형식 검증 (값이 있는 경우만)
|
||||
if (userData.email && userData.email.trim() !== "") {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(userData.email.trim())) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "이메일 형식이 올바르지 않습니다.",
|
||||
error: {
|
||||
code: "INVALID_EMAIL_FORMAT",
|
||||
details: `Invalid email format: ${userData.email}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. companyCode 존재 확인 (값이 있는 경우만)
|
||||
if (userData.companyCode && userData.companyCode.trim() !== "") {
|
||||
const companyExists = await queryOne<{ company_code: string }>(
|
||||
`SELECT company_code FROM company_mng WHERE company_code = $1`,
|
||||
[userData.companyCode.trim()]
|
||||
);
|
||||
if (!companyExists) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `존재하지 않는 회사 코드입니다: ${userData.companyCode}`,
|
||||
error: {
|
||||
code: "INVALID_COMPANY_CODE",
|
||||
details: `Company code not found: ${userData.companyCode}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. userType 유효값 검증 (값이 있는 경우만)
|
||||
if (userData.userType && userData.userType.trim() !== "") {
|
||||
const validUserTypes = ["SUPER_ADMIN", "COMPANY_ADMIN", "USER", "GUEST", "PARTNER"];
|
||||
if (!validUserTypes.includes(userData.userType.trim())) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `유효하지 않은 사용자 유형입니다: ${userData.userType}. 허용값: ${validUserTypes.join(", ")}`,
|
||||
error: {
|
||||
code: "INVALID_USER_TYPE",
|
||||
details: `Invalid userType: ${userData.userType}. Allowed: ${validUserTypes.join(", ")}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 비밀번호 최소 길이 검증 (신규 등록 시)
|
||||
if (!isUpdate && userData.userPassword && userData.userPassword.length < 4) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "비밀번호는 최소 4자 이상이어야 합니다.",
|
||||
error: {
|
||||
code: "PASSWORD_TOO_SHORT",
|
||||
details: "Password must be at least 4 characters long",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 비밀번호 암호화 (비밀번호가 제공된 경우에만)
|
||||
let encryptedPassword = null;
|
||||
if (userData.userPassword) {
|
||||
encryptedPassword = await EncryptUtil.encrypt(userData.userPassword);
|
||||
}
|
||||
|
||||
// PUT(수정) 요청 시 company_code / dept_code 변경 감지
|
||||
if (isUpdate) {
|
||||
const existingUser = await queryOne<{ company_code: string; dept_code: string }>(
|
||||
`SELECT company_code, dept_code FROM user_info WHERE user_id = $1`,
|
||||
[userData.userId]
|
||||
);
|
||||
|
||||
// company_code 변경 감지 → 이전 회사 권한 그룹 제거
|
||||
if (
|
||||
userData.companyCode &&
|
||||
existingUser &&
|
||||
existingUser.company_code &&
|
||||
existingUser.company_code !== userData.companyCode
|
||||
) {
|
||||
const oldCompanyCode = existingUser.company_code;
|
||||
logger.info("사용자 회사 코드 변경 감지 - 이전 회사 권한 그룹 제거", {
|
||||
userId: userData.userId,
|
||||
oldCompanyCode,
|
||||
newCompanyCode: userData.companyCode,
|
||||
});
|
||||
|
||||
// 이전 회사의 권한 그룹에서 해당 사용자 제거
|
||||
await query(
|
||||
`DELETE FROM authority_sub_user
|
||||
WHERE user_id = $1
|
||||
AND master_objid IN (
|
||||
SELECT objid FROM authority_master WHERE company_code = $2
|
||||
)`,
|
||||
[userData.userId, oldCompanyCode]
|
||||
);
|
||||
}
|
||||
|
||||
// dept_code 변경 감지 → 결재 템플릿/진행중 결재라인 경고 로그
|
||||
const newDeptCode = userData.deptCode || null;
|
||||
const oldDeptCode = existingUser?.dept_code || null;
|
||||
if (existingUser && oldDeptCode && newDeptCode && oldDeptCode !== newDeptCode) {
|
||||
logger.warn("사용자 부서 변경 감지 - 결재라인 영향 확인 시작", {
|
||||
userId: userData.userId,
|
||||
userName: userData.userName,
|
||||
oldDeptCode,
|
||||
newDeptCode,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1) 결재선 템플릿 스텝에서 해당 사용자가 결재자로 등록된 건 조회
|
||||
const templateSteps = await query<{
|
||||
template_id: number;
|
||||
step_order: number;
|
||||
approver_label: string | null;
|
||||
approver_dept_code: string | null;
|
||||
}>(
|
||||
`SELECT s.template_id, s.step_order, s.approver_label, s.approver_dept_code
|
||||
FROM approval_line_template_steps s
|
||||
WHERE s.approver_user_id = $1`,
|
||||
[userData.userId]
|
||||
);
|
||||
|
||||
if (templateSteps && templateSteps.length > 0) {
|
||||
logger.warn(
|
||||
`[결재라인 경고] 부서 변경된 사용자(${userData.userId})가 결재선 템플릿 ${templateSteps.length}건에 결재자로 등록되어 있습니다. 수동 확인이 필요합니다.`,
|
||||
{
|
||||
userId: userData.userId,
|
||||
oldDeptCode,
|
||||
newDeptCode,
|
||||
affectedTemplates: templateSteps.map((s) => ({
|
||||
templateId: s.template_id,
|
||||
stepOrder: s.step_order,
|
||||
label: s.approver_label,
|
||||
currentDeptInStep: s.approver_dept_code,
|
||||
})),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 2) 진행중인 결재 요청에서 해당 사용자가 대기중 결재자인 건 조회
|
||||
const pendingLines = await query<{
|
||||
request_id: number;
|
||||
step_order: number;
|
||||
approver_dept: string | null;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT l.request_id, l.step_order, l.approver_dept, l.status
|
||||
FROM approval_lines l
|
||||
JOIN approval_requests r ON r.request_id = l.request_id
|
||||
WHERE l.approver_id = $1
|
||||
AND l.status = 'pending'
|
||||
AND r.status IN ('in_progress', 'pending')`,
|
||||
[userData.userId]
|
||||
);
|
||||
|
||||
if (pendingLines && pendingLines.length > 0) {
|
||||
logger.warn(
|
||||
`[결재라인 경고] 부서 변경된 사용자(${userData.userId})에게 대기중인 결재 ${pendingLines.length}건이 있습니다. 수동 확인이 필요합니다.`,
|
||||
{
|
||||
userId: userData.userId,
|
||||
oldDeptCode,
|
||||
newDeptCode,
|
||||
pendingApprovals: pendingLines.map((l) => ({
|
||||
requestId: l.request_id,
|
||||
stepOrder: l.step_order,
|
||||
currentDeptInLine: l.approver_dept,
|
||||
})),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 감사 로그 기록
|
||||
auditLogService.log({
|
||||
companyCode: userData.companyCode || req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DEPT_CHANGE_WARNING",
|
||||
resourceType: "USER",
|
||||
resourceId: userData.userId,
|
||||
resourceName: userData.userName,
|
||||
summary: `사용자 "${userData.userName}"의 부서 변경 (${oldDeptCode} → ${newDeptCode}). 결재 템플릿 ${templateSteps?.length || 0}건, 대기중 결재 ${pendingLines?.length || 0}건 영향 가능`,
|
||||
changes: {
|
||||
before: { deptCode: oldDeptCode },
|
||||
after: {
|
||||
deptCode: newDeptCode,
|
||||
affectedTemplateCount: templateSteps?.length || 0,
|
||||
pendingApprovalCount: pendingLines?.length || 0,
|
||||
},
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
} catch (approvalCheckError) {
|
||||
// 결재 테이블이 없는 환경에서도 사용자 저장은 계속 진행
|
||||
logger.warn("결재라인 영향 확인 중 오류 (사용자 저장은 계속 진행)", {
|
||||
error: approvalCheckError instanceof Error ? approvalCheckError.message : approvalCheckError,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT)
|
||||
const updatePasswordClause = encryptedPassword ? "user_password = $4," : "";
|
||||
|
||||
@@ -2688,6 +2908,12 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||
savedUser.regdate &&
|
||||
new Date(savedUser.regdate).getTime() < Date.now() - 1000;
|
||||
|
||||
// 기존 사용자의 비밀번호 변경 시 JWT 토큰 무효화
|
||||
if (encryptedPassword && isExistingUser) {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userData.userId);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료",
|
||||
{
|
||||
@@ -3534,6 +3760,10 @@ export const resetUserPassword = async (
|
||||
if (updateResult.length > 0) {
|
||||
// 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
|
||||
|
||||
// 비밀번호 변경 후 기존 JWT 토큰 무효화
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userId);
|
||||
|
||||
logger.info("비밀번호 초기화 성공", {
|
||||
userId,
|
||||
updatedBy: req.user?.userId,
|
||||
@@ -4153,6 +4383,140 @@ export const saveUserWithDept = async (
|
||||
* GET /api/admin/users/:userId/with-dept
|
||||
* 사원 + 부서 정보 조회 API (수정 모달용)
|
||||
*/
|
||||
/**
|
||||
* DELETE /api/admin/users/:userId
|
||||
* 사용자 삭제 API (soft delete)
|
||||
* status = 'deleted', end_date = now() 설정
|
||||
* authority_sub_user 멤버십 제거, JWT 토큰 무효화
|
||||
*/
|
||||
export const deleteUser = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
// 1. userId 파라미터 검증
|
||||
if (!userId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 ID는 필수입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 자기 자신 삭제 방지
|
||||
if (req.user?.userId === userId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "자기 자신은 삭제할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 사용자 존재 여부 확인
|
||||
const currentUser = await queryOne<any>(
|
||||
`SELECT user_id, user_name, status, company_code FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!currentUser) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 삭제된 사용자 체크
|
||||
if (currentUser.status === "deleted") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "이미 삭제된 사용자입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. soft delete: status = 'deleted', end_date = now()
|
||||
const updateResult = await query<any>(
|
||||
`UPDATE user_info
|
||||
SET status = 'deleted', end_date = NOW()
|
||||
WHERE user_id = $1
|
||||
RETURNING *`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (updateResult.length === 0) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 삭제에 실패했습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. authority_sub_user에서 해당 사용자 멤버십 제거
|
||||
await query(
|
||||
`DELETE FROM authority_sub_user WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// 6. JWT 토큰 무효화
|
||||
try {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userId);
|
||||
} catch (tokenError) {
|
||||
logger.warn("토큰 무효화 중 오류 (삭제는 정상 처리됨)", { userId, error: tokenError });
|
||||
}
|
||||
|
||||
logger.info("사용자 삭제(soft delete) 성공", {
|
||||
userId,
|
||||
userName: currentUser.user_name,
|
||||
deletedBy: req.user?.userId,
|
||||
});
|
||||
|
||||
// 7. 감사 로그 기록
|
||||
auditLogService.log({
|
||||
companyCode: currentUser.company_code || req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DELETE",
|
||||
resourceType: "USER",
|
||||
resourceId: userId,
|
||||
resourceName: currentUser.user_name,
|
||||
summary: `사용자 "${currentUser.user_name}" (${userId}) 삭제 처리`,
|
||||
changes: {
|
||||
before: { status: currentUser.status },
|
||||
after: { status: "deleted" },
|
||||
fields: ["status", "end_date"],
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
// 8. 응답
|
||||
res.json({
|
||||
success: true,
|
||||
result: true,
|
||||
message: `사용자 "${currentUser.user_name}" (${userId})이(가) 삭제되었습니다.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("사용자 삭제 중 오류 발생", {
|
||||
error: error.message,
|
||||
userId: req.params.userId,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "시스템 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserWithDept = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
|
||||
@@ -561,6 +561,34 @@ export class EntityJoinController {
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용)
|
||||
* GET /api/table-management/tables/:tableName/column-values/:columnName
|
||||
*/
|
||||
async getColumnUniqueValues(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
const data = await tableManagementService.getColumnDistinctValues(
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`컬럼 고유값 조회 실패: ${req.params.tableName}.${req.params.columnName}`, error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "컬럼 고유값 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const entityJoinController = new EntityJoinController();
|
||||
|
||||
@@ -191,18 +191,30 @@ export const getLangKeys = async (
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
logger.info("다국어 키 목록 조회 요청", {
|
||||
query: req.query,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
// company_code 필터링: 비관리자는 자기 회사 + 공통(*) 키만 조회 가능
|
||||
let effectiveCompanyCode = companyCode as string;
|
||||
if (userCompanyCode !== "*") {
|
||||
// 비관리자가 다른 회사의 데이터를 요청하면 자기 회사로 제한
|
||||
if (companyCode && companyCode !== userCompanyCode && companyCode !== "*") {
|
||||
effectiveCompanyCode = userCompanyCode || "";
|
||||
}
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const langKeys = await multiLangService.getLangKeys({
|
||||
companyCode: companyCode as string,
|
||||
companyCode: effectiveCompanyCode,
|
||||
menuCode: menuCode as string,
|
||||
keyType: keyType as string,
|
||||
searchText: searchText as string,
|
||||
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
|
||||
// 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환
|
||||
userCompanyCode: userCompanyCode,
|
||||
});
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
@@ -235,9 +247,24 @@ export const getLangTexts = async (
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
logger.info("다국어 텍스트 조회 요청", { keyId, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증
|
||||
if (userCompanyCode !== "*") {
|
||||
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||
if (keyOwner && keyOwner !== "*" && keyOwner !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 다국어 키에 접근할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const langTexts = await multiLangService.getLangTexts(parseInt(keyId));
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
@@ -270,6 +297,7 @@ export const createLangKey = async (
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const keyData: CreateLangKeyRequest = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
logger.info("다국어 키 생성 요청", { keyData, user: req.user });
|
||||
|
||||
// 필수 입력값 검증
|
||||
@@ -285,6 +313,26 @@ export const createLangKey = async (
|
||||
return;
|
||||
}
|
||||
|
||||
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
|
||||
if (keyData.companyCode === "*" && userCompanyCode !== "*") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 비관리자: 자기 회사 키만 생성 가능
|
||||
if (userCompanyCode !== "*" && keyData.companyCode !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 키를 생성할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const keyId = await multiLangService.createLangKey({
|
||||
...keyData,
|
||||
@@ -323,10 +371,33 @@ export const updateLangKey = async (
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const keyData: UpdateLangKeyRequest = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
logger.info("다국어 키 수정 요청", { keyId, keyData, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키는 수정 불가)
|
||||
if (userCompanyCode !== "*") {
|
||||
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||
if (!keyOwner) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "다국어 키를 찾을 수 없습니다.",
|
||||
error: { code: "KEY_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (keyOwner !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 다국어 키를 수정할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await multiLangService.updateLangKey(parseInt(keyId), {
|
||||
...keyData,
|
||||
updatedBy: req.user?.userId || "system",
|
||||
@@ -362,9 +433,32 @@ export const deleteLangKey = async (
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
logger.info("다국어 키 삭제 요청", { keyId, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키 삭제 불가)
|
||||
if (userCompanyCode !== "*") {
|
||||
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||
if (!keyOwner) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "다국어 키를 찾을 수 없습니다.",
|
||||
error: { code: "KEY_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (keyOwner !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 다국어 키를 삭제할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await multiLangService.deleteLangKey(parseInt(keyId));
|
||||
|
||||
const response: ApiResponse<string> = {
|
||||
@@ -397,9 +491,32 @@ export const toggleLangKey = async (
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
logger.info("다국어 키 상태 토글 요청", { keyId, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// 비관리자: 해당 키가 자기 회사 키인지 검증
|
||||
if (userCompanyCode !== "*") {
|
||||
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||
if (!keyOwner) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "다국어 키를 찾을 수 없습니다.",
|
||||
error: { code: "KEY_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (keyOwner !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 다국어 키를 변경할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await multiLangService.toggleLangKey(parseInt(keyId));
|
||||
|
||||
const response: ApiResponse<string> = {
|
||||
@@ -433,6 +550,7 @@ export const saveLangTexts = async (
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const textData: SaveLangTextsRequest = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
logger.info("다국어 텍스트 저장 요청", { keyId, textData, user: req.user });
|
||||
|
||||
@@ -454,6 +572,28 @@ export const saveLangTexts = async (
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증
|
||||
if (userCompanyCode !== "*") {
|
||||
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||
if (!keyOwner) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "다국어 키를 찾을 수 없습니다.",
|
||||
error: { code: "KEY_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (keyOwner !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 다국어 텍스트를 수정할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await multiLangService.saveLangTexts(parseInt(keyId), {
|
||||
texts: textData.texts.map((text) => ({
|
||||
...text,
|
||||
|
||||
509
backend-node/src/controllers/outboundController.ts
Normal file
509
backend-node/src/controllers/outboundController.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* 출고관리 컨트롤러
|
||||
*
|
||||
* 출고유형별 소스 테이블:
|
||||
* - 판매출고 → shipment_instruction + shipment_instruction_detail (출하지시)
|
||||
* - 반품출고 → purchase_order_mng (발주/입고)
|
||||
* - 기타출고 → item_info (품목)
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 출고 목록 조회
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const {
|
||||
outbound_type,
|
||||
outbound_status,
|
||||
search_keyword,
|
||||
date_from,
|
||||
date_to,
|
||||
} = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 전체 조회
|
||||
} else {
|
||||
conditions.push(`om.company_code = $${paramIdx}`);
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (outbound_type && outbound_type !== "all") {
|
||||
conditions.push(`om.outbound_type = $${paramIdx}`);
|
||||
params.push(outbound_type);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (outbound_status && outbound_status !== "all") {
|
||||
conditions.push(`om.outbound_status = $${paramIdx}`);
|
||||
params.push(outbound_status);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (search_keyword) {
|
||||
conditions.push(
|
||||
`(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.item_code ILIKE $${paramIdx} OR om.customer_name ILIKE $${paramIdx} OR om.reference_number ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${search_keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (date_from) {
|
||||
conditions.push(`om.outbound_date >= $${paramIdx}`);
|
||||
params.push(date_from);
|
||||
paramIdx++;
|
||||
}
|
||||
if (date_to) {
|
||||
conditions.push(`om.outbound_date <= $${paramIdx}`);
|
||||
params.push(date_to);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
om.*,
|
||||
wh.warehouse_name
|
||||
FROM outbound_mng om
|
||||
LEFT JOIN warehouse_info wh
|
||||
ON om.warehouse_code = wh.warehouse_code
|
||||
AND om.company_code = wh.company_code
|
||||
${whereClause}
|
||||
ORDER BY om.created_date DESC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("출고 목록 조회", {
|
||||
companyCode,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("출고 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 출고 등록 (다건)
|
||||
export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { items, outbound_number, outbound_date, warehouse_code, location_code, manager_id, memo } = req.body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "출고 품목이 없습니다." });
|
||||
}
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
const insertedRows: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO outbound_mng (
|
||||
company_code, outbound_number, outbound_type, outbound_date,
|
||||
reference_number, customer_code, customer_name,
|
||||
item_code, item_name, specification, material, unit,
|
||||
outbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
outbound_status, manager_id, memo,
|
||||
source_type, sales_order_id, shipment_plan_id, item_info_id,
|
||||
destination_code, delivery_destination, delivery_address,
|
||||
created_date, created_by, writer, status
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7,
|
||||
$8, $9, $10, $11, $12,
|
||||
$13, $14, $15,
|
||||
$16, $17, $18,
|
||||
$19, $20, $21,
|
||||
$22, $23, $24, $25,
|
||||
$26, $27, $28,
|
||||
NOW(), $29, $29, '출고'
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode,
|
||||
outbound_number || item.outbound_number,
|
||||
item.outbound_type,
|
||||
outbound_date || item.outbound_date,
|
||||
item.reference_number || null,
|
||||
item.customer_code || null,
|
||||
item.customer_name || null,
|
||||
item.item_code || item.item_number || null,
|
||||
item.item_name || null,
|
||||
item.spec || item.specification || null,
|
||||
item.material || null,
|
||||
item.unit || "EA",
|
||||
item.outbound_qty || 0,
|
||||
item.unit_price || 0,
|
||||
item.total_amount || 0,
|
||||
item.lot_number || null,
|
||||
warehouse_code || item.warehouse_code || null,
|
||||
location_code || item.location_code || null,
|
||||
item.outbound_status || "대기",
|
||||
manager_id || item.manager_id || null,
|
||||
memo || item.memo || null,
|
||||
item.source_type || null,
|
||||
item.sales_order_id || null,
|
||||
item.shipment_plan_id || null,
|
||||
item.item_info_id || null,
|
||||
item.destination_code || null,
|
||||
item.delivery_destination || null,
|
||||
item.delivery_address || null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
insertedRows.push(result.rows[0]);
|
||||
|
||||
// 재고 업데이트 (inventory_stock): 출고 수량 차감
|
||||
const itemCode = item.item_code || item.item_number || null;
|
||||
const whCode = warehouse_code || item.warehouse_code || null;
|
||||
const locCode = location_code || item.location_code || null;
|
||||
const outQty = Number(item.outbound_qty) || 0;
|
||||
if (itemCode && outQty > 0) {
|
||||
const existingStock = await client.query(
|
||||
`SELECT id FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
|
||||
AND COALESCE(location_code, '') = COALESCE($4, '')
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode, whCode || '', locCode || '']
|
||||
);
|
||||
|
||||
if (existingStock.rows.length > 0) {
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text),
|
||||
last_out_date = NOW(),
|
||||
updated_date = NOW()
|
||||
WHERE id = $2`,
|
||||
[outQty, existingStock.rows[0].id]
|
||||
);
|
||||
} else {
|
||||
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
|
||||
await client.query(
|
||||
`INSERT INTO inventory_stock (
|
||||
company_code, item_code, warehouse_code, location_code,
|
||||
current_qty, safety_qty, last_out_date,
|
||||
created_date, updated_date, writer
|
||||
) VALUES ($1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`,
|
||||
[companyCode, itemCode, whCode, locCode, userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 판매출고인 경우 출하지시의 ship_qty 업데이트
|
||||
if (item.outbound_type === "판매출고" && item.source_id && item.source_type === "shipment_instruction_detail") {
|
||||
await client.query(
|
||||
`UPDATE shipment_instruction_detail
|
||||
SET ship_qty = COALESCE(ship_qty, 0) + $1,
|
||||
updated_date = NOW()
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[item.outbound_qty || 0, item.source_id, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("출고 등록 완료", {
|
||||
companyCode,
|
||||
userId,
|
||||
count: insertedRows.length,
|
||||
outbound_number,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: insertedRows,
|
||||
message: `${insertedRows.length}건 출고 등록 완료`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("출고 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 출고 수정
|
||||
export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const {
|
||||
outbound_date, outbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
outbound_status, manager_id: mgr, memo,
|
||||
} = req.body;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`UPDATE outbound_mng SET
|
||||
outbound_date = COALESCE($1, outbound_date),
|
||||
outbound_qty = COALESCE($2, outbound_qty),
|
||||
unit_price = COALESCE($3, unit_price),
|
||||
total_amount = COALESCE($4, total_amount),
|
||||
lot_number = COALESCE($5, lot_number),
|
||||
warehouse_code = COALESCE($6, warehouse_code),
|
||||
location_code = COALESCE($7, location_code),
|
||||
outbound_status = COALESCE($8, outbound_status),
|
||||
manager_id = COALESCE($9, manager_id),
|
||||
memo = COALESCE($10, memo),
|
||||
updated_date = NOW(),
|
||||
updated_by = $11
|
||||
WHERE id = $12 AND company_code = $13
|
||||
RETURNING *`,
|
||||
[
|
||||
outbound_date, outbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
outbound_status, mgr, memo,
|
||||
userId, id, companyCode,
|
||||
]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
logger.info("출고 수정", { companyCode, userId, id });
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("출고 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 출고 삭제
|
||||
export async function deleteOutbound(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
logger.info("출고 삭제", { companyCode, id });
|
||||
|
||||
return res.json({ success: true, message: "삭제 완료" });
|
||||
} catch (error: any) {
|
||||
logger.error("출고 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 판매출고용: 출하지시 데이터 조회
|
||||
export async function getShipmentInstructions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["si.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
sid.id AS detail_id,
|
||||
si.id AS instruction_id,
|
||||
si.instruction_no,
|
||||
si.instruction_date,
|
||||
si.partner_id,
|
||||
si.status AS instruction_status,
|
||||
sid.item_code,
|
||||
sid.item_name,
|
||||
sid.spec,
|
||||
sid.material,
|
||||
COALESCE(sid.plan_qty, 0) AS plan_qty,
|
||||
COALESCE(sid.ship_qty, 0) AS ship_qty,
|
||||
COALESCE(sid.order_qty, 0) AS order_qty,
|
||||
GREATEST(COALESCE(sid.plan_qty, 0) - COALESCE(sid.ship_qty, 0), 0) AS remain_qty,
|
||||
sid.source_type
|
||||
FROM shipment_instruction si
|
||||
JOIN shipment_instruction_detail sid
|
||||
ON si.id = sid.instruction_id
|
||||
AND si.company_code = sid.company_code
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
AND COALESCE(sid.plan_qty, 0) > COALESCE(sid.ship_qty, 0)
|
||||
ORDER BY si.instruction_date DESC, si.instruction_no`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("출하지시 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 반품출고용: 발주(입고) 데이터 조회
|
||||
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
// 입고된 것만 (반품 대상)
|
||||
conditions.push(
|
||||
`COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0`
|
||||
);
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
id, purchase_no, order_date, supplier_code, supplier_name,
|
||||
item_code, item_name, spec, material,
|
||||
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty,
|
||||
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty,
|
||||
COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price,
|
||||
status, due_date
|
||||
FROM purchase_order_mng
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY order_date DESC, purchase_no`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("발주 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 기타출고용: 품목 데이터 조회
|
||||
export async function getItems(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
id, item_number, item_name, size AS spec, material, unit,
|
||||
COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("품목 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 출고번호 자동생성
|
||||
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
const today = new Date();
|
||||
const yyyy = today.getFullYear();
|
||||
const prefix = `OUT-${yyyy}-`;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT outbound_number FROM outbound_mng
|
||||
WHERE company_code = $1 AND outbound_number LIKE $2
|
||||
ORDER BY outbound_number DESC LIMIT 1`,
|
||||
[companyCode, `${prefix}%`]
|
||||
);
|
||||
|
||||
let seq = 1;
|
||||
if (result.rows.length > 0) {
|
||||
const lastNo = result.rows[0].outbound_number;
|
||||
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
|
||||
if (!isNaN(lastSeq)) seq = lastSeq + 1;
|
||||
}
|
||||
|
||||
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
|
||||
|
||||
return res.json({ success: true, data: newNumber });
|
||||
} catch (error: any) {
|
||||
logger.error("출고번호 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 창고 목록 조회
|
||||
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT warehouse_code, warehouse_name, warehouse_type
|
||||
FROM warehouse_info
|
||||
WHERE company_code = $1 AND status != '삭제'
|
||||
ORDER BY warehouse_name`,
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("창고 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,11 @@ export async function getPkgUnitItems(
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM pkg_unit_item WHERE pkg_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
|
||||
`SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit
|
||||
FROM pkg_unit_item pui
|
||||
LEFT JOIN item_info ii ON pui.item_number = ii.item_number AND pui.company_code = ii.company_code
|
||||
WHERE pui.pkg_code=$1 AND pui.company_code=$2
|
||||
ORDER BY pui.created_date DESC`,
|
||||
[pkgCode, companyCode]
|
||||
);
|
||||
|
||||
@@ -410,7 +414,11 @@ export async function getLoadingUnitPkgs(
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM loading_unit_pkg WHERE loading_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
|
||||
`SELECT lup.*, pu.pkg_name, pu.pkg_type
|
||||
FROM loading_unit_pkg lup
|
||||
LEFT JOIN pkg_unit pu ON lup.pkg_code = pu.pkg_code AND lup.company_code = pu.company_code
|
||||
WHERE lup.loading_code=$1 AND lup.company_code=$2
|
||||
ORDER BY lup.created_date DESC`,
|
||||
[loadingCode, companyCode]
|
||||
);
|
||||
|
||||
@@ -476,3 +484,112 @@ export async function deleteLoadingUnitPkg(
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 품목정보 연동 (division별 item_info 조회)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export async function getItemsByDivision(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { divisionLabel } = req.params;
|
||||
const { keyword } = req.query;
|
||||
const pool = getPool();
|
||||
|
||||
// division 카테고리에서 해당 라벨의 코드 찾기
|
||||
const catResult = await pool.query(
|
||||
`SELECT value_code FROM category_values
|
||||
WHERE table_name = 'item_info' AND column_name = 'division'
|
||||
AND value_label = $1 AND company_code = $2
|
||||
LIMIT 1`,
|
||||
[divisionLabel, companyCode]
|
||||
);
|
||||
|
||||
if (catResult.rows.length === 0) {
|
||||
res.json({ success: true, data: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const divisionCode = catResult.rows[0].value_code;
|
||||
|
||||
const conditions: string[] = ["company_code = $1", `$2 = ANY(string_to_array(division, ','))`];
|
||||
const params: any[] = [companyCode, divisionCode];
|
||||
let paramIdx = 3;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, item_number, item_name, size, material, unit, division
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name`,
|
||||
params
|
||||
);
|
||||
|
||||
logger.info(`품목 조회 (division=${divisionLabel})`, { companyCode, count: result.rowCount });
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("품목 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 일반 품목 조회 (포장재/적재함 제외, 매칭용)
|
||||
export async function getGeneralItems(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
const pool = getPool();
|
||||
|
||||
// 포장재/적재함 division 코드 조회
|
||||
const catResult = await pool.query(
|
||||
`SELECT value_code FROM category_values
|
||||
WHERE table_name = 'item_info' AND column_name = 'division'
|
||||
AND value_label IN ('포장재', '적재함') AND company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const excludeCodes = catResult.rows.map((r: any) => r.value_code);
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (excludeCodes.length > 0) {
|
||||
// 다중 값(콤마 구분) 지원: 포장재/적재함 코드가 포함된 품목 제외
|
||||
const excludeConditions = excludeCodes.map((_: any, i: number) => `$${paramIdx + i} = ANY(string_to_array(division, ','))`);
|
||||
conditions.push(`(division IS NULL OR division = '' OR NOT (${excludeConditions.join(" OR ")}))`);
|
||||
params.push(...excludeCodes);
|
||||
paramIdx += excludeCodes.length;
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, item_number, item_name, size AS spec, material, unit, division
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name
|
||||
LIMIT 200`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("일반 품목 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,9 +148,9 @@ export async function getProcessEquipments(req: AuthenticatedRequest, res: Respo
|
||||
const { processCode } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT pe.*, ei.equipment_name
|
||||
`SELECT pe.*, em.equipment_name
|
||||
FROM process_equipment pe
|
||||
LEFT JOIN equipment_info ei ON pe.equipment_code = ei.equipment_code AND pe.company_code = ei.company_code
|
||||
LEFT JOIN equipment_mng em ON pe.equipment_code = em.equipment_code AND pe.company_code = em.company_code
|
||||
WHERE pe.process_code = $1 AND pe.company_code = $2
|
||||
ORDER BY pe.equipment_code`,
|
||||
[processCode, companyCode]
|
||||
@@ -214,7 +214,7 @@ export async function getEquipmentList(req: AuthenticatedRequest, res: Response)
|
||||
const params = companyCode === "*" ? [] : [companyCode];
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`,
|
||||
`SELECT objid AS id, equipment_code, equipment_name FROM equipment_mng ${condition} ORDER BY equipment_code`,
|
||||
params
|
||||
);
|
||||
|
||||
|
||||
@@ -40,6 +40,28 @@ export async function getStockShortage(req: AuthenticatedRequest, res: Response)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 생산계획 목록 조회 ───
|
||||
|
||||
export async function getPlans(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { productType, status, startDate, endDate, itemCode } = req.query;
|
||||
|
||||
const data = await productionService.getPlans(companyCode, {
|
||||
productType: productType as string,
|
||||
status: status as string,
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
itemCode: itemCode as string,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("생산계획 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 생산계획 상세 조회 ───
|
||||
|
||||
export async function getPlanById(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
@@ -170,6 +170,42 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
insertedRows.push(result.rows[0]);
|
||||
|
||||
// 재고 업데이트 (inventory_stock): 입고 수량 증가
|
||||
const itemCode = item.item_number || null;
|
||||
const whCode = warehouse_code || item.warehouse_code || null;
|
||||
const locCode = location_code || item.location_code || null;
|
||||
const inQty = Number(item.inbound_qty) || 0;
|
||||
if (itemCode && inQty > 0) {
|
||||
const existingStock = await client.query(
|
||||
`SELECT id FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
|
||||
AND COALESCE(location_code, '') = COALESCE($4, '')
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode, whCode || '', locCode || '']
|
||||
);
|
||||
|
||||
if (existingStock.rows.length > 0) {
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text),
|
||||
last_in_date = NOW(),
|
||||
updated_date = NOW()
|
||||
WHERE id = $2`,
|
||||
[inQty, existingStock.rows[0].id]
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`INSERT INTO inventory_stock (
|
||||
company_code, item_code, warehouse_code, location_code,
|
||||
current_qty, safety_qty, last_in_date,
|
||||
created_date, updated_date, writer
|
||||
) VALUES ($1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`,
|
||||
[companyCode, itemCode, whCode, locCode, String(inQty), userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 구매입고인 경우 발주의 received_qty 업데이트
|
||||
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
|
||||
await client.query(
|
||||
|
||||
@@ -472,6 +472,10 @@ export const addRoleMembers = async (
|
||||
req.user?.userId || "SYSTEM"
|
||||
);
|
||||
|
||||
// 권한 변경된 사용자들의 JWT 토큰 무효화
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(userIds);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: true,
|
||||
message: "권한 그룹 멤버 추가 성공",
|
||||
@@ -568,6 +572,13 @@ export const updateRoleMembers = async (
|
||||
);
|
||||
}
|
||||
|
||||
// 권한 변경된 사용자들의 JWT 토큰 무효화
|
||||
const allAffectedUsers = [...new Set([...toAdd, ...toRemove])];
|
||||
if (allAffectedUsers.length > 0) {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(allAffectedUsers);
|
||||
}
|
||||
|
||||
logger.info("권한 그룹 멤버 일괄 업데이트 성공", {
|
||||
masterObjid,
|
||||
added: toAdd.length,
|
||||
@@ -646,6 +657,10 @@ export const removeRoleMembers = async (
|
||||
req.user?.userId || "SYSTEM"
|
||||
);
|
||||
|
||||
// 권한 변경된 사용자들의 JWT 토큰 무효화
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(userIds);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: true,
|
||||
message: "권한 그룹 멤버 제거 성공",
|
||||
@@ -777,6 +792,18 @@ export const setMenuPermissions = async (
|
||||
req.user?.userId || "SYSTEM"
|
||||
);
|
||||
|
||||
// 해당 권한 그룹의 모든 멤버 JWT 토큰 무효화
|
||||
try {
|
||||
const members = await RoleService.getRoleMembers(authObjid);
|
||||
const memberIds = members.map((m: any) => m.userId);
|
||||
if (memberIds.length > 0) {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(memberIds);
|
||||
}
|
||||
} catch (invalidateError) {
|
||||
logger.warn("메뉴 권한 변경 후 토큰 무효화 실패 (권한 설정은 성공)", { invalidateError });
|
||||
}
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: true,
|
||||
message: "메뉴 권한 설정 성공",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Request, Response, NextFunction } from "express";
|
||||
import { JwtUtils } from "../utils/jwtUtils";
|
||||
import { AuthenticatedRequest, PersonBean } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
import { TokenInvalidationService } from "../services/tokenInvalidationService";
|
||||
|
||||
// AuthenticatedRequest 타입을 다른 모듈에서 사용할 수 있도록 re-export
|
||||
export { AuthenticatedRequest } from "../types/auth";
|
||||
@@ -22,11 +23,11 @@ declare global {
|
||||
* JWT 토큰 검증 미들웨어
|
||||
* 기존 세션 방식과 동일한 효과를 제공
|
||||
*/
|
||||
export const authenticateToken = (
|
||||
export const authenticateToken = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Authorization 헤더에서 토큰 추출
|
||||
const authHeader = req.get("Authorization");
|
||||
@@ -46,6 +47,25 @@ export const authenticateToken = (
|
||||
// JWT 토큰 검증 및 사용자 정보 추출
|
||||
const userInfo: PersonBean = JwtUtils.verifyToken(token);
|
||||
|
||||
// token_version 검증 (JWT payload vs DB)
|
||||
const decoded = JwtUtils.decodeToken(token);
|
||||
const tokenVersion = decoded?.tokenVersion;
|
||||
|
||||
// tokenVersion이 undefined면 구버전 토큰이므로 통과 (하위 호환)
|
||||
if (tokenVersion !== undefined) {
|
||||
const dbVersion = await TokenInvalidationService.getUserTokenVersion(userInfo.userId);
|
||||
if (tokenVersion !== dbVersion) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "TOKEN_INVALIDATED",
|
||||
details: "보안 정책에 의해 재로그인이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 요청 객체에 사용자 정보 설정 (기존 PersonBean과 동일)
|
||||
req.user = userInfo;
|
||||
|
||||
@@ -173,11 +193,11 @@ export const requireUserOrAdmin = (targetUserId: string) => {
|
||||
* 토큰 갱신 미들웨어
|
||||
* 토큰이 곧 만료될 경우 자동으로 갱신
|
||||
*/
|
||||
export const refreshTokenIfNeeded = (
|
||||
export const refreshTokenIfNeeded = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
@@ -191,6 +211,16 @@ export const refreshTokenIfNeeded = (
|
||||
|
||||
// 1시간(3600초) 이내에 만료되는 경우 갱신
|
||||
if (timeUntilExpiry > 0 && timeUntilExpiry < 3600) {
|
||||
// 갱신 전 token_version 검증
|
||||
if (decoded.tokenVersion !== undefined) {
|
||||
const dbVersion = await TokenInvalidationService.getUserTokenVersion(decoded.userId);
|
||||
if (decoded.tokenVersion !== dbVersion) {
|
||||
// 무효화된 토큰은 갱신하지 않음
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newToken = JwtUtils.refreshToken(token);
|
||||
|
||||
// 새로운 토큰을 응답 헤더에 포함
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
saveUser, // 사용자 등록/수정
|
||||
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
|
||||
getUserWithDept, // 사원 + 부서 조회 (NEW!)
|
||||
deleteUser, // 사용자 삭제 (soft delete)
|
||||
getCompanyList,
|
||||
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
||||
getCompanyByCode, // 회사 단건 조회
|
||||
@@ -62,6 +63,7 @@ router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
|
||||
router.put("/profile", updateProfile); // 프로필 수정
|
||||
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
||||
router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화
|
||||
router.delete("/users/:userId", deleteUser); // 사용자 삭제 (soft delete)
|
||||
|
||||
// 부서 관리 API
|
||||
router.get("/departments", getDepartmentList); // 부서 목록 조회
|
||||
|
||||
@@ -55,6 +55,15 @@ router.get(
|
||||
entityJoinController.getTableDataWithJoins.bind(entityJoinController)
|
||||
);
|
||||
|
||||
/**
|
||||
* 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용)
|
||||
* GET /api/table-management/tables/:tableName/column-values/:columnName
|
||||
*/
|
||||
router.get(
|
||||
"/tables/:tableName/column-values/:columnName",
|
||||
entityJoinController.getColumnUniqueValues.bind(entityJoinController)
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 🎯 Entity 조인 설정 관리
|
||||
// ========================================
|
||||
|
||||
40
backend-node/src/routes/outboundRoutes.ts
Normal file
40
backend-node/src/routes/outboundRoutes.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 출고관리 라우트
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as outboundController from "../controllers/outboundController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 출고 목록 조회
|
||||
router.get("/list", outboundController.getList);
|
||||
|
||||
// 출고번호 자동생성
|
||||
router.get("/generate-number", outboundController.generateNumber);
|
||||
|
||||
// 창고 목록 조회
|
||||
router.get("/warehouses", outboundController.getWarehouses);
|
||||
|
||||
// 소스 데이터: 출하지시 (판매출고)
|
||||
router.get("/source/shipment-instructions", outboundController.getShipmentInstructions);
|
||||
|
||||
// 소스 데이터: 발주 (반품출고)
|
||||
router.get("/source/purchase-orders", outboundController.getPurchaseOrders);
|
||||
|
||||
// 소스 데이터: 품목 (기타출고)
|
||||
router.get("/source/items", outboundController.getItems);
|
||||
|
||||
// 출고 등록
|
||||
router.post("/", outboundController.create);
|
||||
|
||||
// 출고 수정
|
||||
router.put("/:id", outboundController.update);
|
||||
|
||||
// 출고 삭제
|
||||
router.delete("/:id", outboundController.deleteOutbound);
|
||||
|
||||
export default router;
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
|
||||
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
|
||||
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
|
||||
getItemsByDivision, getGeneralItems,
|
||||
} from "../controllers/packagingController";
|
||||
|
||||
const router = Router();
|
||||
@@ -33,4 +34,8 @@ router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
|
||||
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
|
||||
router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg);
|
||||
|
||||
// 품목정보 연동 (division별)
|
||||
router.get("/items/general", getGeneralItems);
|
||||
router.get("/items/:divisionLabel", getItemsByDivision);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -16,6 +16,9 @@ router.get("/order-summary", productionController.getOrderSummary);
|
||||
// 안전재고 부족분 조회
|
||||
router.get("/stock-shortage", productionController.getStockShortage);
|
||||
|
||||
// 생산계획 목록 조회
|
||||
router.get("/plans", productionController.getPlans);
|
||||
|
||||
// 생산계획 CRUD
|
||||
router.get("/plan/:id", productionController.getPlanById);
|
||||
router.put("/plan/:id", productionController.updatePlan);
|
||||
|
||||
@@ -24,7 +24,8 @@ export type AuditAction =
|
||||
| "STATUS_CHANGE"
|
||||
| "BATCH_CREATE"
|
||||
| "BATCH_UPDATE"
|
||||
| "BATCH_DELETE";
|
||||
| "BATCH_DELETE"
|
||||
| "DEPT_CHANGE_WARNING";
|
||||
|
||||
export type AuditResourceType =
|
||||
| "MENU"
|
||||
|
||||
@@ -134,12 +134,14 @@ export class AuthService {
|
||||
company_code: string | null;
|
||||
locale: string | null;
|
||||
photo: Buffer | null;
|
||||
token_version: number | null;
|
||||
}>(
|
||||
`SELECT
|
||||
sabun, user_id, user_name, user_name_eng, user_name_cn,
|
||||
dept_code, dept_name, position_code, position_name,
|
||||
email, tel, cell_phone, user_type, user_type_name,
|
||||
partner_objid, company_code, locale, photo
|
||||
partner_objid, company_code, locale, photo,
|
||||
COALESCE(token_version, 0) as token_version
|
||||
FROM user_info
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
@@ -210,6 +212,7 @@ export class AuthService {
|
||||
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
|
||||
: undefined,
|
||||
locale: userInfo.locale || "KR",
|
||||
tokenVersion: userInfo.token_version ?? 0,
|
||||
// 권한 레벨 정보 추가 (3단계 체계)
|
||||
isSuperAdmin: companyCode === "*" && userType === "SUPER_ADMIN",
|
||||
isCompanyAdmin: userType === "COMPANY_ADMIN" && companyCode !== "*",
|
||||
|
||||
@@ -673,6 +673,22 @@ export class MultiLangService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 키의 소유 회사 코드 조회 (권한 검증용)
|
||||
*/
|
||||
async getKeyCompanyCode(keyId: number): Promise<string | null> {
|
||||
try {
|
||||
const result = await queryOne<{ company_code: string }>(
|
||||
`SELECT company_code FROM multi_lang_key_master WHERE key_id = $1`,
|
||||
[keyId]
|
||||
);
|
||||
return result?.company_code || null;
|
||||
} catch (error) {
|
||||
logger.error("키 소유 회사 코드 조회 실패:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다국어 키 목록 조회
|
||||
*/
|
||||
@@ -688,6 +704,10 @@ export class MultiLangService {
|
||||
if (params.companyCode) {
|
||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||
values.push(params.companyCode);
|
||||
} else if (params.userCompanyCode && params.userCompanyCode !== "*") {
|
||||
// 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환
|
||||
whereConditions.push(`company_code IN ($${paramIndex++}, '*')`);
|
||||
values.push(params.userCompanyCode);
|
||||
}
|
||||
|
||||
// 메뉴 코드 필터
|
||||
|
||||
@@ -35,6 +35,33 @@ export async function getOrderSummary(
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
// item_info에 lead_time 컬럼이 존재하는지 확인
|
||||
const leadTimeColCheck = await pool.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'item_info' AND column_name = 'lead_time'
|
||||
) AS has_lead_time
|
||||
`);
|
||||
const hasLeadTime = leadTimeColCheck.rows[0]?.has_lead_time === true;
|
||||
|
||||
const itemLeadTimeCte = hasLeadTime
|
||||
? `item_lead_time AS (
|
||||
SELECT
|
||||
item_number,
|
||||
id AS item_id,
|
||||
COALESCE(lead_time::int, 0) AS lead_time
|
||||
FROM item_info
|
||||
WHERE company_code = $1
|
||||
),`
|
||||
: `item_lead_time AS (
|
||||
SELECT
|
||||
item_number,
|
||||
id AS item_id,
|
||||
0 AS lead_time
|
||||
FROM item_info
|
||||
WHERE company_code = $1
|
||||
),`;
|
||||
|
||||
const query = `
|
||||
WITH order_summary AS (
|
||||
SELECT
|
||||
@@ -49,6 +76,7 @@ export async function getOrderSummary(
|
||||
WHERE ${whereClause}
|
||||
GROUP BY so.part_code, so.part_name
|
||||
),
|
||||
${itemLeadTimeCte}
|
||||
stock_info AS (
|
||||
SELECT
|
||||
item_code,
|
||||
@@ -85,10 +113,12 @@ export async function getOrderSummary(
|
||||
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
|
||||
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
|
||||
0
|
||||
) AS required_plan_qty
|
||||
) AS required_plan_qty,
|
||||
COALESCE(ilt.lead_time, 0) AS lead_time
|
||||
FROM order_summary os
|
||||
LEFT JOIN stock_info si ON os.item_code = si.item_code
|
||||
LEFT JOIN plan_info pi ON os.item_code = pi.item_code
|
||||
LEFT JOIN item_lead_time ilt ON (os.item_code = ilt.item_number OR os.item_code = ilt.item_id)
|
||||
${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""}
|
||||
ORDER BY os.item_code;
|
||||
`;
|
||||
@@ -155,6 +185,80 @@ export async function getStockShortage(companyCode: string) {
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// ─── 생산계획 목록 조회 ───
|
||||
|
||||
export async function getPlans(
|
||||
companyCode: string,
|
||||
options?: {
|
||||
productType?: string;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
itemCode?: string;
|
||||
}
|
||||
) {
|
||||
const pool = getPool();
|
||||
const conditions: string[] = ["p.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
// 일반 회사: 자사 데이터만
|
||||
} else {
|
||||
// 최고관리자: 전체 데이터 (company_code 조건 제거)
|
||||
conditions.length = 0;
|
||||
}
|
||||
|
||||
if (options?.productType) {
|
||||
conditions.push(`COALESCE(p.product_type, '완제품') = $${paramIdx}`);
|
||||
params.push(options.productType);
|
||||
paramIdx++;
|
||||
}
|
||||
if (options?.status && options.status !== "all") {
|
||||
conditions.push(`p.status = $${paramIdx}`);
|
||||
params.push(options.status);
|
||||
paramIdx++;
|
||||
}
|
||||
if (options?.startDate) {
|
||||
conditions.push(`p.end_date >= $${paramIdx}::date`);
|
||||
params.push(options.startDate);
|
||||
paramIdx++;
|
||||
}
|
||||
if (options?.endDate) {
|
||||
conditions.push(`p.start_date <= $${paramIdx}::date`);
|
||||
params.push(options.endDate);
|
||||
paramIdx++;
|
||||
}
|
||||
if (options?.itemCode) {
|
||||
conditions.push(`(p.item_code ILIKE $${paramIdx} OR p.item_name ILIKE $${paramIdx})`);
|
||||
params.push(`%${options.itemCode}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
p.id, p.company_code, p.plan_no, p.plan_date,
|
||||
p.item_code, p.item_name, p.product_type,
|
||||
p.plan_qty, p.completed_qty, p.progress_rate,
|
||||
p.start_date, p.end_date, p.due_date,
|
||||
p.equipment_id, p.equipment_code, p.equipment_name,
|
||||
p.status, p.priority, p.work_shift,
|
||||
p.work_order_no, p.manager_name,
|
||||
p.order_no, p.parent_plan_id, p.remarks,
|
||||
p.hourly_capacity, p.daily_capacity, p.lead_time,
|
||||
p.created_date, p.updated_date
|
||||
FROM production_plan_mng p
|
||||
${whereClause}
|
||||
ORDER BY p.start_date ASC, p.item_code ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
logger.info("생산계획 목록 조회", { companyCode, count: result.rowCount });
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// ─── 생산계획 CRUD ───
|
||||
|
||||
export async function getPlanById(companyCode: string, planId: number) {
|
||||
@@ -267,49 +371,81 @@ export async function previewSchedule(
|
||||
const deletedSchedules: any[] = [];
|
||||
const keptSchedules: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (options.recalculate_unstarted) {
|
||||
// 삭제 대상(planned) 상세 조회
|
||||
// 같은 item_code에 대한 삭제/유지 조회는 한 번만 수행
|
||||
if (options.recalculate_unstarted) {
|
||||
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
|
||||
for (const itemCode of uniqueItemCodes) {
|
||||
const deleteResult = await pool.query(
|
||||
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status = 'planned'`,
|
||||
[companyCode, item.item_code, productType]
|
||||
[companyCode, itemCode, productType]
|
||||
);
|
||||
deletedSchedules.push(...deleteResult.rows);
|
||||
|
||||
// 유지 대상(진행중 등) 상세 조회
|
||||
const keptResult = await pool.query(
|
||||
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status NOT IN ('planned', 'completed', 'cancelled')`,
|
||||
[companyCode, item.item_code, productType]
|
||||
[companyCode, itemCode, productType]
|
||||
);
|
||||
keptSchedules.push(...keptResult.rows);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const dailyCapacity = item.daily_capacity || 800;
|
||||
const requiredQty = item.required_qty;
|
||||
const itemLeadTime = item.lead_time || 0;
|
||||
|
||||
let requiredQty = item.required_qty;
|
||||
|
||||
// recalculate_unstarted 시, 삭제된 수량을 비율로 분배
|
||||
if (options.recalculate_unstarted) {
|
||||
const deletedQtyForItem = deletedSchedules
|
||||
.filter((d: any) => d.item_code === item.item_code)
|
||||
.reduce((sum: number, d: any) => sum + (parseFloat(d.plan_qty) || 0), 0);
|
||||
if (deletedQtyForItem > 0) {
|
||||
const totalRequestedForItem = items
|
||||
.filter((i) => i.item_code === item.item_code)
|
||||
.reduce((sum, i) => sum + i.required_qty, 0);
|
||||
if (totalRequestedForItem > 0) {
|
||||
requiredQty += Math.round(deletedQtyForItem * (item.required_qty / totalRequestedForItem));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
const productionDays = Math.ceil(requiredQty / dailyCapacity);
|
||||
|
||||
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
|
||||
const dueDate = new Date(item.earliest_due_date);
|
||||
const endDate = new Date(dueDate);
|
||||
endDate.setDate(endDate.getDate() - safetyLeadTime);
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - productionDays);
|
||||
let startDate: Date;
|
||||
let endDate: Date;
|
||||
|
||||
if (itemLeadTime > 0) {
|
||||
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
|
||||
endDate = new Date(dueDate);
|
||||
startDate = new Date(dueDate);
|
||||
startDate.setDate(startDate.getDate() - itemLeadTime);
|
||||
} else {
|
||||
// 리드타임이 없으면 기존 로직 (생산능력 기반)
|
||||
const productionDays = Math.ceil(requiredQty / dailyCapacity);
|
||||
endDate = new Date(dueDate);
|
||||
endDate.setDate(endDate.getDate() - safetyLeadTime);
|
||||
startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - productionDays);
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (startDate < today) {
|
||||
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
startDate.setTime(today.getTime());
|
||||
endDate.setTime(startDate.getTime());
|
||||
endDate.setDate(endDate.getDate() + productionDays);
|
||||
endDate.setDate(endDate.getDate() + duration);
|
||||
}
|
||||
|
||||
// 해당 품목의 수주 건수 확인
|
||||
@@ -326,10 +462,11 @@ export async function previewSchedule(
|
||||
required_qty: requiredQty,
|
||||
daily_capacity: dailyCapacity,
|
||||
hourly_capacity: item.hourly_capacity || 100,
|
||||
production_days: productionDays,
|
||||
production_days: itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity),
|
||||
start_date: startDate.toISOString().split("T")[0],
|
||||
end_date: endDate.toISOString().split("T")[0],
|
||||
due_date: item.earliest_due_date,
|
||||
lead_time: itemLeadTime,
|
||||
order_count: orderCount,
|
||||
status: "planned",
|
||||
});
|
||||
@@ -343,7 +480,7 @@ export async function previewSchedule(
|
||||
};
|
||||
|
||||
logger.info("자동 스케줄 미리보기", { companyCode, summary });
|
||||
return { summary, previews, deletedSchedules, keptSchedules };
|
||||
return { summary, schedules: previews, deletedSchedules, keptSchedules };
|
||||
}
|
||||
|
||||
export async function generateSchedule(
|
||||
@@ -363,10 +500,22 @@ export async function generateSchedule(
|
||||
let deletedCount = 0;
|
||||
let keptCount = 0;
|
||||
const newSchedules: any[] = [];
|
||||
const deletedQtyByItem = new Map<string, number>();
|
||||
|
||||
// 같은 item_code에 대한 삭제는 한 번만 수행
|
||||
if (options.recalculate_unstarted) {
|
||||
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
|
||||
for (const itemCode of uniqueItemCodes) {
|
||||
const deletedQtyResult = await client.query(
|
||||
`SELECT COALESCE(SUM(COALESCE(plan_qty::numeric, 0)), 0) AS deleted_qty
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status = 'planned'`,
|
||||
[companyCode, itemCode, productType]
|
||||
);
|
||||
deletedQtyByItem.set(itemCode, parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0);
|
||||
|
||||
for (const item of items) {
|
||||
// 기존 미진행(planned) 스케줄 처리
|
||||
if (options.recalculate_unstarted) {
|
||||
const deleteResult = await client.query(
|
||||
`DELETE FROM production_plan_mng
|
||||
WHERE company_code = $1
|
||||
@@ -374,7 +523,7 @@ export async function generateSchedule(
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status = 'planned'
|
||||
RETURNING id`,
|
||||
[companyCode, item.item_code, productType]
|
||||
[companyCode, itemCode, productType]
|
||||
);
|
||||
deletedCount += deleteResult.rowCount || 0;
|
||||
|
||||
@@ -384,32 +533,58 @@ export async function generateSchedule(
|
||||
AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status NOT IN ('planned', 'completed', 'cancelled')`,
|
||||
[companyCode, item.item_code, productType]
|
||||
[companyCode, itemCode, productType]
|
||||
);
|
||||
keptCount += parseInt(keptResult.rows[0].cnt, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// 생산일수 계산
|
||||
for (const item of items) {
|
||||
// 필요 수량 계산 (삭제된 planned 수량을 비율로 분배)
|
||||
const dailyCapacity = item.daily_capacity || 800;
|
||||
const requiredQty = item.required_qty;
|
||||
const itemLeadTime = item.lead_time || 0;
|
||||
let requiredQty = item.required_qty;
|
||||
|
||||
if (options.recalculate_unstarted) {
|
||||
const deletedQty = deletedQtyByItem.get(item.item_code) || 0;
|
||||
if (deletedQty > 0) {
|
||||
const totalRequestedForItem = items
|
||||
.filter((i) => i.item_code === item.item_code)
|
||||
.reduce((sum, i) => sum + i.required_qty, 0);
|
||||
if (totalRequestedForItem > 0) {
|
||||
requiredQty += Math.round(deletedQty * (item.required_qty / totalRequestedForItem));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
const productionDays = Math.ceil(requiredQty / dailyCapacity);
|
||||
|
||||
// 시작일 = 납기일 - 생산일수 - 안전리드타임
|
||||
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
|
||||
const dueDate = new Date(item.earliest_due_date);
|
||||
const endDate = new Date(dueDate);
|
||||
endDate.setDate(endDate.getDate() - safetyLeadTime);
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - productionDays);
|
||||
let startDate: Date;
|
||||
let endDate: Date;
|
||||
|
||||
if (itemLeadTime > 0) {
|
||||
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
|
||||
endDate = new Date(dueDate);
|
||||
startDate = new Date(dueDate);
|
||||
startDate.setDate(startDate.getDate() - itemLeadTime);
|
||||
} else {
|
||||
// 리드타임이 없으면 기존 로직 (생산능력 기반)
|
||||
const productionDays = Math.ceil(requiredQty / dailyCapacity);
|
||||
endDate = new Date(dueDate);
|
||||
endDate.setDate(endDate.getDate() - safetyLeadTime);
|
||||
startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - productionDays);
|
||||
}
|
||||
|
||||
// 시작일이 오늘보다 이전이면 오늘로 조정
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (startDate < today) {
|
||||
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
startDate.setTime(today.getTime());
|
||||
endDate.setTime(startDate.getTime());
|
||||
endDate.setDate(endDate.getDate() + productionDays);
|
||||
endDate.setDate(endDate.getDate() + duration);
|
||||
}
|
||||
|
||||
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
|
||||
@@ -576,13 +751,24 @@ async function getBomChildItems(
|
||||
companyCode: string,
|
||||
itemCode: string
|
||||
) {
|
||||
// item_info에 lead_time 컬럼 존재 여부 확인
|
||||
const colCheck = await client.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'item_info' AND column_name = 'lead_time'
|
||||
) AS has_lead_time
|
||||
`);
|
||||
const hasLeadTime = colCheck.rows[0]?.has_lead_time === true;
|
||||
const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time::int, 0)" : "0";
|
||||
|
||||
const bomQuery = `
|
||||
SELECT
|
||||
bd.child_item_id,
|
||||
ii.item_name AS child_item_name,
|
||||
ii.item_number AS child_item_code,
|
||||
bd.quantity AS bom_qty,
|
||||
bd.unit
|
||||
bd.unit,
|
||||
${leadTimeCol} AS child_lead_time
|
||||
FROM bom b
|
||||
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
|
||||
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code
|
||||
@@ -641,9 +827,12 @@ export async function previewSemiSchedule(
|
||||
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
|
||||
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
|
||||
const semiDueDate = plan.start_date;
|
||||
const semiEndDate = new Date(plan.start_date);
|
||||
const semiStartDate = new Date(plan.start_date);
|
||||
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
|
||||
semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
|
||||
|
||||
previews.push({
|
||||
parent_plan_id: plan.id,
|
||||
@@ -653,13 +842,14 @@ export async function previewSemiSchedule(
|
||||
item_name: bomItem.child_item_name || bomItem.child_item_id,
|
||||
plan_qty: requiredQty,
|
||||
bom_qty: parseFloat(bomItem.bom_qty) || 1,
|
||||
lead_time: childLeadTime,
|
||||
start_date: semiStartDate.toISOString().split("T")[0],
|
||||
end_date: typeof semiDueDate === "string"
|
||||
? semiDueDate.split("T")[0]
|
||||
: new Date(semiDueDate).toISOString().split("T")[0],
|
||||
: semiEndDate.toISOString().split("T")[0],
|
||||
due_date: typeof semiDueDate === "string"
|
||||
? semiDueDate.split("T")[0]
|
||||
: new Date(semiDueDate).toISOString().split("T")[0],
|
||||
: semiEndDate.toISOString().split("T")[0],
|
||||
product_type: "반제품",
|
||||
status: "planned",
|
||||
});
|
||||
@@ -683,7 +873,7 @@ export async function previewSemiSchedule(
|
||||
parent_count: plansResult.rowCount,
|
||||
};
|
||||
|
||||
return { summary, previews, deletedSchedules, keptSchedules };
|
||||
return { summary, schedules: previews, deletedSchedules, keptSchedules };
|
||||
}
|
||||
|
||||
// ─── 반제품 계획 자동 생성 ───
|
||||
@@ -740,10 +930,12 @@ export async function generateSemiSchedule(
|
||||
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
|
||||
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
|
||||
const semiDueDate = plan.start_date;
|
||||
const semiEndDate = plan.start_date;
|
||||
const semiStartDate = new Date(plan.start_date);
|
||||
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
|
||||
semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
|
||||
|
||||
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
|
||||
const planNoResult = await client.query(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { query } from "../database/db";
|
||||
import { query, transaction } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
@@ -145,10 +145,19 @@ export class RoleService {
|
||||
writer: string;
|
||||
}): Promise<RoleGroup> {
|
||||
try {
|
||||
// 동일 회사 내 같은 이름의 권한 그룹 중복 체크
|
||||
const dupCheck = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) AS count FROM authority_master WHERE company_code = $1 AND auth_name = $2`,
|
||||
[data.companyCode, data.authName]
|
||||
);
|
||||
if (dupCheck.length > 0 && parseInt(dupCheck[0].count, 10) > 0) {
|
||||
throw new Error(`동일 회사 내에 이미 같은 이름의 권한 그룹이 존재합니다: ${data.authName}`);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate)
|
||||
VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW())
|
||||
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
|
||||
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
|
||||
company_code AS "companyCode", status, writer, regdate
|
||||
`;
|
||||
|
||||
@@ -460,35 +469,37 @@ export class RoleService {
|
||||
writer: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 기존 권한 삭제
|
||||
await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
|
||||
authObjid,
|
||||
]);
|
||||
|
||||
// 새로운 권한 삽입
|
||||
if (permissions.length > 0) {
|
||||
const values = permissions
|
||||
.map(
|
||||
(_, index) =>
|
||||
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
const params = permissions.flatMap((p) => [
|
||||
p.menuObjid,
|
||||
p.createYn,
|
||||
p.readYn,
|
||||
p.updateYn,
|
||||
p.deleteYn,
|
||||
await transaction(async (client) => {
|
||||
// 기존 권한 삭제
|
||||
await client.query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
|
||||
authObjid,
|
||||
]);
|
||||
|
||||
const sql = `
|
||||
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
|
||||
VALUES ${values}
|
||||
`;
|
||||
// 새로운 권한 삽입
|
||||
if (permissions.length > 0) {
|
||||
const values = permissions
|
||||
.map(
|
||||
(_, index) =>
|
||||
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
await query(sql, [authObjid, ...params, writer]);
|
||||
}
|
||||
const params = permissions.flatMap((p) => [
|
||||
p.menuObjid,
|
||||
p.createYn,
|
||||
p.readYn,
|
||||
p.updateYn,
|
||||
p.deleteYn,
|
||||
]);
|
||||
|
||||
const sql = `
|
||||
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
|
||||
VALUES ${values}
|
||||
`;
|
||||
|
||||
await client.query(sql, [authObjid, ...params, writer]);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info("메뉴 권한 설정 성공", {
|
||||
authObjid,
|
||||
|
||||
@@ -211,7 +211,8 @@ class TableCategoryValueService {
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
updated_by AS "updatedBy",
|
||||
path
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
@@ -1441,7 +1442,7 @@ class TableCategoryValueService {
|
||||
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
|
||||
if (companyCode === "*") {
|
||||
query = `
|
||||
SELECT DISTINCT value_code, value_label
|
||||
SELECT DISTINCT value_code, value_label, path
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders1})
|
||||
`;
|
||||
@@ -1449,7 +1450,7 @@ class TableCategoryValueService {
|
||||
} else {
|
||||
const companyIdx = n + 1;
|
||||
query = `
|
||||
SELECT DISTINCT value_code, value_label
|
||||
SELECT DISTINCT value_code, value_label, path
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders1})
|
||||
AND (company_code = $${companyIdx} OR company_code = '*')
|
||||
@@ -1460,10 +1461,15 @@ class TableCategoryValueService {
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
// { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선)
|
||||
// path가 있고 '/'를 포함하면(depth>1) 전체 경로를 ' > ' 구분자로 표시
|
||||
const labels: Record<string, string> = {};
|
||||
for (const row of result.rows) {
|
||||
if (!labels[row.value_code]) {
|
||||
labels[row.value_code] = row.value_label;
|
||||
if (row.path && row.path.includes('/')) {
|
||||
labels[row.value_code] = row.path.replace(/\//g, ' > ');
|
||||
} else {
|
||||
labels[row.value_code] = row.value_label;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1575,7 +1575,7 @@ export class TableManagementService {
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
return {
|
||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||
whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
|
||||
values: [actualValue],
|
||||
paramCount: 1,
|
||||
};
|
||||
@@ -1859,10 +1859,10 @@ export class TableManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
|
||||
// select 필터(equals)인 경우 — 다중 값(콤마 구분) 지원
|
||||
if (operator === "equals") {
|
||||
return {
|
||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||
whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
|
||||
values: [String(value)],
|
||||
paramCount: 1,
|
||||
};
|
||||
@@ -2717,6 +2717,43 @@ export class TableManagementService {
|
||||
logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`);
|
||||
}
|
||||
|
||||
// entity 컬럼의 display_column 자동 채우기 (예: supplier_code → supplier_name)
|
||||
try {
|
||||
const companyCode = data.company_code || "*";
|
||||
const entityColsResult = await query<any>(
|
||||
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'entity'
|
||||
AND reference_table IS NOT NULL AND reference_table != ''
|
||||
AND display_column IS NOT NULL AND display_column != ''
|
||||
AND company_code IN ($2, '*')
|
||||
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
for (const ec of entityColsResult) {
|
||||
const srcVal = data[ec.column_name];
|
||||
const displayCol = ec.display_column;
|
||||
// display_column이 테이블에 존재하고, 값이 비어있거나 없으면 자동 조회
|
||||
if (srcVal && columnTypeMap.has(displayCol) && (!data[displayCol] || data[displayCol] === "")) {
|
||||
try {
|
||||
const refResult = await query<any>(
|
||||
`SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`,
|
||||
[srcVal, companyCode]
|
||||
);
|
||||
if (refResult.length > 0 && refResult[0][displayCol]) {
|
||||
data[displayCol] = refResult[0][displayCol];
|
||||
logger.info(`Entity auto-fill: ${tableName}.${displayCol} = ${data[displayCol]} (from ${ec.reference_table})`);
|
||||
}
|
||||
} catch (refErr: any) {
|
||||
logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (entityErr: any) {
|
||||
logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`);
|
||||
}
|
||||
|
||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
|
||||
const skippedColumns: string[] = [];
|
||||
const existingColumns = Object.keys(data).filter((col) => {
|
||||
@@ -2868,6 +2905,42 @@ export class TableManagementService {
|
||||
logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`);
|
||||
}
|
||||
|
||||
// entity 컬럼의 display_column 자동 채우기 (수정 시)
|
||||
try {
|
||||
const companyCode = updatedData.company_code || originalData.company_code || "*";
|
||||
const entityColsResult = await query<any>(
|
||||
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'entity'
|
||||
AND reference_table IS NOT NULL AND reference_table != ''
|
||||
AND display_column IS NOT NULL AND display_column != ''
|
||||
AND company_code IN ($2, '*')
|
||||
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
for (const ec of entityColsResult) {
|
||||
const srcVal = updatedData[ec.column_name];
|
||||
const displayCol = ec.display_column;
|
||||
if (srcVal && columnTypeMap.has(displayCol) && (!updatedData[displayCol] || updatedData[displayCol] === "")) {
|
||||
try {
|
||||
const refResult = await query<any>(
|
||||
`SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`,
|
||||
[srcVal, companyCode]
|
||||
);
|
||||
if (refResult.length > 0 && refResult[0][displayCol]) {
|
||||
updatedData[displayCol] = refResult[0][displayCol];
|
||||
logger.info(`Entity auto-fill (edit): ${tableName}.${displayCol} = ${updatedData[displayCol]}`);
|
||||
}
|
||||
} catch (refErr: any) {
|
||||
logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (entityErr: any) {
|
||||
logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`);
|
||||
}
|
||||
|
||||
// SET 절 생성 (수정할 데이터) - 먼저 생성
|
||||
// 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외)
|
||||
const setConditions: string[] = [];
|
||||
@@ -3357,16 +3430,20 @@ export class TableManagementService {
|
||||
const safeColumn = `main."${columnName}"`;
|
||||
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
case "equals": {
|
||||
const safeVal = String(value).replace(/'/g, "''");
|
||||
filterConditions.push(
|
||||
`${safeColumn} = '${String(value).replace(/'/g, "''")}'`
|
||||
`('${safeVal}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal}')`
|
||||
);
|
||||
break;
|
||||
case "not_equals":
|
||||
}
|
||||
case "not_equals": {
|
||||
const safeVal2 = String(value).replace(/'/g, "''");
|
||||
filterConditions.push(
|
||||
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
|
||||
`NOT ('${safeVal2}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal2}')`
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "in": {
|
||||
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
|
||||
if (inArr.length > 0) {
|
||||
@@ -3408,6 +3485,31 @@ export class TableManagementService {
|
||||
case "is_not_null":
|
||||
filterConditions.push(`${safeColumn} IS NOT NULL`);
|
||||
break;
|
||||
case "not_contains":
|
||||
filterConditions.push(
|
||||
`${safeColumn}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`
|
||||
);
|
||||
break;
|
||||
case "greater_than":
|
||||
filterConditions.push(
|
||||
`(${safeColumn})::numeric > ${parseFloat(String(value))}`
|
||||
);
|
||||
break;
|
||||
case "less_than":
|
||||
filterConditions.push(
|
||||
`(${safeColumn})::numeric < ${parseFloat(String(value))}`
|
||||
);
|
||||
break;
|
||||
case "greater_or_equal":
|
||||
filterConditions.push(
|
||||
`(${safeColumn})::numeric >= ${parseFloat(String(value))}`
|
||||
);
|
||||
break;
|
||||
case "less_or_equal":
|
||||
filterConditions.push(
|
||||
`(${safeColumn})::numeric <= ${parseFloat(String(value))}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3424,6 +3526,89 @@ export class TableManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 filterGroups 처리 (런타임 필터 빌더 - 그룹별 AND/OR 지원)
|
||||
if (
|
||||
options.dataFilter &&
|
||||
options.dataFilter.filterGroups &&
|
||||
options.dataFilter.filterGroups.length > 0
|
||||
) {
|
||||
const groupConditions: string[] = [];
|
||||
|
||||
for (const group of options.dataFilter.filterGroups) {
|
||||
if (!group.conditions || group.conditions.length === 0) continue;
|
||||
|
||||
const conditions: string[] = [];
|
||||
|
||||
for (const condition of group.conditions) {
|
||||
const { columnName, operator, value } = condition;
|
||||
if (!columnName) continue;
|
||||
|
||||
const safeCol = `main."${columnName}"`;
|
||||
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
conditions.push(`${safeCol}::text = '${String(value).replace(/'/g, "''")}'`);
|
||||
break;
|
||||
case "not_equals":
|
||||
conditions.push(`${safeCol}::text != '${String(value).replace(/'/g, "''")}'`);
|
||||
break;
|
||||
case "contains":
|
||||
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}%'`);
|
||||
break;
|
||||
case "not_contains":
|
||||
conditions.push(`${safeCol}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`);
|
||||
break;
|
||||
case "starts_with":
|
||||
conditions.push(`${safeCol}::text LIKE '${String(value).replace(/'/g, "''")}%'`);
|
||||
break;
|
||||
case "ends_with":
|
||||
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}'`);
|
||||
break;
|
||||
case "greater_than":
|
||||
conditions.push(`(${safeCol})::numeric > ${parseFloat(String(value))}`);
|
||||
break;
|
||||
case "less_than":
|
||||
conditions.push(`(${safeCol})::numeric < ${parseFloat(String(value))}`);
|
||||
break;
|
||||
case "greater_or_equal":
|
||||
conditions.push(`(${safeCol})::numeric >= ${parseFloat(String(value))}`);
|
||||
break;
|
||||
case "less_or_equal":
|
||||
conditions.push(`(${safeCol})::numeric <= ${parseFloat(String(value))}`);
|
||||
break;
|
||||
case "is_null":
|
||||
conditions.push(`(${safeCol} IS NULL OR ${safeCol}::text = '')`);
|
||||
break;
|
||||
case "is_not_null":
|
||||
conditions.push(`(${safeCol} IS NOT NULL AND ${safeCol}::text != '')`);
|
||||
break;
|
||||
case "in": {
|
||||
const inArr = Array.isArray(value) ? value : [String(value)];
|
||||
if (inArr.length > 0) {
|
||||
const vals = inArr.map((v) => `'${String(v).replace(/'/g, "''")}'`).join(", ");
|
||||
conditions.push(`${safeCol}::text IN (${vals})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
const logic = group.logic === "OR" ? " OR " : " AND ";
|
||||
groupConditions.push(`(${conditions.join(logic)})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (groupConditions.length > 0) {
|
||||
const groupWhere = groupConditions.join(" AND ");
|
||||
whereClause = whereClause
|
||||
? `${whereClause} AND ${groupWhere}`
|
||||
: groupWhere;
|
||||
|
||||
logger.info(`🔍 필터 그룹 적용 (Entity 조인): ${groupWhere}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||
if (options.excludeFilter && options.excludeFilter.enabled) {
|
||||
const {
|
||||
@@ -5387,4 +5572,40 @@ export class TableManagementService {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용)
|
||||
*/
|
||||
async getColumnDistinctValues(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
companyCode?: string
|
||||
): Promise<{ value: string; label: string }[]> {
|
||||
try {
|
||||
// 테이블명/컬럼명 안전성 검증 (영문, 숫자, 언더스코어만 허용)
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) {
|
||||
logger.warn(`잘못된 테이블/컬럼명: ${tableName}.${columnName}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
let sql = `SELECT DISTINCT "${columnName}"::text as value FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND "${columnName}"::text != ''`;
|
||||
const params: any[] = [];
|
||||
|
||||
if (companyCode) {
|
||||
params.push(companyCode);
|
||||
sql += ` AND "company_code" = $${params.length}`;
|
||||
}
|
||||
|
||||
sql += ` ORDER BY value LIMIT 500`;
|
||||
|
||||
const rows = await query<{ value: string }>(sql, params);
|
||||
return rows.map((row) => ({
|
||||
value: row.value,
|
||||
label: row.value,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error(`컬럼 고유값 조회 실패: ${tableName}.${columnName}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
75
backend-node/src/services/tokenInvalidationService.ts
Normal file
75
backend-node/src/services/tokenInvalidationService.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// JWT 토큰 무효화 서비스
|
||||
// user_info.token_version 기반으로 기존 JWT 토큰을 무효화
|
||||
|
||||
import { query } from "../database/db";
|
||||
import { cache } from "../utils/cache";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const TOKEN_VERSION_CACHE_TTL = 2 * 60 * 1000; // 2분 캐시
|
||||
|
||||
export class TokenInvalidationService {
|
||||
/**
|
||||
* 캐시 키 생성
|
||||
*/
|
||||
static cacheKey(userId: string): string {
|
||||
return `token_version:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 사용자의 토큰 무효화 (token_version +1)
|
||||
*/
|
||||
static async invalidateUserTokens(userId: string): Promise<void> {
|
||||
try {
|
||||
await query(
|
||||
`UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
cache.delete(this.cacheKey(userId));
|
||||
logger.info(`토큰 무효화: ${userId}`);
|
||||
} catch (error) {
|
||||
logger.error(`토큰 무효화 실패: ${userId}`, { error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 사용자의 토큰 일괄 무효화
|
||||
*/
|
||||
static async invalidateMultipleUserTokens(userIds: string[]): Promise<void> {
|
||||
if (userIds.length === 0) return;
|
||||
try {
|
||||
const placeholders = userIds.map((_, i) => `$${i + 1}`).join(", ");
|
||||
await query(
|
||||
`UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id IN (${placeholders})`,
|
||||
userIds
|
||||
);
|
||||
userIds.forEach((id) => cache.delete(this.cacheKey(id)));
|
||||
logger.info(`토큰 일괄 무효화: ${userIds.length}명`);
|
||||
} catch (error) {
|
||||
logger.error(`토큰 일괄 무효화 실패`, { error, userIds });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 token_version 조회 (캐시 사용)
|
||||
*/
|
||||
static async getUserTokenVersion(userId: string): Promise<number> {
|
||||
const cacheKey = this.cacheKey(userId);
|
||||
const cached = cache.get<number>(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await query<{ token_version: number | null }>(
|
||||
`SELECT token_version FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
const version = result.length > 0 ? (result[0].token_version ?? 0) : 0;
|
||||
cache.set(cacheKey, version, TOKEN_VERSION_CACHE_TTL);
|
||||
return version;
|
||||
} catch (error) {
|
||||
logger.error(`token_version 조회 실패: ${userId}`, { error });
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export interface PersonBean {
|
||||
companyName?: string; // 회사명 추가
|
||||
photo?: string;
|
||||
locale?: string;
|
||||
tokenVersion?: number; // JWT 토큰 무효화용 버전
|
||||
// 권한 레벨 정보 (3단계 체계)
|
||||
isSuperAdmin?: boolean; // 최고 관리자 (company_code === '*' && userType === 'SUPER_ADMIN')
|
||||
isCompanyAdmin?: boolean; // 회사 관리자 (userType === 'COMPANY_ADMIN')
|
||||
@@ -98,6 +99,7 @@ export interface JwtPayload {
|
||||
companyName?: string; // 회사명 추가
|
||||
userType?: string;
|
||||
userTypeName?: string;
|
||||
tokenVersion?: number; // JWT 토큰 무효화용 버전
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
aud?: string;
|
||||
|
||||
@@ -140,6 +140,7 @@ export interface GetLangKeysParams {
|
||||
includeOverrides?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
userCompanyCode?: string; // 요청 사용자의 회사 코드 (비관리자 필터링용)
|
||||
}
|
||||
|
||||
export interface GetUserTextParams {
|
||||
|
||||
@@ -20,6 +20,7 @@ export class JwtUtils {
|
||||
companyName: userInfo.companyName, // 회사명 추가
|
||||
userType: userInfo.userType,
|
||||
userTypeName: userInfo.userTypeName,
|
||||
tokenVersion: userInfo.tokenVersion ?? 0,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, config.jwt.secret, {
|
||||
|
||||
Reference in New Issue
Block a user