Merge remote-tracking branch 'origin/main'
# Please enter a commit message to explain why this merge is necessary, # especially if it merges an updated upstream into a topic branch. # # Lines starting with '#' will be ignored, and an empty message aborts # the commit.
This commit is contained in:
@@ -376,6 +376,8 @@ export class AuthController {
|
||||
userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
|
||||
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
||||
email: dbUserInfo.email || "",
|
||||
tel: dbUserInfo.tel || "",
|
||||
cellPhone: dbUserInfo.cellPhone || "",
|
||||
photo: dbUserInfo.photo,
|
||||
locale: dbUserInfo.locale || "KR", // locale 정보 추가
|
||||
deptCode: dbUserInfo.deptCode, // 추가 필드
|
||||
|
||||
@@ -601,8 +601,17 @@ export const getFileList = async (
|
||||
}
|
||||
|
||||
if (docType) {
|
||||
whereConditions.push(`doc_type = $${paramIndex}`);
|
||||
queryParams.push(docType as string);
|
||||
const docTypes = String(docType)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (docTypes.length === 1) {
|
||||
whereConditions.push(`doc_type = $${paramIndex}`);
|
||||
queryParams.push(docTypes[0]);
|
||||
} else if (docTypes.length > 1) {
|
||||
whereConditions.push(`doc_type = ANY($${paramIndex}::text[])`);
|
||||
queryParams.push(docTypes);
|
||||
}
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
|
||||
@@ -96,3 +96,52 @@ export async function sendMail(req: AuthenticatedRequest, res: Response) {
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// G5 견적작성 — estimate_template
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function saveTemplate1(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const data = await salesEstimateService.saveEstimateTemplate1(userId, req.body);
|
||||
return res.json({ success: true, data, message: "견적서가 저장되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("견적작성(일반) 저장 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTemplate2(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const data = await salesEstimateService.saveEstimateTemplate2(userId, req.body);
|
||||
return res.json({ success: true, data, message: "견적서가 저장되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("견적작성(장비) 저장 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { templateObjid } = req.params;
|
||||
const data = await salesEstimateService.getTemplateById(templateObjid);
|
||||
if (!data) return res.status(404).json({ success: false, message: "견적서를 찾을 수 없습니다." });
|
||||
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 listTemplates(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { contractObjid } = req.params;
|
||||
const data = await salesEstimateService.listTemplatesByContract(contractObjid);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("견적 차수 리스트 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,15 @@ export async function remove(req: AuthenticatedRequest, res: Response) {
|
||||
} catch (e: any) { logger.error("주문서 삭제 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); }
|
||||
}
|
||||
|
||||
export async function getFormView(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const data = await svc.getOrderFormView(id);
|
||||
if (!data) return res.status(404).json({ success: false, message: "주문서를 찾을 수 없습니다." });
|
||||
return res.json({ success: true, data });
|
||||
} catch (e: any) { logger.error("주문서 뷰 조회 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); }
|
||||
}
|
||||
|
||||
export async function updateStatus(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
@@ -59,5 +68,16 @@ export async function updateStatus(req: AuthenticatedRequest, res: Response) {
|
||||
const { contract_result } = req.body;
|
||||
await svc.updateStatus(userId, id, contract_result);
|
||||
return res.json({ success: true, message: "수주 상태가 변경되었습니다." });
|
||||
} catch (e: any) { return res.status(500).json({ success: false, message: e.message }); }
|
||||
} catch (e: any) { logger.error("수주 상태 변경 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); }
|
||||
}
|
||||
|
||||
// 수주취소: 라인별 cancel_qty 다중 UPDATE (wace saveOrderCancelQty 이식)
|
||||
export async function saveCancelQty(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const { entries } = req.body as { entries: svc.CancelQtyEntry[] };
|
||||
const result = await svc.saveOrderCancelQty(userId, entries);
|
||||
if (result.result === "false") return res.status(400).json({ success: false, message: result.msg });
|
||||
return res.json({ success: true, message: result.msg });
|
||||
} catch (e: any) { logger.error("수주취소 저장 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); }
|
||||
}
|
||||
|
||||
@@ -33,17 +33,23 @@ router.get("/codes/:groupId", async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/sales/parts → [{id, item_number, item_name}] (영업관리 풀-옵션용) */
|
||||
/** GET /api/sales/parts → [{id, item_number, item_name}] (영업관리 풀-옵션용)
|
||||
* 데이터 출처: wace 품목 마스터 전용 테이블 part_mng (8,176건).
|
||||
* 반환 키는 기존 호환을 위해 id/item_number/item_name으로 alias.
|
||||
* status는 active/release/활성만 (wace 운영 값).
|
||||
*/
|
||||
router.get("/parts", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
// wace 이식 데이터(company_code 빈 값/별표) 우선, COMPANY_16 데이터 추가
|
||||
const result = await pool.query(
|
||||
`SELECT id, item_number, item_name
|
||||
FROM item_info
|
||||
WHERE COALESCE(company_code, '') IN ('', '*', 'COMPANY_16')
|
||||
AND (item_number IS NOT NULL OR item_name IS NOT NULL)
|
||||
ORDER BY item_number NULLS LAST, item_name NULLS LAST`,
|
||||
`SELECT objid::varchar AS id,
|
||||
part_no AS item_number,
|
||||
part_name AS item_name
|
||||
FROM part_mng
|
||||
WHERE LOWER(COALESCE(status, '')) IN ('active', 'release', '활성')
|
||||
AND part_no IS NOT NULL AND part_no <> ''
|
||||
AND part_name IS NOT NULL AND part_name <> ''
|
||||
ORDER BY part_no NULLS LAST, part_name NULLS LAST`,
|
||||
);
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (err) {
|
||||
|
||||
@@ -8,6 +8,13 @@ router.use(authenticateToken);
|
||||
router.get("/list", salesEstimateController.getList);
|
||||
router.get("/generate-number", salesEstimateController.generateNumber);
|
||||
router.post("/mail", salesEstimateController.sendMail);
|
||||
|
||||
// G5 견적작성 (estimate_template) — /:id 라우트보다 위에
|
||||
router.post("/template1", salesEstimateController.saveTemplate1);
|
||||
router.post("/template2", salesEstimateController.saveTemplate2);
|
||||
router.get("/template/:templateObjid", salesEstimateController.getTemplate);
|
||||
router.get("/templates/:contractObjid", salesEstimateController.listTemplates);
|
||||
|
||||
router.get("/:id", salesEstimateController.getById);
|
||||
router.post("/", salesEstimateController.create);
|
||||
router.put("/:id", salesEstimateController.update);
|
||||
|
||||
@@ -7,10 +7,12 @@ router.use(authenticateToken);
|
||||
|
||||
router.get("/list", ctrl.getList);
|
||||
router.get("/generate-number", ctrl.generateNumber);
|
||||
router.get("/:id/form-view", ctrl.getFormView);
|
||||
router.get("/:id", ctrl.getById);
|
||||
router.post("/", ctrl.create);
|
||||
router.put("/:id", ctrl.update);
|
||||
router.delete("/:id", ctrl.remove);
|
||||
router.patch("/:id/status", ctrl.updateStatus);
|
||||
router.post("/:id/cancel-qty", ctrl.saveCancelQty);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -27,64 +27,38 @@ export interface EstimateListFilter {
|
||||
due_end_date?: string;
|
||||
}
|
||||
|
||||
export interface EstimateTemplateBody {
|
||||
// 헤더(estimate_template)
|
||||
contract_objid?: string; // 신규면 contract_mgmt 생성 후 채워짐
|
||||
template_type: string; // '1' 일반, '2' 장비
|
||||
executor?: string;
|
||||
recipient?: string;
|
||||
estimate_no?: string;
|
||||
contact_person?: string;
|
||||
greeting_text?: string;
|
||||
model_name?: string;
|
||||
model_code?: string;
|
||||
executor_date?: string;
|
||||
note1?: string;
|
||||
note2?: string;
|
||||
note3?: string;
|
||||
note4?: string;
|
||||
categories_json?: string;
|
||||
notes_content?: string;
|
||||
validity_period?: string;
|
||||
total_amount?: string;
|
||||
total_amount_krw?: string;
|
||||
manager_name?: string;
|
||||
manager_contact?: string;
|
||||
note_remarks?: string;
|
||||
show_total_row?: string;
|
||||
group1_subtotal?: string;
|
||||
part_name?: string;
|
||||
part_objid?: string;
|
||||
// 라인
|
||||
items?: EstimateTemplateItem[];
|
||||
// contract_mgmt 신규 생성 시 필요한 컨텍스트(선택)
|
||||
contract_context?: ContractContext;
|
||||
}
|
||||
// wace 견적요청등록(estimateRegistFormPopup.jsp) 폼 — 헤더 8 + 라인 8
|
||||
// 원본: contractMgmt.xml#saveContractMgmtInfo, insertContractItem, insertContractItemSerial
|
||||
// 견적 템플릿(estimate_template)은 견적**작성**(saveEstimateTemplate / G5) 단계 데이터 — 견적요청등록과 분리.
|
||||
|
||||
export interface EstimateTemplateItem {
|
||||
seq: number;
|
||||
category?: string;
|
||||
description?: string;
|
||||
specification?: string;
|
||||
quantity?: string;
|
||||
unit?: string;
|
||||
unit_price?: string;
|
||||
amount?: string;
|
||||
note?: string;
|
||||
remark?: string;
|
||||
part_objid?: string;
|
||||
}
|
||||
|
||||
export interface ContractContext {
|
||||
export interface EstimateBody {
|
||||
// 신규: contract_no 자동 채번. 수정: objid는 path param.
|
||||
contract_no?: string;
|
||||
customer_objid?: string;
|
||||
category_cd?: string;
|
||||
product?: string;
|
||||
area_cd?: string;
|
||||
paid_type?: string;
|
||||
// 헤더 8개 (필수: category_cd, area_cd, customer_objid, paid_type, receipt_date, approval_required)
|
||||
category_cd: string;
|
||||
area_cd: string;
|
||||
customer_objid: string;
|
||||
paid_type: string; // 'paid' | 'free'
|
||||
receipt_date: string;
|
||||
contract_currency?: string;
|
||||
receipt_date?: string;
|
||||
req_del_date?: string;
|
||||
exchange_rate?: string;
|
||||
approval_required: string; // 'Y' | 'N'
|
||||
// 라인
|
||||
items: EstimateItem[];
|
||||
}
|
||||
|
||||
export interface EstimateItem {
|
||||
objid?: string; // 수정 시 기존 라인 식별. 없으면 신규
|
||||
seq: number;
|
||||
product: string; // 제품구분 (필수)
|
||||
part_objid: string; // 품목 마스터 id (필수)
|
||||
part_no: string; // 품번 (마스터 fallback용 raw)
|
||||
part_name: string; // 품명
|
||||
serials?: string[]; // S/N 목록
|
||||
quantity?: string; // 견적수량
|
||||
due_date?: string; // 요청납기 (YYYY-MM-DD)
|
||||
return_reason?: string; // 반납사유 (comm_code)
|
||||
customer_request?: string; // 고객요청사항
|
||||
}
|
||||
|
||||
// ─── 목록 ─────────────────────────────────────────────────────
|
||||
@@ -205,7 +179,7 @@ export async function getList(filter: EstimateListFilter) {
|
||||
,C.customer_name AS CUSTOMER_NAME
|
||||
,C.customer_type AS CUSTOMER_TYPE
|
||||
,T.PRODUCT
|
||||
,CC_PRD.code_name AS PRODUCT_NAME
|
||||
,COALESCE(CI_AGG.product_summary, CC_PRD.code_name) AS PRODUCT_NAME
|
||||
,T.AREA_CD
|
||||
,CC_AREA.code_name AS AREA_NAME
|
||||
,T.PAID_TYPE
|
||||
@@ -214,6 +188,8 @@ export async function getList(filter: EstimateListFilter) {
|
||||
WHEN T.PAID_TYPE='free' THEN '무상'
|
||||
ELSE T.PAID_TYPE
|
||||
END AS PAID_TYPE_NAME
|
||||
,T.CONTRACT_RESULT AS CONTRACT_RESULT
|
||||
,T.APPROVAL_REQUIRED AS APPROVAL_REQUIRED
|
||||
,T.CONTRACT_CURRENCY
|
||||
,CC_CUR.code_name AS CONTRACT_CURRENCY_NAME
|
||||
,T.EXCHANGE_RATE
|
||||
@@ -264,6 +240,7 @@ export async function getList(filter: EstimateListFilter) {
|
||||
END AS SERIAL_NO
|
||||
,CI_AGG.earliest_due_date AS EARLIEST_DUE_DATE
|
||||
,COALESCE(CI_AGG.other_due_date_count, 0) AS OTHER_DUE_DATE_COUNT
|
||||
,CI_AGG.return_reason_summary AS RETURN_REASON_SUMMARY
|
||||
/* 메일 발송 정보 (mail_log: TITLE의 [OBJID:NN]에서 OBJID 매칭) */
|
||||
,ML.mail_send_status AS MAIL_SEND_STATUS
|
||||
,ML.mail_send_date AS MAIL_SEND_DATE
|
||||
@@ -315,17 +292,21 @@ export async function getList(filter: EstimateListFilter) {
|
||||
FROM estimate_template
|
||||
GROUP BY contract_objid
|
||||
) ET_CNT ON ET_CNT.contract_objid = T.OBJID
|
||||
/* 품목 집계 */
|
||||
/* 품목 집계 (제품구분/반납사유 한글명 distinct join — wace 헤더 product 폐지·라인으로 이동) */
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
CI.contract_objid,
|
||||
COUNT(*) AS item_count,
|
||||
(array_agg(COALESCE(IT.item_name, CI.part_name) ORDER BY CI.seq))[1] AS first_part_name,
|
||||
(array_agg(COALESCE(IT.item_number, CI.part_no) ORDER BY CI.seq))[1] AS first_part_no,
|
||||
(array_agg(COALESCE(PM.part_name, CI.part_name) ORDER BY CI.seq))[1] AS first_part_name,
|
||||
(array_agg(COALESCE(PM.part_no, CI.part_no) ORDER BY CI.seq))[1] AS first_part_no,
|
||||
MIN(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN CI.due_date END) AS earliest_due_date,
|
||||
GREATEST(COUNT(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN 1 END) - 1, 0) AS other_due_date_count
|
||||
GREATEST(COUNT(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN 1 END) - 1, 0) AS other_due_date_count,
|
||||
STRING_AGG(DISTINCT CC_RR.code_name, ', ') FILTER (WHERE CC_RR.code_name IS NOT NULL) AS return_reason_summary,
|
||||
STRING_AGG(DISTINCT CC_PRDI.code_name, ', ') FILTER (WHERE CC_PRDI.code_name IS NOT NULL) AS product_summary
|
||||
FROM contract_item CI
|
||||
LEFT JOIN item_info IT ON IT.id = CI.part_objid
|
||||
LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid
|
||||
LEFT JOIN comm_code CC_RR ON CC_RR.code_id = CI.return_reason AND CC_RR.status='active'
|
||||
LEFT JOIN comm_code CC_PRDI ON CC_PRDI.code_id = CI.product AND CC_PRDI.status='active'
|
||||
WHERE CI.status = 'ACTIVE'
|
||||
GROUP BY CI.contract_objid
|
||||
) CI_AGG ON CI_AGG.contract_objid = T.OBJID
|
||||
@@ -369,165 +350,164 @@ export async function getList(filter: EstimateListFilter) {
|
||||
|
||||
// ─── 단건 ─────────────────────────────────────────────────────
|
||||
|
||||
// 견적요청등록의 단건 조회 — contract_mgmt 헤더 + contract_item 라인 + contract_item_serial
|
||||
// (estimate_template는 G5 견적작성 단계 데이터 — 여기 포함 안 함)
|
||||
export async function getById(objid: string) {
|
||||
const pool = getPool();
|
||||
const headerRes = await pool.query(
|
||||
`SELECT et.*, cm.contract_no, cm.customer_objid, cm.category_cd,
|
||||
cm.area_cd, cm.paid_type, cm.contract_currency
|
||||
FROM estimate_template et
|
||||
LEFT JOIN contract_mgmt cm ON cm.objid = et.contract_objid
|
||||
WHERE et.objid = $1`,
|
||||
`SELECT cm.*,
|
||||
(CASE WHEN cm.customer_objid LIKE 'C_%' THEN substring(cm.customer_objid, 3) ELSE cm.customer_objid END) AS customer_code_norm
|
||||
FROM contract_mgmt cm
|
||||
WHERE cm.objid = $1`,
|
||||
[objid],
|
||||
);
|
||||
if (headerRes.rowCount === 0) return null;
|
||||
|
||||
const itemsRes = await pool.query(
|
||||
`SELECT * FROM estimate_template_item
|
||||
WHERE template_objid = $1
|
||||
ORDER BY seq`,
|
||||
`SELECT CI.*,
|
||||
COALESCE(PM.part_no, CI.part_no) AS master_part_no,
|
||||
COALESCE(PM.part_name, CI.part_name) AS master_part_name
|
||||
FROM contract_item CI
|
||||
LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid
|
||||
WHERE CI.contract_objid = $1
|
||||
AND CI.status = 'ACTIVE'
|
||||
ORDER BY CI.seq`,
|
||||
[objid],
|
||||
);
|
||||
|
||||
return { ...headerRes.rows[0], items: itemsRes.rows };
|
||||
const itemIds: string[] = itemsRes.rows.map((r: any) => r.objid);
|
||||
let serials: any[] = [];
|
||||
if (itemIds.length > 0) {
|
||||
const sRes = await pool.query(
|
||||
`SELECT objid, item_objid, seq, serial_no
|
||||
FROM contract_item_serial
|
||||
WHERE item_objid = ANY($1::varchar[])
|
||||
AND status = 'ACTIVE'
|
||||
ORDER BY item_objid, seq`,
|
||||
[itemIds],
|
||||
);
|
||||
serials = sRes.rows;
|
||||
}
|
||||
|
||||
const items = itemsRes.rows.map((it: any) => ({
|
||||
...it,
|
||||
serials: serials.filter((s) => s.item_objid === it.objid).map((s) => s.serial_no),
|
||||
}));
|
||||
return { ...headerRes.rows[0], items };
|
||||
}
|
||||
|
||||
// ─── 채번 ─────────────────────────────────────────────────────
|
||||
// estimate_no 채번. 형식: EST-YYYYMMDD-NNN (3자리)
|
||||
// 견적요청등록은 contract_mgmt 새 행을 만든다 → 기존 generateContractNo 재사용 (SO-YYYYMMDD-NNN).
|
||||
// (wace 운영 26C-XXXX 형식은 별도 채번 룰 — 추후 통일 시 변경)
|
||||
|
||||
export async function generateNumber(): Promise<string> {
|
||||
const pool = getPool();
|
||||
const today = new Date();
|
||||
const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`;
|
||||
const prefix = `EST-${ymd}-`;
|
||||
|
||||
const res = await pool.query(
|
||||
`SELECT estimate_no FROM estimate_template
|
||||
WHERE estimate_no LIKE $1
|
||||
ORDER BY estimate_no DESC LIMIT 1`,
|
||||
[`${prefix}%`],
|
||||
);
|
||||
|
||||
let seq = 1;
|
||||
if (res.rowCount && res.rowCount > 0) {
|
||||
const last = res.rows[0].estimate_no as string;
|
||||
const lastSeq = parseInt(last.replace(prefix, ""), 10);
|
||||
if (!isNaN(lastSeq)) seq = lastSeq + 1;
|
||||
}
|
||||
return `${prefix}${String(seq).padStart(3, "0")}`;
|
||||
return generateContractNo();
|
||||
}
|
||||
|
||||
// objid 채번: estimate_template/contract_mgmt 모두 VARCHAR PK 사용 (wace_plm 관행)
|
||||
// 형식: ET-{epoch_ms}-{rand} / CM-{epoch_ms}-{rand}
|
||||
// objid 채번: contract_mgmt VARCHAR PK
|
||||
function genVarcharObjid(prefix: string) {
|
||||
const ms = Date.now();
|
||||
const rand = Math.floor(Math.random() * 1000).toString().padStart(3, "0");
|
||||
return `${prefix}-${ms}-${rand}`;
|
||||
}
|
||||
|
||||
// ─── 생성 ─────────────────────────────────────────────────────
|
||||
// ─── 라인 UPSERT 공용 (라인 전체 교체 패턴 — wace 견적요청등록은 라인을 통째로 다시 받음) ──
|
||||
|
||||
export async function create(userId: string, body: EstimateTemplateBody) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
async function upsertItems(client: any, contractObjid: string, items: EstimateItem[], userId: string) {
|
||||
// 기존 라인 INACTIVE
|
||||
await client.query(
|
||||
`UPDATE contract_item SET status='INACTIVE', chgdate=NOW(), chg_user_id=$2 WHERE contract_objid=$1`,
|
||||
[contractObjid, userId],
|
||||
);
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. contract_mgmt 헤더 (없으면 신규 생성)
|
||||
let contractObjid = body.contract_objid;
|
||||
if (!contractObjid) {
|
||||
contractObjid = genVarcharObjid("CM");
|
||||
const ctx = body.contract_context ?? {};
|
||||
const contractNo = ctx.contract_no || (await generateContractNo());
|
||||
await client.query(
|
||||
`INSERT INTO contract_mgmt (
|
||||
objid, contract_no, customer_objid, category_cd, area_cd,
|
||||
product, paid_type, contract_currency,
|
||||
writer, regdate
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, NOW())`,
|
||||
[
|
||||
contractObjid,
|
||||
contractNo,
|
||||
ctx.customer_objid || null,
|
||||
ctx.category_cd || null,
|
||||
ctx.area_cd || null,
|
||||
null, // product
|
||||
ctx.paid_type || null,
|
||||
ctx.contract_currency || null,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 2. estimate_template 헤더
|
||||
const templateObjid = genVarcharObjid("ET");
|
||||
const estimateNo = body.estimate_no || (await generateNumber());
|
||||
for (const item of items) {
|
||||
const itemObjid = item.objid || genVarcharObjid("CI");
|
||||
// quantity는 integer 컬럼 — 빈문자/콤마 제거 후 integer 캐스트.
|
||||
const qtyRaw = (item.quantity ?? "").toString().replace(/,/g, "").trim();
|
||||
const qtyInt: number | null = qtyRaw === "" ? null : (Number.isFinite(parseInt(qtyRaw, 10)) ? parseInt(qtyRaw, 10) : null);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO estimate_template (
|
||||
objid, contract_objid, template_type,
|
||||
executor, recipient, estimate_no, contact_person, greeting_text,
|
||||
model_name, model_code, executor_date,
|
||||
note1, note2, note3, note4,
|
||||
categories_json, writer, regdate, chg_user_id, chgdate,
|
||||
notes_content, validity_period,
|
||||
total_amount, total_amount_krw,
|
||||
manager_name, manager_contact, note_remarks,
|
||||
show_total_row, group1_subtotal, part_name, part_objid
|
||||
`INSERT INTO contract_item (
|
||||
objid, contract_objid, seq, product,
|
||||
part_objid, part_no, part_name,
|
||||
quantity, due_date, return_reason, customer_request,
|
||||
regdate, writer, status
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,
|
||||
NOW(),$17,NOW(),$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28
|
||||
)`,
|
||||
$1::varchar, $2::varchar, $3::integer, $4::varchar,
|
||||
$5::varchar, $6::varchar, $7::varchar,
|
||||
$8::integer, $9::varchar, $10::varchar, $11::text,
|
||||
NOW(), $12::varchar, 'ACTIVE'
|
||||
)
|
||||
ON CONFLICT (objid) DO UPDATE SET
|
||||
seq=EXCLUDED.seq, product=EXCLUDED.product,
|
||||
part_objid=EXCLUDED.part_objid, part_no=EXCLUDED.part_no, part_name=EXCLUDED.part_name,
|
||||
quantity=EXCLUDED.quantity, due_date=EXCLUDED.due_date,
|
||||
return_reason=EXCLUDED.return_reason, customer_request=EXCLUDED.customer_request,
|
||||
status='ACTIVE', chgdate=NOW(), chg_user_id=EXCLUDED.writer`,
|
||||
[
|
||||
templateObjid, contractObjid, body.template_type,
|
||||
body.executor || null, body.recipient || null, estimateNo,
|
||||
body.contact_person || null, body.greeting_text || null,
|
||||
body.model_name || null, body.model_code || null, body.executor_date || null,
|
||||
body.note1 || null, body.note2 || null, body.note3 || null, body.note4 || null,
|
||||
body.categories_json || null,
|
||||
itemObjid, contractObjid, item.seq, item.product || null,
|
||||
item.part_objid || null, item.part_no || null, item.part_name || null,
|
||||
qtyInt, item.due_date || null,
|
||||
item.return_reason || null, item.customer_request || null,
|
||||
userId,
|
||||
body.notes_content || null,
|
||||
body.validity_period || null,
|
||||
body.total_amount || null,
|
||||
body.total_amount_krw || null,
|
||||
body.manager_name || null,
|
||||
body.manager_contact || null,
|
||||
body.note_remarks || null,
|
||||
body.show_total_row || "Y",
|
||||
body.group1_subtotal || null,
|
||||
body.part_name || null,
|
||||
body.part_objid || null,
|
||||
],
|
||||
);
|
||||
|
||||
// 3. estimate_template_item 라인 (배치 INSERT)
|
||||
const items = body.items ?? [];
|
||||
for (const item of items) {
|
||||
// 시리얼 재구성: 라인의 모든 시리얼 INACTIVE → 새로 INSERT
|
||||
await client.query(`UPDATE contract_item_serial SET status='INACTIVE' WHERE item_objid=$1`, [itemObjid]);
|
||||
const serials = item.serials ?? [];
|
||||
for (let i = 0; i < serials.length; i++) {
|
||||
const s = (serials[i] ?? "").trim();
|
||||
if (!s) continue;
|
||||
await client.query(
|
||||
`INSERT INTO estimate_template_item (
|
||||
template_objid, seq, category, description, specification,
|
||||
quantity, unit, unit_price, amount, note, remark, part_objid
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
|
||||
[
|
||||
templateObjid,
|
||||
item.seq,
|
||||
item.category || null,
|
||||
item.description || null,
|
||||
item.specification || null,
|
||||
item.quantity || null,
|
||||
item.unit || null,
|
||||
item.unit_price || null,
|
||||
item.amount || null,
|
||||
item.note || null,
|
||||
item.remark || null,
|
||||
item.part_objid || null,
|
||||
],
|
||||
`INSERT INTO contract_item_serial (objid, item_objid, seq, serial_no, regdate, writer, status)
|
||||
VALUES ($1, $2, $3, $4, NOW(), $5, 'ACTIVE')
|
||||
ON CONFLICT (item_objid, serial_no) DO UPDATE SET status='ACTIVE', seq=EXCLUDED.seq`,
|
||||
[genVarcharObjid("CIS"), itemObjid, i + 1, s, userId],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 생성 ─────────────────────────────────────────────────────
|
||||
// wace estimateRegistFormPopup 폼: 헤더(contract_mgmt) + 라인(contract_item) + 시리얼(contract_item_serial)
|
||||
// 견적단계 신규 행: contract_result NULL, is_direct_order='N' (견적관리 그리드에 노출)
|
||||
|
||||
export async function create(userId: string, body: EstimateBody) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const contractObjid = genVarcharObjid("CM");
|
||||
const contractNo = body.contract_no || (await generateContractNo());
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO contract_mgmt (
|
||||
objid, contract_no,
|
||||
category_cd, area_cd, customer_objid, paid_type,
|
||||
receipt_date, contract_currency, exchange_rate, approval_required,
|
||||
is_direct_order, writer, regdate
|
||||
) VALUES (
|
||||
$1::varchar, $2::varchar,
|
||||
$3::varchar, $4::varchar, $5::varchar, $6::varchar,
|
||||
$7::varchar, $8::varchar, $9::varchar, $10::varchar,
|
||||
'N', $11::varchar, NOW()
|
||||
)`,
|
||||
[
|
||||
contractObjid, contractNo,
|
||||
body.category_cd || null, body.area_cd || null, body.customer_objid || null, body.paid_type || null,
|
||||
body.receipt_date || null, body.contract_currency || null,
|
||||
body.exchange_rate || null, body.approval_required || "N",
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await upsertItems(client, contractObjid, body.items ?? [], userId);
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("견적 등록", { templateObjid, contractObjid, estimateNo, itemCount: items.length });
|
||||
return { objid: templateObjid, contract_objid: contractObjid, estimate_no: estimateNo };
|
||||
logger.info("견적요청 등록", { contractObjid, contractNo, items: (body.items ?? []).length });
|
||||
return { objid: contractObjid, contract_no: contractNo };
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
@@ -538,61 +518,32 @@ export async function create(userId: string, body: EstimateTemplateBody) {
|
||||
|
||||
// ─── 수정 ─────────────────────────────────────────────────────
|
||||
|
||||
export async function update(userId: string, objid: string, body: EstimateTemplateBody) {
|
||||
export async function update(userId: string, objid: string, body: EstimateBody) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
await client.query(
|
||||
`UPDATE estimate_template SET
|
||||
template_type=$2, executor=$3, recipient=$4, estimate_no=$5,
|
||||
contact_person=$6, greeting_text=$7, model_name=$8, model_code=$9,
|
||||
executor_date=$10, note1=$11, note2=$12, note3=$13, note4=$14,
|
||||
categories_json=$15, notes_content=$16, validity_period=$17,
|
||||
total_amount=$18, total_amount_krw=$19,
|
||||
manager_name=$20, manager_contact=$21, note_remarks=$22,
|
||||
show_total_row=$23, group1_subtotal=$24, part_name=$25, part_objid=$26,
|
||||
chg_user_id=$27, chgdate=NOW()
|
||||
WHERE objid = $1`,
|
||||
`UPDATE contract_mgmt SET
|
||||
category_cd=$2::varchar, area_cd=$3::varchar, customer_objid=$4::varchar, paid_type=$5::varchar,
|
||||
receipt_date=$6::varchar, contract_currency=$7::varchar, exchange_rate=$8::varchar,
|
||||
approval_required=$9::varchar,
|
||||
chg_user_id=$10::varchar
|
||||
WHERE objid=$1::varchar`,
|
||||
[
|
||||
objid,
|
||||
body.template_type,
|
||||
body.executor || null, body.recipient || null, body.estimate_no || null,
|
||||
body.contact_person || null, body.greeting_text || null,
|
||||
body.model_name || null, body.model_code || null, body.executor_date || null,
|
||||
body.note1 || null, body.note2 || null, body.note3 || null, body.note4 || null,
|
||||
body.categories_json || null,
|
||||
body.notes_content || null, body.validity_period || null,
|
||||
body.total_amount || null, body.total_amount_krw || null,
|
||||
body.manager_name || null, body.manager_contact || null, body.note_remarks || null,
|
||||
body.show_total_row || "Y", body.group1_subtotal || null,
|
||||
body.part_name || null, body.part_objid || null,
|
||||
body.category_cd || null, body.area_cd || null, body.customer_objid || null, body.paid_type || null,
|
||||
body.receipt_date || null, body.contract_currency || null, body.exchange_rate || null,
|
||||
body.approval_required || "N",
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
// 라인 전체 교체 (단순 패턴; 추후 diff 방식으로 개선 가능)
|
||||
await client.query(`DELETE FROM estimate_template_item WHERE template_objid = $1`, [objid]);
|
||||
const items = body.items ?? [];
|
||||
for (const item of items) {
|
||||
await client.query(
|
||||
`INSERT INTO estimate_template_item (
|
||||
template_objid, seq, category, description, specification,
|
||||
quantity, unit, unit_price, amount, note, remark, part_objid
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
|
||||
[
|
||||
objid, item.seq, item.category || null, item.description || null,
|
||||
item.specification || null, item.quantity || null, item.unit || null,
|
||||
item.unit_price || null, item.amount || null, item.note || null,
|
||||
item.remark || null, item.part_objid || null,
|
||||
],
|
||||
);
|
||||
}
|
||||
await upsertItems(client, objid, body.items ?? [], userId);
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("견적 수정", { objid, itemCount: items.length });
|
||||
logger.info("견적요청 수정", { objid, items: (body.items ?? []).length });
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
@@ -651,17 +602,23 @@ export async function sendMail(userId: string, body: EstimateMailBody) {
|
||||
}
|
||||
|
||||
// ─── 삭제 ─────────────────────────────────────────────────────
|
||||
// 라인 → 헤더 순. contract_mgmt는 다른 견적이 참조 중일 수 있어 삭제하지 않음.
|
||||
// 시리얼 → 라인 → 헤더(contract_mgmt) 순.
|
||||
// (G8 — 프로젝트 존재 시 삭제 방지는 별도 PR)
|
||||
|
||||
export async function remove(objid: string) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query(`DELETE FROM estimate_template_item WHERE template_objid = $1`, [objid]);
|
||||
await client.query(`DELETE FROM estimate_template WHERE objid = $1`, [objid]);
|
||||
await client.query(
|
||||
`UPDATE contract_item_serial SET status='INACTIVE'
|
||||
WHERE item_objid IN (SELECT objid FROM contract_item WHERE contract_objid=$1)`,
|
||||
[objid],
|
||||
);
|
||||
await client.query(`DELETE FROM contract_item WHERE contract_objid=$1`, [objid]);
|
||||
await client.query(`DELETE FROM contract_mgmt WHERE objid=$1`, [objid]);
|
||||
await client.query("COMMIT");
|
||||
logger.info("견적 삭제", { objid });
|
||||
logger.info("견적요청 삭제", { objid });
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
@@ -669,3 +626,326 @@ export async function remove(objid: string) {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// G5 견적작성 (estimate_template / estimate_template_item)
|
||||
// ============================================================
|
||||
// wace contractMgmt.xml#insertEstimateTemplate/updateEstimateTemplate (template1=일반)
|
||||
// contractMgmt.xml#insertEstimateTemplate2/updateEstimateTemplate2 (template2=장비)
|
||||
// contractMgmt.xml#deleteEstimateTemplateItems/insertEstimateTemplateItems
|
||||
// ContractMgmtServiceImpl#saveEstimateTemplate/2 (line 1501/1591)
|
||||
//
|
||||
// 한 contract_mgmt → N estimate_template (template_objid 명시 없으면 항상 신규 차수)
|
||||
// 라인 처리: 매 저장마다 DELETE + 전체 재INSERT (wace 패턴 그대로).
|
||||
|
||||
export interface EstimateTemplateItem {
|
||||
seq?: number;
|
||||
category?: string;
|
||||
part_objid?: string;
|
||||
description?: string;
|
||||
specification?: string;
|
||||
quantity?: string;
|
||||
unit?: string;
|
||||
unit_price?: string;
|
||||
amount?: string;
|
||||
note?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
// Template1 (일반 견적서)
|
||||
export interface EstimateTemplate1Body {
|
||||
contract_objid: string;
|
||||
template_objid?: string; // 명시되고 존재하면 수정, 아니면 항상 신규 차수
|
||||
executor?: string;
|
||||
recipient?: string;
|
||||
estimate_no?: string;
|
||||
contact_person?: string;
|
||||
greeting_text?: string;
|
||||
model_name?: string;
|
||||
model_code?: string;
|
||||
executor_date?: string;
|
||||
note1?: string;
|
||||
note2?: string;
|
||||
note3?: string;
|
||||
note4?: string;
|
||||
note_remarks?: string;
|
||||
total_amount?: string;
|
||||
total_amount_krw?: string;
|
||||
manager_name?: string;
|
||||
manager_contact?: string;
|
||||
show_total_row?: "Y" | "N";
|
||||
items: EstimateTemplateItem[];
|
||||
}
|
||||
|
||||
// Template2 (장비 견적서) — categories_json 1개 + items 자동 1행
|
||||
export interface EstimateTemplate2Body {
|
||||
contract_objid: string;
|
||||
template_objid?: string;
|
||||
executor_date?: string;
|
||||
recipient?: string;
|
||||
part_name?: string;
|
||||
part_objid?: string;
|
||||
notes_content?: string;
|
||||
validity_period?: string;
|
||||
categories_json: string;
|
||||
group1_subtotal?: string;
|
||||
total_amount?: string;
|
||||
total_amount_krw?: string;
|
||||
}
|
||||
|
||||
// 라인 재구축 헬퍼 (wace deleteEstimateTemplateItems + insertEstimateTemplateItems)
|
||||
async function rebuildTemplateItems(client: any, templateObjid: string, items: EstimateTemplateItem[]) {
|
||||
await client.query(`DELETE FROM estimate_template_item WHERE template_objid=$1`, [templateObjid]);
|
||||
let seq = 1;
|
||||
for (const item of items ?? []) {
|
||||
const desc = (item.description ?? "").trim();
|
||||
if (desc === "") continue; // wace: WHERE COALESCE(item->>'description','') != ''
|
||||
await client.query(
|
||||
`INSERT INTO estimate_template_item (
|
||||
template_objid, seq, category, part_objid,
|
||||
description, specification, quantity, unit, unit_price, amount,
|
||||
note, remark
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
[
|
||||
templateObjid,
|
||||
item.seq ?? seq++,
|
||||
item.category ?? null,
|
||||
item.part_objid ?? null,
|
||||
desc,
|
||||
item.specification ?? null,
|
||||
item.quantity ?? null,
|
||||
item.unit ?? null,
|
||||
item.unit_price ?? null,
|
||||
item.amount ?? null,
|
||||
item.note ?? null,
|
||||
item.remark ?? null,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function existsTemplate(client: any, templateObjid: string): Promise<boolean> {
|
||||
const r = await client.query(`SELECT 1 FROM estimate_template WHERE objid=$1 LIMIT 1`, [templateObjid]);
|
||||
return (r.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
// 일반 견적서 저장 (wace saveEstimateTemplate)
|
||||
export async function saveEstimateTemplate1(userId: string, body: EstimateTemplate1Body) {
|
||||
if (!body.contract_objid) throw new Error("contract_objid is required");
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
let templateObjid = body.template_objid ?? "";
|
||||
const isUpdate = templateObjid !== "" && templateObjid !== "-1" && (await existsTemplate(client, templateObjid));
|
||||
|
||||
if (isUpdate) {
|
||||
await client.query(
|
||||
`UPDATE estimate_template SET
|
||||
executor=$2, recipient=$3, estimate_no=$4, contact_person=$5,
|
||||
greeting_text=$6, model_name=$7, model_code=$8, executor_date=$9,
|
||||
note1=$10, note2=$11, note3=$12, note4=$13, note_remarks=$14,
|
||||
total_amount=$15, total_amount_krw=$16,
|
||||
manager_name=$17, manager_contact=$18, show_total_row=$19,
|
||||
chg_user_id=$20, chgdate=NOW()
|
||||
WHERE objid=$1`,
|
||||
[
|
||||
templateObjid,
|
||||
body.executor ?? null, body.recipient ?? null, body.estimate_no ?? null, body.contact_person ?? null,
|
||||
body.greeting_text ?? null, body.model_name ?? null, body.model_code ?? null, body.executor_date ?? null,
|
||||
body.note1 ?? null, body.note2 ?? null, body.note3 ?? null, body.note4 ?? null, body.note_remarks ?? null,
|
||||
body.total_amount ?? null, body.total_amount_krw ?? null,
|
||||
body.manager_name ?? null, body.manager_contact ?? null, body.show_total_row ?? "Y",
|
||||
userId,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
templateObjid = genVarcharObjid("ET");
|
||||
await client.query(
|
||||
`INSERT INTO estimate_template (
|
||||
objid, contract_objid, template_type,
|
||||
executor, recipient, estimate_no, contact_person, greeting_text,
|
||||
model_name, model_code, executor_date,
|
||||
note1, note2, note3, note4, note_remarks,
|
||||
total_amount, total_amount_krw,
|
||||
manager_name, manager_contact, show_total_row,
|
||||
writer, regdate, chg_user_id, chgdate
|
||||
) VALUES (
|
||||
$1, $2, '1',
|
||||
$3, $4, $5, $6, $7,
|
||||
$8, $9, $10,
|
||||
$11, $12, $13, $14, $15,
|
||||
$16, $17,
|
||||
$18, $19, $20,
|
||||
$21, NOW(), $21, NOW()
|
||||
)`,
|
||||
[
|
||||
templateObjid, body.contract_objid,
|
||||
body.executor ?? null, body.recipient ?? null, body.estimate_no ?? null, body.contact_person ?? null, body.greeting_text ?? null,
|
||||
body.model_name ?? null, body.model_code ?? null, body.executor_date ?? null,
|
||||
body.note1 ?? null, body.note2 ?? null, body.note3 ?? null, body.note4 ?? null, body.note_remarks ?? null,
|
||||
body.total_amount ?? null, body.total_amount_krw ?? null,
|
||||
body.manager_name ?? null, body.manager_contact ?? null, body.show_total_row ?? "Y",
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await rebuildTemplateItems(client, templateObjid, body.items ?? []);
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("견적작성(일반) 저장", { templateObjid, contractObjid: body.contract_objid, isUpdate, items: (body.items ?? []).length });
|
||||
return { templateObjid, isUpdate };
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 장비 견적서 저장 (wace saveEstimateTemplate2)
|
||||
// 헤더 + categories_json + 장비 1행 자동 라인 INSERT (cnc_machine 수량 추출은 클라이언트 책임)
|
||||
export async function saveEstimateTemplate2(userId: string, body: EstimateTemplate2Body) {
|
||||
if (!body.contract_objid && !body.template_objid) throw new Error("contract_objid or template_objid is required");
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
let templateObjid = body.template_objid ?? "";
|
||||
let contractObjid = body.contract_objid;
|
||||
const isUpdate = templateObjid !== "" && templateObjid !== "-1" && (await existsTemplate(client, templateObjid));
|
||||
|
||||
if (isUpdate) {
|
||||
if (!contractObjid) {
|
||||
const r = await client.query(`SELECT contract_objid FROM estimate_template WHERE objid=$1`, [templateObjid]);
|
||||
contractObjid = r.rows[0]?.contract_objid;
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE estimate_template SET
|
||||
executor_date=$2, recipient=$3, part_name=$4, part_objid=$5,
|
||||
notes_content=$6, validity_period=$7, categories_json=$8,
|
||||
group1_subtotal=$9, total_amount=$10, total_amount_krw=$11,
|
||||
chg_user_id=$12, chgdate=NOW()
|
||||
WHERE objid=$1`,
|
||||
[
|
||||
templateObjid,
|
||||
body.executor_date ?? null, body.recipient ?? null, body.part_name ?? null, body.part_objid ?? null,
|
||||
body.notes_content ?? null, body.validity_period ?? null, body.categories_json ?? null,
|
||||
body.group1_subtotal ?? null, body.total_amount ?? null, body.total_amount_krw ?? null,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
templateObjid = genVarcharObjid("ET");
|
||||
await client.query(
|
||||
`INSERT INTO estimate_template (
|
||||
objid, contract_objid, template_type,
|
||||
executor_date, recipient, part_name, part_objid,
|
||||
notes_content, validity_period, categories_json,
|
||||
group1_subtotal, total_amount, total_amount_krw,
|
||||
writer, regdate, chg_user_id, chgdate
|
||||
) VALUES (
|
||||
$1, $2, '2',
|
||||
$3, $4, $5, $6,
|
||||
$7, $8, $9,
|
||||
$10, $11, $12,
|
||||
$13, NOW(), $13, NOW()
|
||||
)`,
|
||||
[
|
||||
templateObjid, contractObjid,
|
||||
body.executor_date ?? null, body.recipient ?? null, body.part_name ?? null, body.part_objid ?? null,
|
||||
body.notes_content ?? null, body.validity_period ?? null, body.categories_json ?? null,
|
||||
body.group1_subtotal ?? null, body.total_amount ?? null, body.total_amount_krw ?? null,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 장비 견적서는 part_name + total_amount + categories_json의 cnc_machine 수량으로 1행 라인 생성
|
||||
// (wace saveEstimateTemplate2 line 1647~)
|
||||
await client.query(`DELETE FROM estimate_template_item WHERE template_objid=$1`, [templateObjid]);
|
||||
const partName = (body.part_name ?? "").trim();
|
||||
const amountStr = (body.total_amount ?? "").toString().replace(/[^0-9.]/g, "");
|
||||
if (partName !== "" && amountStr !== "") {
|
||||
let qty = 1;
|
||||
if (body.categories_json) {
|
||||
try {
|
||||
const cats = JSON.parse(body.categories_json) as Array<any>;
|
||||
const cnc = cats.find(c => c?.category === "cnc_machine");
|
||||
const firstItem = cnc?.items?.[0];
|
||||
const qtyRaw = firstItem?.quantity;
|
||||
if (qtyRaw != null) {
|
||||
const numOnly = String(qtyRaw).split("\n")[0].replace(/[^0-9]/g, "");
|
||||
if (numOnly) qty = parseInt(numOnly, 10);
|
||||
}
|
||||
} catch {
|
||||
// categories_json 파싱 실패 시 수량 1로 폴백
|
||||
}
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO estimate_template_item (
|
||||
template_objid, seq, category, part_objid,
|
||||
description, specification, quantity, unit, unit_price, amount,
|
||||
note, remark
|
||||
) VALUES ($1, 1, 'cnc_machine', $2, $3, NULL, $4, NULL, NULL, $5, NULL, NULL)`,
|
||||
[templateObjid, body.part_objid ?? null, partName, String(qty), amountStr],
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("견적작성(장비) 저장", { templateObjid, contractObjid, isUpdate });
|
||||
return { templateObjid, isUpdate };
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 단건 조회 (wace getEstimateTemplateByObjId + getEstimateTemplateItemsByTemplateObjId)
|
||||
export async function getTemplateById(templateObjid: string) {
|
||||
const pool = getPool();
|
||||
const headerRes = await pool.query(
|
||||
`SELECT ET.*,
|
||||
TO_CHAR(ET.regdate, 'YYYY-MM-DD HH24:MI') AS regdate_str,
|
||||
TO_CHAR(ET.chgdate, 'YYYY-MM-DD HH24:MI') AS chgdate_str,
|
||||
CM.exchange_rate,
|
||||
CM.contract_currency,
|
||||
CC_CUR.code_name AS contract_currency_name
|
||||
FROM estimate_template ET
|
||||
LEFT JOIN contract_mgmt CM ON CM.objid = ET.contract_objid
|
||||
LEFT JOIN comm_code CC_CUR ON CC_CUR.code_id = CM.contract_currency AND CC_CUR.status='active'
|
||||
WHERE ET.objid=$1`,
|
||||
[templateObjid],
|
||||
);
|
||||
if (headerRes.rowCount === 0) return null;
|
||||
const header = headerRes.rows[0];
|
||||
|
||||
const itemsRes = await pool.query(
|
||||
`SELECT * FROM estimate_template_item WHERE template_objid=$1 ORDER BY seq`,
|
||||
[templateObjid],
|
||||
);
|
||||
|
||||
return { ...header, items: itemsRes.rows };
|
||||
}
|
||||
|
||||
// 견적 차수 리스트 (wace estimateList.jsp fn_showEstimateList — contract_objid별 차수들)
|
||||
export async function listTemplatesByContract(contractObjid: string) {
|
||||
const pool = getPool();
|
||||
const res = await pool.query(
|
||||
`SELECT objid, template_type, estimate_no,
|
||||
recipient, total_amount, total_amount_krw,
|
||||
writer,
|
||||
TO_CHAR(regdate, 'YYYY-MM-DD HH24:MI') AS regdate,
|
||||
TO_CHAR(chgdate, 'YYYY-MM-DD HH24:MI') AS chgdate
|
||||
FROM estimate_template
|
||||
WHERE contract_objid=$1
|
||||
ORDER BY regdate DESC`,
|
||||
[contractObjid],
|
||||
);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface OrderItem {
|
||||
}
|
||||
|
||||
export interface OrderBody {
|
||||
// contract_mgmt 헤더
|
||||
// contract_mgmt 헤더 (wace estimateAndOrderRegistFormPopup 직접등록 통합폼 — G2)
|
||||
objid?: string; // 신규면 자동 생성
|
||||
contract_no?: string;
|
||||
category_cd?: string;
|
||||
@@ -57,15 +57,17 @@ export interface OrderBody {
|
||||
paid_type?: string;
|
||||
contract_currency?: string;
|
||||
exchange_rate?: string;
|
||||
receipt_date?: string;
|
||||
contract_date?: string; // 발주일
|
||||
receipt_date?: string; // 접수일 *
|
||||
order_date?: string; // 발주일 * (wace G2 필수)
|
||||
req_del_date?: string; // 요청납기
|
||||
po_no?: string; // 발주번호
|
||||
contract_result?: string; // 수주상태 (예: WAITING/CONFIRMED/CANCELLED)
|
||||
contract_result?: string; // 수주상태
|
||||
approval_required?: string; // 결재여부 'Y'|'N'
|
||||
pm_user_id?: string;
|
||||
customer_request?: string;
|
||||
shipping_method?: string;
|
||||
incoterms?: string;
|
||||
is_direct_order?: string; // 'Y'면 직접등록 (견적관리 노출 X, 주문관리만 노출)
|
||||
// 라인
|
||||
items: OrderItem[];
|
||||
}
|
||||
@@ -127,7 +129,7 @@ export async function getList(filter: OrderListFilter) {
|
||||
,T.CUSTOMER_OBJID
|
||||
,C.customer_name AS CUSTOMER_NAME
|
||||
,T.PRODUCT
|
||||
,CC_PRD.code_name AS PRODUCT_NAME
|
||||
,COALESCE(CI_AGG.product_summary, CC_PRD.code_name) AS PRODUCT_NAME
|
||||
,T.AREA_CD
|
||||
,CC_AREA.code_name AS AREA_NAME
|
||||
,T.PAID_TYPE
|
||||
@@ -182,6 +184,7 @@ export async function getList(filter: OrderListFilter) {
|
||||
,NULL::text AS ORDER_APPR_STATUS
|
||||
,NULL::text AS AMARANTH_STATUS
|
||||
,0 AS CU01_CNT
|
||||
,T.IS_DIRECT_ORDER AS IS_DIRECT_ORDER
|
||||
FROM contract_mgmt T
|
||||
LEFT JOIN customer_mng C
|
||||
ON C.customer_code = CASE WHEN T.CUSTOMER_OBJID LIKE 'C_%' THEN substring(T.CUSTOMER_OBJID, 3) ELSE T.CUSTOMER_OBJID END
|
||||
@@ -197,16 +200,18 @@ export async function getList(filter: OrderListFilter) {
|
||||
SELECT
|
||||
CI.contract_objid,
|
||||
COUNT(*) AS item_count,
|
||||
(array_agg(COALESCE(IT.item_name, CI.part_name) ORDER BY CI.seq))[1] AS first_part_name,
|
||||
(array_agg(COALESCE(IT.item_number, CI.part_no) ORDER BY CI.seq))[1] AS first_part_no,
|
||||
(array_agg(COALESCE(PM.part_name, CI.part_name) ORDER BY CI.seq))[1] AS first_part_name,
|
||||
(array_agg(COALESCE(PM.part_no, CI.part_no) ORDER BY CI.seq))[1] AS first_part_no,
|
||||
MIN(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN CI.due_date END) AS earliest_due_date,
|
||||
GREATEST(COUNT(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN 1 END) - 1, 0) AS other_due_date_count,
|
||||
COALESCE(SUM(CAST(REPLACE(NULLIF(CI.order_quantity, ''), ',', '') AS NUMERIC)), 0) AS order_quantity_sum,
|
||||
COALESCE(SUM(CASE WHEN CI.cancel_qty IS NOT NULL AND CI.cancel_qty != '' AND CI.cancel_qty != '0'
|
||||
THEN CAST(CI.cancel_qty AS NUMERIC) END), 0) AS cancel_qty_sum,
|
||||
COUNT(CASE WHEN CI.order_quantity IS NOT NULL AND CI.order_quantity != '' AND CI.order_quantity != '0' THEN 1 END) AS has_order_data
|
||||
COUNT(CASE WHEN CI.order_quantity IS NOT NULL AND CI.order_quantity != '' AND CI.order_quantity != '0' THEN 1 END) AS has_order_data,
|
||||
STRING_AGG(DISTINCT CC_PRDI.code_name, ', ') FILTER (WHERE CC_PRDI.code_name IS NOT NULL) AS product_summary
|
||||
FROM contract_item CI
|
||||
LEFT JOIN item_info IT ON IT.id = CI.part_objid
|
||||
LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid
|
||||
LEFT JOIN comm_code CC_PRDI ON CC_PRDI.code_id = CI.product AND CC_PRDI.status='active'
|
||||
WHERE CI.status='ACTIVE'
|
||||
GROUP BY CI.contract_objid
|
||||
) CI_AGG ON CI_AGG.contract_objid = T.OBJID
|
||||
@@ -244,9 +249,9 @@ export async function getById(objid: string) {
|
||||
if (headerRes.rowCount === 0) return null;
|
||||
|
||||
const itemsRes = await pool.query(
|
||||
`SELECT CI.*, IT.item_name AS master_item_name
|
||||
`SELECT CI.*, PM.part_name AS master_item_name
|
||||
FROM contract_item CI
|
||||
LEFT JOIN item_info IT ON IT.id = CI.part_objid
|
||||
LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid
|
||||
WHERE CI.contract_objid = $1 AND CI.status = 'ACTIVE'
|
||||
ORDER BY CI.seq`, [objid],
|
||||
);
|
||||
@@ -270,13 +275,89 @@ export async function getById(objid: string) {
|
||||
return { ...headerRes.rows[0], items };
|
||||
}
|
||||
|
||||
// ─── 주문서 뷰 (wace orderFormView 대응) ──────────────────────
|
||||
// wace 패턴: getOrderFormInfo(헤더 + 거래처) + getOrderFormItems(라인) 두 쿼리.
|
||||
|
||||
export async function getOrderFormView(objid: string) {
|
||||
const pool = getPool();
|
||||
const headerSql = `
|
||||
SELECT
|
||||
T.objid AS objid,
|
||||
T.contract_no AS contract_no,
|
||||
T.po_no AS po_no,
|
||||
T.order_date AS order_date,
|
||||
T.customer_objid AS customer_objid,
|
||||
CC_CAT.code_name AS category_name,
|
||||
CC_CUR.code_name AS currency_name,
|
||||
T.contract_currency AS currency_code,
|
||||
T.exchange_rate AS exchange_rate,
|
||||
T.order_supply_price AS order_supply_price,
|
||||
T.order_vat AS order_vat,
|
||||
T.order_total_amount AS order_total_amount,
|
||||
CASE
|
||||
WHEN T.paid_type = 'paid' THEN '부가세별도'
|
||||
WHEN T.paid_type = 'free' THEN '부가세미포함'
|
||||
ELSE ''
|
||||
END AS vat_note,
|
||||
T.customer_request AS customer_request,
|
||||
C.customer_name AS client_nm,
|
||||
C.business_number AS client_bus_reg_no,
|
||||
COALESCE(C.ceo_name, C.representative_name) AS client_ceo_nm,
|
||||
C.address AS client_addr,
|
||||
C.biz_condition AS client_bus_type,
|
||||
C.biz_item AS client_bus_item,
|
||||
COALESCE(C.tel, C.phone, C.contact_phone) AS client_tel_no,
|
||||
COALESCE(C.fax_no, C.fax) AS client_fax_no,
|
||||
COALESCE(C.email, C.charge_email) AS client_email,
|
||||
TRIM(BOTH FROM COALESCE(U_WR.dept_name, '') || ' ' || COALESCE(U_WR.user_name, T.writer)) AS writer_name,
|
||||
U_WR.tel AS writer_contact,
|
||||
TO_CHAR(T.regdate, 'YYYY-MM-DD HH24:MI:SS') AS reg_datetime
|
||||
FROM contract_mgmt T
|
||||
LEFT JOIN customer_mng C
|
||||
ON C.customer_code = CASE WHEN T.customer_objid LIKE 'C_%' THEN substring(T.customer_objid, 3) ELSE T.customer_objid END
|
||||
LEFT JOIN user_info U_WR ON U_WR.user_id = T.writer
|
||||
LEFT JOIN comm_code CC_CAT ON CC_CAT.code_id = T.category_cd AND CC_CAT.status='active'
|
||||
LEFT JOIN comm_code CC_CUR ON CC_CUR.code_id = T.contract_currency AND CC_CUR.status='active'
|
||||
WHERE T.objid = $1
|
||||
`;
|
||||
const headerRes = await pool.query(headerSql, [objid]);
|
||||
if (headerRes.rowCount === 0) return null;
|
||||
|
||||
const itemsSql = `
|
||||
SELECT
|
||||
CI.seq,
|
||||
COALESCE(PM.part_no, CI.part_no) AS part_no,
|
||||
COALESCE(PM.part_name, CI.part_name) AS part_name,
|
||||
IT.size AS spec,
|
||||
CC_UNIT.code_name AS unit_name,
|
||||
IT.unit AS unit_code,
|
||||
CI.due_date AS due_date,
|
||||
CI.order_quantity AS order_quantity,
|
||||
CI.order_unit_price AS order_unit_price,
|
||||
CI.order_supply_price AS order_supply_price,
|
||||
CI.order_vat AS order_vat,
|
||||
CI.order_total_amount AS order_total_amount
|
||||
FROM contract_item CI
|
||||
LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid
|
||||
LEFT JOIN comm_code CC_UNIT ON CC_UNIT.code_id = IT.unit AND CC_UNIT.status='active'
|
||||
WHERE CI.contract_objid = $1 AND CI.status = 'ACTIVE'
|
||||
ORDER BY CI.seq
|
||||
`;
|
||||
const itemsRes = await pool.query(itemsSql, [objid]);
|
||||
|
||||
logger.info("주문서 뷰 조회", { objid, itemCount: itemsRes.rowCount });
|
||||
return { info: headerRes.rows[0], items: itemsRes.rows };
|
||||
}
|
||||
|
||||
// ─── 영업번호 채번 ─────────────────────────────────────────────
|
||||
|
||||
// 영업번호 채번 — wace 운영 패턴: {YY}C-{NNNN}
|
||||
// YY = 년도 뒷 2자리 / C = 고정 / NNNN = 같은 prefix 안에서 MAX(NNNN)+1, 4자리 zero-pad
|
||||
// 운영 데이터 90건 모두 이 형식 (예: 26C-0801, 26C-0800, ...)
|
||||
export async function generateContractNo(): Promise<string> {
|
||||
const pool = getPool();
|
||||
const today = new Date();
|
||||
const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`;
|
||||
const prefix = `SO-${ymd}-`;
|
||||
const yy = String(new Date().getFullYear()).slice(-2);
|
||||
const prefix = `${yy}C-`;
|
||||
const res = await pool.query(
|
||||
`SELECT contract_no FROM contract_mgmt
|
||||
WHERE contract_no LIKE $1
|
||||
@@ -288,7 +369,7 @@ export async function generateContractNo(): Promise<string> {
|
||||
const lastSeq = parseInt(last.replace(prefix, ""), 10);
|
||||
if (!isNaN(lastSeq)) seq = lastSeq + 1;
|
||||
}
|
||||
return `${prefix}${String(seq).padStart(3, "0")}`;
|
||||
return `${prefix}${String(seq).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
// ─── 생성/수정 공용 ───────────────────────────────────────────
|
||||
@@ -357,25 +438,29 @@ export async function create(userId: string, body: OrderBody) {
|
||||
return s + (isNaN(v) ? 0 : v);
|
||||
}, 0);
|
||||
|
||||
// wace G2 패턴: 주문관리 등록은 직접등록 통합폼 — is_direct_order='Y' (견적관리에 노출 X)
|
||||
const isDirectOrder = body.is_direct_order || "Y";
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO contract_mgmt (
|
||||
objid, contract_no, category_cd, customer_objid, product, area_cd, paid_type,
|
||||
contract_currency, exchange_rate, receipt_date, contract_date, req_del_date,
|
||||
po_no, contract_result, pm_user_id, customer_request, shipping_method, incoterms,
|
||||
contract_currency, exchange_rate, receipt_date, order_date, req_del_date,
|
||||
po_no, contract_result, approval_required, pm_user_id, customer_request,
|
||||
shipping_method, incoterms, is_direct_order,
|
||||
order_supply_price, order_vat, order_total_amount,
|
||||
writer, regdate
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,
|
||||
$19,$20,$21,$22,NOW()
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
|
||||
$21,$22,$23,$24,NOW()
|
||||
)`,
|
||||
[
|
||||
objid, contractNo, body.category_cd || null, body.customer_objid || null,
|
||||
body.product || null, body.area_cd || null, body.paid_type || null,
|
||||
body.contract_currency || null, body.exchange_rate || null,
|
||||
body.receipt_date || null, body.contract_date || null, body.req_del_date || null,
|
||||
body.po_no || null, body.contract_result || null,
|
||||
body.receipt_date || null, body.order_date || null, body.req_del_date || null,
|
||||
body.po_no || null, body.contract_result || null, body.approval_required || "N",
|
||||
body.pm_user_id || null, body.customer_request || null,
|
||||
body.shipping_method || null, body.incoterms || null,
|
||||
body.shipping_method || null, body.incoterms || null, isDirectOrder,
|
||||
String(sum("order_supply_price")), String(sum("order_vat")), String(sum("order_total_amount")),
|
||||
userId,
|
||||
],
|
||||
@@ -406,18 +491,18 @@ export async function update(userId: string, objid: string, body: OrderBody) {
|
||||
await client.query(
|
||||
`UPDATE contract_mgmt SET
|
||||
category_cd=$2, customer_objid=$3, product=$4, area_cd=$5, paid_type=$6,
|
||||
contract_currency=$7, exchange_rate=$8, receipt_date=$9, contract_date=$10,
|
||||
req_del_date=$11, po_no=$12, contract_result=$13, pm_user_id=$14,
|
||||
customer_request=$15, shipping_method=$16, incoterms=$17,
|
||||
order_supply_price=$18, order_vat=$19, order_total_amount=$20,
|
||||
chg_user_id=$21
|
||||
contract_currency=$7, exchange_rate=$8, receipt_date=$9, order_date=$10,
|
||||
req_del_date=$11, po_no=$12, contract_result=$13, approval_required=$14, pm_user_id=$15,
|
||||
customer_request=$16, shipping_method=$17, incoterms=$18,
|
||||
order_supply_price=$19, order_vat=$20, order_total_amount=$21,
|
||||
chg_user_id=$22
|
||||
WHERE objid=$1`,
|
||||
[
|
||||
objid, body.category_cd || null, body.customer_objid || null,
|
||||
body.product || null, body.area_cd || null, body.paid_type || null,
|
||||
body.contract_currency || null, body.exchange_rate || null,
|
||||
body.receipt_date || null, body.contract_date || null, body.req_del_date || null,
|
||||
body.po_no || null, body.contract_result || null,
|
||||
body.receipt_date || null, body.order_date || null, body.req_del_date || null,
|
||||
body.po_no || null, body.contract_result || null, body.approval_required || "N",
|
||||
body.pm_user_id || null, body.customer_request || null,
|
||||
body.shipping_method || null, body.incoterms || null,
|
||||
String(sum("order_supply_price")), String(sum("order_vat")), String(sum("order_total_amount")),
|
||||
@@ -442,13 +527,295 @@ export async function remove(objid: string) {
|
||||
logger.info("주문서 삭제", { objid });
|
||||
}
|
||||
|
||||
// ─── 수주확정 → 프로젝트 자동생성 (wace ContractMgmtService.updateOrderStatus 이식) ──
|
||||
// 원본: wace_plm/src/com/pms/salesmgmt/service/ContractMgmtService.java:2987~3113
|
||||
// + wace_plm/src/com/pms/mapper/project.xml:7415~7618 (createProject)
|
||||
// 트리거: contract_result='0000964'(수주) 또는 '0000968'(수주FCST)
|
||||
// 룰: project_no = {주문유형}-{제품구분}-{YYMMDD}-{NNN}
|
||||
// 주문유형(category code_name): 오버홀=O 개조=M 개발=D 견적=Q 수리=R 판매=S 그외=T
|
||||
// 제품구분(product code_name): Machine=MC A/S=AS D/S=DS B/S=BS C/T=CT A/C=AC W/M=WM 기타=기타 그외=replace('/', '')
|
||||
// 순번: 같은 prefix-prefix-YYMMDD-% 안에서 MAX(NNN)+1, 없으면 001
|
||||
// Machine(0000928): hasProject=false면 quantity 만큼 N회 INSERT (각 quantity=1)
|
||||
|
||||
const PROJECT_TRIGGER_RESULTS = ["0000964", "0000968"] as const;
|
||||
const PRODUCT_MACHINE = "0000928";
|
||||
|
||||
async function generateProjectNo(client: any, categoryCd: string | null, productCd: string | null): Promise<string> {
|
||||
const sql = `
|
||||
WITH meta AS (
|
||||
SELECT
|
||||
CASE CC_CAT.code_name
|
||||
WHEN '오버홀' THEN 'O' WHEN '개조' THEN 'M' WHEN '개발' THEN 'D'
|
||||
WHEN '견적' THEN 'Q' WHEN '수리' THEN 'R' WHEN '판매' THEN 'S'
|
||||
ELSE 'T'
|
||||
END AS cat_abbr,
|
||||
CASE CC_PRD.code_name
|
||||
WHEN 'Machine' THEN 'MC' WHEN 'A/S' THEN 'AS' WHEN 'D/S' THEN 'DS'
|
||||
WHEN 'B/S' THEN 'BS' WHEN 'C/T' THEN 'CT' WHEN 'A/C' THEN 'AC'
|
||||
WHEN 'W/M' THEN 'WM' WHEN '기타' THEN '기타'
|
||||
ELSE REPLACE(COALESCE(CC_PRD.code_name, ''), '/', '')
|
||||
END AS prd_abbr,
|
||||
TO_CHAR(CURRENT_DATE, 'YYMMDD') AS ymd
|
||||
FROM (SELECT 1) X
|
||||
LEFT JOIN comm_code CC_CAT ON CC_CAT.code_id = $1 AND CC_CAT.status='active'
|
||||
LEFT JOIN comm_code CC_PRD ON CC_PRD.code_id = $2 AND CC_PRD.status='active'
|
||||
)
|
||||
SELECT
|
||||
m.cat_abbr || '-' || m.prd_abbr || '-' || m.ymd || '-' ||
|
||||
LPAD(
|
||||
COALESCE((
|
||||
SELECT MAX(SUBSTRING(project_no FROM '\\d{3}$')::int) + 1
|
||||
FROM project_mgmt
|
||||
WHERE project_no LIKE m.cat_abbr || '-' || m.prd_abbr || '-' || m.ymd || '-%'
|
||||
), 1)::text, 3, '0'
|
||||
) AS project_no
|
||||
FROM meta m
|
||||
`;
|
||||
const res = await client.query(sql, [categoryCd, productCd]);
|
||||
return res.rows[0]?.project_no || "";
|
||||
}
|
||||
|
||||
async function insertProjectFromContract(
|
||||
client: any,
|
||||
contractObjid: string,
|
||||
item: any,
|
||||
productCd: string | null,
|
||||
quantity: string,
|
||||
projectNo: string,
|
||||
userId: string,
|
||||
) {
|
||||
const newObjid = genObjid("PJ");
|
||||
const sql = `
|
||||
INSERT INTO project_mgmt (
|
||||
objid, contract_objid, category_cd, customer_objid, product,
|
||||
customer_project_name, status_cd, due_date, location, setup,
|
||||
facility, facility_qty, facility_type, facility_depth, production_no,
|
||||
bus_cal_cd, category1_cd, chg_user_id, plan_date, complete_date,
|
||||
result_cd, project_no, pm_user_id, contract_price, contract_price_currency,
|
||||
contract_currency, regdate, writer, contract_no, customer_equip_name,
|
||||
req_del_date, contract_del_date, contract_company, contract_date, po_no,
|
||||
manufacture_plant, contract_result, project_name, spec_user_id, spec_plan_date,
|
||||
spec_comp_date, spec_result_cd, est_plan_date, est_user_id, est_comp_date,
|
||||
est_result_cd, area_cd, mechanical_type, overhaul_order, is_temp,
|
||||
part_objid, part_no, part_name, quantity, contract_item_objid
|
||||
)
|
||||
SELECT
|
||||
$1::varchar, $2::varchar, T.category_cd, T.customer_objid, COALESCE($3::varchar, T.product),
|
||||
T.customer_project_name, T.status_cd, $4::varchar, T.location, T.setup,
|
||||
T.facility, T.facility_qty, T.facility_type, T.facility_depth, T.production_no,
|
||||
T.bus_cal_cd, T.category1_cd, $5::varchar, T.plan_date, T.complete_date,
|
||||
T.result_cd, $6::varchar, T.pm_user_id, NULL, NULL,
|
||||
T.contract_currency, NOW(), T.writer, T.contract_no, T.customer_equip_name,
|
||||
T.req_del_date, T.contract_del_date, T.contract_company, T.contract_date, T.po_no,
|
||||
T.manufacture_plant, T.contract_result, NULL, T.spec_user_id, T.spec_plan_date,
|
||||
T.spec_comp_date, T.spec_result_cd, T.est_plan_date, T.est_user_id, T.est_comp_date,
|
||||
T.est_result_cd, T.area_cd, T.mechanical_type, NULL, '1',
|
||||
$7::varchar, $8::varchar, $9::varchar, $10::varchar, $11::varchar
|
||||
FROM contract_mgmt T WHERE T.objid = $2::varchar
|
||||
`;
|
||||
await client.query(sql, [
|
||||
newObjid,
|
||||
contractObjid,
|
||||
productCd,
|
||||
item.due_date || null,
|
||||
userId,
|
||||
projectNo,
|
||||
item.part_objid || null,
|
||||
item.part_no || null,
|
||||
item.part_name || null,
|
||||
quantity,
|
||||
item.objid || null,
|
||||
]);
|
||||
logger.info("[수주확정] 프로젝트 생성", { contractObjid, partObjid: item.part_objid, projectNo, quantity });
|
||||
}
|
||||
|
||||
async function updateProjectFromContract(
|
||||
client: any,
|
||||
contractObjid: string,
|
||||
item: any,
|
||||
isMachine: boolean,
|
||||
quantity: string,
|
||||
productCd: string | null,
|
||||
userId: string,
|
||||
) {
|
||||
// wace ModifyProjectByContract: SET DUE_DATE/QUANTITY/PRODUCT 등 (Machine은 quantity 미변경)
|
||||
const setParts: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
if (item.due_date) { setParts.push(`due_date=$${idx++}`); params.push(item.due_date); }
|
||||
if (!isMachine) { setParts.push(`quantity=$${idx++}`); params.push(quantity); }
|
||||
if (productCd) { setParts.push(`product=$${idx++}`); params.push(productCd); }
|
||||
setParts.push(`chg_user_id=$${idx++}`); params.push(userId);
|
||||
|
||||
// contract_item_objid 우선 매칭, 없으면 part_objid fallback (wace choose 패턴)
|
||||
const sql = `UPDATE project_mgmt SET ${setParts.join(", ")}
|
||||
WHERE contract_objid = $${idx++}
|
||||
AND (contract_item_objid = $${idx++}
|
||||
OR (contract_item_objid IS NULL AND part_objid = $${idx++}))`;
|
||||
params.push(contractObjid, item.objid, item.part_objid);
|
||||
await client.query(sql, params);
|
||||
logger.info("[수주확정] 프로젝트 업데이트", { contractObjid, partObjid: item.part_objid, isMachine });
|
||||
}
|
||||
|
||||
async function createProjectsFromContract(client: any, contractObjid: string, userId: string) {
|
||||
// 1) 기존 프로젝트 존재 여부
|
||||
const existsRes = await client.query(
|
||||
`SELECT objid FROM project_mgmt WHERE contract_objid = $1 LIMIT 1`,
|
||||
[contractObjid],
|
||||
);
|
||||
const hasProject = (existsRes.rowCount ?? 0) > 0;
|
||||
|
||||
// 2) 헤더 기본 정보
|
||||
const headerRes = await client.query(
|
||||
`SELECT category_cd, product FROM contract_mgmt WHERE objid = $1`,
|
||||
[contractObjid],
|
||||
);
|
||||
if (headerRes.rowCount === 0) return;
|
||||
const header = headerRes.rows[0];
|
||||
|
||||
// 3) 라인 조회 (item_info 마스터 우선 fallback — wace getContractItems 패턴)
|
||||
const itemsRes = await client.query(
|
||||
`SELECT
|
||||
CI.objid, CI.part_objid,
|
||||
COALESCE(PM.part_no, CI.part_no) AS part_no,
|
||||
COALESCE(PM.part_name, CI.part_name) AS part_name,
|
||||
CI.quantity, CI.order_quantity, CI.due_date, CI.product
|
||||
FROM contract_item CI
|
||||
LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid
|
||||
WHERE CI.contract_objid = $1 AND CI.status = 'ACTIVE'
|
||||
ORDER BY CI.seq`,
|
||||
[contractObjid],
|
||||
);
|
||||
|
||||
if (itemsRes.rowCount === 0) {
|
||||
logger.warn("[수주확정] contract_item 없음 — 프로젝트 생성 스킵", { contractObjid });
|
||||
return;
|
||||
}
|
||||
|
||||
let createdCount = 0;
|
||||
for (const item of itemsRes.rows) {
|
||||
const itemProduct: string | null = item.product || header.product || null;
|
||||
const isMachine = itemProduct === PRODUCT_MACHINE;
|
||||
|
||||
// 수량: order_quantity 우선, 없으면 quantity, 둘 다 없으면 1
|
||||
const rawQty = String(item.order_quantity ?? item.quantity ?? "1").replace(/[^0-9.]/g, "");
|
||||
let qtyNum = parseFloat(rawQty);
|
||||
if (!Number.isFinite(qtyNum) || qtyNum <= 0) qtyNum = 1;
|
||||
const itemQuantity = Math.floor(qtyNum);
|
||||
|
||||
if (hasProject) {
|
||||
// 존재하면 1회 UPDATE만 (Machine이면 quantity 미변경)
|
||||
const updQty = isMachine ? "1" : String(itemQuantity);
|
||||
await updateProjectFromContract(client, contractObjid, item, isMachine, updQty, itemProduct, userId);
|
||||
} else {
|
||||
// 신규: Machine이면 quantity만큼 반복(각 1), 그외 1회(원래 수량)
|
||||
const loopCount = isMachine ? itemQuantity : 1;
|
||||
const insertQty = isMachine ? "1" : String(itemQuantity);
|
||||
for (let q = 0; q < loopCount; q++) {
|
||||
const projectNo = await generateProjectNo(client, header.category_cd, itemProduct);
|
||||
await insertProjectFromContract(client, contractObjid, item, itemProduct, insertQty, projectNo, userId);
|
||||
createdCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("[수주확정] 프로젝트 처리 완료", {
|
||||
contractObjid, hasProject, items: itemsRes.rowCount, created: createdCount,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 수주 상태 변경 ────────────────────────────────────────────
|
||||
|
||||
// ─── 수주취소 (라인별 cancel_qty 입력) — wace saveOrderCancelQty 이식 ──────
|
||||
// 원본: ContractMgmtService.java:3932~4011, contractMgmt.xml:5292~5299
|
||||
// 룰:
|
||||
// - cancel_qty 비었거나 0 → 빈값으로 초기화 (UPDATE cancel_qty='')
|
||||
// - 음수 → 에러 ("수주취소 수량은 0 이상이어야 합니다")
|
||||
// - order_qty 이상 → 에러 + rollback ("수주취소 수량은 수주수량(N)보다 적어야 합니다") — 전체 취소 불가
|
||||
// - 정상 → UPDATE cancel_qty=값
|
||||
// 응답: { result: 'true'|'false', msg }
|
||||
|
||||
export interface CancelQtyEntry {
|
||||
itemObjId: string;
|
||||
cancelQty: string | number;
|
||||
orderQty: string | number;
|
||||
}
|
||||
|
||||
export async function saveOrderCancelQty(userId: string, entries: CancelQtyEntry[]):
|
||||
Promise<{ result: "true" | "false"; msg: string }>
|
||||
{
|
||||
if (!entries || entries.length === 0) {
|
||||
return { result: "false", msg: "취소 수량 정보가 없습니다." };
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
for (const e of entries) {
|
||||
const cancelStr = String(e.cancelQty ?? "").trim();
|
||||
const orderStr = String(e.orderQty ?? "").trim();
|
||||
|
||||
// 빈값 또는 0 → 초기화
|
||||
if (cancelStr === "" || cancelStr === "0") {
|
||||
await client.query(
|
||||
`UPDATE contract_item SET cancel_qty='', chgdate=NOW(), chg_user_id=$2 WHERE objid=$1`,
|
||||
[e.itemObjId, userId],
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cancelInt = parseInt(cancelStr, 10);
|
||||
const orderInt = parseInt(orderStr, 10);
|
||||
|
||||
if (Number.isNaN(cancelInt) || cancelInt < 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return { result: "false", msg: "수주취소 수량은 0 이상이어야 합니다." };
|
||||
}
|
||||
if (cancelInt >= orderInt) {
|
||||
await client.query("ROLLBACK");
|
||||
return { result: "false", msg: `수주취소 수량은 수주수량(${orderInt})보다 적어야 합니다.` };
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE contract_item SET cancel_qty=$2, chgdate=NOW(), chg_user_id=$3 WHERE objid=$1`,
|
||||
[e.itemObjId, cancelStr, userId],
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("수주취소 수량 저장", { count: entries.length });
|
||||
return { result: "true", msg: "수주취소 수량이 저장되었습니다." };
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateStatus(userId: string, objid: string, contractResult: string) {
|
||||
const pool = getPool();
|
||||
await pool.query(
|
||||
`UPDATE contract_mgmt SET contract_result=$2, chg_user_id=$3 WHERE objid=$1`,
|
||||
[objid, contractResult, userId],
|
||||
);
|
||||
logger.info("주문 상태 변경", { objid, contractResult });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
await client.query(
|
||||
`UPDATE contract_mgmt SET contract_result=$2, chg_user_id=$3 WHERE objid=$1`,
|
||||
[objid, contractResult, userId],
|
||||
);
|
||||
|
||||
if (PROJECT_TRIGGER_RESULTS.includes(contractResult as any)) {
|
||||
await createProjectsFromContract(client, objid, userId);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("주문 상태 변경", { objid, contractResult });
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,11 +169,12 @@ export async function getSaleList(filter: SaleListFilter) {
|
||||
,CM.customer_request
|
||||
,T.sales_deadline_date
|
||||
,T.regdate
|
||||
/* 출하지시상태/생산상태/분할S/N/거래명세서/주문서첨부 — 1차 placeholder */
|
||||
/* 출하지시상태/생산상태/분할S/N/거래명세서 — 1차 placeholder */
|
||||
,NULL::text AS production_status
|
||||
,NULL::text AS split_serial_no
|
||||
,'N'::text AS has_transaction_statement
|
||||
,0 AS cu01_cnt
|
||||
/* 주문서첨부 카운트 — contract_mgmt.objid 기반 (wace 동일) */
|
||||
,COALESCE(AF.cu01_cnt, 0) AS cu01_cnt
|
||||
FROM project_mgmt T
|
||||
LEFT JOIN contract_mgmt CM ON CM.objid = T.contract_objid
|
||||
LEFT JOIN contract_item CI ON CI.objid = T.contract_item_objid AND CI.status = 'ACTIVE'
|
||||
@@ -202,6 +203,14 @@ export async function getSaleList(filter: SaleListFilter) {
|
||||
AND CIS.serial_no IS NOT NULL AND CIS.serial_no != ''
|
||||
GROUP BY CIS.item_objid
|
||||
) CIS_AGG ON CIS_AGG.item_objid = T.contract_item_objid
|
||||
/* 주문서첨부 카운트 (contract_mgmt.objid 기반, doc_type FTC_ORDER/ORDER — wace 동일) */
|
||||
LEFT JOIN (
|
||||
SELECT target_objid,
|
||||
COUNT(*) FILTER (WHERE doc_type IN ('FTC_ORDER','ORDER')) AS cu01_cnt
|
||||
FROM attach_file_info
|
||||
WHERE UPPER(status) = 'ACTIVE'
|
||||
GROUP BY target_objid
|
||||
) AF ON AF.target_objid = T.contract_objid
|
||||
${where}
|
||||
ORDER BY T.regdate DESC NULLS LAST, T.project_no DESC
|
||||
`;
|
||||
@@ -297,16 +306,37 @@ export async function getRevenueList(filter: SaleListFilter) {
|
||||
/* 분할S/N · 거래명세서 — 1차 placeholder */
|
||||
,NULL::text AS split_serial_no
|
||||
,'N'::text AS has_transaction_statement
|
||||
/* V1 컬럼 보강 (wace 일치) — 접수일/유무상/요청납기/고객사요청사항/수주상태/주문서첨부/담당자/인도조건 */
|
||||
,CM.receipt_date AS receipt_date
|
||||
,CM.paid_type AS payment_type
|
||||
,CASE WHEN CM.paid_type='paid' THEN '유상' WHEN CM.paid_type='free' THEN '무상' ELSE CM.paid_type END AS payment_type_name
|
||||
,COALESCE(NULLIF(CI.due_date, ''), NULLIF(T.due_date, ''), NULLIF(CM.due_date, '')) AS request_date
|
||||
,CM.customer_request AS customer_request
|
||||
,T.contract_result AS order_status
|
||||
,CC_RES.code_name AS order_status_name
|
||||
,U_MGR.user_name AS manager_name
|
||||
,SR.incoterms AS incoterms
|
||||
,COALESCE(AF.cu01_cnt, 0) AS cu01_cnt
|
||||
FROM project_mgmt T
|
||||
LEFT JOIN contract_mgmt CM ON CM.objid = T.contract_objid
|
||||
LEFT JOIN contract_item CI ON CI.objid = T.contract_item_objid AND CI.status='ACTIVE'
|
||||
LEFT JOIN customer_mng C
|
||||
ON C.customer_code = CASE WHEN T.customer_objid LIKE 'C_%' THEN substring(T.customer_objid, 3) ELSE T.customer_objid END
|
||||
LEFT JOIN sales_registration SR ON SR.project_no = T.project_no
|
||||
LEFT JOIN user_info U_MGR ON U_MGR.user_id = SR.manager_user_id
|
||||
LEFT JOIN comm_code CC_CAT ON CC_CAT.code_id = T.category_cd AND CC_CAT.status='active'
|
||||
LEFT JOIN comm_code CC_AREA ON CC_AREA.code_id = T.area_cd AND CC_AREA.status='active'
|
||||
LEFT JOIN comm_code CC_PRD ON CC_PRD.code_id = T.product AND CC_PRD.status='active'
|
||||
LEFT JOIN comm_code CC_RES ON CC_RES.code_id = T.contract_result AND CC_RES.status='active'
|
||||
LEFT JOIN comm_code CC_CUR ON CC_CUR.code_id = T.contract_currency AND CC_CUR.status='active'
|
||||
LEFT JOIN comm_code CC_CUR_S ON CC_CUR_S.code_id = SR.sales_currency AND CC_CUR_S.status='active'
|
||||
/* 주문서첨부 카운트 — contract_mgmt.objid 기반 (wace 동일) */
|
||||
LEFT JOIN (
|
||||
SELECT target_objid,
|
||||
COUNT(*) FILTER (WHERE doc_type IN ('FTC_ORDER','ORDER')) AS cu01_cnt
|
||||
FROM attach_file_info WHERE UPPER(status)='ACTIVE'
|
||||
GROUP BY target_objid
|
||||
) AF ON AF.target_objid = T.contract_objid
|
||||
${where}
|
||||
ORDER BY T.regdate DESC NULLS LAST, T.project_no DESC
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user