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:
chpark
2026-05-11 17:00:38 +09:00
42 changed files with 6404 additions and 748 deletions
@@ -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, // 추가 필드
+11 -2
View File
@@ -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 }); }
}
+13 -7
View File
@@ -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;
+506 -226
View File
@@ -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();
}
}
+32 -2
View File
@@ -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
`;