Compare commits

...

10 Commits

Author SHA1 Message Date
86bd16ff77 영업정보 수정시 프로젝트 정보 업데이트 수정 2025-11-13 17:44:22 +09:00
dcef82cdca 영업번호 팝업창 수정 2025-11-13 17:30:14 +09:00
6a97bf72e1 수주 (프로젝트 생성) 후 품목 추가, 삭제, 변경 불가! 2025-11-13 16:36:01 +09:00
b761e511ff 공급업체->메이커, EO관련컬럼 주석 2025-11-13 13:31:06 +09:00
bf0e08d90b 화면 수정 2025-11-13 12:24:54 +09:00
333d05d19f E-bom 조회 레벨 검색 추가 2025-11-13 12:24:33 +09:00
031ce7615d ㅇㅇ 2025-11-13 09:56:55 +09:00
cf79338b55 fix: Use official Python image for backup container
- Change from dockerhub.wace.me/python:3.10-slim.linux to python:3.10-slim
- Fix Docker image not found error
2025-11-12 18:46:22 +09:00
ba026842f7 feat: Add database backup system
- Add Dockerfile.backup for backup container
- Add backup.py script with PostgreSQL backup functionality
- Add backup service to docker-compose.prod.yml
- Update env.production.example with backup configuration
- Add db/README.md with backup system documentation

Features:
- Automated daily backups (07:30, 18:00)
- Local and FTP remote backup support
- 7-day retention policy
- PostgreSQL 16 client for waceplm database
2025-11-12 18:19:54 +09:00
8cccd9db2c 화면 수정 2025-11-11 18:09:37 +09:00
23 changed files with 1475 additions and 227 deletions

View File

@@ -19,4 +19,11 @@ DB_PASSWORD=waceplm0909!!
DB_DRIVER_CLASS_NAME=org.postgresql.Driver
DB_MAX_TOTAL=200
DB_MAX_IDLE=50
DB_MAX_WAIT_MILLIS=-1
DB_MAX_WAIT_MILLIS=-1
# --- NAS Backup SMB Configuration ---
FTP_HOST=effectsno1.synology.me
FTP_USER=esgrin-mes-backup
FTP_PASSWORD=UyD12#11YHnn
FTP_PATH=esgrin-mes-backup
FTP_PORT=2112

54
Dockerfile.backup Normal file
View File

@@ -0,0 +1,54 @@
# Use an official Python runtime as a parent image
FROM python:3.10-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV TZ=Asia/Seoul
# Install system dependencies including PostgreSQL client 16
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gnupg \
lsb-release \
wget \
ca-certificates \
tzdata \
gpg \
lftp && \
# Add PostgreSQL Apt Repository
# Download the key
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | \
# Dearmor the key and save to the keyring directory
gpg --dearmor -o /usr/share/keyrings/postgresql-archive-keyring.gpg && \
# Set correct permissions for the keyring file
chmod 644 /usr/share/keyrings/postgresql-archive-keyring.gpg && \
# Add the repository source, signed by the keyring
sh -c 'echo "deb [signed-by=/usr/share/keyrings/postgresql-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \
# Update again after adding repo
apt-get update && \
# Install specific client version
apt-get install -y --no-install-recommends \
postgresql-client-16 && \
# Configure timezone
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone && \
# Clean up (remove build dependencies)
apt-get purge -y --auto-remove wget gnupg lsb-release ca-certificates gpg && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Python dependencies
# Using requirements.txt is generally better, but for a simple script:
RUN pip install --no-cache-dir schedule pytz
# Set the working directory in the container
WORKDIR /app
# Copy the Python script into the container
COPY db/backup.py .
# Ensure .ssh directory exists (still good practice, though key might not be used)
# RUN mkdir -p /root/.ssh && chmod 700 /root/.ssh # No longer needed for SSH keys
# Command to run the application
CMD ["python", "backup.py"]

View File

