ㅋㅓ밋
This commit is contained in:
204
backend/api/admin/bulk-register.post.ts
Normal file
204
backend/api/admin/bulk-register.post.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { query, queryOne, insertReturning, execute } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
|
||||
const ADMIN_EMAIL = 'coziny@gmail.com'
|
||||
|
||||
interface ProjectInput {
|
||||
projectId: number | null // null이면 신규 생성
|
||||
projectName: string
|
||||
workDescription: string | null
|
||||
planDescription: string | null
|
||||
}
|
||||
|
||||
interface ReportInput {
|
||||
employeeId: number
|
||||
projects: ProjectInput[]
|
||||
issueDescription: string | null
|
||||
vacationDescription: string | null
|
||||
remarkDescription: string | null
|
||||
}
|
||||
|
||||
interface BulkRegisterBody {
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
reports: ReportInput[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간보고 일괄 등록
|
||||
* POST /api/admin/bulk-register
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 관리자 권한 체크
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const currentUser = await queryOne<any>(`
|
||||
SELECT employee_email FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [userId])
|
||||
|
||||
if (!currentUser || currentUser.employee_email !== ADMIN_EMAIL) {
|
||||
throw createError({ statusCode: 403, message: '관리자만 사용할 수 있습니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<BulkRegisterBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
|
||||
if (!body.reports || body.reports.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '등록할 보고서가 없습니다.' })
|
||||
}
|
||||
|
||||
const results: any[] = []
|
||||
|
||||
for (const report of body.reports) {
|
||||
try {
|
||||
// 1. 프로젝트 처리 (신규 생성 또는 기존 사용)
|
||||
const projectIds: number[] = []
|
||||
|
||||
for (const proj of report.projects) {
|
||||
let projectId = proj.projectId
|
||||
|
||||
if (!projectId) {
|
||||
// 신규 프로젝트 생성
|
||||
const year = new Date().getFullYear()
|
||||
const lastProject = await queryOne<any>(`
|
||||
SELECT project_code FROM wr_project_info
|
||||
WHERE project_code LIKE $1
|
||||
ORDER BY project_code DESC LIMIT 1
|
||||
`, [`${year}-%`])
|
||||
|
||||
let nextNum = 1
|
||||
if (lastProject?.project_code) {
|
||||
const lastNum = parseInt(lastProject.project_code.split('-')[1]) || 0
|
||||
nextNum = lastNum + 1
|
||||
}
|
||||
const newCode = `${year}-${String(nextNum).padStart(3, '0')}`
|
||||
|
||||
const newProject = await insertReturning(`
|
||||
INSERT INTO wr_project_info (
|
||||
project_code, project_name, project_type,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, 'SI', $3, $4, $3, $4)
|
||||
RETURNING project_id
|
||||
`, [newCode, proj.projectName, clientIp, ADMIN_EMAIL])
|
||||
|
||||
projectId = newProject.project_id
|
||||
}
|
||||
|
||||
projectIds.push(projectId)
|
||||
}
|
||||
|
||||
// 2. 기존 주간보고 확인 (덮어쓰기)
|
||||
const existingReport = await queryOne<any>(`
|
||||
SELECT report_id FROM wr_weekly_report
|
||||
WHERE author_id = $1 AND report_year = $2 AND report_week = $3
|
||||
`, [report.employeeId, body.reportYear, body.reportWeek])
|
||||
|
||||
let reportId: number
|
||||
|
||||
if (existingReport) {
|
||||
// 기존 보고서 업데이트
|
||||
reportId = existingReport.report_id
|
||||
|
||||
// 기존 프로젝트 실적 삭제
|
||||
await execute(`DELETE FROM wr_weekly_report_project WHERE report_id = $1`, [reportId])
|
||||
|
||||
// 마스터 업데이트
|
||||
await execute(`
|
||||
UPDATE wr_weekly_report SET
|
||||
issue_description = $1,
|
||||
vacation_description = $2,
|
||||
remark_description = $3,
|
||||
report_status = 'SUBMITTED',
|
||||
submitted_at = NOW(),
|
||||
updated_at = NOW(),
|
||||
updated_ip = $4,
|
||||
updated_email = $5
|
||||
WHERE report_id = $6
|
||||
`, [
|
||||
report.issueDescription,
|
||||
report.vacationDescription,
|
||||
report.remarkDescription,
|
||||
clientIp,
|
||||
ADMIN_EMAIL,
|
||||
reportId
|
||||
])
|
||||
} else {
|
||||
// 신규 보고서 생성
|
||||
const newReport = await insertReturning(`
|
||||
INSERT INTO wr_weekly_report (
|
||||
author_id, report_year, report_week,
|
||||
week_start_date, week_end_date,
|
||||
issue_description, vacation_description, remark_description,
|
||||
report_status, submitted_at,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'SUBMITTED', NOW(), $9, $10, $9, $10)
|
||||
RETURNING report_id
|
||||
`, [
|
||||
report.employeeId,
|
||||
body.reportYear,
|
||||
body.reportWeek,
|
||||
body.weekStartDate,
|
||||
body.weekEndDate,
|
||||
report.issueDescription,
|
||||
report.vacationDescription,
|
||||
report.remarkDescription,
|
||||
clientIp,
|
||||
ADMIN_EMAIL
|
||||
])
|
||||
reportId = newReport.report_id
|
||||
}
|
||||
|
||||
// 3. 프로젝트별 실적 등록
|
||||
for (let i = 0; i < report.projects.length; i++) {
|
||||
const proj = report.projects[i]
|
||||
const projectId = projectIds[i]
|
||||
|
||||
await execute(`
|
||||
INSERT INTO wr_weekly_report_project (
|
||||
report_id, project_id, work_description, plan_description,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $5, $6)
|
||||
`, [
|
||||
reportId,
|
||||
projectId,
|
||||
proj.workDescription,
|
||||
proj.planDescription,
|
||||
clientIp,
|
||||
ADMIN_EMAIL
|
||||
])
|
||||
}
|
||||
|
||||
// 직원 정보 조회
|
||||
const employee = await queryOne<any>(`
|
||||
SELECT employee_name FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [report.employeeId])
|
||||
|
||||
results.push({
|
||||
success: true,
|
||||
employeeId: report.employeeId,
|
||||
employeeName: employee?.employee_name,
|
||||
reportId,
|
||||
isUpdate: !!existingReport
|
||||
})
|
||||
|
||||
} catch (err: any) {
|
||||
results.push({
|
||||
success: false,
|
||||
employeeId: report.employeeId,
|
||||
error: err.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
totalCount: body.reports.length,
|
||||
successCount: results.filter(r => r.success).length,
|
||||
results
|
||||
}
|
||||
})
|
||||
142
backend/api/admin/parse-report.post.ts
Normal file
142
backend/api/admin/parse-report.post.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { callOpenAI, buildParseReportPrompt } from '../../utils/openai'
|
||||
|
||||
const ADMIN_EMAIL = 'coziny@gmail.com'
|
||||
|
||||
interface ParsedProject {
|
||||
projectName: string
|
||||
workDescription: string | null
|
||||
planDescription: string | null
|
||||
}
|
||||
|
||||
interface ParsedReport {
|
||||
employeeName: string
|
||||
employeeEmail: string | null
|
||||
projects: ParsedProject[]
|
||||
issueDescription: string | null
|
||||
vacationDescription: string | null
|
||||
remarkDescription: string | null
|
||||
}
|
||||
|
||||
interface ParsedResult {
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
reports: ParsedReport[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간보고 텍스트 분석 (OpenAI)
|
||||
* POST /api/admin/parse-report
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 관리자 권한 체크
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const currentUser = await query<any>(`
|
||||
SELECT employee_email FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [userId])
|
||||
|
||||
if (!currentUser[0] || currentUser[0].employee_email !== ADMIN_EMAIL) {
|
||||
throw createError({ statusCode: 403, message: '관리자만 사용할 수 있습니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<{ rawText: string }>(event)
|
||||
|
||||
if (!body.rawText || body.rawText.trim().length < 10) {
|
||||
throw createError({ statusCode: 400, message: '분석할 텍스트를 입력해주세요.' })
|
||||
}
|
||||
|
||||
// OpenAI 분석
|
||||
const messages = buildParseReportPrompt(body.rawText)
|
||||
const aiResponse = await callOpenAI(messages, true)
|
||||
|
||||
let parsed: ParsedResult
|
||||
try {
|
||||
parsed = JSON.parse(aiResponse)
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 500, message: 'AI 응답 파싱 실패' })
|
||||
}
|
||||
|
||||
// 기존 직원 목록 조회
|
||||
const employees = await query<any>(`
|
||||
SELECT employee_id, employee_name, employee_email
|
||||
FROM wr_employee_info
|
||||
WHERE is_active = true
|
||||
`)
|
||||
|
||||
// 기존 프로젝트 목록 조회
|
||||
const projects = await query<any>(`
|
||||
SELECT project_id, project_code, project_name
|
||||
FROM wr_project_info
|
||||
WHERE project_status != 'COMPLETED'
|
||||
`)
|
||||
|
||||
// 직원 매칭
|
||||
const matchedReports = parsed.reports.map(report => {
|
||||
// 이메일로 정확 매칭 시도
|
||||
let matchedEmployee = null
|
||||
if (report.employeeEmail) {
|
||||
matchedEmployee = employees.find(
|
||||
(e: any) => e.employee_email.toLowerCase() === report.employeeEmail?.toLowerCase()
|
||||
)
|
||||
}
|
||||
// 이메일 매칭 실패시 이름으로 매칭
|
||||
if (!matchedEmployee) {
|
||||
matchedEmployee = employees.find(
|
||||
(e: any) => e.employee_name === report.employeeName
|
||||
)
|
||||
}
|
||||
|
||||
// 프로젝트 매칭
|
||||
const matchedProjects = report.projects.map(proj => {
|
||||
const existingProject = projects.find((p: any) =>
|
||||
p.project_name.includes(proj.projectName) ||
|
||||
proj.projectName.includes(p.project_name)
|
||||
)
|
||||
|
||||
return {
|
||||
...proj,
|
||||
matchedProjectId: existingProject?.project_id || null,
|
||||
matchedProjectCode: existingProject?.project_code || null,
|
||||
matchedProjectName: existingProject?.project_name || null,
|
||||
isNewProject: !existingProject
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...report,
|
||||
matchedEmployeeId: matchedEmployee?.employee_id || null,
|
||||
matchedEmployeeName: matchedEmployee?.employee_name || null,
|
||||
matchedEmployeeEmail: matchedEmployee?.employee_email || null,
|
||||
isEmployeeMatched: !!matchedEmployee,
|
||||
projects: matchedProjects
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
parsed: {
|
||||
reportYear: parsed.reportYear,
|
||||
reportWeek: parsed.reportWeek,
|
||||
weekStartDate: parsed.weekStartDate,
|
||||
weekEndDate: parsed.weekEndDate,
|
||||
reports: matchedReports
|
||||
},
|
||||
// 선택용 목록
|
||||
employees: employees.map((e: any) => ({
|
||||
employeeId: e.employee_id,
|
||||
employeeName: e.employee_name,
|
||||
employeeEmail: e.employee_email
|
||||
})),
|
||||
projects: projects.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
projectCode: p.project_code,
|
||||
projectName: p.project_name
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -6,6 +6,8 @@ import { query } from '../../utils/db'
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
const currentHistoryId = getCookie(event, 'login_history_id')
|
||||
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
@@ -31,7 +33,8 @@ export default defineEventHandler(async (event) => {
|
||||
loginIp: h.login_ip,
|
||||
logoutAt: h.logout_at,
|
||||
logoutIp: h.logout_ip,
|
||||
lastActiveAt: h.last_active_at
|
||||
lastActiveAt: h.last_active_at,
|
||||
isCurrentSession: currentHistoryId && h.history_id === parseInt(currentHistoryId)
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { queryOne } from '../../../utils/db'
|
||||
import { queryOne, query } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 직원 상세 조회
|
||||
@@ -6,6 +6,7 @@ import { queryOne } from '../../../utils/db'
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const employeeId = getRouterParam(event, 'id')
|
||||
const currentHistoryId = getCookie(event, 'login_history_id')
|
||||
|
||||
const employee = await queryOne<any>(`
|
||||
SELECT * FROM wr_employee_info WHERE employee_id = $1
|
||||
@@ -15,6 +16,20 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 404, message: '직원을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 로그인 이력 조회 (최근 20건)
|
||||
const loginHistory = await query<any>(`
|
||||
SELECT
|
||||
history_id,
|
||||
login_at,
|
||||
login_ip,
|
||||
logout_at,
|
||||
logout_ip
|
||||
FROM wr_login_history
|
||||
WHERE employee_id = $1
|
||||
ORDER BY login_at DESC
|
||||
LIMIT 20
|
||||
`, [employeeId])
|
||||
|
||||
return {
|
||||
employee: {
|
||||
employeeId: employee.employee_id,
|
||||
@@ -27,6 +42,15 @@ export default defineEventHandler(async (event) => {
|
||||
isActive: employee.is_active,
|
||||
createdAt: employee.created_at,
|
||||
updatedAt: employee.updated_at
|
||||
}
|
||||
},
|
||||
loginHistory: loginHistory.map(h => ({
|
||||
historyId: h.history_id,
|
||||
loginAt: h.login_at,
|
||||
loginIp: h.login_ip,
|
||||
logoutAt: h.logout_at,
|
||||
logoutIp: h.logout_ip,
|
||||
// 현재 세션인지 여부
|
||||
isCurrentSession: currentHistoryId && h.history_id === parseInt(currentHistoryId)
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -24,19 +24,21 @@ export default defineEventHandler(async (event) => {
|
||||
const pl = managers.find((m: any) => m.role_type === 'PL')
|
||||
|
||||
return {
|
||||
projectId: project.project_id,
|
||||
projectCode: project.project_code,
|
||||
projectName: project.project_name,
|
||||
projectType: project.project_type || 'SI',
|
||||
clientName: project.client_name,
|
||||
projectDescription: project.project_description,
|
||||
startDate: project.start_date,
|
||||
endDate: project.end_date,
|
||||
contractAmount: project.contract_amount,
|
||||
projectStatus: project.project_status,
|
||||
createdAt: project.created_at,
|
||||
updatedAt: project.updated_at,
|
||||
currentPm: pm ? { employeeId: pm.employee_id, employeeName: pm.employee_name } : null,
|
||||
currentPl: pl ? { employeeId: pl.employee_id, employeeName: pl.employee_name } : null
|
||||
project: {
|
||||
projectId: project.project_id,
|
||||
projectCode: project.project_code,
|
||||
projectName: project.project_name,
|
||||
projectType: project.project_type || 'SI',
|
||||
clientName: project.client_name,
|
||||
projectDescription: project.project_description,
|
||||
startDate: project.start_date,
|
||||
endDate: project.end_date,
|
||||
contractAmount: project.contract_amount,
|
||||
projectStatus: project.project_status,
|
||||
createdAt: project.created_at,
|
||||
updatedAt: project.updated_at,
|
||||
currentPm: pm ? { employeeId: pm.employee_id, employeeName: pm.employee_name } : null,
|
||||
currentPl: pl ? { employeeId: pl.employee_id, employeeName: pl.employee_name } : null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
101
backend/utils/openai.ts
Normal file
101
backend/utils/openai.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* OpenAI API 유틸리티
|
||||
*/
|
||||
|
||||
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions'
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface OpenAIResponse {
|
||||
choices: {
|
||||
message: {
|
||||
content: string
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
||||
export async function callOpenAI(messages: ChatMessage[], jsonMode = true): Promise<string> {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
|
||||
if (!apiKey || apiKey === 'your-openai-api-key-here') {
|
||||
throw new Error('OPENAI_API_KEY가 설정되지 않았습니다.')
|
||||
}
|
||||
|
||||
const response = await fetch(OPENAI_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages,
|
||||
temperature: 0.1,
|
||||
...(jsonMode && { response_format: { type: 'json_object' } })
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(`OpenAI API 오류: ${response.status} - ${error}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as OpenAIResponse
|
||||
return data.choices[0].message.content
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간보고 텍스트 분석 프롬프트
|
||||
*/
|
||||
export function buildParseReportPrompt(rawText: string): ChatMessage[] {
|
||||
return [
|
||||
{
|
||||
role: 'system',
|
||||
content: `당신은 주간업무보고 텍스트를 분석하여 구조화된 JSON으로 변환하는 전문가입니다.
|
||||
|
||||
입력된 텍스트에서 다음 정보를 추출하세요:
|
||||
1. 직원 정보 (이름, 이메일)
|
||||
2. 프로젝트별 실적 (프로젝트명, 금주실적, 차주계획)
|
||||
3. 공통사항 (이슈/리스크, 휴가일정, 기타사항)
|
||||
4. 보고 주차 정보 (텍스트에서 날짜나 주차 정보 추출)
|
||||
|
||||
반드시 아래 JSON 형식으로 응답하세요:
|
||||
{
|
||||
"reportYear": 2025,
|
||||
"reportWeek": 1,
|
||||
"weekStartDate": "2025-01-06",
|
||||
"weekEndDate": "2025-01-10",
|
||||
"reports": [
|
||||
{
|
||||
"employeeName": "홍길동",
|
||||
"employeeEmail": "hong@example.com",
|
||||
"projects": [
|
||||
{
|
||||
"projectName": "프로젝트명",
|
||||
"workDescription": "금주 실적 내용",
|
||||
"planDescription": "차주 계획 내용"
|
||||
}
|
||||
],
|
||||
"issueDescription": "이슈/리스크 내용 또는 null",
|
||||
"vacationDescription": "휴가 일정 또는 null",
|
||||
"remarkDescription": "기타 사항 또는 null"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
주의사항:
|
||||
- 이메일이 없으면 employeeEmail은 null로
|
||||
- 프로젝트가 여러개면 projects 배열에 모두 포함
|
||||
- 날짜 형식은 YYYY-MM-DD
|
||||
- 주차 정보가 없으면 현재 날짜 기준으로 추정
|
||||
- 실적/계획이 명확히 구분 안되면 workDescription에 통합`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: rawText
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user