jskim-node #406

Merged
kjs merged 7 commits from jskim-node into main 2026-03-09 18:05:31 +09:00
10 changed files with 334 additions and 51 deletions
Showing only changes of commit 4d6783e508 - Show all commits

View File

@ -233,8 +233,35 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
const { moldCode } = req.params; const { moldCode } = req.params;
const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body; const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body;
if (!serial_number) { let finalSerialNumber = serial_number;
res.status(400).json({ success: false, message: "일련번호는 필수입니다." });
// 일련번호가 비어있으면 채번 규칙으로 자동 생성
if (!finalSerialNumber) {
try {
const { numberingRuleService } = await import("../services/numberingRuleService");
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode,
"mold_serial",
"serial_number"
);
if (rule) {
// formData에 mold_code를 포함 (reference 파트에서 참조)
const formData = { mold_code: moldCode, ...req.body };
finalSerialNumber = await numberingRuleService.allocateCode(
rule.ruleId,
companyCode,
formData
);
logger.info("일련번호 자동 채번 완료", { serialNumber: finalSerialNumber, ruleId: rule.ruleId });
}
} catch (numError: any) {
logger.error("일련번호 자동 채번 실패", { error: numError.message });
}
}
if (!finalSerialNumber) {
res.status(400).json({ success: false, message: "일련번호를 생성할 수 없습니다. 채번 규칙을 확인해주세요." });
return; return;
} }
@ -244,7 +271,7 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
RETURNING * RETURNING *
`; `;
const params = [ const params = [
companyCode, moldCode, serial_number, status || "STORED", companyCode, moldCode, finalSerialNumber, status || "STORED",
progress || 0, work_description || null, manager || null, progress || 0, work_description || null, manager || null,
completion_date || null, remarks || null, userId, completion_date || null, remarks || null, userId,
]; ];

View File

@ -172,6 +172,16 @@ class NumberingRuleService {
break; break;
} }
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
prefixParts.push(String(formData[refColumn]));
} else {
prefixParts.push("");
}
break;
}
default: default:
break; break;
} }
@ -1245,6 +1255,14 @@ class NumberingRuleService {
return ""; return "";
} }
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
return "REF";
}
default: default:
logger.warn("알 수 없는 파트 타입", { partType: part.partType }); logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return ""; return "";
@ -1375,6 +1393,13 @@ class NumberingRuleService {
return catMapping2?.format || "CATEGORY"; return catMapping2?.format || "CATEGORY";
} }
case "reference": {
const refCol2 = autoConfig.referenceColumnName;
if (refCol2 && formData && formData[refCol2]) {
return String(formData[refCol2]);
}
return "REF";
}
default: default:
return ""; return "";
} }
@ -1524,6 +1549,15 @@ class NumberingRuleService {
return ""; return "";
} }
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
return "";
}
default: default:
return ""; return "";
} }

View File

