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:
kjs
2026-05-18 16:13:29 +09:00
parent bd7563bd5a
commit fa9f5451f6
52 changed files with 1494 additions and 342 deletions

View File

@@ -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); // 예약 요청 관리

View File

@@ -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,
]
);
}

View 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: "거래처 담당자 조회에 실패했습니다.",
});
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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,
],
);
}

View File

@@ -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 기반 전이는 제거(중복 가산 해소 후 무의미).
}
}

View File

@@ -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,
});
}
};
/**
* 카테고리 값 추가 (메뉴 스코프)
*

View 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;

View File

@@ -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);

View 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,
}));
}

View File

@@ -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

View File

@@ -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;
}