feat: add report preset management API
- Implemented CRUD operations for report presets in reportPresetController. - Added routes for listing, creating, updating, and deleting report presets. - Ensured authentication is required for all preset operations. - Enhanced MaterialData interface to include optional width, height, and thickness properties.
This commit is contained in:
@@ -156,6 +156,7 @@ import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획
|
||||
import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리
|
||||
import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리
|
||||
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
|
||||
import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별)
|
||||
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
import systemNoticeRoutes from "./routes/systemNoticeRoutes"; // 시스템 공지
|
||||
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
|
||||
@@ -379,6 +380,7 @@ app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
|
||||
app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
|
||||
app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
|
||||
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
|
||||
app.use("/api/report-presets", reportPresetRoutes); // 리포트 프리셋 (회사별/리포트별 저장)
|
||||
app.use("/api/system-notice", systemNoticeRoutes); // 시스템 공지
|
||||
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
app.use("/api/design", designRoutes); // 설계 모듈
|
||||
|
||||
@@ -164,7 +164,7 @@ export async function getMaterialStatus(
|
||||
}
|
||||
|
||||
const bomQuery = `
|
||||
SELECT
|
||||
SELECT
|
||||
b.item_code AS parent_item_code,
|
||||
b.base_qty AS bom_base_qty,
|
||||
bd.child_item_id,
|
||||
@@ -173,7 +173,10 @@ export async function getMaterialStatus(
|
||||
bd.loss_rate,
|
||||
ii.item_name AS material_name,
|
||||
ii.item_number AS material_code,
|
||||
ii.unit AS material_unit
|
||||
ii.unit AS material_unit,
|
||||
COALESCE(ii.width::text, '') AS material_width,
|
||||
COALESCE(ii.height::text, '') AS material_height,
|
||||
COALESCE(ii.thickness::text, '') AS material_thickness
|
||||
FROM bom b
|
||||
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
|
||||
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND b.company_code = ii.company_code
|
||||
@@ -191,6 +194,9 @@ export async function getMaterialStatus(
|
||||
materialName: string;
|
||||
unit: string;
|
||||
requiredQty: number;
|
||||
width: string;
|
||||
height: string;
|
||||
thickness: string;
|
||||
}
|
||||
|
||||
const materialMap: Record<string, MaterialNeed> = {};
|
||||
@@ -216,6 +222,9 @@ export async function getMaterialStatus(
|
||||
materialName: bomRow.material_name || "알 수 없음",
|
||||
unit: bomRow.bom_unit || bomRow.material_unit || "EA",
|
||||
requiredQty,
|
||||
width: bomRow.material_width || "",
|
||||
height: bomRow.material_height || "",
|
||||
thickness: bomRow.material_thickness || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -303,6 +312,9 @@ export async function getMaterialStatus(
|
||||
current: totalCurrentQty,
|
||||
unit: material.unit,
|
||||
locations,
|
||||
width: material.width,
|
||||
height: material.height,
|
||||
thickness: material.thickness,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -90,7 +90,10 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
id.id AS detail_id,
|
||||
id.seq_no,
|
||||
id.inbound_type AS detail_inbound_type,
|
||||
wh.warehouse_name
|
||||
wh.warehouse_name,
|
||||
COALESCE(ii.width::text, '') AS width,
|
||||
COALESCE(ii.height::text, '') AS height,
|
||||
COALESCE(ii.thickness::text, '') AS thickness
|
||||
FROM (
|
||||
SELECT DISTINCT ON (h.company_code, h.inbound_number) h.*
|
||||
FROM inbound_mng h
|
||||
@@ -101,6 +104,12 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
LEFT JOIN warehouse_info wh
|
||||
ON im.warehouse_code = wh.warehouse_code
|
||||
AND im.company_code = wh.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT width, height, thickness FROM item_info
|
||||
WHERE item_number = COALESCE(id.item_number, im.item_number)
|
||||
AND company_code = im.company_code
|
||||
LIMIT 1
|
||||
) ii ON true
|
||||
${whereClause}
|
||||
ORDER BY im.created_date DESC, id.seq_no ASC
|
||||
`;
|
||||
|
||||
141
backend-node/src/controllers/reportPresetController.ts
Normal file
141
backend-node/src/controllers/reportPresetController.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Response } from "express";
|
||||
import { query } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 리포트 프리셋 컨트롤러
|
||||
* - 회사별 + 리포트별로 사용자 조건 구성 저장/조회
|
||||
* - 브라우저 localStorage가 아닌 서버 DB에 저장하여 기기/브라우저 간 공유 가능
|
||||
*/
|
||||
|
||||
/**
|
||||
* GET /report-presets?reportKey=xxx
|
||||
* 현재 회사 + report_key 에 해당하는 프리셋 목록 반환
|
||||
*/
|
||||
export async function listPresets(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다" });
|
||||
return;
|
||||
}
|
||||
const reportKey = String(req.query.reportKey || "");
|
||||
if (!reportKey) {
|
||||
res.status(400).json({ success: false, message: "reportKey가 필요합니다" });
|
||||
return;
|
||||
}
|
||||
const rows = await query(
|
||||
`SELECT id, preset_name AS name, description, config_json AS config, created_by, created_at, updated_at
|
||||
FROM report_presets
|
||||
WHERE (company_code = $1 OR $1 = '*') AND report_key = $2
|
||||
ORDER BY updated_at DESC NULLS LAST, id DESC`,
|
||||
[companyCode, reportKey]
|
||||
);
|
||||
res.status(200).json({ success: true, data: rows });
|
||||
} catch (error: any) {
|
||||
logger.error("리포트 프리셋 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /report-presets
|
||||
* body: { reportKey, name, description, config }
|
||||
*/
|
||||
export async function createPreset(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId || null;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다" });
|
||||
return;
|
||||
}
|
||||
const { reportKey, name, description, config } = req.body;
|
||||
if (!reportKey || !name || !config) {
|
||||
res.status(400).json({ success: false, message: "reportKey, name, config는 필수입니다" });
|
||||
return;
|
||||
}
|
||||
const rows = await query(
|
||||
`INSERT INTO report_presets (company_code, report_key, preset_name, description, config_json, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, NOW(), NOW())
|
||||
RETURNING id, preset_name AS name, description, config_json AS config, created_by, created_at, updated_at`,
|
||||
[companyCode, reportKey, name, description || null, JSON.stringify(config), userId]
|
||||
);
|
||||
res.status(200).json({ success: true, data: rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("리포트 프리셋 생성 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /report-presets/:id
|
||||
* body: { name?, description?, config? }
|
||||
*/
|
||||
export async function updatePreset(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다" });
|
||||
return;
|
||||
}
|
||||
const { id } = req.params;
|
||||
const { name, description, config } = req.body;
|
||||
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
if (name !== undefined) { sets.push(`preset_name = $${idx++}`); params.push(name); }
|
||||
if (description !== undefined) { sets.push(`description = $${idx++}`); params.push(description); }
|
||||
if (config !== undefined) { sets.push(`config_json = $${idx++}::jsonb`); params.push(JSON.stringify(config)); }
|
||||
sets.push("updated_at = NOW()");
|
||||
|
||||
params.push(id);
|
||||
const idParam = `$${idx++}`;
|
||||
params.push(companyCode);
|
||||
const companyParam = `$${idx++}`;
|
||||
|
||||
const rows = await query(
|
||||
`UPDATE report_presets SET ${sets.join(", ")}
|
||||
WHERE id = ${idParam} AND (company_code = ${companyParam} OR ${companyParam} = '*')
|
||||
RETURNING id, preset_name AS name, description, config_json AS config, created_by, created_at, updated_at`,
|
||||
params
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
res.status(404).json({ success: false, message: "프리셋을 찾을 수 없거나 권한이 없습니다" });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ success: true, data: rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("리포트 프리셋 수정 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /report-presets/:id
|
||||
*/
|
||||
export async function deletePreset(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다" });
|
||||
return;
|
||||
}
|
||||
const { id } = req.params;
|
||||
const rows = await query(
|
||||
`DELETE FROM report_presets
|
||||
WHERE id = $1 AND (company_code = $2 OR $2 = '*')
|
||||
RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
res.status(404).json({ success: false, message: "프리셋을 찾을 수 없거나 권한이 없습니다" });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("리포트 프리셋 삭제 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export async function getSalesReportData(
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
SELECT
|
||||
som.order_no,
|
||||
COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) as date,
|
||||
som.order_date,
|
||||
@@ -64,22 +64,37 @@ export async function getSalesReportData(
|
||||
CAST(COALESCE(NULLIF(sod.unit_price, ''), '0') AS numeric) as "unitPrice",
|
||||
CAST(COALESCE(NULLIF(sod.amount, ''), '0') AS numeric) as "orderAmt",
|
||||
1 as "orderCount",
|
||||
COALESCE(NULLIF(sod.width::text, ''), '') as width,
|
||||
COALESCE(NULLIF(sod.height::text, ''), '') as height,
|
||||
COALESCE(NULLIF(sod.thickness::text, ''), ii.thickness::text, '') as thickness,
|
||||
-- 면적(㎡) = 가로×세로 / 1,000,000 (가로/세로 mm 단위 가정)
|
||||
CASE
|
||||
WHEN sod.width IS NOT NULL AND sod.height IS NOT NULL
|
||||
THEN ROUND((CAST(sod.width AS numeric) * CAST(sod.height AS numeric) / 1000000.0)::numeric, 4)
|
||||
ELSE 0
|
||||
END as "areaSingle",
|
||||
-- 주문량 반영 총면적
|
||||
CASE
|
||||
WHEN sod.width IS NOT NULL AND sod.height IS NOT NULL
|
||||
THEN ROUND((CAST(sod.width AS numeric) * CAST(sod.height AS numeric) / 1000000.0 * COALESCE(CAST(NULLIF(sod.qty, '') AS numeric), 0))::numeric, 4)
|
||||
ELSE 0
|
||||
END as "totalArea",
|
||||
som.status,
|
||||
som.company_code
|
||||
FROM sales_order_mng som
|
||||
JOIN sales_order_detail sod
|
||||
ON som.order_no = sod.order_no
|
||||
JOIN sales_order_detail sod
|
||||
ON som.order_no = sod.order_no
|
||||
AND som.company_code = sod.company_code
|
||||
LEFT JOIN customer_mng cm
|
||||
ON som.partner_id = cm.customer_code
|
||||
LEFT JOIN customer_mng cm
|
||||
ON som.partner_id = cm.customer_code
|
||||
AND som.company_code = cm.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, thickness, company_code
|
||||
FROM item_info
|
||||
ORDER BY item_number, company_code, created_date DESC
|
||||
) ii
|
||||
ON sod.part_code = ii.item_number
|
||||
) ii
|
||||
ON sod.part_code = ii.item_number
|
||||
AND sod.company_code = ii.company_code
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
|
||||
17
backend-node/src/routes/reportPresetRoutes.ts
Normal file
17
backend-node/src/routes/reportPresetRoutes.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import {
|
||||
listPresets,
|
||||
createPreset,
|
||||
updatePreset,
|
||||
deletePreset,
|
||||
} from "../controllers/reportPresetController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", authenticateToken, listPresets);
|
||||
router.post("/", authenticateToken, createPreset);
|
||||
router.put("/:id", authenticateToken, updatePreset);
|
||||
router.delete("/:id", authenticateToken, deletePreset);
|
||||
|
||||
export default router;
|
||||
@@ -1464,7 +1464,8 @@ class NumberingRuleService {
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const startFrom = autoConfig.startFrom || 1;
|
||||
const nextSequence = baseSeq + startFrom;
|
||||
// 순수 max+1: 테이블에 max가 있으면 max+1, 없으면 startFrom
|
||||
const nextSequence = baseSeq > 0 ? baseSeq + 1 : startFrom;
|
||||
return String(nextSequence).padStart(length, "0");
|
||||
}
|
||||
|
||||
@@ -1606,7 +1607,8 @@ class NumberingRuleService {
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const startFrom = autoConfig.startFrom || 1;
|
||||
const actualSequence = allocatedSequence + startFrom - 1;
|
||||
// allocatedSequence는 이미 max+1 형태. 테이블이 비어 있을 때만 startFrom 적용
|
||||
const actualSequence = allocatedSequence > 1 ? allocatedSequence : startFrom;
|
||||
return String(actualSequence).padStart(length, "0");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user