Add Customer Contact Routes and Enhance User Management
- Introduced new routes for customer contact management, allowing for the retrieval of customer contact information. - Updated the user management functionality to include validation for the hire date, ensuring proper date format and handling of null values. - Enhanced the save user functionality to accommodate the new hire date field, maintaining existing values when not provided. (TASK: ERP-XXX)
This commit is contained in:
@@ -104,6 +104,7 @@ import reportRoutes from "./routes/reportRoutes";
|
||||
import barcodeLabelRoutes from "./routes/barcodeLabelRoutes";
|
||||
import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API
|
||||
import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리
|
||||
import customerContactRoutes from "./routes/customerContactRoutes"; // 거래처 담당자 조회
|
||||
import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리
|
||||
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
||||
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||
@@ -349,6 +350,7 @@ app.use("/api/admin/reports", reportRoutes);
|
||||
app.use("/api/admin/barcode-labels", barcodeLabelRoutes);
|
||||
app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API
|
||||
app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리
|
||||
app.use("/api/customer-contacts", customerContactRoutes); // 거래처 담당자 조회
|
||||
app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
|
||||
app.use("/api/todos", todoRoutes); // To-Do 관리
|
||||
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
||||
|
||||
@@ -4122,6 +4122,7 @@ interface UserWithDeptRequest {
|
||||
position_code?: string;
|
||||
position_name?: string;
|
||||
end_date?: string | null;
|
||||
hire_date?: string | null; // 입사일 (등록시각 regdate 와 분리)
|
||||
};
|
||||
mainDept?: {
|
||||
dept_code: string;
|
||||
@@ -4169,6 +4170,24 @@ export const saveUserWithDept = async (
|
||||
return;
|
||||
}
|
||||
|
||||
// 입사일(hire_date) 형식 검증 — 값이 들어온 경우에만 YYYY-MM-DD 유효 날짜인지 확인
|
||||
let hireDate: string | null | undefined = undefined;
|
||||
if (userInfo.hire_date !== undefined && userInfo.hire_date !== null && userInfo.hire_date !== "") {
|
||||
const raw = String(userInfo.hire_date).substring(0, 10);
|
||||
const d = new Date(`${raw}T00:00:00+09:00`);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw) || isNaN(d.getTime())) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "입사일 형식이 올바르지 않습니다.",
|
||||
error: { code: "INVALID_HIRE_DATE" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
hireDate = raw;
|
||||
} else if (userInfo.hire_date === null || userInfo.hire_date === "") {
|
||||
hireDate = null; // 명시적으로 비운 경우
|
||||
}
|
||||
|
||||
// 트랜잭션 시작
|
||||
await client.query("BEGIN");
|
||||
|
||||
@@ -4234,6 +4253,8 @@ export const saveUserWithDept = async (
|
||||
position_code: userInfo.position_code,
|
||||
position_name: positionName,
|
||||
end_date: userInfo.end_date !== undefined ? (userInfo.end_date ? `${userInfo.end_date.substring(0, 10)}T00:00:00+09:00` : null) : undefined,
|
||||
// 입사일: 페이로드에 hire_date 키가 온 경우에만 갱신(누락 시 기존값 유지)
|
||||
hire_date: userInfo.hire_date !== undefined ? hireDate ?? null : undefined,
|
||||
company_code: companyCode !== "*" ? companyCode : undefined,
|
||||
};
|
||||
|
||||
@@ -4265,8 +4286,8 @@ export const saveUserWithDept = async (
|
||||
email, tel, cell_phone, sabun,
|
||||
user_type, user_type_name, status, locale,
|
||||
dept_code, dept_name, position_code, position_name,
|
||||
company_code, end_date, regdate
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, NOW())`,
|
||||
company_code, end_date, hire_date, regdate
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, COALESCE($19::date, CURRENT_DATE), NOW())`,
|
||||
[
|
||||
userInfo.user_id,
|
||||
userInfo.user_name,
|
||||
@@ -4286,6 +4307,8 @@ export const saveUserWithDept = async (
|
||||
positionName,
|
||||
companyCode !== "*" ? companyCode : null,
|
||||
userInfo.end_date ? `${userInfo.end_date.substring(0, 10)}T00:00:00+09:00` : null,
|
||||
// 입사일: 값 있으면 그 날짜, 없으면 COALESCE 로 CURRENT_DATE(오늘) 기본값
|
||||
hireDate ?? null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
50
backend-node/src/controllers/customerContactController.ts
Normal file
50
backend-node/src/controllers/customerContactController.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 거래처 담당자(customer_contact) 조회 컨트롤러
|
||||
*
|
||||
* 수주관리 등에서 거래처 선택 시 담당자 리스트/메인담당자 조회용.
|
||||
* 조회 전용 (DB 스키마 변경 없음).
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import * as customerContactService from "../services/customerContactService";
|
||||
|
||||
/**
|
||||
* GET /api/customer-contacts/:customerId
|
||||
* 거래처별 담당자 리스트 조회
|
||||
* - :customerId 는 customer_mng.customer_code 또는 customer_mng.id
|
||||
* - company_code 는 인증 토큰에서 추출 (멀티테넌트 격리)
|
||||
*/
|
||||
export async function getContactsByCustomer(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { customerId } = req.params;
|
||||
|
||||
if (!customerId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "거래처 식별자가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contacts = await customerContactService.getContactsByCustomer(
|
||||
companyCode,
|
||||
customerId,
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: contacts,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("거래처 담당자 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "거래처 담당자 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ interface InspectionRow {
|
||||
apply_process: string | null;
|
||||
classification: string | null;
|
||||
pass_criteria: string | null;
|
||||
upper_limit: string | null;
|
||||
lower_limit: string | null;
|
||||
is_required: string | null;
|
||||
is_active: string | null;
|
||||
manager_id: string | null;
|
||||
@@ -98,7 +100,7 @@ export async function getGroupedList(req: AuthenticatedRequest, res: Response) {
|
||||
SELECT
|
||||
id, item_code, item_name, inspection_type, inspection_standard, inspection_standard_id,
|
||||
inspection_item_name, inspection_method, apply_process, classification,
|
||||
pass_criteria, is_required, is_active, manager_id, memo,
|
||||
pass_criteria, upper_limit, lower_limit, is_required, is_active, manager_id, memo,
|
||||
sort_order, change_record, created_date, updated_date
|
||||
FROM item_inspection_info
|
||||
WHERE company_code = $1
|
||||
|
||||
@@ -3644,6 +3644,8 @@ accepted_results AS (
|
||||
'started_at', started_at,
|
||||
'completed_at', completed_at,
|
||||
'equipment_code', equipment_code,
|
||||
'defect_detail', defect_detail,
|
||||
'result_note', result_note,
|
||||
'batch_id', batch_id
|
||||
) ORDER BY seq
|
||||
) AS accepted_results
|
||||
|
||||
@@ -167,6 +167,20 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 담당자(처리자) 한글명 조회 — inventory_history에 manager_id/manager_name 동반 기록용
|
||||
// (CLAUDE.md "사용자 식별 표시 필수": DB에는 user_id 저장, 표시는 user_name)
|
||||
let managerName = userId;
|
||||
try {
|
||||
const mgrRes = await client.query(
|
||||
`SELECT COALESCE(NULLIF(user_name, ''), user_id) AS user_name
|
||||
FROM user_info WHERE user_id = $1 AND company_code = $2 LIMIT 1`,
|
||||
[userId, companyCode],
|
||||
);
|
||||
if (mgrRes.rows[0]?.user_name) managerName = mgrRes.rows[0].user_name;
|
||||
} catch {
|
||||
/* user_info 조회 실패 시 userId fallback 유지 */
|
||||
}
|
||||
|
||||
inboundType = await resolveCategoryCode(client, "inbound_mng", "inbound_type", inboundType);
|
||||
|
||||
// 1. 헤더 — 같은 (company_code, inbound_number) 헤더가 있으면 reuse, 없으면 INSERT (멱등성)
|
||||
@@ -329,8 +343,8 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
`INSERT INTO inventory_history (
|
||||
id, company_code, item_code, warehouse_code, location_code,
|
||||
transaction_type, transaction_date, quantity, balance_qty, remark,
|
||||
writer, created_date
|
||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW(), $5, $6, $7, $8, NOW())`,
|
||||
writer, manager_id, manager_name, created_date
|
||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW(), $5, $6, $7, $8, $9, $10, NOW())`,
|
||||
[
|
||||
companyCode,
|
||||
itemCode,
|
||||
@@ -340,6 +354,8 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
afterQty,
|
||||
resolvedItemInboundType || "입고",
|
||||
userId,
|
||||
userId,
|
||||
managerName,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,8 +121,9 @@ async function getNormalizedOrders(
|
||||
dueDate: r.due_date || "",
|
||||
orderQty: Number(r.order_qty || 0),
|
||||
shipQty: Number(r.ship_qty || 0),
|
||||
// balance_qty가 NULL/0이면 orderQty - shipQty fallback (수주 등록 시 채워지지 않은 데이터 보정)
|
||||
balanceQty: Number(r.balance_qty) || (Number(r.order_qty || 0) - Number(r.ship_qty || 0)),
|
||||
// 잔량은 항상 (수주량 - 출하수량) 파생값으로 산출.
|
||||
// 저장된 balance_qty는 비정규화 필드라 오염될 수 있어 집계에 신뢰하지 않는다.
|
||||
balanceQty: Math.max(0, Number(r.order_qty || 0) - Number(r.ship_qty || 0)),
|
||||
}));
|
||||
} else {
|
||||
// 마스터 기준 → 거래처 JOIN
|
||||
@@ -166,8 +167,9 @@ async function getNormalizedOrders(
|
||||
dueDate: r.due_date || "",
|
||||
orderQty: Number(r.order_qty || 0),
|
||||
shipQty: Number(r.ship_qty || 0),
|
||||
// balance_qty가 NULL/0이면 orderQty - shipQty fallback (수주 등록 시 채워지지 않은 데이터 보정)
|
||||
balanceQty: Number(r.balance_qty) || (Number(r.order_qty || 0) - Number(r.ship_qty || 0)),
|
||||
// 잔량은 항상 (수주량 - 출하수량) 파생값으로 산출.
|
||||
// 저장된 balance_qty는 비정규화 필드라 오염될 수 있어 집계에 신뢰하지 않는다.
|
||||
balanceQty: Math.max(0, Number(r.order_qty || 0) - Number(r.ship_qty || 0)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -411,38 +413,9 @@ export async function updatePlan(req: AuthenticatedRequest, res: Response) {
|
||||
finalParams
|
||||
);
|
||||
|
||||
// plan_qty가 변경되었고 마스터(sales_order_mng) 기반 출하계획이면 master 재계산 + 자동 전이
|
||||
if (qtyDelta !== 0 && masterId) {
|
||||
// sales_order_mng의 ship_qty/balance_qty를 delta만큼 보정
|
||||
await client.query(
|
||||
`UPDATE sales_order_mng
|
||||
SET ship_qty = COALESCE(ship_qty, 0) + $1,
|
||||
balance_qty = COALESCE(order_qty, 0) - (COALESCE(ship_qty, 0) + $1),
|
||||
updated_date = NOW()
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[qtyDelta, masterId, companyCode]
|
||||
);
|
||||
|
||||
// 자동 상태 전이 (TASK:ERP-047) — 양방향
|
||||
// balance_qty <= 0 AND status='CONFIRMED' → 'COMPLETED'
|
||||
await client.query(
|
||||
`UPDATE sales_order_mng
|
||||
SET status = 'COMPLETED', updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2
|
||||
AND status = 'CONFIRMED'
|
||||
AND COALESCE(balance_qty, 0) <= 0`,
|
||||
[masterId, companyCode]
|
||||
);
|
||||
// balance_qty > 0 AND status='COMPLETED' → 'CONFIRMED'
|
||||
await client.query(
|
||||
`UPDATE sales_order_mng
|
||||
SET status = 'CONFIRMED', updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2
|
||||
AND status = 'COMPLETED'
|
||||
AND COALESCE(balance_qty, 0) > 0`,
|
||||
[masterId, companyCode]
|
||||
);
|
||||
}
|
||||
// TASK:ERP-053-A — 출하계획 plan_qty 수정 시 sales_order_mng.ship_qty/balance_qty 가산 보정 제거.
|
||||
// ship_qty는 "실제 출고 누적"으로 단일화되었으므로 계획수량 변경은 ship_qty에 영향 주지 않는다.
|
||||
// 계획수량 변경은 shipment_plan.plan_qty 에만 반영. 수주 상태 전이는 실제 출고 시점에서 처리.
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
@@ -507,9 +480,9 @@ export async function getAggregate(req: AuthenticatedRequest, res: Response) {
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
for (const [partCode, partOrders] of partCodeMap) {
|
||||
// 총수주잔량: 선택된 수주들의 balance_qty 합
|
||||
// 총수주잔량: 선택된 수주들의 (수주량 - 출하수량) 합 (파생값 일관 사용)
|
||||
const totalBalance = partOrders.reduce(
|
||||
(s, o) => s + (o.balanceQty > 0 ? o.balanceQty : o.orderQty - o.shipQty),
|
||||
(s, o) => s + Math.max(0, o.orderQty - o.shipQty),
|
||||
0
|
||||
);
|
||||
|
||||
@@ -723,7 +696,7 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||
// 디테일 소스: detail_id로 저장
|
||||
const detailCheck = await client.query(
|
||||
`SELECT d.id, d.order_no, d.part_code, d.qty, d.ship_qty, d.balance_qty,
|
||||
m.id AS master_id
|
||||
m.id AS master_id, m.order_qty AS m_order_qty
|
||||
FROM sales_order_detail d
|
||||
LEFT JOIN sales_order_mng m
|
||||
ON d.order_no = m.order_no AND d.company_code = m.company_code
|
||||
@@ -736,13 +709,25 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||
}
|
||||
|
||||
const detail = detailCheck.rows[0];
|
||||
const qty = Number(detail.qty || 0);
|
||||
const shipQty = Number(detail.ship_qty || 0);
|
||||
const balanceQty = detail.balance_qty
|
||||
? Number(detail.balance_qty)
|
||||
: qty - shipQty;
|
||||
// 수주량: detail.qty 우선, 없으면 마스터 order_qty 폴백
|
||||
// (getNormalizedOrders와 동일 규칙 — '0'/공란 함정 제거)
|
||||
const baseQty = Number(detail.qty) || Number(detail.m_order_qty) || 0;
|
||||
|
||||
if (balanceQty > 0 && planQty > balanceQty) {
|
||||
// TASK:ERP-053-A — ship_qty는 "실제 출고 누적"으로 단일화.
|
||||
// 출하계획 생성 단계에서는 ship_qty/balance_qty를 가산하지 않는다.
|
||||
// (실제 출고는 outboundController.ts:288 에서만 ship_qty 반영)
|
||||
// 과다 출하계획 가드: 미출하량 = 수주량 - 실제출고(ship_qty) - 기존 계획수량 합.
|
||||
const prevShip = Number(detail.ship_qty) || 0;
|
||||
const planSumRes = await client.query(
|
||||
`SELECT COALESCE(SUM(plan_qty), 0) AS plan_sum
|
||||
FROM shipment_plan
|
||||
WHERE detail_id = $1 AND company_code = $2`,
|
||||
[sourceId, companyCode]
|
||||
);
|
||||
const existingPlanSum = Number(planSumRes.rows[0]?.plan_sum || 0);
|
||||
const balanceQty = baseQty - prevShip - existingPlanSum; // 이번 계획 전 잔여 가능량
|
||||
|
||||
if (baseQty > 0 && planQty > balanceQty) {
|
||||
throw new Error(
|
||||
`수주번호 ${detail.order_no}: 출하계획량(${planQty})이 미출하량(${balanceQty})을 초과합니다`
|
||||
);
|
||||
@@ -758,16 +743,8 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||
);
|
||||
savedPlans.push(insertRes.rows[0]);
|
||||
|
||||
// detail ship_qty 업데이트
|
||||
await client.query(
|
||||
`UPDATE sales_order_detail
|
||||
SET ship_qty = (COALESCE(NULLIF(ship_qty,'')::numeric, 0) + $1)::text,
|
||||
balance_qty = (COALESCE(NULLIF(qty,'')::numeric, 0)
|
||||
- COALESCE(NULLIF(ship_qty,'')::numeric, 0) - $1)::text,
|
||||
updated_date = NOW()
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[planQty, sourceId, companyCode]
|
||||
);
|
||||
// TASK:ERP-053-A — 출하계획 생성 시 sales_order_detail.ship_qty 가산 제거.
|
||||
// 계획수량은 shipment_plan.plan_qty 로만 관리. ship_qty는 실제 출고에서만 반영.
|
||||
} else {
|
||||
// 마스터 소스: sales_order_id로 저장
|
||||
const masterId = Number(sourceId);
|
||||
@@ -783,9 +760,21 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||
}
|
||||
|
||||
const master = masterCheck.rows[0];
|
||||
const balanceQty = Number(master.balance_qty || 0);
|
||||
const orderQty = Number(master.order_qty || 0);
|
||||
|
||||
if (balanceQty > 0 && planQty > balanceQty) {
|
||||
// TASK:ERP-053-A — ship_qty는 "실제 출고 누적"으로 단일화.
|
||||
// 과다 출하계획 가드: 미출하량 = 수주량 - 실제출고(ship_qty) - 기존 계획수량 합.
|
||||
const prevShip = Number(master.ship_qty || 0);
|
||||
const planSumRes = await client.query(
|
||||
`SELECT COALESCE(SUM(plan_qty), 0) AS plan_sum
|
||||
FROM shipment_plan
|
||||
WHERE sales_order_id = $1 AND company_code = $2`,
|
||||
[masterId, companyCode]
|
||||
);
|
||||
const existingPlanSum = Number(planSumRes.rows[0]?.plan_sum || 0);
|
||||
const balanceQty = orderQty - prevShip - existingPlanSum;
|
||||
|
||||
if (orderQty > 0 && planQty > balanceQty) {
|
||||
throw new Error(
|
||||
`수주번호 ${master.order_no}: 출하계획량(${planQty})이 미출하량(${balanceQty})을 초과합니다`
|
||||
);
|
||||
@@ -801,27 +790,10 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||
);
|
||||
savedPlans.push(insertRes.rows[0]);
|
||||
|
||||
// 마스터 ship_qty 업데이트
|
||||
await client.query(
|
||||
`UPDATE sales_order_mng
|
||||
SET ship_qty = COALESCE(ship_qty, 0) + $1,
|
||||
balance_qty = COALESCE(order_qty, 0) - COALESCE(ship_qty, 0) - $1,
|
||||
updated_date = NOW()
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[planQty, masterId, companyCode]
|
||||
);
|
||||
|
||||
// 자동 상태 전이 (TASK:ERP-047)
|
||||
// balance_qty <= 0 AND status='CONFIRMED' → 'COMPLETED'
|
||||
// 같은 트랜잭션 내에서 처리
|
||||
await client.query(
|
||||
`UPDATE sales_order_mng
|
||||
SET status = 'COMPLETED', updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2
|
||||
AND status = 'CONFIRMED'
|
||||
AND COALESCE(balance_qty, 0) <= 0`,
|
||||
[masterId, companyCode]
|
||||
);
|
||||
// TASK:ERP-053-A — 출하계획 생성 시 sales_order_mng.ship_qty/balance_qty 가산 제거.
|
||||
// 계획수량은 shipment_plan.plan_qty 로만 관리. ship_qty는 실제 출고에서만 반영.
|
||||
// 자동 상태 전이(TASK:ERP-047 COMPLETED)는 실제 출고 시점(outboundController)에서 처리되어야 하므로
|
||||
// 출하계획 생성 단계의 stale balance_qty 기반 전이는 제거(중복 가산 해소 후 무의미).
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,18 +82,26 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response
|
||||
filterCompanyCode,
|
||||
});
|
||||
|
||||
const values = await tableCategoryValueService.getCategoryValues(
|
||||
tableName,
|
||||
columnName,
|
||||
effectiveCompanyCode,
|
||||
includeInactive,
|
||||
menuObjid,
|
||||
topLevelOnly
|
||||
);
|
||||
const [values, useHierarchy] = await Promise.all([
|
||||
tableCategoryValueService.getCategoryValues(
|
||||
tableName,
|
||||
columnName,
|
||||
effectiveCompanyCode,
|
||||
includeInactive,
|
||||
menuObjid,
|
||||
topLevelOnly
|
||||
),
|
||||
tableCategoryValueService.getUseHierarchy(
|
||||
tableName,
|
||||
columnName,
|
||||
effectiveCompanyCode
|
||||
),
|
||||
]);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: values,
|
||||
useHierarchy,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 값 조회 실패: ${error.message}`);
|
||||
@@ -105,6 +113,66 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 컬럼 use_hierarchy 플래그 업데이트
|
||||
*
|
||||
* PUT /api/table-categories/:tableName/:columnName/hierarchy-flag
|
||||
*
|
||||
* Body: { useHierarchy: boolean }
|
||||
*/
|
||||
export const updateUseHierarchy = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { tableName, columnName } = req.params;
|
||||
const { useHierarchy } = req.body;
|
||||
|
||||
if (typeof useHierarchy !== "boolean") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "useHierarchy는 boolean 값이어야 합니다",
|
||||
});
|
||||
}
|
||||
|
||||
if (companyCode === "*") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "최고관리자(*) 계정에서는 use_hierarchy 설정을 변경할 수 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("use_hierarchy 업데이트 요청", {
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode,
|
||||
useHierarchy,
|
||||
});
|
||||
|
||||
const updated = await tableCategoryValueService.updateUseHierarchy(
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode,
|
||||
useHierarchy,
|
||||
userId
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { useHierarchy: updated },
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`use_hierarchy 업데이트 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "use_hierarchy 업데이트 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 값 추가 (메뉴 스코프)
|
||||
*
|
||||
|
||||
21
backend-node/src/routes/customerContactRoutes.ts
Normal file
21
backend-node/src/routes/customerContactRoutes.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 거래처 담당자(customer_contact) 조회 라우트
|
||||
*
|
||||
* 수주관리 등에서 거래처 선택 시 담당자 리스트/메인담당자 조회용 경량 API.
|
||||
* 조회 전용 (DB 스키마 변경 없음).
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as customerContactController from "../controllers/customerContactController";
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* GET /api/customer-contacts/:customerId
|
||||
* 거래처별 담당자 리스트 조회 (customerId = customer_code 또는 id)
|
||||
*/
|
||||
router.get("/:customerId", customerContactController.getContactsByCustomer);
|
||||
|
||||
export default router;
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
deleteColumnMappingsByColumn,
|
||||
getSecondLevelMenus,
|
||||
getCategoryLabelsByCodes,
|
||||
updateUseHierarchy,
|
||||
} from "../controllers/tableCategoryValueController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
@@ -33,6 +34,9 @@ router.get("/:tableName/columns", getCategoryColumns);
|
||||
// 카테고리 값 목록 조회
|
||||
router.get("/:tableName/:columnName/values", getCategoryValues);
|
||||
|
||||
// 카테고리 컬럼 use_hierarchy 플래그 업데이트
|
||||
router.put("/:tableName/:columnName/hierarchy-flag", updateUseHierarchy);
|
||||
|
||||
// 카테고리 값 추가
|
||||
router.post("/values", addCategoryValue);
|
||||
|
||||
|
||||
97
backend-node/src/services/customerContactService.ts
Normal file
97
backend-node/src/services/customerContactService.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 거래처 담당자(customer_contact) 조회 서비스
|
||||
*
|
||||
* 수주관리 등에서 거래처 선택 시 해당 거래처의 담당자 리스트와
|
||||
* 메인담당자(is_main='Y')를 조회하기 위한 경량 조회 전용 서비스.
|
||||
* - DB 스키마 변경 없음 (조회 전용)
|
||||
* - 멀티테넌트 격리: company_code 필터 필수
|
||||
*
|
||||
* 거래처 식별자 매핑:
|
||||
* - customer_contact.customer_id = customer_mng.id (숫자 PK, text 비교)
|
||||
* - 프론트(수주관리)의 partner_id 값은 customer_mng.customer_code 이므로
|
||||
* customer_code 로도 조회 가능하도록 customer_mng JOIN 으로 해석
|
||||
*/
|
||||
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface CustomerContact {
|
||||
id: string;
|
||||
customer_id: string | null;
|
||||
contact_name: string | null;
|
||||
contact_phone: string | null;
|
||||
contact_email: string | null;
|
||||
department: string | null;
|
||||
is_main: boolean;
|
||||
memo: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처별 담당자 리스트 조회
|
||||
* @param companyCode 로그인 사용자 회사코드 (멀티테넌트 격리)
|
||||
* @param customerRef 거래처 식별자 — customer_mng.customer_code 또는 customer_mng.id
|
||||
*/
|
||||
export async function getContactsByCustomer(
|
||||
companyCode: string,
|
||||
customerRef: string,
|
||||
): Promise<CustomerContact[]> {
|
||||
const pool = getPool();
|
||||
|
||||
// customer_code(프론트 partner_id) 또는 숫자 id 둘 다 매칭.
|
||||
// customer_contact.customer_id 는 customer_mng.id(숫자)를 text로 저장.
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (companyCode && companyCode !== "*") {
|
||||
conditions.push(`cc.company_code = $${idx}`);
|
||||
params.push(companyCode);
|
||||
idx++;
|
||||
}
|
||||
|
||||
// customerRef 를 customer_mng 에서 해석한 id 목록 또는 직접 일치하는 customer_id
|
||||
conditions.push(`(
|
||||
cc.customer_id = $${idx}
|
||||
OR cc.customer_id IN (
|
||||
SELECT cm.id::text FROM customer_mng cm
|
||||
WHERE (cm.customer_code = $${idx} OR cm.id::text = $${idx})
|
||||
${companyCode && companyCode !== "*" ? `AND cm.company_code = $1` : ""}
|
||||
)
|
||||
)`);
|
||||
params.push(customerRef);
|
||||
idx++;
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT cc.id,
|
||||
cc.customer_id,
|
||||
cc.contact_name,
|
||||
cc.contact_phone,
|
||||
cc.contact_email,
|
||||
cc.department,
|
||||
cc.is_main,
|
||||
cc.memo
|
||||
FROM customer_contact cc
|
||||
${where}
|
||||
ORDER BY (cc.is_main = 'Y') DESC, cc.contact_name ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
logger.info("거래처 담당자 조회", {
|
||||
companyCode,
|
||||
customerRef,
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
return result.rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
customer_id: r.customer_id,
|
||||
contact_name: r.contact_name,
|
||||
contact_phone: r.contact_phone,
|
||||
contact_email: r.contact_email,
|
||||
department: r.department,
|
||||
is_main: r.is_main === "Y" || r.is_main === true,
|
||||
memo: r.memo,
|
||||
}));
|
||||
}
|
||||
@@ -262,9 +262,158 @@ class TableCategoryValueService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 컬럼의 use_hierarchy(트리 사용 여부) 플래그 조회
|
||||
*
|
||||
* - 정책 행이 있으면 boolean 반환
|
||||
* - 없으면 null 반환 (클라이언트가 자동 감지 fallback 사용)
|
||||
*/
|
||||
async getUseHierarchy(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
companyCode: string
|
||||
): Promise<boolean | null> {
|
||||
try {
|
||||
const pool = getPool();
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `
|
||||
SELECT use_hierarchy
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND logical_column_name = $2
|
||||
ORDER BY (physical_column_name = logical_column_name) DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
params = [tableName, columnName];
|
||||
} else {
|
||||
query = `
|
||||
SELECT use_hierarchy
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND logical_column_name = $2
|
||||
AND company_code = $3
|
||||
ORDER BY (physical_column_name = logical_column_name) DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
params = [tableName, columnName, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return Boolean(result.rows[0].use_hierarchy);
|
||||
} catch (error: any) {
|
||||
logger.error(`use_hierarchy 조회 실패: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 컬럼의 use_hierarchy 플래그 업서트
|
||||
*
|
||||
* - 동일 (table_name, logical_column_name, company_code) 정책 행이 있으면 update
|
||||
* - 없으면 신규 정책 행 insert (physical_column_name = logical_column_name)
|
||||
*/
|
||||
async updateUseHierarchy(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
companyCode: string,
|
||||
useHierarchy: boolean,
|
||||
userId: string
|
||||
): Promise<boolean> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
logger.info("use_hierarchy 업데이트", {
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode,
|
||||
useHierarchy,
|
||||
});
|
||||
|
||||
// 최고관리자(*)는 정책 저장 대상이 모호하므로 차단
|
||||
if (companyCode === "*") {
|
||||
throw new Error("최고관리자(*) 계정에서는 use_hierarchy 설정을 변경할 수 없습니다");
|
||||
}
|
||||
|
||||
// 1) 먼저 update 시도
|
||||
const updateQuery = `
|
||||
UPDATE category_column_mapping
|
||||
SET use_hierarchy = $4,
|
||||
updated_at = NOW(),
|
||||
updated_by = $5
|
||||
WHERE table_name = $1
|
||||
AND logical_column_name = $2
|
||||
AND company_code = $3
|
||||
`;
|
||||
const updateResult = await pool.query(updateQuery, [
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode,
|
||||
useHierarchy,
|
||||
userId,
|
||||
]);
|
||||
|
||||
if ((updateResult.rowCount || 0) > 0) {
|
||||
logger.info("use_hierarchy update 완료", {
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode,
|
||||
rowsUpdated: updateResult.rowCount,
|
||||
});
|
||||
return useHierarchy;
|
||||
}
|
||||
|
||||
// 2) update 대상이 없으면 정책 행 신규 insert
|
||||
// mapping_id는 SEQUENCE가 없는 환경 대비 MAX+1
|
||||
const insertQuery = `
|
||||
INSERT INTO category_column_mapping (
|
||||
mapping_id,
|
||||
table_name,
|
||||
logical_column_name,
|
||||
physical_column_name,
|
||||
company_code,
|
||||
description,
|
||||
use_hierarchy,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
(SELECT COALESCE(MAX(mapping_id), 0) + 1 FROM category_column_mapping),
|
||||
$1, $2, $2, $3, 'use_hierarchy 정책 행', $4, $5, $5, NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
await pool.query(insertQuery, [
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode,
|
||||
useHierarchy,
|
||||
userId,
|
||||
]);
|
||||
|
||||
logger.info("use_hierarchy insert 완료", {
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode,
|
||||
useHierarchy,
|
||||
});
|
||||
return useHierarchy;
|
||||
} catch (error: any) {
|
||||
logger.error(`use_hierarchy 업데이트 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 추가 (메뉴 스코프)
|
||||
*
|
||||
*
|
||||
* @param value 카테고리 값 정보
|
||||
* @param companyCode 회사 코드
|
||||
* @param userId 생성자 ID
|
||||
|
||||
@@ -43,3 +43,13 @@ export interface CategoryColumn {
|
||||
valueCount?: number; // 값 개수
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 조회 응답 wrapper
|
||||
* - values: 계층 구조 변환된 카테고리 값 트리
|
||||
* - useHierarchy: category_column_mapping에 저장된 트리 사용 여부 (null이면 미설정 → 클라이언트 fallback 자동 감지)
|
||||
*/
|
||||
export interface CategoryValuesResponse {
|
||||
values: TableCategoryValue[];
|
||||
useHierarchy: boolean | null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user