Files
wace_rps/backend-node/src/services/salesSaleService.ts
T
hjjeong d23b305990 매출관리 그리드 V1 컬럼 9개 보강 + getRevenueList SQL JOIN 확장 + wace 1:1 검증
- getRevenueList SQL: contract_item / user_info / comm_code(CC_RES) / attach_file_info LATERAL 4개 JOIN 추가. receipt_date / payment_type / request_date / customer_request / order_status_name / manager_name / incoterms / cu01_cnt SELECT 보강
- RevenueListRow 타입: 9개 신규 필드 보강
- GRID_COLUMNS 9개 추가: 접수일/유무상/요청납기/고객사요청사항/수주상태/주문서첨부/출하방법/담당자/인도조건 (wace 35/35 일치)
- docs/migration/sales/04-revenue-verify.md, scripts/verify-revenue.sql

영업 4개 메뉴 (견적·주문·판매·매출) 모두 구조적 검증 1차 종결.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:42:21 +09:00

457 lines
22 KiB
TypeScript

// ============================================================
// 영업관리 > 판매관리 + 매출관리 (wace_plm 도메인 이식)
// 메인 테이블: sales_registration (판매 헤더, 프로젝트당 1건)
// + shipment_log (분할출하/매출 라인)
// 라인 표시는 contract_item + (sales_registration | shipment_log) JOIN
// ============================================================
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
export interface SaleListFilter {
orderType?: string; // contract_mgmt.category_cd
poNo?: string; // contract_mgmt.po_no
customer_objid?: string;
search_partObjId?: string;
search_partName?: string;
serialNo?: string;
shippingStatus?: string;
orderDateFrom?: string;
orderDateTo?: string;
shippingDateFrom?: string;
shippingDateTo?: string;
salesStatus?: string; // 판매상태 (registered/cancelled 등)
productType?: string; // project_mgmt.product (제품구분)
nation?: string; // project_mgmt.area_cd (국내/해외)
// 매출관리 전용
revenueMode?: string;
salesDeadlineFrom?: string;
salesDeadlineTo?: string;
}
export interface SaleRegisterBody {
project_no: string; // = contract_mgmt.objid 또는 별도 식별자
shipping_order_status?: string;
serial_no?: string;
sales_quantity?: number;
sales_unit_price?: number;
sales_supply_price?: number;
sales_vat?: number;
sales_total_amount?: number;
sales_currency?: string;
sales_exchange_rate?: number;
shipping_date?: string;
shipping_method?: string;
manager_user_id?: string;
incoterms?: string;
has_split_shipment?: boolean;
}
export interface DeadlineInfoBody {
log_id?: number; // shipment_log.log_id (기존이면 update)
parent_sale_no: number; // sales_registration.sale_no
target_objid: string; // contract_mgmt.objid
sales_deadline_date?: string;
tax_type?: string;
tax_invoice_date?: string;
export_decl_no?: string;
loading_date?: string;
sales_slip_date?: string;
sales_slip_menu_sq?: number;
remark?: string;
}
// ─── 판매 그리드 (project_mgmt 라인 단위) ─────────────────────
// project_mgmt가 메인 (수주 확정된 라인 1건당 1행). sales_registration은 LEFT JOIN.
// wace mapper salesNcollectMgmt.xml getSalesMgmtGridList(line 815~) 패턴.
export async function getSaleList(filter: SaleListFilter) {
const pool = getPool();
const conditions: string[] = ["T.project_no IS NOT NULL", "T.project_no <> ''"];
const params: any[] = [];
let idx = 1;
if (filter.orderType) { conditions.push(`T.category_cd = $${idx++}`); params.push(filter.orderType); }
if (filter.poNo) { conditions.push(`T.po_no ILIKE $${idx++}`); params.push(`%${filter.poNo}%`); }
if (filter.customer_objid) { conditions.push(`T.customer_objid = $${idx++}`); params.push(filter.customer_objid); }
if (filter.search_partObjId) { conditions.push(`T.part_objid = $${idx++}`); params.push(filter.search_partObjId); }
if (filter.serialNo) {
conditions.push(`EXISTS (SELECT 1 FROM contract_item_serial CIS
WHERE CIS.item_objid = T.contract_item_objid AND CIS.status='ACTIVE'
AND UPPER(CIS.serial_no) LIKE UPPER($${idx++}))`);
params.push(`%${filter.serialNo}%`);
}
if (filter.orderDateFrom) { conditions.push(`COALESCE(T.contract_date, CM.order_date) >= $${idx++}`); params.push(filter.orderDateFrom); }
if (filter.orderDateTo) { conditions.push(`COALESCE(T.contract_date, CM.order_date) <= $${idx++}`); params.push(filter.orderDateTo); }
if (filter.shippingDateFrom) { conditions.push(`SR.shipping_date >= $${idx++}`); params.push(filter.shippingDateFrom); }
if (filter.shippingDateTo) { conditions.push(`SR.shipping_date <= $${idx++}`); params.push(filter.shippingDateTo); }
if (filter.shippingStatus) { conditions.push(`SR.shipping_order_status = $${idx++}`); params.push(filter.shippingStatus); }
const where = `WHERE ${conditions.join(" AND ")}`;
const sql = `
SELECT
T.objid AS project_objid
,T.project_no AS project_no
,T.contract_objid
,T.contract_item_objid
,T.contract_no AS contract_no
,T.category_cd AS order_type
,CC_CAT.code_name AS order_type_name
,COALESCE(T.contract_date, CM.order_date) AS order_date
,T.po_no
,COALESCE(NULLIF(CI.due_date, ''), NULLIF(T.due_date, ''), NULLIF(CM.due_date, '')) AS request_date
,T.due_date
,T.customer_objid
,C.customer_name AS customer
,T.product AS product_type
,CC_PRD.code_name AS product_type_name
,T.area_cd AS nation
,CC_AREA.code_name AS nation_name
,T.part_objid
,T.part_no AS product_no
,T.part_name AS product_name
,T.quantity AS order_quantity
,COALESCE(SR.sales_quantity, 0) AS sales_quantity
,GREATEST(
COALESCE(CAST(NULLIF(REPLACE(T.quantity, ',', ''), '') AS NUMERIC), 0)
- COALESCE(SR.sales_quantity, 0),
0
) AS remaining_quantity
,SR.sale_no
,SR.sales_unit_price
,SR.sales_supply_price
,SR.sales_vat
,SR.sales_total_amount
,CASE
WHEN SR.sales_exchange_rate IS NOT NULL AND SR.sales_exchange_rate <> 0
THEN ROUND(COALESCE(SR.sales_total_amount, 0) * SR.sales_exchange_rate, 2)
ELSE COALESCE(SR.sales_total_amount, 0)
END AS sales_total_amount_krw
,CASE
WHEN SR.sales_exchange_rate IS NOT NULL AND SR.sales_exchange_rate <> 0
THEN ROUND(
GREATEST(
COALESCE(CAST(NULLIF(REPLACE(T.quantity, ',', ''), '') AS NUMERIC), 0)
- COALESCE(SR.sales_quantity, 0),
0
) * COALESCE(SR.sales_unit_price, 0) * SR.sales_exchange_rate, 2)
ELSE 0
END AS remaining_amount_krw
/* 환종: sales_registration 우선, 없으면 project_mgmt.contract_currency (wace 패턴) */
,COALESCE(NULLIF(SR.sales_currency, ''), T.contract_currency) AS sales_currency
,COALESCE(CC_CUR_S.code_name, CC_CUR.code_name) AS sales_currency_name
,T.contract_currency
,CC_CUR.code_name AS contract_currency_name
,SR.sales_exchange_rate
,SR.shipping_date
,SR.shipping_method
,SR.shipping_order_status
,SR.manager_user_id
,U_MGR.user_name AS manager_name
,SR.incoterms
,SR.has_split_shipment
/* S/N: contract_item_serial 집계 우선, 없으면 sales_registration 텍스트 fallback (wace 패턴) */
,COALESCE(NULLIF(CIS_AGG.serial_list, ''), SR.serial_no) AS serial_no
,T.contract_result AS order_status
,CC_RES.code_name AS order_status_name
/* 판매상태 — wace 로직: 합계가 수주량 이상이면 완판, 일부만 판매면 분할판매, 0이면 미판매 */
,CASE
WHEN COALESCE(SR_AGG.sales_qty_sum, 0) = 0 THEN '미판매'
WHEN COALESCE(SR_AGG.sales_qty_sum, 0) >= COALESCE(CAST(NULLIF(REPLACE(T.quantity, ',', ''), '') AS NUMERIC), 0) THEN '완판'
WHEN COALESCE(SR_AGG.sales_qty_sum, 0) > 0 THEN '분할판매'
ELSE ''
END AS sales_status
,T.sales_status AS sales_status_raw
,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
,CM.receipt_date
,CM.customer_request
,T.sales_deadline_date
,T.regdate
/* 출하지시상태/생산상태/분할S/N/거래명세서 — 1차 placeholder */
,NULL::text AS production_status
,NULL::text AS split_serial_no
,'N'::text AS has_transaction_statement
/* 주문서첨부 카운트 — 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'
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'
/* 판매수량 합계 (분할 판매 LIKE 패턴 — wace 동일) */
LEFT JOIN (
SELECT SR2.project_no, SUM(SR2.sales_quantity) AS sales_qty_sum
FROM sales_registration SR2
GROUP BY SR2.project_no
) SR_AGG ON SR_AGG.project_no LIKE T.project_no || '%'
/* contract_item_serial 집계 (S/N 표시) */
LEFT JOIN (
SELECT CIS.item_objid,
STRING_AGG(CIS.serial_no, ',' ORDER BY CIS.seq) AS serial_list
FROM contract_item_serial CIS
WHERE UPPER(CIS.status) = 'ACTIVE'
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
`;
const res = await pool.query(sql, params);
logger.info("판매 목록 조회", { count: res.rowCount, filter });
return res.rows;
}
// ─── 매출 그리드 (shipment_log 기반) ─────────────────────────
export async function getRevenueList(filter: SaleListFilter) {
const pool = getPool();
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (filter.orderType) { conditions.push(`T.category_cd = $${idx++}`); params.push(filter.orderType); }
if (filter.poNo) { conditions.push(`T.po_no ILIKE $${idx++}`); params.push(`%${filter.poNo}%`); }
if (filter.customer_objid) { conditions.push(`T.customer_objid = $${idx++}`); params.push(filter.customer_objid); }
if (filter.productType) { conditions.push(`T.product = $${idx++}`); params.push(filter.productType); }
if (filter.nation) { conditions.push(`T.area_cd = $${idx++}`); params.push(filter.nation); }
if (filter.search_partObjId) { conditions.push(`T.part_objid = $${idx++}`); params.push(filter.search_partObjId); }
if (filter.serialNo) {
conditions.push(`EXISTS (SELECT 1 FROM contract_item_serial CIS
WHERE CIS.item_objid = T.contract_item_objid AND CIS.status='ACTIVE'
AND UPPER(CIS.serial_no) LIKE UPPER($${idx++}))`);
params.push(`%${filter.serialNo}%`);
}
if (filter.orderDateFrom) { conditions.push(`COALESCE(T.contract_date, CM.order_date) >= $${idx++}`); params.push(filter.orderDateFrom); }
if (filter.orderDateTo) { conditions.push(`COALESCE(T.contract_date, CM.order_date) <= $${idx++}`); params.push(filter.orderDateTo); }
if (filter.salesDeadlineFrom) { conditions.push(`T.sales_deadline_date >= $${idx++}`); params.push(filter.salesDeadlineFrom); }
if (filter.salesDeadlineTo) { conditions.push(`T.sales_deadline_date <= $${idx++}`); params.push(filter.salesDeadlineTo); }
if (filter.shippingDateFrom) { conditions.push(`SR.shipping_date >= $${idx++}`); params.push(filter.shippingDateFrom); }
if (filter.shippingDateTo) { conditions.push(`SR.shipping_date <= $${idx++}`); params.push(filter.shippingDateTo); }
conditions.push("T.project_no IS NOT NULL");
conditions.push("T.project_no <> ''");
/* wace mapper: shippingDateRequired='Y' — 매출관리는 출하 등록된 프로젝트만 */
conditions.push(`EXISTS (
SELECT 1 FROM sales_registration SR_X
WHERE SR_X.project_no = T.project_no
AND SR_X.shipping_date IS NOT NULL
)`);
const where = `WHERE ${conditions.join(" AND ")}`;
const sql = `
SELECT
T.objid AS project_objid
,T.project_no AS project_no
,T.contract_objid
,T.contract_no AS contract_no
,T.category_cd AS order_type
,CC_CAT.code_name AS order_type_name
,COALESCE(T.contract_date, CM.order_date) AS order_date
,T.po_no
,T.customer_objid
,C.customer_name AS customer
,T.product AS product_type
,CC_PRD.code_name AS product_type_name
,T.area_cd AS nation
,CC_AREA.code_name AS nation_name
,T.part_no AS product_no
,T.part_name AS product_name
,T.quantity AS sales_quantity_raw
,COALESCE(SR.sales_quantity,
CAST(NULLIF(REPLACE(T.quantity, ',', ''), '') AS NUMERIC), 0) AS sales_quantity
,SR.sales_unit_price
,SR.sales_supply_price
,SR.sales_vat
,SR.sales_total_amount
,CASE
WHEN SR.sales_exchange_rate IS NOT NULL AND SR.sales_exchange_rate <> 0
THEN ROUND(COALESCE(SR.sales_total_amount, 0) * SR.sales_exchange_rate, 2)
ELSE COALESCE(SR.sales_total_amount, 0)
END AS sales_total_amount_krw
,T.contract_currency
,CC_CUR.code_name AS contract_currency_name
,SR.sales_currency
,CC_CUR_S.code_name AS sales_currency_name
,SR.sales_exchange_rate
,SR.shipping_date
,SR.shipping_method
,SR.serial_no
,T.sales_deadline_date
,T.tax_type
,T.tax_invoice_date
,T.export_decl_no
,T.loading_date
,T.sales_slip_date
,T.sales_slip_menu_sq
,T.sales_status
/* 분할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
`;
const res = await pool.query(sql, params);
logger.info("매출 목록 조회", { count: res.rowCount, filter });
return res.rows;
}
// ─── 판매 등록/수정 (sales_registration upsert) ─────────────
export async function registerSale(userId: string, body: SaleRegisterBody) {
const pool = getPool();
const sql = `
INSERT INTO sales_registration (
project_no, shipping_order_status, serial_no,
sales_quantity, sales_unit_price, sales_supply_price, sales_vat, sales_total_amount,
sales_currency, sales_exchange_rate,
shipping_date, shipping_method, manager_user_id, incoterms,
has_split_shipment, reg_date, reg_user_id
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW(),$16
)
ON CONFLICT (project_no) DO UPDATE SET
shipping_order_status=EXCLUDED.shipping_order_status,
serial_no=EXCLUDED.serial_no,
sales_quantity=EXCLUDED.sales_quantity,
sales_unit_price=EXCLUDED.sales_unit_price,
sales_supply_price=EXCLUDED.sales_supply_price,
sales_vat=EXCLUDED.sales_vat,
sales_total_amount=EXCLUDED.sales_total_amount,
sales_currency=EXCLUDED.sales_currency,
sales_exchange_rate=EXCLUDED.sales_exchange_rate,
shipping_date=EXCLUDED.shipping_date,
shipping_method=EXCLUDED.shipping_method,
manager_user_id=EXCLUDED.manager_user_id,
incoterms=EXCLUDED.incoterms,
has_split_shipment=EXCLUDED.has_split_shipment,
upd_date=NOW(), upd_user_id=$16
RETURNING sale_no
`;
const params = [
body.project_no, body.shipping_order_status || null, body.serial_no || null,
body.sales_quantity ?? null, body.sales_unit_price ?? null,
body.sales_supply_price ?? null, body.sales_vat ?? null, body.sales_total_amount ?? null,
body.sales_currency || null, body.sales_exchange_rate ?? null,
body.shipping_date || null, body.shipping_method || null,
body.manager_user_id || null, body.incoterms || null,
body.has_split_shipment ?? false, userId,
];
const res = await pool.query(sql, params);
logger.info("판매 등록/수정", { project_no: body.project_no, sale_no: res.rows[0]?.sale_no });
return res.rows[0];
}
export async function deleteSale(projectNo: string) {
const pool = getPool();
await pool.query(`DELETE FROM sales_registration WHERE project_no = $1`, [projectNo]);
logger.info("판매 삭제", { projectNo });
}
// ─── 매출 마감정보 입력/저장 (shipment_log upsert) ───────────
export async function saveDeadlineInfo(userId: string, body: DeadlineInfoBody) {
const pool = getPool();
if (body.log_id) {
// 수정
await pool.query(
`UPDATE shipment_log SET
sales_deadline_date=$2, tax_type=$3, tax_invoice_date=$4,
export_decl_no=$5, loading_date=$6,
sales_slip_date=$7, sales_slip_menu_sq=$8,
remark=$9
WHERE log_id=$1`,
[
body.log_id, body.sales_deadline_date || null, body.tax_type || null,
body.tax_invoice_date || null, body.export_decl_no || null, body.loading_date || null,
body.sales_slip_date || null, body.sales_slip_menu_sq ?? null,
body.remark || null,
],
);
return { log_id: body.log_id };
}
// 신규
const res = await pool.query(
`INSERT INTO shipment_log (
target_objid, log_type, parent_sale_no, is_split_record,
sales_deadline_date, tax_type, tax_invoice_date,
export_decl_no, loading_date,
sales_slip_date, sales_slip_menu_sq, remark,
reg_date, reg_user_id
) VALUES ($1, 'DEADLINE_INFO', $2, false,
$3,$4,$5,$6,$7,$8,$9,$10, NOW(), $11)
RETURNING log_id`,
[
body.target_objid, body.parent_sale_no,
body.sales_deadline_date || null, body.tax_type || null, body.tax_invoice_date || null,
body.export_decl_no || null, body.loading_date || null,
body.sales_slip_date || null, body.sales_slip_menu_sq ?? null, body.remark || null,
userId,
],
);
logger.info("매출 마감정보 등록", { log_id: res.rows[0]?.log_id, target: body.target_objid });
return res.rows[0];
}
export async function confirmSalesDeadline(_userId: string, logIds: number[]) {
const pool = getPool();
// 마감 확정 = sales_deadline_date NOT NULL 로직만 적용. 잠금 컬럼이 별도 없어
// 추후 별도 컬럼 추가하거나 정책 결정 후 강화. 지금은 단순 Touch.
await pool.query(
`UPDATE shipment_log SET log_message=COALESCE(log_message,'') || ' [DEADLINE_CONFIRMED ' || NOW()::text || ']'
WHERE log_id = ANY($1::int[])`,
[logIds],
);
logger.info("매출 마감 확정", { count: logIds.length });
}