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
`;
+118
View File
@@ -0,0 +1,118 @@
# 영업관리 이식 GAP 분석 (원본 wace_plm 대비)
> 작성: 2026-05-08 / 작성자: hjjeong
> 목적: vexplor_rps에 이식된 영업관리 4개 메뉴가 wace_plm 원본 흐름과 어디서 어긋나는지 정리하고, 다음 PR 우선순위를 합의하기 위한 단일 문서.
> 참고: [01-estimate.md](./01-estimate.md), [02-order.md](./02-order.md), [feedback_wace_jsp_columns](../../../../.claude/projects/-Users-jhj-vexplor-rps/memory/feedback_wace_jsp_columns.md)
## 0. 한 문장 요약
견적/주문 list와 SQL은 잘 이식됐지만 **상태 전이 트리거**(수주확정 → 프로젝트 자동생성)와 **직접등록 통합폼**, **결재 자동판정**, **PDF·SMTP 실작업**이 통째로 빠져 있어, 사용자가 영업 흐름을 끝까지 돌릴 수 없는 상태.
## 0.1 이식 원칙 (모든 GAP 작업 공통)
> **JSP/Java/매퍼XML 안의 주석 블록(`/* */`, `<!-- -->`, `//`)은 비활성 옛 로직 보존 영역이다 — 절대 이식 대상이 아니다. 활성 코드만, 한 줄 한 줄 직접 따라가서 그대로 이식한다.**
- **운영 화면이 진실의 기준**: waceplm.esgrin.com 운영 화면에 실제 보이는 항목/동작이 활성. 코드만 보면 활성/비활성 구분이 흐려짐.
- **컬럼 정의(`var columns = [...]`)**: `/* 주석처리된 컬럼 - 필요시 활성화 */` 블록 이하는 무시.
- **검색 폼(`#plmSearchZon`)**: `<!-- 주석처리된 검색필터 - 필요시 활성화 -->` 블록 이하는 무시.
- **서비스 메서드**: 주석된 옛 SQL/분기 무시. 호출 그래프(controller → service → mapper)를 한 줄씩 따라가서 활성 경로만 추출.
- **매퍼 XML**: `<!-- ... -->` 블록 안의 SQL fragment는 무시. `<select id="...">`/`<insert id="...">` 단위로 호출되는 것만 사용.
- 자세한 함정 사례: 메모리 [feedback_wace_jsp_columns](../../../../.claude/projects/-Users-jhj-vexplor-rps/memory/feedback_wace_jsp_columns.md) (2026-05-08 영업관리 4개 메뉴 검색폼 사고 기록).
## 1. 원본 견적·주문 흐름 (10단계)
| # | 단계 | wace_plm endpoint | 핵심 테이블 변경 |
|---|---|---|---|
| 1 | 견적 list 조회 | `POST /contractMgmt/estimateGridList.do` | `contract_mgmt`(read, `IS_DIRECT_ORDER!='Y'` 필터) |
| 2 | 견적요청 등록 | `POST /contractMgmt/saveContractMgmtInfo.do` | `contract_mgmt` + `contract_item`(N) + `contract_item_serial`(N) INSERT |
| 3 | 직접등록 통합폼 (견적 없이 주문) | `POST /contractMgmt/saveEstimateAndOrderInfo.do` | 위와 동일 + `IS_DIRECT_ORDER='Y'` 강제 세팅 |
| 4 | 견적요청 → 견적작성 | (식별자 `contract_objid`로 묶임. 명시적 컬럼 복사 없음) | (없음) |
| 5 | 견적 작성 (일반/장비) | `POST /contractMgmt/saveEstimate.do | saveEstimate2.do` | `estimate_template` + `estimate_template_item` INSERT/UPDATE (N건 가능) |
| 6 | 결재 사전판정 | `POST /contractMgmt/checkApprovalRequired.do` | (read only — 신규수주/가격인하 로직) |
| 6a | 결재불필요 자동처리 | `POST /contractMgmt/setApprovalNotRequired.do` | `contract_mgmt.APPROVAL_REQUIRED='N'` UPDATE |
| 6b | 아마란스 결재상신 | `POST /approval/getAmaranthSsoUrl.do` | `amaranth_approval` INSERT (외부 SSO) |
| 7 | 메일 발송 | `POST /contractMgmt/sendEstimateMail.do` | `mail_log` INSERT + 실제 SMTP `MailUtil.sendMailWithAttachFileUTF8` |
| 8 | 주문서 list 조회 | `POST /contractMgmt/contractGridList.do` | `contract_mgmt`(read, 모든 행) — 견적 list와 동일 테이블 |
| 9 | **수주확정 (전이)** | `POST /contractMgmt/updateOrderStatus.do` | `contract_mgmt.CONTRACT_RESULT` UPDATE만 (새 INSERT 없음) |
| 10 | **프로젝트 자동생성** (9의 부수효과) | (9의 service 내부에서 호출) | `project_mgmt` INSERT × N (라인별, Machine은 수량만큼) + `project_no` 채번 |
> 견적관리·주문서관리·판매관리·매출관리 4개 list는 **모두 같은 `contract_mgmt` 행**을 단계별 필터로 보여주는 구조. 단계 전이는 `CONTRACT_RESULT` 코드 변경으로 일어남.
## 2. GAP 매트릭스 (우선순위 순)
> 🔴 = 사용자 흐름 차단 / 🟠 = 핵심 기능 빠짐 / 🟡 = 보완 필요 / 🟢 = 백로그
| # | 우선 | 항목 | 원본 위치 | 이식본 현재 상태 | 권장 작업 |
|---|---|---|---|---|---|
| G1 | 🔴 | **수주확정 시 프로젝트 자동생성** | `ContractMgmtService.updateOrderStatus` (라인 2987~3113) + `project.xml` (7518~7581) | [salesOrderMgmtService.ts:521](../../../backend-node/src/services/salesOrderMgmtService.ts#L521) `updateStatus``CONTRACT_RESULT` UPDATE만 — 프로젝트 생성 호출 없음 | `updateStatus` 트랜잭션 내에서: contract_item 루프 → PRODUCT='0000928'(Machine)이면 quantity만큼 N회, 아니면 1회 → `project_no` 채번 (`{주문유형}-{제품구분}-{YYMMDD}-{순번3자리}`) → `project_mgmt` INSERT |
| G2 | 🔴 | **직접등록 통합폼** (`estimateAndOrderRegistFormPopup`) | `ContractMgmtService.saveEstimateAndOrderInfo` (라인 2664) | endpoint 자체 부재. 주문관리 화면 "신규" 버튼이 견적 없이 주문 등록하는 흐름 미지원 | `POST /api/sales/order/direct` 신설 — `IS_DIRECT_ORDER='Y'` 강제, `contract_mgmt` UPSERT + `contract_item` UPSERT + `contract_item_serial` 다중 INSERT |
| G3 | 🟠 | 견적요청등록 시 contract_item 다중 INSERT | `ContractMgmtService.saveContractMgmtInfo` (라인 544) | [salesEstimateService.ts](../../../backend-node/src/services/salesEstimateService.ts)는 헤더만 INSERT, 라인 입력 누락 | save 트랜잭션에 `contract_item` 다중 UPSERT + `contract_item_serial` 처리 추가 |
| G4 | 🟠 | 결재 자동판정 (`checkApprovalRequired`) | (별도 컨트롤러, 신규수주/가격인하 룰) | 미구현. APPROVAL_REQUIRED='N' 라벨 표시만 | 룰 분석 후 endpoint 신설. 외부 amaranth SSO는 RPS 결재 모듈 결정 후 |
| G5 | 🟠 | 견적템플릿 일반/장비 분기 + PDF | `ContractMgmtService.saveEstimateTemplate/2` (라인 1501/1591) + SmartEditor `uploadPdfChunk` | 미이식. 추가견적 카운트(시연 시드)만 표시 | template1/template2 popup 라우트 + `puppeteer` 또는 `react-pdf` PDF 생성 → `attach_file_info doc_type='estimate02'` INSERT |
| G6 | 🟠 | SMTP 실제 발송 | `ContractMgmtService.sendEstimateMail` (라인 1774-1968), `MailUtil.sendMailWithAttachFileUTF8` (라인 1925) | [salesEstimateService.ts:618](../../../backend-node/src/services/salesEstimateService.ts#L618)는 mail_log INSERT만 | `mailSendSimpleService`(nodemailer) 통합 + HTML 본문 생성기(`makeEstimateMailContents`) 포팅 + 첨부 결합 |
| G7 | 🟡 | 주문서 수정 시 contract_item UPSERT (OBJID 유지) | `mapper.upsertContractItemWithOrder` (UPSERT 패턴) | 이식본은 단순 UPDATE — 라인 변경 시 OBJID 유지 보장 안 됨 | UPSERT(ON CONFLICT) 패턴 적용 + 삭제된 라인 처리 분기 |
| G8 | 🟡 | 프로젝트 존재 시 견적·주문 삭제 방지 | `ContractMgmtService.deleteContractMngInfo` (라인 794~808) | [salesOrderMgmtService.ts](../../../backend-node/src/services/salesOrderMgmtService.ts) delete는 사전 체크 없음 | delete 전에 `project_mgmt WHERE contract_objid=$1 LIMIT 1` 체크 → 있으면 거부 |
| G9 | 🟡 | 견적요청 → 견적작성 라인 자동 복제 UI | (원본은 사용자가 contract_item에서 수동 선택) | 미구현 — 견적 작성 시 매번 라인 재입력 | "이전 라인 복제" 버튼 + contract_item → estimate_template_item 일괄 복사 |
| G10 | 🟢 | 환율 마스터 + EXCHANGE_RATE 자동 변환 | `contractBase` SQL EST_TOTAL_AMOUNT_KRW 환산식 | 환산식만 있고 환율 마스터 미구축 | 환율 테이블 신설(또는 ECOS API 동기화) |
| G11 | 🟢 | 결재 모듈 (amaranth_approval / 자체) | 외부 amaranth + APPR_STATUS 라벨 | RPS 결재 정책 미정 | vexplor `approvalController` 매핑 vs `amaranth_approval` 도입 결정 |
## 3. 코드/SQL 정합성 메모
### 3.1 견적·주문은 같은 `contract_mgmt` 행
원본은 **상태 전이형 모델**: 같은 `contract_mgmt` 행이 견적단계(`IS_DIRECT_ORDER='N'`) → 수주(`CONTRACT_RESULT='0000964'`) → FCST(`CONTRACT_RESULT='0000968'`) 로 진행. 새 INSERT는 §3 통합폼과 §10 프로젝트 생성에서만 일어남. 우리 이식본은 이 단계 모델에 맞춰져 있어 list 단의 SQL은 정합성 OK.
### 3.2 project_no 채번 룰 (G1의 핵심)
```
형식: {주문유형}-{제품구분}-{YYMMDD}-{순번3자리}
예: R-CT-260507-001
주문유형 매핑 (CATEGORY_CD → 1글자):
오버홀=O, 개조=M, 개발=D, 견적=Q, 수리=R, 판매=S, 기타=T
제품구분 매핑 (PRODUCT → 2글자):
Machine=MC, A/S=AS, D/S=DS, B/S=BS, C/T=CT, A/C=AC, W/M=WM, 기타=원문
순번:
같은 (주문유형 + 제품구분 + 날짜) 조합 내 MAX(순번)+1
없으면 001부터
```
출처: `wace_plm/src/com/pms/projectmgmt/mapper/project.xml:7518-7581`.
RPS의 `project_mgmt` 89건이 모두 이 룰로 채번되어 있으니, 이식 시 **새 행도 같은 룰**을 따라야 일관성 유지됨.
### 3.3 Machine 분기
contract_item 1라인의 PRODUCT가 `0000928`(Machine)이면 **수량만큼 N회** project_mgmt INSERT (각 quantity=1). 그 외(A/S·D/S·C/T 등)는 1회 INSERT (수주수량 그대로). 이 분기 누락 시 시리얼 단위 추적이 깨짐.
### 3.4 첨부 파일 doc_type
| 단계 | doc_type | 그리드 컬럼 |
|---|---|---|
| 견적 PDF | `estimate01` (메인 견적서) | (없음) |
| 추가 견적 | `estimate02` | "추가견적" |
| 주문서 첨부 | `ORDER_DOC` 또는 `FTC_ORDER` | "주문서첨부" (CU01_CNT) |
`02-order.md`에 주문서 첨부 컬럼이 미정의되어 있음 — 보완 필요.
## 4. 다음 PR 후보 (3개로 묶기)
### PR-A: 수주확정 → 프로젝트 자동생성 (G1)
- 단독 PR. SQL·트랜잭션 위주, UI 변경 거의 없음.
- 작업: `salesOrderMgmtService.updateStatus` 안에 project 생성 로직 + project_no 채번 helper + Machine 분기.
- 검증: `0000964`/`0000968` 코드로 상태 변경 시 project_mgmt에 새 행 생기는지, project_no 형식 일치하는지.
### PR-B: 직접등록 통합폼 (G2 + G3)
- 주문관리 화면 "신규" 버튼이 통합 다이얼로그 띄움 → `IS_DIRECT_ORDER='Y'` + contract_item 다중 입력.
- G3(견적요청등록 라인 입력)도 같은 라인 입력 컴포넌트 재사용 가능 → 묶어서.
### PR-C: 결재·메일·PDF (G4 + G5 + G6)
- 분량 큼. RPS 결재 정책 결정(G11) 선행 필요.
- 단계: SMTP(G6) → PDF(G5) → 결재(G4) 순서로 사이즈 작은 것부터.
### 백로그
- G7~G11은 위 3개 끝난 뒤 평가.
## 5. 검증 체크리스트 (PR마다)
- [ ] 원본 endpoint와 이식 endpoint 매핑표 갱신 (이 문서 §1)
- [ ] 영향 받는 테이블의 BEFORE/AFTER row count 기록
- [ ] wace 운영 화면(waceplm.esgrin.com)과 동일 시나리오 비교 스크린샷
- [ ] `project_mgmt` 새 행이 89건 + N 으로 늘어나는지 (G1)
- [ ] `IS_DIRECT_ORDER='Y'` 행 신규 생성 확인 (G2)
+197
View File
@@ -0,0 +1,197 @@
# 01. 견적관리 wace 1:1 검증
> 작성: 2026-05-09 / 사이클: 구조적 검증 1차 (견적관리 메뉴)
> 목적: wace 운영 화면과 RPS 견적관리를 항목/식별자/채번 단위로 1:1 매칭 + 갭 도출 + 자동 검증 시나리오
## 1. 항목 매핑
### 1.1 등록/수정 폼 — 헤더 8개 (`estimateRegistFormPopup.jsp` 1행/2행)
| # | wace 라벨 | wace name | RPS 폼 | 코드 그룹 | 필수 | 운영 데이터 |
|---|---|---|---|---|---|---|
| 1 | 주문유형 | `category_cd` | `EstimateBody.category_cd` | `0000167` | ✅ | 0001792(판매)·0001791(수리)·0900221(자체개발) |
| 2 | 국내/해외 | `area_cd` | `EstimateBody.area_cd` | `0001219` | ✅ | 0001220(국내)·0001221(해외) |
| 3 | 고객사 | `customer_objid` | `EstimateBody.customer_objid` | — | ✅ | `C_{customer_code}` 90건 일치 |
| 4 | 유/무상 | `paid_type` | `EstimateBody.paid_type` | (raw `paid`/`free`) | ✅ | paid 85·free 4·NULL 1 |
| 5 | 접수일 | `receipt_date` | `EstimateBody.receipt_date` | (date varchar) | ✅ | YYYY-MM-DD |
| 6 | 견적환종 | `contract_currency` | `EstimateBody.contract_currency` | `0001533` | — | 0001566(원)·0001534(달러)·0001537(엔) |
| 7 | 견적환율 | `exchange_rate` | `EstimateBody.exchange_rate` | (raw text) | — | |
| 8 | 결재여부 | `approval_required` | `EstimateBody.approval_required` | (Y/N 체크박스) | ✅ | 90건 N (결재모듈 미도입) |
### 1.2 등록/수정 폼 — 라인 8개 + 삭제
| # | wace 라벨 | wace name | RPS 라인 | 컬럼/그룹 | 필수 |
|---|---|---|---|---|---|
| 1 | No (자동) | (auto seq) | `seq` | integer | — |
| 2 | 제품구분 | `item_product[]` | `EstimateItem.product` | comm_code `0000001` | ✅ |
| 3 | 품번 | `item_part_no_select[]` | `EstimateItem.part_objid`+`part_no` | PartSelect | ✅ |
| 4 | 품명 | `item_part_name_select[]` | `EstimateItem.part_name` | PartSelect | ✅ |
| 5 | S/N | `item_serial_no[]` | `EstimateItem.serials[]` | S/N 다이얼로그 | — |
| 6 | 견적수량 | `item_quantity[]` | `EstimateItem.quantity` | integer | — |
| 7 | 요청납기 | `item_due_date[]` | `EstimateItem.due_date` | date varchar | — |
| 8 | 반납사유 | `item_return_reason[]` | `EstimateItem.return_reason` | comm_code `0001810` | — |
| 9 | 고객요청사항 | `item_customer_request[]` | `EstimateItem.customer_request` | text | — |
### 1.3 그리드 컬럼 — wace 활성 22개
| # | wace title | wace field | RPS GRID_COLUMNS | 상태 |
|---|---|---|---|---|
| 1 | 영업번호 | `CONTRACT_NO` | `contract_no` (frozen) | ✅ |
| 2 | 주문유형 | `CATEGORY_NAME` | `category_name` | ✅ |
| 3 | 접수일 | `RECEIPT_DATE` | `receipt_date` | ✅ |
| 4 | 요청납기 | `EARLIEST_DUE_DATE` | `earliest_due_date_label` | ✅ |
| 5 | 고객사 | `CUSTOMER_NAME` | `customer_name` | ✅ |
| 6 | 품명 | `ITEM_SUMMARY` | `item_summary` | ✅ |
| 7 | 견적수량 | `ESTIMATE_QUANTITY` | `estimate_quantity` | ✅ |
| 8 | 유/무상 | `PAID_TYPE` | `paid_type_name` | ✅ |
| 9 | 공급가액 | `EST_TOTAL_AMOUNT` | `est_total_amount` | ✅ |
| 10 | 원화환산공급가액 | `EST_TOTAL_AMOUNT_KRW` | `est_total_amount_krw` | ✅ |
| 11 | 견적현황 | `EST_STATUS` | `est_status` (folder) | ✅ |
| 12 | 추가견적 | `ADD_EST_CNT` | `add_est_cnt` (clip) | ✅ |
| 13 | 결재상태 | `APPR_STATUS` | `appr_status` | ✅ |
| 14 | 메일발송 | `MAIL_SEND_STATUS` | `mail_send_status_label` | ✅ |
| 15 | 환종 | `CONTRACT_CURRENCY_NAME` | `contract_currency_name` | ✅ |
| 16 | 환율 | `EXCHANGE_RATE` | `exchange_rate` | ✅ |
| 17 | S/N | `SERIAL_NO` | `serial_no` | ✅ |
| 18 | 품번 | `PART_NO` | `part_no` | ✅ |
| 19 | 작성자 | `WRITER_NAME` | `writer_name` | ✅ |
| 20 | **제품구분** | `PRODUCT_NAME` | (없음) | 🔴 갭 |
| 21 | **국내/해외** | `AREA_NAME` | (없음) | 🔴 갭 |
| 22 | **반납사유** | `RETURN_REASON_SUMMARY` | (없음) | 🔴 갭 (집계 컬럼 신설 필요) |
### 1.4 검색 폼 — wace 활성 7개
| # | wace 라벨 | wace name | RPS searchForm | 상태 |
|---|---|---|---|---|
| 1 | 주문유형 | `category_cd` | `category_cd` | ✅ |
| 2 | 고객사 | `customer_objid` | `customer_objid` | ✅ |
| 3 | 품번 | `search_partNo` | `search_partObjId` | 🟡 (PartSelect로 part_objid 단일 검색) |
| 4 | **품명** | `search_partName` | (없음) | 🔴 갭 |
| 5 | S/N | `search_serialNo` | `search_serialNo` | ✅ |
| 6 | 결재상태 | `appr_status` | `appr_status` | ✅ |
| 7 | 접수일 | `receipt_start_date~end_date` | `receipt_start_date/end_date` | ✅ |
### 1.5 액션 버튼
| 버튼 | wace endpoint | RPS 동작 | 상태 |
|---|---|---|---|
| 조회 | `/contractMgmt/estimateGridList.do` | `GET /sales/estimate/list` | ✅ |
| 삭제 | `/contractMgmt/deleteEstimateMgmtInfo.do` | `DELETE /sales/estimate/:id` | ✅ |
| 견적요청등록/수정 | `/contractMgmt/saveContractMgmtInfo.do` | `POST/PUT /sales/estimate` | ✅ (선택 시 수정 분기) |
| 견적작성 | `/contractMgmt/saveEstimate.do | saveEstimate2.do` (template1/2) | placeholder | 🟠 G5 별도 PR |
| 결재상신 | `/contractMgmt/checkApprovalRequired.do` → 아마란스 SSO | placeholder | 🟠 G4 별도 PR |
| 메일발송 | `/contractMgmt/sendEstimateMail.do` | `POST /sales/estimate/mail` (mail_log INSERT만) | 🟡 SMTP 미구현(G6) |
---
## 2. 운영 데이터 코드 체계 (90건 검증 완료)
### 2.1 식별자
| 항목 | 형식 | 검증 |
|---|---|---|
| `contract_mgmt.objid` | varchar (raw integer hash 또는 'CM-...') | wace 운영은 raw integer (문자열로 보관) |
| `contract_mgmt.contract_no` | `{YY}C-{NNNN}` | **90/90건 일치** (regex `^[0-9]{2}C-[0-9]{4}$`) |
| `contract_mgmt.customer_objid` | `C_{customer_code}` (10자리 padded) | **90/90건 customer_mng.customer_code로 매칭** |
| `contract_item.objid` | varchar (raw integer 또는 'CI-...') | |
### 2.2 comm_code 그룹 ID
| 용도 | parent_code_id | 자식 예시 |
|---|---|---|
| 주문유형 (category_cd) | `0000167` | 0001791(수리)/0001792(판매)/0900221(자체개발)/0000170(오버홀)/0000171(개조)/0000168(신규개발)/0001790(견적)/0900214(계획생산) |
| 국내/해외 (area_cd) | `0001219` | 0001220(국내)/0001221(해외) |
| 제품구분 (product) | `0000001` | 0000928(Machine)/0000930(A/S)/0001525(D/S)/0001539(B/S)/0001793(C/T)/0001794(A/C)/0001807(W/M)/0001809(기타) |
| 환종 (contract_currency) | `0001533` | 0001534(달러$)/0001535(유로€)/0001536(위안¥)/0001537(엔¥)/0001566(원₩) |
| 수주상태 (contract_result) | `0000963` | 0000964(수주)/0000965(Cancel)/0000966(Hold)/0000968(수주FCST) |
| 반납사유 (return_reason) | `0001810` | 0001811(수리불가) |
### 2.3 채번 룰
| 항목 | 룰 | 적용 위치 |
|---|---|---|
| `contract_no` | `{YY}C-{NNNN}` (4자리 zero-pad, 같은 prefix MAX+1) | `salesOrderMgmtService.generateContractNo` |
| `project_no` | `{주문유형1}-{제품구분2}-{YYMMDD}-{NNN}` (예: R-CT-260507-001) | `salesOrderMgmtService.generateProjectNo` |
---
## 3. 발견된 갭 (우선순위)
| # | 우선 | 항목 | 권장 작업 |
|---|---|---|---|
| V1 | 🔴 | 그리드 컬럼 3개 누락 (제품구분/국내해외/반납사유) | RPS GRID_COLUMNS 추가 + getList SQL에 PRODUCT_NAME/AREA_NAME/RETURN_REASON_SUMMARY 컬럼 추가. RETURN_REASON_SUMMARY는 contract_item 집계로 LATERAL JOIN |
| V2 | 🟠 | 품명 검색 누락 (`search_partName`) | searchForm에 `search_partName` 추가 + 백엔드 SQL where 조건 추가 |
| V3 | 🟡 | paid_type NULL 1건 (운영 데이터 이슈) | 폼에서 신규 시 `paid` default 강제 — 이미 적용. 기존 NULL 데이터 정리는 별도 |
| V4 | 🟢 | 결재모듈 (G4) — 모든 운영 데이터 approval_required='N' | G4 별도 PR |
| V5 | 🟢 | 견적작성 PDF (G5) | G5 별도 PR |
| V6 | 🟢 | SMTP 실발송 (G6) | G6 별도 PR |
---
## 4. 자동 검증 시나리오 (BEGIN/ROLLBACK)
각 시나리오는 dev DB에서 트랜잭션 안에서 실제 SQL 실행 + 결과 검증 후 ROLLBACK. 영향 0.
### 시나리오 1: 신규 견적요청 등록
```
BEFORE: contract_mgmt 90 / contract_item N0 / contract_item_serial S0
- INSERT contract_mgmt 1건 (contract_no=26C-0802, customer_objid='C_0000005546', ...)
- INSERT contract_item 1건 (product=0001793, part_objid=1868255719, quantity=2)
- INSERT contract_item_serial 0건 (S/N 미입력)
AFTER: contract_mgmt 91 / contract_item N0+1 / serial S0
ROLLBACK → 모두 원복
```
### 시나리오 2: 견적요청 수정 (라인 1→2 확장)
```
BEFORE: 26C-0801 contract_item 1건
- upsertItems: 기존 라인 status='INACTIVE'
- INSERT 새 라인 2건 (objid 새로 발급, ON CONFLICT 미발동)
AFTER: contract_item ACTIVE 2건, INACTIVE 1건 (= 누적 3건)
ROLLBACK
```
### 시나리오 3: 견적요청 삭제
```
BEFORE: 26C-XXXX contract_item N건, contract_item_serial M건
- UPDATE contract_item_serial SET status='INACTIVE' WHERE item_objid IN (...)
- DELETE contract_item WHERE contract_objid=$
- DELETE contract_mgmt WHERE objid=$
AFTER: 모두 사라짐
ROLLBACK
```
### 시나리오 4: 수주확정 → 프로젝트 자동생성 (G1)
```
BEFORE: project_mgmt 89 / contract_mgmt.contract_result NULL
- UPDATE contract_mgmt SET contract_result='0000964'
- 트리거: createProjectsFromContract
- hasProject=false
- contract_item N개 루프 → Machine 분기 → project_no 채번 → INSERT
AFTER: project_mgmt 89+N 또는 89+sum(Machine_qty)+non_machine_count
ROLLBACK
```
(검증 완료: 26C-0801 1라인 C/T qty=2 → project_no=R-CT-260508-001 1건 INSERT)
### 시나리오 5: 수주취소 (cancel_qty 입력)
```
BEFORE: contract_item.cancel_qty NULL
- UPDATE contract_item SET cancel_qty='1', chgdate=NOW(), chg_user_id=$
- contract_mgmt.contract_result 미변경
검증: cancel_qty < order_qty (전체 취소 불가)
ROLLBACK
```
---
## 5. 다음 단계
1. **갭 V1·V2 수정** (그리드 3컬럼 + 품명 검색) → 사용자 확인 후 커밋
2. **자동 검증 SQL 스크립트** 정리 (`scripts/verify-estimate.sql` — BEGIN/ROLLBACK 트랜잭션 모음)
3. **사용자 dev 환경 최종 확인** → 견적관리 메뉴 종결 → 주문관리 진행
+109
View File
@@ -0,0 +1,109 @@
# 02. 주문관리 wace 1:1 검증
> 작성: 2026-05-11 / 사이클: 구조적 검증 2차 (주문관리 메뉴)
> 원본: `wace_plm/WebContent/WEB-INF/view/contractMgmt/orderMgmtList.jsp`
> 대상: `app/(main)/COMPANY_16/sales/order/page.tsx`
## 1. 항목 매핑
### 1.1 그리드 컬럼 — wace 활성 27개 vs RPS 27개 (보강 후 일치)
| # | wace title | wace field | RPS GRID_COLUMNS | 상태 |
|---|---|---|---|---|
| 1 | 영업번호 | `CONTRACT_NO` (frozen) | `contract_no` | ✅ |
| 2 | 주문유형 | `CATEGORY_NAME` | `category_name` | ✅ |
| 3 | 발주일 | `ORDER_DATE` | `order_date` | ✅ |
| 4 | 발주번호 | `PO_NO` | `po_no` | ✅ |
| 5 | 요청납기 | `EARLIEST_DUE_DATE` | `earliest_due_date_label` | ✅ |
| 6 | 고객사 | `CUSTOMER_NAME` | `customer_name` | ✅ |
| 7 | 품명 | `ITEM_SUMMARY` | `item_summary` | ✅ |
| 8 | 수주수량 | `ORDER_QUANTITY` | `order_quantity` | ✅ |
| 9 | 수주취소 | `CANCEL_QTY_SUM` | `cancel_qty_sum` | ✅ |
| 10 | 유/무상 | `PAID_TYPE` | `paid_type_name` | ✅ |
| 11 | 수주상태 | `CONTRACT_RESULT_NAME` | `contract_result_name` | ✅ |
| 12 | 공급가액 | `ORDER_SUPPLY_PRICE_SUM` | `order_supply_price_sum` | ✅ |
| 13 | 부가세 | `ORDER_VAT_SUM` | `order_vat_sum` | ✅ |
| 14 | 총액 | `ORDER_TOTAL_AMOUNT_SUM` | `order_total_amount_sum` | ✅ |
| 15 | 원화총액 | `ORDER_TOTAL_AMOUNT_KRW` | `order_total_amount_krw` | ✅ |
| 16 | 주문서첨부 | `CU01_CNT` | `cu01_cnt` (clip) | ✅ |
| 17 | 주문서 | `HAS_ORDER_DATA` | `has_order_data` (folder) | ✅ |
| 18 | 고객사요청사항 | `CUSTOMER_REQUEST` | `customer_request` | ✅ |
| 19 | 결재상태 | `ORDER_APPR_STATUS` | `order_appr_status` | ✅ |
| 20 | 환종 | `CONTRACT_CURRENCY_NAME` | `contract_currency_name` | ✅ |
| 21 | 환율 | `EXCHANGE_RATE` | `exchange_rate` | ✅ |
| 22 | S/N | `SERIAL_NO` | `serial_no` | ✅ |
| 23 | 품번 | `PART_NO` | `part_no` | ✅ |
| 24 | 작성자 | `WRITER_NAME` | `writer_name` | ✅ |
| 25 | **제품구분** | `PRODUCT_NAME` | `product_name` | ✅ (라인 집계, 신규) |
| 26 | **국내/해외** | `AREA_NAME` | `area_name` | ✅ (신규) |
| 27 | **접수일** | `RECEIPT_DATE` | `receipt_date` | ✅ (신규) |
### 1.2 검색 폼 — wace 활성 9개
| # | wace name | RPS searchForm | 상태 |
|---|---|---|---|
| 1 | `category_cd` (주문유형) | `category_cd` | ✅ |
| 2 | `search_poNo` (발주번호) | `search_poNo` | ✅ |
| 3 | `customer_objid` (고객사) | `customer_objid` | ✅ |
| 4 | `search_partNo` (품번) | `search_partObjId` | ✅ (PartSelect) |
| 5 | `search_partName` (품명) | `search_partName` | ✅ (신규 동기화) |
| 6 | `search_serialNo` (S/N) | `search_serialNo` | ✅ |
| 7 | `contract_result` (수주상태) | `contract_result` | ✅ |
| 8 | `order_start_date`~`order_end_date` (발주일) | `order_start_date`/`order_end_date` | ✅ |
| 9 | `due_start_date`~`due_end_date` (요청납기) | `due_start_date`/`due_end_date` | ✅ |
### 1.3 액션 버튼 — wace 운영
| 버튼 | wace id | RPS 동작 | 상태 |
|---|---|---|---|
| 조회 | `btnSearch` | `fetchList` | ✅ |
| 수주복사 | `btnCopy` | 없음 | 🟡 (낮은 우선순위) |
| 수주입력 | `btnRegist` | 등록 다이얼로그 | ✅ (단, 직접등록 G2 흐름은 별도) |
| 수주확정 | `btnOrderConfirm` | 다이얼로그(상태 select 팝업) → setStatus | ✅ |
| 수주취소 | `btnOrderCancel` | 다이얼로그(라인별 cancel_qty) → saveCancelQty | ✅ |
| 결재상신 | `btnApproval` | placeholder | 🟠 G4 별도 PR |
| 삭제 | `btnDelete` | `salesOrderMgmtApi.remove` | ✅ |
---
## 2. 운영 데이터 코드 체계 (90건 — 견적과 공유)
`contract_mgmt` 테이블은 견적/주문 공용. 1. 견적과 동일 식별자/채번 체계.
주문관리 그리드는 `contract_result` 값으로 단계 식별:
- NULL/빈값 → 견적단계 (견적관리 노출)
- `0000964`(수주) / `0000968`(수주FCST) → 수주확정됨 (G1으로 `project_mgmt` 자동생성)
- `0000965`(Cancel) / `0000966`(Hold)
운영 분포(2026-05-11 기준):
- 26C-0801: 수주 / 26C-0797: 수주 / 26C-0796: 수주(FCST) / 26C-0788: 수주 / 26C-0795: NULL(견적단계)
---
## 3. 갭 처리 결과
| # | 갭 | 처리 |
|---|---|---|
| **O1** | 그리드 누락 3개 (제품구분/국내해외/접수일) | ✅ 본 커밋에서 추가 |
| **O2** | 그리드 제품구분이 헤더 `T.PRODUCT` 기반 → wace는 라인으로 이동 | ✅ `CI_AGG.product_summary`(라인 distinct join)로 변경 + `COALESCE(line, header)` |
| **O3** | searchForm `search_partName` 키 누락 (UI에는 이미 PartSelect mode=partName 존재) | ✅ 키 추가 + 초기화 포함 |
| O4 | 수주복사(btnCopy) 미구현 | 🟡 백로그 |
| O5 | 결재상신 실동작 (G4) | 🟠 별도 PR |
---
## 4. 자동 검증 결과 (`scripts/verify-order.sql`)
| # | 시나리오 | 결과 |
|---|---|---|
| 1 | 그리드 V1 컬럼 SQL (제품구분/국내해외/접수일/수주상태명) | ✅ 26C-0801 = `수주/C/T/국내/2026-05-06` |
| 2 | 수주확정 G1 (`updateStatus + createProjectsFromContract`) | ✅ (`verify-estimate.sql §3`과 동일 결과 — `project_no=R-CT-YYMMDD-NNN`) |
| 3 | 수주취소 cancel_qty UPDATE만, `contract_result` 미변경 | ✅ |
| 4 | 채번 룰 검증 — `{YY}C-{NNNN}` next | ✅ (운영 90건 모두 패턴 일치) |
---
## 5. 결론
주문관리 메뉴 wace 운영 화면과 1:1 정합. 그리드 27/27 컬럼, 검색폼 9/9, 액션 버튼 7/7 (수주복사·결재상신은 백로그).
**다음 메뉴**: 판매관리 (sale) — `project_mgmt` 기반 그리드. 메뉴 단위 사이클 동일 패턴으로 진행.
+82
View File
@@ -0,0 +1,82 @@
# 03. 판매관리 wace 1:1 검증
> 작성: 2026-05-11 / 사이클: 구조적 검증 3차 (판매관리 메뉴)
> 원본: `wace_plm/WebContent/WEB-INF/view/salesmgmt/salesMgmt/salesMgmtList.jsp`
> 대상: `app/(main)/COMPANY_16/sales/sale/page.tsx`
> 메인 테이블: `project_mgmt T LEFT JOIN contract_mgmt CM LEFT JOIN sales_registration SR`
## 1. 그리드 컬럼 — wace 활성 36개 vs RPS 36개 (보강 후 일치)
| # | wace title | RPS GRID_COLUMNS key | 상태 |
|---|---|---|---|
| 1 | 프로젝트번호 (frozen) | `project_no` | ✅ |
| 2 | 주문유형 | `order_type_name` | ✅ |
| 3 | 발주일 | `order_date` | ✅ |
| 4 | 발주번호 | `po_no` | ✅ |
| 5 | 요청납기 | `request_date` | ✅ |
| 6 | 출하일 | `shipping_date` | ✅ |
| 7 | 고객사 | `customer` | ✅ |
| 8 | 품명 | `product_name` | ✅ |
| 9 | 수주수량 | `order_quantity` | ✅ |
| 10 | 판매수량 | `sales_quantity` | ✅ |
| 11 | 잔량 | `remaining_quantity` | ✅ |
| 12 | 판매단가 | `sales_unit_price` | ✅ |
| 13 | 판매공급가액 | `sales_supply_price` | ✅ |
| 14 | 부가세 | `sales_vat` | ✅ |
| 15 | 판매총액 | `sales_total_amount` | ✅ |
| 16 | 판매원화총액 | `sales_total_amount_krw` | ✅ |
| 17 | 잔량원화총액 | `remaining_amount_krw` | ✅ |
| 18 | 수주상태 | `order_status_name` | ✅ |
| 19 | 판매상태 | `sales_status` | ✅ (wace 로직: 미판매/완판/분할판매 동적) |
| 20 | 생산상태 | `production_status` | ✅ (placeholder) |
| 21 | 출하지시상태 | `shipping_order_status` | ✅ |
| 22 | 유/무상 | `payment_type_name` | ✅ |
| 23 | 환종 | `sales_currency_name` | ✅ |
| 24 | 환율 | `sales_exchange_rate` | ✅ |
| 25 | S/N | `serial_no` | ✅ |
| 26 | 분할S/N | `split_serial_no` | ✅ (placeholder) |
| 27 | 품번 | `product_no` | ✅ |
| 28 | **제품구분** | `product_type_name` | ✅ (신규) |
| 29 | **국내/해외** | `nation_name` | ✅ (신규) |
| 30 | **접수일** | `receipt_date` | ✅ (신규) |
| 31 | **고객사요청사항** | `customer_request` | ✅ (신규) |
| 32 | **주문서첨부** | `cu01_cnt` (clip) | ✅ (신규, attach_file_info LATERAL JOIN) |
| 33 | **출하방법** | `shipping_method` | ✅ (신규) |
| 34 | **담당자** | `manager_name` | ✅ (신규) |
| 35 | **인도조건** | `incoterms` | ✅ (신규) |
| 36 | 거래명세서 | `has_transaction_statement` | ✅ (placeholder) |
## 2. SQL 핵심 구조
```
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
LEFT JOIN customer_mng C ON C.customer_code = SUBSTRING(T.customer_objid, 3)
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_* ON CC_*.code_id = T.* AND status='active' (CAT/AREA/PRD/RES/CUR/CUR_S)
LEFT JOIN SR_AGG ON SR_AGG.project_no LIKE T.project_no || '%' (판매수량 합계 — 분할판매 패턴)
LEFT JOIN CIS_AGG ON CIS_AGG.item_objid = T.contract_item_objid (S/N 집계)
LEFT JOIN AF ON AF.target_objid = T.contract_objid (주문서첨부 신규)
```
## 3. 갭 처리
| # | 갭 | 처리 |
|---|---|---|
| **S1** | 그리드 8개 누락 (제품구분/국내해외/접수일/고객사요청사항/주문서첨부/출하방법/담당자/인도조건) | ✅ 본 커밋에서 추가 |
| **S2** | `cu01_cnt` placeholder 0 → 실제 attach_file_info 카운트 | ✅ LATERAL JOIN 추가 |
| S3 | 분할S/N (split_serial_no) 실데이터 | 🟡 백로그 — sales_registration 분할 LIKE 패턴 활용 시 추후 |
| S4 | 거래명세서/생산상태 실데이터 | 🟡 백로그 |
## 4. 자동 검증 결과 (`scripts/verify-sale.sql`)
- 그리드 8개 신규 컬럼 SELECT 정상 (10 rows 샘플 모두 product_type_name/nation_name 표시)
- `cu01_cnt` 운영 데이터에서 모두 0 (현재 attach_file_info 추가견적/주문서 doc_type 없음)
- 판매상태 wace 로직 (미판매/완판/분할판매) 정합
## 5. 결론
판매관리 메뉴 wace 운영 화면과 1:1 정합 (36/36 컬럼).
**다음 메뉴**: 매출관리 (revenue) — `shipment_log` 기반.
+76
View File
@@ -0,0 +1,76 @@
# 04. 매출관리 wace 1:1 검증
> 작성: 2026-05-11 / 사이클: 구조적 검증 4차 (매출관리 메뉴)
> 원본: `wace_plm/WebContent/WEB-INF/view/salesmgmt/salesMgmt/revenueMgmtList.jsp`
> 대상: `app/(main)/COMPANY_16/sales/revenue/page.tsx`
> 핵심 필터: `EXISTS (sales_registration WHERE shipping_date IS NOT NULL)` — 출하등록된 프로젝트만
## 1. 그리드 컬럼 — wace 활성 35개 vs RPS 35개 (보강 후 일치)
| # | wace title | RPS GRID_COLUMNS | 상태 |
|---|---|---|---|
| 1 | 프로젝트번호 (frozen) | `project_no` | ✅ |
| 2 | 주문유형 | `order_type_name` | ✅ |
| 3 | 매출마감 | `sales_deadline_date` | ✅ |
| 4 | 발주일 | `order_date` | ✅ |
| 5 | 발주번호 | `po_no` | ✅ |
| 6 | 고객사 | `customer` | ✅ |
| 7 | 제품구분 | `product_type_name` | ✅ |
| 8 | 품명 | `product_name` | ✅ |
| 9 | 수량 | `sales_quantity` | ✅ |
| 10 | 단가 | `sales_unit_price` | ✅ |
| 11 | 공급가액 | `sales_supply_price` | ✅ |
| 12 | 부가세 | `sales_vat` | ✅ |
| 13 | 총액 | `sales_total_amount` | ✅ |
| 14 | 원화총액 | `sales_total_amount_krw` | ✅ |
| 15 | 출하일 | `shipping_date` | ✅ |
| 16 | 국내/해외 | `nation_name` | ✅ |
| 17 | 환종 | `sales_currency_name` | ✅ |
| 18 | 환율 | `sales_exchange_rate` | ✅ |
| 19 | S/N | `serial_no` | ✅ |
| 20 | 분할S/N | `split_serial_no` | ✅ (placeholder) |
| 21 | 품번 | `product_no` | ✅ |
| 22 | 과세구분 | `tax_type` | ✅ |
| 23 | 세금계산서발행일 | `tax_invoice_date` | ✅ |
| 24 | 수출신고필증신고번호 | `export_decl_no` | ✅ |
| 25 | 선적일자 | `loading_date` | ✅ |
| 26 | 거래명세서 | `has_transaction_statement` | ✅ (placeholder) |
| 27 | **접수일** | `receipt_date` | ✅ (신규) |
| 28 | **유/무상** | `payment_type_name` | ✅ (신규) |
| 29 | **요청납기** | `request_date` | ✅ (신규) |
| 30 | **고객사요청사항** | `customer_request` | ✅ (신규) |
| 31 | **수주상태** | `order_status_name` | ✅ (신규) |
| 32 | **주문서첨부** | `cu01_cnt` (clip) | ✅ (신규, attach_file_info LATERAL JOIN) |
| 33 | **출하방법** | `shipping_method` | ✅ (신규) |
| 34 | **담당자** | `manager_name` | ✅ (신규) |
| 35 | **인도조건** | `incoterms` | ✅ (신규) |
## 2. SQL 보강
```
-- 추가된 JOIN
LEFT JOIN contract_item CI ON CI.objid = T.contract_item_objid AND CI.status='ACTIVE' -- request_date COALESCE 위해
LEFT JOIN user_info U_MGR ON U_MGR.user_id = SR.manager_user_id -- manager_name
LEFT JOIN comm_code CC_RES ON CC_RES.code_id = T.contract_result AND CC_RES.status='active' -- order_status_name
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 -- cu01_cnt
```
## 3. 갭 처리
| # | 갭 | 처리 |
|---|---|---|
| **R1** | 그리드 9개 누락 | ✅ 본 커밋에서 추가 |
| **R2** | request_date / order_status_name / manager_name / cu01_cnt SELECT 누락 | ✅ JOIN 추가 |
| R3 | 분할S/N / 거래명세서 실데이터 | 🟡 백로그 |
| R4 | 매출관리 그리드 운영 0건 (sales_registration.shipping_date IS NOT NULL 0건) | 🟢 데이터 이슈 — 출하등록 흐름 신설 시 자연 해소 |
## 4. 자동 검증 결과 (`scripts/verify-revenue.sql`)
운영 sales_registration.shipping_date 0건이라 매출관리 본 필터로는 0 rows. SQL 컬럼 정합성은 필터 해제 샘플로 검증 — 9개 신규 컬럼 모두 정상 반환.
## 5. 결론
매출관리 메뉴 wace 운영 화면과 1:1 정합 (35/35 컬럼). 영업 4개 메뉴 (견적·주문·판매·매출) **모두 구조적 검증 1차 종결**.
@@ -0,0 +1,52 @@
# 05. PR-B G2 — 주문관리 직접등록 통합폼 (`is_direct_order='Y'`)
> 작성: 2026-05-11
> 원본: `wace_plm/WebContent/WEB-INF/view/contractMgmt/estimateAndOrderRegistFormPopup.jsp` + `ContractMgmtService.saveEstimateAndOrderInfo` (라인 2664~)
> 대상: `app/(main)/COMPANY_16/sales/order/page.tsx` "수주입력" 다이얼로그 + `POST /api/sales/order-mgmt`
## 1. wace 패턴
견적 없이 주문을 바로 등록하는 통합폼. 핵심 차이:
- `contract_mgmt.is_direct_order = 'Y'` 강제 (견적관리 그리드 노출 X — 견적관리 SQL이 `IS_DIRECT_ORDER != 'Y'` 필터)
- 주문관리 그리드엔 노출
- 헤더에 발주일(`order_date`) / 발주번호(`po_no`) 추가
- 라인에 `ORDER_*` 컬럼 (수주수량/단가/공급가액/부가세/총액) 입력
- 라인의 `quantity = order_quantity` 미러링 (wace 통합폼은 견적수량 별도 입력 X)
## 2. 운영 데이터 검증
```
contract_mgmt 90건 중 is_direct_order='Y' = 74건 (82%)
```
→ 운영 주문은 절대다수가 직접등록 통합폼으로 작성. G2가 주문 신규 등록의 기본 흐름.
## 3. RPS 변경
### 백엔드 [salesOrderMgmtService.ts]
- `OrderBody` 타입: `order_date` / `approval_required` / `is_direct_order` 추가, `contract_date` 폐지 (운영 컬럼은 `order_date`)
- `create()`: INSERT에 `is_direct_order` (default 'Y') / `order_date` / `approval_required` 추가
- `update()`: 동일 컬럼 UPDATE
- `upsertItems`: 기존 `ORDER_*` 처리 그대로 (이미 G2 호환)
### 프론트 [order/page.tsx] + [salesOrderMgmt.ts]
- `OrderBody`: `order_date`/`approval_required`/`is_direct_order` 보강, `contract_date` 폐지
- `openCreate()`: `order_date = today` + `is_direct_order = 'Y'` 기본값
- `openEdit()`: `order_date`/`approval_required`/`is_direct_order` detail에서 복원
- 폼 다이얼로그: "발주일" 입력을 `order_date` 바인딩 (이전 `contract_date` 잘못 바인딩 정정)
## 4. 자동 검증 결과 (BEGIN/ROLLBACK)
| 항목 | 결과 |
|---|---|
| `is_direct_order='Y'` INSERT | ✅ |
| `order_date` 컬럼에 저장 | ✅ (`2026-05-11`) |
| `ORDER_*` 라인 컬럼 (qty/unit_price/total_amount) | ✅ |
| 견적관리 노출 차단 (필터 `is_direct_order != 'Y'`) | ✅ (0 rows) |
| 주문관리 노출 | ✅ (1 row) |
## 5. 결론
기존 RPS `create/update`가 거의 G2 호환이라 신규 endpoint 없이 컬럼 보강(is_direct_order/order_date/approval_required)으로 처리. 분리 endpoint 불필요.
다음 단계: G5 견적작성 PDF 또는 G4 결재 모듈.
+4 -3
View File
@@ -141,9 +141,10 @@ import { useAuth } from "@/hooks/useAuth";
## 7. 다음 작업
1. **운영 DB 접속해서 누락 테이블 DDL 추출** (`estimate_template`, `estimate_template_item`, `sales_registration`)
2. [01-estimate.md](./01-estimate.md) 견적관리 상세 매핑 작성 → 코드 시작
3. 마스터 매핑 테이블 설계 (`legacy_id_map`)
1. ~~운영 DB DDL 추출~~ 완료 (2026-05-07)
2. ~~01~04 상세 매핑 + 1차 이식~~ 완료 (2026-05-08)
3. **[00-gap.md](./00-gap.md) 우선** — 원본 흐름 10단계 vs 이식본 GAP 매트릭스. 다음 PR(A: 수주확정→프로젝트 자동생성, B: 직접등록 통합폼, C: 결재·메일·PDF) 합의 문서.
4. PR-A부터 착수: `salesOrderMgmtService.updateStatus`에 project_mgmt 자동생성 + project_no 채번 로직 이식.
## 8. 공통 UX 규칙 (검색 폼 / 영업관리 4개 메뉴 동일 적용)
@@ -0,0 +1,38 @@
-- ============================================================
-- part_mng — wace 품목 마스터 데이터 채움 (영업/개발 메뉴 공용)
-- ----------------------------------------------------------------
-- 배경:
-- - part_mng 테이블은 이미 vexplor_rps에 존재 (스키마는 wace 운영 part_mng와 일치)
-- 스키마: objid bigint, part_no, part_name, part_type, status, writer, regdate, company_code
-- - 데이터는 0건이라 비어있음. 다른 RPS 모듈(ecrMngService, wacePlmDataImportService)도 part_mng 사용 가정으로 만들어져 있음.
-- - item_info에는 wace 마이그레이션(numeric id 8,179건) + RPS 자체 등록(UUID 20k+)이 섞여 있음.
-- wace 데이터만 part_mng로 옮기고, wace 도메인 메뉴는 part_mng를 참조.
-- ----------------------------------------------------------------
-- 작업: item_info의 wace numeric id 8,179건을 컬럼명 매핑해서 part_mng에 INSERT
-- item_info.id (varchar) → part_mng.objid (bigint)
-- item_info.item_number → part_mng.part_no
-- item_info.item_name → part_mng.part_name
-- item_info.type/division → part_mng.part_type (있으면)
-- item_info.status → part_mng.status
-- item_info.writer → part_mng.writer
-- item_info.created_date → part_mng.regdate
-- item_info.company_code → part_mng.company_code
-- ============================================================
INSERT INTO part_mng (objid, part_no, part_name, part_type, status, writer, regdate, company_code)
SELECT
CAST(id AS bigint),
item_number,
item_name,
COALESCE(NULLIF(type, ''), NULLIF(division, '')),
LOWER(COALESCE(NULLIF(status, ''), 'active')),
writer,
created_date,
company_code
FROM item_info
WHERE id ~ '^-?[0-9]+$'
AND item_number IS NOT NULL AND item_number <> ''
AND item_name IS NOT NULL AND item_name <> ''
ON CONFLICT (objid) DO NOTHING;
COMMENT ON TABLE part_mng IS 'wace 품목 마스터 (영업/개발 메뉴 공용) — item_info의 wace numeric id 데이터 분리';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,760 @@
"use client";
// ============================================================
// 영업관리 > 견적관리 > 견적작성(일반)
// wace estimateTemplate1.jsp 1:1 이식 (template_type='1')
// 진입: 견적관리 그리드 행 선택 → "견적작성" → "일반 견적서"
// ============================================================
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
import { salesEstimateApi, EstimateTemplateItemRow } from "@/lib/api/salesEstimate";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { useAuth } from "@/hooks/useAuth";
// ─── 포맷 헬퍼 (wace addComma / getCurrencySymbol 1:1) ────────
function addComma(num: number | string): string {
if (num === "" || num == null) return "";
const n = Number(String(num).replace(/,/g, ""));
if (Number.isNaN(n)) return "";
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function getCurrencySymbol(currencyName: string): string {
const c = currencyName ?? "";
if (c.indexOf("달러") >= 0 || c === "USD") return "$";
if (c.indexOf("유로") >= 0 || c === "EUR") return "€";
if (c.indexOf("엔") >= 0 || c === "JPY") return "¥";
if (c.indexOf("위안") >= 0 || c === "CNY") return "¥";
return "₩";
}
// 단가 입력 시 실시간 콤마 처리 (wace input 이벤트 1:1)
function formatPriceInput(raw: string): string {
const cleaned = raw.replace(/,/g, "").replace(/[^0-9.]/g, "");
if (cleaned === "") return "";
return addComma(cleaned);
}
// 라인 1행 상태
interface ItemRow {
rowId: string; // React key 용 클라이언트 ID
partObjid: string;
description: string; // 품명 (readonly)
specification: string;
quantity: string; // 숫자만 (콤마 없음)
unit: string;
unitPrice: string; // 표시용 (콤마 포함)
amount: string; // 표시용 (통화기호 + 콤마)
note: string;
}
function emptyRow(): ItemRow {
return {
rowId: `r_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
partObjid: "",
description: "",
specification: "",
quantity: "",
unit: "EA",
unitPrice: "",
amount: "",
note: "",
};
}
// ─── 페이지 ──────────────────────────────────────────────────
export default function EstimateTemplate1Page() {
const params = useParams<{ contractObjid: string }>();
const searchParams = useSearchParams();
const router = useRouter();
const { user } = useAuth();
const contractObjidParam = params?.contractObjid ?? "";
const templateObjidParam = searchParams?.get("templateObjid") ?? "";
// 환율/통화 (영업 정보에서 로드)
const [exchangeRate, setExchangeRate] = useState<number>(1);
const [currencyName, setCurrencyName] = useState<string>("KRW");
const currencySymbol = getCurrencySymbol(currencyName);
// 헤더 필드
const [executor, setExecutor] = useState<string>(""); // 시행일자 (YYYY-MM-DD)
const executorDateRef = useRef<HTMLInputElement>(null);
const [recipient, setRecipient] = useState<string>(""); // 수신처 (customer_objid)
const [estimateNo, setEstimateNo] = useState<string>("");
const [contactPerson, setContactPerson] = useState<string>("");
const [greetingText, setGreetingText] = useState<string>(
"견적을 요청해 주셔서 대단히 감사합니다.\n하기와 같이 견적서를 제출합니다.",
);
const [managerName, setManagerName] = useState<string>("");
const [managerContact, setManagerContact] = useState<string>("");
const [noteRemarks, setNoteRemarks] = useState<string>("");
const [note1, setNote1] = useState<string>("1. 견적유효기간: 일");
const [note2, setNote2] = useState<string>("2. 납품기간: 발주 후 1주 이내");
const [note3, setNote3] = useState<string>("3. VAT 별도");
const [note4, setNote4] = useState<string>("4. 결제 조건 : 기존 결제조건에 따름.");
const [showTotalRow, setShowTotalRow] = useState<boolean>(true);
// 결재상태 (운영 amaranth_approval 없으면 '작성중'으로 폴백)
const [apprStatus, setApprStatus] = useState<string>("작성중");
const readOnly = apprStatus === "결재완료" || apprStatus === "결재중";
// 라인
const [items, setItems] = useState<ItemRow[]>([emptyRow(), emptyRow()]);
// 수정 모드용 templateObjid (저장 후 갱신)
const [templateObjid, setTemplateObjid] = useState<string>(templateObjidParam);
// 데이터 로드 완료 플래그
const [loading, setLoading] = useState<boolean>(true);
const [contractObjid, setContractObjid] = useState<string>(contractObjidParam);
// ─── 합계 계산 (items 또는 환율 변경 시) ──────────────────────
const { totalAmountNum, totalAmountKrwNum } = useMemo(() => {
const total = items.reduce((sum, r) => {
const a = parseFloat((r.amount ?? "").replace(/[^0-9.]/g, "")) || 0;
return sum + a;
}, 0);
return { totalAmountNum: total, totalAmountKrwNum: total * (exchangeRate || 1) };
}, [items, exchangeRate]);
const totalAmountStr = `${currencySymbol}${addComma(totalAmountNum)}`;
const totalAmountKrwStr = `${addComma(totalAmountKrwNum)}`;
// ─── 라인 수정 핸들러 ─────────────────────────────────────────
function updateItem(idx: number, patch: Partial<ItemRow>) {
setItems(prev => {
const next = [...prev];
const cur = { ...next[idx], ...patch };
// 수량/단가 변경 시 금액 자동 계산 (wace fn_calculateAmount)
if (patch.quantity != null || patch.unitPrice != null) {
const qty = parseFloat((cur.quantity ?? "").replace(/,/g, "")) || 0;
const price = parseFloat((cur.unitPrice ?? "").replace(/,/g, "")) || 0;
const amt = qty * price;
cur.amount = Number.isFinite(amt) && amt !== 0 ? `${currencySymbol}${addComma(amt)}` : "";
}
next[idx] = cur;
return next;
});
}
function addItemRow() {
setItems(prev => [...prev, emptyRow()]);
}
function removeItemRow(idx: number) {
setItems(prev => prev.filter((_, i) => i !== idx));
}
// ─── 데이터 로드 ────────────────────────────────────────────
useEffect(() => {
let cancel = false;
(async () => {
try {
// 1) 영업 정보 로드 (헤더의 customer_objid, 통화, 환율, contract_item 라인)
let contractInfo: any = null;
if (contractObjidParam) {
try {
contractInfo = await salesEstimateApi.detail(contractObjidParam);
} catch (e) {
console.warn("영업정보 로드 실패", e);
}
}
// getById 응답: { ...contract_mgmt 헤더 필드들, items: [...] } (평면 구조)
if (contractInfo && !cancel) {
setExchangeRate(parseFloat(contractInfo.exchange_rate || "1") || 1);
setCurrencyName(contractInfo.contract_currency_name || contractInfo.contract_currency || "KRW");
// 수신처는 customer_objid (예: 'C_RPS001') — 견적요청의 고객사를 견적서에 자동 채움
if (contractInfo.customer_objid) setRecipient(contractInfo.customer_objid);
}
// 2) 기존 견적 차수 수정 (templateObjid 지정) — 우선
if (templateObjidParam) {
const tpl = await salesEstimateApi.getTemplate(templateObjidParam);
if (tpl && !cancel) {
if (tpl.contract_objid) setContractObjid(tpl.contract_objid);
setExchangeRate(parseFloat(tpl.exchange_rate || "1") || 1);
setCurrencyName(tpl.contract_currency_name || tpl.contract_currency || "KRW");
setExecutor(tpl.executor ?? "");
if (tpl.recipient) setRecipient(tpl.recipient);
setEstimateNo(tpl.estimate_no ?? "");
setContactPerson(tpl.contact_person ?? "");
if (tpl.greeting_text) setGreetingText(tpl.greeting_text);
if (tpl.manager_name) setManagerName(tpl.manager_name);
if (tpl.manager_contact) setManagerContact(tpl.manager_contact);
setNoteRemarks(tpl.note_remarks ?? "");
if (tpl.note1) setNote1(tpl.note1);
if (tpl.note2) setNote2(tpl.note2);
if (tpl.note3) setNote3(tpl.note3);
if (tpl.note4) setNote4(tpl.note4);
setShowTotalRow((tpl.show_total_row ?? "Y") !== "N");
const loaded: ItemRow[] = (tpl.items ?? []).map(it => ({
rowId: `t_${(it as any).objid ?? Math.random().toString(36).slice(2, 8)}`,
partObjid: it.part_objid ?? "",
description: it.description ?? "",
specification: it.specification ?? "",
quantity: (it.quantity ?? "").toString(),
unit: it.unit ?? "EA",
unitPrice: it.unit_price ? addComma(it.unit_price) : "",
amount: it.amount
? `${getCurrencySymbol(tpl.contract_currency_name || tpl.contract_currency || "KRW")}${addComma(it.amount)}`
: "",
note: it.note ?? "",
}));
if (loaded.length > 0) setItems(loaded);
}
} else if (contractInfo?.items?.length) {
// 3) 신규 작성: 영업정보의 contract_item 품목을 기본 라인으로 깔아줌 (wace fn_loadContractItems)
const loaded: ItemRow[] = contractInfo.items.map((it: any) => ({
rowId: `c_${it.objid ?? Math.random().toString(36).slice(2, 8)}`,
partObjid: it.part_objid ?? "",
description: it.master_part_name ?? it.part_name ?? "",
specification: "",
quantity: (it.quantity ?? "").toString(),
unit: "EA",
unitPrice: "",
amount: "",
note: "",
}));
if (!cancel && loaded.length > 0) setItems(loaded);
}
// 4) 로그인 사용자 정보 — 담당자 이름/연락처 자동 채움 (기존 값 없을 때만)
// (백엔드에 별도 user 조회 API가 있으면 사용; 일단 비워두고 수동 입력 허용)
} finally {
if (!cancel) setLoading(false);
}
})();
return () => { cancel = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contractObjidParam, templateObjidParam]);
// 로그인 사용자 정보로 담당자/연락처 자동 채움 (wace estimateTemplate1.jsp:321-322)
// — 신규 작성 시에만, 그리고 사용자가 아직 직접 입력하지 않은 경우(빈값)에만 채움
useEffect(() => {
if (!user) return;
if (templateObjidParam) return; // 기존 견적서 수정 모드는 건드리지 않음
setManagerName(prev => prev || `${user.deptName ?? ""} ${user.userName ?? ""}`.trim());
// wace MailUtil 패턴: cell_phone 우선, 없으면 tel
setManagerContact(prev => prev || user.cellPhone || user.tel || "");
}, [user, templateObjidParam]);
// ─── 저장 (wace fn_save 1:1) ─────────────────────────────────
async function handleSave() {
if (!contractObjid) {
alert("견적서를 저장할 수 없습니다. 영업정보가 없습니다.");
return;
}
if (!confirm("견적서를 저장하시겠습니까?")) return;
// 라인 → 페이로드 (콤마/통화기호 제거)
const itemsBody: EstimateTemplateItemRow[] = items.map((r, idx) => ({
seq: idx + 1,
part_objid: r.partObjid || null,
description: r.description || "",
specification: r.specification || "",
quantity: (r.quantity || "").replace(/[^0-9]/g, ""),
unit: r.unit || "",
unit_price: (r.unitPrice || "").replace(/[^0-9.]/g, ""),
amount: (r.amount || "").replace(/[^0-9.]/g, ""),
note: r.note || "",
}));
try {
const data = await salesEstimateApi.saveTemplate1({
contract_objid: contractObjid,
template_objid: templateObjid || undefined,
executor,
recipient,
estimate_no: estimateNo,
contact_person: contactPerson,
greeting_text: greetingText,
manager_name: managerName,
manager_contact: managerContact,
note_remarks: noteRemarks,
note1, note2, note3, note4,
show_total_row: showTotalRow ? "Y" : "N",
total_amount: String(totalAmountNum),
total_amount_krw: String(totalAmountKrwNum),
items: itemsBody,
});
// 저장 성공 → templateObjid 동기화 (수정 모드 유지)
if (data?.templateObjid) setTemplateObjid(data.templateObjid);
alert("저장되었습니다.");
// opener 새로고침 — wace는 window.opener.fn_search() 호출. RPS는 새 탭/창이라 별도 처리 없음.
} catch (e: any) {
console.error("저장 오류", e);
alert("저장에 실패했습니다.\n" + (e?.response?.data?.message ?? e?.message ?? ""));
}
}
function handlePrint() {
window.print();
}
// PDF 다운로드 — wace fn_generatePdf 1:1 (클라이언트 html2canvas + jsPDF)
async function handleDownloadPdf() {
if (!confirm("PDF로 다운로드 하시겠습니까?")) return;
try {
// html2canvas-pro: oklab/oklch 등 모던 CSS color function 지원 (Tailwind 4 호환)
const html2canvas = (await import("html2canvas-pro")).default;
const { jsPDF } = await import("jspdf");
const container = document.querySelector(".estimate-container") as HTMLElement | null;
if (!container) return;
const canvas = await html2canvas(container, {
scale: 2,
useCORS: true,
logging: false,
backgroundColor: "#ffffff",
// 클론된 DOM에서 input/textarea/select → 텍스트 노드로 교체 (글자 잘림 방지)
onclone: (doc) => {
const replaceWithText = (el: HTMLElement, text: string) => {
const span = doc.createElement("span");
span.textContent = text;
const style = el.getAttribute("style") || "";
span.setAttribute("style", style + ";display:inline-block;white-space:pre-wrap;");
el.parentNode?.replaceChild(span, el);
};
doc.querySelectorAll<HTMLInputElement>('input[type="text"],input[type="date"]').forEach(el => replaceWithText(el, el.value || ""));
doc.querySelectorAll<HTMLTextAreaElement>("textarea").forEach(el => replaceWithText(el, el.value || ""));
doc.querySelectorAll<HTMLSelectElement>("select").forEach(el => {
const opt = el.options[el.selectedIndex];
replaceWithText(el as unknown as HTMLElement, opt?.text || "");
});
// 인쇄 비대상 요소 숨김
doc.querySelectorAll<HTMLElement>(".no-print, .btn-area, .delete-btn-cell button").forEach(el => { el.style.display = "none"; });
},
});
const imgData = canvas.toDataURL("image/jpeg", 0.85);
const pdf = new jsPDF("p", "mm", "a4");
const imgWidth = 210, pageHeight = 297;
const imgHeight = canvas.height * imgWidth / canvas.width;
let heightLeft = imgHeight;
let position = 0;
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
heightLeft -= pageHeight;
while (heightLeft > 1) {
position = heightLeft - imgHeight;
pdf.addPage();
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
heightLeft -= pageHeight;
}
const fileName = (estimateNo || "견적서") + ".pdf";
pdf.save(fileName);
} catch (e: any) {
console.error("PDF 생성 오류", e);
alert("PDF 생성 중 오류가 발생했습니다.\n" + (e?.message ?? ""));
}
}
function handleClose() {
// 새 탭으로 열린 경우 닫기 시도 + 안되면 router back
if (window.opener) {
window.close();
} else {
router.back();
}
}
if (loading) return <div style={{ padding: 40 }}> ...</div>;
// ─── 렌더링 (wace 마크업 1:1) ────────────────────────────────
return (
<div className="estimate-page">
<style jsx global>{`
@media print {
@page { size: A4; margin: 10mm; }
body { margin: 0; padding: 0; }
.no-print { display: none !important; }
.delete-btn-cell button { display: none !important; }
}
body {
font-family: "Malgun Gothic", "맑은 고딕", Arial, sans-serif;
font-size: 12pt;
background-color: #f5f5f5;
}
/* 견적서는 인쇄용 양식 — 다크모드와 무관하게 항상 흰 배경/검정 텍스트 */
html.dark .estimate-page, html[data-theme="dark"] .estimate-page,
.estimate-page { color: #000; background-color: #f5f5f5; min-height: 100vh; }
.estimate-page .estimate-container,
.estimate-page .estimate-container * { color: #000; }
.estimate-container {
width: 210mm;
min-height: 297mm;
background: white;
margin: 0 auto;
padding: 20mm;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
box-sizing: border-box;
}
.estimate-title {
text-align: center;
font-size: 28pt;
font-weight: bold;
letter-spacing: 20px;
margin-bottom: 40px;
padding: 10px 0;
}
.info-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 30px;
}
.info-table td { padding: 5px 8px; border: 1px solid #000; font-size: 9pt; }
.info-table .label { background-color: #f0f0f0; font-weight: bold; width: 80px; text-align: center; }
.items-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 30px;
table-layout: fixed;
}
.items-table th, .items-table td {
border: 1px solid #000;
padding: 8px 2px;
text-align: center;
vertical-align: middle;
font-size: 9pt;
line-height: 1.3;
}
.items-table th { background-color: #f0f0f0; font-weight: bold; }
.items-table .col-no { width: 4%; }
.items-table .col-desc { width: 12%; }
.items-table .col-spec { width: 22%; }
.items-table .col-qty { width: 4%; }
.items-table .col-unit { width: 5%; }
.items-table .col-price { width: 14%; }
.items-table .col-amount { width: 15%; }
.items-table .col-note { width: 9%; white-space: normal; word-break: break-all; }
.items-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.items-table .text-left { text-align: left; }
.items-table .text-right { text-align: right; overflow: hidden; }
.estimate-page input[type="text"],
.estimate-page input[type="date"],
.estimate-page textarea,
.estimate-page select {
border: none;
outline: none;
background: transparent;
width: 100%;
font-family: inherit;
font-size: inherit;
box-sizing: border-box;
color: #000;
}
.estimate-page input[readonly], .estimate-page textarea[readonly] { color: #000; }
.estimate-page textarea {
resize: none;
height: 1.3em;
min-height: auto;
padding: 0 2px;
white-space: nowrap;
overflow: hidden;
}
.estimate-page .item-price, .estimate-page .item-amount { text-align: right; }
.estimate-page .item-amount { pointer-events: none; }
.btn-area {
position: fixed; bottom: 0; left: 0; right: 0;
text-align: right;
padding: 15px 30px;
background-color: #ffffff;
border-top: 3px solid #007bff;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
z-index: 1000;
}
.estimate-btn {
display: inline-block;
padding: 5px 15px;
margin: 0 2px;
font-size: 12px;
cursor: pointer;
border: 1px solid #60a5fa;
background-color: #60a5fa;
color: white;
border-radius: 3px;
line-height: 1;
white-space: nowrap;
}
.estimate-btn:hover { background-color: #1e3a8a; border-color: #1e3a8a; }
.estimate-btn[disabled] { background-color: #cbd5e1; border-color: #cbd5e1; cursor: not-allowed; }
.company-stamp-placeholder {
width: 100%; min-height: 200px;
display: flex; align-items: center; justify-content: center;
border: 1px dashed #ccc; color: #666; font-size: 9pt;
}
.editable-input { background-color: transparent; }
.readonly-input { background-color: #f5f5f5; }
`}</style>
<div className="estimate-container" style={{ paddingBottom: 100 }}>
{/* 제목 */}
<div className="estimate-title">&nbsp;&nbsp;&nbsp;&nbsp;</div>
{/* 상단 정보 테이블 */}
<table className="info-table">
<colgroup>
<col width="80px" />
<col width="*" />
<col width="50px" />
<col width="300px" />
</colgroup>
<tbody>
<tr>
<td className="label"></td>
<td>
{/* 표시는 YYYY-MM-DD (text), 클릭 시 숨겨진 type=date의 showPicker 트리거 */}
<div style={{ position: "relative", display: "inline-block", width: 150 }}>
<input
type="text"
value={executor}
readOnly
placeholder="YYYY-MM-DD"
onClick={() => { if (!readOnly) executorDateRef.current?.showPicker?.(); }}
style={{ width: "100%", cursor: readOnly ? "default" : "pointer" }}
/>
<input
ref={executorDateRef}
type="date"
value={executor}
onChange={e => setExecutor(e.target.value)}
tabIndex={-1}
aria-hidden="true"
style={{ position: "absolute", left: 0, top: 0, width: 1, height: 1, opacity: 0, pointerEvents: "none" }}
/>
</div>
</td>
<td rowSpan={4} style={{ border: "none" }}></td>
<td rowSpan={4} style={{ textAlign: "center", border: "none", verticalAlign: "middle", padding: 0 }}>
<div style={{ width: "100%", textAlign: "center", marginBottom: 5 }}>
<img
src="/images/company_stamp.png"
alt="회사 도장"
style={{ width: "100%", height: "auto" }}
onError={(e) => {
const img = e.currentTarget;
img.style.display = "none";
const fb = img.nextElementSibling as HTMLElement | null;
if (fb) fb.style.display = "flex";
}}
/>
<div className="company-stamp-placeholder" style={{ display: "none" }}>
<br />RPS CO., LTD<br />
</div>
</div>
</td>
</tr>
<tr>
<td className="label"></td>
<td>
<CustomerSelect
value={recipient}
onValueChange={setRecipient}
placeholder="고객사 선택"
disabled={readOnly}
/>
</td>
</tr>
<tr>
<td className="label"></td>
<td>
<input
type="text"
value={contactPerson}
onChange={e => setContactPerson(e.target.value)}
readOnly={readOnly}
placeholder="OO 귀하"
/>
</td>
</tr>
<tr>
<td className="label"></td>
<td>
<input
type="text"
value={estimateNo}
onChange={e => setEstimateNo(e.target.value)}
readOnly={readOnly}
/>
</td>
</tr>
</tbody>
</table>
{/* 인사말 + 담당자 + 부가세 별도 */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 10, padding: "0 5px" }}>
<div style={{ lineHeight: 1.6, fontSize: "10pt", whiteSpace: "pre-line" }}>
{greetingText}
</div>
<div style={{ textAlign: "right", fontSize: "9pt", lineHeight: 1.8 }}>
:{" "}
<input
type="text"
value={managerName}
onChange={e => setManagerName(e.target.value)}
readOnly={readOnly}
style={{ width: 120, borderBottom: "1px solid #ddd", fontSize: "9pt", padding: 2, backgroundColor: "#f5f5f5" }}
/><br/>
:{" "}
<input
type="text"
value={managerContact}
onChange={e => setManagerContact(e.target.value)}
readOnly={readOnly}
style={{ width: 120, borderBottom: "1px solid #ddd", fontSize: "9pt", padding: 2, backgroundColor: "#f5f5f5" }}
/><br/><br/>
<span style={{ fontSize: "10pt", marginTop: 5, display: "inline-block" }}> </span>
</div>
</div>
{/* 품목 테이블 */}
<table className="items-table">
<thead>
<tr>
<th className="col-no"><br/>NO.</th>
<th className="col-desc"> <br/>DESCRIPTION</th>
<th className="col-spec"> <br/>SPECIFICATION</th>
<th className="col-qty"><br/>Q'TY</th>
<th className="col-unit"><br/>UNIT</th>
<th className="col-price"> <br/>UNIT PRICE</th>
<th className="col-amount"> <br/>AMOUNT</th>
<th className="col-note"></th>
</tr>
</thead>
<tbody>
{items.map((r, idx) => (
<tr key={r.rowId}>
<td>{idx + 1}</td>
<td className="text-left">
<input type="text" value={r.description} readOnly className="readonly-input" />
</td>
<td className="text-left">
<textarea
value={r.specification}
onChange={e => updateItem(idx, { specification: e.target.value })}
readOnly={readOnly}
/>
</td>
<td>
<input
type="text"
value={r.quantity}
onChange={e => updateItem(idx, { quantity: e.target.value.replace(/[^0-9]/g, "") })}
readOnly={readOnly}
/>
</td>
<td>
<input
type="text"
value={r.unit}
onChange={e => updateItem(idx, { unit: e.target.value })}
readOnly={readOnly}
/>
</td>
<td className="text-right">
<input
type="text"
value={r.unitPrice}
onChange={e => updateItem(idx, { unitPrice: formatPriceInput(e.target.value) })}
readOnly={readOnly}
/>
</td>
<td className="text-right">
<input type="text" value={r.amount} readOnly />
</td>
<td>
<input
type="text"
value={r.note}
onChange={e => updateItem(idx, { note: e.target.value })}
readOnly={readOnly}
/>
</td>
</tr>
))}
{/* 계 행 */}
{showTotalRow && (
<tr className="total-row">
<td colSpan={6} style={{ textAlign: "center", fontWeight: "bold", backgroundColor: "#f0f0f0" }}></td>
<td className="text-right" style={{ fontWeight: "bold", backgroundColor: "#f0f0f0" }}>{totalAmountStr}</td>
<td className="delete-btn-cell" style={{ backgroundColor: "#f0f0f0", textAlign: "center" }}>
{!readOnly && (
<button
type="button"
style={{ padding: "2px 8px", fontSize: "9pt", cursor: "pointer" }}
onClick={() => { if (confirm("계 행을 삭제하시겠습니까?")) setShowTotalRow(false); }}
></button>
)}
</td>
</tr>
)}
{/* 원화환산 (KRW 외 통화 시만 표시) */}
{currencyName && currencyName !== "KRW" && !currencyName.includes("원") && (
<tr className="total-krw-row">
<td colSpan={6} style={{ textAlign: "center", fontWeight: "bold", backgroundColor: "#e8f4f8" }}> (KRW)</td>
<td className="text-right" style={{ fontWeight: "bold", backgroundColor: "#e8f4f8" }}>{totalAmountKrwStr}</td>
<td style={{ backgroundColor: "#e8f4f8" }}></td>
</tr>
)}
{/* 비고 */}
<tr className="remarks-row">
<td colSpan={8} style={{ height: 100, verticalAlign: "top", padding: 10, textAlign: "left" }}>
<div style={{ fontWeight: "bold", marginBottom: 10 }}>&lt;&gt;</div>
<textarea
value={noteRemarks}
onChange={e => setNoteRemarks(e.target.value)}
readOnly={readOnly}
style={{ width: "100%", height: 70, border: "none", resize: "none", fontSize: "10pt", textAlign: "left", whiteSpace: "pre-line" }}
/>
</td>
</tr>
{/* 참조사항 */}
<tr className="notes-row">
<td colSpan={8} style={{ verticalAlign: "top", padding: 10, textAlign: "left", border: "1px solid #000" }}>
<div style={{ fontWeight: "bold", marginBottom: 10 }}>&lt;&gt;</div>
<div style={{ marginBottom: 5 }}>
<input type="text" value={note1} onChange={e => setNote1(e.target.value)} readOnly={readOnly} style={{ fontSize: "10pt" }} />
</div>
<div style={{ marginBottom: 5 }}>
<input type="text" value={note2} onChange={e => setNote2(e.target.value)} readOnly={readOnly} style={{ fontSize: "10pt" }} />
</div>
<div style={{ marginBottom: 5 }}>
<input type="text" value={note3} onChange={e => setNote3(e.target.value)} readOnly={readOnly} style={{ fontSize: "10pt" }} />
</div>
<div style={{ marginBottom: 5 }}>
<input type="text" value={note4} onChange={e => setNote4(e.target.value)} readOnly={readOnly} style={{ fontSize: "10pt" }} />
</div>
</td>
</tr>
{/* 푸터 회사명 */}
<tr className="footer-row">
<td colSpan={8} style={{ textAlign: "right", padding: 15, fontSize: "10pt", fontWeight: "bold", border: "none" }}>
</td>
</tr>
</tbody>
</table>
</div>
{/* 버튼 영역 (고정 하단) */}
<div className="btn-area no-print">
<button type="button" className="estimate-btn" onClick={handlePrint}></button>
<button type="button" className="estimate-btn" onClick={handleDownloadPdf}>PDF </button>
<button type="button" className="estimate-btn" onClick={handleSave} disabled={readOnly}></button>
<button type="button" className="estimate-btn" onClick={handleClose}></button>
</div>
</div>
);
}
@@ -0,0 +1,761 @@
"use client";
// ============================================================
// 영업관리 > 견적관리 > 견적작성(장비)
// wace estimateTemplate2.jsp 1:1 이식 (template_type='2')
// 진입: 견적관리 그리드 행 선택 → "견적작성" → "장비 견적서"
// 구조: CNC Machine 특별 영역 + 7개 기본 카테고리 (group1 4개 + 단독 3개) + 비고 + 푸터
// ============================================================
import React, { useEffect, useMemo, useState } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
import { salesEstimateApi } from "@/lib/api/salesEstimate";
import { CustomerSelect } from "@/components/common/CustomerSelect";
// ─── 포맷 헬퍼 ────────────────────────────────────────────────
function addComma(num: number | string): string {
if (num === "" || num == null) return "";
const n = Number(String(num).replace(/,/g, ""));
if (Number.isNaN(n)) return "";
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// ─── 데이터 타입 ──────────────────────────────────────────────
// CNC Machine 특별 영역 (1개 row)
interface CncRow {
description: string;
specification: string;
quantity: string;
unit_price: string; // 입력 시 콤마 포함
amount: string; // 자동 계산
remark: string;
}
// 일반 카테고리 (N개, textarea 묶음 — 여러 줄)
interface CategoryRow {
category: string; // data-category 값 (예: 'structure', 'category_8')
category_name: string; // 화면 표시명
category_no: number;
group: string; // 'group1' 또는 ''
description: string; // 여러 줄
specification: string;
quantity: string;
quantity2: string;
remark: string;
subtotal: string; // 개별 subtotal (group이 아닌 카테고리만)
}
// ─── wace 1:1 기본 데이터 (신규 작성 시 깔리는 7개 카테고리) ──
const DEFAULT_CATEGORIES: CategoryRow[] = [
{
category: "structure", category_name: "기구", category_no: 1, group: "group1",
description: "X,Y,Z LINEAR\nLINEAR SCALE ENCODER\n주물 BODY\nX,Z AXIS COLUMN\nCOVER\nLM GUIDE\nLM GUIDE\nTABLE\nSUS 자바라 X, Y",
specification: "\n\nMINERAL CASTING\nMINERAL CASTING\nSPCC 도장\nX,Y: SHS25 P급, C1 중예압\nZ: SRG25 P급, C0\n600 * 500\n벨로우즈 멀티 커버",
quantity: "\n\n3\n1\n1\n1\n4\n2\n1\n4",
quantity2: "", remark: "", subtotal: "",
},
{
category: "spindle_module", category_name: "초음파 스핀들 모듈", category_no: 2, group: "group1",
description: "초음파 스핀들 모듈\n제너레이터",
specification: "AS030-080H2A2.0-U\n15~50kHz, 50W",
quantity: "1\n1",
quantity2: "", remark: "", subtotal: "",
},
{
category: "electric", category_name: "전장", category_no: 3, group: "group1",
description: "C-BOX\nINVERTER\nNC 제어기",
specification: "Panel & Box, 공용 전기 자재, 냉각장치\nDELTA MS300\nSIEMENS CONTROL PANEL 828D",
quantity: "1\n1\n1",
quantity2: "", remark: "", subtotal: "",
},
{
category: "utility", category_name: "UTILITY", category_no: 4, group: "group1",
description: "공압 PANEL & HOSE\nCHILLER\n절삭유 공급장치\nOIL 자동급유장치\n절삭유 공급 필터",
specification: "\n\t\t\t\nDSD-010S\n순환장치\nLUBRICATION\n1um 1ea, 10um 1ea",
quantity: "1\n1\n1\n1\n1",
quantity2: "", remark: "", subtotal: "",
},
{
category: "option", category_name: "Option", category_no: 5, group: "",
description: "OMP400\nNC4\nATC",
specification: "OMP400(Renishaw)\nNV4Blue(Renishaw)\n-",
quantity: "1\n1\n1",
quantity2: "", remark: "", subtotal: "",
},
{
category: "setup", category_name: "Set up", category_no: 6, group: "",
description: "인건비 (기구+제어)\n인건비 (Training)\n기타(항공/숙박/교통/식비)",
specification: "", quantity: "", quantity2: "", remark: "", subtotal: "",
},
{
category: "packing", category_name: "포장/물류", category_no: 7, group: "",
description: "포장비\n물류비",
specification: "", quantity: "", quantity2: "", remark: "", subtotal: "",
},
];
const DEFAULT_NOTES_HTML = `<strong>■ 최종 견적가는 부가세 별도입니다.</strong><br>■ 장비 납기 : 발주 시 협의<br>■ 운송 조건 : 국내 RPS 에서 진행<br>국외 FOB 기준으로 운송비 / 포장비는 선 당사 부담후 실비 정산<br>■ 결제 조건 : 계약금 : 30% / 중도금 : 60% / 잔금 : 10%<br>-. 계약금 : 발주 후 7일 이내<br>-. 중도금 : 출하 전<br>-. 잔 금 : 설치 완료 후<br>■ 장비사양 : 본견적은 당사 표준 사항<br>사양 협의시 금액 변동성이 있음.<br>■ Warrenty Period: 1년(소모성 parts 제외)<br>■ 주의 : RPS 동의없이 초음파 스핀들의 임의 탈거 또는 해체시 보증 할수 없음.<br><br><div style="display: flex; justify-content: space-between; align-items: center;"><span>* 견적유효기간: 4주</span><span>㈜ 알 피 에 스</span></div>`;
// ─── 페이지 ──────────────────────────────────────────────────
export default function EstimateTemplate2Page() {
const params = useParams<{ contractObjid: string }>();
const searchParams = useSearchParams();
const router = useRouter();
const contractObjidParam = params?.contractObjid ?? "";
const templateObjidParam = searchParams?.get("templateObjid") ?? "";
const [exchangeRate, setExchangeRate] = useState<number>(1);
// 헤더
const [executorDate, setExecutorDate] = useState<string>("");
const executorDateRef = React.useRef<HTMLInputElement>(null);
const [recipient, setRecipient] = useState<string>("");
const [partName, setPartName] = useState<string>("");
const [partObjid, setPartObjid] = useState<string>("");
const [modelCode, setModelCode] = useState<string>("");
// CNC Machine 특별 영역
const [cnc, setCnc] = useState<CncRow>({
description: "초음파 CNC Machine",
specification: "Hole 가공",
quantity: "1",
unit_price: "",
amount: "",
remark: "",
});
// 일반 카테고리 배열
const [categories, setCategories] = useState<CategoryRow[]>(DEFAULT_CATEGORIES);
// group1 공유 subtotal
const [group1Subtotal, setGroup1Subtotal] = useState<string>("");
// 비고/유효기간
const [notesContent, setNotesContent] = useState<string>(DEFAULT_NOTES_HTML);
const notesRef = React.useRef<HTMLDivElement>(null);
const [validityPeriod, setValidityPeriod] = useState<string>("");
// 상태
const [contractObjid, setContractObjid] = useState<string>(contractObjidParam);
const [templateObjid, setTemplateObjid] = useState<string>(templateObjidParam);
const [loading, setLoading] = useState<boolean>(true);
const [apprStatus, setApprStatus] = useState<string>("작성중");
const readOnly = apprStatus === "결재완료" || apprStatus === "결재중";
// ─── 최종 견적가 계산 ────────────────────────────────────────
// wace fn_calculateTotal: cnc.amount + group1Subtotal + 단독 subtotal들
const { totalAmountNum, totalAmountKrwNum, cncAmountNum } = useMemo(() => {
const cncAmt = parseFloat((cnc.unit_price || "0").replace(/,/g, "")) * parseFloat(cnc.quantity || "0");
const cncAmtNum = Number.isFinite(cncAmt) ? cncAmt : 0;
const g1 = parseFloat((group1Subtotal || "0").replace(/,/g, "")) || 0;
const standalone = categories
.filter(c => !c.group)
.reduce((s, c) => s + (parseFloat((c.subtotal || "0").replace(/,/g, "")) || 0), 0);
const total = cncAmtNum + g1 + standalone;
return { totalAmountNum: total, totalAmountKrwNum: total * (exchangeRate || 1), cncAmountNum: cncAmtNum };
}, [cnc.unit_price, cnc.quantity, group1Subtotal, categories, exchangeRate]);
// CNC amount는 cnc.amount 표시용으로 동기화
useEffect(() => {
setCnc(prev => ({ ...prev, amount: cncAmountNum ? addComma(cncAmountNum) : "" }));
}, [cncAmountNum]);
// ─── 카테고리 핸들러 ─────────────────────────────────────────
function updateCategory(idx: number, patch: Partial<CategoryRow>) {
setCategories(prev => prev.map((c, i) => i === idx ? { ...c, ...patch } : c));
}
function addCategory() {
const visibleCount = categories.length;
const next = visibleCount + 1;
setCategories(prev => [...prev, {
category: `category_${next}`,
category_name: `카테고리 ${next}`,
category_no: next,
group: "",
description: "", specification: "", quantity: "", quantity2: "", remark: "", subtotal: "",
}]);
}
function deleteCategory(idx: number) {
if (!confirm("이 카테고리를 삭제하시겠습니까?")) return;
setCategories(prev => {
const next = prev.filter((_, i) => i !== idx);
// 번호 재정렬 (cnc_machine 제외 — 여긴 일반 카테고리만)
return next.map((c, i) => ({ ...c, category_no: i + 1 }));
});
}
// ─── 데이터 로드 ────────────────────────────────────────────
useEffect(() => {
let cancel = false;
(async () => {
try {
// 영업 정보
let contractInfo: any = null;
if (contractObjidParam) {
try {
contractInfo = await salesEstimateApi.detail(contractObjidParam);
} catch (e) {
console.warn("영업정보 로드 실패", e);
}
}
// getById 응답: { ...contract_mgmt 헤더 필드들, items: [...] } (평면 구조)
if (contractInfo && !cancel) {
setExchangeRate(parseFloat(contractInfo.exchange_rate || "1") || 1);
if (contractInfo.customer_objid) setRecipient(contractInfo.customer_objid);
}
// 신규: contract_item[0]의 part_name을 품명/Model로
if (contractInfo?.items?.[0] && !cancel) {
const it0 = contractInfo.items[0];
const pName = it0.master_part_name ?? it0.part_name ?? "";
if (pName) {
setPartName(pName);
setModelCode(pName);
setPartObjid(it0.part_objid ?? "");
}
if (it0.quantity) setCnc(prev => ({ ...prev, quantity: String(it0.quantity) }));
}
// 기존 견적 차수 수정
if (templateObjidParam) {
const tpl = await salesEstimateApi.getTemplate(templateObjidParam);
if (tpl && !cancel) {
if (tpl.contract_objid) setContractObjid(tpl.contract_objid);
setExchangeRate(parseFloat(tpl.exchange_rate || "1") || 1);
setExecutorDate(tpl.executor_date ?? "");
if (tpl.recipient) setRecipient(tpl.recipient);
if (tpl.part_name) {
setPartName(tpl.part_name);
setModelCode(tpl.part_name);
}
if (tpl.part_objid) setPartObjid(tpl.part_objid);
if (tpl.notes_content) {
setNotesContent(tpl.notes_content);
if (notesRef.current) notesRef.current.innerHTML = tpl.notes_content;
}
if (tpl.validity_period) setValidityPeriod(tpl.validity_period);
if (tpl.group1_subtotal) setGroup1Subtotal(addComma(tpl.group1_subtotal));
// categories_json 복원
if (tpl.categories_json) {
try {
const arr = JSON.parse(tpl.categories_json) as any[];
const cncSaved = arr.find(c => c.category === "cnc_machine");
if (cncSaved?.items?.[0]) {
const it = cncSaved.items[0];
setCnc({
description: it.description ?? "초음파 CNC Machine",
specification: it.specification ?? "",
quantity: it.quantity ?? "1",
unit_price: it.unit_price ? addComma(it.unit_price) : "",
amount: it.amount ? addComma(it.amount) : "",
remark: it.remark ?? "",
});
}
const rest: CategoryRow[] = arr
.filter(c => c.category !== "cnc_machine")
.map((c: any, i: number) => ({
category: c.category ?? `category_${i + 1}`,
category_name: c.category_name ?? `카테고리 ${i + 1}`,
category_no: c.category_no ?? i + 1,
group: c.group ?? "",
description: c.description ?? "",
specification: c.specification ?? "",
quantity: c.quantity ?? "",
quantity2: c.quantity2 ?? "",
remark: c.remark ?? "",
subtotal: c.subtotal ? addComma(c.subtotal) : "",
}));
if (rest.length > 0) setCategories(rest);
} catch (e) {
console.warn("categories_json 파싱 실패", e);
}
}
}
}
} finally {
if (!cancel) setLoading(false);
}
})();
return () => { cancel = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contractObjidParam, templateObjidParam]);
// ─── 저장 (wace fn_save 1:1) ─────────────────────────────────
async function handleSave() {
if (!contractObjid && !templateObjid) {
alert("견적서를 저장할 수 없습니다. 영업정보가 없습니다.");
return;
}
if (!confirm("견적서를 저장하시겠습니까?")) return;
// categories 배열 구성: CNC Machine + 일반 카테고리
const cncAmtStr = (cnc.unit_price || "")
? String(parseFloat(cnc.unit_price.replace(/,/g, "")) * parseFloat(cnc.quantity || "0") || 0)
: "";
const catsBody: any[] = [
{
category: "cnc_machine",
category_name: "초음파 CNC Machine",
category_no: 0,
content: "",
items: [{
description: cnc.description || "",
specification: cnc.specification || "",
quantity: cnc.quantity || "",
unit_price: (cnc.unit_price || "").replace(/,/g, ""),
amount: cncAmtStr,
remark: cnc.remark || "",
}],
subtotal: "",
},
...categories.map((c, idx) => ({
category: c.category,
category_name: c.category_name,
category_no: idx + 1,
group: c.group || "",
description: c.description || "",
specification: c.specification || "",
quantity: c.quantity || "",
quantity2: c.quantity2 || "",
remark: c.remark || "",
subtotal: c.group ? "" : (c.subtotal || "").replace(/,/g, ""),
})),
];
try {
const data = await salesEstimateApi.saveTemplate2({
contract_objid: contractObjid,
template_objid: templateObjid || undefined,
executor_date: executorDate,
recipient,
part_name: partName,
part_objid: partObjid,
notes_content: (notesRef.current?.innerHTML) ?? notesContent,
validity_period: validityPeriod,
categories_json: JSON.stringify(catsBody),
group1_subtotal: (group1Subtotal || "").replace(/,/g, ""),
total_amount: String(totalAmountNum),
total_amount_krw: String(totalAmountKrwNum),
});
if (data?.templateObjid) setTemplateObjid(data.templateObjid);
alert("저장되었습니다.");
} catch (e: any) {
console.error("저장 오류", e);
alert("저장에 실패했습니다.\n" + (e?.response?.data?.message ?? e?.message ?? ""));
}
}
function handlePrint() {
window.print();
}
function handleClose() {
if (window.opener) window.close();
else router.back();
}
if (loading) return <div style={{ padding: 40 }}> ...</div>;
// ─── 렌더링 (wace 마크업 1:1) ────────────────────────────────
return (
<div className="estimate2-page">
<style jsx global>{`
@media print {
@page { size: A4; margin: 10mm; }
body { margin: 0; padding: 0; }
.no-print { display: none !important; }
.btn-delete-category { display: none !important; }
}
.estimate2-page body, body { font-family: "Malgun Gothic","맑은 고딕",Arial,sans-serif; background-color: #f5f5f5; }
/* 견적서는 인쇄용 양식 — 다크모드와 무관하게 항상 흰 배경/검정 텍스트 */
.estimate2-page { color: #000; background-color: #f5f5f5; min-height: 100vh; }
.estimate2-page .estimate-container, .estimate2-page .estimate-container * { color: #000; }
.estimate2-page .estimate-container {
width: 210mm; min-height: 297mm;
background: white; margin: 0 auto;
padding: 15mm; box-shadow: 0 0 10px rgba(0,0,0,.1);
box-sizing: border-box; padding-bottom: 100px;
}
.estimate2-page .header-section {
display: flex; justify-content: space-between;
align-items: center; margin-bottom: 20px;
}
.estimate2-page .title-section { flex: 1; text-align: center; }
.estimate2-page .title {
font-size: 24pt; font-weight: bold; letter-spacing: 15px;
}
.estimate2-page .model-header {
background: #f0f0f0; padding: 8px; text-align: center;
font-size: 11pt; font-weight: bold; margin: 15px 0;
}
.estimate2-page .items-table {
width: 100%; border-collapse: collapse;
margin-bottom: 8px; font-size: 9pt;
}
.estimate2-page .items-table th, .estimate2-page .items-table td {
border: 1px solid #000; padding: 4px 6px; vertical-align: top;
}
.estimate2-page .items-table th { background: #f0f0f0; text-align: center; font-weight: bold; }
.estimate2-page .category-row td:first-child { text-align: center; font-weight: bold; }
.estimate2-page .category-row td[contenteditable] {
background: #fffef0; font-weight: bold; padding: 6px 10px;
}
.estimate2-page .subtotal-row td { background: #f5f5f5; font-weight: bold; }
.estimate2-page input[type="text"], .estimate2-page input[type="date"],
.estimate2-page textarea, .estimate2-page select {
border: none; outline: none; background: transparent;
width: 100%; font-family: inherit; font-size: inherit; box-sizing: border-box;
color: #000;
}
.estimate2-page input[readonly], .estimate2-page textarea[readonly] { color: #000; }
.estimate2-page textarea { resize: vertical; line-height: 1.6; white-space: pre-wrap; }
.estimate2-page .estimate-btn-area {
position: fixed; bottom: 0; left: 0; right: 0;
text-align: right; padding: 12px 24px;
background: #fff; border-top: 3px solid #007bff;
box-shadow: 0 -2px 10px rgba(0,0,0,.1); z-index: 1000;
}
.estimate2-page .estimate-btn {
display: inline-block; padding: 5px 15px; margin: 0 2px;
font-size: 12px; cursor: pointer; border: 1px solid #60a5fa;
background: #60a5fa; color: #fff; border-radius: 3px;
}
.estimate2-page .estimate-btn:hover { background: #1e3a8a; border-color: #1e3a8a; }
.estimate2-page .estimate-btn[disabled] { background: #cbd5e1; border-color: #cbd5e1; cursor: not-allowed; }
.estimate2-page .btn-delete-category {
padding: 2px 8px; font-size: 10px; cursor: pointer;
background: #dc3545; color: #fff; border: none; border-radius: 3px;
}
.estimate2-page .notes-section { margin-top: 15px; }
.estimate2-page .notes-section [contenteditable] {
width: 97%; min-height: 180px; border: 1px solid #ddd;
padding: 10px; font-size: 9pt; line-height: 1.8; background: white;
}
`}</style>
<div className="estimate-container">
{/* 헤더 (로고 + 제목 + 회사정보) */}
<div className="header-section">
<div style={{ flex: "0 0 120px" }}>
<div style={{ fontSize: "12pt", fontWeight: "bold", color: "#dc3545" }}>RPS</div>
</div>
<div className="title-section">
<div className="title">&nbsp;&nbsp;&nbsp;&nbsp;</div>
</div>
<div style={{ flex: "0 0 200px" }}></div>
</div>
{/* 기본 정보 (좌우 배치) */}
<div style={{ display: "flex", justifyContent: "space-between", padding: "0 10px" }}>
<div style={{ textAlign: "left", fontSize: "10pt", lineHeight: 2 }}>
<div>
<strong> :</strong>{" "}
{/* 표시는 YYYY-MM-DD (text), 클릭 시 숨겨진 type=date의 showPicker 트리거 */}
<span style={{ position: "relative", display: "inline-block", width: 200 }}>
<input
type="text"
value={executorDate}
readOnly
placeholder="YYYY-MM-DD"
onClick={() => { if (!readOnly) executorDateRef.current?.showPicker?.(); }}
style={{ width: "100%", borderBottom: "1px solid #999", padding: "2px 5px", cursor: readOnly ? "default" : "pointer" }}
/>
<input
ref={executorDateRef}
type="date"
value={executorDate}
onChange={e => setExecutorDate(e.target.value)}
tabIndex={-1}
aria-hidden="true"
style={{ position: "absolute", left: 0, top: 0, width: 1, height: 1, opacity: 0, pointerEvents: "none" }}
/>
</span>
</div>
<div>
<strong> :</strong>{" "}
<span style={{ display: "inline-block", width: 200 }}>
<CustomerSelect
value={recipient}
onValueChange={setRecipient}
placeholder="고객사 선택"
disabled={readOnly}
/>
</span>
</div>
<div>
<strong>   :</strong>{" "}
<input
type="text"
value={partName}
onChange={e => setPartName(e.target.value)}
readOnly={readOnly}
style={{ width: 200, borderBottom: "1px solid #999", padding: "2px 5px" }}
/>
</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ display: "inline-block", minWidth: 300 }}>
<div style={{ fontWeight: "bold", fontSize: "11pt", marginBottom: 5 }}>RPS CO., LTD</div>
<div style={{ fontSize: "9pt", lineHeight: 1.5 }}> 10 8</div>
<div style={{ fontSize: "9pt", lineHeight: 1.5 }}>TEL: (042)602-3300, FAX: (042)672-3399</div>
</div>
</div>
</div>
{/* 설비 Model */}
<div className="model-header">
Model :{" "}
<input
type="text"
value={modelCode}
onChange={e => setModelCode(e.target.value)}
readOnly={readOnly}
style={{ display: "inline-block", minWidth: 200, textAlign: "center", fontWeight: "bold" }}
/>
</div>
{/* CNC Machine 특별 영역 */}
<table className="items-table" data-category="cnc_machine">
<colgroup>
<col style={{ width: "5%" }} />
<col style={{ width: "26%" }} />
<col style={{ width: "28.5%" }} />
<col style={{ width: "6.5%" }} />
<col style={{ width: "12%" }} />
<col style={{ width: "12%" }} />
<col style={{ width: "10%" }} />
</colgroup>
<thead>
<tr>
<th>NO</th>
<th>DESCRIPTION</th>
<th>SPECIFICATION</th>
<th>Q'TY</th>
<th>UNIT PRICE</th>
<th>AMOUNT</th>
<th>REMARK</th>
</tr>
</thead>
<tbody>
<tr>
<td rowSpan={2} style={{ textAlign: "center" }}>1</td>
<td rowSpan={2}>
<input
type="text" value={cnc.description}
onChange={e => setCnc(prev => ({ ...prev, description: e.target.value }))}
readOnly={readOnly}
/>
</td>
<td>
<input
type="text" value={cnc.specification}
onChange={e => setCnc(prev => ({ ...prev, specification: e.target.value }))}
readOnly={readOnly}
style={{ textAlign: "center" }}
/>
</td>
<td>
<input
type="text" value={cnc.quantity}
onChange={e => setCnc(prev => ({ ...prev, quantity: e.target.value.replace(/[^0-9]/g, "") }))}
readOnly={readOnly}
style={{ textAlign: "center" }}
/>
</td>
<td>
<input
type="text" value={cnc.unit_price}
onChange={e => {
const cleaned = e.target.value.replace(/,/g, "").replace(/[^0-9.]/g, "");
setCnc(prev => ({ ...prev, unit_price: cleaned === "" ? "" : addComma(cleaned) }));
}}
readOnly={readOnly}
style={{ textAlign: "right" }}
/>
</td>
<td>
<input type="text" value={cnc.amount} readOnly style={{ textAlign: "right" }} />
</td>
<td>
<input
type="text" value={cnc.remark}
onChange={e => setCnc(prev => ({ ...prev, remark: e.target.value }))}
readOnly={readOnly}
/>
</td>
</tr>
<tr className="subtotal-row">
<td colSpan={2} style={{ textAlign: "center", fontSize: "8pt" }}> </td>
<td colSpan={2}>
<input
type="text"
value={totalAmountNum ? addComma(totalAmountNum) : ""}
readOnly
style={{ width: "70%", textAlign: "right", fontWeight: "bold" }}
/>
<span style={{ fontWeight: "bold", marginLeft: 5 }}>{exchangeRate === 1 ? "₩" : ""}</span>
</td>
<td style={{ textAlign: "center", fontSize: "8pt" }}>VAT </td>
</tr>
</tbody>
</table>
{/* 일반 카테고리 N개 */}
{categories.map((c, idx) => {
const isGroup1Last = c.group === "group1" &&
(idx === categories.length - 1 || categories[idx + 1]?.group !== "group1");
return (
<React.Fragment key={`${c.category}_${idx}`}>
<table className="items-table" data-category={c.category} data-group={c.group}>
<colgroup>
<col style={{ width: "5%" }} />
<col style={{ width: "26%" }} />
<col style={{ width: "28.5%" }} />
<col style={{ width: "6.5%" }} />
<col style={{ width: "24%" }} />
<col style={{ width: "10%" }} />
</colgroup>
<tbody>
<tr className="category-row">
<td rowSpan={2} style={{ textAlign: "center" }}>{idx + 1}</td>
<td
colSpan={4}
contentEditable={!readOnly}
suppressContentEditableWarning
onBlur={e => updateCategory(idx, { category_name: e.currentTarget.textContent ?? "" })}
>{c.category_name}</td>
<td style={{ textAlign: "center" }}>
{!readOnly && (
<button type="button" className="btn-delete-category" onClick={() => deleteCategory(idx)}></button>
)}
</td>
</tr>
<tr className="detail-row">
<td>
<textarea
value={c.description}
onChange={e => updateCategory(idx, { description: e.target.value })}
readOnly={readOnly}
rows={5}
/>
</td>
<td>
<textarea
value={c.specification}
onChange={e => updateCategory(idx, { specification: e.target.value })}
readOnly={readOnly}
rows={5}
/>
</td>
<td>
<textarea
value={c.quantity}
onChange={e => updateCategory(idx, { quantity: e.target.value })}
readOnly={readOnly}
rows={5}
style={{ textAlign: "right" }}
/>
</td>
<td>
<textarea
value={c.quantity2}
onChange={e => updateCategory(idx, { quantity2: e.target.value })}
readOnly={readOnly}
rows={5}
/>
</td>
<td>
<textarea
value={c.remark}
onChange={e => updateCategory(idx, { remark: e.target.value })}
readOnly={readOnly}
rows={5}
/>
</td>
</tr>
{/* 단독 카테고리는 개별 Subtotal */}
{!c.group && (
<tr className="subtotal-row">
<td colSpan={4}>Subtotal</td>
<td>
<input
type="text" value={c.subtotal}
onChange={e => {
const cleaned = e.target.value.replace(/,/g, "").replace(/[^0-9.]/g, "");
updateCategory(idx, { subtotal: cleaned === "" ? "" : addComma(cleaned) });
}}
readOnly={readOnly}
style={{ textAlign: "right" }}
/>
</td>
<td></td>
</tr>
)}
</tbody>
</table>
{/* group1 마지막 카테고리 뒤 공유 Subtotal */}
{isGroup1Last && (
<table className="items-table" id="group1_subtotal">
<colgroup>
<col style={{ width: "5%" }} />
<col style={{ width: "26%" }} />
<col style={{ width: "28.5%" }} />
<col style={{ width: "6.5%" }} />
<col style={{ width: "24%" }} />
<col style={{ width: "10%" }} />
</colgroup>
<tbody>
<tr className="subtotal-row">
<td colSpan={4}>Subtotal</td>
<td>
<input
type="text" value={group1Subtotal}
onChange={e => {
const cleaned = e.target.value.replace(/,/g, "").replace(/[^0-9.]/g, "");
setGroup1Subtotal(cleaned === "" ? "" : addComma(cleaned));
}}
readOnly={readOnly}
style={{ textAlign: "right" }}
/>
</td>
<td></td>
</tr>
</tbody>
</table>
)}
</React.Fragment>
);
})}
{/* 비고 (contenteditable HTML) */}
<div className="notes-section">
<div
ref={notesRef}
contentEditable={!readOnly}
suppressContentEditableWarning
dangerouslySetInnerHTML={{ __html: notesContent }}
onBlur={e => setNotesContent(e.currentTarget.innerHTML)}
/>
</div>
{/* 푸터 — 대외비 */}
<div style={{ textAlign: "right", fontSize: "7pt", color: "#999", marginTop: 5 }}>
* RPS - RPS의 3 .
</div>
</div>
{/* 버튼 영역 */}
<div className="estimate-btn-area no-print">
<button type="button" className="estimate-btn" onClick={addCategory} disabled={readOnly}>+ </button>
<button type="button" className="estimate-btn" onClick={handlePrint}></button>
<button type="button" className="estimate-btn" onClick={handleSave} disabled={readOnly}></button>
<button type="button" className="estimate-btn" onClick={handleClose}></button>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -20,15 +20,18 @@ import { CustomerSelect } from "@/components/common/CustomerSelect";
import { PartSelect } from "@/components/common/PartSelect";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog";
import { AttachmentDialog } from "@/components/common/AttachmentDialog";
import { OrderFormViewDialog } from "@/components/sales/OrderFormViewDialog";
import { OrderRegistDialog } from "@/components/sales/OrderRegistDialog";
import { salesOrderMgmtApi, OrderRow, OrderBody, OrderItem } from "@/lib/api/salesOrderMgmt";
// wace_plm orderMgmtList.jsp 컬럼 순서/라벨에 맞춤
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "contract_no", label: "영업번호", width: "w-[120px]" },
{ key: "contract_no", label: "영업번호", width: "w-[125px]", frozen: true },
{ key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" },
{ key: "order_date", label: "발주일", width: "w-[100px]", align: "center" },
{ key: "po_no", label: "발주번호", width: "w-[110px]" },
{ key: "earliest_due_date_label", label: "요청납기", width: "w-[110px]", align: "center" },
{ key: "order_date", label: "발주일", width: "w-[120px]", align: "center" },
{ key: "po_no", label: "발주번호", width: "w-[130px]" },
{ key: "earliest_due_date_label", label: "요청납기", width: "w-[160px]", align: "center" },
{ key: "customer_name", label: "고객사", width: "w-[160px]" },
{ key: "item_summary", label: "품명", width: "w-[200px]" },
{ key: "order_quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
@@ -39,8 +42,8 @@ const GRID_COLUMNS: DataGridColumn[] = [
{ key: "order_vat_sum", label: "부가세", width: "w-[100px]", formatMoney: true },
{ key: "order_total_amount_sum", label: "총액", width: "w-[120px]", formatMoney: true },
{ key: "order_total_amount_krw", label: "원화총액", width: "w-[120px]", formatMoney: true },
{ key: "cu01_cnt", label: "주문서첨부", width: "w-[90px]", align: "center", formatNumber: true },
{ key: "has_order_data", label: "주문서", width: "w-[80px]", align: "center", formatNumber: true },
{ key: "cu01_cnt", label: "주문서첨부", width: "w-[90px]", align: "center", renderType: "clip" },
{ key: "has_order_data", label: "주문서", width: "w-[80px]", align: "center", renderType: "folder" },
{ key: "customer_request", label: "고객사요청사항", width: "w-[180px]" },
{ key: "order_appr_status", label: "결재상태", width: "w-[90px]", align: "center" },
{ key: "contract_currency_name", label: "환종", width: "w-[70px]", align: "center" },
@@ -48,18 +51,33 @@ const GRID_COLUMNS: DataGridColumn[] = [
{ key: "serial_no", label: "S/N", width: "w-[140px]" },
{ key: "part_no", label: "품번", width: "w-[120px]" },
{ key: "writer_name", label: "작성자", width: "w-[110px]" },
/* wace orderMgmtList.jsp 429~434 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제.
{ key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" },
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
{ key: "receipt_date", label: "접수일", width: "w-[110px]", align: "center" },
*/
];
const PAID_TYPES: Record<string, string> = { paid: "유상", free: "무상" };
// wace 운영 contract_result 코드 (parent=0000963 영업구분)
// 0000964 수주 / 0000965 Cancel / 0000966 Hold / 0000968 수주(FCST)
// 대기는 NULL/빈값이 운영 패턴 (wace 신규 등록 시 NULL)
// "수주확정" 버튼은 단일 코드로 직행하지 않고, 다이얼로그에서 사용자가 선택
const CONTRACT_RESULTS = [
{ value: "", label: "전체" },
{ value: "WAITING", label: "대기" },
{ value: "CONFIRMED", label: "수주확정" },
{ value: "CANCELLED", label: "수주취소" },
{ value: "0000964", label: "수주" },
{ value: "0000968", label: "수주(FCST)" },
{ value: "0000966", label: "Hold" },
{ value: "0000965", label: "Cancel" },
];
// wace estimateAndOrderRegistFormPopup 라인 — 제품구분/S/N/요청납기/반납사유/고객요청사항 포함
const EMPTY_ITEM: OrderItem = {
seq: 1, part_objid: "", part_no: "", part_name: "", quantity: 1,
seq: 1,
product: "", part_objid: "", part_no: "", part_name: "",
serials: [],
due_date: "", return_reason: "", customer_request: "",
quantity: 1,
order_quantity: "", order_unit_price: "0", order_supply_price: "0",
order_vat: "0", order_total_amount: "0",
};
@@ -74,7 +92,7 @@ export default function SalesOrderPage() {
// wace orderMgmtList.jsp 활성 9개 (1줄 7개 / 2줄 2개)
const [searchForm, setSearchForm] = useState({
category_cd: "", search_poNo: "", customer_objid: "",
search_partObjId: "", search_serialNo: "", contract_result: "",
search_partObjId: "", search_partName: "", search_serialNo: "", contract_result: "",
order_start_date: "", order_end_date: "",
due_start_date: "", due_end_date: "",
});
@@ -86,8 +104,85 @@ export default function SalesOrderPage() {
items: [],
});
// 품목 검색 모달
// 품목 검색 모달 (라인별 진입)
const [itemDialogOpen, setItemDialogOpen] = useState(false);
const [itemSearchTargetIdx, setItemSearchTargetIdx] = useState<number | null>(null);
// S/N 관리 모달 + 연속번호 생성 (wace fn_openItemSnPopup / fn_openItemSequentialSnPopup)
const [serialDialogOpen, setSerialDialogOpen] = useState(false);
const [serialDialogIdx, setSerialDialogIdx] = useState<number | null>(null);
const [serialDraft, setSerialDraft] = useState<string[]>([]);
const [serialInput, setSerialInput] = useState("");
const [seqDialogOpen, setSeqDialogOpen] = useState(false);
const [seqStartNo, setSeqStartNo] = useState("");
const [seqCount, setSeqCount] = useState("");
// 첨부파일 다이얼로그 (주문서첨부 클립 컬럼 클릭 시)
const [attachDialogOpen, setAttachDialogOpen] = useState(false);
const [attachContext, setAttachContext] = useState<{
targetObjid: string;
docType: string | string[];
uploadDocType: string;
uploadDocTypeName?: string;
title: string;
} | null>(null);
// 주문서 자동생성 뷰 다이얼로그 (has_order_data 폴더 컬럼 클릭)
const [orderFormOpen, setOrderFormOpen] = useState(false);
const [orderFormObjid, setOrderFormObjid] = useState<string | null>(null);
// 견적요청에서 시작된 행의 수주등록 다이얼로그 (wace orderRegistFormPopup 1:1)
// - is_direct_order != 'Y' 행을 수정할 때 사용 (헤더 4개 + 라인 ORDER_*만 입력)
// - 직접등록 통합폼(estimateAndOrderRegistFormPopup)과 완전 분리
const [orderRegistOpen, setOrderRegistOpen] = useState(false);
const [orderRegistContract, setOrderRegistContract] = useState<{ objid: string; contractNo: string } | null>(null);
// 수주확정 다이얼로그 (wace fn_openOrderConfirmPopup 이식 — 상태 select)
const [confirmStatusOpen, setConfirmStatusOpen] = useState(false);
const [confirmStatusValue, setConfirmStatusValue] = useState<string>("");
// 수주취소 다이얼로그 (wace fn_openOrderCancelPopup 이식 — 라인별 cancel_qty)
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelLines, setCancelLines] = useState<{
objid: string; part_no: string; part_name: string; order_qty: number; cancel_qty: string;
}[]>([]);
const [cancelSaving, setCancelSaving] = useState(false);
const gridColumns = useMemo<DataGridColumn[]>(
() => GRID_COLUMNS.map((col) => {
if (col.key === "cu01_cnt") {
return {
...col,
onClick: (row) => {
setAttachContext({
targetObjid: String(row.objid),
docType: ["FTC_ORDER", "ORDER"],
uploadDocType: "ORDER",
uploadDocTypeName: "주문서",
title: `주문서 첨부 — ${row.contract_no ?? ""}`,
});
setAttachDialogOpen(true);
},
};
}
if (col.key === "has_order_data") {
return {
...col,
onClick: (row) => {
const cnt = Number(row.has_order_data ?? 0);
if (!cnt || cnt <= 0) {
toast.info("주문 라인이 입력되지 않았습니다.");
return;
}
setOrderFormObjid(String(row.objid));
setOrderFormOpen(true);
},
};
}
return col;
}),
[]
);
const fetchList = useCallback(async () => {
if (!user) return;
@@ -119,7 +214,8 @@ export default function SalesOrderPage() {
contract_no: contractNo,
contract_currency: "KRW",
paid_type: "paid",
contract_date: new Date().toISOString().slice(0, 10),
order_date: new Date().toISOString().slice(0, 10),
is_direct_order: "Y",
items: [{ ...EMPTY_ITEM }],
});
setDialogOpen(true);
@@ -127,6 +223,14 @@ export default function SalesOrderPage() {
const openEdit = async () => {
if (!selected) { toast.warning("수정할 주문서를 선택하세요."); return; }
// wace 분기 (estimateAndOrderRegistFormPopup vs orderRegistFormPopup):
// is_direct_order = 'Y' → 직접등록 통합폼 (헤더 9개 + 라인 13컬럼)
// 그 외('','N',null) → 견적요청에서 시작된 행 → 별도 수주등록 폼 (헤더 4개 + 라인 ORDER_*)
if ((selected.is_direct_order ?? "") !== "Y") {
setOrderRegistContract({ objid: selected.objid, contractNo: selected.contract_no ?? "" });
setOrderRegistOpen(true);
return;
}
setDialogMode("edit");
try {
const detail = await salesOrderMgmtApi.detail(selected.objid);
@@ -141,7 +245,9 @@ export default function SalesOrderPage() {
contract_currency: detail.contract_currency ?? "KRW",
exchange_rate: detail.exchange_rate ?? "",
receipt_date: detail.receipt_date ?? "",
contract_date: detail.contract_date ?? "",
order_date: detail.order_date ?? "",
approval_required: detail.approval_required ?? "N",
is_direct_order: detail.is_direct_order ?? "Y",
req_del_date: detail.req_del_date ?? "",
po_no: detail.po_no ?? "",
contract_result: detail.contract_result ?? "",
@@ -152,11 +258,13 @@ export default function SalesOrderPage() {
items: (detail.items ?? []).map((it: any) => ({
objid: it.objid,
seq: it.seq,
product: it.product ?? "",
part_objid: it.part_objid ?? "",
part_no: it.part_no ?? "",
part_name: it.part_name ?? "",
part_no: it.master_part_no ?? it.part_no ?? "",
part_name: it.master_part_name ?? it.part_name ?? "",
quantity: it.quantity ?? 1,
due_date: it.due_date ?? "",
return_reason: it.return_reason ?? "",
customer_request: it.customer_request ?? "",
order_quantity: it.order_quantity ?? "",
order_unit_price: it.order_unit_price ?? "",
@@ -205,17 +313,76 @@ export default function SalesOrderPage() {
catch (err: any) { toast.error(`삭제 실패: ${err?.response?.data?.message ?? err.message}`); }
};
const handleConfirmOrder = async () => {
if (!selected) { toast.warning("선택하세요."); return; }
try { await salesOrderMgmtApi.setStatus(selected.objid, "CONFIRMED"); toast.success("수주확정 처리되었습니다."); await fetchList(); }
catch (err: any) { toast.error(err?.response?.data?.message ?? err.message); }
// 수주확정 — wace 패턴: 행 1개만 선택 가능, 팝업에서 상태 선택 후 저장
const handleConfirmOrder = () => {
if (!selected) { toast.warning("수주확정할 행을 선택해주십시오."); return; }
setConfirmStatusValue("");
setConfirmStatusOpen(true);
};
const submitConfirmStatus = async () => {
if (!selected) return;
if (!confirmStatusValue) { toast.warning("수주상태를 선택해주세요."); return; }
try {
await salesOrderMgmtApi.setStatus(selected.objid, confirmStatusValue);
toast.success("수주상태가 변경되었습니다.");
setConfirmStatusOpen(false);
await fetchList();
} catch (err: any) {
toast.error("수주확정 저장 중 오류가 발생했습니다.\n" + (err?.response?.data?.message ?? err.message ?? ""));
}
};
// 수주취소 — wace 패턴: 라인 조회 → 라인별 취소수량 input → 일괄 저장
const handleCancelOrder = async () => {
if (!selected) { toast.warning("선택하세요."); return; }
const ok = await confirm("수주 취소", { description: `${selected.contract_no} 을(를) 수주취소 처리하시겠습니까?`, variant: "destructive" });
if (!ok) return;
try { await salesOrderMgmtApi.setStatus(selected.objid, "CANCELLED"); toast.success("수주취소 처리되었습니다."); await fetchList(); }
catch (err: any) { toast.error(err?.response?.data?.message ?? err.message); }
if (!selected) { toast.warning("수주취소할 행을 선택해주십시오."); return; }
const orderQty = Number(selected.order_quantity ?? 0);
if (orderQty === 0) { toast.warning("수주 수량이 없는 건은 취소할 수 없습니다."); return; }
try {
const detail = await salesOrderMgmtApi.detail(selected.objid);
const items = (detail?.items ?? []) as any[];
if (items.length === 0) { toast.warning("수주 품목 정보가 없습니다."); return; }
setCancelLines(items.map((it) => ({
objid: String(it.objid ?? ""),
part_no: String(it.part_no ?? it.master_item_name ?? ""),
part_name: String(it.part_name ?? ""),
order_qty: parseInt(String(it.order_quantity ?? "0").replace(/,/g, ""), 10) || 0,
cancel_qty: String(it.cancel_qty ?? ""),
})));
setCancelDialogOpen(true);
} catch (err: any) {
toast.error(err?.response?.data?.message ?? err.message);
}
};
const updateCancelLine = (idx: number, val: string) => {
setCancelLines((prev) => prev.map((l, i) => i === idx ? { ...l, cancel_qty: val } : l));
};
const submitCancelQty = async () => {
if (!selected) return;
// 클라이언트 측 사전 검증 (wace preConfirm)
for (const l of cancelLines) {
if (l.order_qty === 0) continue;
const v = (l.cancel_qty ?? "").trim();
if (v === "") continue;
const n = parseInt(v, 10);
if (Number.isNaN(n) || n < 0) { toast.warning("취소 수량은 0 이상이어야 합니다."); return; }
if (n >= l.order_qty) { toast.warning(`취소 수량(${n})은 수주수량(${l.order_qty})보다 적어야 합니다.`); return; }
}
setCancelSaving(true);
try {
const entries = cancelLines.map((l) => ({
itemObjId: l.objid,
cancelQty: l.cancel_qty ?? "",
orderQty: String(l.order_qty),
}));
const res = await salesOrderMgmtApi.saveCancelQty(selected.objid, entries);
toast.success(res?.message ?? "수주취소 수량이 저장되었습니다.");
setCancelDialogOpen(false);
await fetchList();
} catch (err: any) {
toast.error(err?.response?.data?.message ?? "수주취소 저장 중 오류가 발생했습니다.");
} finally {
setCancelSaving(false);
}
};
const updateItem = (idx: number, key: keyof OrderItem, val: any) => {
@@ -225,9 +392,95 @@ export default function SalesOrderPage() {
return { ...prev, items };
});
};
// wace fn_calculateItemAmount / fn_calculateFromSupplyPrice / fn_calculateTotalFromVat 이식
// 수량 또는 단가 변경 → 공급가액 = 수량×단가, 부가세 = round(공급가액×0.1), 총액 = 공급가액+부가세
// 공급가액 직접 입력 → 부가세 = round(supply×0.1), 총액 = supply+vat
// 부가세 직접 입력 → 총액 = supply+vat 재계산만
// 총액 직접 입력 → 다른 값 영향 없음
const toNum = (v: any) => Number(String(v ?? "0").replace(/,/g, "")) || 0;
const updateItemWithCalc = (idx: number, key: keyof OrderItem, val: any) => {
setForm((prev) => {
const items = [...(prev.items ?? [])];
const cur: any = { ...items[idx], [key]: val };
if (key === "order_quantity" || key === "order_unit_price") {
const supply = toNum(cur.order_quantity) * toNum(cur.order_unit_price);
const vat = Math.round(supply * 0.1);
cur.order_supply_price = String(supply);
cur.order_vat = String(vat);
cur.order_total_amount = String(supply + vat);
} else if (key === "order_supply_price") {
const supply = toNum(cur.order_supply_price);
const vat = Math.round(supply * 0.1);
cur.order_vat = String(vat);
cur.order_total_amount = String(supply + vat);
} else if (key === "order_vat") {
cur.order_total_amount = String(toNum(cur.order_supply_price) + toNum(cur.order_vat));
}
items[idx] = cur;
return { ...prev, items };
});
};
const addItem = () => setForm((prev) => ({ ...prev, items: [...(prev.items ?? []), { ...EMPTY_ITEM, seq: (prev.items?.length ?? 0) + 1 }] }));
const removeItem = (idx: number) => setForm((prev) => ({ ...prev, items: (prev.items ?? []).filter((_, i) => i !== idx).map((it, i) => ({ ...it, seq: i + 1 })) }));
// S/N 관리 (wace fn_openItemSnPopup) — 견적관리와 동일 패턴
const openSerialDialog = (idx: number) => {
const item = form.items?.[idx];
setSerialDialogIdx(idx);
setSerialDraft([...(item?.serials ?? [])]);
setSerialInput("");
setSerialDialogOpen(true);
};
const addSerialDraft = () => {
const v = serialInput.trim();
if (!v) { toast.warning("S/N을 입력해주세요."); return; }
if (serialDraft.includes(v)) { toast.warning("이미 등록된 S/N입니다."); return; }
setSerialDraft((prev) => [...prev, v]);
setSerialInput("");
};
const removeSerialDraft = (i: number) => setSerialDraft((prev) => prev.filter((_, k) => k !== i));
const applySerialDraft = () => {
if (serialDialogIdx === null) return;
updateItem(serialDialogIdx, "serials", [...serialDraft]);
setSerialDialogOpen(false);
};
const openSeqDialog = () => { setSeqStartNo(""); setSeqCount(""); setSeqDialogOpen(true); };
const generateSequentialSn = () => {
const startNo = seqStartNo.trim();
const count = parseInt(seqCount, 10);
if (!startNo) { toast.warning("시작 번호를 입력해주세요."); return; }
if (!count || count < 1) { toast.warning("생성 개수를 1 이상 입력해주세요."); return; }
if (count > 100) { toast.warning("최대 100개까지만 생성 가능합니다."); return; }
const m = startNo.match(/^(.*?)(\d+)$/);
if (!m) { toast.warning("형식이 올바르지 않습니다. 마지막에 숫자가 있어야 합니다."); return; }
const prefix = m[1]; const startNum = parseInt(m[2], 10); const numLen = m[2].length;
setSerialDraft((prev) => {
const next = [...prev];
for (let i = 0; i < count; i++) {
const sn = prefix + String(startNum + i).padStart(numLen, "0");
if (!next.includes(sn)) next.push(sn);
}
return next;
});
setSeqDialogOpen(false);
};
// 라인 합계 자동 계산용 헬퍼
const formatNum = (v: any) => {
const n = Number(String(v ?? "0").replace(/,/g, ""));
return isNaN(n) ? 0 : n;
};
const lineTotal = useMemo(() => {
const items = form.items ?? [];
return items.reduce((acc, it) => ({
qty: acc.qty + formatNum(it.order_quantity),
supply: acc.supply + formatNum(it.order_supply_price),
vat: acc.vat + formatNum(it.order_vat),
total: acc.total + formatNum(it.order_total_amount),
}), { qty: 0, supply: 0, vat: 0, total: 0 });
}, [form.items]);
return (
<div className="flex h-full flex-col overflow-hidden p-4 gap-4">
{ConfirmDialogComponent}
@@ -241,8 +494,10 @@ export default function SalesOrderPage() {
<Button variant="outline" size="sm" onClick={fetchList} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Search className="w-4 h-4 mr-1" />}
</Button>
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={openEdit} disabled={!selected}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" onClick={() => { if (selected) openEdit(); else openCreate(); }}>
{selected ? <Pencil className="w-4 h-4 mr-1" /> : <Plus className="w-4 h-4 mr-1" />}
{selected ? "수주수정" : "수주입력"}
</Button>
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-700 text-white" onClick={handleConfirmOrder} disabled={!selected}>
<CheckCircle2 className="w-4 h-4 mr-1" />
</Button>
@@ -255,7 +510,7 @@ export default function SalesOrderPage() {
<Button size="sm" variant="ghost"
onClick={() => setSearchForm({
category_cd: "", search_poNo: "", customer_objid: "",
search_partObjId: "", search_serialNo: "", contract_result: "",
search_partObjId: "", search_partName: "", search_serialNo: "", contract_result: "",
order_start_date: "", order_end_date: "",
due_start_date: "", due_end_date: "",
})}>
@@ -336,8 +591,16 @@ export default function SalesOrderPage() {
</div>
<DataGrid
columns={GRID_COLUMNS}
columns={gridColumns}
data={rows}
showCheckbox
checkedIds={selected ? [selected.objid] : []}
onCheckedChange={(ids) => {
// wace 패턴: 다중 선택 가능하나 액션 시 1개 검증. 여기선 마지막 1개만 selected에 반영해 단일 흐름 유지
if (ids.length === 0) { setSelected(null); return; }
const last = ids[ids.length - 1];
setSelected(rows.find((r) => r.id === last) ?? null);
}}
selectedId={selected ? selected.objid : null}
onSelect={(id) => setSelected(id ? rows.find((r) => r.id === id) ?? null : null)}
onRowDoubleClick={(row) => { setSelected(row); openEdit(); }}
@@ -346,107 +609,200 @@ export default function SalesOrderPage() {
/>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogContent
className="!max-w-[95vw] w-[95vw] max-h-[92vh] overflow-y-auto"
onInteractOutside={(e) => e.preventDefault()} /* 자식 S/N·연속번호 Dialog 닫힐 때 부모까지 닫히는 현상 차단 */
>
<DialogHeader>
<DialogTitle>{dialogMode === "create" ? "주문서 등록" : "주문서 수정"}</DialogTitle>
<DialogDescription> + .</DialogDescription>
<DialogTitle>
{/* 이 통합폼은 항상 is_direct_order='Y' 케이스만 처리 (wace estimateAndOrderRegistFormPopup) */}
_ _ {dialogMode === "edit" ? "수정" : "등록"}
</DialogTitle>
<DialogDescription className="sr-only"> + . (wace estimateAndOrderRegistFormPopup 1:1)</DialogDescription>
</DialogHeader>
{/* 수주통합 기본정보 — wace 헤더 9개 (영업번호 자동채번 표시 포함) */}
<fieldset className="border rounded-md p-3">
<legend className="text-sm font-semibold px-2"> </legend>
<legend className="text-sm font-semibold px-2"> </legend>
<div className="grid grid-cols-4 gap-3">
<div><Label className="text-xs"> ()</Label>
<Input readOnly className="bg-muted/30"
value={form.contract_no ?? ""}
placeholder="저장 시 자동 부여됩니다" /></div>
<div><Label className="text-xs"></Label>
<Input value={form.po_no ?? ""} onChange={(e) => setForm({ ...form, po_no: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Input type="date" value={form.contract_date ?? ""} onChange={(e) => setForm({ ...form, contract_date: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Input type="date" value={form.req_del_date ?? ""} onChange={(e) => setForm({ ...form, req_del_date: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<CustomerSelect
value={form.customer_objid ?? ""}
onValueChange={(v) => setForm({ ...form, customer_objid: v })}
/></div>
<div><Label className="text-xs"></Label>
<Input type="date" value={form.receipt_date ?? ""} onChange={(e) => setForm({ ...form, receipt_date: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Input value={form.contract_currency ?? "KRW"} onChange={(e) => setForm({ ...form, contract_currency: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Input value={form.exchange_rate ?? ""} onChange={(e) => setForm({ ...form, exchange_rate: e.target.value })} /></div>
<div><Label className="text-xs">/</Label>
<Select value={form.paid_type ?? "paid"} onValueChange={(v) => setForm({ ...form, paid_type: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<div>
<Label className="text-xs"> ()</Label>
<Input readOnly className="bg-muted/30" value={form.contract_no ?? ""} placeholder="저장 시 자동 부여" />
</div>
<div>
<Label className="text-xs"> <span className="text-rose-600">*</span></Label>
<CommCodeSelect groupId="0000167" value={form.category_cd ?? ""}
onValueChange={(v) => setForm({ ...form, category_cd: v })} />
</div>
<div>
<Label className="text-xs">/ <span className="text-rose-600">*</span></Label>
<CommCodeSelect groupId="0001219" value={form.area_cd ?? ""}
onValueChange={(v) => setForm({ ...form, area_cd: v })} />
</div>
<div>
<Label className="text-xs"> <span className="text-rose-600">*</span></Label>
<CustomerSelect value={form.customer_objid ?? ""}
onValueChange={(v) => setForm({ ...form, customer_objid: v })} />
</div>
<div>
<Label className="text-xs">/ <span className="text-rose-600">*</span></Label>
<Select value={form.paid_type || undefined}
onValueChange={(v) => setForm({ ...form, paid_type: v })}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="paid"></SelectItem>
<SelectItem value="free"></SelectItem>
</SelectContent>
</Select></div>
<div><Label className="text-xs"></Label>
<Select value={form.contract_result || "WAITING"} onValueChange={(v) => setForm({ ...form, contract_result: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{CONTRACT_RESULTS.filter((o) => o.value).map((o) => (<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>))}
</SelectContent>
</Select></div>
<div><Label className="text-xs">(PM ID)</Label>
<Input value={form.pm_user_id ?? ""} onChange={(e) => setForm({ ...form, pm_user_id: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Input value={form.shipping_method ?? ""} onChange={(e) => setForm({ ...form, shipping_method: e.target.value })} /></div>
<div className="col-span-4"><Label className="text-xs"> </Label>
<Textarea rows={2} value={form.customer_request ?? ""} onChange={(e) => setForm({ ...form, customer_request: e.target.value })} /></div>
</Select>
</div>
<div>
<Label className="text-xs"> <span className="text-rose-600">*</span></Label>
<Input type="date" value={form.receipt_date ?? ""}
onChange={(e) => setForm({ ...form, receipt_date: e.target.value })} />
</div>
<div>
<Label className="text-xs"></Label>
<CommCodeSelect groupId="0001533" value={form.contract_currency ?? ""}
onValueChange={(v) => setForm({ ...form, contract_currency: v })} />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={form.exchange_rate ?? ""}
onChange={(e) => setForm({ ...form, exchange_rate: e.target.value })} />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={form.po_no ?? ""}
onChange={(e) => setForm({ ...form, po_no: e.target.value })} />
</div>
<div>
<Label className="text-xs"> <span className="text-rose-600">*</span></Label>
<Input type="date" value={form.order_date ?? ""}
onChange={(e) => setForm({ ...form, order_date: e.target.value })} />
</div>
</div>
</fieldset>
{/* 품목정보 — wace 13컬럼 + Total 합계 행 */}
<fieldset className="border rounded-md p-3 space-y-2">
<legend className="text-sm font-semibold px-2"> </legend>
<legend className="text-sm font-semibold px-2 flex items-center justify-between w-full">
<span></span>
</legend>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="p-2 w-10">#</th>
<th className="p-2 w-32"> ID</th>
<th className="p-2"></th>
<th className="p-2"></th>
<th className="p-2 w-24"></th>
<th className="p-2 w-28"></th>
<th className="p-2 w-28"></th>
<th className="p-2 w-24"></th>
<th className="p-2 w-28"></th>
<th className="p-2 w-28"></th>
<th className="p-2 w-10"></th>
<th className="p-2 w-10 whitespace-nowrap">No</th>
<th className="p-2 w-28 whitespace-nowrap"> <span className="text-rose-600">*</span></th>
<th className="p-2 w-40 whitespace-nowrap"> <span className="text-rose-600">*</span></th>
<th className="p-2 w-56 whitespace-nowrap"> <span className="text-rose-600">*</span></th>
<th className="p-2 w-40 whitespace-nowrap">S/N</th>
<th className="p-2 w-36 whitespace-nowrap"></th>
<th className="p-2 min-w-[220px] whitespace-nowrap"></th>
<th className="p-2 w-28 whitespace-nowrap"></th>
<th className="p-2 w-24 whitespace-nowrap text-right"> <span className="text-rose-600">*</span></th>
<th className="p-2 w-28 whitespace-nowrap text-right"></th>
<th className="p-2 w-32 whitespace-nowrap text-right"></th>
<th className="p-2 w-28 whitespace-nowrap text-right"></th>
<th className="p-2 w-32 whitespace-nowrap text-right"></th>
<th className="p-2 w-12 whitespace-nowrap"></th>
</tr>
</thead>
<tbody>
{(form.items ?? []).map((it, idx) => (
<tr key={idx} className="border-t">
<td className="p-1 text-center">{it.seq}</td>
<td className="p-1"><Input className="h-8" value={it.part_objid} onChange={(e) => updateItem(idx, "part_objid", e.target.value)} /></td>
<td className="p-1"><Input className="h-8" value={it.part_no} onChange={(e) => updateItem(idx, "part_no", e.target.value)} /></td>
<td className="p-1"><Input className="h-8" value={it.part_name} onChange={(e) => updateItem(idx, "part_name", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_quantity ?? ""} onChange={(e) => updateItem(idx, "order_quantity", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_unit_price ?? ""} onChange={(e) => updateItem(idx, "order_unit_price", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_supply_price ?? ""} onChange={(e) => updateItem(idx, "order_supply_price", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_vat ?? ""} onChange={(e) => updateItem(idx, "order_vat", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_total_amount ?? ""} onChange={(e) => updateItem(idx, "order_total_amount", e.target.value)} /></td>
<td className="p-1"><Input className="h-8" type="date" value={it.due_date ?? ""} onChange={(e) => updateItem(idx, "due_date", e.target.value)} /></td>
<td className="p-1 text-center"><Button variant="ghost" size="icon" onClick={() => removeItem(idx)}><Trash2 className="w-3 h-3" /></Button></td>
<td className="p-1">
<CommCodeSelect groupId="0000001"
value={it.product ?? ""}
onValueChange={(v) => updateItem(idx, "product", v)} />
</td>
<td className="p-1">
<PartSelect mode="partNo" value={it.part_objid}
onValueChange={(partObjId, row) => {
setForm((prev) => {
const items = [...(prev.items ?? [])];
items[idx] = { ...items[idx], part_objid: partObjId,
part_no: row?.item_number ?? "",
part_name: row?.item_name ?? items[idx].part_name };
return { ...prev, items };
});
}} />
</td>
<td className="p-1">
<PartSelect mode="partName" value={it.part_objid}
onValueChange={(partObjId, row) => {
setForm((prev) => {
const items = [...(prev.items ?? [])];
items[idx] = { ...items[idx], part_objid: partObjId,
part_no: row?.item_number ?? items[idx].part_no,
part_name: row?.item_name ?? "" };
return { ...prev, items };
});
}} />
</td>
<td className="p-1">
<Input className="h-8" readOnly
value={(it.serials ?? []).join(", ")}
onClick={() => openSerialDialog(idx)}
placeholder="클릭하여 S/N 추가" />
</td>
<td className="p-1">
<Input className="h-8" type="date" value={it.due_date ?? ""}
onChange={(e) => updateItem(idx, "due_date", e.target.value)} />
</td>
<td className="p-1">
<Textarea className="min-h-[34px] resize-y text-xs" rows={1}
value={it.customer_request ?? ""}
onChange={(e) => updateItem(idx, "customer_request", e.target.value)} />
</td>
<td className="p-1">
<CommCodeSelect groupId="0001810"
value={it.return_reason ?? ""}
onValueChange={(v) => updateItem(idx, "return_reason", v)} />
</td>
<td className="p-1">
<Input className="h-8 text-right" type="number" min={0}
value={it.order_quantity ?? ""}
onChange={(e) => updateItemWithCalc(idx, "order_quantity", e.target.value)} />
</td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_unit_price ?? ""}
onChange={(e) => updateItemWithCalc(idx, "order_unit_price", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_supply_price ?? ""}
onChange={(e) => updateItemWithCalc(idx, "order_supply_price", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_vat ?? ""}
onChange={(e) => updateItemWithCalc(idx, "order_vat", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_total_amount ?? ""}
onChange={(e) => updateItem(idx, "order_total_amount", e.target.value)} /></td>
<td className="p-1 text-center">
<Button variant="ghost" size="icon" onClick={() => removeItem(idx)}>
<Trash2 className="w-3 h-3" />
</Button>
</td>
</tr>
))}
{(!form.items || form.items.length === 0) && (
<tr><td colSpan={11} className="p-3 text-center text-muted-foreground"> .</td></tr>
<tr><td colSpan={14} className="p-3 text-center text-muted-foreground"> .</td></tr>
)}
</tbody>
{(form.items?.length ?? 0) > 0 && (
<tfoot>
<tr className="bg-muted/30 font-semibold">
<td colSpan={8} className="p-2 text-center">Total</td>
<td className="p-2 text-right">{lineTotal.qty.toLocaleString()}</td>
<td className="p-2"></td>
<td className="p-2 text-right">{lineTotal.supply.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="p-2 text-right">{lineTotal.vat.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="p-2 text-right">{lineTotal.total.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="p-2"></td>
</tr>
</tfoot>
)}
</table>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={addItem}>
<Plus className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="outline" onClick={() => setItemDialogOpen(true)}>
<Search className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
</fieldset>
@@ -460,6 +816,196 @@ export default function SalesOrderPage() {
</DialogContent>
</Dialog>
{/* S/N 관리 — wace fn_openItemSnPopup */}
<Dialog open={serialDialogOpen} onOpenChange={setSerialDialogOpen}>
<DialogContent className="max-w-2xl" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle className="text-center">S/N </DialogTitle>
<DialogDescription className="sr-only"> / </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="max-h-[300px] overflow-y-auto border rounded">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="border px-3 py-2 w-16 text-center"></th>
<th className="border px-3 py-2 text-center">S/N</th>
<th className="border px-3 py-2 w-20 text-center"></th>
</tr>
</thead>
<tbody>
{serialDraft.length === 0 ? (
<tr><td colSpan={3} className="border px-3 py-8 text-center text-muted-foreground"> S/N이 .</td></tr>
) : serialDraft.map((s, i) => (
<tr key={i}>
<td className="border px-3 py-1.5 text-center">{i + 1}</td>
<td className="border px-3 py-1.5">{s}</td>
<td className="border px-3 py-1.5 text-center">
<Button size="sm" variant="outline" onClick={() => removeSerialDraft(i)}></Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex gap-2">
<Input value={serialInput} onChange={(e) => setSerialInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addSerialDraft(); } }}
placeholder="S/N 입력" />
<Button onClick={addSerialDraft} type="button"></Button>
</div>
</div>
<DialogFooter className="sm:justify-center">
<Button variant="outline" onClick={openSeqDialog}></Button>
<Button onClick={applySerialDraft}></Button>
<Button variant="outline" onClick={() => setSerialDialogOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 연속번호 생성 — wace fn_openItemSequentialSnPopup */}
<Dialog open={seqDialogOpen} onOpenChange={setSeqDialogOpen}>
<DialogContent className="max-w-sm" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription className="sr-only"> S/N </DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs"> <span className="text-rose-600">*</span></Label>
<Input value={seqStartNo} onChange={(e) => setSeqStartNo(e.target.value)} placeholder="예: ITEM-001" />
</div>
<div>
<Label className="text-xs"> <span className="text-rose-600">*</span></Label>
<Input type="number" min={1} max={100} value={seqCount}
onChange={(e) => setSeqCount(e.target.value)} placeholder="예: 10" />
</div>
<div className="bg-muted/40 rounded p-2 text-[11px] leading-5 text-muted-foreground">
: ITEM-001, 3 ITEM-001, ITEM-002, ITEM-003<br />
100
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSeqDialogOpen(false)}></Button>
<Button onClick={generateSequentialSn}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 첨부파일 다이얼로그 — 주문서첨부 클립 컬럼 클릭 시 */}
{attachContext && (
<AttachmentDialog
open={attachDialogOpen}
onOpenChange={setAttachDialogOpen}
targetObjid={attachContext.targetObjid}
docType={attachContext.docType}
uploadDocType={attachContext.uploadDocType}
uploadDocTypeName={attachContext.uploadDocTypeName}
title={attachContext.title}
onChanged={fetchList}
/>
)}
{/* 주문서 자동생성 뷰 — 주문서 폴더 컬럼 클릭 시 (wace orderFormView 대응) */}
<OrderFormViewDialog
open={orderFormOpen}
onOpenChange={setOrderFormOpen}
objid={orderFormObjid}
/>
{/* 견적요청에서 시작된 행의 수주등록 다이얼로그 (wace orderRegistFormPopup 1:1) */}
<OrderRegistDialog
open={orderRegistOpen}
onOpenChange={setOrderRegistOpen}
contractObjId={orderRegistContract?.objid ?? null}
contractNo={orderRegistContract?.contractNo ?? null}
onSaved={() => { setOrderRegistOpen(false); fetchList(); }}
/>
{/* 수주확정 — wace fn_openOrderConfirmPopup 이식: 상태 select */}
<Dialog open={confirmStatusOpen} onOpenChange={setConfirmStatusOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription className="sr-only"> .</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="text-sm">
<span className="font-semibold"> : </span>
<span>{selected?.contract_result ? (CONTRACT_RESULTS.find(o => o.value === selected.contract_result)?.label ?? selected.contract_result) : "-"}</span>
</div>
<div>
<Label className="text-xs mb-1 block"> <span className="text-rose-600">*</span></Label>
<Select value={confirmStatusValue || undefined} onValueChange={setConfirmStatusValue}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{CONTRACT_RESULTS.filter((o) => o.value).map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmStatusOpen(false)}></Button>
<Button onClick={submitConfirmStatus}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 수주취소 — wace fn_openOrderCancelPopup 이식: 라인별 cancel_qty input */}
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription className="sr-only"> . .</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] overflow-y-auto">
<table className="w-full text-sm border">
<thead className="bg-muted/50">
<tr>
<th className="border px-2 py-2 w-32"></th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2 w-24 text-right"></th>
<th className="border px-2 py-2 w-32 text-center"></th>
</tr>
</thead>
<tbody>
{cancelLines.map((l, idx) => (
<tr key={idx}>
<td className="border px-2 py-1 text-center">{l.part_no}</td>
<td className="border px-2 py-1">{l.part_name}</td>
<td className="border px-2 py-1 text-right">{l.order_qty > 0 ? l.order_qty.toLocaleString() : "-"}</td>
<td className="border px-2 py-1 text-center">
{l.order_qty > 0 ? (
<Input
type="number"
min={0}
max={l.order_qty - 1}
value={l.cancel_qty}
onChange={(e) => updateCancelLine(idx, e.target.value)}
className="h-8 text-right"
placeholder="0"
/>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-muted-foreground"> , .</p>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelDialogOpen(false)}></Button>
<Button className="bg-rose-600 hover:bg-rose-700 text-white" onClick={submitCancelQty} disabled={cancelSaving}>
{cancelSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 품목 검색 — 등록 다이얼로그 (다중 선택) */}
<ItemSearchDialog
open={itemDialogOpen}
@@ -23,11 +23,11 @@ import { salesSaleApi, RevenueListRow, DeadlineInfoBody } from "@/lib/api/salesS
// wace_plm revenueMgmtList.jsp 컬럼 순서/라벨에 맞춤
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
{ key: "project_no", label: "프로젝트번호", width: "w-[170px]", frozen: true },
{ key: "order_type_name", label: "주문유형", width: "w-[90px]", align: "center" },
{ key: "sales_deadline_date", label: "매출마감", width: "w-[110px]", align: "center" },
{ key: "order_date", label: "발주일", width: "w-[100px]", align: "center" },
{ key: "po_no", label: "발주번호", width: "w-[110px]" },
{ key: "order_date", label: "발주일", width: "w-[115px]", align: "center" },
{ key: "po_no", label: "발주번호", width: "w-[140px]" },
{ key: "customer", label: "고객사", width: "w-[160px]" },
{ key: "product_type_name", label: "제품구분", width: "w-[90px]", align: "center" },
{ key: "product_name", label: "품명", width: "w-[180px]" },
@@ -37,7 +37,7 @@ const GRID_COLUMNS: DataGridColumn[] = [
{ key: "sales_vat", label: "부가세", width: "w-[100px]", formatMoney: true },
{ key: "sales_total_amount", label: "총액", width: "w-[120px]", formatMoney: true },
{ key: "sales_total_amount_krw", label: "원화총액", width: "w-[120px]", formatMoney: true },
{ key: "shipping_date", label: "출하일", width: "w-[100px]", align: "center" },
{ key: "shipping_date", label: "출하일", width: "w-[115px]", align: "center" },
{ key: "nation_name", label: "국내/해외", width: "w-[90px]", align: "center" },
{ key: "sales_currency_name", label: "환종", width: "w-[70px]", align: "center" },
{ key: "sales_exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true },
@@ -49,6 +49,17 @@ const GRID_COLUMNS: DataGridColumn[] = [
{ key: "export_decl_no", label: "수출신고필증신고번호", width: "w-[160px]" },
{ key: "loading_date", label: "선적일자", width: "w-[100px]", align: "center" },
{ key: "has_transaction_statement", label: "거래명세서", width: "w-[100px]", align: "center" },
/* wace revenueMgmtList.jsp 615~632 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제.
{ key: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" },
{ key: "payment_type_name", label: "유/무상", width: "w-[80px]", align: "center" },
{ key: "request_date", label: "요청납기", width: "w-[115px]", align: "center" },
{ key: "customer_request", label: "고객사요청사항", width: "w-[180px]" },
{ key: "order_status_name", label: "수주상태", width: "w-[90px]", align: "center" },
{ key: "cu01_cnt", label: "주문서첨부", width: "w-[90px]", align: "center", renderType: "clip" },
{ key: "shipping_method", label: "출하방법", width: "w-[90px]", align: "center" },
{ key: "manager_name", label: "담당자", width: "w-[100px]", align: "center" },
{ key: "incoterms", label: "인도조건", width: "w-[90px]", align: "center" },
*/
];
export default function SalesRevenuePage() {
@@ -21,12 +21,12 @@ import { salesSaleApi, SaleListRow, SaleRegisterBody } from "@/lib/api/salesSale
// wace_plm salesMgmtList.jsp 컬럼 순서/라벨에 맞춤
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
{ key: "project_no", label: "프로젝트번호", width: "w-[170px]", frozen: true },
{ key: "order_type_name", label: "주문유형", width: "w-[90px]", align: "center" },
{ key: "order_date", label: "발주일", width: "w-[100px]", align: "center" },
{ key: "po_no", label: "발주번호", width: "w-[110px]" },
{ key: "request_date", label: "요청납기", width: "w-[100px]", align: "center" },
{ key: "shipping_date", label: "출하일", width: "w-[100px]", align: "center" },
{ key: "order_date", label: "발주일", width: "w-[115px]", align: "center" },
{ key: "po_no", label: "발주번호", width: "w-[140px]" },
{ key: "request_date", label: "요청납기", width: "w-[115px]", align: "center" },
{ key: "shipping_date", label: "출하일", width: "w-[115px]", align: "center" },
{ key: "customer", label: "고객사", width: "w-[160px]" },
{ key: "product_name", label: "품명", width: "w-[180px]" },
{ key: "order_quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
@@ -48,6 +48,16 @@ const GRID_COLUMNS: DataGridColumn[] = [
{ key: "serial_no", label: "S/N", width: "w-[140px]" },
{ key: "split_serial_no", label: "분할S/N", width: "w-[140px]" },
{ key: "product_no", label: "품번", width: "w-[120px]" },
/* wace salesMgmtList.jsp 503~519 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제.
{ key: "product_type_name", label: "제품구분", width: "w-[90px]", align: "center" },
{ key: "nation_name", label: "국내/해외", width: "w-[90px]", align: "center" },
{ key: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" },
{ key: "customer_request", label: "고객사요청사항", width: "w-[180px]" },
{ key: "cu01_cnt", label: "주문서첨부", width: "w-[90px]", align: "center", renderType: "clip" },
{ key: "shipping_method", label: "출하방법", width: "w-[90px]", align: "center" },
{ key: "manager_name", label: "담당자", width: "w-[100px]", align: "center" },
{ key: "incoterms", label: "인도조건", width: "w-[90px]", align: "center" },
*/
{ key: "has_transaction_statement", label: "거래명세서", width: "w-[100px]", align: "center" },
];
@@ -0,0 +1,286 @@
"use client";
/**
* AttachmentDialog — 공통 첨부파일 모달
*
* 어디서나 재사용. attach_file_info(target_objid, doc_type) 기반.
* - 목록 조회 / 다운로드 / 업로드(다중) / 삭제
* - readOnly=true 면 조회 전용
* - onChanged 콜백으로 그리드 카운트 갱신 트리거
*/
import { useCallback, useEffect, useRef, useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Download, Paperclip, Trash2, Upload } from "lucide-react";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
interface AttachmentFile {
objid: string;
realFileName: string;
savedFileName: string;
fileSize: number;
fileExt: string;
filePath: string;
docType: string;
docTypeName?: string;
writer: string;
regdate: string;
}
export interface AttachmentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** attach_file_info.target_objid (보통 contract_mgmt.objid 등) */
targetObjid: string | number | null | undefined;
/** 조회 시 doc_type 필터. 단일 문자열 또는 배열(예: ["FTC_ORDER","ORDER"]) */
docType: string | string[];
/** 새 업로드 시 INSERT할 doc_type. 미지정 시 docType 단일/배열의 첫 값 사용 */
uploadDocType?: string;
/** docType_name 컬럼에 저장할 한글 라벨 (선택) */
uploadDocTypeName?: string;
title?: string;
/** 업로드/삭제 버튼 비활성화 (조회 전용) */
readOnly?: boolean;
/** 업로드/삭제 후 호출 — 그리드 카운트 갱신용 */
onChanged?: () => void;
}
export function AttachmentDialog({
open,
onOpenChange,
targetObjid,
docType,
uploadDocType,
uploadDocTypeName,
title = "첨부파일",
readOnly = false,
onChanged,
}: AttachmentDialogProps) {
const [files, setFiles] = useState<AttachmentFile[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const docTypeQuery = Array.isArray(docType) ? docType.join(",") : docType;
const docTypeForUpload =
uploadDocType ?? (Array.isArray(docType) ? docType[0] : docType);
const loadFiles = useCallback(async () => {
if (!targetObjid) {
setFiles([]);
return;
}
setLoading(true);
try {
const res = await apiClient.get("/files", {
params: { targetObjid: String(targetObjid), docType: docTypeQuery },
});
if (res.data?.success) {
setFiles(res.data.files || []);
} else {
setFiles([]);
}
} catch (e: any) {
toast.error("파일 목록 조회 실패: " + (e?.message ?? ""));
setFiles([]);
} finally {
setLoading(false);
}
}, [targetObjid, docTypeQuery]);
useEffect(() => {
if (open) {
loadFiles();
}
}, [open, loadFiles]);
async function handleUpload(filesToUpload: FileList | null) {
if (!filesToUpload || filesToUpload.length === 0) return;
if (!targetObjid) {
toast.error("대상 ID(targetObjid)가 없습니다.");
return;
}
if (!docTypeForUpload) {
toast.error("업로드 doc_type을 결정할 수 없습니다.");
return;
}
setUploading(true);
try {
const fd = new FormData();
for (let i = 0; i < filesToUpload.length; i++) {
fd.append("files", filesToUpload[i]);
}
fd.append("targetObjid", String(targetObjid));
fd.append("docType", docTypeForUpload);
if (uploadDocTypeName) fd.append("docTypeName", uploadDocTypeName);
const res = await apiClient.post("/files/upload", fd, {
headers: { "Content-Type": "multipart/form-data" },
});
if (res.data?.success) {
toast.success(`${filesToUpload.length}건 업로드 완료`);
await loadFiles();
onChanged?.();
} else {
toast.error(res.data?.message || "업로드 실패");
}
} catch (e: any) {
toast.error("업로드 실패: " + (e?.message ?? ""));
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function handleDelete(objid: string, name: string) {
if (!window.confirm(`'${name}' 파일을 삭제하시겠습니까?`)) return;
try {
await apiClient.delete(`/files/${objid}`);
toast.success("파일이 삭제되었습니다.");
await loadFiles();
onChanged?.();
} catch (e: any) {
toast.error("삭제 실패: " + (e?.message ?? ""));
}
}
function handleDownload(objid: string) {
const baseURL = (apiClient.defaults.baseURL ?? "").replace(/\/$/, "");
window.open(`${baseURL}/files/download/${objid}`, "_blank");
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Paperclip className="h-4 w-4" />
{title}
</DialogTitle>
</DialogHeader>
<div className="border rounded-md max-h-[50vh] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center">#</TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[110px] text-center"></TableHead>
<TableHead className="w-[150px] text-center"></TableHead>
<TableHead className="w-[110px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-6">
</TableCell>
</TableRow>
)}
{!loading && files.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-6">
.
</TableCell>
</TableRow>
)}
{!loading &&
files.map((f, i) => (
<TableRow key={f.objid}>
<TableCell className="text-center">{i + 1}</TableCell>
<TableCell className="truncate max-w-[260px]" title={f.realFileName}>
{f.realFileName}
</TableCell>
<TableCell className="text-right">{formatBytes(f.fileSize)}</TableCell>
<TableCell className="text-center">{f.writer}</TableCell>
<TableCell className="text-center">
{f.regdate ? f.regdate.replace("T", " ").slice(0, 16) : ""}
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(f.objid)}
title="다운로드"
>
<Download className="h-4 w-4" />
</Button>
{!readOnly && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(f.objid, f.realFileName)}
title="삭제"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter className="flex sm:justify-between gap-2">
{!readOnly ? (
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
onChange={(e) => handleUpload(e.target.files)}
className="hidden"
/>
<Button
variant="outline"
size="sm"
disabled={uploading || !targetObjid}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-4 w-4 mr-1" />
{uploading ? "업로드 중..." : "파일 추가"}
</Button>
</div>
) : (
<span />
)}
<Button variant="ghost" onClick={() => onOpenChange(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function formatBytes(n: number) {
if (!n) return "0 B";
const u = ["B", "KB", "MB", "GB"];
let i = 0;
let v = n;
while (v >= 1024 && i < u.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`;
}
@@ -3,10 +3,9 @@
import React, { useEffect, useState } from "react";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { apiClient } from "@/lib/api/client";
import { toContractCustomerObjid } from "@/components/common/CustomerSearchDialog";
interface CustomerSelectProps {
/** contract_mgmt.customer_objid 형식 ('C_0000007555') */
/** contract_mgmt.customer_objid 형식 ('C_{customer_code}') — 운영 데이터 호환 */
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
@@ -17,6 +16,8 @@ interface CustomerSelectProps {
let cached: SmartSelectOption[] | null = null;
let inflight: Promise<SmartSelectOption[]> | null = null;
// 운영 wace 데이터: contract_mgmt.customer_objid = 'C_' + customer_mng.customer_code
// (이전엔 customer_mng.id padded로 매핑했으나 운영 데이터와 어긋났음 — 26C-0801 라온기술/정림유리 미스매치 사례)
const fetchCustomers = async (): Promise<SmartSelectOption[]> => {
if (cached) return cached;
if (inflight) return inflight;
@@ -24,9 +25,9 @@ const fetchCustomers = async (): Promise<SmartSelectOption[]> => {
const res = await apiClient.get("/sales/customers");
const rows = (res.data?.data ?? []) as any[];
cached = rows
.filter((r) => r.id != null && r.customer_name)
.filter((r) => r.customer_code && r.customer_name)
.map((r) => ({
code: toContractCustomerObjid(r.id),
code: `C_${r.customer_code}`,
label: String(r.customer_name),
}));
return cached!;
+155 -18
View File
@@ -46,6 +46,10 @@ export interface DataGridColumn {
/** 이미지 타입 — 값을 /api/files/preview/{objid} 이미지로 렌더링 */
renderType?: "image" | "folder" | "clip";
selectOptions?: { value: string; label: string }[];
/** 셀 클릭 핸들러 — folder/clip 등 인터랙션 컬럼에서 모달 오픈용. 행 클릭으로 전파되지 않음 */
onClick?: (row: any) => void;
/** 좌측 고정 컬럼 (가로 스크롤 시 sticky-left). 첫 컬럼에만 사용 권장 */
frozen?: boolean;
}
export interface DataGridProps {
@@ -94,6 +98,8 @@ const fmtMoney = (val: any) => {
function SortableHeaderCell({
col, sortKey, sortDir, onSort,
headerFilterValues, uniqueValues, onToggleFilter, onClearFilter,
frozenLeftClass = "left-0",
widthPx, onResizeStart,
}: {
col: DataGridColumn;
sortKey: string | null;
@@ -103,6 +109,11 @@ function SortableHeaderCell({
uniqueValues: string[];
onToggleFilter: (colKey: string, value: string) => void;
onClearFilter: (colKey: string) => void;
frozenLeftClass?: string;
/** 사용자 리사이즈로 결정된 현재 너비(px). 없으면 col.width Tailwind 클래스 사용 */
widthPx?: number;
/** 리사이즈 핸들 mousedown 핸들러 */
onResizeStart?: (e: React.MouseEvent, colKey: string, currentWidthPx: number) => void;
}) {
const [filterSearch, setFilterSearch] = useState("");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
@@ -113,15 +124,25 @@ function SortableHeaderCell({
opacity: isDragging ? 0.5 : 1,
cursor: "grab",
};
if (widthPx != null) {
style.width = widthPx;
style.minWidth = widthPx;
style.maxWidth = widthPx;
}
const isSorted = sortKey === col.key;
const hasFilter = headerFilterValues.size > 0;
const effectiveWidthPx = widthPx ?? parseWidthClass(col.width) ?? 100;
return (
<TableHead
ref={setNodeRef}
style={style}
className={cn(col.width, col.minWidth, "select-none relative")}
className={cn(
widthPx == null && col.width, widthPx == null && col.minWidth,
"select-none relative",
col.frozen && cn("sticky z-20 bg-background", frozenLeftClass),
)}
>
<div className="inline-flex items-center gap-1">
<div
@@ -209,10 +230,28 @@ function SortableHeaderCell({
</Popover>
)}
</div>
{/* 리사이저 핸들 — 우측 가장자리 6px 영역에서 드래그하여 컬럼 너비 조정 */}
{onResizeStart && (
<div
onMouseDown={(e) => onResizeStart(e, col.key, effectiveWidthPx)}
onClick={(e) => e.stopPropagation()}
className="absolute right-0 top-0 h-full w-1.5 cursor-col-resize hover:bg-primary/40 active:bg-primary/60 transition-colors z-10"
aria-label="컬럼 너비 조정"
title="드래그하여 컬럼 너비 조정"
/>
)}
</TableHead>
);
}
// w-[XXXpx] Tailwind 클래스에서 px 정수 추출. 없으면 undefined.
function parseWidthClass(cls?: string): number | undefined {
if (!cls) return undefined;
const m = cls.match(/w-\[(\d+)px\]/);
return m ? Number(m[1]) : undefined;
}
// --- DataGrid ---
export function DataGrid({
@@ -277,6 +316,52 @@ export function DataGrid({
}
}, [gridId]); // eslint-disable-line react-hooks/exhaustive-deps
// 컬럼별 너비(px) — 사용자가 핸들로 드래그하면 갱신. localStorage에 영구 저장(gridId 있을 때).
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
useEffect(() => {
if (!gridId) return;
const saved = localStorage.getItem(`datagrid_col_widths_${gridId}`);
if (saved) {
try { setColumnWidths(JSON.parse(saved)); } catch { /* skip */ }
}
}, [gridId]);
const persistColumnWidths = useCallback((next: Record<string, number>) => {
setColumnWidths(next);
if (gridId) {
try { localStorage.setItem(`datagrid_col_widths_${gridId}`, JSON.stringify(next)); } catch { /* skip */ }
}
}, [gridId]);
// 리사이즈 드래그 핸들러 — 헤더 우측 핸들에서 mousedown 발생 시 호출.
const startResize = useCallback((e: React.MouseEvent, colKey: string, currentWidthPx: number) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startWidth = currentWidthPx;
const onMove = (ev: MouseEvent) => {
const delta = ev.clientX - startX;
const next = Math.max(40, Math.round(startWidth + delta));
setColumnWidths((prev) => ({ ...prev, [colKey]: next }));
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
// 최종 값으로 영구 저장 (state 최신값 직접 읽기 위해 setter 형태로)
setColumnWidths((latest) => {
if (gridId) {
try { localStorage.setItem(`datagrid_col_widths_${gridId}`, JSON.stringify(latest)); } catch { /* skip */ }
}
return latest;
});
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}, [gridId]);
// 컬럼별 고유값 계산 (필터 팝오버용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
@@ -501,12 +586,17 @@ export function DataGrid({
if (col.renderType === "folder") {
const cnt = Number(val);
const hasValue = !isNaN(cnt) && cnt > 0;
const clickable = !!col.onClick;
return (
<span className="inline-flex items-center justify-center w-full">
<span
className={cn("inline-flex items-center justify-center w-full", clickable && "cursor-pointer")}
onClick={clickable ? (e) => { e.stopPropagation(); col.onClick!(row); } : undefined}
>
<Folder
className={cn("w-5 h-5", hasValue
? "fill-[#1a73e8] text-[#1a73e8]"
: "fill-white text-muted-foreground/60")}
: "fill-white text-muted-foreground/60",
clickable && "hover:opacity-70")}
/>
</span>
);
@@ -516,9 +606,15 @@ export function DataGrid({
if (col.renderType === "clip") {
const cnt = Number(val);
const hasValue = !isNaN(cnt) && cnt > 0;
const clickable = !!col.onClick;
return (
<span className="inline-flex items-center justify-center gap-1 w-full">
<Paperclip className={cn("w-4 h-4", hasValue ? "text-[#1a73e8]" : "text-muted-foreground/50")} />
<span
className={cn("inline-flex items-center justify-center gap-1 w-full", clickable && "cursor-pointer")}
onClick={clickable ? (e) => { e.stopPropagation(); col.onClick!(row); } : undefined}
>
<Paperclip className={cn("w-4 h-4",
hasValue ? "text-[#1a73e8]" : "text-muted-foreground/50",
clickable && "hover:opacity-70")} />
{hasValue && <span className="text-[#1a73e8] font-bold text-xs">{cnt}</span>}
</span>
);
@@ -540,16 +636,23 @@ export function DataGrid({
);
};
// 좌측 고정 보조: NO/체크박스 컬럼은 40px(=left-10), 첫 frozen 컬럼은 그 다음 위치
const hasFrozen = columns.some((c) => c.frozen);
const hasFirstCol = showCheckbox || showRowNumber;
const stickyFirstColClass = "sticky left-0 z-20 bg-background";
const stickyFirstColBodyClass = "sticky left-0 z-[6]";
const frozenLeftClass = hasFirstCol ? "left-10" : "left-0";
return (
<div className="flex flex-col h-full flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-auto">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table noWrapper>
<Table noWrapper className="table-fixed">
<TableHeader className="sticky top-0 bg-background z-10 shadow-[0_1px_0_0_hsl(var(--border))]">
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow>
{showCheckbox && (
<TableHead className="w-[40px] text-center">
<TableHead className={cn("w-[40px] text-center", hasFrozen && stickyFirstColClass)}>
<Checkbox
checked={processedData.length > 0 && checkedIds.length === processedData.length}
onCheckedChange={(checked) => {
@@ -558,7 +661,9 @@ export function DataGrid({
/>
</TableHead>
)}
{showRowNumber && !showCheckbox && <TableHead className="w-[40px] text-center text-xs">No</TableHead>}
{showRowNumber && !showCheckbox && (
<TableHead className={cn("w-[40px] text-center text-xs", hasFrozen && stickyFirstColClass)}>No</TableHead>
)}
{columns.map((col) => (
<SortableHeaderCell
key={col.key}
@@ -570,6 +675,9 @@ export function DataGrid({
uniqueValues={columnUniqueValues[col.key] || []}
onToggleFilter={toggleHeaderFilter}
onClearFilter={clearHeaderFilter}
frozenLeftClass={frozenLeftClass}
widthPx={columnWidths[col.key]}
onResizeStart={startResize}
/>
))}
</TableRow>
@@ -588,12 +696,16 @@ export function DataGrid({
{emptyMessage}
</TableCell>
</TableRow>
) : paginatedData.map((row, rowIdx) => (
) : paginatedData.map((row, rowIdx) => {
const isSelected = selectedId === row.id || (showCheckbox && checkedIds.includes(row.id));
// sticky 셀에 alpha 없는 단색 배경 사용 (반투명이면 뒤 셀이 비침).
// selected → bg-accent / hover(non-selected) → group-hover로 muted 적용 / 기본 → bg-background
const stickyBgClass = isSelected ? "bg-accent" : "bg-background group-hover:bg-muted";
return (
<TableRow
key={row.id || rowIdx}
className={cn("cursor-pointer",
selectedId === row.id && "bg-primary/5",
showCheckbox && checkedIds.includes(row.id) && "bg-primary/5",
className={cn("cursor-pointer group",
isSelected && "bg-accent text-accent-foreground",
)}
onClick={() => {
onSelect?.(row.id);
@@ -609,7 +721,14 @@ export function DataGrid({
onDoubleClick={() => onRowDoubleClick?.(row, rowIdx)}
>
{showCheckbox && (
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<TableCell
className={cn(
"text-center",
isSelected && "bg-accent",
hasFrozen && cn(stickyFirstColBodyClass, stickyBgClass),
)}
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={checkedIds.includes(row.id)}
onCheckedChange={(checked) => {
@@ -621,11 +740,28 @@ export function DataGrid({
/>
</TableCell>
)}
{showRowNumber && !showCheckbox && <TableCell className="text-center text-[10px] text-muted-foreground">{pageOffset + rowIdx + 1}</TableCell>}
{columns.map((col) => (
{showRowNumber && !showCheckbox && (
<TableCell className={cn(
"text-center text-[10px] text-muted-foreground",
isSelected && "bg-accent",
hasFrozen && cn(stickyFirstColBodyClass, stickyBgClass),
)}>
{pageOffset + rowIdx + 1}
</TableCell>
)}
{columns.map((col) => {
const w = columnWidths[col.key];
const inlineStyle = w != null ? { width: w, minWidth: w, maxWidth: w } : undefined;
return (
<TableCell
key={col.key}
className={cn(col.width, col.minWidth, "py-2.5", col.editable && "cursor-text")}
style={inlineStyle}
className={cn(
w == null && col.width, w == null && col.minWidth, "py-2.5",
col.editable && "cursor-text",
isSelected && "bg-accent",
col.frozen && cn("sticky z-[5]", frozenLeftClass, stickyBgClass),
)}
onDoubleClick={(e) => {
if (col.editable) {
e.stopPropagation();
@@ -635,9 +771,10 @@ export function DataGrid({
>
{renderCell(row, col, rowIdx)}
</TableCell>
))}
);
})}
</TableRow>
))}
);})}
</TableBody>
</Table>
</DndContext>
+6 -2
View File
@@ -24,7 +24,8 @@ interface PartSelectProps {
mode: "partNo" | "partName";
/** item_info.id (part_objid) */
value: string;
onValueChange: (partObjId: string) => void;
/** 옵션 선택 시 part_objid + (선택사항) 마스터 정보(item_number/item_name) 전달 */
onValueChange: (partObjId: string, row?: { item_number?: string; item_name?: string }) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
@@ -85,7 +86,10 @@ export function PartSelect({
<SmartSelect
options={options}
value={value}
onValueChange={onValueChange}
onValueChange={(v) => {
const row = cachedRows?.find((r) => r.id === v);
onValueChange(v, row ? { item_number: row.item_number, item_name: row.item_name } : undefined);
}}
placeholder={placeholder}
disabled={disabled}
className={className}
+49 -14
View File
@@ -13,7 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown, Search as SearchIcon } from "lucide-react";
import { Check, ChevronsUpDown, Search as SearchIcon, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useVirtualizer } from "@tanstack/react-virtual";
@@ -33,6 +33,8 @@ interface SmartSelectProps {
placeholder?: string;
disabled?: boolean;
className?: string;
/** 값이 있을 때 ✕(선택 해제) 버튼 노출 (기본 true). 필수 필드는 false로 둘 것. */
clearable?: boolean;
}
export function SmartSelect({
@@ -42,6 +44,7 @@ export function SmartSelect({
placeholder = "선택",
disabled = false,
className,
clearable = true,
}: SmartSelectProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -84,24 +87,54 @@ export function SmartSelect({
return () => cancelAnimationFrame(id);
}, [open, virtualizer, filtered.length]);
const showClear = clearable && !disabled && !!value;
// Radix Select/Popover trigger는 onPointerDown으로 열린다 → 같은 단계에서 차단해야 X 클릭이 trigger를 안 깨움
const stopAndClear = (e: React.PointerEvent | React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onValueChange("");
};
const blockTrigger = (e: React.PointerEvent | React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
};
const ClearBtn = (
<button
type="button"
tabIndex={-1}
aria-label="선택 해제"
onPointerDown={blockTrigger}
onMouseDown={blockTrigger}
onClick={stopAndClear}
className="absolute right-8 top-1/2 -translate-y-1/2 z-10 inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
>
<X className="h-3.5 w-3.5" />
</button>
);
if (safeOptions.length < SEARCH_THRESHOLD) {
return (
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
<SelectTrigger className={cn("h-9", className)}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{safeOptions.map((o, idx) => (
<SelectItem key={`${o.code}-${idx}`} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className={cn("relative", className)}>
{/* key: 빈값↔값 전환 시 Radix Select remount — controlled value=undefined 시 selection 미해제 우회 */}
<Select key={value ? "filled" : "empty"} value={value || undefined} onValueChange={onValueChange} disabled={disabled}>
<SelectTrigger className={cn("h-9", showClear && "pr-12")}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{safeOptions.map((o, idx) => (
<SelectItem key={`${o.code}-${idx}`} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
{showClear && ClearBtn}
</div>
);
}
return (
<div className="relative">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
@@ -109,7 +142,7 @@ export function SmartSelect({
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("h-9 w-full justify-between font-normal", className)}
className={cn("h-9 w-full justify-between font-normal", showClear && "pr-12", className)}
>
<span className="truncate">
{selectedLabel || <span className="text-muted-foreground">{placeholder}</span>}
@@ -177,5 +210,7 @@ export function SmartSelect({
)}
</PopoverContent>
</Popover>
{showClear && ClearBtn}
</div>
);
}
@@ -0,0 +1,347 @@
"use client";
/**
* OrderFormViewDialog — 주문서 자동생성 뷰 (wace orderFormView.jsp 대응)
*
* 주문관리 그리드 "주문서" 폴더 컬럼 클릭 시 표시.
* 한국 표준 주문서 양식 (공급받는자/공급자/품목/합계).
*/
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Printer } from "lucide-react";
import { salesOrderMgmtApi } from "@/lib/api/salesOrderMgmt";
import { toast } from "sonner";
// 공급자(우리 회사) 정보 — wace 원본 하드코딩값. 추후 회사 마스터 테이블로 이전.
const SUPPLIER = {
busRegNo: "314-81-75146",
name: "주식회사알피에스본사",
ceo: "이동헌",
address: "대전광역시 유성구 국제과학10로 8(둔곡동)",
busType: "제조업",
busItem: "금속절삭가공기계,반도체제조용기계",
};
interface OrderFormInfo {
objid: string;
contract_no: string;
po_no: string;
order_date: string;
client_nm?: string;
client_bus_reg_no?: string;
client_ceo_nm?: string;
client_addr?: string;
client_bus_type?: string;
client_bus_item?: string;
client_tel_no?: string;
client_fax_no?: string;
client_email?: string;
writer_name?: string;
writer_contact?: string;
order_supply_price?: string | number;
order_vat?: string | number;
order_total_amount?: string | number;
vat_note?: string;
reg_datetime?: string;
}
interface OrderFormItem {
seq: number;
part_no?: string;
part_name?: string;
spec?: string;
unit_name?: string;
due_date?: string;
order_quantity?: string | number;
order_unit_price?: string | number;
order_supply_price?: string | number;
order_vat?: string | number;
order_total_amount?: string | number;
}
export interface OrderFormViewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
objid: string | null | undefined;
}
export function OrderFormViewDialog({ open, onOpenChange, objid }: OrderFormViewDialogProps) {
const [info, setInfo] = useState<OrderFormInfo | null>(null);
const [items, setItems] = useState<OrderFormItem[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || !objid) {
setInfo(null);
setItems([]);
return;
}
setLoading(true);
salesOrderMgmtApi
.formView(String(objid))
.then((data) => {
setInfo(data.info);
setItems(data.items ?? []);
})
.catch((e: any) => {
toast.error("주문서 데이터 조회 실패: " + (e?.message ?? ""));
setInfo(null);
setItems([]);
})
.finally(() => setLoading(false));
}, [open, objid]);
const orderDateText = formatOrderDate(info?.order_date);
const totalQty = items.reduce((acc, it) => acc + toNum(it.order_quantity), 0);
const totalSupply = items.reduce((acc, it) => acc + toNum(it.order_supply_price), 0);
function handlePrint() {
const node = document.getElementById("order-form-print-area");
if (!node) return;
const w = window.open("", "_blank", "width=950,height=800");
if (!w) return;
w.document.write(`
<html><head><title>주문서</title>
<style>
body{font-family:'맑은 고딕',sans-serif;margin:18px;color:#000;font-size:12px;}
.order-title{text-align:center;font-size:24px;font-weight:bold;letter-spacing:18px;margin:8px 0 14px;}
.header-row{font-size:11px;margin-bottom:2px;}
table{border-collapse:collapse;width:100%;}
td,th{border:1px solid #000;padding:3px 5px;}
.lbl{background:#f3f3f3;text-align:center;font-weight:bold;}
.vl{background:#e8e8e8;text-align:center;font-weight:bold;font-size:13px;}
.tc{text-align:center;}
.tr{text-align:right;}
.item-tbl thead th{background:#fff8c5;font-weight:bold;text-align:center;}
.total-row td{background:#ffffcc;font-weight:bold;text-align:center;letter-spacing:8px;}
</style></head><body>${node.innerHTML}</body></html>
`);
w.document.close();
w.focus();
setTimeout(() => { w.print(); }, 250);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> {info?.contract_no ?? ""}</DialogTitle>
</DialogHeader>
{loading && (
<div className="text-center text-muted-foreground py-10"> </div>
)}
{!loading && !info && (
<div className="text-center text-muted-foreground py-10"> .</div>
)}
{!loading && info && (
<div id="order-form-print-area" className="text-[11px] text-black bg-white p-3">
<div className="text-center text-2xl font-bold tracking-[18px] my-2"> </div>
<div className="text-[11px] mb-0.5"> : {orderDateText}</div>
<div className="text-[11px] mb-1"> : {info.po_no ?? ""}</div>
{/* 공급받는자 / 공급자 */}
<table className="w-full border-collapse text-[11px] mt-1">
<colgroup>
<col style={{ width: "28px" }} />
<col style={{ width: "62px" }} />
<col />
<col style={{ width: "45px" }} />
<col style={{ width: "70px" }} />
<col style={{ width: "28px" }} />
<col style={{ width: "62px" }} />
<col />
<col style={{ width: "45px" }} />
<col style={{ width: "90px" }} />
</colgroup>
<tbody>
<tr>
<td rowSpan={4} className="vl border border-black bg-gray-200 text-center font-bold" style={{ writingMode: "vertical-rl" as any }}></td>
<td className="lbl border border-black bg-gray-100 text-center font-bold"></td>
<td colSpan={3} className="border border-black px-1.5">{info.client_bus_reg_no ?? ""}</td>
<td rowSpan={4} className="vl border border-black bg-gray-200 text-center font-bold" style={{ writingMode: "vertical-rl" as any }}> </td>
<td className="lbl border border-black bg-gray-100 text-center font-bold"></td>
<td colSpan={3} className="border border-black px-1.5">{SUPPLIER.busRegNo}</td>
</tr>
<tr>
<td className="lbl border border-black bg-gray-100 text-center font-bold"> </td>
<td className="border border-black px-1.5">{info.client_nm ?? ""}</td>
<td className="lbl border border-black bg-gray-100 text-center font-bold"></td>
<td className="border border-black px-1.5">{info.client_ceo_nm ?? ""}</td>
<td className="lbl border border-black bg-gray-100 text-center font-bold"> </td>
<td className="border border-black px-1.5">{SUPPLIER.name}</td>
<td className="lbl border border-black bg-gray-100 text-center font-bold"></td>
<td className="border border-black px-1.5">{SUPPLIER.ceo}</td>
</tr>
<tr>
<td className="lbl border border-black bg-gray-100 text-center font-bold"> </td>
<td colSpan={3} className="border border-black px-1.5">{info.client_addr ?? ""}</td>
<td className="lbl border border-black bg-gray-100 text-center font-bold"> </td>
<td colSpan={3} className="border border-black px-1.5">{SUPPLIER.address}</td>
</tr>
<tr>
<td className="lbl border border-black bg-gray-100 text-center font-bold"> </td>
<td className="border border-black px-1.5">{info.client_bus_type ?? ""}</td>
<td className="lbl border border-black bg-gray-100 text-center font-bold"></td>
<td className="border border-black px-1.5">{info.client_bus_item ?? ""}</td>
<td className="lbl border border-black bg-gray-100 text-center font-bold"> </td>
<td className="border border-black px-1.5">{SUPPLIER.busType}</td>
<td className="lbl border border-black bg-gray-100 text-center font-bold"></td>
<td className="border border-black px-1.5 text-[9px]">{SUPPLIER.busItem}</td>
</tr>
</tbody>
</table>
{/* 납품처 / 담당자 */}
<table className="w-full border-collapse text-[11px] -mt-px">
<colgroup>
<col style={{ width: "70px" }} />
<col />
<col style={{ width: "70px" }} />
<col style={{ width: "130px" }} />
<col style={{ width: "70px" }} />
<col style={{ width: "130px" }} />
</colgroup>
<tbody>
<tr>
<td className="lbl border border-black bg-gray-100 text-center font-bold"> </td>
<td className="border border-black px-1.5">{info.client_nm ?? ""}</td>
<td className="lbl border border-black bg-gray-100 text-center font-bold"></td>
<td className="border border-black px-1.5">{info.client_tel_no ?? ""}</td>
<td className="lbl border border-black bg-gray-100 text-center font-bold"></td>
<td className="border border-black px-1.5">{info.client_fax_no ?? ""}</td>
</tr>
<tr>
<td className="lbl border border-black bg-gray-100 text-center font-bold"> </td>
<td className="border border-black px-1.5">{info.client_addr ?? ""}</td>
<td className="lbl border border-black bg-gray-100 text-center font-bold"> </td>
<td className="border border-black px-1.5">{info.writer_name ?? ""}</td>
<td className="lbl border border-black bg-gray-100 text-center font-bold">C.P.</td>
<td className="border border-black px-1.5">{info.writer_contact ?? ""}</td>
</tr>
</tbody>
</table>
{/* 품목 테이블 */}
<table className="item-tbl w-full border-collapse text-[11px] -mt-px">
<colgroup>
<col style={{ width: "32px" }} />
<col style={{ width: "85px" }} />
<col />
<col />
<col style={{ width: "38px" }} />
<col style={{ width: "78px" }} />
<col style={{ width: "48px" }} />
<col style={{ width: "68px" }} />
<col style={{ width: "78px" }} />
</colgroup>
<thead>
<tr>
{["No.", "품번", "품명", "규격", "단위", "납기일", "수량", "단가", "금액"].map((h) => (
<th key={h} className="border border-black bg-yellow-50 font-bold text-center px-1.5 py-1">{h}</th>
))}
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr><td colSpan={9} className="border border-black text-center py-3 text-muted-foreground"> </td></tr>
)}
{items.map((it, i) => (
<tr key={i}>
<td className="border border-black tc text-center px-1">{i + 1}</td>
<td className="border border-black tc text-center px-1">{it.part_no ?? ""}</td>
<td className="border border-black px-1.5">{it.part_name ?? ""}</td>
<td className="border border-black px-1.5">{it.spec ?? ""}</td>
<td className="border border-black tc text-center px-1">{it.unit_name ?? ""}</td>
<td className="border border-black tc text-center px-1">{it.due_date ?? ""}</td>
<td className="border border-black tr text-right px-1.5">{fmt(it.order_quantity)}</td>
<td className="border border-black tr text-right px-1.5">{fmt(it.order_unit_price)}</td>
<td className="border border-black tr text-right px-1.5">{fmt(it.order_supply_price)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="total-row">
<td colSpan={9} className="border border-black bg-yellow-100 text-center font-bold tracking-[8px] py-1"> </td>
</tr>
<tr>
<td colSpan={6} className="border border-black tc"></td>
<td className="border border-black tr text-right px-1.5 font-bold">{fmt(totalQty)}</td>
<td className="border border-black tr"></td>
<td className="border border-black tr text-right px-1.5 font-bold">{fmt(totalSupply)}</td>
</tr>
</tfoot>
</table>
{/* 비고 / 합계 요약 */}
<table className="w-full border-collapse text-[11px] -mt-px">
<colgroup>
<col style={{ width: "40px" }} />
<col />
<col style={{ width: "120px" }} />
<col style={{ width: "150px" }} />
</colgroup>
<tbody>
<tr>
<td rowSpan={3} className="vl border border-black bg-gray-200 text-center font-bold tracking-[8px] text-[13px]" style={{ writingMode: "vertical-rl" as any }}> </td>
<td rowSpan={3} className="border border-black"></td>
<td className="lbl border border-black bg-gray-100 text-center font-bold tracking-[2px]"> </td>
<td className="border border-black tr text-right px-1.5">{fmt(info.order_supply_price)}</td>
</tr>
<tr>
<td className="lbl border border-black bg-gray-100 text-center font-bold tracking-[2px]"> </td>
<td className="border border-black tr text-right px-1.5">{fmt(info.order_vat)}</td>
</tr>
<tr>
<td className="lbl border border-black bg-gray-100 text-center font-bold tracking-[5px]"> </td>
<td className="border border-black tr text-right px-1.5 font-bold">{fmt(info.order_total_amount)}</td>
</tr>
</tbody>
</table>
<div className="flex justify-between border border-black border-t-0 px-1.5 py-1 text-[11px]">
<span>{info.vat_note ?? ""}</span>
<span></span>
</div>
</div>
)}
<DialogFooter className="flex sm:justify-between gap-2 print:hidden">
<Button variant="outline" size="sm" onClick={handlePrint} disabled={!info}>
<Printer className="h-4 w-4 mr-1" />
</Button>
<Button variant="ghost" onClick={() => onOpenChange(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function toNum(v: any): number {
if (v == null || v === "") return 0;
const n = Number(String(v).replace(/,/g, ""));
return isNaN(n) ? 0 : n;
}
function fmt(v: any): string {
const n = toNum(v);
if (n === 0) return "";
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function formatOrderDate(s?: string): string {
if (!s) return "";
const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!m) return s;
return `${m[1]}${m[2]}${m[3]}`;
}
@@ -0,0 +1,307 @@
"use client";
/**
* OrderRegistDialog — wace orderRegistFormPopup.jsp 1:1 이식
*
* 견적요청에서 시작된 행(is_direct_order != 'Y')의 "수주등록/수정" 폼.
* 통합폼(estimateAndOrderRegistFormPopup)과 분리된 별도 화면.
*
* - 헤더 4개: 발주번호 / 발주일* / 견적환종 / 견적환율
* - 라인: contract_item 자동 로드(읽기전용), ORDER_* 5컬럼만 입력
* - 라인 추가/삭제 불가
* - 자동계산: 수량×단가→공급가액, 공급가액×10%→부가세, 공급+부가세→총액
*/
import { useEffect, useMemo, useState } from "react";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2, Save } from "lucide-react";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { salesOrderMgmtApi, OrderBody, OrderItem } from "@/lib/api/salesOrderMgmt";
import { toast } from "sonner";
export interface OrderRegistDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
contractObjId: string | null;
contractNo: string | null;
onSaved?: () => void;
}
const toNum = (v: any) => Number(String(v ?? "0").replace(/,/g, "")) || 0;
export function OrderRegistDialog({ open, onOpenChange, contractObjId, contractNo, onSaved }: OrderRegistDialogProps) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<OrderBody>({ contract_currency: "KRW", items: [] });
useEffect(() => {
if (!open || !contractObjId) return;
let cancelled = false;
(async () => {
setLoading(true);
try {
const detail = await salesOrderMgmtApi.detail(contractObjId);
if (cancelled || !detail) return;
setForm({
objid: detail.objid,
contract_no: detail.contract_no ?? "",
category_cd: detail.category_cd ?? "",
customer_objid: detail.customer_objid ?? "",
product: detail.product ?? "",
area_cd: detail.area_cd ?? "",
paid_type: detail.paid_type ?? "paid",
contract_currency: detail.contract_currency ?? "KRW",
exchange_rate: detail.exchange_rate ?? "",
receipt_date: detail.receipt_date ?? "",
order_date: detail.order_date ?? new Date().toISOString().slice(0, 10),
approval_required: detail.approval_required ?? "N",
is_direct_order: detail.is_direct_order ?? "",
req_del_date: detail.req_del_date ?? "",
po_no: detail.po_no ?? "",
contract_result: detail.contract_result ?? "",
pm_user_id: detail.pm_user_id ?? "",
customer_request: detail.customer_request ?? "",
shipping_method: detail.shipping_method ?? "",
incoterms: detail.incoterms ?? "",
items: (detail.items ?? []).map((it: any) => ({
objid: it.objid,
seq: it.seq,
product: it.product ?? "",
part_objid: it.part_objid ?? "",
part_no: it.master_part_no ?? it.part_no ?? "",
part_name: it.master_part_name ?? it.part_name ?? "",
quantity: it.quantity ?? 1,
due_date: it.due_date ?? "",
return_reason: it.return_reason ?? "",
customer_request: it.customer_request ?? "",
order_quantity: it.order_quantity ?? "",
order_unit_price: it.order_unit_price ?? "",
order_supply_price: it.order_supply_price ?? "",
order_vat: it.order_vat ?? "",
order_total_amount: it.order_total_amount ?? "",
cancel_qty: it.cancel_qty ?? "",
serials: it.serials ?? [],
})),
});
} catch (err: any) {
toast.error(`수주등록 폼 로드 실패: ${err?.response?.data?.message ?? err.message}`);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [open, contractObjId]);
// wace fn_calculateItemAmount / fn_calculateTotalFromVat 이식
const updateItemWithCalc = (idx: number, key: keyof OrderItem, val: any) => {
setForm((prev) => {
const items = [...(prev.items ?? [])];
const cur: any = { ...items[idx], [key]: val };
if (key === "order_quantity" || key === "order_unit_price") {
const supply = toNum(cur.order_quantity) * toNum(cur.order_unit_price);
const vat = Math.round(supply * 0.1);
cur.order_supply_price = String(supply);
cur.order_vat = String(vat);
cur.order_total_amount = String(supply + vat);
} else if (key === "order_vat") {
cur.order_total_amount = String(toNum(cur.order_supply_price) + toNum(cur.order_vat));
}
items[idx] = cur;
return { ...prev, items };
});
};
const formatNum = (v: any) => {
const n = Number(String(v ?? "0").replace(/,/g, ""));
return isNaN(n) ? 0 : n;
};
const lineTotal = useMemo(() => {
const items = form.items ?? [];
return items.reduce((acc, it) => ({
qty: acc.qty + formatNum(it.order_quantity),
supply: acc.supply + formatNum(it.order_supply_price),
vat: acc.vat + formatNum(it.order_vat),
total: acc.total + formatNum(it.order_total_amount),
}), { qty: 0, supply: 0, vat: 0, total: 0 });
}, [form.items]);
const handleSave = async () => {
if (!contractObjId) return;
if (!form.order_date) { toast.warning("발주일을 입력해주세요."); return; }
// wace fn_save 검증: 라인별 제품구분 + 수주수량
for (const it of (form.items ?? [])) {
if (!it.product) { toast.warning("제품구분을 선택해주세요."); return; }
const oq = toNum(it.order_quantity);
if (!oq || oq <= 0) { toast.warning("수주수량을 입력해주세요."); return; }
}
setSaving(true);
try {
await salesOrderMgmtApi.update(contractObjId, form);
toast.success("수주등록이 저장되었습니다.");
onOpenChange(false);
onSaved?.();
} catch (err: any) {
toast.error(`저장 실패: ${err?.response?.data?.message ?? err.message}`);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="!max-w-[90vw] w-[90vw] max-h-[92vh] overflow-y-auto"
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>
_ _ (: {contractNo ?? "-"})
</DialogTitle>
<DialogDescription className="sr-only">
(wace orderRegistFormPopup 1:1)
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="w-5 h-5 animate-spin mr-2" /> ...
</div>
) : (
<>
{/* 수주 기본정보 — wace 헤더 4개 */}
<fieldset className="border rounded-md p-3">
<legend className="text-sm font-semibold px-2"> </legend>
<div className="grid grid-cols-4 gap-3">
<div>
<Label className="text-xs"></Label>
<Input value={form.po_no ?? ""}
onChange={(e) => setForm({ ...form, po_no: e.target.value })} />
</div>
<div>
<Label className="text-xs"> <span className="text-rose-600">*</span></Label>
<Input type="date" value={form.order_date ?? ""}
onChange={(e) => setForm({ ...form, order_date: e.target.value })} />
</div>
<div>
<Label className="text-xs"></Label>
<CommCodeSelect groupId="0001533" value={form.contract_currency ?? ""}
onValueChange={(v) => setForm({ ...form, contract_currency: v })} />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={form.exchange_rate ?? ""}
onChange={(e) => setForm({ ...form, exchange_rate: e.target.value })} />
</div>
</div>
</fieldset>
{/* 품목정보 — 견적요청에서 자동 로드 (라인 추가/삭제 불가) */}
<fieldset className="border rounded-md p-3 mt-3">
<legend className="text-sm font-semibold px-2"> ( )</legend>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="p-2 w-10 whitespace-nowrap">No</th>
<th className="p-2 w-28 whitespace-nowrap"> <span className="text-rose-600">*</span></th>
<th className="p-2 w-32 whitespace-nowrap"></th>
<th className="p-2 w-48 whitespace-nowrap"></th>
<th className="p-2 w-36 whitespace-nowrap">S/N</th>
<th className="p-2 w-24 whitespace-nowrap text-right"> <span className="text-rose-600">*</span></th>
<th className="p-2 w-28 whitespace-nowrap text-right"></th>
<th className="p-2 w-32 whitespace-nowrap text-right"></th>
<th className="p-2 w-28 whitespace-nowrap text-right"></th>
<th className="p-2 w-32 whitespace-nowrap text-right"></th>
<th className="p-2 w-12 whitespace-nowrap text-center">-</th>
</tr>
</thead>
<tbody>
{(form.items ?? []).map((it, idx) => {
const serials = (it.serials ?? []) as string[];
const snDisplay = serials.length > 1
? `${serials[0]}${serials.length - 1}`
: (serials[0] ?? "");
return (
<tr key={idx} className="border-t">
<td className="p-1 text-center">{it.seq}</td>
<td className="p-1">
<CommCodeSelect groupId="0000001"
value={it.product ?? ""}
onValueChange={(v) => {
setForm((prev) => {
const items = [...(prev.items ?? [])];
items[idx] = { ...items[idx], product: v };
return { ...prev, items };
});
}} />
</td>
<td className="p-1">
<Input className="h-8 bg-muted/30" readOnly value={it.part_no ?? ""} />
</td>
<td className="p-1">
<Input className="h-8 bg-muted/30" readOnly value={it.part_name ?? ""} />
</td>
<td className="p-1">
<Input className="h-8 bg-muted/30" readOnly title={serials.join(", ")} value={snDisplay} />
</td>
<td className="p-1">
<Input className="h-8 text-right" type="number" min={0}
value={it.order_quantity ?? ""}
onChange={(e) => updateItemWithCalc(idx, "order_quantity", e.target.value)} />
</td>
<td className="p-1">
<Input className="h-8 text-right" value={it.order_unit_price ?? ""}
onChange={(e) => updateItemWithCalc(idx, "order_unit_price", e.target.value)} />
</td>
<td className="p-1">
<Input className="h-8 text-right bg-muted/30" readOnly value={it.order_supply_price ?? ""} />
</td>
<td className="p-1">
<Input className="h-8 text-right" value={it.order_vat ?? ""}
onChange={(e) => updateItemWithCalc(idx, "order_vat", e.target.value)} />
</td>
<td className="p-1">
<Input className="h-8 text-right bg-muted/30" readOnly value={it.order_total_amount ?? ""} />
</td>
<td className="p-1 text-center text-muted-foreground">-</td>
</tr>
);
})}
{(!form.items || form.items.length === 0) && (
<tr><td colSpan={11} className="p-6 text-center text-muted-foreground"> .</td></tr>
)}
</tbody>
{(form.items?.length ?? 0) > 0 && (
<tfoot>
<tr className="bg-muted/30 font-semibold">
<td colSpan={5} className="p-2 text-center">Total</td>
<td className="p-2 text-right">{lineTotal.qty.toLocaleString()}</td>
<td className="p-2"></td>
<td className="p-2 text-right">{lineTotal.supply.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="p-2 text-right">{lineTotal.vat.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="p-2 text-right">{lineTotal.total.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="p-2"></td>
</tr>
</tfoot>
)}
</table>
</div>
</fieldset>
</>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}></Button>
<Button onClick={handleSave} disabled={saving || loading}>
{saving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+155 -49
View File
@@ -35,6 +35,9 @@ export interface EstimateRow {
area_name: string | null;
paid_type: string | null;
paid_type_name: string | null;
contract_result: string | null;
approval_required: string | null;
return_reason_summary: string | null;
contract_currency: string | null;
contract_currency_name: string | null;
exchange_rate: string | null;
@@ -66,59 +69,33 @@ export interface EstimateRow {
mail_send_date: string | null;
}
// wace estimateRegistFormPopup 폼 — 라인 8개 항목
export interface EstimateItem {
objid?: string;
seq: number;
category?: string;
description?: string;
specification?: string;
quantity?: string;
unit?: string;
unit_price?: string;
amount?: string;
note?: string;
remark?: string;
part_objid?: string;
product: string; // 제품구분 (필수)
part_objid: string; // 품목 마스터 id (필수)
part_no: string;
part_name: string;
serials?: string[]; // S/N 목록
quantity?: string; // 견적수량
due_date?: string; // 요청납기 (YYYY-MM-DD)
return_reason?: string; // 반납사유 (comm_code)
customer_request?: string; // 고객요청사항
}
// wace estimateRegistFormPopup 폼 — 헤더 8개 항목
export interface EstimateBody {
contract_objid?: string;
template_type: 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;
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?: EstimateItem[];
contract_context?: {
contract_no?: string;
customer_objid?: string;
category_cd?: string;
product?: string;
area_cd?: string;
paid_type?: string;
contract_currency?: string;
receipt_date?: string;
req_del_date?: string;
};
contract_no?: string; // 신규: 자동 채번 / 수정: 변경 안 함
category_cd: string; // 주문유형 *
area_cd: string; // 국내/해외 *
customer_objid: string; // 고객사 *
paid_type: string; // 유/무상 * ('paid' | 'free')
receipt_date: string; // 접수일 *
contract_currency?: string; // 견적환종
exchange_rate?: string; // 견적환율
approval_required: string; // 결재여부 * ('Y' | 'N')
items: EstimateItem[];
}
export const salesEstimateApi = {
@@ -139,7 +116,7 @@ export const salesEstimateApi = {
async create(body: EstimateBody) {
const res = await apiClient.post("/sales/estimate", body);
return res.data?.data as { objid: string; contract_objid: string; estimate_no: string };
return res.data?.data as { objid: string; contract_no: string };
},
async update(objid: string, body: EstimateBody) {
@@ -163,4 +140,133 @@ export const salesEstimateApi = {
const res = await apiClient.post("/sales/estimate/mail", body);
return res.data?.data as { objid: string };
},
// ─── G5 견적작성 (estimate_template) ──────────────────────────
async saveTemplate1(body: EstimateTemplate1Body) {
const res = await apiClient.post("/sales/estimate/template1", body);
return res.data?.data as { templateObjid: string; isUpdate: boolean };
},
async saveTemplate2(body: EstimateTemplate2Body) {
const res = await apiClient.post("/sales/estimate/template2", body);
return res.data?.data as { templateObjid: string; isUpdate: boolean };
},
async getTemplate(templateObjid: string) {
const res = await apiClient.get(`/sales/estimate/template/${templateObjid}`);
return res.data?.data as EstimateTemplateDetail | null;
},
async listTemplates(contractObjid: string) {
const res = await apiClient.get(`/sales/estimate/templates/${contractObjid}`);
return (res.data?.data ?? []) as EstimateTemplateRow[];
},
};
// ─── G5 견적작성 타입 ───────────────────────────────────────────
export interface EstimateTemplateItemRow {
seq?: number;
category?: string | null;
part_objid?: string | null;
description?: string | null;
specification?: string | null;
quantity?: string | null;
unit?: string | null;
unit_price?: string | null;
amount?: string | null;
note?: string | null;
remark?: string | null;
}
// 일반(template1) 저장 페이로드 — wace estimateTemplate1.jsp fn_save
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: EstimateTemplateItemRow[];
}
// 장비(template2) 저장 페이로드 — wace estimateTemplate2.jsp fn_save
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;
}
// 단건 조회 응답
export interface EstimateTemplateDetail {
objid: string;
contract_objid: string;
template_type: "1" | "2";
executor: string | null;
recipient: string | null;
estimate_no: string | null;
contact_person: string | null;
greeting_text: string | null;
model_name: string | null;
model_code: string | null;
executor_date: string | null;
note1: string | null;
note2: string | null;
note3: string | null;
note4: string | null;
note_remarks: string | null;
notes_content: string | null;
validity_period: string | null;
categories_json: string | null;
group1_subtotal: string | null;
total_amount: string | null;
total_amount_krw: string | null;
manager_name: string | null;
manager_contact: string | null;
show_total_row: string | null;
part_name: string | null;
part_objid: string | null;
writer: string | null;
regdate_str: string | null;
chgdate_str: string | null;
exchange_rate: string | null;
contract_currency: string | null;
contract_currency_name: string | null;
items: EstimateTemplateItemRow[];
}
// 차수 리스트 행
export interface EstimateTemplateRow {
objid: string;
template_type: "1" | "2";
estimate_no: string | null;
recipient: string | null;
total_amount: string | null;
total_amount_krw: string | null;
writer: string | null;
regdate: string | null;
chgdate: string | null;
}
+14 -1
View File
@@ -27,6 +27,8 @@ export interface OrderRow {
area_cd: string | null;
paid_type: string | null;
paid_type_name: string | null;
product_name: string | null;
area_name: string | null;
contract_currency: string | null;
exchange_rate: string | null;
po_no: string | null;
@@ -53,6 +55,7 @@ export interface OrderRow {
order_appr_status: string | null;
amaranth_status: string | null;
cu01_cnt: number | null;
is_direct_order: string | null;
}
export interface OrderItem {
@@ -86,10 +89,12 @@ export interface OrderBody {
contract_currency?: string;
exchange_rate?: string;
receipt_date?: string;
contract_date?: string;
order_date?: string; // 발주일 (wace G2 필수)
req_del_date?: string;
po_no?: string;
contract_result?: string;
approval_required?: string; // 결재여부 'Y'|'N'
is_direct_order?: string; // 'Y' 기본 (G2 직접등록)
pm_user_id?: string;
customer_request?: string;
shipping_method?: string;
@@ -123,4 +128,12 @@ export const salesOrderMgmtApi = {
async setStatus(objid: string, contract_result: string) {
return (await apiClient.patch(`/sales/order-mgmt/${objid}/status`, { contract_result })).data;
},
// 라인별 cancel_qty 다중 UPDATE (wace saveOrderCancelQty 이식)
async saveCancelQty(objid: string, entries: { itemObjId: string; cancelQty: string | number; orderQty: string | number }[]) {
return (await apiClient.post(`/sales/order-mgmt/${objid}/cancel-qty`, { entries })).data;
},
async formView(objid: string): Promise<{ info: any; items: any[] }> {
const res = await apiClient.get(`/sales/order-mgmt/${objid}/form-view`);
return res.data?.data ?? { info: null, items: [] };
},
};
+17
View File
@@ -53,7 +53,14 @@ export interface SaleListRow {
sales_status: string;
production_status: string | null;
payment_type: string | null;
payment_type_name: string | null;
nation: string | null;
nation_name: string | null;
product_type_name: string | null;
receipt_date: string | null;
customer_request: string | null;
manager_name: string | null;
cu01_cnt: number | null;
serial_no: string | null;
}
@@ -89,6 +96,16 @@ export interface RevenueListRow {
sales_slip_date: string | null;
sales_slip_menu_sq: number | null;
remark: string | null;
receipt_date: string | null;
payment_type: string | null;
payment_type_name: string | null;
request_date: string | null;
customer_request: string | null;
order_status: string | null;
order_status_name: string | null;
manager_name: string | null;
incoterms: string | null;
cu01_cnt: number | null;
}
export interface SaleRegisterBody {
+19 -5
View File
@@ -68,9 +68,10 @@
"exceljs": "^4.4.0",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"html2canvas-pro": "^2.0.2",
"isomorphic-dompurify": "^2.28.0",
"jsbarcode": "^3.12.1",
"jspdf": "^3.0.3",
"jspdf": "^3.0.4",
"leaflet": "^1.9.4",
"lucide-react": "^0.525.0",
"mammoth": "^1.11.0",
@@ -10942,6 +10943,19 @@
"node": ">=8.0.0"
}
},
"node_modules/html2canvas-pro": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-2.0.2.tgz",
"integrity": "sha512-9G/t0XgCZWonLwL0JwI7su6NdbOPUY7Ur4Ihpp8+XMaW9ibA2nDXF181Jr6tm94k8lX6sthpaXB3XqEnsMd5Cw==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -11696,12 +11710,12 @@
}
},
"node_modules/jspdf": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz",
"integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==",
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz",
"integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.9",
"@babel/runtime": "^7.28.4",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
+2 -1
View File
@@ -77,9 +77,10 @@
"exceljs": "^4.4.0",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"html2canvas-pro": "^2.0.2",
"isomorphic-dompurify": "^2.28.0",
"jsbarcode": "^3.12.1",
"jspdf": "^3.0.3",
"jspdf": "^3.0.4",
"leaflet": "^1.9.4",
"lucide-react": "^0.525.0",
"mammoth": "^1.11.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

+244
View File
@@ -0,0 +1,244 @@
-- ============================================================
-- 견적관리 자동 검증 SQL (BEGIN/ROLLBACK 시나리오)
-- 사용법:
-- PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres \
-- -d vexplor_rps -f scripts/verify-estimate.sql
--
-- 모든 시나리오는 트랜잭션 안에서 실행 후 ROLLBACK — DB 영향 0.
-- ============================================================
\echo ''
\echo '================== Estimate 검증 시작 =================='
-- ─────────────────────────────────────────────────────────
-- 시나리오 0: 사전 카운트
-- ─────────────────────────────────────────────────────────
\echo ''
\echo '[0] 사전 카운트 ============================'
SELECT 'contract_mgmt' AS tbl, COUNT(*) AS cnt FROM contract_mgmt
UNION ALL SELECT 'contract_item', COUNT(*) FROM contract_item WHERE status='ACTIVE'
UNION ALL SELECT 'contract_item_serial', COUNT(*) FROM contract_item_serial WHERE status='ACTIVE'
UNION ALL SELECT 'project_mgmt', COUNT(*) FROM project_mgmt;
-- ─────────────────────────────────────────────────────────
-- 시나리오 1: 신규 견적요청 등록 (헤더 + 라인 + 시리얼)
-- ─────────────────────────────────────────────────────────
\echo ''
\echo '[1] 신규 견적요청 등록 시나리오 =========================='
BEGIN;
-- 1-a) 채번 룰 검증: {YY}C-{NNNN} 다음 번호
SELECT '예상 contract_no' AS info, '26C-' || LPAD(
(COALESCE((SELECT MAX(SUBSTRING(contract_no FROM '\d{4}$')::int)
FROM contract_mgmt WHERE contract_no LIKE '26C-%'), 0) + 1)::text, 4, '0') AS expected_no;
-- 1-b) 헤더 INSERT
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 (
'CM-VERIFY-001', '26C-9991', '0001791', '0001220', 'C_0000005546', 'paid',
'2026-05-09', '0001566', NULL, 'N',
'N', 'admin', NOW()
);
-- 1-c) 라인 INSERT
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 (
'CI-VERIFY-001', 'CM-VERIFY-001', 1, '0001793', '1868255719', '10INSQURE', '10인치 사각척',
2, '2026-05-15', NULL, '정합성 검증',
NOW(), 'admin', 'ACTIVE'
);
-- 1-d) 시리얼 INSERT
INSERT INTO contract_item_serial (objid, item_objid, seq, serial_no, regdate, writer, status)
VALUES ('CIS-VERIFY-001', 'CI-VERIFY-001', 1, 'SN-001', NOW(), 'admin', 'ACTIVE'),
('CIS-VERIFY-002', 'CI-VERIFY-001', 2, 'SN-002', NOW(), 'admin', 'ACTIVE');
-- 검증
\echo ' → 헤더/라인/시리얼 확인:'
SELECT cm.contract_no, ci.product, ci.part_no, ci.quantity,
(SELECT COUNT(*) FROM contract_item_serial WHERE item_objid=ci.objid AND status='ACTIVE') AS serial_cnt
FROM contract_mgmt cm
JOIN contract_item ci ON ci.contract_objid=cm.objid AND ci.status='ACTIVE'
WHERE cm.objid='CM-VERIFY-001';
ROLLBACK;
\echo ' → ROLLBACK 완료'
-- ─────────────────────────────────────────────────────────
-- 시나리오 2: 견적요청 수정 (라인 1→2 확장 + UPSERT)
-- ─────────────────────────────────────────────────────────
\echo ''
\echo '[2] 견적요청 수정(라인 확장) 시나리오 =========================='
BEGIN;
-- 26C-0801: 기존 ACTIVE 라인 1건. upsertItems 흐름 시뮬레이션.
-- 2-a) 기존 라인 비활성
UPDATE contract_item SET status='INACTIVE', chgdate=NOW(), chg_user_id='admin'
WHERE contract_objid='-1778190592';
-- 2-b) 기존 라인 1건 ON CONFLICT로 ACTIVE 복구 (objid 동일)
INSERT INTO contract_item (
objid, contract_objid, seq, product, part_objid, part_no, part_name,
quantity, due_date, regdate, writer, status
) VALUES (
'-701833325', '-1778190592', 1, '0001793', '1868255719', '10INSQURE', '10인치 사각척',
2, '2026-05-15', NOW(), 'admin', 'ACTIVE'
)
ON CONFLICT (objid) DO UPDATE SET status='ACTIVE', quantity=EXCLUDED.quantity, chgdate=NOW();
-- 2-c) 새 라인 추가
INSERT INTO contract_item (
objid, contract_objid, seq, product, part_objid, part_no, part_name,
quantity, regdate, writer, status
) VALUES (
'CI-VERIFY-NEW', '-1778190592', 2, '0001807', '1868255719', '10INSQURE', '10인치 사각척',
3, NOW(), 'admin', 'ACTIVE'
);
-- 검증
\echo ' → ACTIVE 라인 (수정 후 2건이어야):'
SELECT seq, product, quantity, status FROM contract_item
WHERE contract_objid='-1778190592' AND status='ACTIVE' ORDER BY seq;
ROLLBACK;
\echo ' → ROLLBACK 완료'
-- ─────────────────────────────────────────────────────────
-- 시나리오 3: 수주확정 → project_mgmt 자동생성 (G1)
-- ─────────────────────────────────────────────────────────
\echo ''
\echo '[3] 수주확정 G1 시나리오 =========================='
BEGIN;
\echo ' → BEFORE project_mgmt:'
SELECT COUNT(*) AS before_cnt FROM project_mgmt;
-- 3-a) UPDATE contract_result='0000964' (수주)
UPDATE contract_mgmt SET contract_result='0000964', chg_user_id='admin'
WHERE objid='-1778190592';
-- 3-b) project_no 채번 (직접 인라인 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 = '0001791' AND CC_CAT.status='active'
LEFT JOIN comm_code CC_PRD ON CC_PRD.code_id = '0001793' AND CC_PRD.status='active'
)
SELECT '예상 project_no' AS info,
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 expected_project_no
FROM meta m;
-- 3-c) project_mgmt INSERT (라인 단위)
INSERT INTO project_mgmt (
objid, contract_objid, category_cd, customer_objid, product,
customer_project_name, status_cd, due_date, contract_currency, regdate, writer,
contract_no, contract_result, project_no, is_temp,
part_objid, part_no, part_name, quantity, contract_item_objid
)
SELECT
'PJ-VERIFY-001'::varchar, T.objid, T.category_cd, T.customer_objid, '0001793'::varchar,
T.customer_project_name, T.status_cd, NULL, T.contract_currency, NOW(), T.writer,
T.contract_no, T.contract_result,
'R-CT-' || TO_CHAR(CURRENT_DATE, 'YYMMDD') || '-' ||
LPAD(COALESCE((SELECT MAX(SUBSTRING(project_no FROM '\d{3}$')::int) + 1
FROM project_mgmt
WHERE project_no LIKE 'R-CT-' || TO_CHAR(CURRENT_DATE, 'YYMMDD') || '-%'), 1)::text, 3, '0'),
'1', '1868255719', '10INSQURE', '10인치 사각척', '2', '-701833325'
FROM contract_mgmt T WHERE T.objid='-1778190592';
\echo ' → AFTER project_mgmt:'
SELECT COUNT(*) AS after_cnt FROM project_mgmt;
\echo ' → 새 project_mgmt 행:'
SELECT objid, project_no, contract_objid, product, quantity FROM project_mgmt WHERE objid='PJ-VERIFY-001';
ROLLBACK;
\echo ' → ROLLBACK 완료'
-- ─────────────────────────────────────────────────────────
-- 시나리오 4: 수주취소 (cancel_qty 입력만, contract_result 미변경)
-- ─────────────────────────────────────────────────────────
\echo ''
\echo '[4] 수주취소 시나리오 =========================='
BEGIN;
\echo ' → BEFORE 26C-0801 contract_result:'
SELECT contract_no, contract_result FROM contract_mgmt WHERE contract_no='26C-0801';
-- 라인 cancel_qty UPDATE만
UPDATE contract_item SET cancel_qty='1', chgdate=NOW(), chg_user_id='admin'
WHERE contract_objid='-1778190592' AND status='ACTIVE';
\echo ' → AFTER 라인 cancel_qty:'
SELECT objid, quantity, cancel_qty FROM contract_item
WHERE contract_objid='-1778190592' AND status='ACTIVE';
\echo ' → contract_result 미변경 검증:'
SELECT contract_no, contract_result FROM contract_mgmt WHERE contract_no='26C-0801';
ROLLBACK;
\echo ' → ROLLBACK 완료'
-- ─────────────────────────────────────────────────────────
-- 시나리오 5: 그리드 SQL (V1 컬럼) 정합성
-- ─────────────────────────────────────────────────────────
\echo ''
\echo '[5] 그리드 V1 컬럼 (제품구분/국내해외/반납사유) 검증 =========================='
SELECT
T.contract_no,
COALESCE(CI_AGG.product_summary, CC_PRD.code_name) AS product_name,
CC_AREA.code_name AS area_name,
CI_AGG.return_reason_summary AS return_reason_summary,
CI_AGG.item_count AS line_cnt
FROM contract_mgmt T
LEFT JOIN comm_code CC_PRD ON CC_PRD.code_id = T.product AND CC_PRD.status='active'
LEFT JOIN comm_code CC_AREA ON CC_AREA.code_id = T.area_cd AND CC_AREA.status='active'
LEFT JOIN (
SELECT CI.contract_objid,
COUNT(*) AS item_count,
STRING_AGG(DISTINCT CC_PRDI.code_name, ', ') FILTER (WHERE CC_PRDI.code_name IS NOT NULL) AS product_summary,
STRING_AGG(DISTINCT CC_RR.code_name, ', ') FILTER (WHERE CC_RR.code_name IS NOT NULL) AS return_reason_summary
FROM contract_item CI
LEFT JOIN comm_code CC_PRDI ON CC_PRDI.code_id = CI.product AND CC_PRDI.status='active'
LEFT JOIN comm_code CC_RR ON CC_RR.code_id = CI.return_reason AND CC_RR.status='active'
WHERE CI.status='ACTIVE'
GROUP BY CI.contract_objid
) CI_AGG ON CI_AGG.contract_objid = T.objid
WHERE T.contract_no IN ('26C-0801','26C-0800','26C-0797','26C-0796','26C-0795','26C-0791','26C-0788')
ORDER BY T.contract_no DESC;
\echo ''
\echo '================== Estimate 검증 끝 (모두 ROLLBACK) =================='
+135
View File
@@ -0,0 +1,135 @@
-- ============================================================
-- 주문관리 자동 검증 SQL (BEGIN/ROLLBACK 시나리오)
-- 사용법:
-- PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres \
-- -d vexplor_rps -f scripts/verify-order.sql
-- ============================================================
\echo ''
\echo '================== Order 검증 시작 =================='
-- ─────────────────────────────────────────────────────────
-- [0] 사전 카운트
-- ─────────────────────────────────────────────────────────
\echo ''
\echo '[0] 사전 카운트 ============================'
SELECT 'contract_mgmt 전체' AS tbl, COUNT(*) FROM contract_mgmt
UNION ALL SELECT 'contract_result 수주(0000964)', COUNT(*) FROM contract_mgmt WHERE contract_result='0000964'
UNION ALL SELECT 'contract_result 수주FCST(0000968)', COUNT(*) FROM contract_mgmt WHERE contract_result='0000968'
UNION ALL SELECT 'project_mgmt', COUNT(*) FROM project_mgmt;
-- ─────────────────────────────────────────────────────────
-- [1] 그리드 V1 컬럼 (제품구분/국내해외/접수일/수주상태) 검증
-- ─────────────────────────────────────────────────────────
\echo ''
\echo '[1] 그리드 V1 컬럼 정합성 검증 =========================='
SELECT
T.contract_no,
CC_RES.code_name AS contract_result_name,
COALESCE(CI_AGG.product_summary, CC_PRD.code_name) AS product_name,
CC_AREA.code_name AS area_name,
T.receipt_date,
T.order_date,
COALESCE(CI_AGG.order_quantity_sum, 0) AS order_qty,
COALESCE(CI_AGG.cancel_qty_sum, 0) AS cancel_qty
FROM contract_mgmt T
LEFT JOIN comm_code CC_PRD ON CC_PRD.code_id = T.product AND CC_PRD.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_RES ON CC_RES.code_id = T.contract_result AND CC_RES.status='active'
LEFT JOIN (
SELECT CI.contract_objid,
STRING_AGG(DISTINCT CC_PRDI.code_name, ', ') FILTER (WHERE CC_PRDI.code_name IS NOT NULL) AS product_summary,
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
FROM contract_item CI
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
WHERE T.contract_no IN ('26C-0801','26C-0797','26C-0796','26C-0795','26C-0788','26C-0791')
ORDER BY T.contract_no DESC;
-- ─────────────────────────────────────────────────────────
-- [2] 수주확정 G1 — updateStatus 트랜잭션
-- ─────────────────────────────────────────────────────────
\echo ''
\echo '[2] 수주확정 G1 시나리오 =========================='
BEGIN;
\echo ' → BEFORE project_mgmt:'
SELECT COUNT(*) AS before_cnt FROM project_mgmt;
-- 26C-0795(견적단계, contract_result NULL)를 수주(0000964)로 전환
UPDATE contract_mgmt SET contract_result='0000964', chg_user_id='admin'
WHERE contract_no='26C-0795';
-- project_mgmt INSERT 시뮬레이션 (createProjectsFromContract 흐름 일부)
WITH meta AS (
SELECT
CASE CC_CAT.code_name WHEN '수리' THEN 'R' WHEN '판매' THEN 'S' ELSE 'T' END AS cat_abbr,
CASE CC_PRDI.code_name WHEN 'C/T' THEN 'CT' WHEN 'A/S' THEN 'AS' WHEN '기타' THEN '기타' ELSE COALESCE(CC_PRDI.code_name,'X') END AS prd_abbr,
TO_CHAR(CURRENT_DATE, 'YYMMDD') AS ymd,
CI.objid AS item_objid, CI.part_objid, CI.part_no, CI.part_name, CI.quantity, CI.product
FROM contract_item CI
LEFT JOIN comm_code CC_PRDI ON CC_PRDI.code_id = CI.product AND CC_PRDI.status='active'
CROSS JOIN comm_code CC_CAT
WHERE CI.contract_objid IN (SELECT objid FROM contract_mgmt WHERE contract_no='26C-0795')
AND CI.status='ACTIVE'
AND CC_CAT.code_id IN (SELECT category_cd FROM contract_mgmt WHERE contract_no='26C-0795')
AND CC_CAT.status='active'
)
SELECT '예상 project_no (line별)' AS info,
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 expected_project_no,
m.part_no, m.quantity
FROM meta m;
ROLLBACK;
\echo ' → ROLLBACK 완료'
-- ─────────────────────────────────────────────────────────
-- [3] 수주취소 시나리오 (cancel_qty 다중 UPDATE)
-- ─────────────────────────────────────────────────────────
\echo ''
\echo '[3] 수주취소 시나리오 =========================='
BEGIN;
\echo ' → BEFORE 26C-0797 contract_result:'
SELECT contract_no, contract_result FROM contract_mgmt WHERE contract_no='26C-0797';
UPDATE contract_item SET cancel_qty='1', chgdate=NOW(), chg_user_id='admin'
WHERE contract_objid=(SELECT objid FROM contract_mgmt WHERE contract_no='26C-0797')
AND status='ACTIVE';
\echo ' → AFTER 라인 cancel_qty (1 이상이면 성공):'
SELECT objid, quantity, cancel_qty FROM contract_item
WHERE contract_objid=(SELECT objid FROM contract_mgmt WHERE contract_no='26C-0797')
AND status='ACTIVE';
\echo ' → contract_result 미변경 확인:'
SELECT contract_no, contract_result FROM contract_mgmt WHERE contract_no='26C-0797';
ROLLBACK;
\echo ' → ROLLBACK 완료'
-- ─────────────────────────────────────────────────────────
-- [4] 채번 룰 — 다음 contract_no
-- ─────────────────────────────────────────────────────────
\echo ''
\echo '[4] 채번 룰 검증 =========================='
SELECT '다음 contract_no' AS info,
'26C-' || LPAD(
(COALESCE((SELECT MAX(SUBSTRING(contract_no FROM '\d{4}$')::int)
FROM contract_mgmt WHERE contract_no LIKE '26C-%'), 0) + 1)::text, 4, '0') AS next_no;
\echo ''
\echo '================== Order 검증 끝 (모두 ROLLBACK) =================='
+45
View File
@@ -0,0 +1,45 @@
-- ============================================================
-- part_mng 분리 검증 — wace 도메인 메뉴가 part_mng를 참조하는지 정합성 확인
-- ============================================================
\echo ''
\echo '[1] 카운트'
SELECT 'item_info numeric id (wace)' AS kind, COUNT(*) FROM item_info WHERE id ~ '^-?[0-9]+$'
UNION ALL SELECT 'part_mng total', COUNT(*) FROM part_mng
UNION ALL SELECT 'part_mng status active/release/활성', COUNT(*) FROM part_mng WHERE LOWER(COALESCE(status,'')) IN ('active','release','활성');
\echo ''
\echo '[2] 견적관리 라인 JOIN — 26C-0801'
SELECT CI.seq, CI.part_objid,
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='-1778190592' AND CI.status='ACTIVE'
ORDER BY CI.seq;
\echo ''
\echo '[3] /sales/parts 호출 결과 — 8,173건 기대'
SELECT COUNT(*) AS cnt 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 <> '';
\echo ''
\echo '[4] 영업관리 4개 메뉴 그리드 SQL 정합성 (라인 JOIN)'
\echo ' - 견적관리 26C-0801 ITEM_SUMMARY'
SELECT T.contract_no,
CASE WHEN COUNT(*) = 1 THEN MIN(COALESCE(PM.part_name, CI.part_name))
WHEN COUNT(*) > 1 THEN MIN(COALESCE(PM.part_name, CI.part_name)) || '' || (COUNT(*) - 1) || ''
ELSE '' END AS item_summary
FROM contract_mgmt T
JOIN contract_item CI ON CI.contract_objid = T.objid AND CI.status='ACTIVE'
LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid
WHERE T.contract_no IN ('26C-0801','26C-0797','26C-0796')
GROUP BY T.contract_no
ORDER BY T.contract_no DESC;
\echo ''
\echo '[5] 잡 데이터 제거 확인 (item_info에 있는 -20260126-, 0 등은 part_mng에 없어야)'
SELECT 'item_info garbage' AS kind, COUNT(*) FROM item_info WHERE item_number IN ('-20260126-', '-20260126-____')
UNION ALL SELECT 'part_mng garbage (0건 기대)', COUNT(*) FROM part_mng WHERE part_no IN ('-20260126-', '-20260126-____');
+47
View File
@@ -0,0 +1,47 @@
-- ============================================================
-- 매출관리 자동 검증 SQL
-- 메인 테이블: project_mgmt + sales_registration (shipping_date IS NOT NULL)
-- ============================================================
\echo ''
\echo '================== Revenue 검증 시작 =================='
\echo ''
\echo '[0] 사전 카운트 ============================'
SELECT 'project_mgmt' AS tbl, COUNT(*) FROM project_mgmt
UNION ALL SELECT 'sales_registration shipping_date NOT NULL',
COUNT(*) FROM sales_registration WHERE shipping_date IS NOT NULL;
\echo ''
\echo '[1] 매출 본 필터 (출하등록된 project) — 운영 0건 예상 =========================='
SELECT COUNT(*) AS revenue_visible_cnt
FROM project_mgmt T
WHERE EXISTS (SELECT 1 FROM sales_registration SR WHERE SR.project_no=T.project_no AND SR.shipping_date IS NOT NULL)
AND T.project_no IS NOT NULL AND T.project_no <> '';
\echo ''
\echo '[2] V1 신규 9개 컬럼 정합성 (필터 해제 샘플) =========================='
SELECT
T.project_no, CM.receipt_date,
CASE WHEN CM.paid_type='paid' THEN '유상' WHEN CM.paid_type='free' THEN '무상' END AS payment_type_name,
COALESCE(NULLIF(CI.due_date, ''), NULLIF(T.due_date, ''), NULLIF(CM.due_date, '')) AS request_date,
CC_RES.code_name AS order_status_name,
U_MGR.user_name AS manager_name,
SR.shipping_method,
SR.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 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_RES ON CC_RES.code_id = T.contract_result AND CC_RES.status='active'
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 T.project_no IS NOT NULL AND T.project_no <> ''
ORDER BY T.regdate DESC NULLS LAST LIMIT 5;
\echo ''
\echo '================== Revenue 검증 끝 =================='
+76
View File
@@ -0,0 +1,76 @@
-- ============================================================
-- 판매관리 자동 검증 SQL (BEGIN/ROLLBACK 시나리오)
-- 메인 테이블: project_mgmt + sales_registration + contract_mgmt
-- ============================================================
\echo ''
\echo '================== Sale 검증 시작 =================='
-- ─────────────────────────────────────────────────────────
-- [0] 사전 카운트
-- ─────────────────────────────────────────────────────────
\echo ''
\echo '[0] 사전 카운트 ============================'
SELECT 'project_mgmt' AS tbl, COUNT(*) FROM project_mgmt
UNION ALL SELECT 'sales_registration', COUNT(*) FROM sales_registration
UNION ALL SELECT 'project_mgmt matched by SR.project_no LIKE',
COUNT(DISTINCT T.project_no)
FROM project_mgmt T JOIN sales_registration SR ON SR.project_no LIKE T.project_no || '%';
-- ─────────────────────────────────────────────────────────
-- [1] 그리드 V1 신규 8개 컬럼 정합성 검증
-- ─────────────────────────────────────────────────────────
\echo ''
\echo '[1] 그리드 V1 신규 컬럼 (제품구분/국내해외/접수일/고객사요청사항/주문서첨부/출하방법/담당자/인도조건) =========================='
SELECT
T.project_no,
CC_PRD.code_name AS product_type_name,
CC_AREA.code_name AS nation_name,
CM.receipt_date,
COALESCE(NULLIF(CM.customer_request, ''), '-') AS customer_request,
COALESCE(AF.cu01_cnt, 0) AS cu01_cnt,
SR.shipping_method,
U_MGR.user_name AS manager_name,
SR.incoterms
FROM project_mgmt T
LEFT JOIN contract_mgmt CM ON CM.objid = T.contract_objid
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_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 (
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
ORDER BY T.regdate DESC NULLS LAST, T.project_no DESC
LIMIT 10;
-- ─────────────────────────────────────────────────────────
-- [2] 판매상태 wace 로직 검증 (미판매/완판/분할판매)
-- ─────────────────────────────────────────────────────────
\echo ''
\echo '[2] 판매상태 wace 로직 (미판매/완판/분할판매) =========================='
SELECT
T.project_no,
T.quantity AS order_qty,
COALESCE(SR_AGG.sales_qty_sum, 0) AS sales_qty_sum,
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
FROM project_mgmt T
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 || '%'
ORDER BY T.regdate DESC NULLS LAST
LIMIT 10;
\echo ''
\echo '================== Sale 검증 끝 =================='