@ -2691,6 +2691,32 @@ export class TableManagementService {
logger.info(`created_date 자동 추가: ${data.created_date}`); logger.info(`created_date 자동 추가: ${data.created_date}`);
} }
// 채번 자동 적용: input_type = 'numbering'인 컬럼에 값이 비어있으면 자동 채번
try {
const companyCode = data.company_code || "*";
const numberingColsResult = await query<any>(
`SELECT DISTINCT column_name FROM table_type_columns
WHERE table_name = $1 AND input_type = 'numbering'
AND company_code IN ($2, '*')`,
[tableName, companyCode]
);
for (const row of numberingColsResult) {
const col = row.column_name;
if (!data[col] || data[col] === "" || data[col] === "자동 생성됩니다") {
const { numberingRuleService } = await import("./numberingRuleService");
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, col);
if (rule) {
const generatedCode = await numberingRuleService.allocateCode(rule.ruleId, companyCode, data);
data[col] = generatedCode;
logger.info(`채번 자동 적용: ${tableName}.${col} = ${generatedCode}`);
}
}
}
} catch (numErr: any) {
logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`);
}
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시) // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
const skippedColumns: string[] = []; const skippedColumns: string[] = [];
const existingColumns = Object.keys(data).filter((col) => { const existingColumns = Object.keys(data).filter((col) => {

View File

@ -18,6 +18,7 @@ interface AutoConfigPanelProps {
config?: any; config?: any;
onChange: (config: any) => void; onChange: (config: any) => void;
isPreview?: boolean; isPreview?: boolean;
tableName?: string;
} }
interface TableInfo { interface TableInfo {
@ -37,6 +38,7 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
config = {}, config = {},
onChange, onChange,
isPreview = false, isPreview = false,
tableName,
}) => { }) => {
// 1. 순번 (자동 증가) // 1. 순번 (자동 증가)
if (partType === "sequence") { if (partType === "sequence") {
@ -161,6 +163,18 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
); );
} }
// 6. 참조 (마스터-디테일 분번)
if (partType === "reference") {
return (
<ReferenceConfigSection
config={config}
onChange={onChange}
isPreview={isPreview}
tableName={tableName}
/>
);
}
return null; return null;
}; };
@ -1088,3 +1102,94 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
</div> </div>
); );
}; };
function ReferenceConfigSection({
config,
onChange,
isPreview,
tableName,
}: {
config: any;
onChange: (c: any) => void;
isPreview: boolean;
tableName?: string;
}) {
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loadingCols, setLoadingCols] = useState(false);
useEffect(() => {
if (!tableName) return;
setLoadingCols(true);
const loadEntityColumns = async () => {
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(
`/screen-management/tables/${tableName}/columns`
);
const allCols = response.data?.data || response.data || [];
const entityCols = allCols.filter(
(c: any) =>
(c.inputType || c.input_type) === "entity" ||
(c.inputType || c.input_type) === "numbering"
);
setColumns(
entityCols.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName:
c.columnLabel || c.column_label || c.columnName || c.column_name,
dataType: c.dataType || c.data_type || "",
inputType: c.inputType || c.input_type || "",
}))
);
} catch {
setColumns([]);
} finally {
setLoadingCols(false);
}
};
loadEntityColumns();
}, [tableName]);
return (
<div className="space-y-3">
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
value={config.referenceColumnName || ""}
onValueChange={(value) =>
onChange({ ...config, referenceColumnName: value })
}
disabled={isPreview || loadingCols}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue
placeholder={
loadingCols
? "로딩 중..."
: columns.length === 0
? "엔티티 컬럼 없음"
: "컬럼 선택"
}
/>
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
className="text-xs"
>
{col.displayName} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
/
</p>
</div>
</div>
);
}

View File

@ -16,6 +16,7 @@ interface NumberingRuleCardProps {
onUpdate: (updates: Partial<NumberingRulePart>) => void; onUpdate: (updates: Partial<NumberingRulePart>) => void;
onDelete: () => void; onDelete: () => void;
isPreview?: boolean; isPreview?: boolean;
tableName?: string;
} }
export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
@ -23,6 +24,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
onUpdate, onUpdate,
onDelete, onDelete,
isPreview = false, isPreview = false,
tableName,
}) => { }) => {
return ( return (
<Card className="border-border bg-card flex-1"> <Card className="border-border bg-card flex-1">
@ -57,6 +59,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
date: { dateFormat: "YYYYMMDD" }, date: { dateFormat: "YYYYMMDD" },
text: { textValue: "CODE" }, text: { textValue: "CODE" },
category: { categoryKey: "", categoryMappings: [] }, category: { categoryKey: "", categoryMappings: [] },
reference: { referenceColumnName: "" },
}; };
onUpdate({ onUpdate({
partType: newPartType, partType: newPartType,
@ -105,6 +108,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
config={part.autoConfig} config={part.autoConfig}
onChange={(autoConfig) => onUpdate({ autoConfig })} onChange={(autoConfig) => onUpdate({ autoConfig })}
isPreview={isPreview} isPreview={isPreview}
tableName={tableName}
/> />
) : ( ) : (
<ManualConfigPanel <ManualConfigPanel

View File

@ -460,6 +460,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
onUpdate={(updates) => handleUpdatePart(part.order, updates)} onUpdate={(updates) => handleUpdatePart(part.order, updates)}
onDelete={() => handleDeletePart(part.order)} onDelete={() => handleDeletePart(part.order)}
isPreview={isPreview} isPreview={isPreview}
tableName={selectedColumn?.tableName}
/> />
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */} {/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
{index < currentRule.parts.length - 1 && ( {index < currentRule.parts.length - 1 && (

View File

@ -619,45 +619,40 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
try { try {
// 채번 규칙 ID 캐싱 (한 번만 조회) // 채번 규칙 ID 캐싱 (한 번만 조회)
if (!numberingRuleIdRef.current) { if (!numberingRuleIdRef.current) {
// table_name + column_name 기반으로 채번 규칙 조회
try {
const { apiClient } = await import("@/lib/api/client");
const ruleResponse = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
if (ruleResponse.data?.success && ruleResponse.data?.data?.ruleId) {
numberingRuleIdRef.current = ruleResponse.data.data.ruleId;
if (onFormDataChange && columnName) {
onFormDataChange(`${columnName}_numberingRuleId`, ruleResponse.data.data.ruleId);
}
}
} catch {
// by-column 조회 실패 시 detailSettings fallback
try {
const { getTableColumns } = await import("@/lib/api/tableManagement"); const { getTableColumns } = await import("@/lib/api/tableManagement");
const columnsResponse = await getTableColumns(tableName); const columnsResponse = await getTableColumns(tableName);
if (columnsResponse.success && columnsResponse.data) {
if (!columnsResponse.success || !columnsResponse.data) {
console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse);
return;
}
const columns = columnsResponse.data.columns || columnsResponse.data; const columns = columnsResponse.data.columns || columnsResponse.data;
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName); const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
if (targetColumn?.detailSettings) {
if (!targetColumn) {
console.warn("컬럼 정보를 찾을 수 없습니다:", columnName);
return;
}
// detailSettings에서 numberingRuleId 추출
if (targetColumn.detailSettings) {
try {
// 문자열이면 파싱, 객체면 그대로 사용
const parsed = typeof targetColumn.detailSettings === "string" const parsed = typeof targetColumn.detailSettings === "string"
? JSON.parse(targetColumn.detailSettings) ? JSON.parse(targetColumn.detailSettings)
: targetColumn.detailSettings; : targetColumn.detailSettings;
numberingRuleIdRef.current = parsed.numberingRuleId || null; numberingRuleIdRef.current = parsed.numberingRuleId || null;
// 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해)
if (parsed.numberingRuleId && onFormDataChange && columnName) {
onFormDataChange(`${columnName}_numberingRuleId`, parsed.numberingRuleId);
} }
} catch {
// JSON 파싱 실패
} }
} catch { /* ignore */ }
} }
} }
const numberingRuleId = numberingRuleIdRef.current; const numberingRuleId = numberingRuleIdRef.current;
if (!numberingRuleId) { if (!numberingRuleId) {
console.warn("채번 규칙 ID가 설정되지 않았습니다. 테이블 관리에서 설정하세요.", { tableName, columnName }); console.warn("채번 규칙을 찾을 수 없습니다. 옵션설정 > 채번설정에서 규칙을 생성하세요.", { tableName, columnName });
return; return;
} }

View File

@ -35,14 +35,16 @@ export const StatusCountComponent: React.FC<StatusCountComponentProps> = ({
setLoading(true); setLoading(true);
try { try {
const res = await apiClient.get(`/table-management/data/${tableName}`, { const res = await apiClient.post(`/table-management/tables/${tableName}/data`, {
params: { page: 1,
autoFilter: "true", size: 9999,
[relationColumn]: parentValue, search: relationColumn ? { [relationColumn]: parentValue } : {},
},
}); });
const rows: any[] = res.data?.data || res.data?.rows || res.data || []; const responseData = res.data?.data;
const rows: any[] = Array.isArray(responseData)
? responseData
: (responseData?.data || responseData?.rows || []);
const grouped: Record<string, number> = {}; const grouped: Record<string, number> = {};
for (const row of rows) { for (const row of rows) {
@ -69,7 +71,7 @@ export const StatusCountComponent: React.FC<StatusCountComponentProps> = ({
}; };
const getCount = (item: StatusCountItem) => { const getCount = (item: StatusCountItem) => {
if (item.value === "__TOTAL__") { if (item.value === "__TOTAL__" || item.value === "__ALL__") {
return Object.values(counts).reduce((sum, c) => sum + c, 0); return Object.values(counts).reduce((sum, c) => sum + c, 0);
} }
const values = item.value.split(",").map((v) => v.trim()); const values = item.value.split(",").map((v) => v.trim());

View File

@ -233,6 +233,47 @@ export const StatusCountConfigPanel: React.FC<StatusCountConfigPanelProps> = ({
); );
}; };
// 상태 컬럼의 카테고리 값 로드
const [statusCategoryValues, setStatusCategoryValues] = useState<Array<{ value: string; label: string }>>([]);
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
useEffect(() => {
if (!config.tableName || !config.statusColumn) {
setStatusCategoryValues([]);
return;
}
const loadCategoryValues = async () => {
setLoadingCategoryValues(true);
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(
`/table-categories/${config.tableName}/${config.statusColumn}/values`
);
if (response.data?.success && response.data?.data) {
const flatValues: Array<{ value: string; label: string }> = [];
const flatten = (items: any[]) => {
for (const item of items) {
flatValues.push({
value: item.valueCode || item.value_code,
label: item.valueLabel || item.value_label,
});
if (item.children?.length > 0) flatten(item.children);
}
};
flatten(response.data.data);
setStatusCategoryValues(flatValues);
}
} catch {
setStatusCategoryValues([]);
} finally {
setLoadingCategoryValues(false);
}
};
loadCategoryValues();
}, [config.tableName, config.statusColumn]);
const tableComboItems = tables.map((t) => ({ const tableComboItems = tables.map((t) => ({
value: t.tableName, value: t.tableName,
label: t.displayName, label: t.displayName,
@ -370,15 +411,52 @@ export const StatusCountConfigPanel: React.FC<StatusCountConfigPanelProps> = ({
</Button> </Button>
</div> </div>
{loadingCategoryValues && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> ...
</div>
)}
{items.map((item: StatusCountItem, i: number) => ( {items.map((item: StatusCountItem, i: number) => (
<div key={i} className="space-y-1 rounded-md border p-2"> <div key={i} className="space-y-1 rounded-md border p-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{statusCategoryValues.length > 0 ? (
<Select
value={item.value || ""}
onValueChange={(v) => {
handleItemChange(i, "value", v);
if (v === "__ALL__" && !item.label) {
handleItemChange(i, "label", "전체");
} else {
const catVal = statusCategoryValues.find((cv) => cv.value === v);
if (catVal && !item.label) {
handleItemChange(i, "label", catVal.label);
}
}
}}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="카테고리 값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__ALL__" className="text-xs font-medium">
</SelectItem>
{statusCategoryValues.map((cv) => (
<SelectItem key={cv.value} value={cv.value} className="text-xs">
{cv.label} ({cv.value})
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input <Input
value={item.value} value={item.value}
onChange={(e) => handleItemChange(i, "value", e.target.value)} onChange={(e) => handleItemChange(i, "value", e.target.value)}
placeholder="상태값 (예: IN_USE)" placeholder="상태값 (예: IN_USE)"
className="h-7 text-xs" className="h-7 text-xs"
/> />
)}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -418,6 +496,12 @@ export const StatusCountConfigPanel: React.FC<StatusCountConfigPanelProps> = ({
</div> </div>
</div> </div>
))} ))}
{!loadingCategoryValues && statusCategoryValues.length === 0 && config.tableName && config.statusColumn && (
<p className="text-[10px] text-amber-600">
. &gt; .
</p>
)}
</div> </div>
</div> </div>
); );

View File

@ -11,7 +11,8 @@ export type CodePartType =
| "number" // 숫자 (고정 자릿수) | "number" // 숫자 (고정 자릿수)
| "date" // 날짜 (다양한 날짜 형식) | "date" // 날짜 (다양한 날짜 형식)
| "text" // 문자 (텍스트) | "text" // 문자 (텍스트)
| "category"; // 카테고리 (카테고리 값에 따른 형식) | "category" // 카테고리 (카테고리 값에 따른 형식)
| "reference"; // 참조 (다른 컬럼의 값을 가져옴, 마스터-디테일 분번용)
/** /**
* *
@ -77,6 +78,9 @@ export interface NumberingRulePart {
// 카테고리용 // 카테고리용
categoryKey?: string; // 카테고리 키 (테이블.컬럼 형식, 예: "item_info.type") categoryKey?: string; // 카테고리 키 (테이블.컬럼 형식, 예: "item_info.type")
categoryMappings?: CategoryFormatMapping[]; // 카테고리 값별 형식 매핑 categoryMappings?: CategoryFormatMapping[]; // 카테고리 값별 형식 매핑
// 참조용 (마스터-디테일 분번)
referenceColumnName?: string; // 참조할 컬럼명 (FK 컬럼 등, 해당 컬럼의 값을 코드에 포함)
}; };
// 직접 입력 설정 // 직접 입력 설정
@ -132,6 +136,7 @@ export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string;
{ value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" }, { value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" },
{ value: "text", label: "문자", description: "텍스트 또는 코드" }, { value: "text", label: "문자", description: "텍스트 또는 코드" },
{ value: "category", label: "카테고리", description: "카테고리 값에 따른 형식" }, { value: "category", label: "카테고리", description: "카테고리 값에 따른 형식" },
{ value: "reference", label: "참조", description: "다른 컬럼 값 참조 (마스터 키 분번)" },
]; ];
export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [ export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [