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

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