@@ -63,11 +63,26 @@ $(document).ready(function(){
//영업활동 등록 팝업
$(".btnRegist").click(function(){
var selectedData = _tabulGrid.getSelectedData();
var popup_width = 1400;
var popup_height = 560;
var params = "?actionType=regist"
var url = "/contractMgmt/estimateRegistFormPopup.do"+params;
//window.open("/ordermgmt/ordermgmtUpdateFormPopup.do"+params, "", "width=650, height=750","menubars=no, scrollbars=yes, resizable=yes");
var popup_height = 560;
var url = "";
// 선택된 행이 없으면 신규 등록
if(selectedData.length === 0){
url = "/contractMgmt/estimateRegistFormPopup.do?actionType=regist";
}
// 한 줄 선택된 경우 상세 팝업 (영업번호 클릭과 동일)
else if(selectedData.length === 1){
var objid = fnc_checkNull(selectedData[0].OBJID);
url = "/contractMgmt/estimateRegistFormPopup.do?objId=" + objid;
}
// 여러 줄 선택된 경우 경고
else {
Swal.fire("한 개의 행만 선택해주세요.");
return false;
}
fn_centerPopup(popup_width, popup_height, url);
});
@@ -502,11 +517,11 @@ function fn_FileRegist(objId, docType, docTypeName){
fn_centerPopup(popup_width, popup_height, url);
}
//영업활동등록 상세
//영업활동등록 상세 (뷰 전용 팝업)
function fn_projectConceptDetail(objId){
var popup_width = 1400;
var popup_height = 560;
var url = "/contractMgmt/estimateRegistFormPopup.do?objId="+objId;
var popup_width = 900;
var popup_height = 700;
var url = "/contractMgmt/estimateViewPopup.do?objId="+objId;
fn_centerPopup(popup_width, popup_height, url);
}
@@ -903,26 +918,24 @@ function openProjectFormPopUp(objId){
</td>
--%>
<td class="align_r">
<label for="" class="">품번</label>
</td>
<td>
<select name="search_partNo" id="search_partNo" class="select2-part" style="width: 100%;">
<option value="">품번 선택</option>
</select>
<input type="hidden" name="search_partObjId" id="search_partObjId" value=""/>
</td>
<td class="align_r">
<label for="" class="">품명</label>
</td>
<td>
<select name="search_partName" id="search_partName" class="select2-part" style="width: 100%;">
<option value="">품명 선택</option>
</select>
</td>
</tr>
<tr>
<td class="align_r">
<label for="" class="">품번</label>
</td>
<td>
<select name="search_partNo" id="search_partNo" class="select2-part" style="width: 100%;">
<option value="">품번 선택</option>
</select>
<input type="hidden" name="search_partObjId" id="search_partObjId" value=""/>
</td>
<td class="align_r">
<label for="" class="">품명</label>
</td>
<td>
<select name="search_partName" id="search_partName" class="select2-part" style="width: 100%;">
<option value="">품명 선택</option>
</select>
</td>
<td class="">
<label for="" class="">S/N</label>
</td>
@@ -931,7 +944,7 @@ function openProjectFormPopUp(objId){
</td>
<td><label for="">결재상태</label></td>
<td>
<td>
<select name="appr_status" id="appr_status" class="select2" autocomplete="off" style="width:130px">
<option value="">선택</option>
<%-- ${code_map.appr_status} --%>
@@ -950,17 +963,18 @@ function openProjectFormPopUp(objId){
<input type="text" name="receipt_start_date" id="receipt_start_date" style="width:90px;" autocomplete="off" value="${param.receipt_start_date}" class="date_icon">~
<input type="text" name="receipt_end_date" id="receipt_end_date" style="width:90px;" autocomplete="off" value="${param.receipt_end_date}" class="date_icon">
</td>
<%--
</tr>
<%--<tr>
<td class="">
<label>요청납기</label>
</td>
<td>
<input type="text" name="due_start_date" id="due_start_date" style="width:90px;" autocomplete="off" value="${param.due_start_date}" class="date_icon">~
<input type="text" name="due_end_date" id="due_end_date" style="width:90px;" autocomplete="off" value="${param.due_end_date}" class="date_icon">
</td>
--%>
</tr>
</td>
</tr>--%>
</table>
</div>

View File

@@ -15,34 +15,35 @@ String contractObjId = request.getParameter("contractObjId");
<title>견적서 메일 발송</title>
<style>
body {
font-family: 'Malgun Gothic', sans-serif;
margin: 20px;
margin: 10px;
background-color: #f5f5f5;
font-size: 13px;
}
.mail-form-container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
max-width: 900px;
padding: 15px 20px;
border-radius: 6px;
box-shadow: 0 1px 5px rgba(0,0,0,0.1);
max-width: 800px;
margin: 0 auto;
}
.form-title {
font-size: 24px;
font-size: 18px;
font-weight: bold;
margin-bottom: 30px;
margin-bottom: 15px;
color: #333;
border-bottom: 2px solid #3085d6;
padding-bottom: 10px;
padding-bottom: 8px;
}
.form-group {
margin-bottom: 20px;
margin-bottom: 12px;
}
.form-group label {
display: block;
font-weight: bold;
margin-bottom: 8px;
margin-bottom: 5px;
color: #555;
font-size: 13px;
}
.form-group label.required:after {
content: " *";
@@ -52,60 +53,63 @@ String contractObjId = request.getParameter("contractObjId");
.form-group input[type="email"],
.form-group textarea {
width: 100%;
padding: 10px;
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
border-radius: 3px;
font-size: 13px;
box-sizing: border-box;
}
.form-group textarea {
min-height: 200px;
min-height: 120px;
resize: vertical;
}
.manager-list {
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
border-radius: 3px;
padding: 8px;
background-color: #fafafa;
max-height: 200px;
max-height: 150px;
overflow-y: auto;
}
.manager-item {
padding: 8px;
margin-bottom: 5px;
padding: 5px;
margin-bottom: 3px;
background: white;
border-radius: 4px;
border-radius: 3px;
display: flex;
align-items: center;
font-size: 13px;
}
.manager-item input[type="checkbox"] {
margin-right: 10px;
width: 18px;
height: 18px;
margin-right: 8px;
width: 16px;
height: 16px;
cursor: pointer;
}
.manager-item label {
cursor: pointer;
margin: 0;
flex: 1;
font-size: 13px;
}
.no-managers {
color: #999;
text-align: center;
padding: 20px;
padding: 12px;
font-size: 13px;
}
.button-group {
text-align: center;
margin-top: 30px;
padding-top: 20px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.btn {
padding: 12px 30px;
margin: 0 5px;
padding: 8px 20px;
margin: 0 4px;
border: none;
border-radius: 4px;
font-size: 16px;
border-radius: 3px;
font-size: 13px;
cursor: pointer;
transition: all 0.3s;
}
@@ -124,16 +128,17 @@ String contractObjId = request.getParameter("contractObjId");
background-color: #5a6268;
}
.info-text {
font-size: 12px;
font-size: 11px;
color: #666;
margin-top: 5px;
margin-top: 3px;
}
.pdf-status {
padding: 10px;
padding: 8px 10px;
background-color: #e7f3ff;
border-left: 4px solid #3085d6;
border-radius: 4px;
margin-bottom: 20px;
border-left: 3px solid #3085d6;
border-radius: 3px;
margin-bottom: 12px;
font-size: 13px;
}
.pdf-status i {
color: #3085d6;

View File

@@ -47,6 +47,9 @@
// 품번 데이터는 AJAX로 검색하므로 초기 데이터 없음
// 프로젝트 존재 여부 체크 변수
var hasProject = false;
$(function() {
// 반납사유 옵션을 템플릿에서 읽어옴
returnReasonOptions = $('#return_reason_template').html();
@@ -107,6 +110,9 @@
fn_addItemRow();
});
// 프로젝트 존재 여부 먼저 확인 (동기 방식)
fn_checkProjectExists();
// 기존 품목 데이터 로드
fn_loadExistingItems();
@@ -533,6 +539,17 @@
// 품번/품명 옵션 채우기 (저장된 품번/품명도 함께 전달)
fn_fillPartOptions(itemId, savedPartObjId, savedPartNo, savedPartName);
// 프로젝트가 있으면 품번/품명 셀렉트박스 비활성화 및 삭제 버튼 비활성화
if(hasProject) {
$("#PART_NO_" + itemId).prop("disabled", true).css("background-color", "#f5f5f5");
$("#PART_NAME_" + itemId).prop("disabled", true).css("background-color", "#f5f5f5");
$("#" + itemId + " button[onclick*='fn_deleteItemRow']").prop("disabled", true).css({
"background-color": "#ccc",
"cursor": "not-allowed",
"opacity": "0.6"
});
}
// datepicker 적용
$("#" + itemId + " .date_icon").datepicker({
changeMonth: true,
@@ -1176,8 +1193,66 @@
// ========== 품목 관리 함수 ==========
// 프로젝트 존재 여부 확인
function fn_checkProjectExists() {
var contractObjId = "${info.OBJID}";
console.log("=== fn_checkProjectExists 시작 ===");
console.log("contractObjId:", contractObjId);
if(!contractObjId || contractObjId === '') {
console.log("contractObjId 없음 - 체크 중단");
return;
}
$.ajax({
url: "/contractMgmt/checkProjectExists.do",
type: "POST",
data: { contractObjId: contractObjId },
dataType: "json",
async: false,
success: function(data) {
console.log("AJAX 응답 전체:", data);
console.log("data.exists 값:", data.exists);
console.log("data.exists 타입:", typeof data.exists);
console.log("JSON.stringify(data):", JSON.stringify(data));
if(data && data.exists === true) {
hasProject = true;
console.log("✅ 프로젝트 존재 확인 - hasProject = true");
console.log("프로젝트 정보:", data.projectInfo);
// 품목 추가 버튼 비활성화
$("#btnAddItem").prop("disabled", true).css({
"background-color": "#ccc",
"cursor": "not-allowed",
"opacity": "0.6"
});
} else {
console.log("❌ 프로젝트 없음 - hasProject = false");
}
},
error: function(xhr, status, error) {
console.error("프로젝트 존재 여부 확인 실패:", error);
console.error("Status:", status);
console.error("Response:", xhr.responseText);
}
});
console.log("=== fn_checkProjectExists 종료 - hasProject:", hasProject, "===");
}
// 품목 행 추가
function fn_addItemRow() {
// 프로젝트가 있으면 품목 추가 불가
if(hasProject) {
Swal.fire({
title: '품목 추가 불가',
text: '프로젝트가 이미 생성되어 품목을 추가할 수 없습니다.\n수량, 납기일 등만 수정 가능합니다.',
icon: 'warning'
});
return;
}
var itemId = 'item_' + itemCounter++;
// "품목 추가하세요" 메시지 행 제거
@@ -1520,6 +1595,16 @@
// 품목 행 삭제
function fn_deleteItemRow(itemId) {
// 프로젝트가 있으면 품목 삭제 불가
if(hasProject) {
Swal.fire({
title: '품목 삭제 불가',
text: '프로젝트가 이미 생성되어 품목을 삭제할 수 없습니다.',
icon: 'warning'
});
return;
}
if(confirm("해당 품목을 삭제하시겠습니까?")) {
$("#" + itemId).remove();

View File

@@ -523,7 +523,7 @@ function getCurrencySymbol() {
}
}
// 품명 셀렉트박스 초기화 함수
/* 품명 셀렉트박스 초기화 함수 - 주석처리 (텍스트 readonly로 변경)
function fn_initItemDescSelect(itemId) {
$("#" + itemId + " .item-desc-select").select2({
placeholder: "품명 입력하여 검색...",
@@ -594,6 +594,7 @@ function fn_initItemDescSelect(itemId) {
$("#" + itemId + " .item-part-objid").val(''); // part_objid 초기화
});
}
*/
// 행 추가 함수
function fn_addItemRow() {
@@ -606,8 +607,7 @@ function fn_addItemRow() {
var newRow = '<tr id="' + itemId + '">' +
'<td>' + nextNo + '</td>' +
'<td class="text-left editable">' +
'<select class="item-desc-select" style="width:100%;"></select>' +
'<input type="hidden" class="item-desc" value="">' +
'<input type="text" class="item-desc" value="" readonly style="background-color: #f5f5f5;">' +
'<input type="hidden" class="item-part-objid" value="">' +
'</td>' +
'<td class="text-left editable"><textarea class="item-spec"></textarea></td>' +
@@ -621,8 +621,8 @@ function fn_addItemRow() {
// 계 행 바로 위에 추가
$(".total-row").before(newRow);
// 품명 셀렉트박스 초기화
fn_initItemDescSelect(itemId);
// 품명 셀렉트박스 초기화 - 주석처리 (텍스트 readonly로 변경)
// fn_initItemDescSelect(itemId);
// 합계 재계산
fn_calculateTotal();
@@ -686,12 +686,7 @@ function fn_loadContractItems(contractObjId) {
itemsHtml += '<tr id="' + itemId + '">';
itemsHtml += '<td>' + (i + 1) + '</td>';
itemsHtml += '<td class="text-left editable">';
itemsHtml += '<select class="item-desc-select" style="width:100%;">';
if(partName) {
itemsHtml += '<option value="' + partObjId + '" selected>' + partName + '</option>';
}
itemsHtml += '</select>';
itemsHtml += '<input type="hidden" class="item-desc" value="' + partName + '">';
itemsHtml += '<input type="text" class="item-desc" value="' + partName + '" readonly style="background-color: #f5f5f5;">';
itemsHtml += '<input type="hidden" class="item-part-objid" value="' + partObjId + '">';
itemsHtml += '</td>';
itemsHtml += '<td class="text-left editable"><textarea class="item-spec"></textarea></td>';
@@ -746,11 +741,12 @@ function fn_loadContractItems(contractObjId) {
// HTML 삽입
$("#itemsTableBody").html(itemsHtml);
// 셀렉트박스 초기화
/* 셀렉트박스 초기화 - 주석처리 (텍스트 readonly로 변경)
for(var i = 0; i < data.items.length; i++) {
var itemId = 'contract_item_' + i;
fn_initItemDescSelect(itemId);
}
*/
// 합계 계산
fn_calculateTotal();
@@ -760,8 +756,10 @@ function fn_loadContractItems(contractObjId) {
fn_controlButtons();
} else {
// 품목이 없으면 기본 행 표시
/* 셀렉트박스 초기화 - 주석처리 (텍스트 readonly로 변경)
fn_initItemDescSelect('default_item_1');
fn_initItemDescSelect('default_item_2');
*/
fn_calculateTotal();
g_apprStatus = "작성중";
fn_controlButtons();
@@ -772,8 +770,10 @@ function fn_loadContractItems(contractObjId) {
Swal.fire("품목 데이터를 불러오는데 실패했습니다.");
// 오류 시 기본 행 표시
/* 셀렉트박스 초기화 - 주석처리 (텍스트 readonly로 변경)
fn_initItemDescSelect('default_item_1');
fn_initItemDescSelect('default_item_2');
*/
fn_calculateTotal();
g_apprStatus = "작성중";
fn_controlButtons();
@@ -935,9 +935,11 @@ function fn_loadData() {
}
*/ // 품목 데이터 로드 주석처리 끝
// 새로 작성 시 기본 행의 셀렉트박스 초기화
// 새로 작성 시 기본 행의 셀렉트박스 초기화 - 주석처리 (텍스트 readonly로 변경)
/*
fn_initItemDescSelect('default_item_1');
fn_initItemDescSelect('default_item_2');
*/
// 초기 로드 시 합계 계산
fn_calculateTotal();
@@ -1064,13 +1066,7 @@ function fn_loadTemplateData(templateObjId){
itemsHtml += '<tr id="' + itemId + '">';
itemsHtml += '<td>' + (i + 1) + '</td>';
itemsHtml += '<td class="text-left editable">';
itemsHtml += '<select class="item-desc-select" style="width:100%;">';
// PART_OBJID가 없으면 기존 텍스트를 옵션으로 표시
if(description) {
itemsHtml += '<option value="" selected>' + description + '</option>';
}
itemsHtml += '</select>';
itemsHtml += '<input type="hidden" class="item-desc" value="' + description + '">';
itemsHtml += '<input type="text" class="item-desc" value="' + description + '" readonly style="background-color: #f5f5f5;">';
itemsHtml += '<input type="hidden" class="item-part-objid" value="' + partObjId + '">';
itemsHtml += '</td>';
itemsHtml += '<td class="text-left editable"><textarea class="item-spec">' + specification + '</textarea></td>';
@@ -1125,7 +1121,7 @@ function fn_loadTemplateData(templateObjId){
// HTML 삽입
$("#itemsTableBody").html(itemsHtml);
// 셀렉트박스 초기화 및 데이터 설정
/* 셀렉트박스 초기화 및 데이터 설정 - 주석처리 (텍스트 readonly로 변경)
for(var i = 0; i < data.items.length; i++) {
var item = data.items[i];
var itemId = 'template_item_' + i;
@@ -1167,6 +1163,7 @@ function fn_loadTemplateData(templateObjId){
// 셀렉트박스 초기화 (옵션 추가 후)
fn_initItemDescSelect(itemId);
}
*/
// 테이블 내 비고 값 설정 (textarea 생성 직후)
$("#note_remarks").val(noteRemarks);
@@ -1436,8 +1433,7 @@ function fn_save() {
<tr id="default_item_1">
<td>1</td>
<td class="text-left editable">
<select class="item-desc-select" style="width:100%;"></select>
<input type="hidden" class="item-desc" value="">
<input type="text" class="item-desc" value="" readonly style="background-color: #f5f5f5;">
<input type="hidden" class="item-part-objid" value="">
</td>
<td class="text-left editable"><textarea class="item-spec"></textarea></td>
@@ -1450,8 +1446,7 @@ function fn_save() {
<tr id="default_item_2">
<td>2</td>
<td class="text-left editable">
<select class="item-desc-select" style="width:100%;"></select>
<input type="hidden" class="item-desc" value="">
<input type="text" class="item-desc" value="" readonly style="background-color: #f5f5f5;">
<input type="hidden" class="item-part-objid" value="">
</td>
<td class="text-left editable"><textarea class="item-spec"></textarea></td>
@@ -1502,7 +1497,7 @@ function fn_save() {
<!-- 버튼 영역 -->
<div class="btn-area no-print">
<button type="button" id="btnAddRow" class="estimate-btn">행 추가</button>
<!-- <button type="button" id="btnAddRow" class="estimate-btn">행 추가</button> -->
<button type="button" id="btnPrint" class="estimate-btn">인쇄</button>
<button type="button" id="btnDownloadPdf" class="estimate-btn">PDF 다운로드</button>
<button type="button" id="btnSave" class="estimate-btn">저장</button>

View File

@@ -0,0 +1,201 @@
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ page import="com.pms.common.utils.*"%>
<%@ page import="java.util.*"%>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>
<%@include file="/init_new.jsp"%>
<%
PersonBean person = (PersonBean) session.getAttribute(Constants.PERSON_BEAN);
String userId = CommonUtils.checkNull(person.getUserId());
%>
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title><%=Constants.SYSTEM_NAME%></title>
<style>
body {
font-family: 'Malgun Gothic', sans-serif;
font-size: 12px;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.popup-container {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 20px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #333;
padding: 10px;
background-color: #f8f9fa;
border-left: 4px solid #007bff;
margin-bottom: 15px;
}
.info-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.info-table th {
background-color: #f8f9fa;
padding: 10px;
text-align: center;
border: 1px solid #dee2e6;
font-weight: bold;
width: 15%;
}
.info-table td {
padding: 10px;
border: 1px solid #dee2e6;
background-color: white;
}
.item-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.item-table thead th {
background-color: #f8f9fa;
padding: 10px;
text-align: center;
border: 1px solid #dee2e6;
font-weight: bold;
}
.item-table tbody td {
padding: 8px;
border: 1px solid #dee2e6;
text-align: center;
}
.item-table tbody td.text-left {
text-align: left;
}
.btn-area {
text-align: center;
margin-top: 20px;
}
.plm_btns {
padding: 8px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
margin: 0 5px;
}
.plm_btns:hover {
background-color: #0056b3;
}
</style>
<script type="text/javascript">
$(function() {
$("#btnClose").click(function() {
self.close();
});
});
</script>
</head>
<body>
<div class="popup-container">
<!-- 영업정보 섹션 -->
<div class="section-title">영업정보</div>
<table class="info-table">
<colgroup>
<col width="15%">
<col width="35%">
<col width="15%">
<col width="35%">
</colgroup>
<tr>
<th>주문유형</th>
<td>${info.CATEGORY_NAME}</td>
<th>제품구분</th>
<td>${info.PRODUCT_NAME}</td>
</tr>
<tr>
<th>유/무상</th>
<td>${info.PAID_TYPE_NAME}</td>
<th>접수일</th>
<td>${info.RECEIPT_DATE}</td>
</tr>
<tr>
<th>국내/해외</th>
<td>${info.AREA_NAME}</td>
<th>고객사</th>
<td>${info.CUSTOMER_NAME}</td>
</tr>
</table>
<!-- 품목정보 섹션 -->
<div class="section-title">품목정보</div>
<table class="item-table">
<colgroup>
<col width="5%">
<col width="15%">
<col width="25%">
<col width="15%">
<col width="10%">
<col width="15%">
<col width="15%">
</colgroup>
<thead>
<tr>
<th>No</th>
<th>품번</th>
<th>품명</th>
<th>S/N</th>
<th>요청납기</th>
<th>고객요청사항</th>
<th>반납사유</th>
</tr>
</thead>
<tbody>
<c:choose>
<c:when test="${empty itemList}">
<tr>
<td colspan="7" style="text-align:center; padding:30px; color:#999;">
등록된 품목이 없습니다.
</td>
</tr>
</c:when>
<c:otherwise>
<c:forEach items="${itemList}" var="item" varStatus="status">
<tr>
<td>${status.count}</td>
<td>${item.part_no}</td>
<td class="text-left">${item.part_name}</td>
<td>${item.serial_nos}</td>
<td>${item.due_date}</td>
<td class="text-left">${item.customer_request}</td>
<td>${item.return_reason}</td>
</tr>
</c:forEach>
</c:otherwise>
</c:choose>
</tbody>
</table>
<!-- 버튼 영역 -->
<div class="btn-area">
<input type="button" value="닫기" class="plm_btns" id="btnClose">
</div>
</div>
</body>
</html>

View File

@@ -317,11 +317,11 @@ function fn_FileRegist(objId, docType, docTypeName){
fn_centerPopup(popup_width, popup_height, url);
}
//영업활동등록 상세
//영업활동등록 상세 (뷰 전용 팝업)
function fn_projectConceptDetail(objId){
var popup_width = 1400;
var popup_height = 560;
var url = "/contractMgmt/estimateRegistFormPopup.do?objId="+objId;
var popup_width = 900;
var popup_height = 700;
var url = "/contractMgmt/estimateViewPopup.do?objId="+objId;
fn_centerPopup(popup_width, popup_height, url);
}

View File

@@ -12,7 +12,11 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title><%=Constants.SYSTEM_NAME%></title>
<style type="text/css">
#plmSearchZon {
width: auto !important;
}
</style>
<!-- //JSTL 변수선언 -->
<c:set var="totalCount" value="${empty TOTAL_COUNT?0:TOTAL_COUNT}" />
<c:set var="maxPage" value="${empty MAX_PAGE_SIZE?1:MAX_PAGE_SIZE}" />
@@ -146,7 +150,7 @@ function fn_excel() {
}
function openOEMPopUp(objid){
var popup_width = 900;
var popup_height = 700;
var popup_height = 750;
var target = "openOEMPopUp";
fn_centerPopup(popup_width, popup_height, url, target);

View File

@@ -2,7 +2,7 @@
<%@ page import="com.pms.common.utils.*"%>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>
<%@ page import="java.util.*" %>
<%@include file= "/init.jsp" %>
<%@include file= "/init_new.jsp" %>
<%
PersonBean person = (PersonBean)session.getAttribute(Constants.PERSON_BEAN);
String userId = CommonUtils.checkNull(person.getUserId());
@@ -412,7 +412,7 @@ function fn_edit(){
<input type="hidden" name="CONTRACT_OBJID" id="CONTRACT_OBJID" value="${resultMap.CONTRACT_OBJID}">
<section>
<div class="plm_menu_name" style="display:flex;">
<h2>
<h2 style="width: 100%;">
<span>품목 상세</span>
</h2>
</div>

View File

@@ -2,7 +2,7 @@
<%@ page import="com.pms.common.utils.*"%>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>
<%@ page import="java.util.*" %>
<%@include file= "/init.jsp" %>
<%@include file= "/init_new.jsp" %>
<%
PersonBean person = (PersonBean)session.getAttribute(Constants.PERSON_BEAN);
String userId = CommonUtils.checkNull(person.getUserId());
@@ -286,7 +286,7 @@ function fn_overlapPartMng(){
<input type="hidden" name="CONTRACT_OBJID" id="CONTRACT_OBJID" value="${resultMap.CONTRACT_OBJID}">
<section>
<div class="plm_menu_name" style="display:flex;">
<h2>
<h2 style="width: 100%;">
<span>
<c:if test="${ 'changeDesign' ne param.ACTION_TYPE}">
품목 등록

View File

@@ -26,6 +26,48 @@ $(document).ready(function(){
debug: false // 디버깅 모드 비활성화
});
// Select2 선택 시 텍스트 저장
$('#search_partNo').on('select2:select', function (e) {
var data = e.params.data;
$('#search_partNo_text').val(data.text);
});
$('#search_partName').on('select2:select', function (e) {
var data = e.params.data;
$('#search_partName_text').val(data.text);
});
// 이전 검색 조건 복원
setTimeout(function(){
var prevPartNoText = '${param.search_partNo_text}';
var prevPartNameText = '${param.search_partName_text}';
var prevPartNo = '${param.search_partNo}';
var prevPartName = '${param.search_partName}';
var prevPartObjId = '${param.search_partObjId}';
console.log("=== 검색 조건 복원 ===");
console.log("prevPartNoText:", prevPartNoText);
console.log("prevPartNameText:", prevPartNameText);
console.log("prevPartNo:", prevPartNo);
console.log("prevPartName:", prevPartName);
console.log("prevPartObjId:", prevPartObjId);
// 품번과 품명은 같은 OBJID를 사용하므로, 둘 중 하나만 복원
if(prevPartNoText && prevPartNo){
// 품번이 선택되어 있었던 경우
console.log("품번 복원:", prevPartNoText, prevPartNo);
var newOption = new Option(prevPartNoText, prevPartNo, true, true);
$('#search_partNo').append(newOption).trigger('change');
$('#search_partObjId').val(prevPartNo);
} else if(prevPartNameText && prevPartName){
// 품명이 선택되어 있었던 경우
console.log("품명 복원:", prevPartNameText, prevPartName);
var newOption = new Option(prevPartNameText, prevPartName, true, true);
$('#search_partName').append(newOption).trigger('change');
$('#search_partObjId').val(prevPartName);
}
}, 500);
$("#mainGrid").jqGrid({
height : 630,
colNames : headerNames,
@@ -82,12 +124,30 @@ $(document).ready(function(){
//정전개 조회
$("#btnSearchAscending").click(function(){
// 품번 또는 품명 필수 체크
var partNo = $.trim($("#search_partNo").val());
var partName = $.trim($("#search_partName").val());
if(!partNo && !partName){
Swal.fire('품번 또는 품명을 선택해 주세요');
return;
}
$("#searchType").val("ascending");
fn_search();
});
//역전개 조회
$("#btnSearchDescending").click(function(){
// 품번 또는 품명 필수 체크
var partNo = $.trim($("#search_partNo").val());
var partName = $.trim($("#search_partName").val());
if(!partNo && !partName){
Swal.fire('품번 또는 품명을 선택해 주세요');
return;
}
$("#searchType").val("descending");
fn_search();
});
@@ -100,18 +160,43 @@ $(document).ready(function(){
fn_excelExport($("#mainGrid"),"BOM_REPORT_정전개");
});
var search_level = '${param.search_level}';
$(".dataTr").each(function(i){
var lev = $(this).attr("data-LEVEL");
//if(lev == 1){ //1level만 활성화
if(!fnc_isEmpty(search_level)){ //검색조건 LEVEL까지만 활성화
if(Number(lev) <= Number(search_level)){ //검색조건 LEVEL까지만 활성화
$(this).show();
// 초기 로딩 시 레벨에 따라 표시 (검색 결과 기준)
setTimeout(function(){
var searchLevel = '${param.search_level}'; // 검색 조건의 레벨
// 최소 레벨 찾기
var minLevel = 999;
$(".dataTr").each(function(){
var lev = parseInt($(this).attr("data-LEVEL"));
if(lev < minLevel){
minLevel = lev;
}
}else{
$(this).show(); //전체 펼치기
});
// 표시할 최대 레벨 결정
var maxDisplayLevel = minLevel; // 기본값: 최소 레벨만
if(searchLevel && searchLevel != ''){
maxDisplayLevel = parseInt(searchLevel);
}
});
console.log("최소 레벨:", minLevel, "표시할 최대 레벨:", maxDisplayLevel);
// 지정된 레벨까지 표시
$(".dataTr").each(function(){
var lev = parseInt($(this).attr("data-LEVEL"));
if(lev <= maxDisplayLevel){
$(this).show();
// 표시된 항목 중 하위가 있고 아직 펼쳐지지 않은 항목은 Minus 아이콘으로 변경
if(lev < maxDisplayLevel){
var $img = $(this).find(".btnToggle");
if($img.length > 0 && $img.attr("src").indexOf("Plus") > -1){
$img.attr("src", "/images/btnMinus.png");
}
}
}
});
}, 100); // DOM 로딩 대기
/*
//클릭시 하위정보를 토글한다.
$(".dataTr").click(function(){
@@ -707,6 +792,8 @@ function fn_excelExport(pGridObj,pFileName){
<input type="hidden" name="search" id="search" value="Y">
<input type="hidden" name="actionType" id="actionType" value="" />
<input type="hidden" name="searchType" id="searchType" value="ascending" />
<input type="hidden" name="search_partNo_text" id="search_partNo_text" value="${param.search_partNo_text}" />
<input type="hidden" name="search_partName_text" id="search_partName_text" value="${param.search_partName_text}" />
<div class="min_part_enroll">
<div class="content-box">
<div class=""> <!-- content-box-s -->
@@ -766,16 +853,29 @@ function fn_excelExport(pGridObj,pFileName){
<select name="search_partNo" id="search_partNo" class="select2-part" style="width: 100%;">
<option value="">품번 선택</option>
</select>
<input type="hidden" name="search_partObjId" id="search_partObjId" value=""/>
<input type="hidden" name="search_partObjId" id="search_partObjId" value="${param.search_partObjId}"/>
</td>
<td class="align_r">
<label for="" class="">품명</label>
</td>
<td colspan="3">
<td>
<select name="search_partName" id="search_partName" class="select2-part" style="width: 100%;">
<option value="">품명 선택</option>
</select>
</td>
<td class="align_r">
<label for="" class="">표시 레벨</label>
</td>
<td>
<select name="search_level" id="search_level" class="select2" style="width: 100%;">
<option value="">선택</option>
<option value="1" ${param.search_level eq '1'?'selected':''}>1레벨</option>
<option value="2" ${param.search_level eq '2'?'selected':''}>2레벨</option>
<option value="3" ${param.search_level eq '3'?'selected':''}>3레벨</option>
<option value="4" ${param.search_level eq '4'?'selected':''}>4레벨</option>
<option value="5" ${param.search_level eq '5'?'selected':''}>5레벨</option>
</select>
</td>
<!-- <td class="align_r">
<label for="" class="">품번</label>
@@ -847,10 +947,10 @@ function fn_excelExport(pGridObj,pFileName){
<col width="90px" /> <!-- 표면처리 -->
<col width="90px" /> <!-- 공급업체 -->
<col width="80px" /> <!-- PART 타입 -->
<col width="60px" /> <!-- REVISION -->
<col width="70px" /> <!-- EO No -->
<col width="70px" /> <!-- EO Date -->
<col width="200px" /> <!-- REMARK -->
<!-- <col width="60px" /> REVISION
<col width="70px" /> EO No
<col width="70px" /> EO Date -->
<col width="200px" /> <!-- REMARK -->
</colgroup>
<thead>
<tr class="plm_thead">
@@ -877,11 +977,11 @@ function fn_excelExport(pGridObj,pFileName){
<td>열처리경도</td>
<td>열처리방법</td>
<td>표면처리</td>
<td>공급업체</td>
<td>메이커</td>
<td>범주 이름</td>
<td>Revision</td>
<!-- <td>Revision</td>
<td>EO No</td>
<td>EO Date</td>
<td>EO Date</td> -->
<td>비고</td>
</tr>
</thead>
@@ -915,9 +1015,9 @@ function fn_excelExport(pGridObj,pFileName){
<col width="90px" /> <!-- 표면처리 -->
<col width="90px" /> <!-- 공급업체 -->
<col width="80px" /> <!-- PART 타입 -->
<col width="60px" /> <!-- REVISION -->
<col width="70px" /> <!-- EO No -->
<col width="70px" /> <!-- EO Date -->
<!-- <col width="60px" /> REVISION
<col width="70px" /> EO No
<col width="70px" /> EO Date -->
<col width="200px" /> <!-- REMARK -->
</colgroup>
<c:choose>
@@ -931,22 +1031,17 @@ function fn_excelExport(pGridObj,pFileName){
<tr class="dataTr" data-LEVEL="${item.LEVEL}" data-REAL_LEVEL="${item.LEV}" data-BOM_REPORT_OBJID="${item.BOM_REPORT_OBJID}" data-TOP_OBJID="${item.ROOT_OBJID}"
data-SUB_TOP_OBJID="${item.SUB_ROOT_OBJID}" data-OBJID="${item.PART_OBJID}${status.index}" style="display:none;">
<td>
<c:if test="${item.LEAF eq '0'}">
<c:choose>
<c:when test="${empty param.search_level}">
<img src="/images/btnMinus.png" width="13px" height="13px" class="btnToggle" style="${item.LEVEL eq '1' and item.LEAF eq '0'?'cursor:pointer;':''}" data-BOM_REPORT_OBJID="${item.BOM_REPORT_OBJID}"
data-OBJID="${item.PART_OBJID}${status.index}" data-REAL_LEVEL="${item.LEV}" data-TOP_OBJID="${item.ROOT_OBJID}" data-SUB_TOP_OBJID="${item.SUB_ROOT_OBJID}">
<c:choose>
<c:when test="${item.LEAF eq '0'}">
<!-- 하위 항목이 있음: Plus 아이콘 표시 -->
<img src="/images/btnPlus.png" width="13px" height="13px" class="btnToggle" style="cursor:pointer;" data-BOM_REPORT_OBJID="${item.BOM_REPORT_OBJID}"
data-OBJID="${item.PART_OBJID}${status.index}" data-REAL_LEVEL="${item.LEV}" data-TOP_OBJID="${item.ROOT_OBJID}" data-SUB_TOP_OBJID="${item.SUB_ROOT_OBJID}">
</c:when>
<c:when test="${param.search_level > item.LEV}">
<img src="/images/btnMinus.png" width="13px" height="13px" class="btnToggle" style="${item.LEVEL eq '1' and item.LEAF eq '0'?'cursor:pointer;':''}" data-BOM_REPORT_OBJID="${item.BOM_REPORT_OBJID}"
data-OBJID="${item.PART_OBJID}${status.index}" data-REAL_LEVEL="${item.LEV}" data-TOP_OBJID="${item.ROOT_OBJID}" data-SUB_TOP_OBJID="${item.SUB_ROOT_OBJID}">
</c:when>
<c:when test="${param.search_level <= item.LEV}">
<img src="/images/btnPlus.png" width="13px" height="13px" class="btnToggle" style="${item.LEVEL eq '1' and item.LEAF eq '0'?'cursor:pointer;':''}" data-BOM_REPORT_OBJID="${item.BOM_REPORT_OBJID}"
data-OBJID="${item.PART_OBJID}${status.index}" data-REAL_LEVEL="${item.LEV}" data-TOP_OBJID="${item.ROOT_OBJID}" data-SUB_TOP_OBJID="${item.SUB_ROOT_OBJID}">
</c:when>
</c:choose>
</c:if>
<c:otherwise>
<!-- 하위 항목이 없음: - 표시 -->
<span style="display:inline-block; width:13px; text-align:center; color:#999;">-</span>
</c:otherwise>
</c:choose>
</td>
<c:forEach var="i" begin="1" end="${item.MAX_LEVEL}">
<c:if test="${item.LEVEL eq i}">
@@ -971,9 +1066,9 @@ function fn_excelExport(pGridObj,pFileName){
<td title="${item.SURFACE_TREATMENT}" class="align_l" style="text-align: left; padding-left: 5px;">${item.SURFACE_TREATMENT}</td><!-- 표면처리 -->
<td title="${item.MAKER}" class="align_l" style="text-align: left; padding-left: 5px;">${item.MAKER}</td><!-- 공급업체 -->
<td title="${item.PART_TYPE_TITLE}" class="align_c">${item.PART_TYPE_TITLE}</td><!-- PART_TYPE -->
<td title="${item.REVISION}" class="align_c" style="text-align: left; padding-left: 5px;">${item.REVISION}</td><!-- REVISION -->
<td title="${item.EO_NO}" class="align_c" style="text-align: left; padding-left: 5px;">${item.EO_NO}</td><!-- EO_NO -->
<td title="${item.EO_DATE}" class="align_c" style="text-align: left; padding-left: 5px;">${item.EO_DATE}</td><!-- EO_DATE -->
<!-- <td title="${item.REVISION}" class="align_c" style="text-align: left; padding-left: 5px;">${item.REVISION}</td> REVISION
<td title="${item.EO_NO}" class="align_c" style="text-align: left; padding-left: 5px;">${item.EO_NO}</td> EO_NO
<td title="${item.EO_DATE}" class="align_c" style="text-align: left; padding-left: 5px;">${item.EO_DATE}</td> EO_DATE -->
<td title="${item.REMARK}" class="align_l">${item.REMARK}</td><!-- REMARK -->
</tr>
</c:forEach>

View File

@@ -12,7 +12,11 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title><%=Constants.SYSTEM_NAME%></title>
<style type="text/css">
#plmSearchZon {
width: auto !important;
}
</style>
<script>
// Excel 버튼 이벤트 등록 함수
function fn_bindExcelButton() {
@@ -187,7 +191,8 @@ var columns = [
// 요청된 열 순서: 프로젝트번호, 주문유형, 매출 마감, 발주일, 발주번호, 고객사, 제품구분, 품명, 수량, 단가, 공급가액, 부가세, 총액, 원화총액, 출고일, 국내/해외, 환종, 환율, S/N, 품번
{headerHozAlign : 'center', hozAlign : 'center', width : '120', title : '프로젝트번호', field : 'PROJECT_NO', frozen : true},
{headerHozAlign : 'center', hozAlign : 'center', width : '100', title : '주문유형', field : 'ORDER_TYPE'},
{headerHozAlign : 'center', hozAlign : 'center', width : '120', title : '매출마감', field : 'SALES_STATUS'},
//{headerHozAlign : 'center', hozAlign : 'center', width : '120', title : '매출마감', field : 'SALES_STATUS'},
{headerHozAlign : 'center', hozAlign : 'center', width : '120', title : '매출마감', field : 'SALES_DEADLINE_DATE'},
{headerHozAlign : 'center', hozAlign : 'center', width : '100', title : '발주일', field : 'ORDER_DATE'},
{headerHozAlign : 'center', hozAlign : 'center', width : '120', title : '발주번호', field : 'PO_NO'},
{headerHozAlign : 'center', hozAlign : 'left', width : '150', title : '고객사', field : 'CUSTOMER'},
@@ -253,6 +258,7 @@ function fn_search(){
// 그리드 초기화
_tabulGrid = new Tabulator("#mainGrid", {
layout: _tabul_layout_fitColumns,
height: "650px", // 그리드 고정 높이 설정
columns: columns,
data: response.RESULTLIST || [],
selectable: "highlight" // 다중 선택 가능하도록 설정

111
db/README.md Normal file
View File

@@ -0,0 +1,111 @@
# 데이터베이스 백업 시스템
## 개요
PostgreSQL 데이터베이스를 자동으로 백업하는 시스템입니다.
- 하루 2회 자동 백업 (오전 7:30, 오후 6:00)
- 로컬 백업 및 FTP 원격 백업 지원
- 7일 이상 된 백업 자동 삭제
## 파일 구성
- `backup.py`: 백업 스크립트
- `00-create-roles.sh`: 데이터베이스 역할 생성 스크립트
- `dbexport.pgsql`: 데이터베이스 덤프 파일
## 설정 방법
### 1. 환경 변수 설정
`.env.production` 파일에 다음 설정을 추가:
```bash
# 필수 설정
POSTGRES_HOST=wace-plm-db
POSTGRES_DOCKER_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_password
POSTGRES_DB=waceplm
LOCAL_BACKUP_PATH=/backups/local
BACKUP_RETENTION_DAYS=7
BACKUP_TIME_AM=07:30
BACKUP_TIME_PM=18:00
# 선택 설정 (FTP 원격 백업)
FTP_HOST=ftp.example.com
FTP_USER=backup_user
FTP_PASSWORD=ftp_password
FTP_PATH=/backups
FTP_PORT=2122
```
### 2. Docker Compose 실행
```bash
# 백업 서비스 포함하여 실행
docker-compose -f docker-compose.prod.yml up -d
# 백업 서비스만 재시작
docker-compose -f docker-compose.prod.yml restart wace-plm-backup
```
### 3. 백업 확인
```bash
# 백업 로그 확인
docker logs -f wace-plm-backup
# 백업 파일 확인
ls -lh /home/wace-plm/backups/
```
## 수동 백업 실행
### 즉시 백업 실행
```bash
docker exec wace-plm-backup python -c "from backup import backup_databases; backup_databases()"
```
### 백업 파일 복원
```bash
# 백업 파일 확인
ls /home/wace-plm/backups/
# 복원 실행
docker exec -i wace-plm-db psql -U postgres -d waceplm < /home/wace-plm/backups/waceplm_backup_YYYYMMDD_HHMMSS.sql
```
## 백업 스케줄
- **오전 백업**: 매일 07:30
- **오후 백업**: 매일 18:00
- **자동 삭제**: 7일 이상 된 백업 파일
## 트러블슈팅
### 백업이 실행되지 않는 경우
```bash
# 컨테이너 상태 확인
docker ps | grep backup
# 로그 확인
docker logs wace-plm-backup
# 환경 변수 확인
docker exec wace-plm-backup env | grep POSTGRES
```
### 디스크 공간 부족
```bash
# 백업 파일 크기 확인
du -sh /home/wace-plm/backups/
# 오래된 백업 수동 삭제
find /home/wace-plm/backups/ -name "*.sql" -mtime +7 -delete
```
### FTP 업로드 실패
- FTP 서버 접속 정보 확인
- 방화벽 설정 확인
- FTP 경로 권한 확인
## 주의사항
1. 백업 파일은 민감한 정보를 포함하므로 접근 권한 관리 필요
2. 충분한 디스크 공간 확보 필요 (데이터베이스 크기의 최소 10배 권장)
3. FTP 비밀번호는 반드시 환경 변수로 관리
4. 정기적으로 복원 테스트 수행 권장

463
db/backup.py Normal file
View File

@@ -0,0 +1,463 @@
import os
import schedule
import time
import subprocess
from datetime import datetime, timedelta
import logging
import sys
import ftplib # Use the built-in FTP library
from io import BytesIO # Needed for reading file content for FTP upload
# --- Configuration (from environment variables) ---
POSTGRES_HOST = os.getenv('POSTGRES_HOST', 'wace-plm-db')
POSTGRES_PORT = os.getenv('POSTGRES_DOCKER_PORT', '5432')
POSTGRES_USER = os.getenv('POSTGRES_USER')
POSTGRES_PASSWORD = os.getenv('POSTGRES_PASSWORD')
POSTGRES_DB = os.getenv('POSTGRES_DB')
LOCAL_BACKUP_PATH = os.getenv('LOCAL_BACKUP_PATH', '/backups/local')
# FTP Configuration
FTP_HOST = os.getenv('FTP_HOST')
FTP_USER = os.getenv('FTP_USER')
FTP_PASSWORD = os.getenv('FTP_PASSWORD')
FTP_PATH = os.getenv('FTP_PATH', '/') # Default to root FTP directory if not specified
FTP_PORT = int(os.getenv('FTP_PORT', 2122)) # Default FTP port is 21
BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 7))
BACKUP_TIME_AM = os.getenv('BACKUP_TIME_AM', "07:30")
BACKUP_TIME_PM = os.getenv('BACKUP_TIME_PM', "18:00")
# --- End Configuration ---
# --- Logging Setup ---
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
stream=sys.stdout # Log to stdout to be captured by Docker logs
)
# --- End Logging Setup ---
def check_env_vars():
"""Check if required environment variables are set."""
required_vars = ['POSTGRES_USER', 'POSTGRES_PASSWORD', 'POSTGRES_DB']
# Add FTP vars only if host/user/password/path are provided
if FTP_HOST and FTP_USER and FTP_PASSWORD and FTP_PATH:
required_vars.extend(['FTP_HOST', 'FTP_USER', 'FTP_PASSWORD', 'FTP_PATH'])
logging.info("FTP configuration found in environment variables.")
else:
logging.warning("FTP configuration not fully provided (HOST, USER, PASSWORD, PATH). Remote backups will be skipped.")
# Check database vars
missing_vars = [var for var in required_vars if not os.getenv(var)]
if missing_vars:
logging.error(f"Missing required environment variables: {', '.join(missing_vars)}")
sys.exit(1)
logging.info("All required environment variables checked.")
def create_backup_dirs():
"""Create local backup directory if it doesn't exist."""
try:
os.makedirs(LOCAL_BACKUP_PATH, exist_ok=True)
logging.info(f"Ensured local backup directory exists: {LOCAL_BACKUP_PATH}")
except OSError as e:
logging.error(f"Error creating local backup directory: {e}")
sys.exit(1) # Stop if local backup isn't possible
# Note: We will try to create the remote FTP directory if it doesn't exist during upload/cleanup
def ensure_ftp_dir(ftp, path):
"""Ensures the specified directory exists on the FTP server."""
parts = path.strip('/').split('/')
current_path = ''
for part in parts:
if not part: continue
current_path += '/' + part
try:
ftp.cwd(current_path)
except ftplib.error_perm as e:
if str(e).startswith('550'): # 550: Directory not found or permission denied
try:
ftp.mkd(current_path)
logging.info(f"Created remote FTP directory: {current_path}")
ftp.cwd(current_path) # Go into the newly created dir
except ftplib.error_perm as mkd_e:
logging.error(f"Failed to create or access FTP directory {current_path}: {mkd_e}")
raise # Re-raise the exception to signal failure
else:
logging.error(f"FTP error accessing {current_path}: {e}")
raise
# Ensure we are in the final target directory
ftp.cwd(path)
logging.info(f"Ensured remote FTP directory exists and CWD set to: {path}")
def perform_database_backup(db_config, backup_prefix):
"""Performs PostgreSQL database backup for a specific database configuration."""
timestamp = datetime.now().strftime('%Y-%m-%d_%H%M%S')
backup_filename = f"{backup_prefix}_{timestamp}.sql" # SQL 파일로 변경 (.gz 제거)
local_filepath = os.path.join(LOCAL_BACKUP_PATH, backup_filename)
logging.info(f"Starting backup for database '{db_config['db_name']}' with prefix '{backup_prefix}'...")
logging.info(f"Local target: {local_filepath}")
# 1. Create local backup using pg_dump with plain SQL format (no compression)
pg_dump_command = [
'pg_dump',
f'--host={db_config["host"]}',
f'--port={db_config["port"]}',
f'--username={db_config["user"]}',
f'--dbname={db_config["db_name"]}',
'--format=plain', # 일반 SQL 텍스트 형식 사용
'--no-owner', # 소유자 정보 제외 (복원 시 유연성 향상)
'--no-privileges', # 권한 정보 제외 (복원 시 유연성 향상)
f'--file={local_filepath}'
]
env = os.environ.copy()
env['PGPASSWORD'] = db_config['password']
try:
process = subprocess.run(
pg_dump_command,
env=env,
check=True,
capture_output=True,
text=True
)
logging.info(f"Successfully created local backup: {local_filepath}")
logging.debug(f"pg_dump stdout: {process.stdout}")
logging.debug(f"pg_dump stderr: {process.stderr}")
# 2. Upload to Remote via FTP if configured
if FTP_HOST and FTP_USER and FTP_PASSWORD and FTP_PATH:
remote_target_path = FTP_PATH.rstrip("/") + "/" + backup_filename
# Ensure log path starts with / for clarity
log_full_path = f"/{remote_target_path.lstrip('/')}"
logging.info(f"Attempting to upload backup via FTP to: ftp://{FTP_HOST}:{FTP_PORT}{log_full_path}")
ftp = None # Initialize ftp to None
try:
ftp = ftplib.FTP()
ftp.connect(FTP_HOST, FTP_PORT, timeout=60) # Increased timeout to 60 seconds
ftp.login(FTP_USER, FTP_PASSWORD)
ftp.set_pasv(True) # Use passive mode, usually necessary
# --- Simplified directory change (mimicking lftp) ---
try:
if FTP_PATH: # Only change directory if FTP_PATH is set
logging.info(f"Changing remote directory to: {FTP_PATH}")
ftp.cwd(FTP_PATH)
logging.info(f"Successfully changed remote directory to: {ftp.pwd()}") # Log current dir
else:
logging.info("FTP_PATH is not set, uploading to user's home directory.")
# Upload the file to the current directory
logging.info(f"Attempting to upload {backup_filename} to current remote directory.")
with open(local_filepath, 'rb') as local_file:
ftp.storbinary(f'STOR {backup_filename}', local_file)
logging.info(f"Successfully uploaded backup via FTP to remote path: {FTP_PATH}/{backup_filename}") # Adjust log message slightly
except ftplib.error_perm as ftp_cwd_err:
logging.error(f"Failed to change FTP directory to '{FTP_PATH}': {ftp_cwd_err}")
except ftplib.all_errors as ftp_err:
logging.error(f"FTP operation failed during/after CWD or during STOR: {ftp_err}")
# --- End Simplified directory change ---
except ftplib.all_errors as ftp_err:
logging.error(f"FTP connection/login failed: {ftp_err}") # Adjusted error scope
# Potentially retry or raise an error to indicate failure
except FileNotFoundError:
logging.error(f"Local backup file not found for FTP upload: {local_filepath}")
except Exception as ftp_e:
logging.error(f"An unexpected error occurred during FTP upload: {ftp_e}")
finally:
if ftp:
try:
ftp.quit()
except ftplib.all_errors:
logging.debug("FTP quit command failed, closing connection.")
ftp.close() # Force close if quit fails
else:
logging.warning("FTP configuration not provided. Skipping remote upload.")
except subprocess.CalledProcessError as e:
logging.error(f"pg_dump failed with exit code {e.returncode} for database '{db_config['db_name']}'")
logging.error(f"pg_dump stderr: {e.stderr}")
if os.path.exists(local_filepath):
try:
os.remove(local_filepath)
logging.info(f"Removed incomplete local backup file: {local_filepath}")
except OSError as remove_err:
logging.error(f"Error removing incomplete local backup file {local_filepath}: {remove_err}")
except Exception as e:
logging.error(f"An unexpected error occurred during backup for '{db_config['db_name']}': {e}")
def perform_backup():
"""Performs the PostgreSQL database backup."""
logging.info("=== Starting backup process ===")
# Database configuration
db_config = {
'host': POSTGRES_HOST,
'port': POSTGRES_PORT,
'user': POSTGRES_USER,
'password': POSTGRES_PASSWORD,
'db_name': POSTGRES_DB
}
# Perform backup
try:
perform_database_backup(db_config, POSTGRES_DB)
logging.info(f"Completed backup for database: {POSTGRES_DB}")
except Exception as e:
logging.error(f"Failed to backup database: {e}")
logging.info("=== Backup process completed ===")
# Legacy function kept for compatibility (now calls the new generic function)
def perform_backup_legacy():
"""Legacy backup function - kept for backward compatibility."""
return perform_backup()
def cleanup_local_backups(backup_dir):
"""Removes local backups older than BACKUP_RETENTION_DAYS."""
if not os.path.isdir(backup_dir):
logging.warning(f"Local cleanup skipped: Directory not found or inaccessible: {backup_dir}")
return
logging.info(f"Starting cleanup of old local backups in: {backup_dir}")
cutoff_date = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS)
files_deleted = 0
files_checked = 0
try:
for filename in os.listdir(backup_dir):
# Match the filename pattern
is_db_backup = filename.startswith(f"{POSTGRES_DB}_") and filename.endswith(".sql")
if is_db_backup:
files_checked += 1
filepath = os.path.join(backup_dir, filename)
try:
# Use file modification time for age check
file_mod_time_ts = os.path.getmtime(filepath)
file_mod_time = datetime.fromtimestamp(file_mod_time_ts)
if file_mod_time < cutoff_date:
os.remove(filepath)
logging.info(f"Deleted old local backup: {filepath} (modified: {file_mod_time})")
files_deleted += 1
except OSError as e:
logging.error(f"Error processing or deleting local file {filepath}: {e}")
except ValueError: # Should not happen with getmtime
logging.warning(f"Could not get modification time for local file: {filename}. Skipping.")
logging.info(f"Local cleanup finished for {backup_dir}. Checked: {files_checked}, Deleted: {files_deleted}.")
except OSError as e:
logging.error(f"Error listing directory {backup_dir} during local cleanup: {e}")
def parse_mlsd_time(timestr):
"""Parses the timestamp from MLSD command output (YYYYMMDDHHMMSS)."""
try:
return datetime.strptime(timestr, '%Y%m%d%H%M%S')
except ValueError:
logging.warning(f"Could not parse MLSD time string: {timestr}")
return None
def cleanup_remote_backups():
"""Removes remote backups older than BACKUP_RETENTION_DAYS using FTP."""
if not (FTP_HOST and FTP_USER and FTP_PASSWORD and FTP_PATH):
logging.warning("FTP configuration not provided. Skipping remote cleanup.")
return
remote_dir = FTP_PATH.rstrip("/")
# Correct the logging message to avoid double slash if FTP_PATH starts with /
log_path = f"/{remote_dir.lstrip('/')}" if remote_dir else "/"
logging.info(f"Starting cleanup of old remote backups in: ftp://{FTP_HOST}:{FTP_PORT}{log_path}")
cutoff_date = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS)
files_deleted = 0
files_checked = 0
ftp = None
try:
ftp = ftplib.FTP()
ftp.connect(FTP_HOST, FTP_PORT, timeout=60) # Increased timeout to 60 seconds
ftp.login(FTP_USER, FTP_PASSWORD)
ftp.set_pasv(True)
# --- Simplified directory change (similar to upload) ---
try:
if remote_dir: # Only change directory if remote_dir (derived from FTP_PATH) is set
logging.info(f"Changing remote directory for cleanup to: {remote_dir}")
ftp.cwd(remote_dir)
logging.info(f"Successfully changed remote directory for cleanup to: {ftp.pwd()}") # Log current dir
else:
logging.info("FTP_PATH is not set, performing cleanup in user's home directory.")
# --- Proceed with listing and deletion in the CURRENT directory ---
# Use MLSD for modern servers, fallback needed if not supported
try:
lines = []
ftp.retrlines('MLSD', lines.append)
logging.debug(f"MLSD output for current directory ({ftp.pwd()}):\n" + "\n".join(lines))
for line in lines:
parts = line.split(';')
facts = {}
for part in parts:
if '=' in part:
key, value = part.split('=', 1)
facts[key.strip().lower()] = value.strip()
filename = facts.get('') # Filename is the part without key=value
filetype = facts.get('type')
modify_time_str = facts.get('modify')
# Process files matching database pattern
is_db_backup = filename and filename.startswith(f"{POSTGRES_DB}_") and filename.endswith(".sql")
if filetype == 'file' and is_db_backup:
files_checked += 1
if modify_time_str:
file_mod_time = parse_mlsd_time(modify_time_str)
if file_mod_time and file_mod_time < cutoff_date:
try:
ftp.delete(filename)
logging.info(f"Deleted old remote backup: {filename} (modified: {file_mod_time})")
files_deleted += 1
except ftplib.error_perm as del_err:
logging.error(f"Failed to delete remote file {filename}: {del_err}")
elif not file_mod_time:
logging.warning(f"Skipping remote file due to unparseable time: {filename}")
else:
logging.warning(f"Could not get modification time for remote file: {filename}. Skipping deletion check.")
logging.info(f"Remote cleanup finished using MLSD for {remote_dir}. Checked: {files_checked}, Deleted: {files_deleted}.")
except ftplib.error_perm as mlsd_err:
logging.warning(f"MLSD command failed (server might not support it): {mlsd_err}. Falling back to LIST/MDTM (less reliable).")
# Fallback to LIST and MDTM (less efficient and parsing can be fragile)
files_deleted = 0 # Reset counter for fallback
files_checked = 0
try:
filenames = ftp.nlst()
logging.debug(f"NLST output for {remote_dir}: {filenames}")
for filename in filenames:
# Check for database backup pattern
is_db_backup = filename.startswith(f"{POSTGRES_DB}_") and filename.endswith(".sql")
if is_db_backup:
files_checked += 1
try:
# Attempt to get modification time
mdtm_str = ftp.voidcmd(f"MDTM {filename}")
# Response format is usually "213 YYYYMMDDHHMMSS"
if mdtm_str.startswith("213 "):
file_mod_time = parse_mlsd_time(mdtm_str[4:].strip())
if file_mod_time and file_mod_time < cutoff_date:
try:
ftp.delete(filename)
logging.info(f"Deleted old remote backup (fallback): {filename} (modified: {file_mod_time})")
files_deleted += 1
except ftplib.error_perm as del_err_fb:
logging.error(f"Failed to delete remote file {filename} (fallback): {del_err_fb}")
elif not file_mod_time:
logging.warning(f"Skipping remote file (fallback) due to unparseable time: {filename}")
else:
logging.warning(f"Could not get MDTM for remote file {filename}: {mdtm_str}. Skipping deletion check.")
except ftplib.error_perm as mdtm_err:
logging.warning(f"MDTM command failed for {filename}: {mdtm_err}. Skipping deletion check.")
except Exception as fb_err:
logging.warning(f"Error processing file {filename} in fallback: {fb_err}. Skipping.")
logging.info(f"Remote cleanup finished using LIST/MDTM fallback for {remote_dir}. Checked: {files_checked}, Deleted: {files_deleted}.")
except ftplib.error_perm as list_err:
logging.error(f"Failed to list files using NLST in fallback: {list_err}")
except Exception as fallback_list_err:
logging.error(f"An unexpected error occurred during FTP fallback cleanup: {fallback_list_err}")
except ftplib.error_perm as ftp_cwd_err:
logging.error(f"Failed to change FTP directory for cleanup to '{remote_dir}': {ftp_cwd_err}")
# If we can't change directory, we can't clean it.
return # Exit cleanup function
except ftplib.all_errors as ftp_err:
logging.error(f"FTP connection/login failed during cleanup: {ftp_err}")
return # Exit cleanup function
# --- End Simplified directory change ---
except ftplib.all_errors as ftp_err:
logging.error(f"FTP cleanup failed: {ftp_err}")
except Exception as ftp_clean_e:
logging.error(f"An unexpected error occurred during FTP cleanup: {ftp_clean_e}")
finally:
if ftp:
try:
ftp.quit()
except ftplib.all_errors:
logging.debug("FTP quit command failed during cleanup, closing connection.")
ftp.close()
def run_cleanup():
"""Runs cleanup for both local and remote directories."""
logging.info("Running scheduled cleanup job.")
cleanup_local_backups(LOCAL_BACKUP_PATH)
cleanup_remote_backups()
def run_backup_job():
"""Runs the backup job."""
logging.info("Running scheduled backup job.")
perform_backup()
# Cleanup is handled by a separate schedule
if __name__ == "__main__":
check_env_vars()
create_backup_dirs()
logging.info("Backup script starting.")
logging.info(f"Scheduling backups for {BACKUP_TIME_AM} and {BACKUP_TIME_PM} KST (Asia/Seoul).")
logging.info(f"Backup retention: {BACKUP_RETENTION_DAYS} days.")
logging.info(f"Local backup path: {LOCAL_BACKUP_PATH}")
# Log database configuration
logging.info(f"Database: {POSTGRES_DB} at {POSTGRES_HOST}:{POSTGRES_PORT}")
if FTP_HOST and FTP_USER and FTP_PASSWORD and FTP_PATH:
# Ensure log path starts with / for clarity
log_ftp_path = f"/{FTP_PATH.lstrip('/')}"
logging.info(f"FTP Target: ftp://{FTP_USER}:****@{FTP_HOST}:{FTP_PORT}{log_ftp_path}")
else:
logging.info("FTP Target: Not configured.")
# --- Initial Run ---
logging.info("--- Performing initial backup and cleanup run on startup ---")
try:
run_backup_job() # Perform the backup job first
run_cleanup() # Then run cleanup
logging.info("--- Initial run complete. Proceeding to scheduled runs. ---")
except Exception as initial_run_error:
logging.error(f"Error during initial backup/cleanup run: {initial_run_error}")
# Log the error and continue to the scheduler.
# --- End Initial Run ---
# --- Scheduling ---
schedule.every().day.at(BACKUP_TIME_AM, "Asia/Seoul").do(run_backup_job)
schedule.every().day.at(BACKUP_TIME_PM, "Asia/Seoul").do(run_backup_job)
# Schedule cleanup (e.g., once daily, shortly after the first backup)
# Ensure the time parsing and addition handles potential day rollovers if needed,
# but adding 15 minutes should be safe.
try:
cleanup_dt = datetime.strptime(BACKUP_TIME_AM, "%H:%M") + timedelta(minutes=15)
cleanup_time = cleanup_dt.strftime("%H:%M")
logging.info(f"Scheduling daily cleanup job for {cleanup_time} KST (Asia/Seoul).")
schedule.every().day.at(cleanup_time, "Asia/Seoul").do(run_cleanup)
except ValueError:
logging.error(f"Invalid BACKUP_TIME_AM format: {BACKUP_TIME_AM}. Cannot schedule cleanup accurately.")
# Fallback: Schedule cleanup at a fixed time like 08:00
logging.warning("Scheduling cleanup for 08:00 KST as fallback.")
schedule.every().day.at("08:00", "Asia/Seoul").do(run_cleanup)
# --- End Scheduling ---
logging.info("Scheduler started. Waiting for scheduled jobs...")
while True:
schedule.run_pending()
time.sleep(60) # Check every 60 seconds

View File

@@ -46,6 +46,20 @@ services:
timeout: 5s
retries: 5
wace-plm-backup:
build:
context: .
dockerfile: Dockerfile.backup
container_name: wace-plm-backup
restart: always
env_file:
- .env.production
depends_on:
wace-plm-db:
condition: service_healthy
volumes:
- /home/wace-plm/backups:/backups/local
networks:
default:
external:

View File

@@ -1,15 +1,15 @@
# PLM ILSHIN 운영환경 설정
# WACE PLM 운영환경 설정
# 애플리케이션 환경
NODE_ENV=production
# 데이터베이스 설정
DB_URL=jdbc:postgresql://localhost:5432/ilshin
DB_URL=jdbc:postgresql://wace-plm-db:5432/waceplm
DB_USERNAME=postgres
DB_PASSWORD=your_production_password
# PostgreSQL 환경 변수
POSTGRES_DB=ilshin
POSTGRES_DB=waceplm
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_production_password
@@ -26,5 +26,19 @@ DEBUG=false
SSL_ENABLED=true
# 도메인 설정
DOMAIN=ilshin.esgrin.com
ALT_DOMAIN=autoclave.co.kr
DOMAIN=waceplm.esgrin.com
# 백업 설정
POSTGRES_HOST=wace-plm-db
POSTGRES_DOCKER_PORT=5432
LOCAL_BACKUP_PATH=/backups/local
BACKUP_RETENTION_DAYS=7
BACKUP_TIME_AM=07:30
BACKUP_TIME_PM=18:00
# FTP 백업 설정 (NAS Backup SMB Configuration)
FTP_HOST=effectsno1.synology.me
FTP_USER=esgrin-mes-backup
FTP_PASSWORD=UyD12#11YHnn
FTP_PATH=esgrin-mes-backup
FTP_PORT=2112

View File

@@ -6038,7 +6038,7 @@ SELECT T1.LEV, T1.BOM_REPORT_OBJID, T1.ROOT_PART_NO, T1.PATH, T1.LEAF, T2.*
BT.ITEM_QTY,
BT.QTY AS QTY_TEMP,
BT.SEQ,
BT.LEAF,
-- BT.LEAF, -- 재귀 CTE의 LEAF는 항상 0이므로 제거 (아래에서 재계산)
-- PART 정보
PM.OBJID AS PART_OBJID,
PM.PART_NO,
@@ -6424,12 +6424,21 @@ SELECT T1.LEV, T1.BOM_REPORT_OBJID, T1.ROOT_PART_NO, T1.PATH, T1.LEAF, T2.*
)
SELECT
BT.BOM_REPORT_OBJID,
BT.LEV,
BT.LEV AS LEVEL,
-- 역전개는 레벨을 거꾸로 표시 (최상위가 1레벨)
(ML.MAX_LEVEL - BT.LEV + 1) AS LEV,
(ML.MAX_LEVEL - BT.LEV + 1) AS LEVEL,
BT.QTY,
BT.ITEM_QTY,
BT.QTY AS P_QTY,
BT.SEQ,
-- LEAF 계산 (역전개: 하위 항목이 있는지 체크 - 정전개와 동일)
(
SELECT CASE WHEN COUNT(*) > 0 THEN 0 ELSE 1 END
FROM BOM_PART_QTY BPQ2
WHERE BPQ2.PARENT_OBJID = BT.CHILD_OBJID
AND BPQ2.BOM_REPORT_OBJID = BT.BOM_REPORT_OBJID
AND COALESCE(BPQ2.STATUS, '') NOT IN ('deleting', 'deleted')
) AS LEAF,
-- PART 정보
PM.OBJID AS PART_OBJID,
PM.PART_NO,
@@ -6472,7 +6481,7 @@ SELECT T1.LEV, T1.BOM_REPORT_OBJID, T1.ROOT_PART_NO, T1.PATH, T1.LEAF, T2.*
ORDER BY
PBR.REGDATE DESC,
BT.LEV,
(ML.MAX_LEVEL - BT.LEV + 1), -- 역전개: 레벨 역순
BT.PATH
</select>

View File

@@ -7627,29 +7627,65 @@ SELECT
<!-- //영업정보 수정시 프로젝트 정보 업데이트 -->
<update id="ModifyProjectByContract" parameterType="map">
UPDATE PROJECT_MGMT
SET
DUE_DATE = #{due_date}
,CUSTOMER_PROJECT_NAME = #{customer_project_name}
,LOCATION = #{location}
,SETUP = #{setup}
,FACILITY = #{facility}
,FACILITY_TYPE = #{facility_type}
,FACILITY_DEPTH = #{facility_depth}
,CONTRACT_DATE = #{contract_date}
,PO_NO = #{po_no}
,PM_USER_ID = #{pm_user_id}
,CONTRACT_CURRENCY = #{contract_currency}
,CONTRACT_PRICE_CURRENCY = #{contract_price_currency}
,CONTRACT_PRICE = #{contract_price}
,PROJECT_NAME = #{project_name}
,CONTRACT_DEL_DATE = #{contract_del_date}
,REQ_DEL_DATE = #{req_del_date}
,CONTRACT_COMPANY = #{contract_company}
,MANUFACTURE_PLANT = #{manufacture_plant}
,PART_OBJID = #{part_objid}
,PART_NO = #{part_no}
,PART_NAME = #{part_name}
,QUANTITY = #{quantity}
<set>
<if test="due_date != null and due_date != ''">
DUE_DATE = #{due_date},
</if>
<if test="customer_project_name != null and customer_project_name != ''">
CUSTOMER_PROJECT_NAME = #{customer_project_name},
</if>
<if test="location != null and location != ''">
LOCATION = #{location},
</if>
<if test="setup != null and setup != ''">
SETUP = #{setup},
</if>
<if test="facility != null and facility != ''">
FACILITY = #{facility},
</if>
<if test="facility_type != null and facility_type != ''">
FACILITY_TYPE = #{facility_type},
</if>
<if test="facility_depth != null and facility_depth != ''">
FACILITY_DEPTH = #{facility_depth},
</if>
<if test="contract_date != null and contract_date != ''">
CONTRACT_DATE = #{contract_date},
</if>
<if test="po_no != null and po_no != ''">
PO_NO = #{po_no},
</if>
<if test="pm_user_id != null and pm_user_id != ''">
PM_USER_ID = #{pm_user_id},
</if>
<if test="contract_currency != null and contract_currency != ''">
CONTRACT_CURRENCY = #{contract_currency},
</if>
<if test="contract_price_currency != null and contract_price_currency != ''">
CONTRACT_PRICE_CURRENCY = #{contract_price_currency},
</if>
<if test="contract_price != null and contract_price != ''">
CONTRACT_PRICE = #{contract_price},
</if>
<if test="project_name != null and project_name != ''">
PROJECT_NAME = #{project_name},
</if>
<if test="contract_del_date != null and contract_del_date != ''">
CONTRACT_DEL_DATE = #{contract_del_date},
</if>
<if test="req_del_date != null and req_del_date != ''">
REQ_DEL_DATE = #{req_del_date},
</if>
<if test="contract_company != null and contract_company != ''">
CONTRACT_COMPANY = #{contract_company},
</if>
<if test="manufacture_plant != null and manufacture_plant != ''">
MANUFACTURE_PLANT = #{manufacture_plant},
</if>
<if test="quantity != null and quantity != ''">
QUANTITY = #{quantity}
</if>
</set>
WHERE CONTRACT_OBJID = #{objId}
AND PART_OBJID = #{part_objid}
</update>

View File

@@ -1813,6 +1813,40 @@ public class ContractMgmtController {
return "/contractMgmt/estimateRegistFormPopup";
}
/**
* 견적요청 조회 전용 팝업 (View Only)
* @param request
* @param paramMap
* @return
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
@RequestMapping("/contractMgmt/estimateViewPopup.do")
public String estimateViewPopup(HttpSession session, HttpServletRequest request, @RequestParam Map<String, Object> paramMap){
String objId = CommonUtils.checkNull(paramMap.get("objId"));
try{
Map info = null;
List<Map<String, Object>> itemList = new ArrayList<Map<String, Object>>();
if(paramMap.get("objId")!=null){
paramMap.put("objId",objId);
info = CommonUtils.keyChangeUpperMap(contractMgmtService.getContractMgmtInfo(paramMap));
// 품목 목록 조회
itemList = contractMgmtService.getContractItemList(objId);
}
request.setAttribute("info", info);
request.setAttribute("objId", objId);
request.setAttribute("itemList", itemList);
}catch(Exception e){
e.printStackTrace();
}
return "/contractMgmt/estimateViewPopup";
}
/**
* 품번 검색 (AJAX용)
*/
@@ -2571,4 +2605,43 @@ public class ContractMgmtController {
return resultMap;
}
/**
* 프로젝트 존재 여부 확인 (AJAX)
* @param request
* @param paramMap - contractObjId
* @return
*/
@ResponseBody
@RequestMapping(value="/contractMgmt/checkProjectExists.do", method=RequestMethod.POST)
public Map checkProjectExists(HttpServletRequest request, @RequestParam Map<String, Object> paramMap){
Map resultMap = new HashMap();
try {
String contractObjId = CommonUtils.checkNull(paramMap.get("contractObjId"));
if(StringUtils.isBlank(contractObjId)) {
System.out.println("contractObjId가 비어있음 - exists: false");
resultMap.put("exists", false);
return resultMap;
}
paramMap.put("objId", contractObjId);
Map projectInfo = contractMgmtService.checkProjectExists(paramMap);
if(projectInfo != null) {
resultMap.put("exists", true);
resultMap.put("projectInfo", projectInfo);
} else {
resultMap.put("exists", false);
}
} catch (Exception e) {
e.printStackTrace();
resultMap.put("exists", false);
resultMap.put("error", e.getMessage());
}
return resultMap;
}
}

View File

@@ -1411,8 +1411,11 @@
SELECT
A.OBJID
,A.CATEGORY_CD
,CODE_NAME(A.CATEGORY_CD) AS CATEGORY_NAME
,A.CUSTOMER_OBJID
,(SELECT SUPPLY_NAME FROM SUPPLY_MNG AS O WHERE O.OBJID = A.CUSTOMER_OBJID::NUMERIC) AS CUSTOMER_NAME
,A.PRODUCT
,CODE_NAME(A.PRODUCT) AS PRODUCT_NAME
,A.CUSTOMER_PROJECT_NAME
,A.STATUS_CD
,A.DUE_DATE
@@ -1455,12 +1458,14 @@
,A.EST_COMP_DATE
,A.EST_RESULT_CD
,A.AREA_CD
,CODE_NAME(A.AREA_CD) AS AREA_NAME
,A.TARGET_PROJECT_NO
,A.TARGET_PROJECT_NO_DIRECT
,A.CUSTOMER_PRODUCTION_NO
,A.MECHANICAL_TYPE
,A.OVERHAUL_ORDER
,A.PAID_TYPE
,(case when A.PAID_TYPE = 'paid' then '유상' when A.PAID_TYPE = 'free' then '무상' else A.PAID_TYPE end) AS PAID_TYPE_NAME
,A.RECEIPT_DATE
,A.PART_NO
,A.PART_NAME
@@ -3511,7 +3516,9 @@ ORDER BY ASM.SUPPLY_NAME
<select id="getProjectListBycontractObjid" parameterType="map" resultType="map">
SELECT
PROJECT_NAME
OBJID,
PROJECT_NAME,
PROJECT_NO
FROM
PROJECT_MGMT
WHERE CONTRACT_OBJID = #{objId}

View File

@@ -819,6 +819,7 @@
T.OBJID,
T.PROJECT_NO,
T.CONTRACT_OBJID,
T.SALES_DEADLINE_DATE,
CODE_NAME(T.CATEGORY_CD) AS ORDER_TYPE,
CODE_NAME(T.PRODUCT) AS PRODUCT_TYPE,
CODE_NAME(T.AREA_CD) AS NATION,

View File

@@ -512,6 +512,35 @@ public class ContractMgmtService {
paramMap.put("writer", person.getUserId());
int cnt = sqlSession.update("contractMgmt.saveContractMgmtInfo", paramMap);
// 프로젝트가 존재하는 경우 프로젝트 정보도 업데이트 (수량 제외)
String contract_objid = CommonUtils.checkNull(paramMap.get("objId"));
if(!"".equals(contract_objid)) {
// CONTRACT_OBJID로 프로젝트 존재 여부 확인
resultList = sqlSession.selectOne("contractMgmt.getProjectListBycontractObjid", paramMap);
if(resultList != null) {
System.out.println("=== 견적요청 수정 시 프로젝트 업데이트 (수량 제외) ===");
System.out.println("CONTRACT_OBJID: " + contract_objid);
// 품목별로 프로젝트 업데이트 (수량은 제외)
List<Map> contractItems = getContractItems(paramMap);
if(contractItems != null && !contractItems.isEmpty()) {
for(Map item : contractItems) {
Map<String, Object> updateParam = new HashMap<String, Object>();
updateParam.putAll(paramMap);
updateParam.put("part_objid", item.get("PART_OBJID"));
updateParam.put("due_date", item.get("DUE_DATE"));
// quantity는 paramMap에서 제거하여 업데이트되지 않도록 함
updateParam.remove("quantity");
System.out.println("프로젝트 업데이트 - PART_OBJID: " + item.get("PART_OBJID") + ", 납기일: " + updateParam.get("due_date"));
sqlSession.update("project.ModifyProjectByContract", updateParam);
}
}
}
}
resultMap.put("result", true);
resultMap.put("msg", Message.SAVE_SUCCESS);
sqlSession.commit();
@@ -2591,39 +2620,36 @@ private String encodeImageToBase64(String imagePath) {
//paramMap.put("contract_price", contract_price/project_cnt + "");
if("0000964".equals(result_cd) || "0000968".equals(result_cd)){
// 품목별로 프로젝트 생성
// CONTRACT_OBJID로 프로젝트 존재 여부 확인 (한 번만 체크)
resultList = sqlSession.selectOne("contractMgmt.getProjectListBycontractObjid", paramMap);
boolean hasProject = (resultList != null);
// 품목별로 프로젝트 생성 또는 업데이트
List<Map> contractItems = getContractItems(paramMap);
if(contractItems != null && !contractItems.isEmpty()) {
System.out.println("품목 개수: " + contractItems.size() + "개 - 품목별 프로젝트 생성 시작");
System.out.println("품목 개수: " + contractItems.size() + "개 - 프로젝트 " + (hasProject ? "업데이트" : "생성") + " 시작");
for(Map item : contractItems) {
// 품목별 프로젝트 존재 여부 확인
Map<String, Object> projectCheckParam = new HashMap<String, Object>();
projectCheckParam.put("contractObjId", contract_objid);
projectCheckParam.put("part_objid", item.get("PART_OBJID"));
resultList = sqlSession.selectOne("contractMgmt.getProjectListByContractAndPartObjid", projectCheckParam);
if(null == resultList) {
// 새 프로젝트 생성
Map<String, Object> projectParam = new HashMap<String, Object>();
projectParam.putAll(paramMap); // 기본 정보 복사
// 품목별 정보 설정
projectParam.put("OBJID", CommonUtils.createObjId());
projectParam.put("is_temp", '1');
projectParam.put("part_objid", item.get("PART_OBJID"));
projectParam.put("part_no", item.get("PART_NO"));
projectParam.put("part_name", item.get("PART_NAME"));
projectParam.put("quantity", item.get("ORDER_QUANTITY") != null ? item.get("ORDER_QUANTITY") : item.get("QUANTITY"));
projectParam.put("due_date", item.get("DUE_DATE"));
if("0000170".equals(category_cd) || "0000171".equals(category_cd)){
projectParam.put("overhaul_project_no", target_project_no);
}
System.out.println("프로젝트 생성 - PART_OBJID: " + item.get("PART_OBJID") + ", 품번: " + item.get("PART_NO") + ", 품명: " + item.get("PART_NAME"));
for(Map item : contractItems) {
if(!hasProject) {
// 프로젝트가 없으면 모든 품목에 대해 생성
Map<String, Object> projectParam = new HashMap<String, Object>();
projectParam.putAll(paramMap); // 기본 정보 복사
// 품목별 정보 설정
projectParam.put("OBJID", CommonUtils.createObjId());
projectParam.put("is_temp", '1');
projectParam.put("part_objid", item.get("PART_OBJID"));
projectParam.put("part_no", item.get("PART_NO"));
projectParam.put("part_name", item.get("PART_NAME"));
projectParam.put("quantity", item.get("ORDER_QUANTITY") != null ? item.get("ORDER_QUANTITY") : item.get("QUANTITY"));
projectParam.put("due_date", item.get("DUE_DATE"));
if("0000170".equals(category_cd) || "0000171".equals(category_cd)){
projectParam.put("overhaul_project_no", target_project_no);
}
System.out.println("프로젝트 생성 - PART_OBJID: " + item.get("PART_OBJID") + ", 품번: " + item.get("PART_NO") + ", 품명: " + item.get("PART_NAME"));
// 프로젝트 등록
cnt = sqlSession.update("project.createProject", projectParam);
@@ -2648,25 +2674,22 @@ private String encodeImageToBase64(String imagePath) {
}
}
}
} else {
// 기존 프로젝트 업데이트
Map<String, Object> updateParam = new HashMap<String, Object>();
updateParam.putAll(paramMap);
updateParam.put("part_objid", item.get("PART_OBJID"));
updateParam.put("part_no", item.get("PART_NO"));
updateParam.put("part_name", item.get("PART_NAME"));
updateParam.put("quantity", item.get("ORDER_QUANTITY") != null ? item.get("ORDER_QUANTITY") : item.get("QUANTITY"));
updateParam.put("due_date", item.get("DUE_DATE"));
System.out.println("프로젝트 업데이트 - PART_OBJID: " + item.get("PART_OBJID") + ", 품번: " + item.get("PART_NO"));
sqlSession.update("project.ModifyProjectByContract", updateParam);
}
} else {
// 프로젝트가 있으면 모든 품목 업데이트 (수량, 금액 등만)
Map<String, Object> updateParam = new HashMap<String, Object>();
updateParam.putAll(paramMap);
updateParam.put("part_objid", item.get("PART_OBJID"));
updateParam.put("quantity", item.get("ORDER_QUANTITY") != null ? item.get("ORDER_QUANTITY") : item.get("QUANTITY"));
updateParam.put("due_date", item.get("DUE_DATE"));
System.out.println("프로젝트 업데이트 - PART_OBJID: " + item.get("PART_OBJID") + ", 수량: " + updateParam.get("quantity"));
sqlSession.update("project.ModifyProjectByContract", updateParam);
}
}
} else {
System.out.println("품목이 없습니다 - 기존 방식으로 프로젝트 생성");
// 품목이 없는 경우 기존 방식대로 처리
resultList = sqlSession.selectOne("contractMgmt.getProjectListBycontractObjid", paramMap);
if(null==resultList){
if(!hasProject){
paramMap.put("OBJID", CommonUtils.createObjId());
paramMap.put("is_temp", '1');
if("0000170".equals(category_cd) || "0000171".equals(category_cd)){
@@ -3235,4 +3258,35 @@ private String encodeImageToBase64(String imagePath) {
return resultMap;
}
/**
* 프로젝트 존재 여부 확인
* @param paramMap - objId (contractObjId)
* @return 프로젝트 정보 (없으면 null)
*/
@SuppressWarnings("rawtypes")
public Map checkProjectExists(Map paramMap) {
SqlSession sqlSession = null;
Map projectInfo = null;
try {
// autoCommit = true로 세션 생성
sqlSession = SqlMapConfig.getInstance().getSqlSession(true);
projectInfo = sqlSession.selectOne("contractMgmt.getProjectListBycontractObjid", paramMap);
if(projectInfo != null) {
projectInfo = CommonUtils.keyChangeUpperMap(projectInfo);
}
} catch(Exception e) {
System.out.println("❌ Service 오류: " + e.getMessage());
e.printStackTrace();
} finally {
if(sqlSession != null) sqlSession.close();
}
System.out.println("Service 최종 반환값: " + projectInfo);
return projectInfo;
}
}