Compare commits
4 Commits
main
...
2cc398a7f4
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cc398a7f4 | |||
| 07ad735208 | |||
| 269e1986a6 | |||
| 2a80c98fc3 |
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
# DATABASE
|
# DATABASE
|
||||||
POSTGRES_HOST=192.168.11.46
|
POSTGRES_HOST=192.168.11.46
|
||||||
# POSTGRES_HOST=localhost
|
|
||||||
POSTGRES_USER=genome
|
POSTGRES_USER=genome
|
||||||
POSTGRES_PASSWORD=genome1@3
|
POSTGRES_PASSWORD=genome1@3
|
||||||
POSTGRES_DB=genome_db
|
POSTGRES_DB=genome_db
|
||||||
|
|||||||
95
backend/check-data.js
Normal file
95
backend/check-data.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
const { Client } = require('pg');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const conn = new Client({
|
||||||
|
host: 'localhost',
|
||||||
|
port: 5432,
|
||||||
|
user: 'postgres',
|
||||||
|
password: 'turbo123',
|
||||||
|
database: 'genome_db'
|
||||||
|
});
|
||||||
|
await conn.connect();
|
||||||
|
|
||||||
|
// 1. 해당 개체의 형질 데이터 확인
|
||||||
|
const cowTraitsResult = await conn.query(
|
||||||
|
"SELECT trait_name, trait_ebv FROM tb_genome_trait_detail WHERE cow_id = 'KOR002191643715' AND del_dt IS NULL ORDER BY trait_name"
|
||||||
|
);
|
||||||
|
const cowTraits = cowTraitsResult.rows;
|
||||||
|
console.log('=== 개체 KOR002191643715 형질 데이터 ===');
|
||||||
|
console.log('형질수:', cowTraits.length);
|
||||||
|
let totalEbv = 0;
|
||||||
|
cowTraits.forEach(t => {
|
||||||
|
console.log(t.trait_name + ': ' + t.trait_ebv);
|
||||||
|
totalEbv += Number(t.trait_ebv || 0);
|
||||||
|
});
|
||||||
|
console.log('\n*** 내 개체 EBV 합계(선발지수):', totalEbv.toFixed(2));
|
||||||
|
|
||||||
|
// 2. 해당 개체의 농가 확인
|
||||||
|
const cowInfoResult = await conn.query(
|
||||||
|
"SELECT gr.fk_farm_no, f.farmer_name FROM tb_genome_request gr JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no LEFT JOIN tb_farm f ON gr.fk_farm_no = f.pk_farm_no WHERE c.cow_id = 'KOR002191643715' AND gr.del_dt IS NULL LIMIT 1"
|
||||||
|
);
|
||||||
|
const cowInfo = cowInfoResult.rows;
|
||||||
|
console.log('\n=== 농가 정보 ===');
|
||||||
|
console.log('농가번호:', cowInfo[0]?.fk_farm_no, '농장주:', cowInfo[0]?.farmer_name);
|
||||||
|
|
||||||
|
const farmNo = cowInfo[0]?.fk_farm_no;
|
||||||
|
|
||||||
|
// 3. 같은 농가의 모든 개체 EBV 합계
|
||||||
|
if (farmNo) {
|
||||||
|
const farmCowsResult = await conn.query(
|
||||||
|
`SELECT c.cow_id, SUM(gtd.trait_ebv) as total_ebv, COUNT(*) as trait_count
|
||||||
|
FROM tb_genome_request gr
|
||||||
|
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
|
||||||
|
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
|
||||||
|
WHERE gr.fk_farm_no = $1 AND gr.del_dt IS NULL AND c.del_dt IS NULL
|
||||||
|
AND gr.chip_sire_name = '일치'
|
||||||
|
GROUP BY c.cow_id
|
||||||
|
HAVING COUNT(*) = 35
|
||||||
|
ORDER BY total_ebv DESC`,
|
||||||
|
[farmNo]
|
||||||
|
);
|
||||||
|
const farmCows = farmCowsResult.rows;
|
||||||
|
console.log('\n=== 같은 농가 개체들 EBV 합계 (35형질 전체) ===');
|
||||||
|
console.log('개체수:', farmCows.length);
|
||||||
|
let farmSum = 0;
|
||||||
|
farmCows.forEach(c => {
|
||||||
|
console.log(c.cow_id + ': ' + Number(c.total_ebv).toFixed(2));
|
||||||
|
farmSum += Number(c.total_ebv);
|
||||||
|
});
|
||||||
|
if (farmCows.length > 0) {
|
||||||
|
console.log('\n*** 농가 평균:', (farmSum / farmCows.length).toFixed(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 전체 보은군 평균
|
||||||
|
const allCowsResult = await conn.query(
|
||||||
|
`SELECT c.cow_id, SUM(gtd.trait_ebv) as total_ebv
|
||||||
|
FROM tb_genome_request gr
|
||||||
|
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
|
||||||
|
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
|
||||||
|
WHERE gr.del_dt IS NULL AND c.del_dt IS NULL
|
||||||
|
AND gr.chip_sire_name = '일치'
|
||||||
|
GROUP BY c.cow_id
|
||||||
|
HAVING COUNT(*) = 35`
|
||||||
|
);
|
||||||
|
const allCows = allCowsResult.rows;
|
||||||
|
console.log('\n=== 보은군 전체 통계 ===');
|
||||||
|
console.log('개체수:', allCows.length);
|
||||||
|
let regionSum = 0;
|
||||||
|
allCows.forEach(c => regionSum += Number(c.total_ebv));
|
||||||
|
if (allCows.length > 0) {
|
||||||
|
console.log('*** 보은군 평균:', (regionSum / allCows.length).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 최대/최소 확인
|
||||||
|
if (allCows.length > 0) {
|
||||||
|
const maxCow = allCows.reduce((max, c) => Number(c.total_ebv) > Number(max.total_ebv) ? c : max, allCows[0]);
|
||||||
|
const minCow = allCows.reduce((min, c) => Number(c.total_ebv) < Number(min.total_ebv) ? c : min, allCows[0]);
|
||||||
|
console.log('\n최대:', maxCow?.cow_id, Number(maxCow?.total_ebv).toFixed(2));
|
||||||
|
console.log('최소:', minCow?.cow_id, Number(minCow?.total_ebv).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
97
backend/check-data2.js
Normal file
97
backend/check-data2.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
const { Client } = require('pg');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const conn = new Client({
|
||||||
|
host: 'localhost',
|
||||||
|
port: 5432,
|
||||||
|
user: 'postgres',
|
||||||
|
password: 'turbo123',
|
||||||
|
database: 'genome_db'
|
||||||
|
});
|
||||||
|
await conn.connect();
|
||||||
|
|
||||||
|
// getComparisonAverages가 계산하는 방식 확인
|
||||||
|
// 농가 1번의 카테고리별 평균 EBV 계산
|
||||||
|
const farmNo = 1;
|
||||||
|
|
||||||
|
// 카테고리 매핑 (백엔드와 동일)
|
||||||
|
const TRAIT_CATEGORY_MAP = {
|
||||||
|
'12개월령체중': '성장',
|
||||||
|
'도체중': '생산', '등심단면적': '생산', '등지방두께': '생산', '근내지방도': '생산',
|
||||||
|
'체고': '체형', '십자': '체형', '체장': '체형', '흉심': '체형', '흉폭': '체형',
|
||||||
|
'고장': '체형', '요각폭': '체형', '곤폭': '체형', '좌골폭': '체형', '흉위': '체형',
|
||||||
|
'안심weight': '무게', '등심weight': '무게', '채끝weight': '무게', '목심weight': '무게',
|
||||||
|
'앞다리weight': '무게', '우둔weight': '무게', '설도weight': '무게', '사태weight': '무게',
|
||||||
|
'양지weight': '무게', '갈비weight': '무게',
|
||||||
|
'안심rate': '비율', '등심rate': '비율', '채끝rate': '비율', '목심rate': '비율',
|
||||||
|
'앞다리rate': '비율', '우둔rate': '비율', '설도rate': '비율', '사태rate': '비율',
|
||||||
|
'양지rate': '비율', '갈비rate': '비율',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 농가 1번의 모든 형질 데이터 조회
|
||||||
|
const result = await conn.query(`
|
||||||
|
SELECT gtd.trait_name, gtd.trait_ebv
|
||||||
|
FROM tb_genome_trait_detail gtd
|
||||||
|
JOIN tb_genome_request gr ON gtd.fk_request_no = gr.pk_request_no
|
||||||
|
WHERE gr.fk_farm_no = $1
|
||||||
|
AND gtd.del_dt IS NULL
|
||||||
|
AND gtd.trait_ebv IS NOT NULL
|
||||||
|
`, [farmNo]);
|
||||||
|
|
||||||
|
const details = result.rows;
|
||||||
|
console.log('농가 1번 전체 형질 데이터 수:', details.length);
|
||||||
|
|
||||||
|
// 카테고리별로 합계 계산
|
||||||
|
const categoryMap = {};
|
||||||
|
for (const d of details) {
|
||||||
|
const category = TRAIT_CATEGORY_MAP[d.trait_name] || '기타';
|
||||||
|
if (!categoryMap[category]) {
|
||||||
|
categoryMap[category] = { sum: 0, count: 0 };
|
||||||
|
}
|
||||||
|
categoryMap[category].sum += Number(d.trait_ebv);
|
||||||
|
categoryMap[category].count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== getComparisonAverages 방식 (카테고리별 평균) ===');
|
||||||
|
const categories = ['성장', '생산', '체형', '무게', '비율'];
|
||||||
|
let totalAvgEbv = 0;
|
||||||
|
for (const cat of categories) {
|
||||||
|
const data = categoryMap[cat];
|
||||||
|
const avgEbv = data ? data.sum / data.count : 0;
|
||||||
|
console.log(`${cat}: 합계=${data?.sum?.toFixed(2)} / 개수=${data?.count} = 평균 ${avgEbv.toFixed(2)}`);
|
||||||
|
totalAvgEbv += avgEbv;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n*** farmAvgZ (카테고리 평균의 합/5):', (totalAvgEbv / categories.length).toFixed(2));
|
||||||
|
|
||||||
|
// getSelectionIndex 방식 비교
|
||||||
|
console.log('\n=== getSelectionIndex 방식 (개체별 합계의 평균) ===');
|
||||||
|
const farmCowsResult = await conn.query(`
|
||||||
|
SELECT c.cow_id, SUM(gtd.trait_ebv) as total_ebv, COUNT(*) as trait_count
|
||||||
|
FROM tb_genome_request gr
|
||||||
|
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
|
||||||
|
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
|
||||||
|
WHERE gr.fk_farm_no = $1 AND gr.del_dt IS NULL AND c.del_dt IS NULL
|
||||||
|
AND gr.chip_sire_name = '일치'
|
||||||
|
GROUP BY c.cow_id
|
||||||
|
HAVING COUNT(*) = 35
|
||||||
|
ORDER BY total_ebv DESC
|
||||||
|
`, [farmNo]);
|
||||||
|
const farmCows = farmCowsResult.rows;
|
||||||
|
|
||||||
|
let farmSum = 0;
|
||||||
|
farmCows.forEach(c => farmSum += Number(c.total_ebv));
|
||||||
|
const farmAvgScore = farmCows.length > 0 ? farmSum / farmCows.length : 0;
|
||||||
|
|
||||||
|
console.log(`개체수: ${farmCows.length}`);
|
||||||
|
console.log(`*** farmAvgScore (개체별 합계의 평균): ${farmAvgScore.toFixed(2)}`);
|
||||||
|
|
||||||
|
console.log('\n=================================================');
|
||||||
|
console.log('farmAvgZ (카테고리 방식):', (totalAvgEbv / categories.length).toFixed(2));
|
||||||
|
console.log('farmAvgScore (선발지수 방식):', farmAvgScore.toFixed(2));
|
||||||
|
console.log('=================================================');
|
||||||
|
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
114
backend/check-data3.js
Normal file
114
backend/check-data3.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
const { Client } = require('pg');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const conn = new Client({
|
||||||
|
host: 'localhost',
|
||||||
|
port: 5432,
|
||||||
|
user: 'postgres',
|
||||||
|
password: 'turbo123',
|
||||||
|
database: 'genome_db'
|
||||||
|
});
|
||||||
|
await conn.connect();
|
||||||
|
|
||||||
|
const farmNo = 1;
|
||||||
|
const cowId = 'KOR002191643715';
|
||||||
|
|
||||||
|
console.log('=======================================================');
|
||||||
|
console.log('대시보드 vs 개체상세 차트 비교');
|
||||||
|
console.log('=======================================================\n');
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 1. 대시보드: getFarmRegionRanking API
|
||||||
|
// - 농가 평균 = 농가 내 개체들의 선발지수 평균
|
||||||
|
// - 보은군 평균 = 전체 개체들의 선발지수 평균
|
||||||
|
// =====================================================
|
||||||
|
console.log('=== 1. 대시보드 (getFarmRegionRanking) ===');
|
||||||
|
console.log('보은군 내 농가 위치 차트\n');
|
||||||
|
|
||||||
|
// 모든 개체별 선발지수 (35개 형질 EBV 합계)
|
||||||
|
const allCowsResult = await conn.query(`
|
||||||
|
SELECT c.cow_id, gr.fk_farm_no, SUM(gtd.trait_ebv) as total_ebv
|
||||||
|
FROM tb_genome_request gr
|
||||||
|
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
|
||||||
|
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
|
||||||
|
WHERE gr.del_dt IS NULL AND c.del_dt IS NULL
|
||||||
|
AND gr.chip_sire_name = '일치'
|
||||||
|
GROUP BY c.cow_id, gr.fk_farm_no
|
||||||
|
HAVING COUNT(*) = 35
|
||||||
|
`);
|
||||||
|
const allCows = allCowsResult.rows;
|
||||||
|
|
||||||
|
// 농가별 평균 계산
|
||||||
|
const farmScoresMap = new Map();
|
||||||
|
for (const cow of allCows) {
|
||||||
|
const fNo = cow.fk_farm_no;
|
||||||
|
if (!farmScoresMap.has(fNo)) {
|
||||||
|
farmScoresMap.set(fNo, []);
|
||||||
|
}
|
||||||
|
farmScoresMap.set(fNo, [...farmScoresMap.get(fNo), Number(cow.total_ebv)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 농가별 평균
|
||||||
|
const farmAverages = [];
|
||||||
|
for (const [fNo, scores] of farmScoresMap.entries()) {
|
||||||
|
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
|
||||||
|
farmAverages.push({ farmNo: fNo, avgScore: avg, cowCount: scores.length });
|
||||||
|
}
|
||||||
|
farmAverages.sort((a, b) => b.avgScore - a.avgScore);
|
||||||
|
|
||||||
|
// 보은군 전체 평균 (개체별 합계의 평균)
|
||||||
|
const regionAvgScore_dashboard = allCows.reduce((sum, c) => sum + Number(c.total_ebv), 0) / allCows.length;
|
||||||
|
|
||||||
|
// 농가 1번 평균
|
||||||
|
const myFarm = farmAverages.find(f => f.farmNo === farmNo);
|
||||||
|
const farmAvgScore_dashboard = myFarm?.avgScore || 0;
|
||||||
|
|
||||||
|
console.log('농가 평균 (개체 선발지수 평균):', farmAvgScore_dashboard.toFixed(2));
|
||||||
|
console.log('보은군 평균 (개체 선발지수 평균):', regionAvgScore_dashboard.toFixed(2));
|
||||||
|
console.log('차이 (농가 - 보은군):', (farmAvgScore_dashboard - regionAvgScore_dashboard).toFixed(2));
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 2. 개체 상세: getSelectionIndex API
|
||||||
|
// - 내 개체 = 개체의 선발지수 (35개 형질 EBV 합계)
|
||||||
|
// - 농가 평균 = 같은 농가 개체들의 선발지수 평균
|
||||||
|
// - 보은군 평균 = 전체 개체들의 선발지수 평균
|
||||||
|
// =====================================================
|
||||||
|
console.log('\n=== 2. 개체 상세 (getSelectionIndex) ===');
|
||||||
|
console.log('농가 및 보은군 내 개체 위치 차트\n');
|
||||||
|
|
||||||
|
// 내 개체 선발지수
|
||||||
|
const myCow = allCows.find(c => c.cow_id === cowId);
|
||||||
|
const myScore = myCow ? Number(myCow.total_ebv) : 0;
|
||||||
|
|
||||||
|
// 같은 농가 개체들의 평균
|
||||||
|
const farmCows = allCows.filter(c => c.fk_farm_no === farmNo);
|
||||||
|
const farmAvgScore_detail = farmCows.reduce((sum, c) => sum + Number(c.total_ebv), 0) / farmCows.length;
|
||||||
|
|
||||||
|
// 보은군 전체 평균
|
||||||
|
const regionAvgScore_detail = regionAvgScore_dashboard; // 동일
|
||||||
|
|
||||||
|
console.log('내 개체 선발지수:', myScore.toFixed(2));
|
||||||
|
console.log('농가 평균:', farmAvgScore_detail.toFixed(2));
|
||||||
|
console.log('보은군 평균:', regionAvgScore_detail.toFixed(2));
|
||||||
|
console.log('');
|
||||||
|
console.log('내 개체 vs 농가평균:', (myScore - farmAvgScore_detail).toFixed(2));
|
||||||
|
console.log('내 개체 vs 보은군평균:', (myScore - regionAvgScore_detail).toFixed(2));
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 3. 비교 요약
|
||||||
|
// =====================================================
|
||||||
|
console.log('\n=======================================================');
|
||||||
|
console.log('비교 요약');
|
||||||
|
console.log('=======================================================');
|
||||||
|
console.log('');
|
||||||
|
console.log('[대시보드] 농가 vs 보은군 차이:', (farmAvgScore_dashboard - regionAvgScore_dashboard).toFixed(2));
|
||||||
|
console.log('[개체상세] 개체 vs 농가 차이:', (myScore - farmAvgScore_detail).toFixed(2));
|
||||||
|
console.log('[개체상세] 개체 vs 보은군 차이:', (myScore - regionAvgScore_detail).toFixed(2));
|
||||||
|
console.log('');
|
||||||
|
console.log('=> 대시보드는 농가평균 vs 보은군평균 비교 (차이 작음)');
|
||||||
|
console.log('=> 개체상세는 개별개체 vs 평균 비교 (개체가 우수하면 차이 큼)');
|
||||||
|
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
126
backend/check-data4.js
Normal file
126
backend/check-data4.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
const { Client } = require('pg');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const conn = new Client({
|
||||||
|
host: 'localhost',
|
||||||
|
port: 5432,
|
||||||
|
user: 'postgres',
|
||||||
|
password: 'turbo123',
|
||||||
|
database: 'genome_db'
|
||||||
|
});
|
||||||
|
await conn.connect();
|
||||||
|
|
||||||
|
const cowId = 'KOR002191643715';
|
||||||
|
const farmNo = 1;
|
||||||
|
|
||||||
|
console.log('=======================================================');
|
||||||
|
console.log('선발지수 계산 방식 비교 분석');
|
||||||
|
console.log('=======================================================\n');
|
||||||
|
|
||||||
|
// 1. 해당 개체의 35개 형질 EBV 확인
|
||||||
|
const traitsResult = await conn.query(`
|
||||||
|
SELECT trait_name, trait_ebv
|
||||||
|
FROM tb_genome_trait_detail
|
||||||
|
WHERE cow_id = $1 AND del_dt IS NULL
|
||||||
|
ORDER BY trait_name
|
||||||
|
`, [cowId]);
|
||||||
|
const traits = traitsResult.rows;
|
||||||
|
|
||||||
|
console.log('=== 개체 형질 데이터 ===');
|
||||||
|
console.log('형질 수:', traits.length);
|
||||||
|
|
||||||
|
// EBV 합계
|
||||||
|
const ebvSum = traits.reduce((sum, t) => sum + Number(t.trait_ebv || 0), 0);
|
||||||
|
console.log('EBV 합계:', ebvSum.toFixed(2));
|
||||||
|
|
||||||
|
// EBV 평균
|
||||||
|
const ebvAvg = ebvSum / traits.length;
|
||||||
|
console.log('EBV 평균:', ebvAvg.toFixed(2));
|
||||||
|
|
||||||
|
console.log('\n=== 선발지수 계산 방식 비교 ===\n');
|
||||||
|
|
||||||
|
// 방식 1: EBV 합계 (getSelectionIndex 방식)
|
||||||
|
console.log('방식1 - EBV 합계 (weightedSum):', ebvSum.toFixed(2));
|
||||||
|
|
||||||
|
// 방식 2: EBV 평균
|
||||||
|
console.log('방식2 - EBV 평균 (sum/count):', ebvAvg.toFixed(2));
|
||||||
|
|
||||||
|
// 농가/보은군 평균도 각 방식으로 계산
|
||||||
|
console.log('\n=== 농가 평균 계산 방식 비교 ===\n');
|
||||||
|
|
||||||
|
// 농가 내 모든 개체
|
||||||
|
const farmCowsResult = await conn.query(`
|
||||||
|
SELECT c.cow_id, SUM(gtd.trait_ebv) as sum_ebv, AVG(gtd.trait_ebv) as avg_ebv, COUNT(*) as cnt
|
||||||
|
FROM tb_genome_request gr
|
||||||
|
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
|
||||||
|
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
|
||||||
|
WHERE gr.fk_farm_no = $1 AND gr.del_dt IS NULL AND c.del_dt IS NULL
|
||||||
|
AND gr.chip_sire_name = '일치'
|
||||||
|
GROUP BY c.cow_id
|
||||||
|
HAVING COUNT(*) = 35
|
||||||
|
`, [farmNo]);
|
||||||
|
const farmCows = farmCowsResult.rows;
|
||||||
|
|
||||||
|
// 방식 1: 개체별 합계의 평균
|
||||||
|
const farmSumAvg = farmCows.reduce((sum, c) => sum + Number(c.sum_ebv), 0) / farmCows.length;
|
||||||
|
console.log('방식1 - 개체별 합계의 평균:', farmSumAvg.toFixed(2));
|
||||||
|
|
||||||
|
// 방식 2: 개체별 평균의 평균
|
||||||
|
const farmAvgAvg = farmCows.reduce((sum, c) => sum + Number(c.avg_ebv), 0) / farmCows.length;
|
||||||
|
console.log('방식2 - 개체별 평균의 평균:', farmAvgAvg.toFixed(2));
|
||||||
|
|
||||||
|
console.log('\n=== 보은군 평균 계산 방식 비교 ===\n');
|
||||||
|
|
||||||
|
// 보은군 전체 개체
|
||||||
|
const allCowsResult = await conn.query(`
|
||||||
|
SELECT c.cow_id, SUM(gtd.trait_ebv) as sum_ebv, AVG(gtd.trait_ebv) as avg_ebv, COUNT(*) as cnt
|
||||||
|
FROM tb_genome_request gr
|
||||||
|
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
|
||||||
|
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
|
||||||
|
WHERE gr.del_dt IS NULL AND c.del_dt IS NULL
|
||||||
|
AND gr.chip_sire_name = '일치'
|
||||||
|
GROUP BY c.cow_id
|
||||||
|
HAVING COUNT(*) = 35
|
||||||
|
`);
|
||||||
|
const allCows = allCowsResult.rows;
|
||||||
|
|
||||||
|
// 방식 1: 개체별 합계의 평균
|
||||||
|
const regionSumAvg = allCows.reduce((sum, c) => sum + Number(c.sum_ebv), 0) / allCows.length;
|
||||||
|
console.log('방식1 - 개체별 합계의 평균:', regionSumAvg.toFixed(2));
|
||||||
|
|
||||||
|
// 방식 2: 개체별 평균의 평균
|
||||||
|
const regionAvgAvg = allCows.reduce((sum, c) => sum + Number(c.avg_ebv), 0) / allCows.length;
|
||||||
|
console.log('방식2 - 개체별 평균의 평균:', regionAvgAvg.toFixed(2));
|
||||||
|
|
||||||
|
console.log('\n=======================================================');
|
||||||
|
console.log('결과 비교');
|
||||||
|
console.log('=======================================================\n');
|
||||||
|
|
||||||
|
console.log('만약 "합계" 방식 사용 시:');
|
||||||
|
console.log(' 내 개체:', ebvSum.toFixed(2));
|
||||||
|
console.log(' 농가 평균:', farmSumAvg.toFixed(2));
|
||||||
|
console.log(' 보은군 평균:', regionSumAvg.toFixed(2));
|
||||||
|
console.log(' → 내 개체 vs 농가: +', (ebvSum - farmSumAvg).toFixed(2));
|
||||||
|
console.log(' → 내 개체 vs 보은군: +', (ebvSum - regionSumAvg).toFixed(2));
|
||||||
|
|
||||||
|
console.log('\n만약 "평균" 방식 사용 시:');
|
||||||
|
console.log(' 내 개체:', ebvAvg.toFixed(2));
|
||||||
|
console.log(' 농가 평균:', farmAvgAvg.toFixed(2));
|
||||||
|
console.log(' 보은군 평균:', regionAvgAvg.toFixed(2));
|
||||||
|
console.log(' → 내 개체 vs 농가: +', (ebvAvg - farmAvgAvg).toFixed(2));
|
||||||
|
console.log(' → 내 개체 vs 보은군: +', (ebvAvg - regionAvgAvg).toFixed(2));
|
||||||
|
|
||||||
|
console.log('\n=======================================================');
|
||||||
|
console.log('리스트 선발지수 확인 (page.tsx의 GENOMIC_TRAITS)');
|
||||||
|
console.log('=======================================================\n');
|
||||||
|
|
||||||
|
// 리스트에서 보여주는 선발지수는 어떻게 계산되나?
|
||||||
|
// page.tsx:350-356 확인 필요
|
||||||
|
console.log('리스트의 overallScore 계산식 확인 필요:');
|
||||||
|
console.log(' - selectionIndex.score 사용 시: 합계 방식');
|
||||||
|
console.log(' - GENOMIC_TRAITS.reduce / length 사용 시: 평균 방식');
|
||||||
|
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
47
backend/check-data5.js
Normal file
47
backend/check-data5.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const { Client } = require('pg');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const conn = new Client({
|
||||||
|
host: 'localhost',
|
||||||
|
port: 5432,
|
||||||
|
user: 'postgres',
|
||||||
|
password: 'turbo123',
|
||||||
|
database: 'genome_db'
|
||||||
|
});
|
||||||
|
await conn.connect();
|
||||||
|
|
||||||
|
const farmNo = 1;
|
||||||
|
|
||||||
|
// 농가 내 모든 개체의 선발지수(가중 합계) 조회
|
||||||
|
const farmCowsResult = await conn.query(`
|
||||||
|
SELECT c.cow_id, SUM(gtd.trait_ebv) as sum_ebv
|
||||||
|
FROM tb_genome_request gr
|
||||||
|
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
|
||||||
|
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
|
||||||
|
WHERE gr.fk_farm_no = $1 AND gr.del_dt IS NULL AND c.del_dt IS NULL
|
||||||
|
AND gr.chip_sire_name = '일치'
|
||||||
|
GROUP BY c.cow_id
|
||||||
|
HAVING COUNT(*) = 35
|
||||||
|
ORDER BY sum_ebv DESC
|
||||||
|
`, [farmNo]);
|
||||||
|
|
||||||
|
const farmCows = farmCowsResult.rows;
|
||||||
|
|
||||||
|
console.log('=== 농가 1번 개체별 선발지수 (가중 합계) ===\n');
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
farmCows.forEach((c, i) => {
|
||||||
|
const score = Number(c.sum_ebv);
|
||||||
|
total += score;
|
||||||
|
console.log(`${i+1}. ${c.cow_id}: ${score.toFixed(2)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n=== 계산 ===');
|
||||||
|
console.log('개체 수:', farmCows.length);
|
||||||
|
console.log('선발지수 총합:', total.toFixed(2));
|
||||||
|
console.log('농가 평균 = 총합 / 개체수 =', total.toFixed(2), '/', farmCows.length, '=', (total / farmCows.length).toFixed(2));
|
||||||
|
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
66
backend/check-data6.js
Normal file
66
backend/check-data6.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const { Client } = require('pg');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const conn = new Client({
|
||||||
|
host: 'localhost',
|
||||||
|
port: 5432,
|
||||||
|
user: 'postgres',
|
||||||
|
password: 'turbo123',
|
||||||
|
database: 'genome_db'
|
||||||
|
});
|
||||||
|
await conn.connect();
|
||||||
|
|
||||||
|
const cowId = 'KOR002191643715';
|
||||||
|
|
||||||
|
console.log('=======================================================');
|
||||||
|
console.log('리스트 vs 개체상세 선발지수 비교');
|
||||||
|
console.log('=======================================================\n');
|
||||||
|
|
||||||
|
// 해당 개체의 35개 형질 EBV 조회
|
||||||
|
const traitsResult = await conn.query(`
|
||||||
|
SELECT trait_name, trait_ebv
|
||||||
|
FROM tb_genome_trait_detail
|
||||||
|
WHERE cow_id = $1 AND del_dt IS NULL
|
||||||
|
ORDER BY trait_name
|
||||||
|
`, [cowId]);
|
||||||
|
const traits = traitsResult.rows;
|
||||||
|
|
||||||
|
console.log('형질 수:', traits.length);
|
||||||
|
|
||||||
|
// 1. 가중 합계 (weight = 1)
|
||||||
|
let weightedSum = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
traits.forEach(t => {
|
||||||
|
const ebv = Number(t.trait_ebv);
|
||||||
|
const weight = 1;
|
||||||
|
weightedSum += ebv * weight;
|
||||||
|
totalWeight += weight;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n=== 계산 비교 ===');
|
||||||
|
console.log('가중 합계 (weightedSum):', weightedSum.toFixed(2));
|
||||||
|
console.log('총 가중치 (totalWeight):', totalWeight);
|
||||||
|
console.log('');
|
||||||
|
console.log('리스트 (cow.service.ts) - 가중 합계:', weightedSum.toFixed(2));
|
||||||
|
console.log('개체상세 (genome.service.ts) - 가중 합계:', weightedSum.toFixed(2));
|
||||||
|
|
||||||
|
console.log('\n=== 프론트엔드 가중치 확인 ===');
|
||||||
|
console.log('프론트엔드에서 weight / 100 정규화 확인 필요');
|
||||||
|
console.log('예: weight 100 → 1, weight 50 → 0.5');
|
||||||
|
|
||||||
|
// 만약 프론트에서 weight/100을 적용한다면?
|
||||||
|
console.log('\n=== 만약 weight가 0.01로 적용된다면? ===');
|
||||||
|
let weightedSum2 = 0;
|
||||||
|
let totalWeight2 = 0;
|
||||||
|
traits.forEach(t => {
|
||||||
|
const ebv = Number(t.trait_ebv);
|
||||||
|
const weight = 0.01; // 1/100
|
||||||
|
weightedSum2 += ebv * weight;
|
||||||
|
totalWeight2 += weight;
|
||||||
|
});
|
||||||
|
console.log('가중 합계:', weightedSum2.toFixed(2));
|
||||||
|
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
19860
backend/database/full_data_dump.sql
Normal file
19860
backend/database/full_data_dump.sql
Normal file
File diff suppressed because it is too large
Load Diff
26080
backend/database/full_database_with_schema.sql
Normal file
26080
backend/database/full_database_with_schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
559
backend/database/insert_cow.sql
Normal file
559
backend/database/insert_cow.sql
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
DELETE FROM "tb_genome_cow";
|
||||||
|
/*!40000 ALTER TABLE "tb_genome_cow" DISABLE KEYS */;
|
||||||
|
INSERT INTO "tb_genome_cow" ("reg_dt", "updt_dt", "reg_ip", "reg_user_id", "updt_ip", "updt_user_id", "pk_genome_cow_no", "fk_genome_no", "fk_trait_no", "trait_val", "breed_val", "percentile") VALUES
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 1, 1, 1, 73.20, 2.30, 1),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 2, 1, 2, 76.30, 1.90, 2),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 3, 1, 3, 14.80, 1.90, 2),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 4, 1, 4, -2.20, -1.10, 12),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 5, 1, 5, 0.30, -0.50, 67),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 6, 1, 6, 5.60, 2.00, 2),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 7, 1, 7, 6.70, 2.30, 1),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 8, 1, 8, 9.20, 2.40, 0),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 9, 1, 9, 0.60, 0.30, 39),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 10, 1, 10, 2.60, 2.10, 1),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 11, 1, 11, 2.50, 1.90, 2),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 12, 1, 12, 1.80, 1.40, 8),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 13, 1, 13, 3.80, 2.50, 0),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 14, 1, 14, 2.00, 2.40, 0),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 15, 1, 15, 4.20, 1.00, 16),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 16, 1, 16, 1.50, 2.50, 0),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 17, 1, 17, 6.70, 1.70, 4),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 18, 1, 18, 2.10, 2.40, 0),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 19, 1, 19, 4.70, 2.80, 0),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 20, 1, 20, 6.30, 2.10, 1),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 21, 1, 21, 5.00, 2.10, 1),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 22, 1, 22, 9.60, 2.40, 0),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 23, 1, 23, 3.90, 2.10, 2),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 24, 1, 24, 6.10, 2.10, 1),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 25, 1, 25, 7.20, 1.00, 15),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 26, 1, 26, 0.10, 1.80, 3),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 27, 1, 27, 0.10, -0.10, 54),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 28, 1, 28, 0.20, 1.60, 5),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 29, 1, 29, 0.50, 2.90, 0),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 30, 1, 30, 0.40, 1.70, 4),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 31, 1, 31, 0.30, 1.40, 8),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 32, 1, 32, 0.90, 2.60, 0),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 33, 1, 33, 0.30, 1.80, 3),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 34, 1, 34, 0.20, 1.20, 10),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 35, 1, 35, -0.90, -2.90, 99),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 36, 2, 1, 26.70, 0.10, 46),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 37, 2, 2, 26.00, -0.10, 54),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 38, 2, 3, 4.60, -0.30, 62),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 39, 2, 4, -1.50, -0.60, 27),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 40, 2, 5, 0.50, -0.20, 58),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 41, 2, 6, 1.70, 0.30, 38),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 42, 2, 7, 2.00, 0.40, 34),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 43, 2, 8, 4.50, 0.80, 21),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 44, 2, 9, -0.30, -0.10, 54),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 45, 2, 10, 0.50, 0.20, 42),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 46, 2, 11, 0.50, 0.30, 38),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 47, 2, 12, 0.50, 0.30, 38),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 48, 2, 13, 1.10, 0.60, 27),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 49, 2, 14, 0.90, 0.50, 31),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 50, 2, 15, -0.10, -0.20, 58),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 51, 2, 16, 2.10, -0.20, 58),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 52, 2, 17, 0.50, -0.10, 54),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 53, 2, 18, 1.80, 0.10, 46),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 54, 2, 19, 2.10, 0.10, 46),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 55, 2, 20, 1.70, 0.00, 50),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 56, 2, 21, 2.20, -0.30, 62),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 57, 2, 22, 1.30, 0.10, 46),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 58, 2, 23, 2.30, 0.00, 50),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 59, 2, 24, 3.20, -0.10, 54),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 60, 2, 25, 0.00, 0.00, 50),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 61, 2, 26, 0.00, -0.20, 58),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 62, 2, 27, 0.00, -0.10, 54),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 63, 2, 28, 0.20, 0.10, 46),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 64, 2, 29, 0.20, 0.10, 46),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 65, 2, 30, 0.10, 0.00, 50),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 66, 2, 31, 0.10, 0.00, 50),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 67, 2, 32, 0.10, 0.10, 46),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 68, 2, 33, 0.20, 0.20, 42),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 69, 2, 34, -0.10, -0.10, 54),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 70, 2, 35, 0.20, 0.10, 46),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 71, 3, 1, -18.00, -2.00, 97),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 72, 3, 2, -18.90, -1.90, 97),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 73, 3, 3, -7.20, -2.80, 99),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 74, 3, 4, 0.40, 0.60, 73),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 75, 3, 5, 0.50, -0.10, 54),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 76, 3, 6, -1.60, -1.20, 89),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 77, 3, 7, -1.60, -1.20, 89),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 78, 3, 8, -1.90, -1.50, 93),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 79, 3, 9, -1.40, -1.70, 95),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 80, 3, 10, -1.30, -2.10, 98),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 81, 3, 11, -0.80, -1.60, 94),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 82, 3, 12, -1.10, -2.10, 98),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 83, 3, 13, -0.70, -1.40, 92),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 84, 3, 14, -0.10, -1.00, 83),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 85, 3, 15, -4.70, -2.40, 99),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 86, 3, 16, -0.30, -2.00, 97),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 87, 3, 17, -1.50, -2.00, 97),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 88, 3, 18, -0.60, -2.20, 98),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 89, 3, 19, -0.80, -1.70, 95),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 90, 3, 20, -0.80, -1.50, 93),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 91, 3, 21, -1.50, -1.90, 97),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 92, 3, 22, -2.30, -1.90, 97),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 93, 3, 23, -0.90, -1.80, 96),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 94, 3, 24, -2.50, -2.40, 99),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 95, 3, 25, -1.70, -1.70, 95),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 96, 3, 26, 0.00, -0.80, 78),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 97, 3, 27, -0.10, -0.70, 75),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 98, 3, 28, -0.10, -1.10, 87),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 99, 3, 29, 0.00, -0.50, 70),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 100, 3, 30, 0.10, -0.20, 56),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 101, 3, 31, -0.20, -0.90, 82),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 102, 3, 32, -0.20, -1.30, 89),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 103, 3, 33, -0.10, -1.20, 87),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 104, 3, 34, -0.40, -2.10, 98),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 105, 3, 35, 0.20, 0.90, 19),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 106, 4, 1, 59.70, 1.80, 8),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 107, 4, 2, 16.50, 0.90, 22),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 108, 4, 4, -0.10, -0.30, 64),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 109, 4, 5, 0.40, 0.60, 32),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 110, 4, 6, 0.40, 1.20, 17),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 111, 4, 8, 0.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 112, 4, 15, 0.30, 0.90, 23),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 113, 4, 9, 0.20, 0.60, 32),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 114, 4, 13, 0.20, 0.50, 35),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 115, 4, 14, 0.40, 1.10, 19),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 116, 4, 26, 0.10, 0.40, 44),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 117, 4, 27, 0.20, 0.60, 33),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 118, 4, 28, 0.10, 0.30, 47),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 119, 4, 29, 0.10, 0.20, 52),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 120, 4, 30, 0.00, -0.10, 59),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 121, 4, 31, 0.20, 0.70, 29),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 122, 4, 32, 0.30, 0.80, 25),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 123, 4, 33, 0.10, 0.40, 43),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 124, 4, 34, 0.00, -0.20, 62),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 125, 4, 35, 0.10, 0.30, 48),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 126, 5, 1, 49.20, 1.40, 14),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 127, 5, 2, 9.90, 0.60, 30),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 128, 5, 4, 0.00, -0.10, 55),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 129, 5, 5, 0.20, 0.30, 42),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 130, 5, 6, 0.20, 0.70, 28),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 131, 5, 8, 0.30, 0.80, 25),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 132, 5, 15, 0.20, 0.50, 35),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 133, 5, 9, 0.10, 0.30, 45),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 134, 5, 13, 0.10, 0.20, 48),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 135, 5, 14, 0.20, 0.60, 32),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 136, 5, 26, 0.00, 0.10, 55),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 137, 5, 27, 0.10, 0.30, 47),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 138, 5, 28, 0.00, 0.10, 56),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 139, 5, 29, 0.00, 0.00, 58),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 140, 5, 30, -0.10, -0.20, 64),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 141, 5, 31, 0.10, 0.30, 48),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 142, 5, 32, 0.10, 0.40, 43),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 143, 5, 33, 0.00, 0.10, 54),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 144, 5, 34, -0.10, -0.30, 68),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 145, 5, 35, 0.00, 0.20, 51),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 146, 6, 1, 31.40, 0.90, 32),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 147, 6, 2, 5.70, 0.40, 42),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 148, 6, 4, 0.10, 0.10, 48),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 149, 6, 5, 0.10, 0.10, 52),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 150, 6, 6, 0.10, 0.30, 45),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 151, 6, 8, 0.10, 0.30, 46),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 152, 6, 15, 0.00, 0.10, 54),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 153, 6, 9, 0.00, 0.00, 58),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 154, 6, 13, 0.00, 0.00, 59),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 155, 6, 14, 0.10, 0.20, 49),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 156, 6, 26, 0.00, -0.10, 61),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 157, 6, 27, 0.00, 0.00, 58),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 158, 6, 28, 0.00, -0.10, 62),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 159, 6, 29, 0.00, -0.10, 63),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 160, 6, 30, -0.10, -0.30, 69),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 161, 6, 31, 0.00, 0.10, 57),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 162, 6, 32, 0.00, 0.20, 53),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 163, 6, 33, 0.00, 0.00, 60),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 164, 6, 34, -0.20, -0.50, 74),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 165, 6, 35, 0.00, 0.10, 55),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 166, 7, 1, 29.10, 0.80, 36),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 167, 7, 2, 9.40, 0.50, 32),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 168, 7, 4, 0.00, 0.00, 57),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 169, 7, 5, 0.10, 0.00, 54),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 170, 7, 6, 0.00, 0.10, 52),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 171, 7, 8, 0.00, 0.10, 53),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 172, 7, 15, 0.00, 0.00, 58),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 173, 7, 9, 0.00, -0.10, 62),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 174, 7, 13, 0.00, -0.10, 63),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 175, 7, 14, 0.00, 0.10, 55),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 176, 7, 26, 0.00, -0.20, 65),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 177, 7, 27, 0.00, -0.10, 61),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 178, 7, 28, 0.00, -0.20, 66),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 179, 7, 29, 0.00, -0.20, 67),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 180, 7, 30, -0.10, -0.40, 72),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 181, 7, 31, 0.00, 0.00, 58),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 182, 7, 32, 0.00, 0.10, 56),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 183, 7, 33, 0.00, -0.10, 64),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 184, 7, 34, -0.20, -0.70, 78),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 185, 7, 35, 0.00, 0.00, 59),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 186, 8, 1, 1.10, 0.10, 53),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 187, 8, 2, -2.60, -0.20, 62),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 188, 8, 4, 0.10, 0.20, 45),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 189, 8, 5, 0.00, -0.10, 61),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 190, 8, 6, -0.10, -0.30, 68),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 191, 8, 8, -0.10, -0.30, 69),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 192, 8, 15, -0.10, -0.20, 65),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 193, 8, 9, 0.00, -0.20, 66),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 194, 8, 13, 0.00, -0.20, 68),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 195, 8, 14, -0.10, -0.30, 70),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 196, 8, 26, -0.10, -0.40, 73),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 197, 8, 27, -0.10, -0.50, 76),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 198, 8, 28, -0.10, -0.60, 79),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 199, 8, 29, 0.00, -0.30, 70),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 200, 8, 30, -0.10, -0.50, 77),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 201, 8, 31, -0.10, -0.60, 80),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 202, 8, 32, -0.20, -0.70, 82),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 203, 8, 33, -0.10, -0.60, 81),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 204, 8, 34, -0.30, -1.00, 86),
|
||||||
|
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 205, 8, 35, 0.00, -0.20, 65),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 206, 4, 1, 50.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 207, 9, 1, 50.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 208, 4, 2, 45.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 209, 9, 2, 45.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 210, 4, 3, 10.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 211, 9, 3, 10.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 212, 4, 4, 2.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 213, 9, 4, 2.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 214, 4, 5, 2.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 215, 9, 5, 2.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 216, 4, 6, 3.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 217, 9, 6, 3.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 218, 4, 7, 3.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 219, 9, 7, 3.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 220, 4, 8, 5.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 221, 9, 8, 5.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 222, 4, 9, 1.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 223, 9, 9, 1.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 224, 4, 10, 2.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 225, 9, 10, 2.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 226, 4, 11, 1.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 227, 9, 11, 1.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 228, 4, 12, 1.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 229, 9, 12, 1.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 230, 4, 13, 2.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 231, 9, 13, 2.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 232, 4, 14, 2.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 233, 9, 14, 2.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 234, 4, 15, 2.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 235, 9, 15, 2.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 236, 4, 16, 5.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 237, 9, 16, 5.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 238, 4, 17, 1.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 239, 9, 17, 1.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 240, 4, 18, 2.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 241, 9, 18, 2.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 242, 4, 19, 2.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 243, 9, 19, 2.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 244, 4, 20, 2.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 245, 9, 20, 2.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 246, 4, 21, 4.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 247, 9, 21, 4.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 248, 4, 22, 1.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 249, 9, 22, 1.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 250, 4, 23, 3.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 251, 9, 23, 3.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 252, 4, 24, 5.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 253, 9, 24, 5.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 254, 4, 25, 1.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 255, 9, 25, 1.00, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 256, 4, 26, 0.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 257, 9, 26, 0.50, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 258, 4, 27, 0.30, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 259, 9, 27, 0.30, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 260, 4, 28, 0.30, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 261, 9, 28, 0.30, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 262, 4, 29, 0.20, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 263, 9, 29, 0.20, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 264, 4, 30, 0.20, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 265, 9, 30, 0.20, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 266, 4, 31, 0.30, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 267, 9, 31, 0.30, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 268, 4, 32, 0.20, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 269, 9, 32, 0.20, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 270, 4, 33, 0.30, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 271, 9, 33, 0.30, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 272, 4, 34, 0.30, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 273, 9, 34, 0.30, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 274, 4, 35, 0.10, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 275, 9, 35, 0.10, 1.50, 15),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 276, 5, 1, 35.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 277, 10, 1, 35.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 278, 5, 2, 38.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 279, 10, 2, 38.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 280, 5, 3, 8.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 281, 10, 3, 8.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 282, 5, 4, 0.50, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 283, 10, 4, 0.50, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 284, 5, 5, 1.50, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 285, 10, 5, 1.50, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 286, 5, 6, 1.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 287, 10, 6, 1.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 288, 5, 7, 1.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 289, 10, 7, 1.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 290, 5, 8, 3.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 291, 10, 8, 3.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 292, 5, 9, 0.50, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 293, 10, 9, 0.50, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 294, 5, 10, 1.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 295, 10, 10, 1.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 296, 5, 11, 0.80, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 297, 10, 11, 0.80, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 298, 5, 12, 0.80, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 299, 10, 12, 0.80, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 300, 5, 13, 1.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 301, 10, 13, 1.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 302, 5, 14, 1.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 303, 10, 14, 1.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 304, 5, 15, 1.50, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 305, 10, 15, 1.50, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 306, 5, 16, 4.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 307, 10, 16, 4.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 308, 5, 17, 1.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 309, 10, 17, 1.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 310, 5, 18, 2.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 311, 10, 18, 2.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 312, 5, 19, 2.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 313, 10, 19, 2.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 314, 5, 20, 1.80, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 315, 10, 20, 1.80, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 316, 5, 21, 3.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 317, 10, 21, 3.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 318, 5, 22, 1.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 319, 10, 22, 1.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 320, 5, 23, 2.50, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 321, 10, 23, 2.50, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 322, 5, 24, 3.80, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 323, 10, 24, 3.80, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 324, 5, 25, 0.50, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 325, 10, 25, 0.50, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 326, 5, 26, 0.40, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 327, 10, 26, 0.40, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 328, 5, 27, 0.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 329, 10, 27, 0.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 330, 5, 28, 0.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 331, 10, 28, 0.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 332, 5, 29, 0.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 333, 10, 29, 0.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 334, 5, 30, 0.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 335, 10, 30, 0.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 336, 5, 31, 0.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 337, 10, 31, 0.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 338, 5, 32, 0.10, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 339, 10, 32, 0.10, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 340, 5, 33, 0.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 341, 10, 33, 0.20, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 342, 5, 34, 0.30, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 343, 10, 34, 0.30, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 344, 5, 35, 0.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 345, 10, 35, 0.00, 0.90, 25),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 346, 6, 1, 42.00, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 347, 11, 1, 42.00, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 348, 6, 2, 43.00, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 349, 11, 2, 43.00, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 350, 6, 3, 8.50, 0.50, 38),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 351, 11, 3, 8.50, 0.50, 38),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 352, 6, 4, -0.80, -0.20, 48),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 353, 11, 4, -0.80, -0.20, 48),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 354, 6, 5, 1.80, 0.90, 32),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 355, 11, 5, 1.80, 0.90, 32),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 356, 6, 6, 1.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 357, 11, 6, 1.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 358, 6, 7, 1.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 359, 11, 7, 1.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 360, 6, 8, 3.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 361, 11, 8, 3.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 362, 6, 9, 0.60, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 363, 11, 9, 0.60, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 364, 6, 10, 1.00, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 365, 11, 10, 1.00, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 366, 6, 11, 0.70, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 367, 11, 11, 0.70, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 368, 6, 12, 0.70, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 369, 11, 12, 0.70, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 370, 6, 13, 1.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 371, 11, 13, 1.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 372, 6, 14, 1.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 373, 11, 14, 1.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 374, 6, 15, 1.40, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 375, 11, 15, 1.40, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 376, 6, 16, 3.80, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 377, 11, 16, 3.80, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 378, 6, 17, 1.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 379, 11, 17, 1.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 380, 6, 18, 2.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 381, 11, 18, 2.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 382, 6, 19, 2.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 383, 11, 19, 2.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 384, 6, 20, 1.70, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 385, 11, 20, 1.70, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 386, 6, 21, 3.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 387, 11, 21, 3.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 388, 6, 22, 1.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 389, 11, 22, 1.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 390, 6, 23, 2.60, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 391, 11, 23, 2.60, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 392, 6, 24, 3.90, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 393, 11, 24, 3.90, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 394, 6, 25, 0.60, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 395, 11, 25, 0.60, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 396, 6, 26, 0.30, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 397, 11, 26, 0.30, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 398, 6, 27, 0.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 399, 11, 27, 0.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 400, 6, 28, 0.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 401, 11, 28, 0.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 402, 6, 29, 0.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 403, 11, 29, 0.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 404, 6, 30, 0.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 405, 11, 30, 0.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 406, 6, 31, 0.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 407, 11, 31, 0.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 408, 6, 32, 0.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 409, 11, 32, 0.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 410, 6, 33, 0.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 411, 11, 33, 0.20, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 412, 6, 34, 0.30, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 413, 11, 34, 0.30, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 414, 6, 35, 0.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 415, 11, 35, 0.10, 0.60, 35),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 416, 7, 1, 5.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 417, 12, 1, 5.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 418, 7, 2, 10.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 419, 12, 2, 10.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 420, 7, 3, 2.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 421, 12, 3, 2.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 422, 7, 4, -2.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 423, 12, 4, -2.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 424, 7, 5, -1.20, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 425, 12, 5, -1.20, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 426, 7, 6, -2.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 427, 12, 6, -2.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 428, 7, 7, -2.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 429, 12, 7, -2.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 430, 7, 8, -1.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 431, 12, 8, -1.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 432, 7, 9, -1.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 433, 12, 9, -1.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 434, 7, 10, -1.20, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 435, 12, 10, -1.20, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 436, 7, 11, -1.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 437, 12, 11, -1.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 438, 7, 12, -1.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 439, 12, 12, -1.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 440, 7, 13, -0.80, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 441, 12, 13, -0.80, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 442, 7, 14, -0.80, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 443, 12, 14, -0.80, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 444, 7, 15, -1.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 445, 12, 15, -1.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 446, 7, 16, 1.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 447, 12, 16, 1.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 448, 7, 17, 0.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 449, 12, 17, 0.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 450, 7, 18, 0.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 451, 12, 18, 0.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 452, 7, 19, 0.80, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 453, 12, 19, 0.80, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 454, 7, 20, 0.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 455, 12, 20, 0.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 456, 7, 21, 1.20, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 457, 12, 21, 1.20, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 458, 7, 22, 0.20, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 459, 12, 22, 0.20, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 460, 7, 23, 1.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 461, 12, 23, 1.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 462, 7, 24, 1.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 463, 12, 24, 1.50, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 464, 7, 25, -0.80, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 465, 12, 25, -0.80, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 466, 7, 26, -0.20, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 467, 12, 26, -0.20, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 468, 7, 27, -0.20, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 469, 12, 27, -0.20, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 470, 7, 28, 0.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 471, 12, 28, 0.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 472, 7, 29, 0.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 473, 12, 29, 0.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 474, 7, 30, -0.10, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 475, 12, 30, -0.10, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 476, 7, 31, -0.10, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 477, 12, 31, -0.10, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 478, 7, 32, -0.10, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 479, 12, 32, -0.10, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 480, 7, 33, 0.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 481, 12, 33, 0.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 482, 7, 34, 0.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 483, 12, 34, 0.00, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 484, 7, 35, -0.20, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 485, 12, 35, -0.20, -1.50, 85),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 486, 8, 1, 45.00, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 487, 13, 1, 45.00, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 488, 8, 2, 42.00, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 489, 13, 2, 42.00, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 490, 8, 3, 9.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 491, 13, 3, 9.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 492, 8, 4, 1.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 493, 13, 4, 1.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 494, 8, 5, 2.00, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 495, 13, 5, 2.00, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 496, 8, 6, 2.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 497, 13, 6, 2.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 498, 8, 7, 2.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 499, 13, 7, 2.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 500, 8, 8, 4.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 501, 13, 8, 4.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 502, 8, 9, 1.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 503, 13, 9, 1.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 504, 8, 10, 1.80, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 505, 13, 10, 1.80, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 506, 8, 11, 1.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 507, 13, 11, 1.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 508, 8, 12, 1.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 509, 13, 12, 1.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 510, 8, 13, 1.80, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 511, 13, 13, 1.80, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 512, 8, 14, 1.80, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 513, 13, 14, 1.80, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 514, 8, 15, 2.00, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 515, 13, 15, 2.00, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 516, 8, 16, 4.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 517, 13, 16, 4.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 518, 8, 17, 1.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 519, 13, 17, 1.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 520, 8, 18, 2.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 521, 13, 18, 2.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 522, 8, 19, 2.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 523, 13, 19, 2.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 524, 8, 20, 1.90, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 525, 13, 20, 1.90, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 526, 8, 21, 3.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 527, 13, 21, 3.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 528, 8, 22, 1.30, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 529, 13, 22, 1.30, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 530, 8, 23, 2.80, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 531, 13, 23, 2.80, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 532, 8, 24, 4.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 533, 13, 24, 4.50, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 534, 8, 25, 0.80, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 535, 13, 25, 0.80, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 536, 8, 26, 0.40, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 537, 13, 26, 0.40, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 538, 8, 27, 0.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 539, 13, 27, 0.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 540, 8, 28, 0.30, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 541, 13, 28, 0.30, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 542, 8, 29, 0.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 543, 13, 29, 0.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 544, 8, 30, 0.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 545, 13, 30, 0.20, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 546, 8, 31, 0.30, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 547, 13, 31, 0.30, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 548, 8, 32, 0.10, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 549, 13, 32, 0.10, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 550, 8, 33, 0.30, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 551, 13, 33, 0.30, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 552, 8, 34, 0.30, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 553, 13, 34, 0.30, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 554, 8, 35, 0.10, 1.32, 18),
|
||||||
|
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 555, 13, 35, 0.10, 1.32, 18);
|
||||||
|
/*!40000 ALTER TABLE "tb_genome_cow" ENABLE KEYS */;
|
||||||
17370
backend/database/seeds/dump_가져온거.sql
Normal file
17370
backend/database/seeds/dump_가져온거.sql
Normal file
File diff suppressed because it is too large
Load Diff
6686
backend/database/seeds/seed_all_fixed.sql
Normal file
6686
backend/database/seeds/seed_all_fixed.sql
Normal file
File diff suppressed because it is too large
Load Diff
1720
backend/database/seeds/seed_simple_v2.sql
Normal file
1720
backend/database/seeds/seed_simple_v2.sql
Normal file
File diff suppressed because it is too large
Load Diff
214
backend/database/seeds/seed_simple_v3.sql
Normal file
214
backend/database/seeds/seed_simple_v3.sql
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 간단 Seed 데이터 v3 (업로드 테스트용)
|
||||||
|
-- 시나리오: 마스터 데이터 + 최소 사용자만 / 실제 업로드 파일로 테스트
|
||||||
|
-- ============================================
|
||||||
|
-- 설명: 파일 업로드 기능 테스트를 위한 최소 seed 데이터
|
||||||
|
-- - 마스터 데이터 (마커, SNP, 형질 등) 포함
|
||||||
|
-- - 사용자/농장 데이터만 최소로 포함
|
||||||
|
-- - 개체 데이터는 업로드 파일로 생성 예정
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PART 0: 스키마 초기화 (선택사항 - 완전히 새로 시작할 때만 사용)
|
||||||
|
-- ============================================
|
||||||
|
-- 주의: 아래 주석을 해제하면 모든 테이블과 데이터가 삭제됩니다
|
||||||
|
-- DROP SCHEMA public CASCADE;
|
||||||
|
-- CREATE SCHEMA public;
|
||||||
|
-- GRANT ALL ON SCHEMA public TO postgres;
|
||||||
|
-- GRANT ALL ON SCHEMA public TO public;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PART 0: ENUM 타입 생성 및 VARCHAR 길이 수정
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- ENUM 타입이 이미 존재하면 삭제 후 재생성
|
||||||
|
DROP TYPE IF EXISTS tb_cow_anlys_stat_enum CASCADE;
|
||||||
|
CREATE TYPE tb_cow_anlys_stat_enum AS ENUM ('친자일치', '친자불일치', '분석불가', '이력제부재');
|
||||||
|
|
||||||
|
DROP TYPE IF EXISTS tb_cow_cow_repro_type_enum CASCADE;
|
||||||
|
CREATE TYPE tb_cow_cow_repro_type_enum AS ENUM ('공란우', '수란우', '인공수정', '도태대상');
|
||||||
|
|
||||||
|
DROP TYPE IF EXISTS tb_cow_cow_status_enum CASCADE;
|
||||||
|
CREATE TYPE tb_cow_cow_status_enum AS ENUM ('정상', '폐사', '도축', '매각');
|
||||||
|
|
||||||
|
-- tb_cow 테이블에 컬럼 추가 (이미 있으면 무시)
|
||||||
|
ALTER TABLE tb_cow ADD COLUMN IF NOT EXISTS anlys_stat tb_cow_anlys_stat_enum;
|
||||||
|
ALTER TABLE tb_cow ADD COLUMN IF NOT EXISTS cow_repro_type tb_cow_cow_repro_type_enum;
|
||||||
|
ALTER TABLE tb_cow ADD COLUMN IF NOT EXISTS cow_status tb_cow_cow_status_enum DEFAULT '정상'::tb_cow_cow_status_enum;
|
||||||
|
ALTER TABLE tb_cow ADD COLUMN IF NOT EXISTS cow_short_no varchar(4);
|
||||||
|
|
||||||
|
-- 기타 테이블 컬럼 확장 (테이블 있을 경우만)
|
||||||
|
ALTER TABLE tb_genome_trait ALTER COLUMN dam TYPE varchar(20);
|
||||||
|
ALTER TABLE tb_region_genome ALTER COLUMN dam TYPE varchar(20);
|
||||||
|
|
||||||
|
-- tb_pedigree 모든 ID 컬럼 확장
|
||||||
|
ALTER TABLE tb_pedigree
|
||||||
|
ALTER COLUMN fk_animal_no TYPE varchar(20),
|
||||||
|
ALTER COLUMN sire_id TYPE varchar(20),
|
||||||
|
ALTER COLUMN dam_id TYPE varchar(20),
|
||||||
|
ALTER COLUMN paternal_grandsire_id TYPE varchar(20),
|
||||||
|
ALTER COLUMN paternal_granddam_id TYPE varchar(20),
|
||||||
|
ALTER COLUMN maternal_grandsire_id TYPE varchar(20),
|
||||||
|
ALTER COLUMN maternal_granddam_id TYPE varchar(20);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PART 1: 마스터 데이터 (필수 참조 데이터)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 1. 마커 타입 (2개)
|
||||||
|
INSERT INTO tb_marker_type (pk_type_cd, type_nm, type_desc, use_yn, reg_dt, updt_dt)
|
||||||
|
VALUES
|
||||||
|
('QTY', '육량형', '육량 관련 마커 (도체중, 등심단면적 등)', 'Y', NOW(), NOW()),
|
||||||
|
('QLT', '육질형', '육질 관련 마커 (근내지방도, 연도 등)', 'Y', NOW(), NOW())
|
||||||
|
ON CONFLICT (pk_type_cd) DO NOTHING;
|
||||||
|
|
||||||
|
-- 2. 마커 정보 (7개 유전자만 - 각 1개 대표 SNP)
|
||||||
|
INSERT INTO tb_marker (fk_marker_type, marker_nm, marker_desc, related_trait, snp_cnt, use_yn, favorable_allele, reg_dt, updt_dt)
|
||||||
|
VALUES
|
||||||
|
-- 육량형 (3개)
|
||||||
|
('QTY', 'PLAG1', 'Pleiomorphic adenoma gene 1', '도체중', 1, 'Y', 'G', NOW(), NOW()),
|
||||||
|
('QTY', 'NCAPG2', 'Non-SMC condensin II complex subunit G2', '체구', 1, 'Y', 'T', NOW(), NOW()),
|
||||||
|
('QTY', 'BTB', 'BTB domain containing', '등심단면적', 1, 'Y', 'T', NOW(), NOW()),
|
||||||
|
|
||||||
|
-- 육질형 (4개)
|
||||||
|
('QLT', 'NT5E', '5 prime nucleotidase ecto', '근내지방도', 1, 'Y', 'C', NOW(), NOW()),
|
||||||
|
('QLT', 'SCD', 'Stearoyl-CoA desaturase', '지방산불포화도', 1, 'Y', 'G', NOW(), NOW()),
|
||||||
|
('QLT', 'FASN', 'Fatty acid synthase', '지방합성', 1, 'Y', 'G', NOW(), NOW()),
|
||||||
|
('QLT', 'CAPN1', 'Calpain 1', '연도', 1, 'Y', 'G', NOW(), NOW())
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- 3. SNP 정보 (7개 - 각 유전자당 1개 대표 SNP)
|
||||||
|
INSERT INTO tb_snp_info (snp_nm, chr, position, snp_alleles, reg_dt, updt_dt)
|
||||||
|
VALUES
|
||||||
|
-- 육량형 대표 SNP
|
||||||
|
('14:25016263', '14', 25016263, '[G/C]', NOW(), NOW()), -- PLAG1 (GG/GC/CC)
|
||||||
|
('7:38528304', '7', 38528304, '[T/G]', NOW(), NOW()), -- NCAPG2 (TT/TG/GG)
|
||||||
|
('5:45120340', '5', 45120340, '[T/C]', NOW(), NOW()), -- BTB (TT/TC/CC)
|
||||||
|
|
||||||
|
-- 육질형 대표 SNP
|
||||||
|
('6:58230560', '6', 58230560, '[C/T]', NOW(), NOW()), -- NT5E (CC/CT/TT)
|
||||||
|
('26:21194784', '26', 21194784, '[G/A]', NOW(), NOW()), -- SCD (GG/GA/AA)
|
||||||
|
('19:51230120', '19', 51230120, '[G/A]', NOW(), NOW()), -- FASN (GG/GA/AA)
|
||||||
|
('29:44104889', '29', 44104889, '[G/A]', NOW(), NOW()) -- CAPN1 (GG/GA/AA)
|
||||||
|
ON CONFLICT (snp_nm) DO NOTHING;
|
||||||
|
|
||||||
|
-- 4. 마커-SNP 매핑 (7개 - 각 유전자당 1개 대표 SNP)
|
||||||
|
INSERT INTO tb_marker_snp (pk_fk_marker_no, pk_fk_snp_no, reg_dt, updt_dt)
|
||||||
|
SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'PLAG1' AND s.snp_nm = '14:25016263'
|
||||||
|
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'NCAPG2' AND s.snp_nm = '7:38528304'
|
||||||
|
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'BTB' AND s.snp_nm = '5:45120340'
|
||||||
|
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'NT5E' AND s.snp_nm = '6:58230560'
|
||||||
|
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'SCD' AND s.snp_nm = '26:21194784'
|
||||||
|
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'FASN' AND s.snp_nm = '19:51230120'
|
||||||
|
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'CAPN1' AND s.snp_nm = '29:44104889'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- 5. 형질 정보 (35개 경제형질)
|
||||||
|
INSERT INTO tb_genome_trait_info (trait_nm, trait_ctgry, trait_desc, use_yn, reg_dt, updt_dt)
|
||||||
|
VALUES
|
||||||
|
-- 성장형질 (6개)
|
||||||
|
('이유체중', '성장', '송아지 이유시 체중 (kg)', 'Y', NOW(), NOW()),
|
||||||
|
('육성체중', '성장', '육성기 체중 (kg)', 'Y', NOW(), NOW()),
|
||||||
|
('12개월체중', '성장', '12개월령 체중 (kg)', 'Y', NOW(), NOW()),
|
||||||
|
('일당증체량', '성장', '일일 체중 증가량 (kg/day)', 'Y', NOW(), NOW()),
|
||||||
|
('체고', '성장', '어깨높이 (cm)', 'Y', NOW(), NOW()),
|
||||||
|
('체장', '성장', '몸통 길이 (cm)', 'Y', NOW(), NOW()),
|
||||||
|
|
||||||
|
-- 도체형질 (10개)
|
||||||
|
('도체중', '도체', '도축 후 도체 무게 (kg)', 'Y', NOW(), NOW()),
|
||||||
|
('등지방두께', '도체', '등 부위 지방 두께 (mm)', 'Y', NOW(), NOW()),
|
||||||
|
('등심단면적', '도체', '등심 단면적 (cm²)', 'Y', NOW(), NOW()),
|
||||||
|
('근내지방도', '도체', '마블링 점수 (1~9)', 'Y', NOW(), NOW()),
|
||||||
|
('육량지수', '도체', '고기 생산량 지수', 'Y', NOW(), NOW()),
|
||||||
|
('육색', '도체', '고기 색깔 (1~9)', 'Y', NOW(), NOW()),
|
||||||
|
('지방색', '도체', '지방 색깔 (1~9)', 'Y', NOW(), NOW()),
|
||||||
|
('조직감', '도체', '고기 조직감 (1~3)', 'Y', NOW(), NOW()),
|
||||||
|
('성숙도', '도체', '고기 성숙 정도 (1~9)', 'Y', NOW(), NOW()),
|
||||||
|
('보수력', '도체', '수분 보유 능력 (%)', 'Y', NOW(), NOW()),
|
||||||
|
|
||||||
|
-- 육질형질 (7개)
|
||||||
|
('연도', '육질', '고기 부드러운 정도', 'Y', NOW(), NOW()),
|
||||||
|
('다즙성', '육질', '육즙 함량', 'Y', NOW(), NOW()),
|
||||||
|
('풍미', '육질', '고기 맛', 'Y', NOW(), NOW()),
|
||||||
|
('가열감량', '육질', '조리시 손실율 (%)', 'Y', NOW(), NOW()),
|
||||||
|
('전단력', '육질', '자르는 힘 (kgf)', 'Y', NOW(), NOW()),
|
||||||
|
('지방산불포화도', '육질', '불포화 지방산 비율 (%)', 'Y', NOW(), NOW()),
|
||||||
|
('오메가3비율', '육질', '오메가3 지방산 비율 (%)', 'Y', NOW(), NOW()),
|
||||||
|
|
||||||
|
-- 번식형질 (6개)
|
||||||
|
('초산월령', '번식', '첫 분만 월령 (개월)', 'Y', NOW(), NOW()),
|
||||||
|
('분만간격', '번식', '분만 사이 기간 (일)', 'Y', NOW(), NOW()),
|
||||||
|
('수태율', '번식', '임신 성공률 (%)', 'Y', NOW(), NOW()),
|
||||||
|
('분만난이도', '번식', '분만 어려움 정도 (1~5)', 'Y', NOW(), NOW()),
|
||||||
|
('송아지생존율', '번식', '신생 송아지 생존율 (%)', 'Y', NOW(), NOW()),
|
||||||
|
('모성능력', '번식', '어미소 양육 능력', 'Y', NOW(), NOW()),
|
||||||
|
|
||||||
|
-- 건강형질 (6개)
|
||||||
|
('체세포수', '건강', '우유 체세포 수 (천개/ml)', 'Y', NOW(), NOW()),
|
||||||
|
('질병저항성', '건강', '질병 저항 능력', 'Y', NOW(), NOW()),
|
||||||
|
('발굽건강', '건강', '발굽 건강 상태', 'Y', NOW(), NOW()),
|
||||||
|
('유방염저항성', '건강', '유방염 저항성', 'Y', NOW(), NOW()),
|
||||||
|
('호흡기질환저항성', '건강', '호흡기 질환 저항성', 'Y', NOW(), NOW()),
|
||||||
|
('대사질환저항성', '건강', '대사 질환 저항성', 'Y', NOW(), NOW())
|
||||||
|
ON CONFLICT (trait_nm) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PART 2: 사용자 및 농장 데이터 (최소)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 사용자 2명 (ADMIN + TEST 농장주)
|
||||||
|
INSERT INTO tb_user (user_id, user_password, user_nm, user_role, hp_no, email, addr, reg_dt, updt_dt)
|
||||||
|
VALUES
|
||||||
|
('admin', '$2b$10$abcdefghijklmnopqrstuvwxyz123456789', '관리자', 'ADMIN', '010-0000-0000', 'admin@test.com', '서울시', NOW(), NOW()),
|
||||||
|
('testuser', '$2b$10$abcdefghijklmnopqrstuvwxyz123456789', '테스트농장주', 'FARM_OWNER', '010-1111-1111', 'test@test.com', '충북 청주시', NOW(), NOW())
|
||||||
|
ON CONFLICT (user_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 농장 1개 (테스트용)
|
||||||
|
INSERT INTO tb_farm (farm_nm, farm_addr, owner_nm, contact, reg_dt, updt_dt)
|
||||||
|
VALUES
|
||||||
|
('테스트농장', '충북 청주시 상당구', '테스트농장주', '010-1111-1111', NOW(), NOW())
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PART 3: KPN (종축 수소) 데이터 (최소)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- KPN 수소 2마리만 (부계/모계 참조용)
|
||||||
|
INSERT INTO tb_kpn (kpn_no, kpn_nm, birth_dt, sire, dam, reg_dt, updt_dt)
|
||||||
|
VALUES
|
||||||
|
('KPN001001001001', '종축수소1', '2018-01-15', NULL, NULL, NOW(), NOW()),
|
||||||
|
('KPN001001001002', '종축수소2', '2019-03-20', NULL, NULL, NOW(), NOW())
|
||||||
|
ON CONFLICT (kpn_no) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 업로드 테스트용 안내
|
||||||
|
-- ============================================
|
||||||
|
-- 다음 단계: 파일 업로드로 데이터 생성
|
||||||
|
--
|
||||||
|
-- 1. 개체정보 파일 업로드 (fileType: "유전자")
|
||||||
|
-- → 농장주명 + 개체번호 매핑 정보
|
||||||
|
-- → tb_cow 테이블에 저장
|
||||||
|
--
|
||||||
|
-- 2. 유전능력평가 결과 업로드 (fileType: "유전체")
|
||||||
|
-- → 533두 유전체 분석 데이터 (CSV)
|
||||||
|
-- → tb_genome_cow, tb_genome_trait 테이블에 저장
|
||||||
|
--
|
||||||
|
-- 3. SNP 타이핑 결과 업로드 (fileType: "유전자")
|
||||||
|
-- → 개체별 SNP 유전자형
|
||||||
|
-- → tb_snp_cow 테이블에 저장
|
||||||
|
--
|
||||||
|
-- 4. MPT 분석결과 업로드 (fileType: "혈액대사검사", 선택사항)
|
||||||
|
-- → 혈액 샘플 분석 결과
|
||||||
|
-- → tb_repro_mpt 테이블에 저장
|
||||||
|
--
|
||||||
|
-- 업로드 후 확인 쿼리:
|
||||||
|
-- SELECT * FROM tb_uploadfile WHERE file_type IN ('유전자', '유전체', '혈액대사검사') ORDER BY reg_dt DESC;
|
||||||
|
-- SELECT count(*) FROM tb_cow;
|
||||||
|
-- SELECT count(*) FROM tb_genome_cow;
|
||||||
|
-- SELECT count(*) FROM tb_snp_cow;
|
||||||
|
-- SELECT farm_owner, count(*) FROM tb_cow GROUP BY farm_owner;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 완료
|
||||||
|
-- ============================================
|
||||||
|
-- v3 seed 데이터 생성 완료
|
||||||
|
-- 마스터 데이터만 포함, 실제 개체 데이터는 파일 업로드로 생성 예정
|
||||||
2521
backend/doc/FRONTEND_IMPLEMENTATION_GUIDE.md
Normal file
2521
backend/doc/FRONTEND_IMPLEMENTATION_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,78 +0,0 @@
|
|||||||
# 혈액대사판정시험(MPT) 검사항목 및 권장수치
|
|
||||||
|
|
||||||
## 개요
|
|
||||||
번식능력 검사를 위한 혈액대사판정시험(MPT) 검사항목으로, 5개 카테고리 총 16개 항목으로 구성됩니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 에너지 카테고리
|
|
||||||
|
|
||||||
| 항목 | 권장수치 | 단위 |
|
|
||||||
|------|---------|------|
|
|
||||||
| 혈당 | 40-84 | mg/dL |
|
|
||||||
| 콜레스테롤 | 74-252 | mg/dL |
|
|
||||||
| 유리지방산(NEFA) | 115-660 | μEq/L |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 단백질 카테고리
|
|
||||||
|
|
||||||
| 항목 | 권장수치 | 단위 |
|
|
||||||
|------|---------|------|
|
|
||||||
| 총단백질 | 6.2-7.7 | g/dL |
|
|
||||||
| 알부민 | 3.3-4.3 | g/dL |
|
|
||||||
| 총글로블린 | 9.1-36.1 | g/dL |
|
|
||||||
| A/G | 0.1-0.4 | - |
|
|
||||||
| 요소태질소(BUN) | 11.7-18.9 | mg/dL |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 간기능 카테고리
|
|
||||||
|
|
||||||
| 항목 | 권장수치 | 단위 |
|
|
||||||
|------|---------|------|
|
|
||||||
| AST | 47-92 | U/L |
|
|
||||||
| GGT | 11-32 | U/L |
|
|
||||||
| 지방간 지수 | -1.2 ~ 9.9 | - |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 미네랄 카테고리
|
|
||||||
|
|
||||||
| 항목 | 권장수치 | 단위 |
|
|
||||||
|------|---------|------|
|
|
||||||
| 칼슘 | 8.1-10.6 | mg/dL |
|
|
||||||
| 인 | 6.2-8.9 | mg/dL |
|
|
||||||
| 칼슘/인 | 1.2-1.3 | - |
|
|
||||||
| 마그네슘 | 1.6-3.3 | mg/dL |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 별도 카테고리
|
|
||||||
|
|
||||||
| 항목 | 권장수치 | 단위 |
|
|
||||||
|------|---------|------|
|
|
||||||
| 크레아틴 | 1.0-1.3 | mg/dL |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 결과 판정 기준
|
|
||||||
|
|
||||||
| 판정 | 설명 |
|
|
||||||
|------|------|
|
|
||||||
| 낮음 | 권장수치 하한 미만 |
|
|
||||||
| 권장범위 | 권장수치 범위 내 |
|
|
||||||
| 높음 | 권장수치 상한 초과 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 시각화 방식
|
|
||||||
- **폴리곤(레이더) 차트**: 5개 카테고리를 5각형 구조로 표현
|
|
||||||
- **가로 막대 도표**: 각 항목별 낮음/권장범위/높음 표시
|
|
||||||
- **색상 구분**: 우수/적정/부족으로 구분
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
> 혈액대사판정시험의 주요 5가지 항목에 대한 시군 및 농가 수치비교를 위해 표준화한 자료입니다.
|
|
||||||
> 본 결과자료를 통한 종합평가는 보은군 평균과 농가 평균을 비교하여 상대적 차이의 수준을 나타냅니다.
|
|
||||||
1044
backend/doc/ux-detail.md
Normal file
1044
backend/doc/ux-detail.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,146 +0,0 @@
|
|||||||
# 유전체/유전자 검사 가능 조건 요약
|
|
||||||
|
|
||||||
## 1. DB 상태값 정의
|
|
||||||
|
|
||||||
### chipSireName (아비명)
|
|
||||||
|
|
||||||
| DB 값 | 의미 | 분석 가능 여부 |
|
|
||||||
|-------|------|----------------|
|
|
||||||
| `일치` | 친자감별 일치 | 가능 |
|
|
||||||
| `불일치` | 친자감별 불일치 | 유전체 불가 / 유전자 가능 |
|
|
||||||
| `분석불가` | 모근 오염/불량 등 기타 사유 | 불가 |
|
|
||||||
| `정보없음` | 개체 식별번호/형식 오류 | 불가 |
|
|
||||||
| `null` | 미분석 (의뢰 없음) | - 표시 |
|
|
||||||
|
|
||||||
## - 아비명 가능한 개체에 대해서 어미명 판단 진행
|
|
||||||
|
|
||||||
### chipDamName (어미명)
|
|
||||||
|
|
||||||
| DB 값 | 의미 | 분석 가능 여부 |
|
|
||||||
|-------|------|----------------|
|
|
||||||
| `일치` | 친자감별 일치 | 통과 |
|
|
||||||
| `불일치` | 친자감별 불일치 | 유전체 불가 / 유전자 가능 |
|
|
||||||
| `이력제부재` | 모 이력제 정보 없음 | 유전체 불가 / 유전자 가능 |
|
|
||||||
| `정보없음` | 정보 없음 | 통과 |
|
|
||||||
| `null` | 정보 없음 | 통과 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 탭별 검사 가능 조건
|
|
||||||
|
|
||||||
### 유전체 탭
|
|
||||||
```
|
|
||||||
유효 조건 (모두 충족해야 함):
|
|
||||||
1. chipSireName === '일치'
|
|
||||||
2. chipDamName !== '불일치'
|
|
||||||
3. chipDamName !== '이력제부재'
|
|
||||||
4. cowId가 EXCLUDED_COW_IDS에 포함되지 않음
|
|
||||||
```
|
|
||||||
|
|
||||||
### 유전자 탭
|
|
||||||
```
|
|
||||||
유효 조건:
|
|
||||||
1. chipSireName !== '분석불가'
|
|
||||||
2. chipSireName !== '정보없음'
|
|
||||||
3. cowId가 EXCLUDED_COW_IDS에 포함되지 않음
|
|
||||||
|
|
||||||
※ 불일치/이력제부재도 유전자 데이터가 있으면 표시 가능
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 개체 목록 배지 표시 (unavailableReason)
|
|
||||||
|
|
||||||
### 분석일자 컬럼
|
|
||||||
|
|
||||||
| unavailableReason | 배지 색상 | 표시 텍스트 |
|
|
||||||
|-------------------|-----------|-------------|
|
|
||||||
| `null` | - | `-` |
|
|
||||||
| `분석불가` | 회색 | 분석불가 |
|
|
||||||
| `부 불일치` | 빨간색 | 부 불일치 |
|
|
||||||
| `모 불일치` | 주황색 | 모 불일치 |
|
|
||||||
| `모 이력제부재` | 주황색 | 모 이력제부재 |
|
|
||||||
| `형질정보없음` | 회색 | 형질정보없음 |
|
|
||||||
|
|
||||||
### unavailableReason 결정 로직 (cow.service.ts)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (!latestRequest || !latestRequest.chipSireName) {
|
|
||||||
unavailableReason = null; // '-' 표시
|
|
||||||
} else if (chipSireName === '분석불가' || chipSireName === '정보없음') {
|
|
||||||
unavailableReason = '분석불가';
|
|
||||||
} else if (chipSireName !== '일치') {
|
|
||||||
unavailableReason = '부 불일치';
|
|
||||||
} else if (chipDamName === '불일치') {
|
|
||||||
unavailableReason = '모 불일치';
|
|
||||||
} else if (chipDamName === '이력제부재') {
|
|
||||||
unavailableReason = '모 이력제부재';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 형질 데이터 없으면
|
|
||||||
unavailableReason = '형질정보없음';
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 개체 상세 페이지 배지
|
|
||||||
|
|
||||||
### 부 KPN 배지 (renderSireBadge)
|
|
||||||
|
|
||||||
| 조건 | 배지 색상 | 표시 |
|
|
||||||
|------|-----------|------|
|
|
||||||
| `EXCLUDED_COW_IDS` 포함 | 회색 | 분석불가 |
|
|
||||||
| `chipSireName === '분석불가'` | 회색 | 분석불가 |
|
|
||||||
| `chipSireName === '정보없음'` | 회색 | 분석불가 |
|
|
||||||
| `chipSireName === '일치'` | 초록색 | 일치 |
|
|
||||||
| 그 외 | 빨간색 | 불일치 |
|
|
||||||
| `null` | - | 표시 안 함 |
|
|
||||||
|
|
||||||
### 모 개체 배지 (renderDamBadge)
|
|
||||||
|
|
||||||
| 조건 | 배지 색상 | 표시 |
|
|
||||||
|------|-----------|------|
|
|
||||||
| `chipDamName === '일치'` | 초록색 | 일치 |
|
|
||||||
| `chipDamName === '불일치'` | 빨간색 | 불일치 |
|
|
||||||
| `chipDamName === '이력제부재'` | 주황색 | 이력제부재 |
|
|
||||||
| 그 외/null | - | 표시 안 함 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 분석불가 안내 문구
|
|
||||||
|
|
||||||
| 상태 | 안내 문구 |
|
|
||||||
|------|-----------|
|
|
||||||
| `분석불가` (DB) | 모근 오염 및 불량 등 기타 사유로 유전체 분석 보고서를 제공할 수 없습니다. |
|
|
||||||
| `정보없음` (DB) | 개체 식별번호 및 형식오류로 유전체 분석 보고서를 제공할 수 없습니다. |
|
|
||||||
| `부 불일치` | 부 친자감별 결과가 불일치하여 유전체 분석 보고서를 제공할 수 없습니다. |
|
|
||||||
| `모 불일치` | 모 친자감별 결과가 불일치하여 유전체 분석 보고서를 제공할 수 없습니다. |
|
|
||||||
| `모 이력제부재` | 모 이력제 정보가 부재하여 유전체 분석 보고서를 제공할 수 없습니다. |
|
|
||||||
| `EXCLUDED_COW_IDS` | 모근 오염 및 불량 등 기타 사유로 유전체 분석 보고서를 제공할 수 없습니다. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 관련 파일
|
|
||||||
|
|
||||||
### 백엔드
|
|
||||||
- `backend/src/common/config/GenomeAnalysisConfig.ts` - 유효성 검사 함수
|
|
||||||
- `backend/src/cow/cow.service.ts` - unavailableReason 결정 로직
|
|
||||||
|
|
||||||
### 프론트엔드
|
|
||||||
- `frontend/src/lib/utils/genome-analysis-config.ts` - 유효성 검사, 메시지 함수
|
|
||||||
- `frontend/src/app/cow/page.tsx` - 개체 목록 배지
|
|
||||||
- `frontend/src/app/cow/[cowNo]/page.tsx` - 개체 상세 배지, 탭 조건
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 제외 개체 목록 (EXCLUDED_COW_IDS)
|
|
||||||
|
|
||||||
특수 사유로 분석 불가한 개체를 하드코딩으로 관리:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const EXCLUDED_COW_IDS = [
|
|
||||||
'KOR002191642861', // 모근상태 불량으로 인한 DNA분해
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
> 이 목록에 포함된 개체는 유전체/유전자 탭 모두 분석불가로 처리됨
|
|
||||||
1234
backend/doc/기능요구사항전체정리.md
Normal file
1234
backend/doc/기능요구사항전체정리.md
Normal file
File diff suppressed because it is too large
Load Diff
719
backend/doc/프론트엔드_API_연동_가이드.md
Normal file
719
backend/doc/프론트엔드_API_연동_가이드.md
Normal file
@@ -0,0 +1,719 @@
|
|||||||
|
# 프론트엔드 API 연동 가이드
|
||||||
|
|
||||||
|
> **작성일**: 2025-10-26
|
||||||
|
> **버전**: 1.0
|
||||||
|
> **대상**: 한우 유전체 분석 시스템 프론트엔드 개발자
|
||||||
|
|
||||||
|
## 📋 목차
|
||||||
|
|
||||||
|
1. [개요](#1-개요)
|
||||||
|
2. [인증 흐름](#2-인증-흐름)
|
||||||
|
3. [사용자-농장-개체 관계](#3-사용자-농장-개체-관계)
|
||||||
|
4. [API 연동 방법](#4-api-연동-방법)
|
||||||
|
5. [주요 구현 사례](#5-주요-구현-사례)
|
||||||
|
6. [문제 해결](#6-문제-해결)
|
||||||
|
7. [추가 구현 권장사항](#7-추가-구현-권장사항)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
### 1.1 시스템 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자 (User) 1:N 농장 (Farm) 1:N 개체 (Cow)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **User**: 로그인한 사용자 (농가, 컨설턴트, 기관담당자)
|
||||||
|
- **Farm**: 사용자가 소유한 농장 (한 사용자가 여러 농장 소유 가능)
|
||||||
|
- **Cow**: 농장에 속한 개체 (한우)
|
||||||
|
|
||||||
|
### 1.2 주요 기술 스택
|
||||||
|
|
||||||
|
**백엔드**:
|
||||||
|
- NestJS 10.x
|
||||||
|
- TypeORM
|
||||||
|
- JWT 인증
|
||||||
|
- PostgreSQL
|
||||||
|
|
||||||
|
**프론트엔드**:
|
||||||
|
- Next.js 15.5.3 (App Router)
|
||||||
|
- TypeScript
|
||||||
|
- Zustand (상태관리)
|
||||||
|
- Axios (HTTP 클라이언트)
|
||||||
|
|
||||||
|
### 1.3 API Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
개발: http://localhost:4000
|
||||||
|
운영: 환경변수 NEXT_PUBLIC_API_URL 사용
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 인증 흐름
|
||||||
|
|
||||||
|
### 2.1 JWT 기반 인증
|
||||||
|
|
||||||
|
모든 API 요청은 JWT 토큰을 필요로 합니다 (일부 Public 엔드포인트 제외).
|
||||||
|
|
||||||
|
**전역 Guard 적용**:
|
||||||
|
```typescript
|
||||||
|
// backend/src/main.ts
|
||||||
|
app.useGlobalGuards(new JwtAuthGuard(reflector));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 로그인 프로세스
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. 사용자 로그인
|
||||||
|
const response = await authApi.login({
|
||||||
|
userId: 'user123',
|
||||||
|
userPassword: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 응답 구조
|
||||||
|
{
|
||||||
|
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
user: {
|
||||||
|
userNo: 1,
|
||||||
|
userName: '홍길동',
|
||||||
|
userEmail: 'hong@example.com',
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 토큰 자동 저장 (auth-store.ts에서 처리)
|
||||||
|
localStorage.setItem('accessToken', response.accessToken);
|
||||||
|
localStorage.setItem('refreshToken', response.refreshToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 자동 토큰 주입
|
||||||
|
|
||||||
|
`apiClient`가 모든 요청에 자동으로 토큰을 추가합니다:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/src/lib/api-client.ts (자동 처리)
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**개발자는 별도로 토큰을 관리할 필요 없음** ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 사용자-농장-개체 관계
|
||||||
|
|
||||||
|
### 3.1 데이터 모델
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// User Entity
|
||||||
|
{
|
||||||
|
pkUserNo: number; // 사용자 번호
|
||||||
|
userId: string; // 로그인 ID
|
||||||
|
userName: string; // 이름
|
||||||
|
userSe: 'FARM' | 'CNSLT' | 'ORGAN'; // 사용자 구분
|
||||||
|
farms: FarmModel[]; // 소유 농장 목록
|
||||||
|
}
|
||||||
|
|
||||||
|
// Farm Entity
|
||||||
|
{
|
||||||
|
pkFarmNo: number; // 농장 번호
|
||||||
|
fkUserNo: number; // 사용자 번호 (FK)
|
||||||
|
farmCode: string; // 농장 코드 (F000001)
|
||||||
|
farmName: string; // 농장명
|
||||||
|
farmAddress: string; // 농장 주소
|
||||||
|
cows: CowModel[]; // 농장의 개체 목록
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cow Entity
|
||||||
|
{
|
||||||
|
pkCowNo: string; // 개체번호 (12자리)
|
||||||
|
fkFarmNo: number; // 농장 번호 (FK)
|
||||||
|
cowSex: 'M' | 'F'; // 성별
|
||||||
|
cowBirthDt: Date; // 생년월일
|
||||||
|
cowStatus: string; // 개체 상태
|
||||||
|
delYn: 'Y' | 'N'; // 삭제 여부
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 관계 API 호출 순서
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 올바른 순서:
|
||||||
|
// 1. 로그인한 사용자의 농장 목록 조회
|
||||||
|
const farms = await farmApi.findAll(); // GET /farm
|
||||||
|
|
||||||
|
// 2. 특정 농장의 개체 조회
|
||||||
|
const cows = await cowApi.findByFarmNo(farms[0].pkFarmNo); // GET /cow/farm/:farmNo
|
||||||
|
```
|
||||||
|
|
||||||
|
**❌ 잘못된 방법**: farmNo를 하드코딩
|
||||||
|
```typescript
|
||||||
|
const cows = await cowApi.findByFarmNo(1); // ❌ 다른 사용자의 데이터 접근 불가
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 연동 방법
|
||||||
|
|
||||||
|
### 4.1 API 모듈 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/lib/api/
|
||||||
|
├── api-client.ts # Axios 인스턴스 + 인터셉터
|
||||||
|
├── index.ts # 모든 API export
|
||||||
|
├── auth.api.ts # 인증 API
|
||||||
|
├── farm.api.ts # 농장 API
|
||||||
|
├── cow.api.ts # 개체 API
|
||||||
|
├── kpn.api.ts # KPN API
|
||||||
|
└── dashboard.api.ts # 대시보드 API
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Import 방법
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { cowApi, farmApi, authApi } from '@/lib/api';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 주요 Farm API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. 현재 사용자의 농장 목록 조회
|
||||||
|
const farms = await farmApi.findAll();
|
||||||
|
// GET /farm
|
||||||
|
// 응답: FarmDto[]
|
||||||
|
|
||||||
|
// 2. 농장 상세 조회
|
||||||
|
const farm = await farmApi.findOne(farmNo);
|
||||||
|
// GET /farm/:id
|
||||||
|
// 응답: FarmDto
|
||||||
|
|
||||||
|
// 3. 농장 생성
|
||||||
|
const newFarm = await farmApi.create({
|
||||||
|
userNo: 1,
|
||||||
|
farmName: '행복농장',
|
||||||
|
farmAddress: '충청북도 보은군...',
|
||||||
|
farmBizNo: '123-45-67890'
|
||||||
|
});
|
||||||
|
// POST /farm
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 주요 Cow API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. 특정 농장의 개체 목록 조회
|
||||||
|
const cows = await cowApi.findByFarmNo(farmNo);
|
||||||
|
// GET /cow/farm/:farmNo
|
||||||
|
// 응답: CowDto[]
|
||||||
|
|
||||||
|
// 2. 개체 상세 조회
|
||||||
|
const cow = await cowApi.findOne(cowNo);
|
||||||
|
// GET /cow/:cowNo
|
||||||
|
// 응답: CowDto
|
||||||
|
|
||||||
|
// 3. 개체 검색
|
||||||
|
const results = await cowApi.search('KOR001', farmNo, 20);
|
||||||
|
// GET /cow/search?keyword=KOR001&farmNo=1&limit=20
|
||||||
|
// 응답: CowDto[]
|
||||||
|
|
||||||
|
// 4. 개체 랭킹 조회 (필터 + 정렬)
|
||||||
|
const ranking = await cowApi.getRanking({
|
||||||
|
filterOptions: {
|
||||||
|
filters: [/* 필터 조건 */]
|
||||||
|
},
|
||||||
|
rankingOptions: {
|
||||||
|
criteria: 'GENE',
|
||||||
|
order: 'DESC'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// POST /cow/ranking
|
||||||
|
// 응답: RankingResult<CowDto>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 주요 구현 사례
|
||||||
|
|
||||||
|
### 5.1 개체 목록 페이지 (/cow/page.tsx)
|
||||||
|
|
||||||
|
**완전한 구현 예시** (하드코딩 없음):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { cowApi, farmApi } from '@/lib/api'
|
||||||
|
import type { Cow } from '@/types/cow.types'
|
||||||
|
|
||||||
|
export default function CowListPage() {
|
||||||
|
const [cows, setCows] = useState<Cow[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCows = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// 1단계: 사용자의 농장 목록 조회
|
||||||
|
const farms = await farmApi.findAll()
|
||||||
|
|
||||||
|
if (!farms || farms.length === 0) {
|
||||||
|
setError('등록된 농장이 없습니다. 농장을 먼저 등록해주세요.')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2단계: 첫 번째 농장의 개체 조회
|
||||||
|
// TODO: 여러 농장이 있을 경우 사용자가 선택할 수 있도록 UI 추가
|
||||||
|
const farmNo = farms[0].pkFarmNo
|
||||||
|
|
||||||
|
const data = await cowApi.findByFarmNo(farmNo)
|
||||||
|
setCows(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('개체 데이터 조회 실패:', err)
|
||||||
|
setError(err instanceof Error ? err.message : '데이터를 불러오는데 실패했습니다')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCows()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) return <div>로딩 중...</div>
|
||||||
|
if (error) return <div>에러: {error}</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>개체 목록</h1>
|
||||||
|
{cows.map(cow => (
|
||||||
|
<div key={cow.pkCowNo}>
|
||||||
|
<p>개체번호: {cow.pkCowNo}</p>
|
||||||
|
<p>성별: {cow.cowSex === 'M' ? '수소' : '암소'}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 개체 상세 페이지 (/cow/[cowNo]/page.tsx)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { cowApi } from '@/lib/api'
|
||||||
|
import type { Cow } from '@/types/cow.types'
|
||||||
|
|
||||||
|
export default function CowDetailPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const cowNo = params.cowNo as string
|
||||||
|
const [cow, setCow] = useState<Cow | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCow = async () => {
|
||||||
|
try {
|
||||||
|
const data = await cowApi.findOne(cowNo)
|
||||||
|
setCow(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('개체 상세 조회 실패:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCow()
|
||||||
|
}, [cowNo])
|
||||||
|
|
||||||
|
if (loading) return <div>로딩 중...</div>
|
||||||
|
if (!cow) return <div>개체를 찾을 수 없습니다</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>개체 상세: {cow.pkCowNo}</h1>
|
||||||
|
<p>농장번호: {cow.fkFarmNo}</p>
|
||||||
|
<p>성별: {cow.cowSex === 'M' ? '수소' : '암소'}</p>
|
||||||
|
<p>생년월일: {cow.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString() : '-'}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Dashboard 통계 페이지
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { farmApi, cowApi } from '@/lib/api'
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
totalFarms: 0,
|
||||||
|
totalCows: 0,
|
||||||
|
farms: []
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
// 1. 사용자의 농장 목록 조회
|
||||||
|
const farms = await farmApi.findAll()
|
||||||
|
|
||||||
|
// 2. 각 농장의 개체 수 집계
|
||||||
|
let totalCows = 0
|
||||||
|
const farmsWithCows = await Promise.all(
|
||||||
|
farms.map(async (farm) => {
|
||||||
|
const cows = await cowApi.findByFarmNo(farm.pkFarmNo)
|
||||||
|
totalCows += cows.length
|
||||||
|
return {
|
||||||
|
...farm,
|
||||||
|
cowCount: cows.length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
totalFarms: farms.length,
|
||||||
|
totalCows,
|
||||||
|
farms: farmsWithCows
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('통계 조회 실패:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchStats()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>대시보드</h1>
|
||||||
|
<p>총 농장 수: {stats.totalFarms}개</p>
|
||||||
|
<p>총 개체 수: {stats.totalCows}마리</p>
|
||||||
|
{stats.farms.map(farm => (
|
||||||
|
<div key={farm.pkFarmNo}>
|
||||||
|
<p>{farm.farmName}: {farm.cowCount}마리</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 문제 해결
|
||||||
|
|
||||||
|
### 6.1 인증 에러 (401 Unauthorized)
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
```
|
||||||
|
{"statusCode":401,"message":["인증이 필요합니다. 로그인 후 이용해주세요."]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**원인**:
|
||||||
|
- localStorage에 토큰이 없음
|
||||||
|
- 토큰 만료
|
||||||
|
|
||||||
|
**해결 방법**:
|
||||||
|
```typescript
|
||||||
|
// 1. 로그인 상태 확인
|
||||||
|
const { isAuthenticated } = useAuthStore()
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 토큰 갱신 (필요 시)
|
||||||
|
await authApi.refreshToken(refreshToken)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 농장이 없는 경우
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
```
|
||||||
|
등록된 농장이 없습니다.
|
||||||
|
```
|
||||||
|
|
||||||
|
**해결 방법**:
|
||||||
|
```typescript
|
||||||
|
if (!farms || farms.length === 0) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>등록된 농장이 없습니다.</p>
|
||||||
|
<button onClick={() => router.push('/farm/create')}>
|
||||||
|
농장 등록하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 CORS 에러
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
```
|
||||||
|
Access to XMLHttpRequest has been blocked by CORS policy
|
||||||
|
```
|
||||||
|
|
||||||
|
**해결 방법**:
|
||||||
|
|
||||||
|
백엔드에서 CORS 설정 확인:
|
||||||
|
```typescript
|
||||||
|
// backend/src/main.ts
|
||||||
|
app.enableCors({
|
||||||
|
origin: 'http://localhost:3000',
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 추가 구현 권장사항
|
||||||
|
|
||||||
|
### 7.1 여러 농장 선택 UI
|
||||||
|
|
||||||
|
현재는 첫 번째 농장만 사용하지만, 사용자가 여러 농장을 소유한 경우 선택할 수 있어야 합니다.
|
||||||
|
|
||||||
|
**구현 예시**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { farmApi, cowApi } from '@/lib/api'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
|
||||||
|
export default function CowListPage() {
|
||||||
|
const [farms, setFarms] = useState([])
|
||||||
|
const [selectedFarmNo, setSelectedFarmNo] = useState<number | null>(null)
|
||||||
|
const [cows, setCows] = useState([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFarms = async () => {
|
||||||
|
const data = await farmApi.findAll()
|
||||||
|
setFarms(data)
|
||||||
|
|
||||||
|
// 첫 번째 농장 자동 선택
|
||||||
|
if (data.length > 0) {
|
||||||
|
setSelectedFarmNo(data[0].pkFarmNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchFarms()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedFarmNo) return
|
||||||
|
|
||||||
|
const fetchCows = async () => {
|
||||||
|
const data = await cowApi.findByFarmNo(selectedFarmNo)
|
||||||
|
setCows(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCows()
|
||||||
|
}, [selectedFarmNo])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Select value={String(selectedFarmNo)} onValueChange={(val) => setSelectedFarmNo(Number(val))}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="농장 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{farms.map(farm => (
|
||||||
|
<SelectItem key={farm.pkFarmNo} value={String(farm.pkFarmNo)}>
|
||||||
|
{farm.farmName} ({farm.farmCode})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{cows.map(cow => (
|
||||||
|
<div key={cow.pkCowNo}>{cow.pkCowNo}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 유전자 정보 포함 API 사용
|
||||||
|
|
||||||
|
현재 `GET /cow/farm/:farmNo`는 기본 정보만 반환합니다.
|
||||||
|
유전자 정보가 필요한 경우 `POST /cow/ranking` API를 사용하세요.
|
||||||
|
|
||||||
|
**구현 예시**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 유전자 정보를 포함한 개체 조회
|
||||||
|
const ranking = await cowApi.getRanking({
|
||||||
|
filterOptions: {
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
field: 'cow.fkFarmNo',
|
||||||
|
operator: 'equals',
|
||||||
|
value: farmNo
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
rankingOptions: {
|
||||||
|
criteria: 'GENE',
|
||||||
|
order: 'DESC'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ranking.items에 SNP, Trait 등 모든 관계 데이터 포함됨
|
||||||
|
const cowsWithGenes = ranking.items
|
||||||
|
```
|
||||||
|
|
||||||
|
**백엔드 참조**:
|
||||||
|
- `backend/src/cow/cow.service.ts:122-140` - `createRankingQueryBuilder()`
|
||||||
|
- SNP, Trait, Repro, MPT 데이터를 모두 leftJoin으로 포함
|
||||||
|
|
||||||
|
### 7.3 에러 바운더리 추가
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/ErrorBoundary.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
export default function ErrorBoundary({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('에러 발생:', error)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">문제가 발생했습니다</h2>
|
||||||
|
<p className="mb-4">{error.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded"
|
||||||
|
>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 로딩 스켈레톤 UI
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/CowListSkeleton.tsx
|
||||||
|
export default function CowListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<div key={i} className="border rounded p-4 animate-pulse">
|
||||||
|
<div className="h-6 bg-gray-200 rounded mb-2"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용
|
||||||
|
if (loading) return <CowListSkeleton />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 참고 자료
|
||||||
|
|
||||||
|
### 8.1 백엔드 API 문서
|
||||||
|
|
||||||
|
- **Cow API**: `backend/src/cow/cow.controller.ts`
|
||||||
|
- **Farm API**: `backend/src/farm/farm.controller.ts`
|
||||||
|
- **Auth API**: `backend/src/auth/auth.controller.ts`
|
||||||
|
|
||||||
|
### 8.2 프론트엔드 코드 위치
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── app/
|
||||||
|
│ ├── cow/
|
||||||
|
│ │ ├── page.tsx # 개체 목록 (완성)
|
||||||
|
│ │ └── [cowNo]/page.tsx # 개체 상세
|
||||||
|
│ └── dashboard/page.tsx # 대시보드
|
||||||
|
├── lib/api/
|
||||||
|
│ ├── cow.api.ts # Cow API
|
||||||
|
│ ├── farm.api.ts # Farm API (신규 추가)
|
||||||
|
│ └── index.ts # API export
|
||||||
|
├── types/
|
||||||
|
│ ├── cow.types.ts # Cow 타입
|
||||||
|
│ └── auth.types.ts # Auth 타입
|
||||||
|
└── store/
|
||||||
|
└── auth-store.ts # 인증 상태 관리
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 타입 정의 참조
|
||||||
|
|
||||||
|
**백엔드 Entity → 프론트엔드 Types 매핑**:
|
||||||
|
|
||||||
|
| 백엔드 Entity | 프론트엔드 Types | 설명 |
|
||||||
|
|--------------|-----------------|------|
|
||||||
|
| `UsersModel` | `UserDto` | 사용자 |
|
||||||
|
| `FarmModel` | `FarmDto` | 농장 |
|
||||||
|
| `CowModel` | `CowDto` | 개체 |
|
||||||
|
| `KpnModel` | `KpnDto` | KPN |
|
||||||
|
|
||||||
|
**필드명 주의사항**:
|
||||||
|
- 백엔드: `pkCowNo`, `fkFarmNo`, `cowBirthDt`, `cowSex`
|
||||||
|
- 프론트엔드도 동일하게 사용 (DTO 변환 없음)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 체크리스트
|
||||||
|
|
||||||
|
개발 시 확인사항:
|
||||||
|
|
||||||
|
- [ ] JWT 토큰이 localStorage에 저장되는가?
|
||||||
|
- [ ] API 호출 시 Authorization 헤더가 자동으로 추가되는가?
|
||||||
|
- [ ] farmNo를 하드코딩하지 않고 `farmApi.findAll()`로 조회하는가?
|
||||||
|
- [ ] 농장이 없는 경우를 처리했는가?
|
||||||
|
- [ ] 에러 발생 시 사용자에게 적절한 메시지를 보여주는가?
|
||||||
|
- [ ] 로딩 상태를 표시하는가?
|
||||||
|
- [ ] 여러 농장이 있는 경우를 고려했는가?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 문의
|
||||||
|
|
||||||
|
질문이나 문제가 있는 경우:
|
||||||
|
|
||||||
|
1. 백엔드 API 문서 확인: `backend/doc/기능요구사항전체정리.md`
|
||||||
|
2. PRD 문서 확인: `E:/repo5/prd/`
|
||||||
|
3. 코드 참조:
|
||||||
|
- 완성된 `/cow/page.tsx` 구현 참조
|
||||||
|
- `lib/api-client.ts` 인터셉터 참조
|
||||||
|
- `store/auth-store.ts` 인증 흐름 참조
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**문서 작성**: Claude Code
|
||||||
|
**최종 수정일**: 2025-10-26
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { BadRequestException, Body, Controller, Get, Post, UploadedFile, UseInterceptors, Logger } from "@nestjs/common";
|
|
||||||
import { AdminService } from "./admin.service";
|
|
||||||
import { FileInterceptor } from "@nestjs/platform-express";
|
|
||||||
import { basename, extname, join } from "path";
|
|
||||||
import { BaseResultDto } from "src/common/dto/base.result.dto";
|
|
||||||
import { diskStorage } from "multer";
|
|
||||||
|
|
||||||
import * as fs from 'fs/promises';
|
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
import { tmpdir } from "os";
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
※업로드 관련 기능 추후 공통화 처리 필요.
|
|
||||||
**/
|
|
||||||
const ALLOWED_EXTENSIONS = ['.xlsx', '.txt', '.csv', '.xls']; // 파일 업로드 허용 확장자
|
|
||||||
const ALLOWED_MIME_TYPES = [ // 파일 업로드 허용 MIME 타입
|
|
||||||
'text/plain',
|
|
||||||
// XLSX
|
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
// XLS (구형 + CSV도 섞여 나옴)
|
|
||||||
'application/vnd.ms-excel',
|
|
||||||
// CSV 계열
|
|
||||||
'text/csv',
|
|
||||||
'application/csv',
|
|
||||||
'text/plain',
|
|
||||||
// 한컴
|
|
||||||
'application/haansoftxls',
|
|
||||||
];
|
|
||||||
|
|
||||||
@Controller('admin')
|
|
||||||
export class AdminController {
|
|
||||||
constructor(private readonly adminService: AdminService) {}
|
|
||||||
|
|
||||||
private readonly logger = new Logger(AdminController.name);
|
|
||||||
|
|
||||||
@Get('dashboard')
|
|
||||||
getDashboard() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Post('batchUpload')
|
|
||||||
@UseInterceptors(FileInterceptor('file', {
|
|
||||||
storage: diskStorage({
|
|
||||||
destination: async (req, file, callback) => {
|
|
||||||
// 환경 변수가 없으면 시스템 임시 디렉터리 사용
|
|
||||||
const uploadDir = process.env.UPLOAD_DESTINATION
|
|
||||||
? join(process.env.UPLOAD_DESTINATION, 'tmp')
|
|
||||||
: join(tmpdir(), 'genome2025-uploads');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 디렉터리가 없으면 생성
|
|
||||||
await fs.mkdir(uploadDir, { recursive: true });
|
|
||||||
callback(null, uploadDir);
|
|
||||||
} catch (error) {
|
|
||||||
callback(error, null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
filename: (req, file, callback) => {
|
|
||||||
const ext = extname(file.originalname).toLowerCase();
|
|
||||||
callback(null, `${randomUUID()}-${Date.now()}${ext}`);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
fileFilter: (req, file, callback) => { // 파일 업로드 필터링
|
|
||||||
const ext = extname(file.originalname).toLowerCase();
|
|
||||||
const mime = file.mimetype;
|
|
||||||
|
|
||||||
if (!ALLOWED_EXTENSIONS.includes(ext)) { // 허용되지 않은 확장자 필터링
|
|
||||||
return callback(
|
|
||||||
new BadRequestException(`허용되지 않은 확장자: ${ext}`),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ALLOWED_MIME_TYPES.includes(mime)) { // 허용되지 않은 MIME 타입 필터링
|
|
||||||
return callback(
|
|
||||||
new BadRequestException(`허용되지 않은 MIME 타입: ${mime}`),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
callback(null, true);
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
async batchUpload(@UploadedFile() file: Express.Multer.File, @Body('div') div: string) {
|
|
||||||
let divName = '';
|
|
||||||
try {
|
|
||||||
if (!file?.path){
|
|
||||||
throw new BadRequestException('파일 업로드 실패')
|
|
||||||
};
|
|
||||||
|
|
||||||
if (div === 'genome-result') { // 유전체 분석 결과(DGV)
|
|
||||||
divName = '유전체 분석 결과(DGV)';
|
|
||||||
await this.adminService.batchInsertGenomeResult(file);
|
|
||||||
}else if (div === 'snp-typing') { // 개체별 SNP 타이핑 결과(유전자 타이핑 결과)
|
|
||||||
divName = '개체별 SNP 타이핑 결과(유전자 타이핑 결과)';
|
|
||||||
await this.adminService.batchInsertSnpTyping(file);
|
|
||||||
}else if (div === 'mpt-result') { // MPT 분석결과(종합혈액화학검사결과서)
|
|
||||||
divName = 'MPT 분석결과(종합혈액화학검사결과서)';
|
|
||||||
await this.adminService.batchInsertMptResult(file);
|
|
||||||
}
|
|
||||||
// else if (div === 'animal-info') { // 소 정보 입력은 어디서 처리?
|
|
||||||
// divName = '소 정보 입력';
|
|
||||||
// return this.adminService.batchUploadAnimalInfo(file);
|
|
||||||
// }
|
|
||||||
return BaseResultDto.ok(`${divName} 파일 업로드 성공.\n데이터 입력 중...`, 'SUCCESS', 'OK');
|
|
||||||
} catch (error) {
|
|
||||||
return BaseResultDto.fail(`${divName} 파일 업로드 실패.\n${error.message}`, 'FAIL');
|
|
||||||
} finally {
|
|
||||||
await fs.unlink(file.path).catch(() => {}); // 파일 삭제
|
|
||||||
this.logger.log(`[batchUpload] ${divName} 파일 업로드 완료`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
|
||||||
import { AdminController } from "./admin.controller";
|
|
||||||
import { AdminService } from "./admin.service";
|
|
||||||
import { GenomeRequestModel } from "../genome/entities/genome-request.entity";
|
|
||||||
import { CowModel } from "../cow/entities/cow.entity";
|
|
||||||
import { GenomeTraitDetailModel } from "../genome/entities/genome-trait-detail.entity";
|
|
||||||
import { MptModel } from "src/mpt/entities/mpt.entity";
|
|
||||||
import { FarmModel } from "src/farm/entities/farm.entity";
|
|
||||||
import { GeneDetailModel } from "src/gene/entities/gene-detail.entity";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
TypeOrmModule.forFeature([
|
|
||||||
GenomeRequestModel,
|
|
||||||
CowModel,
|
|
||||||
GenomeTraitDetailModel,
|
|
||||||
MptModel,
|
|
||||||
FarmModel,
|
|
||||||
GeneDetailModel,
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
controllers: [AdminController],
|
|
||||||
providers: [AdminService],
|
|
||||||
exports: [AdminService],
|
|
||||||
})
|
|
||||||
export class AdminModule {}
|
|
||||||
@@ -1,847 +0,0 @@
|
|||||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
|
||||||
import { GenomeTraitDetailModel } from "src/genome/entities/genome-trait-detail.entity";
|
|
||||||
import { GenomeRequestModel } from "src/genome/entities/genome-request.entity";
|
|
||||||
import { CowModel } from "src/cow/entities/cow.entity";
|
|
||||||
import { Repository, IsNull, In } from "typeorm";
|
|
||||||
import { MptModel } from "src/mpt/entities/mpt.entity";
|
|
||||||
import { MptDto } from "src/mpt/dto/mpt.dto";
|
|
||||||
import { parseNumber, parseDate } from "src/common/utils";
|
|
||||||
import { ExcelUtil } from "src/common/excel/excel.util";
|
|
||||||
import { createReadStream } from "fs";
|
|
||||||
import * as readline from 'readline';
|
|
||||||
import { GeneDetailModel } from "src/gene/entities/gene-detail.entity";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AdminService {
|
|
||||||
private readonly logger = new Logger(AdminService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
// 유전체 분석 결과 Repository
|
|
||||||
@InjectRepository(GenomeTraitDetailModel)
|
|
||||||
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
|
|
||||||
|
|
||||||
// 유전체 분석 의뢰 Repository
|
|
||||||
@InjectRepository(GenomeRequestModel)
|
|
||||||
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
|
||||||
|
|
||||||
// 소 개체 Repository
|
|
||||||
@InjectRepository(CowModel)
|
|
||||||
private readonly cowRepository: Repository<CowModel>,
|
|
||||||
|
|
||||||
// 혈액화학검사 결과 Repository
|
|
||||||
@InjectRepository(MptModel)
|
|
||||||
private readonly mptRepository: Repository<MptModel>,
|
|
||||||
|
|
||||||
// 유전자 상세 정보 Repository
|
|
||||||
@InjectRepository(GeneDetailModel)
|
|
||||||
private readonly geneDetailRepository: Repository<GeneDetailModel>,
|
|
||||||
|
|
||||||
@Inject(ExcelUtil)
|
|
||||||
private readonly excelUtil: ExcelUtil,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
|
|
||||||
async batchInsertGenomeResult(file: Express.Multer.File) {
|
|
||||||
this.logger.log(`[배치업로드] 유전체 분석 결과 파일 처리 시작: ${file.originalname}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 엑셀파일 로드
|
|
||||||
const rawData = this.excelUtil.parseExcelData(file);
|
|
||||||
|
|
||||||
const headerRow = rawData[0];
|
|
||||||
const dataRows = rawData.slice(1);
|
|
||||||
|
|
||||||
// 헤더에서 형질명 추출 및 인덱스 매핑
|
|
||||||
// 컬럼 구조: A(samplename), B, C, D~DD(형질 데이터)
|
|
||||||
// 형질 패턴: "XXXXX", "XXXXX_표준화육종가", "XXXXX_백분율"
|
|
||||||
|
|
||||||
const traitMap = new Map<string, {
|
|
||||||
name: string;
|
|
||||||
valIndex: number | null;
|
|
||||||
ebvIndex: number | null;
|
|
||||||
percentileIndex: number | null;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// 헤더 분석 (인덱스 3부터 시작, D열부터)
|
|
||||||
for (let colIdx = 3; colIdx < headerRow.length && colIdx <= 107; colIdx++) {
|
|
||||||
const headerValue = headerRow[colIdx];
|
|
||||||
if (!headerValue || typeof headerValue !== 'string') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedHeader = headerValue.trim();
|
|
||||||
|
|
||||||
// 형질명 추출 (접미사 제거)
|
|
||||||
if (trimmedHeader.endsWith('_표준화육종가')) {
|
|
||||||
const traitName = trimmedHeader.replace('_표준화육종가', '');
|
|
||||||
if (!traitMap.has(traitName)) {
|
|
||||||
traitMap.set(traitName, { name: traitName, valIndex: null, ebvIndex: null, percentileIndex: null });
|
|
||||||
}
|
|
||||||
traitMap.get(traitName)!.ebvIndex = colIdx;
|
|
||||||
} else if (trimmedHeader.endsWith('_백분율')) {
|
|
||||||
const traitName = trimmedHeader.replace('_백분율', '');
|
|
||||||
if (!traitMap.has(traitName)) {
|
|
||||||
traitMap.set(traitName, { name: traitName, valIndex: null, ebvIndex: null, percentileIndex: null });
|
|
||||||
}
|
|
||||||
traitMap.get(traitName)!.percentileIndex = colIdx;
|
|
||||||
} else {
|
|
||||||
// 형질명 단독 (trait_val)
|
|
||||||
// 이미 존재하는 형질인지 확인 (접미사가 있는 경우 이미 추가됨)
|
|
||||||
if (!traitMap.has(trimmedHeader)) {
|
|
||||||
traitMap.set(trimmedHeader, { name: trimmedHeader, valIndex: null, ebvIndex: null, percentileIndex: null });
|
|
||||||
}
|
|
||||||
traitMap.get(trimmedHeader)!.valIndex = colIdx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`[배치업로드] 형질 개수: ${traitMap.size}개`);
|
|
||||||
|
|
||||||
// 데이터 행 처리
|
|
||||||
const traitDataArray: Array<{
|
|
||||||
cowId: string;
|
|
||||||
traitName: string;
|
|
||||||
traitVal: number | null;
|
|
||||||
traitEbv: number | null;
|
|
||||||
traitPercentile: number | null;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (let rowIdx = 0; rowIdx < dataRows.length; rowIdx++) {
|
|
||||||
const row = dataRows[rowIdx];
|
|
||||||
|
|
||||||
// A열(인덱스 0): cowId (samplename)
|
|
||||||
const cowId = row[0];
|
|
||||||
if (!cowId || typeof cowId !== 'string' || !cowId.trim()) {
|
|
||||||
this.logger.warn(`[배치업로드] ${rowIdx + 2}행: cowId가 없어 건너뜀`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedCowId = cowId.trim();
|
|
||||||
|
|
||||||
// 각 형질별로 데이터 추출
|
|
||||||
for (const [traitName, indices] of traitMap.entries()) {
|
|
||||||
const traitVal = indices.valIndex !== null && row[indices.valIndex] !== null && row[indices.valIndex] !== undefined
|
|
||||||
? parseNumber(row[indices.valIndex])
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const traitEbv = indices.ebvIndex !== null && row[indices.ebvIndex] !== null && row[indices.ebvIndex] !== undefined
|
|
||||||
? parseNumber(row[indices.ebvIndex])
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const traitPercentile = indices.percentileIndex !== null && row[indices.percentileIndex] !== null && row[indices.percentileIndex] !== undefined
|
|
||||||
? parseNumber(row[indices.percentileIndex])
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// 값이 하나라도 있으면 추가
|
|
||||||
if (traitVal !== null || traitEbv !== null || traitPercentile !== null) {
|
|
||||||
traitDataArray.push({
|
|
||||||
cowId: trimmedCowId,
|
|
||||||
traitName: traitName,
|
|
||||||
traitVal: traitVal,
|
|
||||||
traitEbv: traitEbv,
|
|
||||||
traitPercentile: traitPercentile,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`[배치업로드] 파싱 완료: ${traitDataArray.length}개 형질 데이터`);
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 3단계: Json Array 각 객체 fk_request_no 추가
|
|
||||||
// - fk_request_no 추가 방법 : 소 식별번호(cowId)로 조회 후 fk_request_no 추가
|
|
||||||
// ============================================
|
|
||||||
this.logger.log('[배치업로드] 3단계: fk_request_no 조회 중...');
|
|
||||||
|
|
||||||
// 고유한 cowId 목록 추출
|
|
||||||
const uniqueCowIds = [...new Set(traitDataArray.map(item => item.cowId))];
|
|
||||||
this.logger.log(`[배치업로드] 고유 cowId 개수: ${uniqueCowIds.length}개`);
|
|
||||||
|
|
||||||
// cowId별로 Cow와 GenomeRequest 조회
|
|
||||||
const cowIdToRequestNoMap = new Map<string, number | null>();
|
|
||||||
|
|
||||||
for (const cowId of uniqueCowIds) {
|
|
||||||
try {
|
|
||||||
// Step 1: cowId로 개체 조회
|
|
||||||
const cow = await this.cowRepository.findOne({
|
|
||||||
where: { cowId: cowId, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!cow) {
|
|
||||||
this.logger.warn(`[배치업로드] cowId "${cowId}"에 해당하는 개체를 찾을 수 없습니다.`);
|
|
||||||
cowIdToRequestNoMap.set(cowId, null);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: 해당 개체의 최신 분석 의뢰 조회
|
|
||||||
const request = await this.genomeRequestRepository.findOne({
|
|
||||||
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
|
|
||||||
order: { requestDt: 'DESC', regDt: 'DESC' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!request) {
|
|
||||||
this.logger.warn(`[배치업로드] cowId "${cowId}"에 해당하는 유전체 분석 의뢰를 찾을 수 없습니다.`);
|
|
||||||
cowIdToRequestNoMap.set(cowId, null);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
cowIdToRequestNoMap.set(cowId, request.pkRequestNo);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`[배치업로드] cowId "${cowId}" 조회 중 오류: ${error.message}`);
|
|
||||||
cowIdToRequestNoMap.set(cowId, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fk_request_no 추가
|
|
||||||
const traitDataWithRequestNo = traitDataArray.map(item => {
|
|
||||||
const fkRequestNo = cowIdToRequestNoMap.get(item.cowId);
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
fkRequestNo: fkRequestNo,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// fk_request_no가 없는 데이터 필터링
|
|
||||||
const validTraitData = traitDataWithRequestNo.filter(item => item.fkRequestNo !== null);
|
|
||||||
const invalidCount = traitDataWithRequestNo.length - validTraitData.length;
|
|
||||||
|
|
||||||
if (invalidCount > 0) {
|
|
||||||
this.logger.warn(`[배치업로드] fk_request_no가 없는 데이터 ${invalidCount}건 제외`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`[배치업로드] 유효한 형질 데이터: ${validTraitData.length}건`);
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 4단계: 데이터 DB Insert : 비동기 batchInsert(upsert)
|
|
||||||
// ============================================
|
|
||||||
this.logger.log('[배치업로드] 4단계: DB 배치 삽입(upsert) 중...');
|
|
||||||
|
|
||||||
let successCount = 0;
|
|
||||||
let errorCount = 0;
|
|
||||||
|
|
||||||
// 배치 크기 설정 (한 번에 처리할 데이터 수)
|
|
||||||
const BATCH_SIZE = 100;
|
|
||||||
|
|
||||||
for (let i = 0; i < validTraitData.length; i += BATCH_SIZE) {
|
|
||||||
const batch = validTraitData.slice(i, i + BATCH_SIZE);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 각 배치를 upsert 처리
|
|
||||||
const insertPromises = batch.map(async (item) => {
|
|
||||||
try {
|
|
||||||
// 기존 데이터 조회 (cowId와 traitName 기준)
|
|
||||||
const existing = await this.genomeTraitDetailRepository.findOne({
|
|
||||||
where: {
|
|
||||||
cowId: item.cowId,
|
|
||||||
traitName: item.traitName,
|
|
||||||
delDt: IsNull(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
// 업데이트
|
|
||||||
existing.fkRequestNo = item.fkRequestNo!;
|
|
||||||
existing.traitVal = item.traitVal;
|
|
||||||
existing.traitEbv = item.traitEbv;
|
|
||||||
existing.traitPercentile = item.traitPercentile;
|
|
||||||
await this.genomeTraitDetailRepository.save(existing);
|
|
||||||
} else {
|
|
||||||
// 삽입
|
|
||||||
const newTraitDetail = this.genomeTraitDetailRepository.create({
|
|
||||||
fkRequestNo: item.fkRequestNo!,
|
|
||||||
cowId: item.cowId,
|
|
||||||
traitName: item.traitName,
|
|
||||||
traitVal: item.traitVal,
|
|
||||||
traitEbv: item.traitEbv,
|
|
||||||
traitPercentile: item.traitPercentile,
|
|
||||||
});
|
|
||||||
await this.genomeTraitDetailRepository.save(newTraitDetail);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, traitName: ${item.traitName}): ${error.message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.all(insertPromises);
|
|
||||||
successCount += results.filter(r => r === true).length;
|
|
||||||
errorCount += results.filter(r => r === false).length;
|
|
||||||
|
|
||||||
this.logger.log(`[배치업로드] 진행률: ${Math.min(i + BATCH_SIZE, validTraitData.length)}/${validTraitData.length}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`[배치업로드] 배치 처리 중 오류: ${error.message}`);
|
|
||||||
errorCount += batch.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 5단계: 결과 로깅
|
|
||||||
// ============================================
|
|
||||||
this.logger.log(`[배치업로드] 처리 완료`);
|
|
||||||
this.logger.log(`[배치업로드] 성공: ${successCount}건, 실패: ${errorCount}건, 제외: ${invalidCount}건`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
total: traitDataArray.length,
|
|
||||||
valid: validTraitData.length,
|
|
||||||
successCount: successCount,
|
|
||||||
errorCount: errorCount,
|
|
||||||
excludedCount: invalidCount,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`[배치업로드] 처리 중 오류 발생: ${error.message}`);
|
|
||||||
this.logger.error(error.stack);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async batchInsertSnpTyping(file: Express.Multer.File) {
|
|
||||||
this.logger.log(`[배치업로드] SNP 타이핑 결과 파일 처리 시작: ${file.originalname}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// ============================================
|
|
||||||
// 1단계: 텍스트 파일 스트림 읽기
|
|
||||||
// ============================================
|
|
||||||
this.logger.log('[배치업로드] 1단계: 텍스트 파일 스트림 읽기 중...');
|
|
||||||
|
|
||||||
const stream = createReadStream(file.path, {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
highWaterMark: 64 * 1024, // 64KB 버퍼
|
|
||||||
});
|
|
||||||
|
|
||||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
||||||
|
|
||||||
let section: 'NONE' | 'HEADER' | 'DATA_WAIT_HEADER' | 'DATA' = 'NONE';
|
|
||||||
let dataHeader: string[] | null = null;
|
|
||||||
|
|
||||||
// 컬럼 인덱스 매핑
|
|
||||||
const COLUMN_MAPPING = {
|
|
||||||
SNP_NAME: 'SNP Name',
|
|
||||||
SAMPLE_ID: 'Sample ID',
|
|
||||||
ALLELE1: 'Allele1 - Top',
|
|
||||||
ALLELE2: 'Allele2 - Top',
|
|
||||||
CHR: 'Chr',
|
|
||||||
POSITION: 'Position',
|
|
||||||
SNP: 'SNP',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컬럼 인덱스 캐시 (헤더 읽은 후 한 번만 계산)
|
|
||||||
let columnIndices: {
|
|
||||||
snpNameIdx: number;
|
|
||||||
sampleIdIdx: number;
|
|
||||||
allele1Idx: number;
|
|
||||||
allele2Idx: number;
|
|
||||||
chrIdx: number;
|
|
||||||
positionIdx: number;
|
|
||||||
snpIdx: number;
|
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 2단계: 스트림 방식으로 파일 파싱 및 배치 단위 DB 저장
|
|
||||||
// ============================================
|
|
||||||
this.logger.log('[배치업로드] 2단계: 스트림 방식 파일 파싱 및 배치 저장 중...');
|
|
||||||
|
|
||||||
// 배치 버퍼 (메모리에 일정 크기만 유지)
|
|
||||||
const BATCH_SIZE = 5000; // 배치 크기 (메모리 사용량 제어)
|
|
||||||
const batchBuffer: Array<{
|
|
||||||
cowId: string;
|
|
||||||
snpName: string;
|
|
||||||
chromosome: string | null;
|
|
||||||
position: string | null;
|
|
||||||
snpType: string | null;
|
|
||||||
allele1: string | null;
|
|
||||||
allele2: string | null;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
let totalRows = 0;
|
|
||||||
let successCount = 0;
|
|
||||||
let errorCount = 0;
|
|
||||||
let skippedCount = 0;
|
|
||||||
|
|
||||||
// 배치 저장 함수
|
|
||||||
const flushBatch = async () => {
|
|
||||||
if (batchBuffer.length === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 배치 단위로 upsert 처리
|
|
||||||
const insertPromises = batchBuffer.map(async (item) => {
|
|
||||||
try {
|
|
||||||
// 기존 데이터 조회 (cowId와 snpName 기준)
|
|
||||||
const existing = await this.geneDetailRepository.findOne({
|
|
||||||
where: {
|
|
||||||
cowId: item.cowId,
|
|
||||||
snpName: item.snpName,
|
|
||||||
delDt: IsNull(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
// 업데이트
|
|
||||||
existing.fkRequestNo = null; // cowId 조회 제거로 null 처리
|
|
||||||
existing.chromosome = item.chromosome;
|
|
||||||
existing.position = item.position;
|
|
||||||
existing.snpType = item.snpType;
|
|
||||||
existing.allele1 = item.allele1;
|
|
||||||
existing.allele2 = item.allele2;
|
|
||||||
await this.geneDetailRepository.save(existing);
|
|
||||||
} else {
|
|
||||||
// 삽입
|
|
||||||
const newGeneDetail = this.geneDetailRepository.create({
|
|
||||||
cowId: item.cowId,
|
|
||||||
snpName: item.snpName,
|
|
||||||
fkRequestNo: null, // cowId 조회 제거로 null 처리
|
|
||||||
chromosome: item.chromosome,
|
|
||||||
position: item.position,
|
|
||||||
snpType: item.snpType,
|
|
||||||
allele1: item.allele1,
|
|
||||||
allele2: item.allele2,
|
|
||||||
});
|
|
||||||
await this.geneDetailRepository.save(newGeneDetail);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, snpName: ${item.snpName}): ${error.message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.all(insertPromises);
|
|
||||||
const batchSuccess = results.filter(r => r === true).length;
|
|
||||||
const batchError = results.filter(r => r === false).length;
|
|
||||||
|
|
||||||
successCount += batchSuccess;
|
|
||||||
errorCount += batchError;
|
|
||||||
|
|
||||||
this.logger.log(`[배치업로드] 배치 저장 완료: ${batchSuccess}건 성공, ${batchError}건 실패 (총 처리: ${totalRows}건)`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`[배치업로드] 배치 저장 중 오류: ${error.message}`);
|
|
||||||
errorCount += batchBuffer.length;
|
|
||||||
} finally {
|
|
||||||
// 배치 버퍼 초기화 (메모리 해제)
|
|
||||||
batchBuffer.length = 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 파일 라인별 처리
|
|
||||||
for await (const rawLine of rl) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!line) continue;
|
|
||||||
|
|
||||||
// 섹션 전환
|
|
||||||
if (line === '[Header]') {
|
|
||||||
section = 'HEADER';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (line === '[Data]') {
|
|
||||||
section = 'DATA_WAIT_HEADER';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// [Header] 섹션은 무시
|
|
||||||
if (section === 'HEADER') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// [Data] 다음 줄 = 컬럼 헤더
|
|
||||||
if (section === 'DATA_WAIT_HEADER') {
|
|
||||||
dataHeader = rawLine.split('\t').map(s => s.trim());
|
|
||||||
if (dataHeader.length < 2) {
|
|
||||||
throw new Error('[Data] 헤더 라인이 비정상입니다. (탭 구분 여부 확인 필요)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컬럼 인덱스 계산 및 캐시
|
|
||||||
columnIndices = {
|
|
||||||
snpNameIdx: dataHeader.indexOf(COLUMN_MAPPING.SNP_NAME),
|
|
||||||
sampleIdIdx: dataHeader.indexOf(COLUMN_MAPPING.SAMPLE_ID),
|
|
||||||
allele1Idx: dataHeader.indexOf(COLUMN_MAPPING.ALLELE1),
|
|
||||||
allele2Idx: dataHeader.indexOf(COLUMN_MAPPING.ALLELE2),
|
|
||||||
chrIdx: dataHeader.indexOf(COLUMN_MAPPING.CHR),
|
|
||||||
positionIdx: dataHeader.indexOf(COLUMN_MAPPING.POSITION),
|
|
||||||
snpIdx: dataHeader.indexOf(COLUMN_MAPPING.SNP),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (columnIndices.snpNameIdx === -1 || columnIndices.sampleIdIdx === -1) {
|
|
||||||
throw new Error(`필수 컬럼이 없습니다. (SNP Name: ${columnIndices.snpNameIdx}, Sample ID: ${columnIndices.sampleIdIdx})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`[배치업로드] Data 헤더 컬럼 수: ${dataHeader.length}`);
|
|
||||||
this.logger.log(`[배치업로드] 컬럼 인덱스 - SNP Name: ${columnIndices.snpNameIdx}, Sample ID: ${columnIndices.sampleIdIdx}, Chr: ${columnIndices.chrIdx}, Position: ${columnIndices.positionIdx}, SNP: ${columnIndices.snpIdx}`);
|
|
||||||
|
|
||||||
section = 'DATA';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 데이터 라인 처리
|
|
||||||
if (section === 'DATA') {
|
|
||||||
if (!dataHeader || !columnIndices) {
|
|
||||||
throw new Error('dataHeader 또는 columnIndices가 초기화되지 않았습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = rawLine.split('\t');
|
|
||||||
|
|
||||||
// 컬럼 수 불일치 시 스킵
|
|
||||||
if (values.length !== dataHeader.length) {
|
|
||||||
this.logger.warn(
|
|
||||||
`[배치업로드] 컬럼 수 불일치: header=${dataHeader.length}, values=${values.length} / line=${rawLine.slice(0, 120)}...`,
|
|
||||||
);
|
|
||||||
skippedCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 필수 필드 검증
|
|
||||||
const cowId = values[columnIndices.sampleIdIdx]?.trim();
|
|
||||||
const snpName = values[columnIndices.snpNameIdx]?.trim();
|
|
||||||
|
|
||||||
if (!cowId || !snpName) {
|
|
||||||
this.logger.warn(`[배치업로드] 필수 필드 누락: cowId=${cowId}, snpName=${snpName}`);
|
|
||||||
skippedCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 배치 버퍼에 추가
|
|
||||||
batchBuffer.push({
|
|
||||||
cowId,
|
|
||||||
snpName,
|
|
||||||
chromosome: columnIndices.chrIdx !== -1 ? (values[columnIndices.chrIdx]?.trim() || null) : null,
|
|
||||||
position: columnIndices.positionIdx !== -1 ? (values[columnIndices.positionIdx]?.trim() || null) : null,
|
|
||||||
snpType: columnIndices.snpIdx !== -1 ? (values[columnIndices.snpIdx]?.trim() || null) : null,
|
|
||||||
allele1: columnIndices.allele1Idx !== -1 ? (values[columnIndices.allele1Idx]?.trim() || null) : null,
|
|
||||||
allele2: columnIndices.allele2Idx !== -1 ? (values[columnIndices.allele2Idx]?.trim() || null) : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
totalRows++;
|
|
||||||
|
|
||||||
// 배치 크기에 도달하면 즉시 DB에 저장
|
|
||||||
if (batchBuffer.length >= BATCH_SIZE) {
|
|
||||||
await flushBatch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 마지막 남은 배치 처리
|
|
||||||
await flushBatch();
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 3단계: 결과 로깅
|
|
||||||
// ============================================
|
|
||||||
this.logger.log(`[배치업로드] 처리 완료`);
|
|
||||||
this.logger.log(`[배치업로드] 총 처리: ${totalRows}건, 성공: ${successCount}건, 실패: ${errorCount}건, 스킵: ${skippedCount}건`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
total: totalRows,
|
|
||||||
successCount: successCount,
|
|
||||||
errorCount: errorCount,
|
|
||||||
skippedCount: skippedCount,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`[배치업로드] 처리 중 오류 발생: ${error.message}`);
|
|
||||||
this.logger.error(error.stack);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 혈액화학검사 결과 배치 삽입
|
|
||||||
* @param file - 파일
|
|
||||||
* @returns 성공 여부
|
|
||||||
*/
|
|
||||||
async batchInsertMptResult(file: Express.Multer.File) {
|
|
||||||
this.logger.log(`[배치업로드] 혈액화학검사 결과 파일 처리 시작: ${file.originalname}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// ============================================
|
|
||||||
// 1단계: 엑셀파일 로드
|
|
||||||
// ============================================
|
|
||||||
this.logger.log('[배치업로드] 1단계: 엑셀 파일 로드 중...');
|
|
||||||
const rawData = this.excelUtil.parseExcelData(file);
|
|
||||||
|
|
||||||
if (rawData.length < 6) {
|
|
||||||
throw new Error('엑셀 파일에 데이터가 부족합니다. (최소 6행 필요: 헤더 5행 + 데이터 1행)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 2단계: 데이터 파싱 및 검증
|
|
||||||
// ============================================
|
|
||||||
this.logger.log('[배치업로드] 2단계: 데이터 파싱 중...');
|
|
||||||
|
|
||||||
// MptDto를 기반으로 하되, 파싱 단계에서는 null 허용
|
|
||||||
const mptDataArray: Array<Partial<MptDto> & {
|
|
||||||
cowId: string;
|
|
||||||
fkFarmNo: number | null;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
// 5행부터 데이터 시작 (인덱스 5)
|
|
||||||
for (let rowIdx = 5; rowIdx < rawData.length; rowIdx++) {
|
|
||||||
const row = rawData[rowIdx];
|
|
||||||
|
|
||||||
// cowId 검증 (A열, 인덱스 0)
|
|
||||||
const cowId = row[0];
|
|
||||||
if (!cowId || typeof cowId !== 'string' || !cowId.trim()) {
|
|
||||||
this.logger.warn(`[배치업로드] ${rowIdx + 1}행: cowId가 없어 건너뜀`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedCowId = cowId.trim();
|
|
||||||
|
|
||||||
// cowShortNo 추출 (길이 검증)
|
|
||||||
let cowShortNo: string;
|
|
||||||
if (trimmedCowId.length >= 11) {
|
|
||||||
cowShortNo = trimmedCowId.slice(7, 11);
|
|
||||||
} else {
|
|
||||||
this.logger.warn(`[배치업로드] ${rowIdx + 1}행: cowId 길이가 부족하여 cowShortNo 추출 실패 (cowId: ${trimmedCowId})`);
|
|
||||||
cowShortNo = trimmedCowId.slice(-4) || trimmedCowId; // 최소한 뒤 4자리 또는 전체
|
|
||||||
}
|
|
||||||
|
|
||||||
// 날짜 파싱 (C열, 인덱스 2)
|
|
||||||
const testDt = parseDate(row[2]);
|
|
||||||
|
|
||||||
// 숫자 필드 파싱
|
|
||||||
const monthAge = parseNumber(row[3]);
|
|
||||||
const milkYield = parseNumber(row[4]);
|
|
||||||
const parity = parseNumber(row[5]);
|
|
||||||
const glucose = parseNumber(row[6]);
|
|
||||||
const cholesterol = parseNumber(row[7]);
|
|
||||||
const nefa = parseNumber(row[8]);
|
|
||||||
const bcs = parseNumber(row[9]);
|
|
||||||
const totalProtein = parseNumber(row[10]);
|
|
||||||
const albumin = parseNumber(row[11]);
|
|
||||||
const globulin = parseNumber(row[12]);
|
|
||||||
const agRatio = parseNumber(row[13]);
|
|
||||||
const bun = parseNumber(row[14]);
|
|
||||||
const ast = parseNumber(row[15]);
|
|
||||||
const ggt = parseNumber(row[16]);
|
|
||||||
const fattyLiverIdx = parseNumber(row[17]);
|
|
||||||
const calcium = parseNumber(row[18]);
|
|
||||||
const phosphorus = parseNumber(row[19]);
|
|
||||||
const caPRatio = parseNumber(row[20]);
|
|
||||||
const magnesium = parseNumber(row[21]);
|
|
||||||
const creatine = parseNumber(row[22]);
|
|
||||||
|
|
||||||
mptDataArray.push({
|
|
||||||
cowId: `KOR${trimmedCowId}`,
|
|
||||||
cowShortNo,
|
|
||||||
fkFarmNo: null, // 3단계에서 채움
|
|
||||||
testDt,
|
|
||||||
monthAge,
|
|
||||||
milkYield,
|
|
||||||
parity,
|
|
||||||
glucose,
|
|
||||||
cholesterol,
|
|
||||||
nefa,
|
|
||||||
bcs,
|
|
||||||
totalProtein,
|
|
||||||
albumin,
|
|
||||||
globulin,
|
|
||||||
agRatio,
|
|
||||||
bun,
|
|
||||||
ast,
|
|
||||||
ggt,
|
|
||||||
fattyLiverIdx,
|
|
||||||
calcium,
|
|
||||||
phosphorus,
|
|
||||||
caPRatio,
|
|
||||||
magnesium,
|
|
||||||
creatine,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`[배치업로드] 파싱 완료: ${mptDataArray.length}건`);
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 3단계: cowId별 fkFarmNo 조회
|
|
||||||
// ============================================
|
|
||||||
this.logger.log('[배치업로드] 3단계: fkFarmNo 조회 중...');
|
|
||||||
|
|
||||||
// 고유한 cowId 목록 추출
|
|
||||||
const uniqueCowIds = [...new Set(mptDataArray.map(item => item.cowId))];
|
|
||||||
this.logger.log(`[배치업로드] 고유 cowId 개수: ${uniqueCowIds.length}개`);
|
|
||||||
|
|
||||||
// cowId별 fkFarmNo 매핑 생성 (일괄 조회로 성능 최적화)
|
|
||||||
const cowIdToFarmNoMap = new Map<string, number | null>();
|
|
||||||
|
|
||||||
if (uniqueCowIds.length > 0) {
|
|
||||||
try {
|
|
||||||
// 모든 cowId를 한 번에 조회 (IN 쿼리)
|
|
||||||
const cows = await this.cowRepository.find({
|
|
||||||
where: {
|
|
||||||
cowId: In(uniqueCowIds),
|
|
||||||
delDt: IsNull(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 조회된 결과를 Map으로 변환
|
|
||||||
const foundCowIdSet = new Set<string>();
|
|
||||||
for (const cow of cows) {
|
|
||||||
if (cow.cowId) {
|
|
||||||
cowIdToFarmNoMap.set(cow.cowId, cow.fkFarmNo || null);
|
|
||||||
foundCowIdSet.add(cow.cowId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 조회되지 않은 cowId는 null로 설정
|
|
||||||
for (const cowId of uniqueCowIds) {
|
|
||||||
if (!foundCowIdSet.has(cowId)) {
|
|
||||||
this.logger.warn(`[배치업로드] cowId "${cowId}"에 해당하는 개체를 찾을 수 없습니다.`);
|
|
||||||
cowIdToFarmNoMap.set(cowId, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`[배치업로드] 조회 성공: ${cows.length}/${uniqueCowIds.length}개`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`[배치업로드] cowId 일괄 조회 중 오류: ${error.message}`);
|
|
||||||
// 에러 발생 시 모든 cowId를 null로 설정
|
|
||||||
for (const cowId of uniqueCowIds) {
|
|
||||||
cowIdToFarmNoMap.set(cowId, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fkFarmNo 추가
|
|
||||||
const mptDataWithFarmNo = mptDataArray.map(item => ({
|
|
||||||
...item,
|
|
||||||
fkFarmNo: cowIdToFarmNoMap.get(item.cowId) || null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// fkFarmNo가 있는 유효한 데이터만 필터링
|
|
||||||
const validMptData = mptDataWithFarmNo.filter(item => item.fkFarmNo !== null);
|
|
||||||
const invalidCount = mptDataWithFarmNo.length - validMptData.length;
|
|
||||||
|
|
||||||
if (invalidCount > 0) {
|
|
||||||
this.logger.warn(`[배치업로드] fkFarmNo가 없는 데이터 ${invalidCount}건 제외`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`[배치업로드] 유효한 MPT 데이터: ${validMptData.length}건`);
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 4단계: 데이터 DB Insert (Upsert)
|
|
||||||
// ============================================
|
|
||||||
this.logger.log('[배치업로드] 4단계: DB 배치 삽입(upsert) 중...');
|
|
||||||
|
|
||||||
let successCount = 0;
|
|
||||||
let errorCount = 0;
|
|
||||||
|
|
||||||
// 배치 크기 설정 (한 번에 처리할 데이터 수)
|
|
||||||
const BATCH_SIZE = 100;
|
|
||||||
|
|
||||||
for (let i = 0; i < validMptData.length; i += BATCH_SIZE) {
|
|
||||||
const batch = validMptData.slice(i, i + BATCH_SIZE);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 각 배치를 upsert 처리
|
|
||||||
const insertPromises = batch.map(async (item) => {
|
|
||||||
try {
|
|
||||||
// 기존 데이터 조회 (cowId와 testDt 기준)
|
|
||||||
const existing = await this.mptRepository.findOne({
|
|
||||||
where: {
|
|
||||||
cowId: item.cowId,
|
|
||||||
testDt: item.testDt,
|
|
||||||
delDt: IsNull(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
// 업데이트
|
|
||||||
existing.fkFarmNo = item.fkFarmNo!;
|
|
||||||
existing.cowShortNo = item.cowShortNo;
|
|
||||||
existing.monthAge = item.monthAge;
|
|
||||||
existing.milkYield = item.milkYield;
|
|
||||||
existing.parity = item.parity;
|
|
||||||
existing.glucose = item.glucose;
|
|
||||||
existing.cholesterol = item.cholesterol;
|
|
||||||
existing.nefa = item.nefa;
|
|
||||||
existing.bcs = item.bcs;
|
|
||||||
existing.totalProtein = item.totalProtein;
|
|
||||||
existing.albumin = item.albumin;
|
|
||||||
existing.globulin = item.globulin;
|
|
||||||
existing.agRatio = item.agRatio;
|
|
||||||
existing.bun = item.bun;
|
|
||||||
existing.ast = item.ast;
|
|
||||||
existing.ggt = item.ggt;
|
|
||||||
existing.fattyLiverIdx = item.fattyLiverIdx;
|
|
||||||
existing.calcium = item.calcium;
|
|
||||||
existing.phosphorus = item.phosphorus;
|
|
||||||
existing.caPRatio = item.caPRatio;
|
|
||||||
existing.magnesium = item.magnesium;
|
|
||||||
existing.creatine = item.creatine;
|
|
||||||
await this.mptRepository.save(existing);
|
|
||||||
} else {
|
|
||||||
// 삽입
|
|
||||||
const newMpt = this.mptRepository.create({
|
|
||||||
cowId: item.cowId,
|
|
||||||
cowShortNo: item.cowShortNo,
|
|
||||||
fkFarmNo: item.fkFarmNo!,
|
|
||||||
testDt: item.testDt,
|
|
||||||
monthAge: item.monthAge,
|
|
||||||
milkYield: item.milkYield,
|
|
||||||
parity: item.parity,
|
|
||||||
glucose: item.glucose,
|
|
||||||
cholesterol: item.cholesterol,
|
|
||||||
nefa: item.nefa,
|
|
||||||
bcs: item.bcs,
|
|
||||||
totalProtein: item.totalProtein,
|
|
||||||
albumin: item.albumin,
|
|
||||||
globulin: item.globulin,
|
|
||||||
agRatio: item.agRatio,
|
|
||||||
bun: item.bun,
|
|
||||||
ast: item.ast,
|
|
||||||
ggt: item.ggt,
|
|
||||||
fattyLiverIdx: item.fattyLiverIdx,
|
|
||||||
calcium: item.calcium,
|
|
||||||
phosphorus: item.phosphorus,
|
|
||||||
caPRatio: item.caPRatio,
|
|
||||||
magnesium: item.magnesium,
|
|
||||||
creatine: item.creatine,
|
|
||||||
});
|
|
||||||
await this.mptRepository.save(newMpt);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, ${error.message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.all(insertPromises);
|
|
||||||
successCount += results.filter(r => r === true).length;
|
|
||||||
errorCount += results.filter(r => r === false).length;
|
|
||||||
|
|
||||||
this.logger.log(`[배치업로드] 진행률: ${Math.min(i + BATCH_SIZE, validMptData.length)}/${validMptData.length}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`[배치업로드] 배치 처리 중 오류: ${error.message}`);
|
|
||||||
errorCount += batch.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 5단계: 결과 로깅
|
|
||||||
// ============================================
|
|
||||||
this.logger.log(`[배치업로드] 처리 완료`);
|
|
||||||
this.logger.log(`[배치업로드] 성공: ${successCount}건, 실패: ${errorCount}건, 제외: ${invalidCount}건`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
total: mptDataArray.length,
|
|
||||||
valid: validMptData.length,
|
|
||||||
successCount: successCount,
|
|
||||||
errorCount: errorCount,
|
|
||||||
excludedCount: invalidCount,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`[배치업로드] 처리 중 오류 발생: ${error.message}`);
|
|
||||||
this.logger.error(error.stack);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -7,16 +7,18 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
import { CommonModule } from './common/common.module';
|
import { CommonModule } from './common/common.module';
|
||||||
import { SharedModule } from './shared/shared.module';
|
import { SharedModule } from './shared/shared.module';
|
||||||
|
import { HelpModule } from './help/help.module';
|
||||||
import { JwtModule } from './common/jwt/jwt.module';
|
import { JwtModule } from './common/jwt/jwt.module';
|
||||||
import { JwtStrategy } from './common/jwt/jwt.strategy';
|
import { JwtStrategy } from './common/jwt/jwt.strategy';
|
||||||
|
|
||||||
|
// 새로 생성한 모듈들
|
||||||
import { FarmModule } from './farm/farm.module';
|
import { FarmModule } from './farm/farm.module';
|
||||||
import { CowModule } from './cow/cow.module';
|
import { CowModule } from './cow/cow.module';
|
||||||
import { GenomeModule } from './genome/genome.module';
|
import { GenomeModule } from './genome/genome.module';
|
||||||
import { MptModule } from './mpt/mpt.module';
|
import { MptModule } from './mpt/mpt.module';
|
||||||
|
import { DashboardModule } from './dashboard/dashboard.module';
|
||||||
import { GeneModule } from './gene/gene.module';
|
import { GeneModule } from './gene/gene.module';
|
||||||
import { SystemModule } from './system/system.module';
|
import { SystemModule } from './system/system.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
|
||||||
import { ExcelModule } from './common/excel/excel.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -54,13 +56,11 @@ import { ExcelModule } from './common/excel/excel.module';
|
|||||||
GenomeModule,
|
GenomeModule,
|
||||||
GeneModule,
|
GeneModule,
|
||||||
MptModule,
|
MptModule,
|
||||||
|
DashboardModule,
|
||||||
// 관리자 모듈
|
|
||||||
AdminModule,
|
|
||||||
|
|
||||||
// 기타
|
// 기타
|
||||||
|
HelpModule,
|
||||||
SystemModule,
|
SystemModule,
|
||||||
ExcelModule,
|
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService, JwtStrategy],
|
providers: [AppService, JwtStrategy],
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { UserModel } from '../user/entities/user.entity';
|
import { UserModel } from '../user/entities/user.entity';
|
||||||
import { FarmModel } from '../farm/entities/farm.entity';
|
|
||||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
|
||||||
import { JwtModule } from 'src/common/jwt/jwt.module';
|
import { JwtModule } from 'src/common/jwt/jwt.module';
|
||||||
import { EmailModule } from 'src/shared/email/email.module';
|
import { EmailModule } from 'src/shared/email/email.module';
|
||||||
import { VerificationModule } from 'src/shared/verification/verification.module';
|
import { VerificationModule } from 'src/shared/verification/verification.module';
|
||||||
@@ -15,7 +13,7 @@ import { VerificationModule } from 'src/shared/verification/verification.module'
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([UserModel, FarmModel, GenomeRequestModel]),
|
TypeOrmModule.forFeature([UserModel]),
|
||||||
JwtModule,
|
JwtModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
VerificationModule,
|
VerificationModule,
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { UserModel } from '../user/entities/user.entity';
|
import { UserModel } from '../user/entities/user.entity';
|
||||||
import { FarmModel } from '../farm/entities/farm.entity';
|
import { Repository } from 'typeorm';
|
||||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
|
||||||
import { Repository, IsNull } from 'typeorm';
|
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { LoginResponseDto } from './dto/login-response.dto';
|
import { LoginResponseDto } from './dto/login-response.dto';
|
||||||
import { SignupDto } from './dto/signup.dto';
|
import { SignupDto } from './dto/signup.dto';
|
||||||
@@ -38,10 +36,6 @@ export class AuthService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(UserModel)
|
@InjectRepository(UserModel)
|
||||||
private readonly userRepository: Repository<UserModel>,
|
private readonly userRepository: Repository<UserModel>,
|
||||||
@InjectRepository(FarmModel)
|
|
||||||
private readonly farmRepository: Repository<FarmModel>,
|
|
||||||
@InjectRepository(GenomeRequestModel)
|
|
||||||
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
|
||||||
private readonly emailService: EmailService,
|
private readonly emailService: EmailService,
|
||||||
private readonly verificationService: VerificationService,
|
private readonly verificationService: VerificationService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
@@ -65,8 +59,6 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isPasswordValid = await bcrypt.compare(userPassword, user.userPw);
|
const isPasswordValid = await bcrypt.compare(userPassword, user.userPw);
|
||||||
const inputHash = await bcrypt.hash(userPassword, 10);
|
|
||||||
this.logger.log(`[DEBUG] 입력 해시: ${inputHash}, DB 해시: ${user.userPw}`);
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
this.logger.warn(`[LOGIN] 비밀번호 불일치 - userId: ${userId}`);
|
this.logger.warn(`[LOGIN] 비밀번호 불일치 - userId: ${userId}`);
|
||||||
throw new UnauthorizedException('아이디 또는 비밀번호가 틀렸습니다');
|
throw new UnauthorizedException('아이디 또는 비밀번호가 틀렸습니다');
|
||||||
@@ -83,15 +75,18 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const accessToken = this.jwtService.sign(payload as any);
|
const accessToken = this.jwtService.sign(payload as any);
|
||||||
|
const refreshOptions = {
|
||||||
|
secret: this.configService.get<string>('JWT_REFRESH_SECRET')!,
|
||||||
|
expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') || '7d',
|
||||||
|
};
|
||||||
|
const refreshToken = this.jwtService.sign(payload as any, refreshOptions as any);
|
||||||
|
|
||||||
// 최근 검사 년도 조회
|
this.logger.log(`[LOGIN] 로그인 성공 - userId: ${userId}`);
|
||||||
const defaultAnalysisYear = await this.getDefaultAnalysisYear(user.pkUserNo);
|
|
||||||
|
|
||||||
this.logger.log(`[LOGIN] 로그인 성공 - userId: ${userId}, defaultAnalysisYear: ${defaultAnalysisYear}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: '로그인 성공',
|
message: '로그인 성공',
|
||||||
accessToken,
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
user: {
|
user: {
|
||||||
pkUserNo: user.pkUserNo,
|
pkUserNo: user.pkUserNo,
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
@@ -99,61 +94,9 @@ export class AuthService {
|
|||||||
userEmail: user.userEmail,
|
userEmail: user.userEmail,
|
||||||
userRole: user.userRole || 'USER',
|
userRole: user.userRole || 'USER',
|
||||||
},
|
},
|
||||||
defaultAnalysisYear,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자의 최근 검사 년도 조회
|
|
||||||
* @param userNo - 사용자 번호
|
|
||||||
* @returns 최근 검사 년도 (없으면 현재 년도)
|
|
||||||
*/
|
|
||||||
private async getDefaultAnalysisYear(userNo: number): Promise<number> {
|
|
||||||
try {
|
|
||||||
// 1. 사용자의 농장 번호 조회
|
|
||||||
const farm = await this.farmRepository.findOne({
|
|
||||||
where: { fkUserNo: userNo, delDt: IsNull() },
|
|
||||||
select: ['pkFarmNo'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!farm) {
|
|
||||||
this.logger.log(`[getDefaultAnalysisYear] userNo: ${userNo}, No farm found, returning current year`);
|
|
||||||
return new Date().getFullYear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 농장의 검사 이력에서 최신 날짜 조회
|
|
||||||
const result = await this.genomeRequestRepository
|
|
||||||
.createQueryBuilder('request')
|
|
||||||
.select('MAX(request.chipReportDt)', 'maxChipDt')
|
|
||||||
.addSelect('MAX(request.msReportDt)', 'maxMsDt')
|
|
||||||
.where('request.fkFarmNo = :farmNo', { farmNo: farm.pkFarmNo })
|
|
||||||
.andWhere('request.delDt IS NULL')
|
|
||||||
.getRawOne();
|
|
||||||
|
|
||||||
const maxChipDt = result?.maxChipDt ? new Date(result.maxChipDt) : null;
|
|
||||||
const maxMsDt = result?.maxMsDt ? new Date(result.maxMsDt) : null;
|
|
||||||
|
|
||||||
// 둘 중 최신 날짜 선택
|
|
||||||
let latestDate: Date | null = null;
|
|
||||||
if (maxChipDt && maxMsDt) {
|
|
||||||
latestDate = maxChipDt > maxMsDt ? maxChipDt : maxMsDt;
|
|
||||||
} else if (maxChipDt) {
|
|
||||||
latestDate = maxChipDt;
|
|
||||||
} else if (maxMsDt) {
|
|
||||||
latestDate = maxMsDt;
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = latestDate ? latestDate.getFullYear() : new Date().getFullYear();
|
|
||||||
|
|
||||||
this.logger.log(`[getDefaultAnalysisYear] userNo: ${userNo}, farmNo: ${farm.pkFarmNo}, maxChipDt: ${maxChipDt?.toISOString()}, maxMsDt: ${maxMsDt?.toISOString()}, year: ${year}`);
|
|
||||||
|
|
||||||
return year;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`[getDefaultAnalysisYear] Error: ${error.message}`);
|
|
||||||
return new Date().getFullYear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회원가입
|
* 회원가입
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
export class LoginResponseDto {
|
export class LoginResponseDto {
|
||||||
message: string;
|
message: string;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
user: {
|
user: {
|
||||||
pkUserNo: number;
|
pkUserNo: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -11,5 +12,4 @@ export class LoginResponseDto {
|
|||||||
userEmail: string;
|
userEmail: string;
|
||||||
userRole: 'USER' | 'ADMIN';
|
userRole: 'USER' | 'ADMIN';
|
||||||
};
|
};
|
||||||
defaultAnalysisYear: number; // 최근 검사 년도
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CommonController } from './common.controller';
|
import { CommonController } from './common.controller';
|
||||||
import { CommonService } from './common.service';
|
import { CommonService } from './common.service';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { JwtStrategy } from './jwt/jwt.strategy';
|
import { JwtStrategy } from './jwt/jwt.strategy';
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
import { HttpExceptionFilter } from './filters/http-exception.filter';
|
||||||
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
|
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
|
||||||
import { LoggingInterceptor } from './interceptors/logging.interceptor';
|
import { LoggingInterceptor } from './interceptors/logging.interceptor';
|
||||||
import { TransformInterceptor } from './interceptors/transform.interceptor';
|
import { TransformInterceptor } from './interceptors/transform.interceptor';
|
||||||
@@ -13,6 +15,7 @@ import { TransformInterceptor } from './interceptors/transform.interceptor';
|
|||||||
CommonService,
|
CommonService,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
JwtAuthGuard,
|
JwtAuthGuard,
|
||||||
|
HttpExceptionFilter,
|
||||||
AllExceptionsFilter,
|
AllExceptionsFilter,
|
||||||
LoggingInterceptor,
|
LoggingInterceptor,
|
||||||
TransformInterceptor,
|
TransformInterceptor,
|
||||||
@@ -20,6 +23,7 @@ import { TransformInterceptor } from './interceptors/transform.interceptor';
|
|||||||
exports: [
|
exports: [
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
JwtAuthGuard,
|
JwtAuthGuard,
|
||||||
|
HttpExceptionFilter,
|
||||||
AllExceptionsFilter,
|
AllExceptionsFilter,
|
||||||
LoggingInterceptor,
|
LoggingInterceptor,
|
||||||
TransformInterceptor,
|
TransformInterceptor,
|
||||||
|
|||||||
@@ -23,18 +23,11 @@ export const VALID_CHIP_SIRE_NAME = '일치';
|
|||||||
/** 제외할 어미 칩 이름 값 목록 */
|
/** 제외할 어미 칩 이름 값 목록 */
|
||||||
export const INVALID_CHIP_DAM_NAMES = ['불일치', '이력제부재'];
|
export const INVALID_CHIP_DAM_NAMES = ['불일치', '이력제부재'];
|
||||||
|
|
||||||
/** 순위/평균 집계 대상 지역 (이 지역만 집계에 포함, 테스트/기관 계정은 다른 regionSi 사용) */
|
|
||||||
export const VALID_REGION = '보은군';
|
|
||||||
|
|
||||||
/** ===============================개별 제외 개체 목록 (분석불가 등 특수 사유) 하단 개체 정보없음 확인필요=================*/
|
/** ===============================개별 제외 개체 목록 (분석불가 등 특수 사유) 하단 개체 정보없음 확인필요=================*/
|
||||||
export const EXCLUDED_COW_IDS = [
|
export const EXCLUDED_COW_IDS = [
|
||||||
'KOR002191642861',
|
'KOR002191642861',
|
||||||
// 일치인데 정보가 없음
|
// 일치인데 정보가 없음 / 김정태님 유전체 내역 빠짐 1두
|
||||||
// 김정태님 유전체 내역 빠짐 1두
|
|
||||||
// 근데 유전자 검사내역은 있음
|
|
||||||
// 일단 모근 1회분량이고 재검사어려움 , 모근상태 불량으로 인한 DNA분해로 인해 분석불가 상태로 넣음
|
// 일단 모근 1회분량이고 재검사어려움 , 모근상태 불량으로 인한 DNA분해로 인해 분석불가 상태로 넣음
|
||||||
// 분석불가로 넣으면 유전자가 조회가 안됨
|
|
||||||
// 유전자가 조회될수 있는 조건은 불일치와 이력제부재만 가능 // 분석불가는 아예안되는듯
|
|
||||||
|
|
||||||
];
|
];
|
||||||
//=================================================================================================================
|
//=================================================================================================================
|
||||||
@@ -67,29 +60,29 @@ export function isValidGenomeAnalysis(
|
|||||||
chipDamName: string | null | undefined,
|
chipDamName: string | null | undefined,
|
||||||
cowId?: string | null,
|
cowId?: string | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
// 1. 개별 제외 개체 확인
|
// 1. 아비 일치 확인
|
||||||
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
|
|
||||||
|
|
||||||
// 2. 아비명이 '일치'가 아니면 무효 (null, 불일치, 분석불가, 정보없음 등)
|
|
||||||
if (chipSireName !== VALID_CHIP_SIRE_NAME) return false;
|
if (chipSireName !== VALID_CHIP_SIRE_NAME) return false;
|
||||||
|
|
||||||
// 3. 어미명이 '불일치' 또는 '이력제부재'면 무효
|
// 2. 어미 제외 조건 확인
|
||||||
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) return false;
|
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) return false;
|
||||||
|
|
||||||
|
// 3. 개별 제외 개체 확인
|
||||||
|
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SQL WHERE 조건 생성 (TypeORM QueryBuilder용)
|
* SQL WHERE 조건 생성 (TypeORM QueryBuilder용)
|
||||||
* 부/모 불일치여도 유전자 데이터 있으면 표시하므로 조건 제거
|
* 주의: cowId 제외 목록은 SQL에 포함되지 않으므로 별도 필터링 필요
|
||||||
*
|
*
|
||||||
* @param alias - 테이블 별칭 (예: 'request', 'genome')
|
* @param alias - 테이블 별칭 (예: 'request', 'genome')
|
||||||
* @returns SQL 조건 문자열 (항상 true)
|
* @returns SQL 조건 문자열
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* queryBuilder.andWhere(getValidGenomeConditionSQL('request'));
|
* queryBuilder.andWhere(getValidGenomeConditionSQL('request'));
|
||||||
*/
|
*/
|
||||||
export function getValidGenomeConditionSQL(alias: string): string {
|
export function getValidGenomeConditionSQL(alias: string): string {
|
||||||
// 부/모 불일치 조건 제거 - 유전자 데이터 있으면 모두 표시
|
const damConditions = INVALID_CHIP_DAM_NAMES.map(name => `${alias}.chipDamName != '${name}'`).join(' AND ');
|
||||||
return '1=1';
|
return `${alias}.chipSireName = '${VALID_CHIP_SIRE_NAME}' AND (${alias}.chipDamName IS NULL OR (${damConditions}))`;
|
||||||
}
|
}
|
||||||
|
|||||||
73
backend/src/common/config/InbreedingConfig.ts
Normal file
73
backend/src/common/config/InbreedingConfig.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* 근친도 관련 설정 상수
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Wright's Coefficient of Inbreeding 알고리즘 기반 근친도 계산 및 위험도 판정 기준
|
||||||
|
*
|
||||||
|
* @source PRD 기능요구사항20.md SFR-COW-016-3
|
||||||
|
* @reference Wright, S. (1922). Coefficients of Inbreeding and Relationship
|
||||||
|
*/
|
||||||
|
export const INBREEDING_CONFIG = {
|
||||||
|
/**
|
||||||
|
* 위험도 판정 기준 (%)
|
||||||
|
* - 정상: < 15%
|
||||||
|
* - 주의: 15-20%
|
||||||
|
* - 위험: > 20%
|
||||||
|
*/
|
||||||
|
RISK_LEVELS: {
|
||||||
|
NORMAL_MAX: 15, // 정상 상한선 (< 15%)
|
||||||
|
WARNING_MIN: 15, // 주의 하한선 (>= 15%)
|
||||||
|
WARNING_MAX: 20, // 주의 상한선 (<= 20%)
|
||||||
|
DANGER_MIN: 20, // 위험 하한선 (> 20%)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다세대 시뮬레이션 위험도 판정 기준 (%)
|
||||||
|
* - 정상: < 6.25%
|
||||||
|
* - 주의: 6.25% ~ 임계값
|
||||||
|
* - 위험: > 임계값
|
||||||
|
*/
|
||||||
|
MULTI_GENERATION_RISK_LEVELS: {
|
||||||
|
SAFE_MAX: 6.25, // 안전 상한선 (< 6.25%)
|
||||||
|
// WARNING: 6.25% ~ inbreedingThreshold (사용자 지정)
|
||||||
|
// DANGER: > inbreedingThreshold (사용자 지정)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 근친도 임계값 (%)
|
||||||
|
* Wright's Coefficient 기준 안전 임계값
|
||||||
|
*/
|
||||||
|
DEFAULT_THRESHOLD: 12.5,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세대별 근친도 영향 감소율
|
||||||
|
* - 1세대: 100% 영향
|
||||||
|
* - 2세대: 50% 영향 (1/2)
|
||||||
|
* - 3세대: 25% 영향 (1/4)
|
||||||
|
* - 4세대: 12.5% 영향 (1/8)
|
||||||
|
*/
|
||||||
|
GENERATION_DECAY: {
|
||||||
|
GEN_1: 1.0, // 100%
|
||||||
|
GEN_2: 0.5, // 50%
|
||||||
|
GEN_3: 0.25, // 25%
|
||||||
|
GEN_4: 0.125, // 12.5%
|
||||||
|
GEN_5: 0.0625, // 6.25%
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPN 순환 전략 설정
|
||||||
|
*/
|
||||||
|
ROTATION_STRATEGY: {
|
||||||
|
CYCLE_GENERATIONS: 2, // N세대마다 순환 (기본값: 2세대)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유리형 비율 평가 기준 (%)
|
||||||
|
*/
|
||||||
|
FAVORABLE_RATE_THRESHOLDS: {
|
||||||
|
EXCELLENT: 75, // 매우 우수 (>= 75%)
|
||||||
|
GOOD: 60, // 양호 (>= 60%)
|
||||||
|
AVERAGE: 50, // 보통 (>= 50%)
|
||||||
|
POOR: 70, // 권장사항 생성 기준 (>= 70%)
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
90
backend/src/common/config/MptNormalRanges.ts
Normal file
90
backend/src/common/config/MptNormalRanges.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* MPT 혈액대사검사 정상 범위 기준값
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 각 MPT 항목별 권장 정상 범위를 정의합니다.
|
||||||
|
* 이 범위 내에 있으면 "우수" 판정을 받습니다.
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @constant
|
||||||
|
*/
|
||||||
|
export const MPT_NORMAL_RANGES = {
|
||||||
|
/**
|
||||||
|
* 알부민 (Albumin)
|
||||||
|
* 단위: g/dL
|
||||||
|
*/
|
||||||
|
albumin: { min: 3.3, max: 4.3 },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 총 글로불린 (Total Globulin)
|
||||||
|
* 단위: g/L
|
||||||
|
*/
|
||||||
|
totalGlobulin: { min: 9.1, max: 36.1 },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A/G 비율 (Albumin/Globulin Ratio)
|
||||||
|
* 단위: 비율
|
||||||
|
*/
|
||||||
|
agRatio: { min: 0.1, max: 0.4 },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 혈중요소질소 (Blood Urea Nitrogen)
|
||||||
|
* 단위: mg/dL
|
||||||
|
*/
|
||||||
|
bun: { min: 11.7, max: 18.9 },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AST (Aspartate Aminotransferase)
|
||||||
|
* 단위: U/L
|
||||||
|
*/
|
||||||
|
ast: { min: 47, max: 92 },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GGT (Gamma-Glutamyl Transferase)
|
||||||
|
* 단위: U/L
|
||||||
|
*/
|
||||||
|
ggt: { min: 11, max: 32 },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지방간 지수 (Fatty Liver Index)
|
||||||
|
* 단위: 지수
|
||||||
|
*/
|
||||||
|
fattyLiverIndex: { min: -1.2, max: 9.9 },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 칼슘 (Calcium)
|
||||||
|
* 단위: mg/dL
|
||||||
|
*/
|
||||||
|
calcium: { min: 8.1, max: 10.6 },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인 (Phosphorus)
|
||||||
|
* 단위: mg/dL
|
||||||
|
*/
|
||||||
|
phosphorus: { min: 6.2, max: 8.9 },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ca/P 비율 (Calcium/Phosphorus Ratio)
|
||||||
|
* 단위: 비율
|
||||||
|
*/
|
||||||
|
caPRatio: { min: 1.2, max: 1.3 },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마그네슘 (Magnesium)
|
||||||
|
* 단위: mg/dL
|
||||||
|
*/
|
||||||
|
magnesium: { min: 1.6, max: 3.3 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MPT 항목 타입
|
||||||
|
*/
|
||||||
|
export type MptCriteriaKey = keyof typeof MPT_NORMAL_RANGES;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MPT 범위 타입
|
||||||
|
*/
|
||||||
|
export interface MptRange {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
105
backend/src/common/config/RecommendationConfig.ts
Normal file
105
backend/src/common/config/RecommendationConfig.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* 추천 시스템 설정 상수
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* KPN 추천, 개체 추천, 패키지 추천 등 추천 시스템 관련 설정값
|
||||||
|
*
|
||||||
|
* @source PRD 기능요구사항20.md SFR-COW-016, SFR-COW-037
|
||||||
|
*/
|
||||||
|
export const RECOMMENDATION_CONFIG = {
|
||||||
|
/**
|
||||||
|
* 유전자 매칭 점수 관련
|
||||||
|
*/
|
||||||
|
GENE_SCORE: {
|
||||||
|
/**
|
||||||
|
* 점수 차이 임계값
|
||||||
|
* 유전자 매칭 점수 차이가 이 값보다 작으면 근친도를 우선 고려
|
||||||
|
*/
|
||||||
|
DIFF_THRESHOLD: 5,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본값
|
||||||
|
*/
|
||||||
|
DEFAULTS: {
|
||||||
|
/**
|
||||||
|
* 근친도 임계값 (%)
|
||||||
|
* Wright's Coefficient 기준
|
||||||
|
*/
|
||||||
|
INBREEDING_THRESHOLD: 12.5,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천 개수
|
||||||
|
* 상위 N개의 KPN/개체를 추천
|
||||||
|
*/
|
||||||
|
RECOMMENDATION_LIMIT: 10,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세대제약 기준
|
||||||
|
* 최근 N세대 이내 사용된 KPN을 추천에서 제외
|
||||||
|
*/
|
||||||
|
GENERATION_THRESHOLD: 3,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPN 패키지 설정
|
||||||
|
*/
|
||||||
|
PACKAGE: {
|
||||||
|
/**
|
||||||
|
* 기본 패키지 크기
|
||||||
|
* 추천할 KPN 세트 개수
|
||||||
|
*/
|
||||||
|
DEFAULT_SIZE: 5,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최소 패키지 크기
|
||||||
|
*/
|
||||||
|
MIN_SIZE: 3,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최대 패키지 크기
|
||||||
|
*/
|
||||||
|
MAX_SIZE: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커버리지 기준 (%)
|
||||||
|
* 유전자 목표 달성률 평가 기준
|
||||||
|
*/
|
||||||
|
COVERAGE: {
|
||||||
|
/**
|
||||||
|
* 우수 기준
|
||||||
|
* 50% 이상 커버리지
|
||||||
|
*/
|
||||||
|
EXCELLENT: 50,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 양호 기준
|
||||||
|
* 30% 이상 커버리지
|
||||||
|
*/
|
||||||
|
GOOD: 30,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최소 기준
|
||||||
|
* 20% 이상 커버리지
|
||||||
|
*/
|
||||||
|
MINIMUM: 20,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPN 순환 전략
|
||||||
|
*/
|
||||||
|
ROTATION: {
|
||||||
|
/**
|
||||||
|
* 최소 KPN 개수
|
||||||
|
* 순환 전략 적용 최소 개수
|
||||||
|
*/
|
||||||
|
MIN_KPN_COUNT: 3,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 재사용 안전 세대
|
||||||
|
* 동일 KPN을 이 세대 이후에 재사용 가능
|
||||||
|
*/
|
||||||
|
SAFE_REUSE_GENERATION: 4,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
6
backend/src/common/const/AccountStatusType.ts
Normal file
6
backend/src/common/const/AccountStatusType.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// 계정 상태 Enum
|
||||||
|
export enum AccountStatusType {
|
||||||
|
ACTIVE = "ACTIVE", // 정상
|
||||||
|
INACTIVE = "INACTIVE", // 비활성
|
||||||
|
SUSPENDED = "SUSPENDED", // 정지
|
||||||
|
}
|
||||||
5
backend/src/common/const/AnimalType.ts
Normal file
5
backend/src/common/const/AnimalType.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// 개체 타입 Enum
|
||||||
|
export enum AnimalType {
|
||||||
|
COW = 'COW', // 개체
|
||||||
|
KPN = 'KPN', // KPN
|
||||||
|
}
|
||||||
12
backend/src/common/const/AnlysStatType.ts
Normal file
12
backend/src/common/const/AnlysStatType.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* 분석 현황 상태 값 Enum
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @enum {number}
|
||||||
|
*/
|
||||||
|
export enum AnlysStatType {
|
||||||
|
MATCH = '친자일치',
|
||||||
|
MISMATCH = '친자불일치',
|
||||||
|
IMPOSSIBLE = '분석불가',
|
||||||
|
NO_HISTORY = '이력제부재',
|
||||||
|
}
|
||||||
13
backend/src/common/const/BreedingRecommendationType.ts
Normal file
13
backend/src/common/const/BreedingRecommendationType.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 사육/도태 추천 타입 Enum
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
export enum BreedingRecommendationType {
|
||||||
|
/** 사육 추천 */
|
||||||
|
BREED = '사육추천',
|
||||||
|
|
||||||
|
/** 도태 추천 */
|
||||||
|
CULL = '도태추천',
|
||||||
|
}
|
||||||
7
backend/src/common/const/CowReproType.ts
Normal file
7
backend/src/common/const/CowReproType.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// 개체 번식 타입 Enum
|
||||||
|
export enum CowReproType {
|
||||||
|
DONOR = "공란우",
|
||||||
|
RECIPIENT = "수란우",
|
||||||
|
AI = "인공수정",
|
||||||
|
CULL = "도태대상",
|
||||||
|
}
|
||||||
7
backend/src/common/const/CowStatusType.ts
Normal file
7
backend/src/common/const/CowStatusType.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// 개체 상태 Enum
|
||||||
|
export enum CowStatusType {
|
||||||
|
NORMAL = "정상",
|
||||||
|
DEAD = "폐사",
|
||||||
|
SLAUGHTER = "도축",
|
||||||
|
SALE = "매각",
|
||||||
|
}
|
||||||
55
backend/src/common/const/FileType.ts
Normal file
55
backend/src/common/const/FileType.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 파일 타입 Enum
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 엑셀 업로드 시 지원되는 파일 유형을 정의합니다.
|
||||||
|
* 각 파일 유형별로 고유한 파싱 로직과 대상 테이블이 매핑됩니다.
|
||||||
|
*
|
||||||
|
* @reference SFR-ADMIN-001 (기능요구사항20.md)
|
||||||
|
*
|
||||||
|
* 파일 유형별 매핑:
|
||||||
|
* - COW: 개체(암소) 정보 → tb_cow
|
||||||
|
* - GENE: 유전자(SNP) 데이터 → tb_snp_cow
|
||||||
|
* - GENOME: 유전체(유전능력) 데이터 → tb_genome_cow
|
||||||
|
* - MPT: 혈액대사검사(MPT) (1행: 카테고리, 2행: 항목명, 3행~: 데이터) → tb_repro_mpt, tb_repro_mpt_item
|
||||||
|
* - FERTILITY: 수태율 데이터 → tb_fertility_rate
|
||||||
|
* - KPN_GENE: KPN 유전자 데이터 → tb_kpn_snp
|
||||||
|
* - KPN_GENOME: KPN 유전체 데이터 → tb_kpn_genome
|
||||||
|
* - KPN_MPT: KPN 혈액대사검사 → tb_kpn_mpt
|
||||||
|
* - REGION_COW: 지역 개체 정보 → tb_region_cow
|
||||||
|
* - REGION_GENE: 지역 유전자 데이터 → tb_region_snp
|
||||||
|
* - REGION_GENOME: 지역 유전체 데이터 → tb_region_genome
|
||||||
|
* - REGION_MPT: 지역 혈액대사검사 → tb_region_mpt
|
||||||
|
* - HELP: 도움말 데이터 (유전자/유전체/번식능력 설명) → tb_help_content
|
||||||
|
* - MARKER: 마커(유전자) 정보 (마커명, 관련형질, 목표유전자형 등) → tb_marker
|
||||||
|
* 목표유전자형(target_genotype): KPN 추천 시 각 유전자의 우량형 기준 (AA, GG, CC 등)
|
||||||
|
*/
|
||||||
|
export enum FileType {
|
||||||
|
// 개체(암소) 데이터
|
||||||
|
COW = '개체',
|
||||||
|
|
||||||
|
// 유전 데이터
|
||||||
|
GENE = '유전자',
|
||||||
|
GENOME = '유전체',
|
||||||
|
|
||||||
|
// 번식 데이터
|
||||||
|
MPT = '혈액대사검사',
|
||||||
|
FERTILITY = '수태율',
|
||||||
|
|
||||||
|
// KPN 데이터
|
||||||
|
KPN_GENE = 'KPN유전자',
|
||||||
|
KPN_GENOME = 'KPN유전체',
|
||||||
|
KPN_MPT = 'KPN혈액대사검사',
|
||||||
|
|
||||||
|
// 지역 개체 데이터 (보은군 비교용)
|
||||||
|
REGION_COW = '지역개체',
|
||||||
|
REGION_GENE = '지역유전자',
|
||||||
|
REGION_GENOME = '지역유전체',
|
||||||
|
REGION_MPT = '지역혈액대사검사',
|
||||||
|
|
||||||
|
// 도움말 데이터
|
||||||
|
HELP = '도움말',
|
||||||
|
|
||||||
|
// 마커(유전자) 정보
|
||||||
|
MARKER = '마커정보',
|
||||||
|
}
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
/**
|
|
||||||
* MPT (혈액대사판정시험) 항목별 권장치 참고 범위
|
|
||||||
* 백엔드 중앙 관리 파일 - 프론트엔드에서 API로 조회
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface MptReferenceRange {
|
|
||||||
key: string;
|
|
||||||
name: string; // 한글 표시명
|
|
||||||
upperLimit: number | null;
|
|
||||||
lowerLimit: number | null;
|
|
||||||
unit: string;
|
|
||||||
category: 'energy' | 'protein' | 'liver' | 'mineral' | 'etc';
|
|
||||||
categoryName: string; // 카테고리 한글명
|
|
||||||
description?: string; // 항목 설명 (선택)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MptCategory {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
items: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MPT 참조값 범위
|
|
||||||
*/
|
|
||||||
export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
|
|
||||||
// 에너지 카테고리
|
|
||||||
glucose: {
|
|
||||||
key: 'glucose',
|
|
||||||
name: '혈당',
|
|
||||||
upperLimit: 84,
|
|
||||||
lowerLimit: 40,
|
|
||||||
unit: 'mg/dL',
|
|
||||||
category: 'energy',
|
|
||||||
categoryName: '에너지 대사',
|
|
||||||
description: '에너지 대사 상태 지표',
|
|
||||||
},
|
|
||||||
cholesterol: {
|
|
||||||
key: 'cholesterol',
|
|
||||||
name: '콜레스테롤',
|
|
||||||
upperLimit: 252,
|
|
||||||
lowerLimit: 74,
|
|
||||||
unit: 'mg/dL',
|
|
||||||
category: 'energy',
|
|
||||||
categoryName: '에너지 대사',
|
|
||||||
description: '혈액 내 콜레스테롤 수치',
|
|
||||||
},
|
|
||||||
nefa: {
|
|
||||||
key: 'nefa',
|
|
||||||
name: '유리지방산(NEFA)',
|
|
||||||
upperLimit: 660,
|
|
||||||
lowerLimit: 115,
|
|
||||||
unit: 'μEq/L',
|
|
||||||
category: 'energy',
|
|
||||||
categoryName: '에너지 대사',
|
|
||||||
description: '혈액 내 유리지방산 수치',
|
|
||||||
},
|
|
||||||
bcs: {
|
|
||||||
key: 'bcs',
|
|
||||||
name: 'BCS',
|
|
||||||
upperLimit: 3.5,
|
|
||||||
lowerLimit: 2.5,
|
|
||||||
unit: '점',
|
|
||||||
category: 'energy',
|
|
||||||
categoryName: '에너지 대사',
|
|
||||||
description: '체충실지수(Body Condition Score)',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 단백질 카테고리
|
|
||||||
totalProtein: {
|
|
||||||
key: 'totalProtein',
|
|
||||||
name: '총단백질',
|
|
||||||
upperLimit: 7.7,
|
|
||||||
lowerLimit: 6.2,
|
|
||||||
unit: 'g/dL',
|
|
||||||
category: 'protein',
|
|
||||||
categoryName: '단백질 대사',
|
|
||||||
description: '혈액 내 총단백질 수치',
|
|
||||||
},
|
|
||||||
albumin: {
|
|
||||||
key: 'albumin',
|
|
||||||
name: '알부민',
|
|
||||||
upperLimit: 4.3,
|
|
||||||
lowerLimit: 3.3,
|
|
||||||
unit: 'g/dL',
|
|
||||||
category: 'protein',
|
|
||||||
categoryName: '단백질 대사',
|
|
||||||
description: '혈액 내 알부민 수치',
|
|
||||||
},
|
|
||||||
globulin: {
|
|
||||||
key: 'globulin',
|
|
||||||
name: '총글로불린',
|
|
||||||
upperLimit: 36.1,
|
|
||||||
lowerLimit: 9.1,
|
|
||||||
unit: 'g/dL',
|
|
||||||
category: 'protein',
|
|
||||||
categoryName: '단백질 대사',
|
|
||||||
description: '혈액 내 총글로불린 수치',
|
|
||||||
},
|
|
||||||
agRatio: {
|
|
||||||
key: 'agRatio',
|
|
||||||
name: 'A/G 비율',
|
|
||||||
upperLimit: 0.4,
|
|
||||||
lowerLimit: 0.1,
|
|
||||||
unit: '',
|
|
||||||
category: 'protein',
|
|
||||||
categoryName: '단백질 대사',
|
|
||||||
description: '알부민/글로불린 비율',
|
|
||||||
},
|
|
||||||
bun: {
|
|
||||||
key: 'bun',
|
|
||||||
name: '요소태질소(BUN)',
|
|
||||||
upperLimit: 18.9,
|
|
||||||
lowerLimit: 11.7,
|
|
||||||
unit: 'mg/dL',
|
|
||||||
category: 'protein',
|
|
||||||
categoryName: '단백질 대사',
|
|
||||||
description: '혈액 내 요소태질소 수치',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 간기능 카테고리
|
|
||||||
ast: {
|
|
||||||
key: 'ast',
|
|
||||||
name: 'AST',
|
|
||||||
upperLimit: 92,
|
|
||||||
lowerLimit: 47,
|
|
||||||
unit: 'U/L',
|
|
||||||
category: 'liver',
|
|
||||||
categoryName: '간기능',
|
|
||||||
description: '혈액 내 AST 수치',
|
|
||||||
},
|
|
||||||
ggt: {
|
|
||||||
key: 'ggt',
|
|
||||||
name: 'GGT',
|
|
||||||
upperLimit: 32,
|
|
||||||
lowerLimit: 11,
|
|
||||||
unit: 'U/L',
|
|
||||||
category: 'liver',
|
|
||||||
categoryName: '간기능',
|
|
||||||
description: '혈액 내 GGT 수치',
|
|
||||||
},
|
|
||||||
fattyLiverIdx: {
|
|
||||||
key: 'fattyLiverIdx',
|
|
||||||
name: '지방간 지수',
|
|
||||||
upperLimit: 9.9,
|
|
||||||
lowerLimit: -1.2,
|
|
||||||
unit: '',
|
|
||||||
category: 'liver',
|
|
||||||
categoryName: '간기능',
|
|
||||||
description: '혈액 내 지방간 지수 수치',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 미네랄 카테고리
|
|
||||||
calcium: {
|
|
||||||
key: 'calcium',
|
|
||||||
name: '칼슘',
|
|
||||||
upperLimit: 10.6,
|
|
||||||
lowerLimit: 8.1,
|
|
||||||
unit: 'mg/dL',
|
|
||||||
category: 'mineral',
|
|
||||||
categoryName: '미네랄',
|
|
||||||
description: '혈액 내 칼슘 수치',
|
|
||||||
},
|
|
||||||
phosphorus: {
|
|
||||||
key: 'phosphorus',
|
|
||||||
name: '인',
|
|
||||||
upperLimit: 8.9,
|
|
||||||
lowerLimit: 6.2,
|
|
||||||
unit: 'mg/dL',
|
|
||||||
category: 'mineral',
|
|
||||||
categoryName: '미네랄',
|
|
||||||
description: '혈액 내 인 수치',
|
|
||||||
},
|
|
||||||
caPRatio: {
|
|
||||||
key: 'caPRatio',
|
|
||||||
name: '칼슘/인 비율',
|
|
||||||
upperLimit: 1.3,
|
|
||||||
lowerLimit: 1.2,
|
|
||||||
unit: '',
|
|
||||||
category: 'mineral',
|
|
||||||
categoryName: '미네랄',
|
|
||||||
description: '혈액 내 칼슘/인 비율',
|
|
||||||
},
|
|
||||||
magnesium: {
|
|
||||||
key: 'magnesium',
|
|
||||||
name: '마그네슘',
|
|
||||||
upperLimit: 3.3,
|
|
||||||
lowerLimit: 1.6,
|
|
||||||
unit: 'mg/dL',
|
|
||||||
category: 'mineral',
|
|
||||||
categoryName: '미네랄',
|
|
||||||
description: '혈액 내 마그네슘 수치',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 별도 카테고리
|
|
||||||
creatine: {
|
|
||||||
key: 'creatine',
|
|
||||||
name: '크레아틴',
|
|
||||||
upperLimit: 1.3,
|
|
||||||
lowerLimit: 1.0,
|
|
||||||
unit: 'mg/dL',
|
|
||||||
category: 'etc',
|
|
||||||
categoryName: '기타',
|
|
||||||
description: '혈액 내 크레아틴 수치',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MPT 카테고리 목록
|
|
||||||
*/
|
|
||||||
export const MPT_CATEGORIES: MptCategory[] = [
|
|
||||||
{
|
|
||||||
key: 'energy',
|
|
||||||
name: '에너지 대사',
|
|
||||||
color: 'bg-muted/50',
|
|
||||||
items: ['glucose', 'cholesterol', 'nefa', 'bcs'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'protein',
|
|
||||||
name: '단백질 대사',
|
|
||||||
color: 'bg-muted/50',
|
|
||||||
items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'liver',
|
|
||||||
name: '간기능',
|
|
||||||
color: 'bg-muted/50',
|
|
||||||
items: ['ast', 'ggt', 'fattyLiverIdx'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'mineral',
|
|
||||||
name: '미네랄',
|
|
||||||
color: 'bg-muted/50',
|
|
||||||
items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'etc',
|
|
||||||
name: '기타',
|
|
||||||
color: 'bg-muted/50',
|
|
||||||
items: ['creatine'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 측정값이 정상 범위 내에 있는지 확인
|
|
||||||
*/
|
|
||||||
export function checkMptStatus(
|
|
||||||
value: number | null,
|
|
||||||
itemKey: string,
|
|
||||||
): 'normal' | 'high' | 'low' | 'unknown' {
|
|
||||||
if (value === null || value === undefined) return 'unknown';
|
|
||||||
|
|
||||||
const reference = MPT_REFERENCE_RANGES[itemKey];
|
|
||||||
if (!reference || reference.upperLimit === null || reference.lowerLimit === null) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value > reference.upperLimit) return 'high';
|
|
||||||
if (value < reference.lowerLimit) return 'low';
|
|
||||||
return 'normal';
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* 랭킹 기준 타입 Enum
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 개체 목록 페이지에서 사용하는 랭킹 기준
|
|
||||||
*
|
|
||||||
* @export
|
|
||||||
* @enum {string}
|
|
||||||
*/
|
|
||||||
export enum RankingCriteriaType {
|
|
||||||
/** 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균) */
|
|
||||||
GENOME = 'GENOME',
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
/**
|
|
||||||
* 형질(Trait) 관련 상수 정의
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 유전체 분석에서 사용하는 35개 형질 목록
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 성장형질 (1개) */
|
|
||||||
export const GROWTH_TRAITS = ['12개월령체중'] as const;
|
|
||||||
|
|
||||||
/** 경제형질 (4개) - 생산 카테고리 */
|
|
||||||
export const ECONOMIC_TRAITS = ['도체중', '등심단면적', '등지방두께', '근내지방도'] as const;
|
|
||||||
|
|
||||||
/** 체형형질 (10개) */
|
|
||||||
export const BODY_TRAITS = [
|
|
||||||
'체고', '십자', '체장', '흉심', '흉폭',
|
|
||||||
'고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** 부위별 무게 (10개) */
|
|
||||||
export const WEIGHT_TRAITS = [
|
|
||||||
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
|
||||||
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** 부위별 비율 (10개) */
|
|
||||||
export const RATE_TRAITS = [
|
|
||||||
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
|
||||||
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** 전체 형질 (35개) */
|
|
||||||
export const ALL_TRAITS = [
|
|
||||||
...GROWTH_TRAITS,
|
|
||||||
...ECONOMIC_TRAITS,
|
|
||||||
...BODY_TRAITS,
|
|
||||||
...WEIGHT_TRAITS,
|
|
||||||
...RATE_TRAITS,
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** 낮을수록 좋은 형질 (부호 반전 필요) */
|
|
||||||
export const NEGATIVE_TRAITS: string[] = ['등지방두께'];
|
|
||||||
|
|
||||||
/** 형질 타입 */
|
|
||||||
export type TraitName = typeof ALL_TRAITS[number];
|
|
||||||
|
|
||||||
/** 카테고리 타입 */
|
|
||||||
export type TraitCategory = '성장' | '생산' | '체형' | '무게' | '비율';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 형질별 카테고리 매핑
|
|
||||||
* - 형질명 → 카테고리 조회용
|
|
||||||
*/
|
|
||||||
export const TRAIT_CATEGORY_MAP: Record<string, TraitCategory> = {
|
|
||||||
// 성장 카테고리 - 월령별 체중
|
|
||||||
'12개월령체중': '성장',
|
|
||||||
|
|
||||||
// 생산 카테고리 - 도체(도축 후 고기) 품질
|
|
||||||
'도체중': '생산',
|
|
||||||
'등심단면적': '생산',
|
|
||||||
'등지방두께': '생산',
|
|
||||||
'근내지방도': '생산',
|
|
||||||
|
|
||||||
// 체형 카테고리 - 신체 구조
|
|
||||||
'체고': '체형',
|
|
||||||
'십자': '체형',
|
|
||||||
'체장': '체형',
|
|
||||||
'흉심': '체형',
|
|
||||||
'흉폭': '체형',
|
|
||||||
'고장': '체형',
|
|
||||||
'요각폭': '체형',
|
|
||||||
'좌골폭': '체형',
|
|
||||||
'곤폭': '체형',
|
|
||||||
'흉위': '체형',
|
|
||||||
|
|
||||||
// 무게 카테고리 - 부위별 실제 무게 (kg)
|
|
||||||
'안심weight': '무게',
|
|
||||||
'등심weight': '무게',
|
|
||||||
'채끝weight': '무게',
|
|
||||||
'목심weight': '무게',
|
|
||||||
'앞다리weight': '무게',
|
|
||||||
'우둔weight': '무게',
|
|
||||||
'설도weight': '무게',
|
|
||||||
'사태weight': '무게',
|
|
||||||
'양지weight': '무게',
|
|
||||||
'갈비weight': '무게',
|
|
||||||
|
|
||||||
// 비율 카테고리 - 부위별 비율 (%)
|
|
||||||
'안심rate': '비율',
|
|
||||||
'등심rate': '비율',
|
|
||||||
'채끝rate': '비율',
|
|
||||||
'목심rate': '비율',
|
|
||||||
'앞다리rate': '비율',
|
|
||||||
'우둔rate': '비율',
|
|
||||||
'설도rate': '비율',
|
|
||||||
'사태rate': '비율',
|
|
||||||
'양지rate': '비율',
|
|
||||||
'갈비rate': '비율',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 형질명으로 카테고리 조회
|
|
||||||
*
|
|
||||||
* @param traitName - 형질명
|
|
||||||
* @returns 카테고리명 (없으면 '기타')
|
|
||||||
*/
|
|
||||||
export function getTraitCategory(traitName: string): string {
|
|
||||||
return TRAIT_CATEGORY_MAP[traitName] ?? '기타';
|
|
||||||
}
|
|
||||||
41
backend/src/common/decorators/current-user.decorator.ts
Normal file
41
backend/src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 로그인한 사용자 정보를 가져오는 Decorator
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 인증 미들웨어(JWT, Passport 등)가 req.user에 추가한 사용자 정보를 추출합니다.
|
||||||
|
* 인증되지 않은 경우 기본값을 반환합니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 전체 user 객체 가져오기
|
||||||
|
* async method(@CurrentUser() user: any) {
|
||||||
|
* console.log(user.userId, user.email);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 특정 속성만 가져오기
|
||||||
|
* async method(@CurrentUser('userId') userId: string) {
|
||||||
|
* console.log(userId); // 'user123' or 'system'
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(data: string | undefined, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
// 사용자 정보가 없으면 기본값 반환
|
||||||
|
if (!user) {
|
||||||
|
// userId를 요청한 경우 'system' 반환
|
||||||
|
if (data === 'userId') {
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
// 전체 user 객체를 요청한 경우 null 반환
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 특정 속성을 요청한 경우 해당 속성 반환
|
||||||
|
// 전체 user 객체를 요청한 경우 user 반환
|
||||||
|
return data ? user[data] : user;
|
||||||
|
},
|
||||||
|
);
|
||||||
37
backend/src/common/decorators/user.decorator.ts
Normal file
37
backend/src/common/decorators/user.decorator.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User 데코레이터
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* JWT 인증 후 Request 객체에서 사용자 정보를 추출하는 데코레이터입니다.
|
||||||
|
* @Req() req 대신 사용하여 더 간결하게 사용자 정보를 가져올 수 있습니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 전체 사용자 정보 가져오기
|
||||||
|
* @Get('profile')
|
||||||
|
* @UseGuards(JwtAuthGuard)
|
||||||
|
* getProfile(@User() user: any) {
|
||||||
|
* return user; // { userId: '...', userNo: 1, role: 'user' }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 특정 필드만 가져오기
|
||||||
|
* @Get('my-data')
|
||||||
|
* @UseGuards(JwtAuthGuard)
|
||||||
|
* getMyData(@User('userId') userId: string) {
|
||||||
|
* return `Your ID is ${userId}`;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @constant User
|
||||||
|
*/
|
||||||
|
export const User = createParamDecorator(
|
||||||
|
(data: string, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
// 특정 필드만 반환
|
||||||
|
return data ? user?.[data] : user;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// common/dto/base-result.dto.ts
|
|
||||||
export class BaseResultDto<T = any> {
|
|
||||||
success: boolean;
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
data?: T;
|
|
||||||
timestamp: string;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
success: boolean,
|
|
||||||
code: string,
|
|
||||||
message: string,
|
|
||||||
data?: T,
|
|
||||||
) {
|
|
||||||
this.success = success;
|
|
||||||
this.code = code;
|
|
||||||
this.message = message;
|
|
||||||
this.data = data;
|
|
||||||
this.timestamp = new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
static ok<T>(
|
|
||||||
data?: T,
|
|
||||||
message = 'SUCCESS',
|
|
||||||
code = 'OK',
|
|
||||||
): BaseResultDto<T> {
|
|
||||||
return new BaseResultDto<T>(true, code, message, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
static fail(
|
|
||||||
message: string,
|
|
||||||
code = 'FAIL',
|
|
||||||
): BaseResultDto<null> {
|
|
||||||
return new BaseResultDto<null>(false, code, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { ExcelUtil } from "./excel.util";
|
|
||||||
import { Global, Module } from "@nestjs/common";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Excel 모듈
|
|
||||||
* Excel 파일 유틸리티를 제공하는 모듈입니다.
|
|
||||||
* 각 서비스 클래스에서 사용 가능하도록 공통 모듈로 생성
|
|
||||||
*/
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [ExcelUtil],
|
|
||||||
exports: [ExcelUtil],
|
|
||||||
})
|
|
||||||
export class ExcelModule {}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import * as XLSX from "xlsx";
|
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Excel 파일 유틸리티
|
|
||||||
* Lib활용 등 서비스에 가까운 공통 유틸이므로, 서비스 클래스 형태로 생성
|
|
||||||
* 각 서비스 클래스에서 사용 가능하도록 공통 모듈로 생성
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class ExcelUtil {
|
|
||||||
private readonly logger = new Logger(ExcelUtil.name);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* file 파일 데이터를 파싱하여 json 배열로 변환 - 추후 공통으로 처리 가능하면 정리해서 처리
|
|
||||||
* @param file - 파싱할 데이터
|
|
||||||
* @returns json 배열
|
|
||||||
*/
|
|
||||||
parseExcelData(file: Express.Multer.File): any[] {
|
|
||||||
try {
|
|
||||||
// ============================================
|
|
||||||
// 1단계: 엑셀파일 로드
|
|
||||||
// ============================================
|
|
||||||
this.logger.log('[parseExcelData] 1단계: 엑셀 파일 로드 중...');
|
|
||||||
|
|
||||||
let workbook: XLSX.WorkBook;
|
|
||||||
|
|
||||||
if(file?.buffer) {
|
|
||||||
workbook = XLSX.read(file.buffer, { type: 'buffer', cellDates: true });
|
|
||||||
}else if (file?.path){
|
|
||||||
workbook = XLSX.readFile(file.path, { cellDates: true });
|
|
||||||
}else{
|
|
||||||
throw new Error('file.buffer/file.path가 모두 없습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
|
|
||||||
throw new Error('엑셀 파일에 시트가 없습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const sheetName = workbook.SheetNames[0];
|
|
||||||
const worksheet = workbook.Sheets[sheetName];
|
|
||||||
|
|
||||||
if (!worksheet) {
|
|
||||||
throw new Error(`시트 "${sheetName}"를 읽을 수 없습니다.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시트를 JSON 배열로 변환 (헤더 포함)
|
|
||||||
const rawData = XLSX.utils.sheet_to_json(worksheet, {
|
|
||||||
header: 1,
|
|
||||||
defval: null,
|
|
||||||
raw: false
|
|
||||||
}) as any[][];
|
|
||||||
|
|
||||||
if (rawData.length < 2) {
|
|
||||||
throw new Error('엑셀 파일에 데이터가 없습니다. (헤더 포함 최소 2행 필요)');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`[parseExcelData] 엑셀 파일 로드 완료: ${rawData.length}행`);
|
|
||||||
|
|
||||||
return rawData;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`[parseExcelData] 처리 중 오류 발생: ${error.message}`);
|
|
||||||
this.logger.error(error.stack);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
this.logger.log(`[parseExcelData] 처리 완료`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
93
backend/src/common/filters/http-exception.filter.ts
Normal file
93
backend/src/common/filters/http-exception.filter.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
ExceptionFilter,
|
||||||
|
Catch,
|
||||||
|
ArgumentsHost,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP 예외 필터
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 모든 HTTP 예외를 잡아서 일관된 형식으로 응답을 반환합니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // main.ts에서 전역 적용
|
||||||
|
* app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class HttpExceptionFilter
|
||||||
|
* @implements {ExceptionFilter}
|
||||||
|
*/
|
||||||
|
@Catch(HttpException)
|
||||||
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
catch(exception: HttpException, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
const request = ctx.getRequest<Request>();
|
||||||
|
const status = exception.getStatus();
|
||||||
|
const exceptionResponse = exception.getResponse();
|
||||||
|
|
||||||
|
// 에러 메시지 추출
|
||||||
|
let message: string | string[];
|
||||||
|
if (typeof exceptionResponse === 'string') {
|
||||||
|
message = exceptionResponse;
|
||||||
|
} else if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
|
||||||
|
message = (exceptionResponse as any).message || exception.message;
|
||||||
|
} else {
|
||||||
|
message = exception.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일관된 에러 응답 형식
|
||||||
|
const errorResponse = {
|
||||||
|
success: false,
|
||||||
|
statusCode: status,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
method: request.method,
|
||||||
|
message: Array.isArray(message) ? message : [message],
|
||||||
|
error: this.getErrorName(status),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개발 환경에서는 스택 트레이스 포함
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
(errorResponse as any).stack = exception.stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로깅
|
||||||
|
console.error(
|
||||||
|
`[${errorResponse.timestamp}] ${request.method} ${request.url} - ${status}`,
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
|
||||||
|
response.status(status).json(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP 상태 코드에 따른 에러 이름 반환
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param {number} status - HTTP 상태 코드
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
private getErrorName(status: number): string {
|
||||||
|
switch (status) {
|
||||||
|
case HttpStatus.BAD_REQUEST:
|
||||||
|
return 'Bad Request';
|
||||||
|
case HttpStatus.UNAUTHORIZED:
|
||||||
|
return 'Unauthorized';
|
||||||
|
case HttpStatus.FORBIDDEN:
|
||||||
|
return 'Forbidden';
|
||||||
|
case HttpStatus.NOT_FOUND:
|
||||||
|
return 'Not Found';
|
||||||
|
case HttpStatus.CONFLICT:
|
||||||
|
return 'Conflict';
|
||||||
|
case HttpStatus.INTERNAL_SERVER_ERROR:
|
||||||
|
return 'Internal Server Error';
|
||||||
|
default:
|
||||||
|
return 'Error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* 날짜 문자열을 Date 객체로 변환
|
|
||||||
* @param value - 변환할 값
|
|
||||||
* @returns Date 객체 또는 null
|
|
||||||
*/
|
|
||||||
export function parseDate(value: any): Date | null {
|
|
||||||
if (value === null || value === undefined || value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이미 Date 객체인 경우
|
|
||||||
if (value instanceof Date) {
|
|
||||||
return isNaN(value.getTime()) ? null : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 숫자인 경우 (엑셀 날짜 시리얼 번호)
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
// Excel 날짜는 1900-01-01부터의 일수
|
|
||||||
// 하지만 XLSX 라이브러리가 이미 변환해줄 수 있으므로 일반 Date로 처리
|
|
||||||
const date = new Date(value);
|
|
||||||
return isNaN(date.getTime()) ? null : date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 문자열인 경우
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (trimmed === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 다양한 날짜 형식 시도
|
|
||||||
// YYYY-MM-DD, YYYY/MM/DD, YYYYMMDD 등
|
|
||||||
const date = new Date(trimmed);
|
|
||||||
if (!isNaN(date.getTime())) {
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// YYYYMMDD 형식 처리
|
|
||||||
if (/^\d{8}$/.test(trimmed)) {
|
|
||||||
const year = parseInt(trimmed.substring(0, 4), 10);
|
|
||||||
const month = parseInt(trimmed.substring(4, 6), 10) - 1; // 월은 0부터 시작
|
|
||||||
const day = parseInt(trimmed.substring(6, 8), 10);
|
|
||||||
const date = new Date(year, month, day);
|
|
||||||
return isNaN(date.getTime()) ? null : date;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 숫자 문자열을 숫자로 변환
|
|
||||||
* @param value - 변환할 값
|
|
||||||
* @returns 숫자 또는 null
|
|
||||||
*/
|
|
||||||
export function parseNumber(value: any): number | null {
|
|
||||||
if (value === null || value === undefined || value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
return isNaN(value) ? null : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (trimmed === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const parsed = parseFloat(trimmed);
|
|
||||||
return isNaN(parsed) ? null : parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
52
backend/src/common/utils/get-client-ip.ts
Normal file
52
backend/src/common/utils/get-client-ip.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클라이언트의 실제 IP 주소를 추출합니다.
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Proxy, Load Balancer, CDN 뒤에 있어도 실제 클라이언트 IP를 정확하게 가져옵니다.
|
||||||
|
* 다음 순서로 IP를 확인합니다:
|
||||||
|
* 1. X-Forwarded-For 헤더 (Proxy/Load Balancer)
|
||||||
|
* 2. X-Real-IP 헤더 (Nginx)
|
||||||
|
* 3. req.ip (Express 기본)
|
||||||
|
* 4. req.socket.remoteAddress (직접 연결)
|
||||||
|
* 5. 'unknown' (IP를 찾을 수 없는 경우)
|
||||||
|
*
|
||||||
|
* @param req - Express Request 객체
|
||||||
|
* @returns 클라이언트 IP 주소
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const ip = getClientIp(req);
|
||||||
|
* console.log(ip); // '203.123.45.67' or 'unknown'
|
||||||
|
*/
|
||||||
|
export function getClientIp(req: Request): string {
|
||||||
|
// 1. X-Forwarded-For 헤더 확인 (Proxy/Load Balancer 환경)
|
||||||
|
// 형식: "client IP, proxy1 IP, proxy2 IP"
|
||||||
|
const forwardedFor = req.headers['x-forwarded-for'];
|
||||||
|
if (forwardedFor) {
|
||||||
|
// 배열이면 첫 번째 요소, 문자열이면 콤마로 split
|
||||||
|
const ips = Array.isArray(forwardedFor)
|
||||||
|
? forwardedFor[0]
|
||||||
|
: forwardedFor.split(',')[0];
|
||||||
|
return ips.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. X-Real-IP 헤더 확인 (Nginx 환경)
|
||||||
|
const realIp = req.headers['x-real-ip'];
|
||||||
|
if (realIp && typeof realIp === 'string') {
|
||||||
|
return realIp.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Express가 제공하는 req.ip
|
||||||
|
if (req.ip) {
|
||||||
|
return req.ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Socket의 remoteAddress
|
||||||
|
if (req.socket?.remoteAddress) {
|
||||||
|
return req.socket.remoteAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. IP를 찾을 수 없는 경우
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
@@ -3,22 +3,37 @@
|
|||||||
* 개체(Cow) 컨트롤러
|
* 개체(Cow) 컨트롤러
|
||||||
* ============================================================
|
* ============================================================
|
||||||
*
|
*
|
||||||
* 사용 페이지: 개체 목록 페이지 (/cow), 개체 상세 페이지 (/cow/:cowNo)
|
* 사용 페이지: 개체 목록 페이지 (/cow)
|
||||||
*
|
*
|
||||||
* 엔드포인트:
|
* 엔드포인트:
|
||||||
* - GET /cow/:cowId - 개체 상세 조회
|
* - GET /cow - 기본 개체 목록 조회
|
||||||
|
* - GET /cow/:id - 개체 상세 조회
|
||||||
* - POST /cow/ranking - 랭킹 적용 개체 목록 조회
|
* - POST /cow/ranking - 랭킹 적용 개체 목록 조회
|
||||||
|
* - POST /cow/ranking/global - 전체 개체 랭킹 조회
|
||||||
* ============================================================
|
* ============================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
||||||
import { CowService } from './cow.service';
|
import { CowService } from './cow.service';
|
||||||
|
import { CowModel } from './entities/cow.entity';
|
||||||
import { RankingRequestDto } from './dto/ranking-request.dto';
|
import { RankingRequestDto } from './dto/ranking-request.dto';
|
||||||
|
|
||||||
@Controller('cow')
|
@Controller('cow')
|
||||||
export class CowController {
|
export class CowController {
|
||||||
constructor(private readonly cowService: CowService) {}
|
constructor(private readonly cowService: CowService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /cow
|
||||||
|
* 기본 개체 목록 조회
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
findAll(@Query('farmId') farmId?: string) {
|
||||||
|
if (farmId) {
|
||||||
|
return this.cowService.findByFarmId(+farmId);
|
||||||
|
}
|
||||||
|
return this.cowService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /cow/ranking
|
* POST /cow/ranking
|
||||||
* 랭킹이 적용된 개체 목록 조회
|
* 랭킹이 적용된 개체 목록 조회
|
||||||
@@ -30,6 +45,25 @@ export class CowController {
|
|||||||
return this.cowService.findAllWithRanking(rankingRequest);
|
return this.cowService.findAllWithRanking(rankingRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /cow/ranking/global
|
||||||
|
* 전체 개체 랭킹 조회 (모든 농장 포함)
|
||||||
|
*
|
||||||
|
* 사용 페이지: 대시보드 (농장 순위 비교)
|
||||||
|
*/
|
||||||
|
@Post('ranking/global')
|
||||||
|
findAllWithGlobalRanking(@Body() rankingRequest: RankingRequestDto) {
|
||||||
|
// farmNo 필터 없이 전체 개체 랭킹 조회
|
||||||
|
const globalRequest = {
|
||||||
|
...rankingRequest,
|
||||||
|
filterOptions: {
|
||||||
|
...rankingRequest.filterOptions,
|
||||||
|
farmNo: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return this.cowService.findAllWithRanking(globalRequest);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /cow/:cowId
|
* GET /cow/:cowId
|
||||||
* 개체 상세 조회 (cowId: 개체식별번호 KOR로 시작)
|
* 개체 상세 조회 (cowId: 개체식별번호 KOR로 시작)
|
||||||
@@ -38,4 +72,19 @@ export class CowController {
|
|||||||
findOne(@Param('cowId') cowId: string) {
|
findOne(@Param('cowId') cowId: string) {
|
||||||
return this.cowService.findByCowId(cowId);
|
return this.cowService.findByCowId(cowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() data: Partial<CowModel>) {
|
||||||
|
return this.cowService.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
update(@Param('id') id: string, @Body() data: Partial<CowModel>) {
|
||||||
|
return this.cowService.update(+id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.cowService.remove(+id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ import { CowService } from './cow.service';
|
|||||||
import { CowModel } from './entities/cow.entity';
|
import { CowModel } from './entities/cow.entity';
|
||||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||||
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||||
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
|
|
||||||
import { MptModel } from '../mpt/entities/mpt.entity';
|
|
||||||
import { FilterEngineModule } from '../shared/filter/filter-engine.module';
|
import { FilterEngineModule } from '../shared/filter/filter-engine.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -29,8 +27,6 @@ import { FilterEngineModule } from '../shared/filter/filter-engine.module';
|
|||||||
CowModel, // 개체 기본 정보 (tb_cow)
|
CowModel, // 개체 기본 정보 (tb_cow)
|
||||||
GenomeRequestModel, // 유전체 분석 의뢰 (tb_genome_request)
|
GenomeRequestModel, // 유전체 분석 의뢰 (tb_genome_request)
|
||||||
GenomeTraitDetailModel, // 유전체 형질 상세 (tb_genome_trait_detail)
|
GenomeTraitDetailModel, // 유전체 형질 상세 (tb_genome_trait_detail)
|
||||||
GeneDetailModel, // 유전자 상세 (tb_gene_detail)
|
|
||||||
MptModel, // 번식능력 (tb_mpt)
|
|
||||||
]),
|
]),
|
||||||
FilterEngineModule, // 필터 엔진 모듈
|
FilterEngineModule, // 필터 엔진 모듈
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
* 개체(Cow) 서비스
|
* 개체(Cow) 서비스
|
||||||
* ============================================================
|
* ============================================================
|
||||||
*
|
*
|
||||||
* 사용 페이지: 개체 목록 페이지 (/cow), 개체 상세 페이지 (/cow/:cowNo)
|
* 사용 페이지: 개체 목록 페이지 (/cow)
|
||||||
*
|
*
|
||||||
* 주요 기능:
|
* 주요 기능:
|
||||||
* 1. 개체 단건 조회 (findByCowId)
|
* 1. 기본 개체 목록 조회 (findAll, findByFarmId)
|
||||||
* 2. 랭킹 적용 개체 목록 조회 (findAllWithRanking)
|
* 2. 개체 단건 조회 (findOne, findByCowId)
|
||||||
|
* 3. 랭킹 적용 개체 목록 조회 (findAllWithRanking)
|
||||||
* - GENOME: 35개 형질 EBV 가중 평균
|
* - GENOME: 35개 형질 EBV 가중 평균
|
||||||
|
* 4. 개체 CRUD (create, update, remove)
|
||||||
* ============================================================
|
* ============================================================
|
||||||
*/
|
*/
|
||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
@@ -17,16 +19,20 @@ import { Repository, IsNull } from 'typeorm';
|
|||||||
import { CowModel } from './entities/cow.entity';
|
import { CowModel } from './entities/cow.entity';
|
||||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||||
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||||
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
|
|
||||||
import { MptModel } from '../mpt/entities/mpt.entity';
|
|
||||||
import { FilterEngineService } from '../shared/filter/filter-engine.service';
|
import { FilterEngineService } from '../shared/filter/filter-engine.service';
|
||||||
import {
|
import {
|
||||||
RankingRequestDto,
|
RankingRequestDto,
|
||||||
RankingCriteriaType,
|
RankingCriteriaType,
|
||||||
TraitRankingConditionDto,
|
TraitRankingCondition,
|
||||||
} from './dto/ranking-request.dto';
|
} from './dto/ranking-request.dto';
|
||||||
import { isValidGenomeAnalysis, EXCLUDED_COW_IDS } from '../common/config/GenomeAnalysisConfig';
|
import { isValidGenomeAnalysis } from '../common/config/GenomeAnalysisConfig';
|
||||||
import { ALL_TRAITS, NEGATIVE_TRAITS } from '../common/const/TraitTypes';
|
|
||||||
|
/**
|
||||||
|
* 낮을수록 좋은 형질 목록 (부호 반전 필요)
|
||||||
|
* - 등지방두께: 지방이 얇을수록(EBV가 낮을수록) 좋은 형질
|
||||||
|
* - 선발지수 계산 시 EBV 부호를 반전하여 적용
|
||||||
|
*/
|
||||||
|
const NEGATIVE_TRAITS = ['등지방두께'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 개체(소) 관리 서비스
|
* 개체(소) 관리 서비스
|
||||||
@@ -51,30 +57,69 @@ export class CowService {
|
|||||||
@InjectRepository(GenomeTraitDetailModel)
|
@InjectRepository(GenomeTraitDetailModel)
|
||||||
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
|
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
|
||||||
|
|
||||||
// 유전자 상세 Repository (SNP 데이터 접근용)
|
|
||||||
@InjectRepository(GeneDetailModel)
|
|
||||||
private readonly geneDetailRepository: Repository<GeneDetailModel>,
|
|
||||||
|
|
||||||
// 번식능력 Repository (MPT 데이터 접근용)
|
|
||||||
@InjectRepository(MptModel)
|
|
||||||
private readonly mptRepository: Repository<MptModel>,
|
|
||||||
|
|
||||||
// 동적 필터링 서비스 (검색, 정렬, 페이지네이션)
|
// 동적 필터링 서비스 (검색, 정렬, 페이지네이션)
|
||||||
private readonly filterEngineService: FilterEngineService,
|
private readonly filterEngineService: FilterEngineService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 개체 조회 메서드
|
// 기본 조회 메서드
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 개체 목록 조회
|
||||||
|
*
|
||||||
|
* @returns 삭제되지 않은 모든 개체 목록
|
||||||
|
* - farm 관계 데이터 포함
|
||||||
|
* - 등록일(regDt) 기준 내림차순 정렬 (최신순)
|
||||||
|
*/
|
||||||
|
async findAll(): Promise<CowModel[]> {
|
||||||
|
return this.cowRepository.find({
|
||||||
|
where: { delDt: IsNull() }, // 삭제되지 않은 데이터만
|
||||||
|
relations: ['farm'], // 농장 정보 JOIN
|
||||||
|
order: { regDt: 'DESC' }, // 최신순 정렬
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 농장별 개체 목록 조회
|
||||||
|
*
|
||||||
|
* @param farmNo - 농장 PK 번호
|
||||||
|
* @returns 해당 농장의 모든 개체 목록 (최신순)
|
||||||
|
*/
|
||||||
|
async findByFarmId(farmNo: number): Promise<CowModel[]> {
|
||||||
|
return this.cowRepository.find({
|
||||||
|
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
relations: ['farm'],
|
||||||
|
order: { regDt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개체 PK로 단건 조회
|
||||||
|
*
|
||||||
|
* @param id - 개체 PK 번호 (pkCowNo)
|
||||||
|
* @returns 개체 정보 (farm 포함)
|
||||||
|
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||||
|
*/
|
||||||
|
async findOne(id: number): Promise<CowModel> {
|
||||||
|
const cow = await this.cowRepository.findOne({
|
||||||
|
where: { pkCowNo: id, delDt: IsNull() },
|
||||||
|
relations: ['farm'],
|
||||||
|
});
|
||||||
|
if (!cow) {
|
||||||
|
throw new NotFoundException(`Cow #${id} not found`);
|
||||||
|
}
|
||||||
|
return cow;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 개체식별번호(cowId)로 단건 조회
|
* 개체식별번호(cowId)로 단건 조회
|
||||||
*
|
*
|
||||||
* @param cowId - 개체식별번호 (예: KOR002119144049)
|
* @param cowId - 개체식별번호 (예: KOR002119144049)
|
||||||
* @returns 개체 정보 (farm 포함) + dataStatus (데이터 존재 여부)
|
* @returns 개체 정보 (farm 포함)
|
||||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||||
*/
|
*/
|
||||||
async findByCowId(cowId: string): Promise<CowModel & { dataStatus: { hasGenomeData: boolean; hasGeneData: boolean } }> {
|
async findByCowId(cowId: string): Promise<CowModel> {
|
||||||
const cow = await this.cowRepository.findOne({
|
const cow = await this.cowRepository.findOne({
|
||||||
where: { cowId: cowId, delDt: IsNull() },
|
where: { cowId: cowId, delDt: IsNull() },
|
||||||
relations: ['farm'],
|
relations: ['farm'],
|
||||||
@@ -82,26 +127,7 @@ export class CowService {
|
|||||||
if (!cow) {
|
if (!cow) {
|
||||||
throw new NotFoundException(`Cow with cowId ${cowId} not found`);
|
throw new NotFoundException(`Cow with cowId ${cowId} not found`);
|
||||||
}
|
}
|
||||||
|
return cow;
|
||||||
// 데이터 존재 여부 확인 (가벼운 COUNT 쿼리)
|
|
||||||
const [genomeCount, geneCount] = await Promise.all([
|
|
||||||
this.genomeTraitDetailRepository.count({
|
|
||||||
where: { cowId, delDt: IsNull() },
|
|
||||||
take: 1,
|
|
||||||
}),
|
|
||||||
this.geneDetailRepository.count({
|
|
||||||
where: { cowId, delDt: IsNull() },
|
|
||||||
take: 1,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...cow,
|
|
||||||
dataStatus: {
|
|
||||||
hasGenomeData: genomeCount > 0,
|
|
||||||
hasGeneData: geneCount > 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -126,33 +152,23 @@ export class CowService {
|
|||||||
const { filterOptions, rankingOptions } = rankingRequest;
|
const { filterOptions, rankingOptions } = rankingRequest;
|
||||||
const { criteriaType } = rankingOptions;
|
const { criteriaType } = rankingOptions;
|
||||||
|
|
||||||
// Step 2: 필터 조건에 맞는 개체 목록 조회 (+ MPT cowId Set)
|
// Step 2: 필터 조건에 맞는 개체 목록 조회
|
||||||
const { cows, mptCowIdMap } = await this.getFilteredCows(filterOptions);
|
const cows = await this.getFilteredCows(filterOptions);
|
||||||
|
|
||||||
// Step 3: 랭킹 기준에 따라 분기 처리
|
// Step 3: 랭킹 기준에 따라 분기 처리
|
||||||
switch (criteriaType) {
|
switch (criteriaType) {
|
||||||
// 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
|
// 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
|
||||||
// 지금은 유전체 형질만 기반으로 랭킹을 매기고 있음 추후 유전자와 유전체 복합 랭킹 변경될수있음
|
|
||||||
// case 추가 예정
|
|
||||||
case RankingCriteriaType.GENOME:
|
case RankingCriteriaType.GENOME:
|
||||||
return this.applyGenomeRanking(cows, rankingOptions.traitConditions || [], mptCowIdMap);
|
return this.applyGenomeRanking(cows, rankingOptions.traitConditions || []);
|
||||||
|
|
||||||
// 기본값: 랭킹 없이 순서대로 반환
|
// 기본값: 랭킹 없이 순서대로 반환
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
items: cows.map((cow, index) => {
|
items: cows.map((cow, index) => ({
|
||||||
const mptData = mptCowIdMap.get(cow.cowId);
|
entity: cow,
|
||||||
return {
|
|
||||||
entity: {
|
|
||||||
...cow,
|
|
||||||
hasMpt: mptCowIdMap.has(cow.cowId),
|
|
||||||
mptTestDt: mptData?.testDt || null,
|
|
||||||
mptMonthAge: mptData?.monthAge || null,
|
|
||||||
},
|
|
||||||
rank: index + 1,
|
rank: index + 1,
|
||||||
sortValue: 0,
|
sortValue: 0,
|
||||||
};
|
})),
|
||||||
}),
|
|
||||||
total: cows.length,
|
total: cows.length,
|
||||||
criteriaType,
|
criteriaType,
|
||||||
};
|
};
|
||||||
@@ -161,94 +177,16 @@ export class CowService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 필터 조건에 맞는 개체 목록 조회 (Private)
|
* 필터 조건에 맞는 개체 목록 조회 (Private)
|
||||||
* 유전체 분석 의뢰/유전체 형질/유전자/번식능력(MPT) 데이터 중 하나라도 있는 개체 조회
|
|
||||||
*
|
*
|
||||||
* @param filterOptions - 필터/정렬/페이지네이션 옵션
|
* @param filterOptions - 필터/정렬/페이지네이션 옵션
|
||||||
* @returns { cows: 필터링된 개체 목록, mptCowIdMap: MPT cowId -> { testDt, monthAge } Map }
|
* @returns 필터링된 개체 목록
|
||||||
*/
|
*/
|
||||||
private async getFilteredCows(filterOptions?: any): Promise<{ cows: CowModel[], mptCowIdMap: Map<string, { testDt: string; monthAge: number }> }> {
|
private async getFilteredCows(filterOptions?: any): Promise<CowModel[]> {
|
||||||
// Step 1: 4가지 데이터 소스에서 cowId 수집 (병렬 처리)
|
// QueryBuilder로 기본 쿼리 구성
|
||||||
const [genomeRequestCowIds, genomeCowIds, geneCowIds, mptCowIds] = await Promise.all([
|
|
||||||
// 유전체 분석 의뢰가 있고, 유효한 형질 데이터가 있는 개체의 cowId만 조회
|
|
||||||
// (genomeScore가 null이지만 anlysDt가 있는 경우 제외)
|
|
||||||
this.genomeRequestRepository
|
|
||||||
.createQueryBuilder('request')
|
|
||||||
.innerJoin('request.cow', 'cow')
|
|
||||||
.innerJoin('request.traitDetails', 'trait', 'trait.delDt IS NULL AND trait.traitEbv IS NOT NULL')
|
|
||||||
.select('DISTINCT cow.cowId', 'cowId')
|
|
||||||
.where('request.delDt IS NULL')
|
|
||||||
.andWhere('request.requestDt IS NOT NULL')
|
|
||||||
.getRawMany(),
|
|
||||||
// 유전체 형질 데이터가 있는 cowId (유효한 EBV 값이 있는 경우만)
|
|
||||||
this.genomeTraitDetailRepository
|
|
||||||
.createQueryBuilder('trait')
|
|
||||||
.select('DISTINCT trait.cowId', 'cowId')
|
|
||||||
.where('trait.delDt IS NULL')
|
|
||||||
.andWhere('trait.traitEbv IS NOT NULL')
|
|
||||||
.getRawMany(),
|
|
||||||
// 유전자 데이터가 있는 cowId
|
|
||||||
this.geneDetailRepository
|
|
||||||
.createQueryBuilder('gene')
|
|
||||||
.select('DISTINCT gene.cowId', 'cowId')
|
|
||||||
.where('gene.delDt IS NULL')
|
|
||||||
.getRawMany(),
|
|
||||||
// 번식능력(MPT) 데이터가 있는 cowId와 최신 검사일/월령
|
|
||||||
// cowId별 최신 검사일 기준으로 중복 제거 (GROUP BY)
|
|
||||||
this.mptRepository
|
|
||||||
.createQueryBuilder('mpt')
|
|
||||||
.select('mpt.cowId', 'cowId')
|
|
||||||
.addSelect('MAX(mpt.testDt)', 'testDt')
|
|
||||||
.addSelect('MAX(mpt.monthAge)', 'monthAge')
|
|
||||||
.where('mpt.delDt IS NULL')
|
|
||||||
.groupBy('mpt.cowId')
|
|
||||||
.getRawMany(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Step 2: cowId 통합 (중복 제거)
|
|
||||||
const allCowIds = [...new Set([
|
|
||||||
...genomeRequestCowIds.map(c => c.cowId).filter(Boolean),
|
|
||||||
...genomeCowIds.map(c => c.cowId).filter(Boolean),
|
|
||||||
...geneCowIds.map(c => c.cowId).filter(Boolean),
|
|
||||||
...mptCowIds.map(c => c.cowId).filter(Boolean),
|
|
||||||
])];
|
|
||||||
|
|
||||||
// MPT cowId -> { testDt, monthAge } Map 생성
|
|
||||||
const mptCowIdMap = new Map<string, { testDt: string; monthAge: number }>(
|
|
||||||
mptCowIds
|
|
||||||
.filter(c => c.cowId)
|
|
||||||
.map(c => [c.cowId, { testDt: c.testDt, monthAge: c.monthAge }])
|
|
||||||
);
|
|
||||||
|
|
||||||
// 데이터가 있는 개체가 없으면 빈 배열 반환 (단, 테스트 농장 예외)
|
|
||||||
const TEST_FARM_NO = 26; // 코쿤 테스트 농장
|
|
||||||
|
|
||||||
// farmNo 체크: filterOptions.farmNo 또는 filterOptions.filters에서 추출
|
|
||||||
let isTestFarm = Number(filterOptions?.farmNo) === TEST_FARM_NO;
|
|
||||||
if (!isTestFarm && filterOptions?.filters) {
|
|
||||||
const farmFilter = filterOptions.filters.find(
|
|
||||||
(f: { field: string; value: number | number[] }) => f.field === 'cow.fkFarmNo'
|
|
||||||
);
|
|
||||||
if (farmFilter) {
|
|
||||||
const farmNos = Array.isArray(farmFilter.value) ? farmFilter.value : [farmFilter.value];
|
|
||||||
// 숫자/문자열 모두 처리 (프론트에서 문자열로 올 수 있음)
|
|
||||||
isTestFarm = farmNos.map(Number).includes(TEST_FARM_NO);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allCowIds.length === 0 && !isTestFarm) {
|
|
||||||
return { cows: [], mptCowIdMap };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: 해당 cowId로 개체 조회
|
|
||||||
const queryBuilder = this.cowRepository
|
const queryBuilder = this.cowRepository
|
||||||
.createQueryBuilder('cow')
|
.createQueryBuilder('cow')
|
||||||
.leftJoinAndSelect('cow.farm', 'farm')
|
.leftJoinAndSelect('cow.farm', 'farm') // 농장 정보 JOIN
|
||||||
.where('cow.delDt IS NULL');
|
.where('cow.delDt IS NULL'); // 삭제되지 않은 데이터만
|
||||||
|
|
||||||
// 테스트 농장(26번)은 tb_cow 전체 조회, 그 외는 데이터 있는 개체만
|
|
||||||
if (!isTestFarm && allCowIds.length > 0) {
|
|
||||||
queryBuilder.andWhere('cow.cowId IN (:...cowIds)', { cowIds: allCowIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
// farmNo가 직접 전달된 경우 처리 (프론트엔드 호환성)
|
// farmNo가 직접 전달된 경우 처리 (프론트엔드 호환성)
|
||||||
if (filterOptions?.farmNo) {
|
if (filterOptions?.farmNo) {
|
||||||
@@ -257,29 +195,17 @@ export class CowService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilterEngine 사용하여 동적 필터 적용 (페이지네이션 없이 전체 조회)
|
// FilterEngine 사용하여 동적 필터 적용
|
||||||
if (filterOptions?.filters) {
|
if (filterOptions?.filters) {
|
||||||
const result = await this.filterEngineService.executeFilteredQuery(
|
const result = await this.filterEngineService.executeFilteredQuery(
|
||||||
queryBuilder,
|
queryBuilder,
|
||||||
{
|
filterOptions,
|
||||||
...filterOptions,
|
|
||||||
pagination: { page: 1, limit: 10000 }, // 전체 조회 (프론트에서 페이지네이션 처리)
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
// cowId 기준 중복 제거 (tb_cow에 같은 cowId가 여러 row일 수 있음)
|
return result.data;
|
||||||
const uniqueCows = Array.from(
|
|
||||||
new Map(result.data.map((cow: CowModel) => [cow.cowId, cow])).values()
|
|
||||||
);
|
|
||||||
return { cows: uniqueCows, mptCowIdMap };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필터 없으면 전체 조회 (최신순)
|
// 필터 없으면 전체 조회 (최신순)
|
||||||
const cows = await queryBuilder.orderBy('cow.regDt', 'DESC').getMany();
|
return queryBuilder.orderBy('cow.regDt', 'DESC').getMany();
|
||||||
// cowId 기준 중복 제거
|
|
||||||
const uniqueCows = Array.from(
|
|
||||||
new Map(cows.map(cow => [cow.cowId, cow])).values()
|
|
||||||
);
|
|
||||||
return { cows: uniqueCows, mptCowIdMap };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -304,9 +230,19 @@ export class CowService {
|
|||||||
*/
|
*/
|
||||||
private async applyGenomeRanking(
|
private async applyGenomeRanking(
|
||||||
cows: CowModel[],
|
cows: CowModel[],
|
||||||
inputTraitConditions: TraitRankingConditionDto[],
|
inputTraitConditions: TraitRankingCondition[],
|
||||||
mptCowIdMap: Map<string, { testDt: string; monthAge: number }>,
|
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
|
// 35개 전체 형질 (기본값)
|
||||||
|
const ALL_TRAITS = [
|
||||||
|
'12개월령체중',
|
||||||
|
'도체중', '등심단면적', '등지방두께', '근내지방도',
|
||||||
|
'체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
||||||
|
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
||||||
|
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
||||||
|
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
||||||
|
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
||||||
|
];
|
||||||
|
|
||||||
// traitConditions가 비어있으면 35개 전체 형질 사용 (개체상세, 대시보드와 동일)
|
// traitConditions가 비어있으면 35개 전체 형질 사용 (개체상세, 대시보드와 동일)
|
||||||
const traitConditions = inputTraitConditions && inputTraitConditions.length > 0
|
const traitConditions = inputTraitConditions && inputTraitConditions.length > 0
|
||||||
? inputTraitConditions
|
? inputTraitConditions
|
||||||
@@ -318,47 +254,23 @@ export class CowService {
|
|||||||
// Step 1: 해당 개체의 최신 유전체 분석 의뢰 조회 (친자감별 확인용)
|
// Step 1: 해당 개체의 최신 유전체 분석 의뢰 조회 (친자감별 확인용)
|
||||||
const latestRequest = await this.genomeRequestRepository.findOne({
|
const latestRequest = await this.genomeRequestRepository.findOne({
|
||||||
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
|
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
|
||||||
order: {
|
order: { requestDt: 'DESC', regDt: 'DESC' },
|
||||||
requestDt: 'DESC',
|
|
||||||
regDt: 'DESC' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 2: 친자감별 확인 - 유효하지 않으면 분석 불가
|
// Step 2: 친자감별 확인 - 유효하지 않으면 분석 불가
|
||||||
if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) {
|
if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) {
|
||||||
// 분석불가 사유 결정
|
// 분석불가 사유 결정
|
||||||
let unavailableReason: string | null = null;
|
let unavailableReason = '분석불가';
|
||||||
|
if (latestRequest) {
|
||||||
// EXCLUDED_COW_IDS에 포함된 개체 (모근 오염/불량 등 기타 사유)
|
if (latestRequest.chipSireName !== '일치') {
|
||||||
if (EXCLUDED_COW_IDS.includes(cow.cowId)) {
|
|
||||||
unavailableReason = '분석불가';
|
|
||||||
} else if (!latestRequest || !latestRequest.chipSireName) {
|
|
||||||
// latestRequest 없거나 chipSireName이 null → '-' 표시 (프론트에서 null은 '-'로 표시)
|
|
||||||
unavailableReason = null;
|
|
||||||
} else if (latestRequest.chipSireName === '분석불가' || latestRequest.chipSireName === '정보없음') {
|
|
||||||
// 분석불가, 정보없음 → 분석불가
|
|
||||||
unavailableReason = '분석불가';
|
|
||||||
} else if (latestRequest.chipSireName !== '일치') {
|
|
||||||
// 불일치 등 그 외 → 부 불일치
|
|
||||||
unavailableReason = '부 불일치';
|
unavailableReason = '부 불일치';
|
||||||
} else if (latestRequest.chipDamName === '불일치') {
|
} else if (latestRequest.chipDamName === '불일치') {
|
||||||
unavailableReason = '모 불일치';
|
unavailableReason = '모 불일치';
|
||||||
} else if (latestRequest.chipDamName === '이력제부재') {
|
} else if (latestRequest.chipDamName === '이력제부재') {
|
||||||
unavailableReason = '모 이력제부재';
|
unavailableReason = '모 이력제부재';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const mptData = mptCowIdMap.get(cow.cowId);
|
return { entity: { ...cow, unavailableReason }, sortValue: null, details: [] };
|
||||||
return {
|
|
||||||
entity: {
|
|
||||||
...cow,
|
|
||||||
unavailableReason,
|
|
||||||
hasMpt: mptCowIdMap.has(cow.cowId),
|
|
||||||
mptTestDt: mptData?.testDt || null,
|
|
||||||
mptMonthAge: mptData?.monthAge || null,
|
|
||||||
anlysDt: latestRequest?.requestDt ?? null,
|
|
||||||
},
|
|
||||||
sortValue: null,
|
|
||||||
details: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: cowId로 직접 형질 상세 데이터 조회 (35개 형질의 EBV 값)
|
// Step 3: cowId로 직접 형질 상세 데이터 조회 (35개 형질의 EBV 값)
|
||||||
@@ -368,21 +280,10 @@ export class CowService {
|
|||||||
|
|
||||||
// 형질 데이터가 없으면 점수 null (친자는 일치하지만 형질 데이터 없음)
|
// 형질 데이터가 없으면 점수 null (친자는 일치하지만 형질 데이터 없음)
|
||||||
if (traitDetails.length === 0) {
|
if (traitDetails.length === 0) {
|
||||||
const mptData = mptCowIdMap.get(cow.cowId);
|
return { entity: { ...cow, unavailableReason: '형질정보없음' }, sortValue: null, details: [] };
|
||||||
return {
|
|
||||||
entity: {
|
|
||||||
...cow,
|
|
||||||
unavailableReason: '형질정보없음',
|
|
||||||
hasMpt: mptCowIdMap.has(cow.cowId),
|
|
||||||
mptTestDt: mptData?.testDt || null,
|
|
||||||
mptMonthAge: mptData?.monthAge || null,
|
|
||||||
},
|
|
||||||
sortValue: null,
|
|
||||||
details: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: 가중 합계 계산 ====================================================
|
// Step 4: 가중 합계 계산
|
||||||
let weightedSum = 0; // 가중치 적용된 EBV 합계
|
let weightedSum = 0; // 가중치 적용된 EBV 합계
|
||||||
let totalWeight = 0; // 총 가중치
|
let totalWeight = 0; // 총 가중치
|
||||||
let hasAllTraits = true; // 모든 선택 형질 존재 여부
|
let hasAllTraits = true; // 모든 선택 형질 존재 여부
|
||||||
@@ -421,15 +322,11 @@ export class CowService {
|
|||||||
? weightedSum // 가중 합계 (개체상세, 대시보드와 동일한 방식)
|
? weightedSum // 가중 합계 (개체상세, 대시보드와 동일한 방식)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Step 7: 응답 데이터 구성 (반환 값)
|
// Step 7: 응답 데이터 구성
|
||||||
const mptData = mptCowIdMap.get(cow.cowId);
|
|
||||||
return {
|
return {
|
||||||
entity: {
|
entity: {
|
||||||
...cow,
|
...cow,
|
||||||
anlysDt: latestRequest.requestDt, // 분석일자 추가
|
anlysDt: latestRequest.requestDt, // 분석일자 추가
|
||||||
hasMpt: mptCowIdMap.has(cow.cowId), // MPT 검사 여부
|
|
||||||
mptTestDt: mptData?.testDt || null, // MPT 검사일
|
|
||||||
mptMonthAge: mptData?.monthAge || null, // MPT 월령
|
|
||||||
},
|
},
|
||||||
sortValue, // 계산된 종합 점수 (선발지수)
|
sortValue, // 계산된 종합 점수 (선발지수)
|
||||||
details, // 점수 계산에 사용된 형질별 상세
|
details, // 점수 계산에 사용된 형질별 상세
|
||||||
@@ -469,27 +366,8 @@ export class CowService {
|
|||||||
// "criteriaType": "GENOME"
|
// "criteriaType": "GENOME"
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// Step 8: genomeScore(sortValue)가 null/undefined이고 anlysDt가 있는 데이터 제외
|
// Step 8: 점수 기준 내림차순 정렬
|
||||||
// 단, 부모불일치인 개체는 조회되어야 하므로 제외하지 않음
|
const sorted = cowsWithScore.sort((a, b) => {
|
||||||
const filteredCows = cowsWithScore.filter((item) => {
|
|
||||||
const sortValue = item.sortValue;
|
|
||||||
const anlysDt = (item.entity as any).anlysDt;
|
|
||||||
const unavailableReason = (item.entity as any).unavailableReason;
|
|
||||||
|
|
||||||
// 부모불일치 관련 사유인 경우는 조회 유지
|
|
||||||
const isParentMismatch = unavailableReason === '부 불일치' ||
|
|
||||||
unavailableReason === '모 불일치' ||
|
|
||||||
unavailableReason === '모 이력제부재';
|
|
||||||
|
|
||||||
// sortValue가 null/undefined이고 anlysDt가 있지만, 부모불일치가 아닌 경우만 제외
|
|
||||||
if ((sortValue === null || sortValue === undefined) && anlysDt && !isParentMismatch) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 9: 점수 기준 내림차순 정렬
|
|
||||||
const sorted = filteredCows.sort((a, b) => {
|
|
||||||
// null 값은 맨 뒤로
|
// null 값은 맨 뒤로
|
||||||
if (a.sortValue === null && b.sortValue === null) return 0;
|
if (a.sortValue === null && b.sortValue === null) return 0;
|
||||||
if (a.sortValue === null) return 1;
|
if (a.sortValue === null) return 1;
|
||||||
@@ -498,7 +376,7 @@ export class CowService {
|
|||||||
return b.sortValue - a.sortValue;
|
return b.sortValue - a.sortValue;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 10: 순위 부여 후 반환
|
// Step 9: 순위 부여 후 반환
|
||||||
return {
|
return {
|
||||||
items: sorted.map((item, index) => ({
|
items: sorted.map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -509,4 +387,45 @@ export class CowService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CRUD 메서드
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새로운 개체 생성
|
||||||
|
*
|
||||||
|
* @param data - 생성할 개체 데이터
|
||||||
|
* @returns 생성된 개체 엔티티
|
||||||
|
*/
|
||||||
|
async create(data: Partial<CowModel>): Promise<CowModel> {
|
||||||
|
const cow = this.cowRepository.create(data);
|
||||||
|
return this.cowRepository.save(cow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개체 정보 수정
|
||||||
|
*
|
||||||
|
* @param id - 개체 PK 번호
|
||||||
|
* @param data - 수정할 데이터
|
||||||
|
* @returns 수정된 개체 엔티티
|
||||||
|
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||||
|
*/
|
||||||
|
async update(id: number, data: Partial<CowModel>): Promise<CowModel> {
|
||||||
|
await this.findOne(id); // 존재 여부 확인
|
||||||
|
await this.cowRepository.update(id, data);
|
||||||
|
return this.findOne(id); // 수정된 데이터 반환
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개체 삭제 (Soft Delete)
|
||||||
|
*
|
||||||
|
* 실제 삭제가 아닌 delDt 컬럼에 삭제 시간 기록
|
||||||
|
*
|
||||||
|
* @param id - 개체 PK 번호
|
||||||
|
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||||
|
*/
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
const cow = await this.findOne(id); // 존재 여부 확인
|
||||||
|
await this.cowRepository.softRemove(cow);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,30 +12,69 @@
|
|||||||
* ============================================================
|
* ============================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
/**
|
||||||
IsEnum,
|
* 랭킹 기준 타입
|
||||||
IsOptional,
|
* - GENOME: 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
|
||||||
IsArray,
|
*/
|
||||||
IsString,
|
export enum RankingCriteriaType {
|
||||||
IsNumber,
|
GENOME = 'GENOME',
|
||||||
Min,
|
}
|
||||||
Max,
|
|
||||||
ValidateNested,
|
|
||||||
} from 'class-validator';
|
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
import {
|
|
||||||
FilterCondition,
|
|
||||||
SortOption,
|
|
||||||
PaginationOption,
|
|
||||||
FilterEngineOptions,
|
|
||||||
} from '../../shared/filter/interfaces/filter.interface';
|
|
||||||
import { RankingCriteriaType } from '../../common/const/RankingCriteriaType';
|
|
||||||
|
|
||||||
// Re-export for convenience
|
|
||||||
export { RankingCriteriaType };
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 랭킹 조건 DTO
|
// 필터 관련 타입 (FilterEngine에서 사용)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export type FilterOperator =
|
||||||
|
| 'eq' // 같음
|
||||||
|
| 'ne' // 같지 않음
|
||||||
|
| 'gt' // 초과
|
||||||
|
| 'gte' // 이상
|
||||||
|
| 'lt' // 미만
|
||||||
|
| 'lte' // 이하
|
||||||
|
| 'like' // 포함 (문자열)
|
||||||
|
| 'in' // 배열 내 포함
|
||||||
|
| 'between'; // 범위
|
||||||
|
|
||||||
|
export type SortOrder = 'ASC' | 'DESC';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 조건
|
||||||
|
* 예: { field: 'cowSex', operator: 'eq', value: 'F' }
|
||||||
|
*/
|
||||||
|
export interface FilterCondition {
|
||||||
|
field: string;
|
||||||
|
operator: FilterOperator;
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정렬 옵션
|
||||||
|
*/
|
||||||
|
export interface SortOption {
|
||||||
|
field: string;
|
||||||
|
order: SortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지네이션 옵션
|
||||||
|
*/
|
||||||
|
export interface PaginationOption {
|
||||||
|
page: number; // 페이지 번호 (1부터 시작)
|
||||||
|
limit: number; // 페이지당 개수
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 엔진 옵션
|
||||||
|
* - 개체 목록 필터링에 사용
|
||||||
|
*/
|
||||||
|
export interface FilterEngineOptions {
|
||||||
|
filters?: FilterCondition[];
|
||||||
|
sorts?: SortOption[];
|
||||||
|
pagination?: PaginationOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 랭킹 조건 타입
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,62 +84,21 @@ export { RankingCriteriaType };
|
|||||||
*
|
*
|
||||||
* 예: { traitNm: '도체중', weight: 8 }
|
* 예: { traitNm: '도체중', weight: 8 }
|
||||||
*/
|
*/
|
||||||
export class TraitRankingConditionDto {
|
export interface TraitRankingCondition {
|
||||||
@IsString()
|
|
||||||
traitNm: string; // 형질명 (예: '도체중', '근내지방도')
|
traitNm: string; // 형질명 (예: '도체중', '근내지방도')
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(1)
|
|
||||||
@Max(10)
|
|
||||||
weight?: number; // 가중치 1~10 (기본값: 1)
|
weight?: number; // 가중치 1~10 (기본값: 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 랭킹 옵션 DTO
|
* 랭킹 옵션
|
||||||
*/
|
*/
|
||||||
export class RankingOptionsDto {
|
export interface RankingOptions {
|
||||||
@IsEnum(RankingCriteriaType)
|
|
||||||
criteriaType: RankingCriteriaType; // 랭킹 기준 타입
|
criteriaType: RankingCriteriaType; // 랭킹 기준 타입
|
||||||
|
traitConditions?: TraitRankingCondition[]; // GENOME용: 형질별 가중치
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
@ValidateNested({ each: true })
|
|
||||||
@Type(() => TraitRankingConditionDto)
|
|
||||||
traitConditions?: TraitRankingConditionDto[]; // GENOME용: 형질별 가중치
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(1)
|
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 필터 옵션 DTO (FilterEngine용)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필터 엔진 옵션 DTO
|
|
||||||
* - 개체 목록 필터링에 사용
|
|
||||||
*/
|
|
||||||
export class FilterEngineOptionsDto implements FilterEngineOptions {
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
filters?: FilterCondition[];
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
sorts?: SortOption[];
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
pagination?: PaginationOption;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 메인 요청 DTO
|
// 메인 요청 DTO
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -125,13 +123,7 @@ export class FilterEngineOptionsDto implements FilterEngineOptions {
|
|||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export class RankingRequestDto {
|
export interface RankingRequestDto {
|
||||||
@IsOptional()
|
filterOptions?: FilterEngineOptions; // 필터/정렬/페이지네이션
|
||||||
@ValidateNested()
|
rankingOptions: RankingOptions; // 랭킹 조건
|
||||||
@Type(() => FilterEngineOptionsDto)
|
|
||||||
filterOptions?: FilterEngineOptionsDto; // 필터/정렬/페이지네이션
|
|
||||||
|
|
||||||
@ValidateNested()
|
|
||||||
@Type(() => RankingOptionsDto)
|
|
||||||
rankingOptions: RankingOptionsDto; // 랭킹 조건
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export class CowModel extends BaseModel {
|
|||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
length: 1,
|
length: 1,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
comment: '성별 (암/수)',
|
comment: '성별 (M/F)',
|
||||||
})
|
})
|
||||||
cowSex: string;
|
cowSex: string;
|
||||||
|
|
||||||
|
|||||||
150
backend/src/dashboard/dashboard.controller.ts
Normal file
150
backend/src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
|
import { DashboardService } from './dashboard.service';
|
||||||
|
import { DashboardFilterDto } from './dto/dashboard-filter.dto';
|
||||||
|
|
||||||
|
@Controller('dashboard')
|
||||||
|
export class DashboardController {
|
||||||
|
constructor(private readonly dashboardService: DashboardService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/summary/:farmNo - 농장 현황 요약
|
||||||
|
*/
|
||||||
|
@Get('summary/:farmNo')
|
||||||
|
getFarmSummary(
|
||||||
|
@Param('farmNo') farmNo: string,
|
||||||
|
@Query() filter: DashboardFilterDto,
|
||||||
|
) {
|
||||||
|
return this.dashboardService.getFarmSummary(+farmNo, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/analysis-completion/:farmNo - 분석 완료 현황
|
||||||
|
*/
|
||||||
|
@Get('analysis-completion/:farmNo')
|
||||||
|
getAnalysisCompletion(
|
||||||
|
@Param('farmNo') farmNo: string,
|
||||||
|
@Query() filter: DashboardFilterDto,
|
||||||
|
) {
|
||||||
|
return this.dashboardService.getAnalysisCompletion(+farmNo, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/evaluation/:farmNo - 농장 종합 평가
|
||||||
|
*/
|
||||||
|
@Get('evaluation/:farmNo')
|
||||||
|
getFarmEvaluation(
|
||||||
|
@Param('farmNo') farmNo: string,
|
||||||
|
@Query() filter: DashboardFilterDto,
|
||||||
|
) {
|
||||||
|
return this.dashboardService.getFarmEvaluation(+farmNo, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/region-comparison/:farmNo - 보은군 비교 분석
|
||||||
|
*/
|
||||||
|
@Get('region-comparison/:farmNo')
|
||||||
|
getRegionComparison(
|
||||||
|
@Param('farmNo') farmNo: string,
|
||||||
|
@Query() filter: DashboardFilterDto,
|
||||||
|
) {
|
||||||
|
return this.dashboardService.getRegionComparison(+farmNo, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/cow-distribution/:farmNo - 개체 분포 분석
|
||||||
|
*/
|
||||||
|
@Get('cow-distribution/:farmNo')
|
||||||
|
getCowDistribution(
|
||||||
|
@Param('farmNo') farmNo: string,
|
||||||
|
@Query() filter: DashboardFilterDto,
|
||||||
|
) {
|
||||||
|
return this.dashboardService.getCowDistribution(+farmNo, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/kpn-aggregation/:farmNo - KPN 추천 집계
|
||||||
|
*/
|
||||||
|
@Get('kpn-aggregation/:farmNo')
|
||||||
|
getKpnRecommendationAggregation(
|
||||||
|
@Param('farmNo') farmNo: string,
|
||||||
|
@Query() filter: DashboardFilterDto,
|
||||||
|
) {
|
||||||
|
return this.dashboardService.getKpnRecommendationAggregation(+farmNo, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/farm-kpn-inventory/:farmNo - 농장 보유 KPN 목록
|
||||||
|
*/
|
||||||
|
@Get('farm-kpn-inventory/:farmNo')
|
||||||
|
getFarmKpnInventory(@Param('farmNo') farmNo: string) {
|
||||||
|
return this.dashboardService.getFarmKpnInventory(+farmNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/analysis-years/:farmNo - 농장 분석 이력 연도 목록
|
||||||
|
*/
|
||||||
|
@Get('analysis-years/:farmNo')
|
||||||
|
getAnalysisYears(@Param('farmNo') farmNo: string) {
|
||||||
|
return this.dashboardService.getAnalysisYears(+farmNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/analysis-years/:farmNo/latest - 최신 분석 연도
|
||||||
|
*/
|
||||||
|
@Get('analysis-years/:farmNo/latest')
|
||||||
|
getLatestAnalysisYear(@Param('farmNo') farmNo: string) {
|
||||||
|
return this.dashboardService.getLatestAnalysisYear(+farmNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/year-comparison/:farmNo - 3개년 비교 분석
|
||||||
|
*/
|
||||||
|
@Get('year-comparison/:farmNo')
|
||||||
|
getYearComparison(@Param('farmNo') farmNo: string) {
|
||||||
|
return this.dashboardService.getYearComparison(+farmNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/repro-efficiency/:farmNo - 번식 효율성 분석
|
||||||
|
*/
|
||||||
|
@Get('repro-efficiency/:farmNo')
|
||||||
|
getReproEfficiency(
|
||||||
|
@Param('farmNo') farmNo: string,
|
||||||
|
@Query() filter: DashboardFilterDto,
|
||||||
|
) {
|
||||||
|
return this.dashboardService.getReproEfficiency(+farmNo, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/excellent-cows/:farmNo - 우수개체 추천
|
||||||
|
*/
|
||||||
|
@Get('excellent-cows/:farmNo')
|
||||||
|
getExcellentCows(
|
||||||
|
@Param('farmNo') farmNo: string,
|
||||||
|
@Query() filter: DashboardFilterDto,
|
||||||
|
) {
|
||||||
|
return this.dashboardService.getExcellentCows(+farmNo, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/cull-cows/:farmNo - 도태개체 추천
|
||||||
|
*/
|
||||||
|
@Get('cull-cows/:farmNo')
|
||||||
|
getCullCows(
|
||||||
|
@Param('farmNo') farmNo: string,
|
||||||
|
@Query() filter: DashboardFilterDto,
|
||||||
|
) {
|
||||||
|
return this.dashboardService.getCullCows(+farmNo, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/cattle-ranking/:farmNo - 보은군 내 소 개별 순위
|
||||||
|
*/
|
||||||
|
@Get('cattle-ranking/:farmNo')
|
||||||
|
getCattleRankingInRegion(
|
||||||
|
@Param('farmNo') farmNo: string,
|
||||||
|
@Query() filter: DashboardFilterDto,
|
||||||
|
) {
|
||||||
|
return this.dashboardService.getCattleRankingInRegion(+farmNo, filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/src/dashboard/dashboard.module.ts
Normal file
23
backend/src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { DashboardController } from './dashboard.controller';
|
||||||
|
import { DashboardService } from './dashboard.service';
|
||||||
|
import { CowModel } from '../cow/entities/cow.entity';
|
||||||
|
import { FarmModel } from '../farm/entities/farm.entity';
|
||||||
|
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||||
|
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
CowModel,
|
||||||
|
FarmModel,
|
||||||
|
GenomeRequestModel,
|
||||||
|
GenomeTraitDetailModel,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [DashboardController],
|
||||||
|
providers: [DashboardService],
|
||||||
|
exports: [DashboardService],
|
||||||
|
})
|
||||||
|
export class DashboardModule {}
|
||||||
548
backend/src/dashboard/dashboard.service.ts
Normal file
548
backend/src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, IsNull } from 'typeorm';
|
||||||
|
import { CowModel } from '../cow/entities/cow.entity';
|
||||||
|
import { FarmModel } from '../farm/entities/farm.entity';
|
||||||
|
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||||
|
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||||
|
import { DashboardFilterDto } from './dto/dashboard-filter.dto';
|
||||||
|
import { isValidGenomeAnalysis, VALID_CHIP_SIRE_NAME } from '../common/config/GenomeAnalysisConfig';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DashboardService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(CowModel)
|
||||||
|
private readonly cowRepository: Repository<CowModel>,
|
||||||
|
|
||||||
|
@InjectRepository(FarmModel)
|
||||||
|
private readonly farmRepository: Repository<FarmModel>,
|
||||||
|
|
||||||
|
@InjectRepository(GenomeRequestModel)
|
||||||
|
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
||||||
|
|
||||||
|
@InjectRepository(GenomeTraitDetailModel)
|
||||||
|
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 농장 현황 요약
|
||||||
|
*/
|
||||||
|
async getFarmSummary(farmNo: number, filter?: DashboardFilterDto) {
|
||||||
|
// 농장 정보 조회
|
||||||
|
const farm = await this.farmRepository.findOne({
|
||||||
|
where: { pkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 농장 소 목록 조회
|
||||||
|
const cows = await this.cowRepository.find({
|
||||||
|
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCowCount = cows.length;
|
||||||
|
const maleCowCount = cows.filter(cow => cow.cowSex === 'M').length;
|
||||||
|
const femaleCowCount = cows.filter(cow => cow.cowSex === 'F').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
farmNo,
|
||||||
|
farmName: farm?.farmerName || '농장',
|
||||||
|
totalCowCount,
|
||||||
|
maleCowCount,
|
||||||
|
femaleCowCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분석 완료 현황
|
||||||
|
*/
|
||||||
|
async getAnalysisCompletion(farmNo: number, filter?: DashboardFilterDto) {
|
||||||
|
// 농장의 모든 유전체 분석 의뢰 조회
|
||||||
|
const requests = await this.genomeRequestRepository.find({
|
||||||
|
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
relations: ['cow'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const farmAnlysCnt = requests.length;
|
||||||
|
const matchCnt = requests.filter(r => r.chipSireName === '일치').length;
|
||||||
|
const failCnt = requests.filter(r => r.chipSireName && r.chipSireName !== '일치').length;
|
||||||
|
const noHistCnt = requests.filter(r => !r.chipSireName).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
farmAnlysCnt,
|
||||||
|
matchCnt,
|
||||||
|
failCnt,
|
||||||
|
noHistCnt,
|
||||||
|
paternities: requests.map(r => ({
|
||||||
|
cowNo: r.fkCowNo,
|
||||||
|
cowId: r.cow?.cowId,
|
||||||
|
fatherMatch: r.chipSireName === '일치' ? '일치' : (r.chipSireName ? '불일치' : '미확인'),
|
||||||
|
requestDt: r.requestDt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 농장 종합 평가
|
||||||
|
*/
|
||||||
|
async getFarmEvaluation(farmNo: number, filter?: DashboardFilterDto) {
|
||||||
|
const cows = await this.cowRepository.find({
|
||||||
|
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 각 개체의 유전체 점수 계산
|
||||||
|
const scores: number[] = [];
|
||||||
|
|
||||||
|
for (const cow of cows) {
|
||||||
|
// cowId로 직접 형질 데이터 조회
|
||||||
|
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||||
|
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (traitDetails.length === 0) continue;
|
||||||
|
|
||||||
|
// 모든 형질의 EBV 평균 계산
|
||||||
|
const ebvValues = traitDetails
|
||||||
|
.filter(d => d.traitEbv !== null)
|
||||||
|
.map(d => Number(d.traitEbv));
|
||||||
|
|
||||||
|
if (ebvValues.length > 0) {
|
||||||
|
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||||
|
scores.push(avgEbv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const farmAverage = scores.length > 0
|
||||||
|
? scores.reduce((sum, s) => sum + s, 0) / scores.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// 등급 산정 (표준화육종가 기준)
|
||||||
|
let grade = 'C';
|
||||||
|
if (farmAverage >= 1.0) grade = 'A';
|
||||||
|
else if (farmAverage >= 0.5) grade = 'B';
|
||||||
|
else if (farmAverage >= -0.5) grade = 'C';
|
||||||
|
else if (farmAverage >= -1.0) grade = 'D';
|
||||||
|
else grade = 'E';
|
||||||
|
|
||||||
|
return {
|
||||||
|
farmNo,
|
||||||
|
farmAverage: Math.round(farmAverage * 100) / 100,
|
||||||
|
grade,
|
||||||
|
analyzedCount: scores.length,
|
||||||
|
totalCount: cows.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보은군 비교 분석
|
||||||
|
*/
|
||||||
|
async getRegionComparison(farmNo: number, filter?: DashboardFilterDto) {
|
||||||
|
// 내 농장 평균 계산
|
||||||
|
const farmEval = await this.getFarmEvaluation(farmNo, filter);
|
||||||
|
|
||||||
|
// 전체 농장 평균 계산 (보은군 대비)
|
||||||
|
const allFarms = await this.farmRepository.find({
|
||||||
|
where: { delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const farmScores: { farmNo: number; avgScore: number }[] = [];
|
||||||
|
|
||||||
|
for (const farm of allFarms) {
|
||||||
|
const farmCows = await this.cowRepository.find({
|
||||||
|
where: { fkFarmNo: farm.pkFarmNo, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const scores: number[] = [];
|
||||||
|
for (const cow of farmCows) {
|
||||||
|
// cowId로 직접 형질 데이터 조회
|
||||||
|
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||||
|
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (traitDetails.length === 0) continue;
|
||||||
|
|
||||||
|
const ebvValues = traitDetails
|
||||||
|
.filter(d => d.traitEbv !== null)
|
||||||
|
.map(d => Number(d.traitEbv));
|
||||||
|
|
||||||
|
if (ebvValues.length > 0) {
|
||||||
|
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||||
|
scores.push(avgEbv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scores.length > 0) {
|
||||||
|
farmScores.push({
|
||||||
|
farmNo: farm.pkFarmNo,
|
||||||
|
avgScore: scores.reduce((sum, s) => sum + s, 0) / scores.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내 농장 순위 계산
|
||||||
|
farmScores.sort((a, b) => b.avgScore - a.avgScore);
|
||||||
|
const myFarmRank = farmScores.findIndex(f => f.farmNo === farmNo) + 1;
|
||||||
|
const totalFarmCount = farmScores.length;
|
||||||
|
const topPercent = totalFarmCount > 0 ? Math.round((myFarmRank / totalFarmCount) * 100) : 0;
|
||||||
|
|
||||||
|
// 지역 평균
|
||||||
|
const regionAverage = farmScores.length > 0
|
||||||
|
? farmScores.reduce((sum, f) => sum + f.avgScore, 0) / farmScores.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
farmNo,
|
||||||
|
farmAverage: farmEval.farmAverage,
|
||||||
|
regionAverage: Math.round(regionAverage * 100) / 100,
|
||||||
|
farmRank: myFarmRank || 1,
|
||||||
|
totalFarmCount: totalFarmCount || 1,
|
||||||
|
topPercent: topPercent || 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개체 분포 분석
|
||||||
|
*/
|
||||||
|
async getCowDistribution(farmNo: number, filter?: DashboardFilterDto) {
|
||||||
|
const cows = await this.cowRepository.find({
|
||||||
|
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const distribution = {
|
||||||
|
A: 0,
|
||||||
|
B: 0,
|
||||||
|
C: 0,
|
||||||
|
D: 0,
|
||||||
|
E: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const cow of cows) {
|
||||||
|
// cowId로 직접 형질 데이터 조회
|
||||||
|
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||||
|
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (traitDetails.length === 0) continue;
|
||||||
|
|
||||||
|
const ebvValues = traitDetails
|
||||||
|
.filter(d => d.traitEbv !== null)
|
||||||
|
.map(d => Number(d.traitEbv));
|
||||||
|
|
||||||
|
if (ebvValues.length > 0) {
|
||||||
|
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||||
|
|
||||||
|
if (avgEbv >= 1.0) distribution.A++;
|
||||||
|
else if (avgEbv >= 0.5) distribution.B++;
|
||||||
|
else if (avgEbv >= -0.5) distribution.C++;
|
||||||
|
else if (avgEbv >= -1.0) distribution.D++;
|
||||||
|
else distribution.E++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
farmNo,
|
||||||
|
distribution,
|
||||||
|
total: cows.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPN 추천 집계
|
||||||
|
*/
|
||||||
|
async getKpnRecommendationAggregation(farmNo: number, filter?: DashboardFilterDto) {
|
||||||
|
// 타겟 유전자 기반 KPN 추천 로직
|
||||||
|
const targetGenes = filter?.targetGenes || [];
|
||||||
|
|
||||||
|
// 농장 소 목록 조회
|
||||||
|
const cows = await this.cowRepository.find({
|
||||||
|
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 간단한 KPN 추천 집계 (실제 로직은 더 복잡할 수 있음)
|
||||||
|
const kpnAggregations = [
|
||||||
|
{
|
||||||
|
kpnNumber: 'KPN001',
|
||||||
|
kpnName: '한우왕',
|
||||||
|
avgMatchingScore: 85.5,
|
||||||
|
recommendedCowCount: Math.floor(cows.length * 0.3),
|
||||||
|
percentage: 30,
|
||||||
|
rank: 1,
|
||||||
|
isOwned: false,
|
||||||
|
sampleCowIds: cows.slice(0, 3).map(c => c.cowId),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kpnNumber: 'KPN002',
|
||||||
|
kpnName: '육량대왕',
|
||||||
|
avgMatchingScore: 82.3,
|
||||||
|
recommendedCowCount: Math.floor(cows.length * 0.25),
|
||||||
|
percentage: 25,
|
||||||
|
rank: 2,
|
||||||
|
isOwned: true,
|
||||||
|
sampleCowIds: cows.slice(3, 6).map(c => c.cowId),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kpnNumber: 'KPN003',
|
||||||
|
kpnName: '품질명가',
|
||||||
|
avgMatchingScore: 79.1,
|
||||||
|
recommendedCowCount: Math.floor(cows.length * 0.2),
|
||||||
|
percentage: 20,
|
||||||
|
rank: 3,
|
||||||
|
isOwned: false,
|
||||||
|
sampleCowIds: cows.slice(6, 9).map(c => c.cowId),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
farmNo,
|
||||||
|
targetGenes,
|
||||||
|
kpnAggregations,
|
||||||
|
totalCows: cows.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 농장 보유 KPN 목록
|
||||||
|
*/
|
||||||
|
async getFarmKpnInventory(farmNo: number) {
|
||||||
|
// 실제 구현에서는 별도의 KPN 보유 테이블을 조회
|
||||||
|
return {
|
||||||
|
farmNo,
|
||||||
|
kpnList: [
|
||||||
|
{ kpnNumber: 'KPN002', kpnName: '육량대왕', stockCount: 10 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분석 이력 연도 목록
|
||||||
|
*/
|
||||||
|
async getAnalysisYears(farmNo: number): Promise<number[]> {
|
||||||
|
const requests = await this.genomeRequestRepository.find({
|
||||||
|
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
select: ['requestDt'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const years = new Set<number>();
|
||||||
|
for (const req of requests) {
|
||||||
|
if (req.requestDt) {
|
||||||
|
years.add(new Date(req.requestDt).getFullYear());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(years).sort((a, b) => b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최신 분석 연도
|
||||||
|
*/
|
||||||
|
async getLatestAnalysisYear(farmNo: number): Promise<number> {
|
||||||
|
const years = await this.getAnalysisYears(farmNo);
|
||||||
|
return years[0] || new Date().getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3개년 비교 분석
|
||||||
|
*/
|
||||||
|
async getYearComparison(farmNo: number) {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const years = [currentYear, currentYear - 1, currentYear - 2];
|
||||||
|
|
||||||
|
const comparison = [];
|
||||||
|
for (const year of years) {
|
||||||
|
// 해당 연도의 분석 데이터 집계
|
||||||
|
const requests = await this.genomeRequestRepository.find({
|
||||||
|
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const yearRequests = requests.filter(r => {
|
||||||
|
if (!r.requestDt) return false;
|
||||||
|
return new Date(r.requestDt).getFullYear() === year;
|
||||||
|
});
|
||||||
|
|
||||||
|
comparison.push({
|
||||||
|
year,
|
||||||
|
analysisCount: yearRequests.length,
|
||||||
|
matchCount: yearRequests.filter(r => r.chipSireName === '일치').length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { farmNo, comparison };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 번식 효율성 분석 (더미 데이터)
|
||||||
|
*/
|
||||||
|
async getReproEfficiency(farmNo: number, filter?: DashboardFilterDto) {
|
||||||
|
return {
|
||||||
|
farmNo,
|
||||||
|
avgCalvingInterval: 12.5,
|
||||||
|
avgFirstCalvingAge: 24,
|
||||||
|
conceptionRate: 65.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 우수개체 추천
|
||||||
|
*/
|
||||||
|
async getExcellentCows(farmNo: number, filter?: DashboardFilterDto) {
|
||||||
|
const limit = filter?.limit || 5;
|
||||||
|
|
||||||
|
const cows = await this.cowRepository.find({
|
||||||
|
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const cowsWithScore: Array<{ cow: CowModel; score: number }> = [];
|
||||||
|
|
||||||
|
for (const cow of cows) {
|
||||||
|
// cowId로 직접 형질 데이터 조회
|
||||||
|
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||||
|
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (traitDetails.length === 0) continue;
|
||||||
|
|
||||||
|
const ebvValues = traitDetails
|
||||||
|
.filter(d => d.traitEbv !== null)
|
||||||
|
.map(d => Number(d.traitEbv));
|
||||||
|
|
||||||
|
if (ebvValues.length > 0) {
|
||||||
|
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||||
|
cowsWithScore.push({ cow, score: avgEbv });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 점수 내림차순 정렬
|
||||||
|
cowsWithScore.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
return {
|
||||||
|
farmNo,
|
||||||
|
excellentCows: cowsWithScore.slice(0, limit).map((item, index) => ({
|
||||||
|
rank: index + 1,
|
||||||
|
cowNo: item.cow.pkCowNo,
|
||||||
|
cowId: item.cow.cowId,
|
||||||
|
score: Math.round(item.score * 100) / 100,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도태개체 추천
|
||||||
|
*/
|
||||||
|
async getCullCows(farmNo: number, filter?: DashboardFilterDto) {
|
||||||
|
const limit = filter?.limit || 5;
|
||||||
|
|
||||||
|
const cows = await this.cowRepository.find({
|
||||||
|
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const cowsWithScore: Array<{ cow: CowModel; score: number }> = [];
|
||||||
|
|
||||||
|
for (const cow of cows) {
|
||||||
|
// cowId로 직접 형질 데이터 조회
|
||||||
|
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||||
|
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (traitDetails.length === 0) continue;
|
||||||
|
|
||||||
|
const ebvValues = traitDetails
|
||||||
|
.filter(d => d.traitEbv !== null)
|
||||||
|
.map(d => Number(d.traitEbv));
|
||||||
|
|
||||||
|
if (ebvValues.length > 0) {
|
||||||
|
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||||
|
cowsWithScore.push({ cow, score: avgEbv });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 점수 오름차순 정렬 (낮은 점수가 도태 대상)
|
||||||
|
cowsWithScore.sort((a, b) => a.score - b.score);
|
||||||
|
|
||||||
|
return {
|
||||||
|
farmNo,
|
||||||
|
cullCows: cowsWithScore.slice(0, limit).map((item, index) => ({
|
||||||
|
rank: index + 1,
|
||||||
|
cowNo: item.cow.pkCowNo,
|
||||||
|
cowId: item.cow.cowId,
|
||||||
|
score: Math.round(item.score * 100) / 100,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보은군 내 소 개별 순위
|
||||||
|
*/
|
||||||
|
async getCattleRankingInRegion(farmNo: number, filter?: DashboardFilterDto) {
|
||||||
|
// 전체 소 목록과 점수 계산
|
||||||
|
const allCows = await this.cowRepository.find({
|
||||||
|
where: { delDt: IsNull() },
|
||||||
|
relations: ['farm'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cowsWithScore: Array<{
|
||||||
|
cow: CowModel;
|
||||||
|
score: number;
|
||||||
|
farmNo: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const cow of allCows) {
|
||||||
|
// cowId로 직접 형질 데이터 조회
|
||||||
|
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||||
|
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (traitDetails.length === 0) continue;
|
||||||
|
|
||||||
|
const ebvValues = traitDetails
|
||||||
|
.filter(d => d.traitEbv !== null)
|
||||||
|
.map(d => Number(d.traitEbv));
|
||||||
|
|
||||||
|
if (ebvValues.length > 0) {
|
||||||
|
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||||
|
cowsWithScore.push({
|
||||||
|
cow,
|
||||||
|
score: avgEbv,
|
||||||
|
farmNo: cow.fkFarmNo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 점수 내림차순 정렬
|
||||||
|
cowsWithScore.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
// 순위 부여
|
||||||
|
const rankedCows = cowsWithScore.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
rank: index + 1,
|
||||||
|
percentile: Math.round(((index + 1) / cowsWithScore.length) * 100),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 내 농장 소만 필터링
|
||||||
|
const myFarmCows = rankedCows.filter(item => item.farmNo === farmNo);
|
||||||
|
|
||||||
|
const farm = await this.farmRepository.findOne({
|
||||||
|
where: { pkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
farmNo,
|
||||||
|
farmName: farm?.farmerName || '농장',
|
||||||
|
regionName: farm?.regionSi || '보은군',
|
||||||
|
totalCattle: cowsWithScore.length,
|
||||||
|
farmCattleCount: myFarmCows.length,
|
||||||
|
rankings: myFarmCows.map(item => ({
|
||||||
|
cowNo: item.cow.cowId,
|
||||||
|
cowName: `KOR ${item.cow.cowId}`,
|
||||||
|
genomeScore: Math.round(item.score * 100) / 100,
|
||||||
|
rank: item.rank,
|
||||||
|
totalCattle: cowsWithScore.length,
|
||||||
|
percentile: item.percentile,
|
||||||
|
})),
|
||||||
|
statistics: {
|
||||||
|
bestRank: myFarmCows.length > 0 ? myFarmCows[0].rank : 0,
|
||||||
|
averageRank: myFarmCows.length > 0
|
||||||
|
? Math.round(myFarmCows.reduce((sum, c) => sum + c.rank, 0) / myFarmCows.length)
|
||||||
|
: 0,
|
||||||
|
topPercentCount: myFarmCows.filter(c => c.percentile <= 10).length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
42
backend/src/dashboard/dto/dashboard-filter.dto.ts
Normal file
42
backend/src/dashboard/dto/dashboard-filter.dto.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { IsOptional, IsArray, IsNumber, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 필터 DTO
|
||||||
|
*/
|
||||||
|
export class DashboardFilterDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
anlysStatus?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reproType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
geneGrades?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
genomeGrades?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
reproGrades?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
targetGenes?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
minScore?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
regionNm?: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
||||||
import { FarmService } from './farm.service';
|
import { FarmService } from './farm.service';
|
||||||
|
import { FarmModel } from './entities/farm.entity';
|
||||||
|
|
||||||
@Controller('farm')
|
@Controller('farm')
|
||||||
export class FarmController {
|
export class FarmController {
|
||||||
@@ -12,4 +13,40 @@ export class FarmController {
|
|||||||
}
|
}
|
||||||
return this.farmService.findAll();
|
return this.farmService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.farmService.findOne(+id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /farm/:farmNo/analysis-latest - 농장 최신 분석 의뢰 정보 조회
|
||||||
|
*/
|
||||||
|
@Get(':farmNo/analysis-latest')
|
||||||
|
getLatestAnalysisRequest(@Param('farmNo') farmNo: string) {
|
||||||
|
return this.farmService.getLatestAnalysisRequest(+farmNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /farm/:farmNo/analysis-all - 농장 전체 분석 의뢰 목록 조회
|
||||||
|
*/
|
||||||
|
@Get(':farmNo/analysis-all')
|
||||||
|
getAllAnalysisRequests(@Param('farmNo') farmNo: string) {
|
||||||
|
return this.farmService.getAllAnalysisRequests(+farmNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() data: Partial<FarmModel>) {
|
||||||
|
return this.farmService.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
update(@Param('id') id: string, @Body() data: Partial<FarmModel>) {
|
||||||
|
return this.farmService.update(+id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.farmService.remove(+id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,17 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { FarmController } from './farm.controller';
|
import { FarmController } from './farm.controller';
|
||||||
import { FarmService } from './farm.service';
|
import { FarmService } from './farm.service';
|
||||||
import { FarmModel } from './entities/farm.entity';
|
import { FarmModel } from './entities/farm.entity';
|
||||||
|
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||||
|
import { CowModel } from '../cow/entities/cow.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([FarmModel])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
FarmModel,
|
||||||
|
GenomeRequestModel,
|
||||||
|
CowModel,
|
||||||
|
]),
|
||||||
|
],
|
||||||
controllers: [FarmController],
|
controllers: [FarmController],
|
||||||
providers: [FarmService],
|
providers: [FarmService],
|
||||||
exports: [FarmService],
|
exports: [FarmService],
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, IsNull } from 'typeorm';
|
import { Repository, IsNull } from 'typeorm';
|
||||||
import { FarmModel } from './entities/farm.entity';
|
import { FarmModel } from './entities/farm.entity';
|
||||||
|
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||||
|
import { CowModel } from '../cow/entities/cow.entity';
|
||||||
|
import { isValidGenomeAnalysis, VALID_CHIP_SIRE_NAME } from '../common/config/GenomeAnalysisConfig';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FarmService {
|
export class FarmService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(FarmModel)
|
@InjectRepository(FarmModel)
|
||||||
private readonly farmRepository: Repository<FarmModel>,
|
private readonly farmRepository: Repository<FarmModel>,
|
||||||
|
|
||||||
|
@InjectRepository(GenomeRequestModel)
|
||||||
|
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
||||||
|
|
||||||
|
@InjectRepository(CowModel)
|
||||||
|
private readonly cowRepository: Repository<CowModel>,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// 전체 농장 조회
|
// 전체 농장 조회
|
||||||
@@ -27,4 +36,93 @@ export class FarmService {
|
|||||||
order: { regDt: 'DESC' },
|
order: { regDt: 'DESC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 농장 단건 조회
|
||||||
|
async findOne(id: number): Promise<FarmModel> {
|
||||||
|
const farm = await this.farmRepository.findOne({
|
||||||
|
where: { pkFarmNo: id, delDt: IsNull() },
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
if (!farm) {
|
||||||
|
throw new NotFoundException('Farm #' + id + ' not found');
|
||||||
|
}
|
||||||
|
return farm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 농장 생성
|
||||||
|
async create(data: Partial<FarmModel>): Promise<FarmModel> {
|
||||||
|
const farm = this.farmRepository.create(data);
|
||||||
|
return this.farmRepository.save(farm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 농장 수정
|
||||||
|
async update(id: number, data: Partial<FarmModel>): Promise<FarmModel> {
|
||||||
|
await this.findOne(id);
|
||||||
|
await this.farmRepository.update(id, data);
|
||||||
|
return this.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 농장 삭제
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
const farm = await this.findOne(id);
|
||||||
|
await this.farmRepository.softRemove(farm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 농장 최신 분석 의뢰 정보 조회
|
||||||
|
async getLatestAnalysisRequest(farmNo: number): Promise<any> {
|
||||||
|
const farm = await this.findOne(farmNo);
|
||||||
|
|
||||||
|
const requests = await this.genomeRequestRepository.find({
|
||||||
|
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
relations: ['cow'],
|
||||||
|
order: { requestDt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const cows = await this.cowRepository.find({
|
||||||
|
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const farmAnlysCnt = requests.length;
|
||||||
|
const matchCnt = requests.filter(r => isValidGenomeAnalysis(r.chipSireName, r.chipDamName, r.cow?.cowId)).length;
|
||||||
|
const failCnt = requests.filter(r => r.chipSireName && r.chipSireName !== '일치').length;
|
||||||
|
const noHistCnt = requests.filter(r => !r.chipSireName).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pkFarmAnlysNo: 1,
|
||||||
|
fkFarmNo: farmNo,
|
||||||
|
farmAnlysNm: farm.farmerName,
|
||||||
|
anlysReqDt: requests[0]?.requestDt || new Date(),
|
||||||
|
region: farm.regionSi,
|
||||||
|
city: farm.regionGu,
|
||||||
|
anlysReqCnt: cows.length,
|
||||||
|
farmAnlysCnt,
|
||||||
|
matchCnt,
|
||||||
|
mismatchCnt: failCnt,
|
||||||
|
failCnt,
|
||||||
|
noHistCnt,
|
||||||
|
matchRate: farmAnlysCnt > 0 ? Math.round((matchCnt / farmAnlysCnt) * 100) : 0,
|
||||||
|
msAnlysCnt: 0,
|
||||||
|
anlysRmrk: '',
|
||||||
|
paternities: requests.map(r => ({
|
||||||
|
pkFarmPaternityNo: r.pkRequestNo,
|
||||||
|
fkFarmAnlysNo: 1,
|
||||||
|
receiptDate: r.requestDt,
|
||||||
|
farmOwnerName: farm.farmerName,
|
||||||
|
individualNo: r.cow?.cowId || '',
|
||||||
|
kpnNo: r.cow?.sireKpn || '',
|
||||||
|
motherIndividualNo: r.cow?.damCowId || '',
|
||||||
|
hairRootQuality: r.sampleAmount || '',
|
||||||
|
remarks: r.cowRemarks || '',
|
||||||
|
fatherMatch: r.chipSireName === '일치' ? '일치' : (r.chipSireName ? '불일치' : '미확인'),
|
||||||
|
motherMatch: r.chipDamName || '미확인',
|
||||||
|
reportDate: r.chipReportDt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 농장 전체 분석 의뢰 목록 조회
|
||||||
|
async getAllAnalysisRequests(farmNo: number): Promise<any[]> {
|
||||||
|
const latestRequest = await this.getLatestAnalysisRequest(farmNo);
|
||||||
|
return [latestRequest];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
backend/src/gene/dto
Normal file
0
backend/src/gene/dto
Normal file
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Param } from '@nestjs/common';
|
import { Controller, Get, Param, Post, Body } from '@nestjs/common';
|
||||||
import { GeneService } from './gene.service';
|
import { GeneService } from './gene.service';
|
||||||
import { GeneDetailModel } from './entities/gene-detail.entity';
|
import { GeneDetailModel } from './entities/gene-detail.entity';
|
||||||
|
|
||||||
@@ -14,4 +14,53 @@ export class GeneController {
|
|||||||
async findByCowId(@Param('cowId') cowId: string): Promise<GeneDetailModel[]> {
|
async findByCowId(@Param('cowId') cowId: string): Promise<GeneDetailModel[]> {
|
||||||
return this.geneService.findByCowId(cowId);
|
return this.geneService.findByCowId(cowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개체별 유전자 요약 정보 조회
|
||||||
|
* GET /gene/summary/:cowId
|
||||||
|
*/
|
||||||
|
@Get('summary/:cowId')
|
||||||
|
async getGeneSummary(@Param('cowId') cowId: string): Promise<{
|
||||||
|
total: number;
|
||||||
|
homozygousCount: number;
|
||||||
|
heterozygousCount: number;
|
||||||
|
}> {
|
||||||
|
return this.geneService.getGeneSummary(cowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 의뢰번호로 유전자 상세 정보 조회
|
||||||
|
* GET /gene/request/:requestNo
|
||||||
|
*/
|
||||||
|
@Get('request/:requestNo')
|
||||||
|
async findByRequestNo(@Param('requestNo') requestNo: number): Promise<GeneDetailModel[]> {
|
||||||
|
return this.geneService.findByRequestNo(requestNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유전자 상세 정보 단건 조회
|
||||||
|
* GET /gene/detail/:geneDetailNo
|
||||||
|
*/
|
||||||
|
@Get('detail/:geneDetailNo')
|
||||||
|
async findOne(@Param('geneDetailNo') geneDetailNo: number): Promise<GeneDetailModel> {
|
||||||
|
return this.geneService.findOne(geneDetailNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유전자 상세 정보 생성
|
||||||
|
* POST /gene
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
async create(@Body() data: Partial<GeneDetailModel>): Promise<GeneDetailModel> {
|
||||||
|
return this.geneService.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유전자 상세 정보 일괄 생성
|
||||||
|
* POST /gene/bulk
|
||||||
|
*/
|
||||||
|
@Post('bulk')
|
||||||
|
async createBulk(@Body() dataList: Partial<GeneDetailModel>[]): Promise<GeneDetailModel[]> {
|
||||||
|
return this.geneService.createBulk(dataList);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { IsNull, Repository } from 'typeorm';
|
import { IsNull, Repository } from 'typeorm';
|
||||||
import { GeneDetailModel } from './entities/gene-detail.entity';
|
import { GeneDetailModel } from './entities/gene-detail.entity';
|
||||||
|
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GeneService {
|
export class GeneService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(GeneDetailModel)
|
@InjectRepository(GeneDetailModel)
|
||||||
private readonly geneDetailRepository: Repository<GeneDetailModel>,
|
private readonly geneDetailRepository: Repository<GeneDetailModel>,
|
||||||
|
@InjectRepository(GenomeRequestModel)
|
||||||
|
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,15 +19,111 @@ export class GeneService {
|
|||||||
* @returns 유전자 상세 정보 배열
|
* @returns 유전자 상세 정보 배열
|
||||||
*/
|
*/
|
||||||
async findByCowId(cowId: string): Promise<GeneDetailModel[]> {
|
async findByCowId(cowId: string): Promise<GeneDetailModel[]> {
|
||||||
return await this.geneDetailRepository.find({
|
const results = await this.geneDetailRepository.find({
|
||||||
where: {
|
where: {
|
||||||
cowId,
|
cowId,
|
||||||
delDt: IsNull(),
|
delDt: IsNull(),
|
||||||
},
|
},
|
||||||
|
relations: ['genomeRequest'],
|
||||||
|
order: {
|
||||||
|
chromosome: 'ASC',
|
||||||
|
position: 'ASC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 의뢰번호(requestNo)로 유전자 상세 정보 조회
|
||||||
|
* @param requestNo 의뢰번호
|
||||||
|
* @returns 유전자 상세 정보 배열
|
||||||
|
*/
|
||||||
|
async findByRequestNo(requestNo: number): Promise<GeneDetailModel[]> {
|
||||||
|
const results = await this.geneDetailRepository.find({
|
||||||
|
where: {
|
||||||
|
fkRequestNo: requestNo,
|
||||||
|
delDt: IsNull(),
|
||||||
|
},
|
||||||
order: {
|
order: {
|
||||||
chromosome: 'ASC',
|
chromosome: 'ASC',
|
||||||
position: 'ASC',
|
position: 'ASC',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개체별 유전자 요약 정보 조회
|
||||||
|
* @param cowId 개체식별번호
|
||||||
|
* @returns 동형접합/이형접합 개수 요약
|
||||||
|
*/
|
||||||
|
async getGeneSummary(cowId: string): Promise<{
|
||||||
|
total: number;
|
||||||
|
homozygousCount: number;
|
||||||
|
heterozygousCount: number;
|
||||||
|
}> {
|
||||||
|
const geneDetails = await this.findByCowId(cowId);
|
||||||
|
|
||||||
|
let homozygousCount = 0;
|
||||||
|
let heterozygousCount = 0;
|
||||||
|
|
||||||
|
geneDetails.forEach((gene) => {
|
||||||
|
if (gene.allele1 && gene.allele2) {
|
||||||
|
if (gene.allele1 === gene.allele2) {
|
||||||
|
homozygousCount++;
|
||||||
|
} else {
|
||||||
|
heterozygousCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: geneDetails.length,
|
||||||
|
homozygousCount,
|
||||||
|
heterozygousCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유전자 상세 정보 단건 조회
|
||||||
|
* @param geneDetailNo 유전자상세번호
|
||||||
|
* @returns 유전자 상세 정보
|
||||||
|
*/
|
||||||
|
async findOne(geneDetailNo: number): Promise<GeneDetailModel> {
|
||||||
|
const result = await this.geneDetailRepository.findOne({
|
||||||
|
where: {
|
||||||
|
pkGeneDetailNo: geneDetailNo,
|
||||||
|
delDt: IsNull(),
|
||||||
|
},
|
||||||
|
relations: ['genomeRequest'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new NotFoundException(`유전자 상세 정보를 찾을 수 없습니다. (geneDetailNo: ${geneDetailNo})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유전자 상세 정보 생성
|
||||||
|
* @param data 생성할 데이터
|
||||||
|
* @returns 생성된 유전자 상세 정보
|
||||||
|
*/
|
||||||
|
async create(data: Partial<GeneDetailModel>): Promise<GeneDetailModel> {
|
||||||
|
const geneDetail = this.geneDetailRepository.create(data);
|
||||||
|
return await this.geneDetailRepository.save(geneDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유전자 상세 정보 일괄 생성
|
||||||
|
* @param dataList 생성할 데이터 배열
|
||||||
|
* @returns 생성된 유전자 상세 정보 배열
|
||||||
|
*/
|
||||||
|
async createBulk(dataList: Partial<GeneDetailModel>[]): Promise<GeneDetailModel[]> {
|
||||||
|
const geneDetails = this.geneDetailRepository.create(dataList);
|
||||||
|
return await this.geneDetailRepository.save(geneDetails);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
backend/src/genome/dto
Normal file
0
backend/src/genome/dto
Normal file
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* 카테고리별 평균 EBV 정보
|
|
||||||
*/
|
|
||||||
export interface CategoryAverageDto {
|
|
||||||
/** 카테고리명 (성장/생산/체형/무게/비율) */
|
|
||||||
category: string;
|
|
||||||
/** 평균 EBV 값 (표준화 육종가) */
|
|
||||||
avgEbv: number;
|
|
||||||
/** 평균 EPD 값 (원래 육종가) */
|
|
||||||
avgEpd: number;
|
|
||||||
/** 해당 카테고리의 데이터 개수 */
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전국/지역/농가 비교 평균 데이터
|
|
||||||
*/
|
|
||||||
export interface ComparisonAveragesDto {
|
|
||||||
/** 전국 평균 */
|
|
||||||
nationwide: CategoryAverageDto[];
|
|
||||||
/** 지역 평균 */
|
|
||||||
region: CategoryAverageDto[];
|
|
||||||
/** 농가 평균 */
|
|
||||||
farm: CategoryAverageDto[];
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* 대시보드 요약 정보 DTO
|
|
||||||
*/
|
|
||||||
export interface DashboardSummaryDto {
|
|
||||||
// 요약
|
|
||||||
summary: {
|
|
||||||
totalCows: number; // 검사 받은 전체 개체 수
|
|
||||||
genomeCowCount: number; // 유전체 분석 개체 수
|
|
||||||
geneCowCount: number; // 유전자검사 개체 수
|
|
||||||
mptCowCount: number; // 번식능력검사 개체 수
|
|
||||||
totalRequests: number; // 유전체 의뢰 건수
|
|
||||||
analyzedCount: number; // 분석 완료
|
|
||||||
pendingCount: number; // 대기
|
|
||||||
mismatchCount: number; // 불일치
|
|
||||||
maleCount: number; // 수컷 수
|
|
||||||
femaleCount: number; // 암컷 수
|
|
||||||
};
|
|
||||||
// 친자감별 결과 현황
|
|
||||||
paternityStats: {
|
|
||||||
analysisComplete: number; // 분석 완료
|
|
||||||
sireMismatch: number; // 부 불일치
|
|
||||||
damMismatch: number; // 모 불일치
|
|
||||||
damNoRecord: number; // 모 이력제부재
|
|
||||||
notAnalyzed: number; // 미분석
|
|
||||||
};
|
|
||||||
// 검사 종류별 현황
|
|
||||||
testTypeStats: {
|
|
||||||
snp: { total: number; completed: number };
|
|
||||||
ms: { total: number; completed: number };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 연도별 통계 DTO
|
|
||||||
*/
|
|
||||||
export interface YearlyStatsDto {
|
|
||||||
// 연도별 분석 현황
|
|
||||||
yearlyStats: {
|
|
||||||
year: number;
|
|
||||||
totalRequests: number;
|
|
||||||
analyzedCount: number;
|
|
||||||
pendingCount: number;
|
|
||||||
sireMatchCount: number;
|
|
||||||
analyzeRate: number;
|
|
||||||
sireMatchRate: number;
|
|
||||||
}[];
|
|
||||||
// 월별 접수 현황
|
|
||||||
monthlyStats: { month: number; count: number }[];
|
|
||||||
// 연도별 평균 EBV (농가 vs 보은군)
|
|
||||||
yearlyAvgEbv: {
|
|
||||||
year: number;
|
|
||||||
farmAvgEbv: number;
|
|
||||||
regionAvgEbv: number;
|
|
||||||
traitCount: number;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 형질 평균 DTO
|
|
||||||
*/
|
|
||||||
export interface TraitAveragesDto {
|
|
||||||
traitAverages: {
|
|
||||||
traitName: string;
|
|
||||||
category: string;
|
|
||||||
avgEbv: number;
|
|
||||||
avgEpd: number;
|
|
||||||
avgPercentile: number;
|
|
||||||
count: number;
|
|
||||||
rank: number | null;
|
|
||||||
totalFarms: number;
|
|
||||||
percentile: number | null;
|
|
||||||
regionAvgEpd?: number;
|
|
||||||
}[];
|
|
||||||
// 연도별 형질 평균 (차트용)
|
|
||||||
yearlyTraitAverages: {
|
|
||||||
year: number;
|
|
||||||
traits: { traitName: string; avgEbv: number | null }[];
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 접수 내역 DTO
|
|
||||||
*/
|
|
||||||
export interface RequestHistoryDto {
|
|
||||||
requestHistory: {
|
|
||||||
pkRequestNo: number;
|
|
||||||
cowId: string;
|
|
||||||
cowRemarks: string | null;
|
|
||||||
requestDt: string | null;
|
|
||||||
chipSireName: string | null;
|
|
||||||
chipReportDt: string | null;
|
|
||||||
status: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 칩/모근 통계 DTO
|
|
||||||
*/
|
|
||||||
export interface ChipStatsDto {
|
|
||||||
chipTypeStats: { chipType: string; count: number }[];
|
|
||||||
sampleAmountStats: { sampleAmount: string; count: number }[];
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* 형질별 평균 EBV 응답 DTO
|
|
||||||
*/
|
|
||||||
export interface TraitAverageDto {
|
|
||||||
traitName: string; // 형질명
|
|
||||||
category: string; // 카테고리
|
|
||||||
avgEbv: number; // 평균 EBV (표준화 육종가)
|
|
||||||
avgEpd: number; // 평균 EPD (육종가 원본값)
|
|
||||||
count: number; // 데이터 개수
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 형질별 비교 평균 응답 DTO
|
|
||||||
*/
|
|
||||||
export interface TraitComparisonAveragesDto {
|
|
||||||
nationwide: TraitAverageDto[]; // 전국 평균
|
|
||||||
region: TraitAverageDto[]; // 지역(군) 평균
|
|
||||||
farm: TraitAverageDto[]; // 농장 평균
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { BaseModel } from 'src/common/entities/base.entity';
|
import { BaseModel } from 'src/common/entities/base.entity';
|
||||||
import { CowModel } from 'src/cow/entities/cow.entity';
|
import { CowModel } from 'src/cow/entities/cow.entity';
|
||||||
import { FarmModel } from 'src/farm/entities/farm.entity';
|
import { FarmModel } from 'src/farm/entities/farm.entity';
|
||||||
import { GenomeTraitDetailModel } from './genome-trait-detail.entity';
|
|
||||||
import {
|
import {
|
||||||
Column,
|
Column,
|
||||||
Entity,
|
Entity,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
OneToMany,
|
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
@@ -191,7 +189,4 @@ export class GenomeRequestModel extends BaseModel {
|
|||||||
@ManyToOne(() => FarmModel, { onDelete: 'CASCADE' })
|
@ManyToOne(() => FarmModel, { onDelete: 'CASCADE' })
|
||||||
@JoinColumn({ name: 'fk_farm_no' })
|
@JoinColumn({ name: 'fk_farm_no' })
|
||||||
farm: FarmModel;
|
farm: FarmModel;
|
||||||
|
|
||||||
@OneToMany(() => GenomeTraitDetailModel, (trait) => trait.genomeRequest)
|
|
||||||
traitDetails: GenomeTraitDetailModel[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
|
||||||
|
import { Public } from '../common/decorators/public.decorator';
|
||||||
import { GenomeService } from './genome.service';
|
import { GenomeService } from './genome.service';
|
||||||
import { ComparisonAveragesDto } from './dto/comparison-averages.dto';
|
import { GenomeRequestModel } from './entities/genome-request.entity';
|
||||||
|
import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity';
|
||||||
|
|
||||||
|
export interface CategoryAverageDto {
|
||||||
|
category: string;
|
||||||
|
avgEbv: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComparisonAveragesDto {
|
||||||
|
nationwide: CategoryAverageDto[];
|
||||||
|
region: CategoryAverageDto[];
|
||||||
|
farm: CategoryAverageDto[];
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('genome')
|
@Controller('genome')
|
||||||
export class GenomeController {
|
export class GenomeController {
|
||||||
@@ -16,6 +30,16 @@ export class GenomeController {
|
|||||||
return this.genomeService.getDashboardStats(+farmNo);
|
return this.genomeService.getDashboardStats(+farmNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /genome/farm-trait-comparison/:farmNo
|
||||||
|
* 농가별 형질 비교 데이터 (농가 vs 지역 vs 전국)
|
||||||
|
* @param farmNo - 농장 번호
|
||||||
|
*/
|
||||||
|
@Get('farm-trait-comparison/:farmNo')
|
||||||
|
getFarmTraitComparison(@Param('farmNo') farmNo: string) {
|
||||||
|
return this.genomeService.getFarmTraitComparison(+farmNo);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /genome/farm-region-ranking/:farmNo
|
* GET /genome/farm-region-ranking/:farmNo
|
||||||
* 농가의 보은군 내 순위 조회 (대시보드용)
|
* 농가의 보은군 내 순위 조회 (대시보드용)
|
||||||
@@ -43,6 +67,21 @@ export class GenomeController {
|
|||||||
return this.genomeService.getTraitRank(cowId, traitName);
|
return this.genomeService.getTraitRank(cowId, traitName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Genome Request endpoints
|
||||||
|
@Get('request')
|
||||||
|
findAllRequests(
|
||||||
|
@Query('cowId') cowId?: string,
|
||||||
|
@Query('farmId') farmId?: string,
|
||||||
|
) {
|
||||||
|
if (cowId) {
|
||||||
|
return this.genomeService.findRequestsByCowId(+cowId);
|
||||||
|
}
|
||||||
|
if (farmId) {
|
||||||
|
return this.genomeService.findRequestsByFarmId(+farmId);
|
||||||
|
}
|
||||||
|
return this.genomeService.findAllRequests();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /genome/request/:cowId
|
* GET /genome/request/:cowId
|
||||||
* 개체식별번호(KOR...)로 유전체 분석 의뢰 정보 조회
|
* 개체식별번호(KOR...)로 유전체 분석 의뢰 정보 조회
|
||||||
@@ -53,6 +92,11 @@ export class GenomeController {
|
|||||||
return this.genomeService.findRequestByCowIdentifier(cowId);
|
return this.genomeService.findRequestByCowIdentifier(cowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('request')
|
||||||
|
createRequest(@Body() data: Partial<GenomeRequestModel>) {
|
||||||
|
return this.genomeService.createRequest(data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /genome/comparison-averages/:cowId
|
* GET /genome/comparison-averages/:cowId
|
||||||
* 개체 기준 전국/지역/농장 카테고리별 평균 EBV 비교 데이터
|
* 개체 기준 전국/지역/농장 카테고리별 평균 EBV 비교 데이터
|
||||||
@@ -89,14 +133,30 @@ export class GenomeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Genome Trait Detail endpoints
|
||||||
|
@Get('trait-detail/:requestId')
|
||||||
|
findTraitDetailsByRequestId(@Param('requestId') requestId: string) {
|
||||||
|
return this.genomeService.findTraitDetailsByRequestId(+requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('trait-detail/cow/:cowId')
|
||||||
|
findTraitDetailsByCowId(@Param('cowId') cowId: string) {
|
||||||
|
return this.genomeService.findTraitDetailsByCowId(cowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('trait-detail')
|
||||||
|
createTraitDetail(@Body() data: Partial<GenomeTraitDetailModel>) {
|
||||||
|
return this.genomeService.createTraitDetail(data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /genome/yearly-ebv-stats/:farmNo
|
* GET /genome/check-cow/:cowId
|
||||||
* 연도별 EBV 통계 (개체상세 > 유전체 통합비교용)
|
* 특정 개체 상세 정보 조회 (디버깅용)
|
||||||
* @param farmNo - 농장 번호
|
|
||||||
*/
|
*/
|
||||||
@Get('yearly-ebv-stats/:farmNo')
|
@Public()
|
||||||
getYearlyEbvStats(@Param('farmNo') farmNo: string) {
|
@Get('check-cow/:cowId')
|
||||||
return this.genomeService.getYearlyEbvStats(+farmNo);
|
checkSpecificCow(@Param('cowId') cowId: string) {
|
||||||
|
return this.genomeService.checkSpecificCows([cowId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,16 +175,6 @@ export class GenomeController {
|
|||||||
return this.genomeService.getYearlyTraitTrend(+farmNo, category, traitName);
|
return this.genomeService.getYearlyTraitTrend(+farmNo, category, traitName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /genome/latest-analysis-year/:farmNo
|
|
||||||
* 농장의 가장 최근 분석 연도 조회 (chip_report_dt 또는 ms_report_dt 기준)
|
|
||||||
* @param farmNo - 농장 번호
|
|
||||||
*/
|
|
||||||
@Get('latest-analysis-year/:farmNo')
|
|
||||||
getLatestAnalysisYear(@Param('farmNo') farmNo: string) {
|
|
||||||
return this.genomeService.getLatestAnalysisYear(+farmNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /genome/:cowId
|
* GET /genome/:cowId
|
||||||
* cowId(개체식별번호)로 유전체 데이터 조회
|
* cowId(개체식별번호)로 유전체 데이터 조회
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { GenomeRequestModel } from './entities/genome-request.entity';
|
|||||||
import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity';
|
import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity';
|
||||||
import { CowModel } from '../cow/entities/cow.entity';
|
import { CowModel } from '../cow/entities/cow.entity';
|
||||||
import { FarmModel } from '../farm/entities/farm.entity';
|
import { FarmModel } from '../farm/entities/farm.entity';
|
||||||
import { MptModel } from '../mpt/entities/mpt.entity';
|
|
||||||
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -16,8 +14,6 @@ import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
|
|||||||
GenomeTraitDetailModel,
|
GenomeTraitDetailModel,
|
||||||
CowModel,
|
CowModel,
|
||||||
FarmModel,
|
FarmModel,
|
||||||
MptModel,
|
|
||||||
GeneDetailModel,
|
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [GenomeController],
|
controllers: [GenomeController],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
58
backend/src/help/dto/create-help.dto.ts
Normal file
58
backend/src/help/dto/create-help.dto.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { IsNotEmpty, IsString, IsOptional, IsInt, MaxLength, IsIn } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도움말 생성 DTO
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class CreateHelpDto
|
||||||
|
*/
|
||||||
|
export class CreateHelpDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['SNP', 'GENOME', 'MPT'])
|
||||||
|
@MaxLength(20)
|
||||||
|
helpCtgry: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
targetNm: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
helpTitle?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
helpShort?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
helpFull?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
helpImageUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
helpVideoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
helpLinkUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
displayOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['Y', 'N'])
|
||||||
|
@MaxLength(1)
|
||||||
|
useYn?: string;
|
||||||
|
}
|
||||||
26
backend/src/help/dto/filter-help.dto.ts
Normal file
26
backend/src/help/dto/filter-help.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { IsOptional, IsString, IsIn, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도움말 필터링 DTO
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class FilterHelpDto
|
||||||
|
*/
|
||||||
|
export class FilterHelpDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['SNP', 'GENOME', 'MPT'])
|
||||||
|
@MaxLength(20)
|
||||||
|
helpCtgry?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
targetNm?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['Y', 'N'])
|
||||||
|
@MaxLength(1)
|
||||||
|
useYn?: string;
|
||||||
|
}
|
||||||
11
backend/src/help/dto/update-help.dto.ts
Normal file
11
backend/src/help/dto/update-help.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateHelpDto } from './create-help.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도움말 수정 DTO
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class UpdateHelpDto
|
||||||
|
* @extends {PartialType(CreateHelpDto)}
|
||||||
|
*/
|
||||||
|
export class UpdateHelpDto extends PartialType(CreateHelpDto) {}
|
||||||
108
backend/src/help/entities/help.entity.ts
Normal file
108
backend/src/help/entities/help.entity.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { BaseModel } from "src/common/entities/base.entity";
|
||||||
|
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||||
|
|
||||||
|
@Entity({ name: "tb_help" })
|
||||||
|
export class HelpModel extends BaseModel {
|
||||||
|
@PrimaryGeneratedColumn({
|
||||||
|
name: "pk_help_no",
|
||||||
|
type: "int",
|
||||||
|
comment: "도움말 번호",
|
||||||
|
})
|
||||||
|
pkHelpNo: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "help_ctgry",
|
||||||
|
type: "varchar",
|
||||||
|
length: 20,
|
||||||
|
nullable: false,
|
||||||
|
comment: "분류 (SNP/GENOME/MPT)",
|
||||||
|
})
|
||||||
|
helpCtgry: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "target_nm",
|
||||||
|
type: "varchar",
|
||||||
|
length: 100,
|
||||||
|
nullable: false,
|
||||||
|
comment: "대상명 (PLAG1, 도체중, 혈당 등)",
|
||||||
|
})
|
||||||
|
targetNm: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "help_title",
|
||||||
|
type: "varchar",
|
||||||
|
length: 200,
|
||||||
|
nullable: true,
|
||||||
|
comment: "제목",
|
||||||
|
})
|
||||||
|
helpTitle: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "help_short",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
comment: "짧은 설명 (툴팁용)",
|
||||||
|
})
|
||||||
|
helpShort: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "help_full",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
comment: "상세 설명 (사이드패널용)",
|
||||||
|
})
|
||||||
|
helpFull: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "help_image_url",
|
||||||
|
type: "varchar",
|
||||||
|
length: 500,
|
||||||
|
nullable: true,
|
||||||
|
comment: "이미지 URL",
|
||||||
|
})
|
||||||
|
helpImageUrl: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "help_video_url",
|
||||||
|
type: "varchar",
|
||||||
|
length: 500,
|
||||||
|
nullable: true,
|
||||||
|
comment: "영상 URL",
|
||||||
|
})
|
||||||
|
helpVideoUrl: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "help_link_url",
|
||||||
|
type: "varchar",
|
||||||
|
length: 500,
|
||||||
|
nullable: true,
|
||||||
|
comment: "참고 링크 URL",
|
||||||
|
})
|
||||||
|
helpLinkUrl: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "display_order",
|
||||||
|
type: "int",
|
||||||
|
nullable: true,
|
||||||
|
comment: "표시 순서",
|
||||||
|
})
|
||||||
|
displayOrder: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "use_yn",
|
||||||
|
type: "char",
|
||||||
|
length: 1,
|
||||||
|
nullable: false,
|
||||||
|
default: "Y",
|
||||||
|
comment: "사용 여부 (Y/N)",
|
||||||
|
})
|
||||||
|
useYn: string;
|
||||||
|
|
||||||
|
// BaseModel에서 상속받는 컬럼들:
|
||||||
|
// - regDt: 등록일시
|
||||||
|
// - updtDt: 수정일시
|
||||||
|
// - regIp: 등록 IP
|
||||||
|
// - updtIp: 수정 IP
|
||||||
|
// - regUserId: 등록자 ID
|
||||||
|
// - updtUserId: 수정자 ID
|
||||||
|
}
|
||||||
185
backend/src/help/help.controller.ts
Normal file
185
backend/src/help/help.controller.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { Controller, Get, Post, Put, Delete, Body, Param, Query, Req } from '@nestjs/common';
|
||||||
|
import { HelpService } from './help.service';
|
||||||
|
import { CreateHelpDto } from './dto/create-help.dto';
|
||||||
|
import { UpdateHelpDto } from './dto/update-help.dto';
|
||||||
|
import { FilterHelpDto } from './dto/filter-help.dto';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Help Controller
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 도움말/툴팁 시스템 API 엔드포인트를 제공합니다.
|
||||||
|
*
|
||||||
|
* 주요 기능:
|
||||||
|
* - 도움말 CRUD (생성, 조회, 수정, 삭제)
|
||||||
|
* - 카테고리별 조회 (SNP/GENOME/MPT)
|
||||||
|
* - 대상명별 조회 (PLAG1, 도체중 등)
|
||||||
|
* - 툴팁 데이터 제공
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class HelpController
|
||||||
|
*/
|
||||||
|
@Controller('help')
|
||||||
|
export class HelpController {
|
||||||
|
constructor(private readonly helpService: HelpService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /help - 도움말 생성 (관리자)
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 새로운 도움말을 생성합니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // POST /help
|
||||||
|
* {
|
||||||
|
* "helpCtgry": "SNP",
|
||||||
|
* "targetNm": "PLAG1",
|
||||||
|
* "helpTitle": "PLAG1 유전자란?",
|
||||||
|
* "helpShort": "체고 및 성장 관련 유전자",
|
||||||
|
* "helpFull": "PLAG1은 소의 체고와 성장에 영향을 미치는 주요 유전자입니다...",
|
||||||
|
* "displayOrder": 1,
|
||||||
|
* "useYn": "Y"
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @param {CreateHelpDto} createHelpDto - 생성할 도움말 데이터
|
||||||
|
* @param {Request} req - Express Request 객체
|
||||||
|
* @returns {Promise<HelpModel>}
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
async create(@Body() createHelpDto: CreateHelpDto, @Req() req: Request) {
|
||||||
|
const userId = (req as any).user?.userId || 'system';
|
||||||
|
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||||
|
return await this.helpService.create(createHelpDto, userId, ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /help - 전체 도움말 목록 조회
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 전체 도움말 목록을 조회합니다. 필터 조건을 통해 검색 가능합니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // GET /help
|
||||||
|
* // GET /help?helpCtgry=SNP
|
||||||
|
* // GET /help?useYn=Y
|
||||||
|
* // GET /help?targetNm=PLAG1
|
||||||
|
*
|
||||||
|
* @param {FilterHelpDto} filterDto - 필터 조건 (선택)
|
||||||
|
* @returns {Promise<HelpModel[]>}
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
async findAll(@Query() filterDto: FilterHelpDto) {
|
||||||
|
return await this.helpService.findAll(filterDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /help/category/:category - 카테고리별 도움말 조회
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 특정 카테고리(SNP/GENOME/MPT)의 모든 도움말을 조회합니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // GET /help/category/SNP
|
||||||
|
* // GET /help/category/GENOME
|
||||||
|
* // GET /help/category/MPT
|
||||||
|
*
|
||||||
|
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
||||||
|
* @returns {Promise<HelpModel[]>}
|
||||||
|
*/
|
||||||
|
@Get('category/:category')
|
||||||
|
async findByCategory(@Param('category') category: string) {
|
||||||
|
return await this.helpService.findByCategory(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /help/:category/:targetNm - 특정 대상의 도움말 조회
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 특정 카테고리와 대상명에 해당하는 도움말을 조회합니다.
|
||||||
|
* 툴팁이나 사이드패널에서 사용됩니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // GET /help/SNP/PLAG1
|
||||||
|
* // GET /help/GENOME/도체중
|
||||||
|
* // GET /help/MPT/혈당
|
||||||
|
*
|
||||||
|
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
||||||
|
* @param {string} targetNm - 대상명 (PLAG1, 도체중 등)
|
||||||
|
* @returns {Promise<HelpModel>}
|
||||||
|
*/
|
||||||
|
@Get(':category/:targetNm')
|
||||||
|
async findByTarget(
|
||||||
|
@Param('category') category: string,
|
||||||
|
@Param('targetNm') targetNm: string,
|
||||||
|
) {
|
||||||
|
return await this.helpService.findByTarget(category, targetNm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /help/id/:id - 도움말 단건 조회
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 도움말 번호로 단건을 조회합니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // GET /help/id/1
|
||||||
|
*
|
||||||
|
* @param {number} id - 도움말 번호
|
||||||
|
* @returns {Promise<HelpModel>}
|
||||||
|
*/
|
||||||
|
@Get('id/:id')
|
||||||
|
async findOne(@Param('id') id: number) {
|
||||||
|
return await this.helpService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /help/:id - 도움말 수정 (관리자)
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 기존 도움말을 수정합니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // PUT /help/1
|
||||||
|
* {
|
||||||
|
* "helpTitle": "수정된 제목",
|
||||||
|
* "helpShort": "수정된 짧은 설명",
|
||||||
|
* "displayOrder": 2
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @param {number} id - 도움말 번호
|
||||||
|
* @param {UpdateHelpDto} updateHelpDto - 수정할 데이터
|
||||||
|
* @param {Request} req - Express Request 객체
|
||||||
|
* @returns {Promise<HelpModel>}
|
||||||
|
*/
|
||||||
|
@Put(':id')
|
||||||
|
async update(
|
||||||
|
@Param('id') id: number,
|
||||||
|
@Body() updateHelpDto: UpdateHelpDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const userId = (req as any).user?.userId || 'system';
|
||||||
|
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||||
|
return await this.helpService.update(id, updateHelpDto, userId, ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /help/:id - 도움말 삭제 (관리자)
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 도움말을 삭제합니다 (soft delete - useYn = 'N').
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // DELETE /help/1
|
||||||
|
*
|
||||||
|
* @param {number} id - 도움말 번호
|
||||||
|
* @param {Request} req - Express Request 객체
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
@Delete(':id')
|
||||||
|
async remove(@Param('id') id: number, @Req() req: Request) {
|
||||||
|
const userId = (req as any).user?.userId || 'system';
|
||||||
|
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||||
|
return await this.helpService.remove(id, userId, ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/src/help/help.module.ts
Normal file
28
backend/src/help/help.module.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { HelpController } from './help.controller';
|
||||||
|
import { HelpService } from './help.service';
|
||||||
|
import { HelpModel } from './entities/help.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Help Module
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 도움말/툴팁 시스템 모듈입니다.
|
||||||
|
* SNP, GENOME, MPT 등의 용어에 대한 설명을 제공합니다.
|
||||||
|
*
|
||||||
|
* 주요 기능:
|
||||||
|
* - 도움말 CRUD
|
||||||
|
* - 카테고리별 조회
|
||||||
|
* - 툴팁/사이드패널 데이터 제공
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class HelpModule
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([HelpModel])],
|
||||||
|
controllers: [HelpController],
|
||||||
|
providers: [HelpService],
|
||||||
|
exports: [HelpService],
|
||||||
|
})
|
||||||
|
export class HelpModule {}
|
||||||
179
backend/src/help/help.service.ts
Normal file
179
backend/src/help/help.service.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { HelpModel } from './entities/help.entity';
|
||||||
|
import { CreateHelpDto } from './dto/create-help.dto';
|
||||||
|
import { UpdateHelpDto } from './dto/update-help.dto';
|
||||||
|
import { FilterHelpDto } from './dto/filter-help.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Help Service
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 도움말/툴팁 시스템 서비스입니다.
|
||||||
|
* SNP, GENOME, MPT 등의 용어에 대한 도움말을 제공합니다.
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class HelpService
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HelpService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(HelpModel)
|
||||||
|
private readonly helpRepository: Repository<HelpModel>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도움말 생성
|
||||||
|
*
|
||||||
|
* @param {CreateHelpDto} createHelpDto - 생성할 도움말 데이터
|
||||||
|
* @param {string} userId - 생성자 ID
|
||||||
|
* @param {string} ip - 생성자 IP
|
||||||
|
* @returns {Promise<HelpModel>}
|
||||||
|
*/
|
||||||
|
async create(createHelpDto: CreateHelpDto, userId: string, ip: string): Promise<HelpModel> {
|
||||||
|
const help = this.helpRepository.create({
|
||||||
|
...createHelpDto,
|
||||||
|
regUserId: userId,
|
||||||
|
regIp: ip,
|
||||||
|
useYn: createHelpDto.useYn || 'Y',
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.helpRepository.save(help);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 도움말 목록 조회
|
||||||
|
*
|
||||||
|
* @param {FilterHelpDto} filterDto - 필터 조건 (선택)
|
||||||
|
* @returns {Promise<HelpModel[]>}
|
||||||
|
*/
|
||||||
|
async findAll(filterDto?: FilterHelpDto): Promise<HelpModel[]> {
|
||||||
|
const queryBuilder = this.helpRepository.createQueryBuilder('help');
|
||||||
|
|
||||||
|
if (filterDto?.helpCtgry) {
|
||||||
|
queryBuilder.andWhere('help.helpCtgry = :helpCtgry', { helpCtgry: filterDto.helpCtgry });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterDto?.targetNm) {
|
||||||
|
queryBuilder.andWhere('help.targetNm LIKE :targetNm', { targetNm: `%${filterDto.targetNm}%` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterDto?.useYn) {
|
||||||
|
queryBuilder.andWhere('help.useYn = :useYn', { useYn: filterDto.useYn });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await queryBuilder
|
||||||
|
.orderBy('help.displayOrder', 'ASC')
|
||||||
|
.addOrderBy('help.pkHelpNo', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리별 도움말 조회
|
||||||
|
*
|
||||||
|
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
||||||
|
* @returns {Promise<HelpModel[]>}
|
||||||
|
*/
|
||||||
|
async findByCategory(category: string): Promise<HelpModel[]> {
|
||||||
|
return await this.helpRepository.find({
|
||||||
|
where: {
|
||||||
|
helpCtgry: category,
|
||||||
|
useYn: 'Y',
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
displayOrder: 'ASC',
|
||||||
|
pkHelpNo: 'DESC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 대상명의 도움말 조회
|
||||||
|
*
|
||||||
|
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
||||||
|
* @param {string} targetNm - 대상명 (예: PLAG1, 도체중 등)
|
||||||
|
* @returns {Promise<HelpModel>}
|
||||||
|
*/
|
||||||
|
async findByTarget(category: string, targetNm: string): Promise<HelpModel> {
|
||||||
|
const help = await this.helpRepository.findOne({
|
||||||
|
where: {
|
||||||
|
helpCtgry: category,
|
||||||
|
targetNm: targetNm,
|
||||||
|
useYn: 'Y',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!help) {
|
||||||
|
throw new NotFoundException(`도움말을 찾을 수 없습니다. (카테고리: ${category}, 대상: ${targetNm})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return help;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도움말 번호로 단건 조회
|
||||||
|
*
|
||||||
|
* @param {number} id - 도움말 번호
|
||||||
|
* @returns {Promise<HelpModel>}
|
||||||
|
*/
|
||||||
|
async findOne(id: number): Promise<HelpModel> {
|
||||||
|
const help = await this.helpRepository.findOne({
|
||||||
|
where: { pkHelpNo: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!help) {
|
||||||
|
throw new NotFoundException(`도움말을 찾을 수 없습니다. (ID: ${id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return help;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도움말 수정
|
||||||
|
*
|
||||||
|
* @param {number} id - 도움말 번호
|
||||||
|
* @param {UpdateHelpDto} updateHelpDto - 수정할 데이터
|
||||||
|
* @param {string} userId - 수정자 ID
|
||||||
|
* @param {string} ip - 수정자 IP
|
||||||
|
* @returns {Promise<HelpModel>}
|
||||||
|
*/
|
||||||
|
async update(id: number, updateHelpDto: UpdateHelpDto, userId: string, ip: string): Promise<HelpModel> {
|
||||||
|
const help = await this.findOne(id);
|
||||||
|
|
||||||
|
Object.assign(help, updateHelpDto);
|
||||||
|
help.updtUserId = userId;
|
||||||
|
help.updtIp = ip;
|
||||||
|
|
||||||
|
return await this.helpRepository.save(help);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도움말 삭제 (soft delete - useYn = 'N')
|
||||||
|
*
|
||||||
|
* @param {number} id - 도움말 번호
|
||||||
|
* @param {string} userId - 삭제자 ID
|
||||||
|
* @param {string} ip - 삭제자 IP
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async remove(id: number, userId: string, ip: string): Promise<void> {
|
||||||
|
const help = await this.findOne(id);
|
||||||
|
|
||||||
|
help.useYn = 'N';
|
||||||
|
help.updtUserId = userId;
|
||||||
|
help.updtIp = ip;
|
||||||
|
|
||||||
|
await this.helpRepository.save(help);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도움말 영구 삭제 (hard delete)
|
||||||
|
*
|
||||||
|
* @param {number} id - 도움말 번호
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async hardRemove(id: number): Promise<void> {
|
||||||
|
const help = await this.findOne(id);
|
||||||
|
await this.helpRepository.remove(help);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* MPT 통계 응답 DTO
|
|
||||||
*/
|
|
||||||
export interface MptStatisticsDto {
|
|
||||||
totalMptCows: number;
|
|
||||||
latestTestDate: Date | null;
|
|
||||||
categories: {
|
|
||||||
energy: { safe: number; caution: number };
|
|
||||||
protein: { safe: number; caution: number };
|
|
||||||
liver: { safe: number; caution: number };
|
|
||||||
mineral: { safe: number; caution: number };
|
|
||||||
};
|
|
||||||
riskyCows: Array<{
|
|
||||||
cowId: string;
|
|
||||||
category: string;
|
|
||||||
itemName: string;
|
|
||||||
value: number;
|
|
||||||
status: 'high' | 'low';
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* MPT 결과 DTO
|
|
||||||
*/
|
|
||||||
export interface MptDto {
|
|
||||||
cowId: string;
|
|
||||||
cowShortNo: string;
|
|
||||||
fkFarmNo: number;
|
|
||||||
testDt: Date;
|
|
||||||
monthAge: number;
|
|
||||||
milkYield: number;
|
|
||||||
parity: number;
|
|
||||||
glucose: number;
|
|
||||||
cholesterol: number;
|
|
||||||
nefa: number;
|
|
||||||
bcs: number;
|
|
||||||
totalProtein: number;
|
|
||||||
albumin: number;
|
|
||||||
globulin: number;
|
|
||||||
agRatio: number;
|
|
||||||
bun: number;
|
|
||||||
ast: number;
|
|
||||||
ggt: number;
|
|
||||||
fattyLiverIdx: number;
|
|
||||||
calcium: number;
|
|
||||||
phosphorus: number;
|
|
||||||
caPRatio: number;
|
|
||||||
magnesium: number;
|
|
||||||
creatine: number;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { BaseModel } from 'src/common/entities/base.entity';
|
import { BaseModel } from 'src/common/entities/base.entity';
|
||||||
import { CowModel } from 'src/cow/entities/cow.entity';
|
import { FarmModel } from 'src/farm/entities/farm.entity';
|
||||||
import {
|
import {
|
||||||
Column,
|
Column,
|
||||||
Entity,
|
Entity,
|
||||||
Index,
|
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
@@ -21,26 +20,15 @@ export class MptModel extends BaseModel {
|
|||||||
})
|
})
|
||||||
pkMptNo: number;
|
pkMptNo: number;
|
||||||
|
|
||||||
@Index('idx_mpt_cow_id')
|
|
||||||
@Column({
|
|
||||||
name: 'cow_id',
|
|
||||||
type: 'varchar',
|
|
||||||
length: 15,
|
|
||||||
nullable: true,
|
|
||||||
comment: '개체식별번호 (KOR...)',
|
|
||||||
})
|
|
||||||
cowId: string;
|
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
name: 'cow_short_no',
|
name: 'cow_short_no',
|
||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
length: 4,
|
length: 4,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
comment: '개체 요약번호 (뒤 4자리)',
|
comment: '개체 요약번호',
|
||||||
})
|
})
|
||||||
cowShortNo: string;
|
cowShortNo: string;
|
||||||
|
|
||||||
@Index('idx_mpt_fk_farm_no')
|
|
||||||
@Column({
|
@Column({
|
||||||
name: 'fk_farm_no',
|
name: 'fk_farm_no',
|
||||||
type: 'int',
|
type: 'int',
|
||||||
@@ -244,20 +232,17 @@ export class MptModel extends BaseModel {
|
|||||||
magnesium: number;
|
magnesium: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
name: 'creatine',
|
name: 'creatinine',
|
||||||
type: 'decimal',
|
type: 'decimal',
|
||||||
precision: 10,
|
precision: 10,
|
||||||
scale: 2,
|
scale: 2,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
comment: '크레아틴',
|
comment: '크레아틴',
|
||||||
})
|
})
|
||||||
creatine: number;
|
creatinine: number;
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
@ManyToOne(() => CowModel, {
|
@ManyToOne(() => FarmModel, { onDelete: 'CASCADE' })
|
||||||
onDelete: 'CASCADE',
|
@JoinColumn({ name: 'fk_farm_no' })
|
||||||
createForeignKeyConstraints: false
|
farm: FarmModel;
|
||||||
})
|
|
||||||
@JoinColumn({ name: 'cow_id', referencedColumnName: 'cowId' })
|
|
||||||
cow: CowModel;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,47 @@
|
|||||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
||||||
import { MptService } from './mpt.service';
|
import { MptService } from './mpt.service';
|
||||||
|
import { MptModel } from './entities/mpt.entity';
|
||||||
|
|
||||||
@Controller('mpt')
|
@Controller('mpt')
|
||||||
export class MptController {
|
export class MptController {
|
||||||
constructor(private readonly mptService: MptService) {}
|
constructor(private readonly mptService: MptService) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* MPT 참조값 조회
|
|
||||||
*/
|
|
||||||
@Get('reference')
|
|
||||||
getReferenceValues() {
|
|
||||||
return this.mptService.getReferenceValues();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll(
|
findAll(
|
||||||
|
@Query('farmId') farmId?: string,
|
||||||
@Query('cowShortNo') cowShortNo?: string,
|
@Query('cowShortNo') cowShortNo?: string,
|
||||||
@Query('cowId') cowId?: string,
|
|
||||||
) {
|
) {
|
||||||
if (cowId) {
|
if (farmId) {
|
||||||
return this.mptService.findByCowId(cowId);
|
return this.mptService.findByFarmId(+farmId);
|
||||||
}
|
}
|
||||||
if (cowShortNo) {
|
if (cowShortNo) {
|
||||||
return this.mptService.findByCowShortNo(cowShortNo);
|
return this.mptService.findByCowShortNo(cowShortNo);
|
||||||
}
|
}
|
||||||
return [];
|
return this.mptService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Get(':id')
|
||||||
* 농장별 MPT 통계 조회
|
findOne(@Param('id') id: string) {
|
||||||
*/
|
return this.mptService.findOne(+id);
|
||||||
@Get('statistics/:farmNo')
|
}
|
||||||
getMptStatistics(@Param('farmNo') farmNo: string) {
|
|
||||||
return this.mptService.getMptStatistics(+farmNo);
|
@Post()
|
||||||
|
create(@Body() data: Partial<MptModel>) {
|
||||||
|
return this.mptService.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk')
|
||||||
|
bulkCreate(@Body() data: Partial<MptModel>[]) {
|
||||||
|
return this.mptService.bulkCreate(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
update(@Param('id') id: string, @Body() data: Partial<MptModel>) {
|
||||||
|
return this.mptService.update(+id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.mptService.remove(+id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, IsNull } from 'typeorm';
|
import { Repository, IsNull } from 'typeorm';
|
||||||
import { MptModel } from './entities/mpt.entity';
|
import { MptModel } from './entities/mpt.entity';
|
||||||
import {
|
|
||||||
MPT_REFERENCE_RANGES,
|
|
||||||
MPT_CATEGORIES,
|
|
||||||
MptReferenceRange,
|
|
||||||
MptCategory,
|
|
||||||
} from '../common/const/MptReference';
|
|
||||||
import { MptStatisticsDto } from './dto/mpt-statistics.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MptService {
|
export class MptService {
|
||||||
@@ -17,168 +10,59 @@ export class MptService {
|
|||||||
private readonly mptRepository: Repository<MptModel>,
|
private readonly mptRepository: Repository<MptModel>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async findAll(): Promise<MptModel[]> {
|
||||||
|
return this.mptRepository.find({
|
||||||
|
where: { delDt: IsNull() },
|
||||||
|
relations: ['farm'],
|
||||||
|
order: { testDt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByFarmId(farmNo: number): Promise<MptModel[]> {
|
||||||
|
return this.mptRepository.find({
|
||||||
|
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
relations: ['farm'],
|
||||||
|
order: { testDt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async findByCowShortNo(cowShortNo: string): Promise<MptModel[]> {
|
async findByCowShortNo(cowShortNo: string): Promise<MptModel[]> {
|
||||||
return this.mptRepository.find({
|
return this.mptRepository.find({
|
||||||
where: { cowShortNo: cowShortNo, delDt: IsNull() },
|
where: { cowShortNo: cowShortNo, delDt: IsNull() },
|
||||||
relations: ['cow', 'cow.farm'],
|
relations: ['farm'],
|
||||||
order: { testDt: 'DESC' },
|
order: { testDt: 'DESC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByCowId(cowId: string): Promise<MptModel[]> {
|
async findOne(id: number): Promise<MptModel> {
|
||||||
return this.mptRepository.find({
|
const mpt = await this.mptRepository.findOne({
|
||||||
where: { cowId: cowId, delDt: IsNull() },
|
where: { pkMptNo: id, delDt: IsNull() },
|
||||||
relations: ['cow', 'cow.farm'],
|
relations: ['farm'],
|
||||||
order: { testDt: 'DESC' },
|
|
||||||
});
|
});
|
||||||
|
if (!mpt) {
|
||||||
|
throw new NotFoundException(`MPT #${id} not found`);
|
||||||
|
}
|
||||||
|
return mpt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async create(data: Partial<MptModel>): Promise<MptModel> {
|
||||||
* 농장별 MPT 통계 조회
|
const mpt = this.mptRepository.create(data);
|
||||||
* - 개체별 최신 검사 결과 기준
|
return this.mptRepository.save(mpt);
|
||||||
* - 카테고리별 정상/주의/위험 개체 수
|
|
||||||
* - 위험 개체 목록 (Top 5)
|
|
||||||
*/
|
|
||||||
async getMptStatistics(farmNo: number): Promise<MptStatisticsDto> {
|
|
||||||
// 농장의 모든 MPT 데이터 조회
|
|
||||||
const allMptData = await this.mptRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
order: { testDt: 'DESC' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (allMptData.length === 0) {
|
|
||||||
return {
|
|
||||||
totalMptCows: 0,
|
|
||||||
latestTestDate: null,
|
|
||||||
categories: {
|
|
||||||
energy: { safe: 0, caution: 0 },
|
|
||||||
protein: { safe: 0, caution: 0 },
|
|
||||||
liver: { safe: 0, caution: 0 },
|
|
||||||
mineral: { safe: 0, caution: 0 },
|
|
||||||
},
|
|
||||||
riskyCows: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 개체별 최신 검사 데이터만 추출
|
async bulkCreate(data: Partial<MptModel>[]): Promise<MptModel[]> {
|
||||||
const latestByCoW = new Map<string, MptModel>();
|
const mpts = this.mptRepository.create(data);
|
||||||
for (const mpt of allMptData) {
|
return this.mptRepository.save(mpts);
|
||||||
if (!mpt.cowId) continue;
|
|
||||||
if (!latestByCoW.has(mpt.cowId)) {
|
|
||||||
latestByCoW.set(mpt.cowId, mpt);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestMptData = Array.from(latestByCoW.values());
|
async update(id: number, data: Partial<MptModel>): Promise<MptModel> {
|
||||||
const totalMptCows = latestMptData.length;
|
await this.findOne(id);
|
||||||
const latestTestDate = latestMptData[0]?.testDt || null;
|
await this.mptRepository.update(id, data);
|
||||||
|
return this.findOne(id);
|
||||||
// 카테고리별 통계 초기화 (안전/주의 2단계)
|
|
||||||
const categoryStats = {
|
|
||||||
energy: { safe: 0, caution: 0 },
|
|
||||||
protein: { safe: 0, caution: 0 },
|
|
||||||
liver: { safe: 0, caution: 0 },
|
|
||||||
mineral: { safe: 0, caution: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
// 주의 개체 목록
|
|
||||||
const riskyCowsList: MptStatisticsDto['riskyCows'] = [];
|
|
||||||
|
|
||||||
// 각 개체별로 카테고리별 상태 평가
|
|
||||||
for (const mpt of latestMptData) {
|
|
||||||
// 각 카테고리별로 이상 항목이 있는지 체크 (안전/주의 2단계)
|
|
||||||
const categoryStatus: Record<string, 'safe' | 'caution'> = {
|
|
||||||
energy: 'safe',
|
|
||||||
protein: 'safe',
|
|
||||||
liver: 'safe',
|
|
||||||
mineral: 'safe',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 각 항목별 체크 (범위 내 = 안전, 범위 밖 = 주의)
|
|
||||||
const checkItem = (value: number | null, itemKey: string) => {
|
|
||||||
if (value === null || value === undefined) return;
|
|
||||||
const ref = MPT_REFERENCE_RANGES[itemKey];
|
|
||||||
if (!ref) return;
|
|
||||||
|
|
||||||
const category = ref.category as keyof typeof categoryStatus;
|
|
||||||
|
|
||||||
// 범위 밖이면 주의
|
|
||||||
if (value > ref.upperLimit || value < ref.lowerLimit) {
|
|
||||||
categoryStatus[category] = 'caution';
|
|
||||||
|
|
||||||
// 주의 개체 목록에 추가
|
|
||||||
riskyCowsList.push({
|
|
||||||
cowId: mpt.cowId,
|
|
||||||
category,
|
|
||||||
itemName: itemKey,
|
|
||||||
value,
|
|
||||||
status: value > ref.upperLimit ? 'high' : 'low',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 에너지 카테고리
|
|
||||||
checkItem(mpt.glucose, 'glucose');
|
|
||||||
checkItem(mpt.cholesterol, 'cholesterol');
|
|
||||||
checkItem(mpt.nefa, 'nefa');
|
|
||||||
checkItem(mpt.bcs, 'bcs');
|
|
||||||
|
|
||||||
// 단백질 카테고리
|
|
||||||
checkItem(mpt.totalProtein, 'totalProtein');
|
|
||||||
checkItem(mpt.albumin, 'albumin');
|
|
||||||
checkItem(mpt.globulin, 'globulin');
|
|
||||||
checkItem(mpt.agRatio, 'agRatio');
|
|
||||||
checkItem(mpt.bun, 'bun');
|
|
||||||
|
|
||||||
// 간기능 카테고리
|
|
||||||
checkItem(mpt.ast, 'ast');
|
|
||||||
checkItem(mpt.ggt, 'ggt');
|
|
||||||
checkItem(mpt.fattyLiverIdx, 'fattyLiverIdx');
|
|
||||||
|
|
||||||
// 미네랄 카테고리
|
|
||||||
checkItem(mpt.calcium, 'calcium');
|
|
||||||
checkItem(mpt.phosphorus, 'phosphorus');
|
|
||||||
checkItem(mpt.caPRatio, 'caPRatio');
|
|
||||||
checkItem(mpt.magnesium, 'magnesium');
|
|
||||||
|
|
||||||
// 카테고리별 통계 업데이트
|
|
||||||
for (const [cat, status] of Object.entries(categoryStatus)) {
|
|
||||||
const category = cat as keyof typeof categoryStats;
|
|
||||||
categoryStats[category][status]++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 위험 개체 정렬 및 상위 5개만
|
async remove(id: number): Promise<void> {
|
||||||
const sortedRiskyCows = riskyCowsList
|
const mpt = await this.findOne(id);
|
||||||
.sort((a, b) => {
|
await this.mptRepository.softRemove(mpt);
|
||||||
// 범위 이탈 정도로 정렬
|
|
||||||
const refA = MPT_REFERENCE_RANGES[a.itemName];
|
|
||||||
const refB = MPT_REFERENCE_RANGES[b.itemName];
|
|
||||||
const deviationA = a.status === 'high'
|
|
||||||
? (a.value - refA.upperLimit) / (refA.upperLimit - refA.lowerLimit)
|
|
||||||
: (refA.lowerLimit - a.value) / (refA.upperLimit - refA.lowerLimit);
|
|
||||||
const deviationB = b.status === 'high'
|
|
||||||
? (b.value - refB.upperLimit) / (refB.upperLimit - refB.lowerLimit)
|
|
||||||
: (refB.lowerLimit - b.value) / (refB.upperLimit - refB.lowerLimit);
|
|
||||||
return deviationB - deviationA;
|
|
||||||
})
|
|
||||||
.slice(0, 5);
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalMptCows,
|
|
||||||
latestTestDate,
|
|
||||||
categories: categoryStats,
|
|
||||||
riskyCows: sortedRiskyCows,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MPT 참조값 조회
|
|
||||||
*/
|
|
||||||
getReferenceValues(): { references: Record<string, MptReferenceRange>; categories: MptCategory[] } {
|
|
||||||
return {
|
|
||||||
references: MPT_REFERENCE_RANGES,
|
|
||||||
categories: MPT_CATEGORIES,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
FilterEngineResult,
|
FilterEngineResult,
|
||||||
SortOption,
|
SortOption,
|
||||||
} from './interfaces/filter.interface';
|
} from './interfaces/filter.interface';
|
||||||
import { PAGINATION_CONFIG } from '../../common/config/PaginationConfig';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 동적 필터링, 정렬, 페이지네이션을 제공하는 공통 엔진
|
* 동적 필터링, 정렬, 페이지네이션을 제공하는 공통 엔진
|
||||||
@@ -161,15 +160,11 @@
|
|||||||
// 3. 전체 개수 조회 (페이지네이션 전)
|
// 3. 전체 개수 조회 (페이지네이션 전)
|
||||||
const total = await queryBuilder.getCount();
|
const total = await queryBuilder.getCount();
|
||||||
|
|
||||||
// 4. 페이지네이션 적용 (기본값: PaginationConfig 사용)
|
// 4. 페이지네이션 적용
|
||||||
const page = options.pagination?.page ?? PAGINATION_CONFIG.LIMITS.DEFAULT_PAGE;
|
if (options.pagination) {
|
||||||
const requestedLimit = options.pagination?.limit ?? PAGINATION_CONFIG.DEFAULTS.COW_LIST;
|
const { page, limit } = options.pagination;
|
||||||
// 최대값 제한 적용
|
|
||||||
const limit = Math.min(
|
|
||||||
Math.max(requestedLimit, PAGINATION_CONFIG.LIMITS.MIN),
|
|
||||||
PAGINATION_CONFIG.LIMITS.MAX
|
|
||||||
);
|
|
||||||
this.applyPagination(queryBuilder, page, limit);
|
this.applyPagination(queryBuilder, page, limit);
|
||||||
|
}
|
||||||
|
|
||||||
// 5. 데이터 조회
|
// 5. 데이터 조회
|
||||||
const data = await queryBuilder.getMany();
|
const data = await queryBuilder.getMany();
|
||||||
@@ -178,11 +173,15 @@
|
|||||||
const result: FilterEngineResult<T> = {
|
const result: FilterEngineResult<T> = {
|
||||||
data,
|
data,
|
||||||
total,
|
total,
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
totalPages: Math.ceil(total / limit),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (options.pagination) {
|
||||||
|
const { page, limit } = options.pagination;
|
||||||
|
result.page = page;
|
||||||
|
result.limit = limit;
|
||||||
|
result.totalPages = Math.ceil(total / limit);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/**
|
|
||||||
* 시스템 헬스체크 응답 DTO
|
|
||||||
*/
|
|
||||||
export interface SystemHealthResponse {
|
|
||||||
status: 'ok' | 'error';
|
|
||||||
timestamp: string;
|
|
||||||
environment: string;
|
|
||||||
database: {
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
database: string;
|
|
||||||
user: string;
|
|
||||||
status: 'connected' | 'disconnected';
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
/**
|
|
||||||
* 검사 집계 DTO
|
|
||||||
* 농가별/개체별 유전체, 유전자, 번식능력 검사 현황
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 개체별 검사 상세
|
|
||||||
export class CowTestDetailDto {
|
|
||||||
cowId: string; // 개체번호
|
|
||||||
cowBirthDt: string | null; // 생년월일
|
|
||||||
cowSex: string | null; // 성별
|
|
||||||
hasGenome: boolean; // 유전체 검사 여부
|
|
||||||
hasGene: boolean; // 유전자 검사 여부
|
|
||||||
hasMpt: boolean; // 번식능력 검사 여부
|
|
||||||
testCount: number; // 받은 검사 수 (1~3)
|
|
||||||
testTypes: string[]; // 검사 종류 목록
|
|
||||||
}
|
|
||||||
|
|
||||||
// 농가별 검사 집계
|
|
||||||
export class FarmTestSummaryDto {
|
|
||||||
farmNo: number;
|
|
||||||
farmerName: string | null;
|
|
||||||
regionSi: string | null;
|
|
||||||
|
|
||||||
// 검사별 개체수 (중복 허용)
|
|
||||||
genomeCowCount: number; // 유전체 검사 개체수
|
|
||||||
geneCowCount: number; // 유전자 검사 개체수
|
|
||||||
mptCowCount: number; // 번식능력 검사 개체수
|
|
||||||
|
|
||||||
// 중복 검사 조합별 개체수
|
|
||||||
genomeOnly: number; // 유전체만
|
|
||||||
geneOnly: number; // 유전자만
|
|
||||||
mptOnly: number; // 번식능력만
|
|
||||||
genomeAndGene: number; // 유전체 + 유전자
|
|
||||||
genomeAndMpt: number; // 유전체 + 번식능력
|
|
||||||
geneAndMpt: number; // 유전자 + 번식능력
|
|
||||||
allThree: number; // 유전체 + 유전자 + 번식능력
|
|
||||||
|
|
||||||
// 합계
|
|
||||||
totalCows: number; // 전체 개체수 (합집합, 중복 제외)
|
|
||||||
totalTests: number; // 총 검사 건수 (중복 포함)
|
|
||||||
|
|
||||||
// 개체별 상세 (선택적)
|
|
||||||
cows?: CowTestDetailDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전체 검사 집계 (모든 농가 합산)
|
|
||||||
export class TestSummaryDto {
|
|
||||||
// 전체 집계
|
|
||||||
totalFarms: number; // 농가 수
|
|
||||||
totalCows: number; // 전체 개체수 (합집합)
|
|
||||||
totalTests: number; // 총 검사 건수 (중복 포함)
|
|
||||||
|
|
||||||
// 검사별 개체수 (중복 허용)
|
|
||||||
genomeCowCount: number;
|
|
||||||
geneCowCount: number;
|
|
||||||
mptCowCount: number;
|
|
||||||
|
|
||||||
// 중복 검사 조합별 개체수
|
|
||||||
genomeOnly: number;
|
|
||||||
geneOnly: number;
|
|
||||||
mptOnly: number;
|
|
||||||
genomeAndGene: number;
|
|
||||||
genomeAndMpt: number;
|
|
||||||
geneAndMpt: number;
|
|
||||||
allThree: number;
|
|
||||||
|
|
||||||
// 농가별 상세
|
|
||||||
farms: FarmTestSummaryDto[];
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { SystemService } from './system.service';
|
import { SystemService, SystemHealthResponse } from './system.service';
|
||||||
import { SystemHealthResponse } from './dto/system-health.dto';
|
|
||||||
import { TestSummaryDto } from './dto/test-summary.dto';
|
|
||||||
import { Public } from '../common/decorators/public.decorator';
|
import { Public } from '../common/decorators/public.decorator';
|
||||||
|
|
||||||
@Controller('system')
|
@Controller('system')
|
||||||
@@ -13,14 +11,4 @@ export class SystemController {
|
|||||||
async getHealth(): Promise<SystemHealthResponse> {
|
async getHealth(): Promise<SystemHealthResponse> {
|
||||||
return this.systemService.getHealth();
|
return this.systemService.getHealth();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 검사 집계 조회
|
|
||||||
* 농가별/개체별 유전체, 유전자, 번식능력 검사 현황
|
|
||||||
*/
|
|
||||||
@Public()
|
|
||||||
@Get('test-summary')
|
|
||||||
async getTestSummary(): Promise<TestSummaryDto> {
|
|
||||||
return this.systemService.getTestSummary();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { SystemController } from './system.controller';
|
import { SystemController } from './system.controller';
|
||||||
import { SystemService } from './system.service';
|
import { SystemService } from './system.service';
|
||||||
import { CowModel } from '../cow/entities/cow.entity';
|
|
||||||
import { FarmModel } from '../farm/entities/farm.entity';
|
|
||||||
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
|
||||||
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
|
|
||||||
import { MptModel } from '../mpt/entities/mpt.entity';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
|
||||||
TypeOrmModule.forFeature([
|
|
||||||
CowModel,
|
|
||||||
FarmModel,
|
|
||||||
GenomeTraitDetailModel,
|
|
||||||
GeneDetailModel,
|
|
||||||
MptModel,
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
controllers: [SystemController],
|
controllers: [SystemController],
|
||||||
providers: [SystemService],
|
providers: [SystemService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,30 +1,27 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource, IsNull, Repository } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { SystemHealthResponse } from './dto/system-health.dto';
|
|
||||||
import { TestSummaryDto, FarmTestSummaryDto, CowTestDetailDto } from './dto/test-summary.dto';
|
export interface SystemHealthResponse {
|
||||||
import { CowModel } from '../cow/entities/cow.entity';
|
status: 'ok' | 'error';
|
||||||
import { FarmModel } from '../farm/entities/farm.entity';
|
timestamp: string;
|
||||||
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
environment: string;
|
||||||
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
|
database: {
|
||||||
import { MptModel } from '../mpt/entities/mpt.entity';
|
host: string;
|
||||||
|
port: number;
|
||||||
|
database: string;
|
||||||
|
user: string;
|
||||||
|
status: 'connected' | 'disconnected';
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SystemService {
|
export class SystemService {
|
||||||
constructor(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
@InjectDataSource() private dataSource: DataSource,
|
@InjectDataSource() private dataSource: DataSource,
|
||||||
@InjectRepository(CowModel)
|
|
||||||
private readonly cowRepository: Repository<CowModel>,
|
|
||||||
@InjectRepository(FarmModel)
|
|
||||||
private readonly farmRepository: Repository<FarmModel>,
|
|
||||||
@InjectRepository(GenomeTraitDetailModel)
|
|
||||||
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
|
|
||||||
@InjectRepository(GeneDetailModel)
|
|
||||||
private readonly geneDetailRepository: Repository<GeneDetailModel>,
|
|
||||||
@InjectRepository(MptModel)
|
|
||||||
private readonly mptRepository: Repository<MptModel>,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getHealth(): Promise<SystemHealthResponse> {
|
async getHealth(): Promise<SystemHealthResponse> {
|
||||||
@@ -53,233 +50,4 @@ export class SystemService {
|
|||||||
return { ...config, status: 'disconnected' as const, error: error.message };
|
return { ...config, status: 'disconnected' as const, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 검사 집계 조회
|
|
||||||
* 농가별/개체별 유전체, 유전자, 번식능력 검사 현황
|
|
||||||
*/
|
|
||||||
async getTestSummary(): Promise<TestSummaryDto> {
|
|
||||||
// 1. 모든 농가 조회
|
|
||||||
const farms = await this.farmRepository.find({
|
|
||||||
where: { delDt: IsNull() },
|
|
||||||
order: { farmerName: 'ASC' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 각 검사별 cowId 조회 (전체)
|
|
||||||
const [genomeCowIds, geneCowIds, mptCowIds] = await Promise.all([
|
|
||||||
// 유전체 검사 개체 (형질 데이터 보유)
|
|
||||||
this.genomeTraitDetailRepository
|
|
||||||
.createQueryBuilder('trait')
|
|
||||||
.select('DISTINCT trait.cowId', 'cowId')
|
|
||||||
.where('trait.delDt IS NULL')
|
|
||||||
.getRawMany()
|
|
||||||
.then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)),
|
|
||||||
// 유전자검사 개체
|
|
||||||
this.geneDetailRepository
|
|
||||||
.createQueryBuilder('gene')
|
|
||||||
.select('DISTINCT gene.cowId', 'cowId')
|
|
||||||
.where('gene.delDt IS NULL')
|
|
||||||
.getRawMany()
|
|
||||||
.then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)),
|
|
||||||
// 번식능력검사 개체
|
|
||||||
this.mptRepository
|
|
||||||
.createQueryBuilder('mpt')
|
|
||||||
.select('DISTINCT mpt.cowId', 'cowId')
|
|
||||||
.where('mpt.delDt IS NULL')
|
|
||||||
.getRawMany()
|
|
||||||
.then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const genomeSet = new Set(genomeCowIds);
|
|
||||||
const geneSet = new Set(geneCowIds);
|
|
||||||
const mptSet = new Set(mptCowIds);
|
|
||||||
|
|
||||||
// 3. 모든 개체 정보 조회 (cowId로 농가 매핑)
|
|
||||||
const allCows = await this.cowRepository.find({
|
|
||||||
where: { delDt: IsNull() },
|
|
||||||
select: ['cowId', 'cowBirthDt', 'cowSex', 'fkFarmNo'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const cowFarmMap = new Map<string, number>();
|
|
||||||
const cowInfoMap = new Map<string, { cowBirthDt: Date | null; cowSex: string | null }>();
|
|
||||||
for (const cow of allCows) {
|
|
||||||
if (cow.cowId && cow.fkFarmNo) {
|
|
||||||
cowFarmMap.set(cow.cowId, cow.fkFarmNo);
|
|
||||||
cowInfoMap.set(cow.cowId, { cowBirthDt: cow.cowBirthDt, cowSex: cow.cowSex });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 농가별 집계
|
|
||||||
const farmSummaries: FarmTestSummaryDto[] = [];
|
|
||||||
|
|
||||||
for (const farm of farms) {
|
|
||||||
const farmNo = farm.pkFarmNo;
|
|
||||||
const farmCowIds = new Set<string>();
|
|
||||||
|
|
||||||
// 해당 농가의 개체 필터링
|
|
||||||
for (const [cowId, fNo] of cowFarmMap.entries()) {
|
|
||||||
if (fNo === farmNo) {
|
|
||||||
// 검사 받은 개체만 추가
|
|
||||||
if (genomeSet.has(cowId) || geneSet.has(cowId) || mptSet.has(cowId)) {
|
|
||||||
farmCowIds.add(cowId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 개체별 검사 상세
|
|
||||||
const cows: CowTestDetailDto[] = [];
|
|
||||||
let genomeCowCount = 0;
|
|
||||||
let geneCowCount = 0;
|
|
||||||
let mptCowCount = 0;
|
|
||||||
let genomeOnly = 0;
|
|
||||||
let geneOnly = 0;
|
|
||||||
let mptOnly = 0;
|
|
||||||
let genomeAndGene = 0;
|
|
||||||
let genomeAndMpt = 0;
|
|
||||||
let geneAndMpt = 0;
|
|
||||||
let allThree = 0;
|
|
||||||
let totalTests = 0;
|
|
||||||
|
|
||||||
for (const cowId of farmCowIds) {
|
|
||||||
const hasGenome = genomeSet.has(cowId);
|
|
||||||
const hasGene = geneSet.has(cowId);
|
|
||||||
const hasMpt = mptSet.has(cowId);
|
|
||||||
const cowInfo = cowInfoMap.get(cowId);
|
|
||||||
|
|
||||||
const testTypes: string[] = [];
|
|
||||||
if (hasGenome) testTypes.push('유전체');
|
|
||||||
if (hasGene) testTypes.push('유전자');
|
|
||||||
if (hasMpt) testTypes.push('번식능력');
|
|
||||||
|
|
||||||
// cowBirthDt 포맷 처리 (Date 객체 또는 문자열)
|
|
||||||
let birthDtStr: string | null = null;
|
|
||||||
if (cowInfo?.cowBirthDt) {
|
|
||||||
if (cowInfo.cowBirthDt instanceof Date) {
|
|
||||||
birthDtStr = cowInfo.cowBirthDt.toISOString().split('T')[0];
|
|
||||||
} else {
|
|
||||||
birthDtStr = String(cowInfo.cowBirthDt).split('T')[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cows.push({
|
|
||||||
cowId,
|
|
||||||
cowBirthDt: birthDtStr,
|
|
||||||
cowSex: cowInfo?.cowSex || null,
|
|
||||||
hasGenome,
|
|
||||||
hasGene,
|
|
||||||
hasMpt,
|
|
||||||
testCount: testTypes.length,
|
|
||||||
testTypes,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 검사별 카운트
|
|
||||||
if (hasGenome) genomeCowCount++;
|
|
||||||
if (hasGene) geneCowCount++;
|
|
||||||
if (hasMpt) mptCowCount++;
|
|
||||||
totalTests += testTypes.length;
|
|
||||||
|
|
||||||
// 중복 검사 조합별 카운트
|
|
||||||
if (hasGenome && hasGene && hasMpt) {
|
|
||||||
allThree++;
|
|
||||||
} else if (hasGenome && hasGene && !hasMpt) {
|
|
||||||
genomeAndGene++;
|
|
||||||
} else if (hasGenome && !hasGene && hasMpt) {
|
|
||||||
genomeAndMpt++;
|
|
||||||
} else if (!hasGenome && hasGene && hasMpt) {
|
|
||||||
geneAndMpt++;
|
|
||||||
} else if (hasGenome && !hasGene && !hasMpt) {
|
|
||||||
genomeOnly++;
|
|
||||||
} else if (!hasGenome && hasGene && !hasMpt) {
|
|
||||||
geneOnly++;
|
|
||||||
} else if (!hasGenome && !hasGene && hasMpt) {
|
|
||||||
mptOnly++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// testCount 내림차순, cowId 오름차순 정렬
|
|
||||||
cows.sort((a, b) => {
|
|
||||||
if (b.testCount !== a.testCount) return b.testCount - a.testCount;
|
|
||||||
return a.cowId.localeCompare(b.cowId);
|
|
||||||
});
|
|
||||||
|
|
||||||
farmSummaries.push({
|
|
||||||
farmNo,
|
|
||||||
farmerName: farm.farmerName || null,
|
|
||||||
regionSi: farm.regionSi || null,
|
|
||||||
genomeCowCount,
|
|
||||||
geneCowCount,
|
|
||||||
mptCowCount,
|
|
||||||
genomeOnly,
|
|
||||||
geneOnly,
|
|
||||||
mptOnly,
|
|
||||||
genomeAndGene,
|
|
||||||
genomeAndMpt,
|
|
||||||
geneAndMpt,
|
|
||||||
allThree,
|
|
||||||
totalCows: farmCowIds.size,
|
|
||||||
totalTests,
|
|
||||||
cows,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 검사 개체가 있는 농가만 필터링
|
|
||||||
const activeFarms = farmSummaries.filter(f => f.totalCows > 0);
|
|
||||||
|
|
||||||
// 5. 전체 집계 계산
|
|
||||||
const allTestedCowIds = new Set([...genomeCowIds, ...geneCowIds, ...mptCowIds]);
|
|
||||||
|
|
||||||
let totalGenomeOnly = 0;
|
|
||||||
let totalGeneOnly = 0;
|
|
||||||
let totalMptOnly = 0;
|
|
||||||
let totalGenomeAndGene = 0;
|
|
||||||
let totalGenomeAndMpt = 0;
|
|
||||||
let totalGeneAndMpt = 0;
|
|
||||||
let totalAllThree = 0;
|
|
||||||
let totalTestsSum = 0;
|
|
||||||
|
|
||||||
for (const cowId of allTestedCowIds) {
|
|
||||||
const hasGenome = genomeSet.has(cowId);
|
|
||||||
const hasGene = geneSet.has(cowId);
|
|
||||||
const hasMpt = mptSet.has(cowId);
|
|
||||||
|
|
||||||
let testCount = 0;
|
|
||||||
if (hasGenome) testCount++;
|
|
||||||
if (hasGene) testCount++;
|
|
||||||
if (hasMpt) testCount++;
|
|
||||||
totalTestsSum += testCount;
|
|
||||||
|
|
||||||
if (hasGenome && hasGene && hasMpt) {
|
|
||||||
totalAllThree++;
|
|
||||||
} else if (hasGenome && hasGene && !hasMpt) {
|
|
||||||
totalGenomeAndGene++;
|
|
||||||
} else if (hasGenome && !hasGene && hasMpt) {
|
|
||||||
totalGenomeAndMpt++;
|
|
||||||
} else if (!hasGenome && hasGene && hasMpt) {
|
|
||||||
totalGeneAndMpt++;
|
|
||||||
} else if (hasGenome && !hasGene && !hasMpt) {
|
|
||||||
totalGenomeOnly++;
|
|
||||||
} else if (!hasGenome && hasGene && !hasMpt) {
|
|
||||||
totalGeneOnly++;
|
|
||||||
} else if (!hasGenome && !hasGene && hasMpt) {
|
|
||||||
totalMptOnly++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalFarms: activeFarms.length,
|
|
||||||
totalCows: allTestedCowIds.size,
|
|
||||||
totalTests: totalTestsSum,
|
|
||||||
genomeCowCount: genomeCowIds.length,
|
|
||||||
geneCowCount: geneCowIds.length,
|
|
||||||
mptCowCount: mptCowIds.length,
|
|
||||||
genomeOnly: totalGenomeOnly,
|
|
||||||
geneOnly: totalGeneOnly,
|
|
||||||
mptOnly: totalMptOnly,
|
|
||||||
genomeAndGene: totalGenomeAndGene,
|
|
||||||
genomeAndMpt: totalGenomeAndMpt,
|
|
||||||
geneAndMpt: totalGeneAndMpt,
|
|
||||||
allThree: totalAllThree,
|
|
||||||
farms: activeFarms,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ interface UploadState {
|
|||||||
status: "idle" | "uploading" | "success" | "error"
|
status: "idle" | "uploading" | "success" | "error"
|
||||||
message: string
|
message: string
|
||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
fileType: FileType
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileUploadCard({ fileType }: { fileType: FileType }) {
|
function FileUploadCard({ fileType }: { fileType: FileType }) {
|
||||||
@@ -82,7 +81,6 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
|
|||||||
status: "idle",
|
status: "idle",
|
||||||
message: "",
|
message: "",
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
fileType: fileType,
|
|
||||||
})
|
})
|
||||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -111,7 +109,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
|
|||||||
const files = e.dataTransfer.files
|
const files = e.dataTransfer.files
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
const file = files[0]
|
const file = files[0]
|
||||||
if (isValidFile(file)) {
|
if (isValidExcelFile(file)) {
|
||||||
setState(prev => ({ ...prev, file, status: "idle", message: "" }))
|
setState(prev => ({ ...prev, file, status: "idle", message: "" }))
|
||||||
} else {
|
} else {
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
@@ -127,7 +125,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
|
|||||||
const files = e.target.files
|
const files = e.target.files
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
const file = files[0]
|
const file = files[0]
|
||||||
if (isValidFile(file)) {
|
if (isValidExcelFile(file)) {
|
||||||
setState(prev => ({ ...prev, file, status: "idle", message: "" }))
|
setState(prev => ({ ...prev, file, status: "idle", message: "" }))
|
||||||
} else {
|
} else {
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
@@ -139,21 +137,18 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidFile = (file: File): boolean => {
|
const isValidExcelFile = (file: File): boolean => {
|
||||||
const validExtensions = [
|
const validExtensions = [
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
"application/vnd.ms-excel",
|
"application/vnd.ms-excel",
|
||||||
"text/csv",
|
"text/csv",
|
||||||
"application/csv",
|
"application/csv",
|
||||||
"application/haansoftxls",
|
|
||||||
"text/plain",
|
|
||||||
]
|
]
|
||||||
return (
|
return (
|
||||||
validExtensions.includes(file.type) ||
|
validExtensions.includes(file.type) ||
|
||||||
file.name.endsWith(".xlsx") ||
|
file.name.endsWith(".xlsx") ||
|
||||||
file.name.endsWith(".xls") ||
|
file.name.endsWith(".xls") ||
|
||||||
file.name.endsWith(".csv") ||
|
file.name.endsWith(".csv")
|
||||||
file.name.endsWith(".txt")
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,28 +167,16 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("file", state.file)
|
formData.append("file", state.file)
|
||||||
formData.append("div", state.fileType.id)
|
formData.append("fileType", fileType.fileType)
|
||||||
|
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/uploadfile`, {
|
||||||
// const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/uploadfile`, {
|
|
||||||
// method: "POST",
|
|
||||||
// body: formData,
|
|
||||||
// headers: {
|
|
||||||
// 'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
|
|
||||||
// 배치 파일 업로드 테스트
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/admin/batchUpload`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${useAuthStore.getState().accessToken}`,
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("파일 업로드에 실패했습니다.")
|
throw new Error("파일 업로드에 실패했습니다.")
|
||||||
}
|
}
|
||||||
@@ -212,7 +195,6 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
|
|||||||
status: "idle",
|
status: "idle",
|
||||||
message: "",
|
message: "",
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
fileType: fileType,
|
|
||||||
})
|
})
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = ""
|
fileInputRef.current.value = ""
|
||||||
@@ -236,7 +218,6 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
|
|||||||
status: "idle",
|
status: "idle",
|
||||||
message: "",
|
message: "",
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
fileType: fileType,
|
|
||||||
})
|
})
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = ""
|
fileInputRef.current.value = ""
|
||||||
|
|||||||
44
frontend/src/app/cow/[cowNo]/_components/header.tsx
Normal file
44
frontend/src/app/cow/[cowNo]/_components/header.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
|
interface CowHeaderProps {
|
||||||
|
from?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CowHeader({ from }: CowHeaderProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (from === 'ranking') {
|
||||||
|
router.push('/ranking')
|
||||||
|
} else if (from === 'list') {
|
||||||
|
router.push('/list')
|
||||||
|
} else {
|
||||||
|
router.push('/cow')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 뒤로가기 버튼 */}
|
||||||
|
<Button
|
||||||
|
onClick={handleBack}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground hover:text-foreground hover:bg-muted -ml-2 gap-1.5"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span className="text-sm">목록으로</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 페이지 헤더 카드 */}
|
||||||
|
<div className="rounded-lg p-6 border bg-slate-50">
|
||||||
|
<h1 className="text-2xl font-bold mb-2">개체 상세 정보</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">개체의 기본 정보와 분석 현황을 확인할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState } from 'react'
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
DrawerHeader,
|
DrawerHeader,
|
||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
} from "@/components/ui/drawer"
|
} from "@/components/ui/drawer"
|
||||||
import { ComparisonAveragesDto, TraitComparisonAveragesDto } from "@/lib/api/genome.api"
|
import { ComparisonAveragesDto, FarmTraitComparisonDto, TraitComparisonAveragesDto } from "@/lib/api"
|
||||||
import { Pencil, X, RotateCcw } from 'lucide-react'
|
import { Pencil, X, RotateCcw } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
PolarAngleAxis,
|
PolarAngleAxis,
|
||||||
@@ -26,8 +26,73 @@ import {
|
|||||||
ResponsiveContainer
|
ResponsiveContainer
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query"
|
import { useMediaQuery } from "@/hooks/use-media-query"
|
||||||
import { DEFAULT_TRAITS, ALL_TRAITS, TRAIT_CATEGORIES, getTraitDisplayName, TRAIT_DISPLAY_NAMES } from "@/constants/traits"
|
|
||||||
import { GenomeCowTraitDto } from "@/types/genome.types"
|
// 디폴트로 표시할 주요 형질 목록
|
||||||
|
const DEFAULT_TRAITS = ['도체중', '등심단면적', '등지방두께', '근내지방도', '체장', '체고', '등심weight']
|
||||||
|
|
||||||
|
// 전체 형질 목록 (35개)
|
||||||
|
const ALL_TRAITS = [
|
||||||
|
// 성장형질 (1개)
|
||||||
|
'12개월령체중',
|
||||||
|
// 경제형질 (4개)
|
||||||
|
'도체중', '등심단면적', '등지방두께', '근내지방도',
|
||||||
|
// 체형형질 (10개)
|
||||||
|
'체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
||||||
|
// 부위별무게 (10개)
|
||||||
|
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
||||||
|
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
||||||
|
// 부위별비율 (10개)
|
||||||
|
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
||||||
|
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
||||||
|
]
|
||||||
|
|
||||||
|
// 형질 카테고리 (백엔드 API와 일치: 성장, 생산, 체형, 무게, 비율)
|
||||||
|
const TRAIT_CATEGORIES: Record<string, string[]> = {
|
||||||
|
'성장': ['12개월령체중'],
|
||||||
|
'생산': ['도체중', '등심단면적', '등지방두께', '근내지방도'],
|
||||||
|
'체형': ['체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위'],
|
||||||
|
'무게': ['안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight', '우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight'],
|
||||||
|
'비율': ['안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate', '우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate'],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 형질명 표시 (전체 이름)
|
||||||
|
const TRAIT_SHORT_NAMES: Record<string, string> = {
|
||||||
|
'도체중': '도체중',
|
||||||
|
'등심단면적': '등심단면적',
|
||||||
|
'등지방두께': '등지방두께',
|
||||||
|
'근내지방도': '근내지방도',
|
||||||
|
'체장': '체장',
|
||||||
|
'체고': '체고',
|
||||||
|
'등심weight': '등심중량',
|
||||||
|
'12개월령체중': '12개월령체중',
|
||||||
|
'십자': '십자',
|
||||||
|
'흉심': '흉심',
|
||||||
|
'흉폭': '흉폭',
|
||||||
|
'고장': '고장',
|
||||||
|
'요각폭': '요각폭',
|
||||||
|
'좌골폭': '좌골폭',
|
||||||
|
'곤폭': '곤폭',
|
||||||
|
'흉위': '흉위',
|
||||||
|
'안심weight': '안심무게',
|
||||||
|
'채끝weight': '채끝무게',
|
||||||
|
'목심weight': '목심무게',
|
||||||
|
'앞다리weight': '앞다리무게',
|
||||||
|
'우둔weight': '우둔무게',
|
||||||
|
'설도weight': '설도무게',
|
||||||
|
'사태weight': '사태무게',
|
||||||
|
'양지weight': '양지무게',
|
||||||
|
'갈비weight': '갈비무게',
|
||||||
|
'안심rate': '안심비율',
|
||||||
|
'등심rate': '등심비율',
|
||||||
|
'채끝rate': '채끝비율',
|
||||||
|
'목심rate': '목심비율',
|
||||||
|
'앞다리rate': '앞다리비율',
|
||||||
|
'우둔rate': '우둔비율',
|
||||||
|
'설도rate': '설도비율',
|
||||||
|
'사태rate': '사태비율',
|
||||||
|
'양지rate': '양지비율',
|
||||||
|
'갈비rate': '갈비비율',
|
||||||
|
}
|
||||||
|
|
||||||
interface CategoryStat {
|
interface CategoryStat {
|
||||||
category: string
|
category: string
|
||||||
@@ -36,14 +101,27 @@ interface CategoryStat {
|
|||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TraitData {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
breedVal: number // 표준화육종가 (σ 단위)
|
||||||
|
percentile: number
|
||||||
|
actualValue: number // EPD (예상후대차이) 원래 값
|
||||||
|
unit: string
|
||||||
|
description: string
|
||||||
|
importance: string
|
||||||
|
}
|
||||||
|
|
||||||
interface CategoryEvaluationCardProps {
|
interface CategoryEvaluationCardProps {
|
||||||
categoryStats: CategoryStat[]
|
categoryStats: CategoryStat[]
|
||||||
comparisonAverages: ComparisonAveragesDto | null
|
comparisonAverages: ComparisonAveragesDto | null
|
||||||
traitComparisonAverages?: TraitComparisonAveragesDto | null // 형질별 평균 비교 데이터 (폴리곤 차트용)
|
traitComparisonAverages?: TraitComparisonAveragesDto | null // 형질별 평균 비교 데이터 (폴리곤 차트용)
|
||||||
regionAvgZ: number
|
regionAvgZ: number
|
||||||
farmAvgZ: number
|
farmAvgZ: number
|
||||||
allTraits?: GenomeCowTraitDto[]
|
allTraits?: TraitData[]
|
||||||
cowNo?: string
|
cowNo?: string
|
||||||
|
traitAverages?: FarmTraitComparisonDto | null // 형질별 평균 비교 데이터 (기존)
|
||||||
hideTraitCards?: boolean // 형질 카드 숨김 여부
|
hideTraitCards?: boolean // 형질 카드 숨김 여부
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,13 +147,11 @@ export function CategoryEvaluationCard({
|
|||||||
farmAvgZ,
|
farmAvgZ,
|
||||||
allTraits = [],
|
allTraits = [],
|
||||||
cowNo,
|
cowNo,
|
||||||
|
traitAverages,
|
||||||
hideTraitCards = false,
|
hideTraitCards = false,
|
||||||
}: CategoryEvaluationCardProps) {
|
}: CategoryEvaluationCardProps) {
|
||||||
// 차트에 표시할 형질 목록 (커스텀 가능)
|
// 차트에 표시할 형질 목록 (커스텀 가능)
|
||||||
const [chartTraits, setChartTraits] = useState<string[]>([...DEFAULT_TRAITS])
|
const [chartTraits, setChartTraits] = useState<string[]>(DEFAULT_TRAITS)
|
||||||
|
|
||||||
// 활성화된 형질 목록 (차트에 표시할 형질)
|
|
||||||
const [activeTraits, setActiveTraits] = useState<Set<string>>(new Set([...DEFAULT_TRAITS]))
|
|
||||||
|
|
||||||
// 형질 추가 모달/드로어 상태
|
// 형질 추가 모달/드로어 상태
|
||||||
const [isTraitSelectorOpen, setIsTraitSelectorOpen] = useState(false)
|
const [isTraitSelectorOpen, setIsTraitSelectorOpen] = useState(false)
|
||||||
@@ -83,44 +159,13 @@ export function CategoryEvaluationCard({
|
|||||||
// 선택된 형질 (터치/클릭 시 정보 표시용)
|
// 선택된 형질 (터치/클릭 시 정보 표시용)
|
||||||
const [selectedTraitName, setSelectedTraitName] = useState<string | null>(null)
|
const [selectedTraitName, setSelectedTraitName] = useState<string | null>(null)
|
||||||
|
|
||||||
// 차트 로딩 상태
|
|
||||||
const [isChartLoading, setIsChartLoading] = useState(false)
|
|
||||||
|
|
||||||
// 모바일 여부 확인
|
// 모바일 여부 확인
|
||||||
const isDesktop = useMediaQuery("(min-width: 768px)")
|
const isDesktop = useMediaQuery("(min-width: 768px)")
|
||||||
|
|
||||||
// 형질 활성화/비활성화 토글
|
|
||||||
const toggleTraitActive = (traitName: string) => {
|
|
||||||
setActiveTraits(prev => {
|
|
||||||
const newSet = new Set(prev)
|
|
||||||
if (newSet.has(traitName)) {
|
|
||||||
// 비활성화 시 제한 없음 (2개 이하일 때 차트 비활성화로 처리)
|
|
||||||
newSet.delete(traitName)
|
|
||||||
} else {
|
|
||||||
newSet.add(traitName)
|
|
||||||
}
|
|
||||||
return newSet
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 차트 데이터 변경 시 로딩 처리
|
|
||||||
useEffect(() => {
|
|
||||||
setIsChartLoading(true)
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setIsChartLoading(false)
|
|
||||||
}, 300) // 차트 렌더링 시뮬레이션
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [activeTraits])
|
|
||||||
|
|
||||||
// 형질 제거
|
// 형질 제거
|
||||||
const removeTrait = (traitName: string) => {
|
const removeTrait = (traitName: string) => {
|
||||||
if (chartTraits.length > 3) { // 최소 3개는 유지
|
if (chartTraits.length > 3) { // 최소 3개는 유지
|
||||||
setChartTraits(prev => prev.filter(t => t !== traitName))
|
setChartTraits(prev => prev.filter(t => t !== traitName))
|
||||||
setActiveTraits(prev => {
|
|
||||||
const newSet = new Set(prev)
|
|
||||||
newSet.delete(traitName)
|
|
||||||
return newSet
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,22 +173,17 @@ export function CategoryEvaluationCard({
|
|||||||
const addTrait = (traitName: string) => {
|
const addTrait = (traitName: string) => {
|
||||||
if (chartTraits.length < 7 && !chartTraits.includes(traitName)) { // 최대 7개
|
if (chartTraits.length < 7 && !chartTraits.includes(traitName)) { // 최대 7개
|
||||||
setChartTraits(prev => [...prev, traitName])
|
setChartTraits(prev => [...prev, traitName])
|
||||||
setActiveTraits(prev => new Set([...prev, traitName]))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기본값으로 초기화
|
// 기본값으로 초기화
|
||||||
const resetToDefault = () => {
|
const resetToDefault = () => {
|
||||||
setChartTraits([...DEFAULT_TRAITS])
|
setChartTraits(DEFAULT_TRAITS)
|
||||||
setActiveTraits(new Set([...DEFAULT_TRAITS]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 폴리곤 차트용 데이터 생성 (활성화된 형질만 포함) - 보은군, 농가, 이 개체 비교
|
// 폴리곤 차트용 데이터 생성 (선택된 형질 기반) - 보은군, 농가, 이 개체 비교
|
||||||
const traitChartData = useMemo(() => {
|
const traitChartData = chartTraits.map(traitName => {
|
||||||
return chartTraits
|
const trait = allTraits.find((t: TraitData) => t.name === traitName)
|
||||||
.filter(traitName => activeTraits.has(traitName))
|
|
||||||
.map(traitName => {
|
|
||||||
const trait = allTraits.find((t: GenomeCowTraitDto) => t.traitName === traitName)
|
|
||||||
|
|
||||||
// 형질별 평균 데이터에서 해당 형질 찾기
|
// 형질별 평균 데이터에서 해당 형질 찾기
|
||||||
const traitAvgRegion = traitComparisonAverages?.region?.find(t => t.traitName === traitName)
|
const traitAvgRegion = traitComparisonAverages?.region?.find(t => t.traitName === traitName)
|
||||||
@@ -158,83 +198,43 @@ export function CategoryEvaluationCard({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
name: traitName,
|
name: traitName,
|
||||||
shortName: getTraitDisplayName(traitName),
|
shortName: TRAIT_SHORT_NAMES[traitName] || traitName,
|
||||||
breedVal: trait?.breedVal ?? 0, // 이 개체 표준화육종가 (σ)
|
breedVal: trait?.breedVal ?? 0, // 이 개체 표준화육종가 (σ)
|
||||||
epd: trait?.traitVal ?? 0, // 이 개체 EPD (육종가)
|
epd: trait?.actualValue ?? 0, // 이 개체 EPD (육종가)
|
||||||
regionVal: regionTraitAvg, // 보은군 평균 (표준화육종가)
|
regionVal: regionTraitAvg, // 보은군 평균 (표준화육종가)
|
||||||
farmVal: farmTraitAvg, // 농가 평균 (표준화육종가)
|
farmVal: farmTraitAvg, // 농가 평균 (표준화육종가)
|
||||||
regionEpd: regionEpdAvg, // 보은군 평균 (육종가)
|
regionEpd: regionEpdAvg, // 보은군 평균 (육종가)
|
||||||
farmEpd: farmEpdAvg, // 농가 평균 (육종가)
|
farmEpd: farmEpdAvg, // 농가 평균 (육종가)
|
||||||
percentile: trait?.percentile ?? 50,
|
percentile: trait?.percentile ?? 50,
|
||||||
category: trait?.traitCategory ?? '체형',
|
category: trait?.category ?? '체형',
|
||||||
diff: trait?.breedVal ?? 0,
|
diff: trait?.breedVal ?? 0,
|
||||||
hasData: !!trait
|
hasData: !!trait
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [chartTraits, activeTraits, allTraits, traitComparisonAverages])
|
|
||||||
|
|
||||||
// 가장 높은 형질 찾기 (이 개체 기준)
|
// 가장 높은 형질 찾기 (이 개체 기준)
|
||||||
const bestTraitName = traitChartData.reduce((best, current) =>
|
const bestTraitName = traitChartData.reduce((best, current) =>
|
||||||
current.breedVal > best.breedVal ? current : best
|
current.breedVal > best.breedVal ? current : best
|
||||||
, traitChartData[0])?.shortName
|
, traitChartData[0])?.shortName
|
||||||
|
|
||||||
// 동적 스케일 계산 (실제 데이터 범위를 기반으로, min/max 각각에 5% 여유분만 추가)
|
// 동적 스케일 계산 (모든 값의 최대 절대값 기준)
|
||||||
// useMemo를 사용하는 이유: traitChartData가 변경될 때만 재계산하여 성능 최적화
|
|
||||||
// - traitChartData는 activeTraits, chartTraits, allTraits, traitComparisonAverages에 의존
|
|
||||||
// - 이 값들이 변경될 때마다 스케일을 다시 계산해야 함
|
|
||||||
// - useMemo를 사용하면 의존성이 변경되지 않으면 이전 계산 결과를 재사용
|
|
||||||
const dynamicDomain = useMemo(() => {
|
|
||||||
if (traitChartData.length === 0) return [-0.3, 0.3]
|
|
||||||
|
|
||||||
// 모든 값 수집 (breedVal, regionVal, farmVal)
|
|
||||||
const allValues = traitChartData.flatMap(d => [d.breedVal, d.regionVal, d.farmVal])
|
const allValues = traitChartData.flatMap(d => [d.breedVal, d.regionVal, d.farmVal])
|
||||||
|
const maxAbsValue = Math.max(...allValues.map(Math.abs), 0.3) // 최소 0.3
|
||||||
// 실제 데이터의 최소값과 최대값 찾기
|
const dynamicDomain = Math.ceil(maxAbsValue * 1.2 * 10) / 10 // 20% 여유
|
||||||
const minValue = Math.min(...allValues)
|
|
||||||
const maxValue = Math.max(...allValues)
|
|
||||||
|
|
||||||
// 데이터 범위 계산
|
|
||||||
const dataRange = maxValue - minValue
|
|
||||||
|
|
||||||
// 데이터 범위가 너무 작으면 최소 범위 보장 (0.3)
|
|
||||||
const effectiveRange = Math.max(dataRange, 0.3)
|
|
||||||
|
|
||||||
// min/max 각각에 범위의 10%만큼 여유분 추가 (대칭 처리하지 않음)
|
|
||||||
const padding = effectiveRange * 0.10
|
|
||||||
let domainMin = minValue - padding
|
|
||||||
let domainMax = maxValue + padding
|
|
||||||
|
|
||||||
// 소수점 첫째자리까지 반올림
|
|
||||||
domainMin = Math.floor(domainMin * 10) / 10
|
|
||||||
domainMax = Math.ceil(domainMax * 10) / 10
|
|
||||||
|
|
||||||
return [domainMin, domainMax]
|
|
||||||
}, [traitChartData])
|
|
||||||
|
|
||||||
// 활성화된 형질 개수
|
|
||||||
const activeTraitsCount = activeTraits.size
|
|
||||||
const hasEnoughTraits = activeTraitsCount >= 3
|
|
||||||
|
|
||||||
// 형질 이름으로 원본 형질명 찾기 (shortName -> name)
|
// 형질 이름으로 원본 형질명 찾기 (shortName -> name)
|
||||||
const findTraitNameByShortName = (shortName: string) => {
|
const findTraitNameByShortName = (shortName: string) => {
|
||||||
const entry = Object.entries(TRAIT_DISPLAY_NAMES).find(([, short]) => short === shortName)
|
const entry = Object.entries(TRAIT_SHORT_NAMES).find(([, short]) => short === shortName)
|
||||||
return entry ? entry[0] : shortName
|
return entry ? entry[0] : shortName
|
||||||
}
|
}
|
||||||
|
|
||||||
// 커스텀 Tick 컴포넌트 (클릭 시 강조)
|
// 커스텀 Tick 컴포넌트 (가장 좋은 형질에 배경색 + 클릭 가능)
|
||||||
const CustomAngleTick = ({ x, y, cx, cy, payload }: { x: number; y: number; cx: number; cy: number; payload: { value: string } }) => {
|
const CustomAngleTick = ({ x, y, payload }: { x: number; y: number; payload: { value: string } }) => {
|
||||||
|
const isBest = payload.value === bestTraitName
|
||||||
const isSelected = selectedTraitName === findTraitNameByShortName(payload.value)
|
const isSelected = selectedTraitName === findTraitNameByShortName(payload.value)
|
||||||
const textWidth = payload.value.length * 11 + 20
|
const textWidth = payload.value.length * 11 + 20
|
||||||
const textHeight = 28
|
const textHeight = 28
|
||||||
|
|
||||||
// 차트 중심에서 바깥 방향으로 offset 추가
|
|
||||||
const offset = 12
|
|
||||||
const dx = x - cx
|
|
||||||
const dy = y - cy
|
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy) || 1
|
|
||||||
const newX = x + (dx / distance) * offset
|
|
||||||
const newY = y + (dy / distance) * offset
|
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
const traitName = findTraitNameByShortName(payload.value)
|
const traitName = findTraitNameByShortName(payload.value)
|
||||||
setSelectedTraitName(prev => prev === traitName ? null : traitName)
|
setSelectedTraitName(prev => prev === traitName ? null : traitName)
|
||||||
@@ -242,18 +242,18 @@ export function CategoryEvaluationCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
transform={`translate(${newX},${newY})`}
|
transform={`translate(${x},${y})`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
{isSelected && (
|
{(isBest || isSelected) && (
|
||||||
<rect
|
<rect
|
||||||
x={-textWidth / 2}
|
x={-textWidth / 2}
|
||||||
y={-textHeight / 2}
|
y={-textHeight / 2}
|
||||||
width={textWidth}
|
width={textWidth}
|
||||||
height={textHeight}
|
height={textHeight}
|
||||||
rx={6}
|
rx={6}
|
||||||
fill="#1482B0"
|
fill={isSelected ? '#1F3A8F' : '#1482B0'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<text
|
<text
|
||||||
@@ -261,9 +261,9 @@ export function CategoryEvaluationCard({
|
|||||||
y={0}
|
y={0}
|
||||||
dy={5}
|
dy={5}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fontSize={isDesktop ? 17 : 15}
|
fontSize={15}
|
||||||
fontWeight={isSelected ? 700 : 600}
|
fontWeight={(isBest || isSelected) ? 700 : 600}
|
||||||
fill={isSelected ? '#ffffff' : '#334155'}
|
fill={(isBest || isSelected) ? '#ffffff' : '#334155'}
|
||||||
>
|
>
|
||||||
{payload.value}
|
{payload.value}
|
||||||
</text>
|
</text>
|
||||||
@@ -287,7 +287,7 @@ export function CategoryEvaluationCard({
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{traits.map(trait => {
|
{traits.map(trait => {
|
||||||
const isSelected = chartTraits.includes(trait)
|
const isSelected = chartTraits.includes(trait)
|
||||||
const traitData = allTraits.find((t: GenomeCowTraitDto) => t.traitName === trait)
|
const traitData = allTraits.find((t: TraitData) => t.name === trait)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={trait}
|
key={trait}
|
||||||
@@ -298,13 +298,12 @@ export function CategoryEvaluationCard({
|
|||||||
: 'bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-50'
|
: 'bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{getTraitDisplayName(trait)}
|
{TRAIT_SHORT_NAMES[trait] || trait}
|
||||||
{/* 육종가(EBV) 값 표시 (주석 처리) */}
|
{traitData && (
|
||||||
{/* {traitData && traitData.breedVal !== undefined && (
|
|
||||||
<span className={`ml-1 text-xs ${isSelected ? 'text-primary-foreground/80' : 'text-muted-foreground'}`}>
|
<span className={`ml-1 text-xs ${isSelected ? 'text-primary-foreground/80' : 'text-muted-foreground'}`}>
|
||||||
({traitData.breedVal > 0 ? '+' : ''}{traitData.breedVal.toFixed(1)})
|
({traitData.breedVal > 0 ? '+' : ''}{traitData.breedVal.toFixed(1)})
|
||||||
</span>
|
</span>
|
||||||
)} */}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -355,52 +354,38 @@ export function CategoryEvaluationCard({
|
|||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||||
{/* 2열 레이아웃: 왼쪽 차트 + 오른쪽 카드 */}
|
{/* 2열 레이아웃: 왼쪽 차트 + 오른쪽 카드 */}
|
||||||
<div className="p-4 lg:p-6 lg:pb-0">
|
<div className="p-4 lg:p-6">
|
||||||
{/* 형질 선택 칩 영역 */}
|
{/* 형질 선택 칩 영역 */}
|
||||||
<div className="mb-4 lg:mb-2">
|
<div className="mb-4 lg:mb-6">
|
||||||
<div className="flex items-center justify-between mb-2 lg:mb-0">
|
<div className="flex items-center justify-between mb-2 lg:mb-3">
|
||||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
<span className="text-sm lg:text-base font-medium text-muted-foreground">비교 형질을 선택해주세요</span>
|
||||||
<div className="text-lg lg:text-base font-medium text-muted-foreground">비교 형질을 선택해주세요 :</div>
|
<button
|
||||||
<div className="flex flex-wrap gap-1.5 lg:gap-2">
|
onClick={() => setIsTraitSelectorOpen(true)}
|
||||||
{chartTraits.map(trait => {
|
className="text-sm lg:text-base text-primary flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-primary/30 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||||
const isActive = activeTraits.has(trait)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={trait}
|
|
||||||
className={`inline-flex items-center gap-1 lg:gap-1.5 px-2.5 lg:px-3.5 py-1 lg:py-1.5 rounded-full text-sm lg:text-base font-medium transition-all cursor-pointer ${
|
|
||||||
isActive
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: 'bg-primary/10 text-primary opacity-50'
|
|
||||||
}`}
|
|
||||||
onClick={() => toggleTraitActive(trait)}
|
|
||||||
>
|
>
|
||||||
<span className="text-md font-bold">{getTraitDisplayName(trait)}</span>
|
|
||||||
{chartTraits.length > 3 && (
|
|
||||||
<span
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
removeTrait(trait)
|
|
||||||
setActiveTraits(prev => {
|
|
||||||
const newSet = new Set(prev)
|
|
||||||
newSet.delete(trait)
|
|
||||||
return newSet
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
className="w-4 h-4 lg:w-5 lg:h-5 rounded-full hover:bg-primary/20 flex items-center justify-center opacity-60 hover:opacity-100 transition-opacity cursor-pointer"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3 lg:w-4 lg:h-4" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setIsTraitSelectorOpen(true)} className="text-sm lg:text-base text-primary flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-primary/30 hover:bg-primary/10 hover:border-primary/50 transition-colors">
|
|
||||||
<Pencil className="w-4 h-4 lg:w-5 lg:h-5" />
|
<Pencil className="w-4 h-4 lg:w-5 lg:h-5" />
|
||||||
편집
|
편집
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5 lg:gap-2">
|
||||||
|
{chartTraits.map(trait => (
|
||||||
|
<span
|
||||||
|
key={trait}
|
||||||
|
className="inline-flex items-center gap-1 lg:gap-1.5 px-2.5 lg:px-3.5 py-1 lg:py-1.5 bg-primary/10 text-primary rounded-full text-sm lg:text-base font-medium group"
|
||||||
|
>
|
||||||
|
{TRAIT_SHORT_NAMES[trait] || trait}
|
||||||
|
{chartTraits.length > 3 && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeTrait(trait)}
|
||||||
|
className="w-4 h-4 lg:w-5 lg:h-5 rounded-full hover:bg-primary/20 flex items-center justify-center opacity-60 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 lg:w-4 lg:h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 형질 편집 모달 - 웹: Dialog, 모바일: Drawer */}
|
{/* 형질 편집 모달 - 웹: Dialog, 모바일: Drawer */}
|
||||||
{isDesktop ? (
|
{isDesktop ? (
|
||||||
@@ -429,47 +414,23 @@ export function CategoryEvaluationCard({
|
|||||||
<div className={`flex flex-col ${hideTraitCards ? '' : 'lg:flex-row'} gap-6`}>
|
<div className={`flex flex-col ${hideTraitCards ? '' : 'lg:flex-row'} gap-6`}>
|
||||||
{/* 폴리곤 차트 */}
|
{/* 폴리곤 차트 */}
|
||||||
<div className={hideTraitCards ? 'w-full' : 'lg:w-1/2'}>
|
<div className={hideTraitCards ? 'w-full' : 'lg:w-1/2'}>
|
||||||
<div className="bg-muted/20 rounded-xl h-full relative">
|
<div className="bg-muted/20 rounded-xl h-full">
|
||||||
<div className={hideTraitCards ? 'h-[95vw] max-h-[520px] sm:h-[420px]' : 'h-[95vw] max-h-[520px] sm:h-[440px]'}>
|
<div className={hideTraitCards ? 'h-[95vw] max-h-[520px] sm:h-[420px]' : 'h-[95vw] max-h-[520px] sm:h-[440px]'}>
|
||||||
{/* 범례 - 좌측 상단 */}
|
|
||||||
<div className="absolute top-2 left-2 z-20 flex items-center gap-2 sm:gap-3 flex-wrap">
|
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
|
||||||
<div className="w-3 h-3 sm:w-3.5 sm:h-3.5 rounded" style={{ backgroundColor: '#10b981' }}></div>
|
|
||||||
<span className="text-lg sm:text-base font-medium text-muted-foreground">보은군 평균</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
|
||||||
<div className="w-3 h-3 sm:w-3.5 sm:h-3.5 rounded" style={{ backgroundColor: '#1F3A8F' }}></div>
|
|
||||||
<span className="text-lg sm:text-base font-medium text-muted-foreground">농가 평균</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
|
||||||
<div className="w-3 h-3 sm:w-3.5 sm:h-3.5 rounded" style={{ backgroundColor: '#1482B0' }}></div>
|
|
||||||
<span className="text-lg sm:text-base font-medium text-foreground">{formatCowNoShort(cowNo)} 개체</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 로딩 상태 또는 최소 형질 개수 미달 */}
|
|
||||||
{(isChartLoading || !hasEnoughTraits) ? (
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-slate-50/80 rounded-xl z-10">
|
|
||||||
{isChartLoading ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent mb-4"></div>
|
|
||||||
<p className="text-sm text-muted-foreground font-medium">차트 데이터 로딩 중...</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-lg text-muted-foreground font-bold">비교 형질 3개 이상 선택해주세요.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<RadarChart data={traitChartData} margin={{ top: 40, right: 0, bottom: 0, left: 0 }}>
|
<RadarChart data={traitChartData} margin={{ top: 25, right: 30, bottom: 25, left: 30 }}>
|
||||||
<PolarGrid
|
<PolarGrid
|
||||||
stroke="#e2e8f0"
|
stroke="#e2e8f0"
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
/>
|
/>
|
||||||
|
<PolarAngleAxis
|
||||||
|
dataKey="shortName"
|
||||||
|
tick={<CustomAngleTick x={0} y={0} payload={{ value: '' }} />}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
<PolarRadiusAxis
|
<PolarRadiusAxis
|
||||||
angle={90}
|
angle={90}
|
||||||
domain={dynamicDomain}
|
domain={[-dynamicDomain, dynamicDomain]}
|
||||||
tick={{ fontSize: isDesktop ? 16 : 15, fill: '#64748b', fontWeight: 700 }}
|
tick={{ fontSize: 12, fill: '#64748b', fontWeight: 500 }}
|
||||||
tickCount={5}
|
tickCount={5}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
@@ -478,9 +439,8 @@ export function CategoryEvaluationCard({
|
|||||||
name="보은군 평균"
|
name="보은군 평균"
|
||||||
dataKey="regionVal"
|
dataKey="regionVal"
|
||||||
stroke="#10b981"
|
stroke="#10b981"
|
||||||
// fill="#10b981"
|
fill="#10b981"
|
||||||
// fillOpacity={0.2}
|
fillOpacity={0.2}
|
||||||
fill="transparent"
|
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
/>
|
/>
|
||||||
@@ -489,9 +449,8 @@ export function CategoryEvaluationCard({
|
|||||||
name="농가 평균"
|
name="농가 평균"
|
||||||
dataKey="farmVal"
|
dataKey="farmVal"
|
||||||
stroke="#1F3A8F"
|
stroke="#1F3A8F"
|
||||||
// fill="#1F3A8F"
|
fill="#1F3A8F"
|
||||||
// fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
fill="transparent"
|
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
dot={false}
|
dot={false}
|
||||||
/>
|
/>
|
||||||
@@ -500,9 +459,8 @@ export function CategoryEvaluationCard({
|
|||||||
name={formatCowNo(cowNo)}
|
name={formatCowNo(cowNo)}
|
||||||
dataKey="breedVal"
|
dataKey="breedVal"
|
||||||
stroke="#1482B0"
|
stroke="#1482B0"
|
||||||
// fill="#1482B0"
|
fill="#1482B0"
|
||||||
// fillOpacity={0.35}
|
fillOpacity={0.35}
|
||||||
fill="transparent"
|
|
||||||
strokeWidth={isDesktop ? 3 : 2}
|
strokeWidth={isDesktop ? 3 : 2}
|
||||||
dot={{
|
dot={{
|
||||||
fill: '#1482B0',
|
fill: '#1482B0',
|
||||||
@@ -511,25 +469,18 @@ export function CategoryEvaluationCard({
|
|||||||
r: isDesktop ? 3 : 2
|
r: isDesktop ? 3 : 2
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* PolarAngleAxis를 Radar 뒤에 배치하여 tick이 차트 위에 표시되도록 함 */}
|
|
||||||
<PolarAngleAxis
|
|
||||||
dataKey="shortName"
|
|
||||||
tick={CustomAngleTick}
|
|
||||||
tickLine={false}
|
|
||||||
/>
|
|
||||||
<RechartsTooltip
|
<RechartsTooltip
|
||||||
animationDuration={0}
|
|
||||||
content={({ active, payload }) => {
|
content={({ active, payload }) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
const item = payload[0]?.payload
|
const item = payload[0]?.payload
|
||||||
const epd = item?.epd ?? 0
|
const epd = item?.epd ?? 0
|
||||||
const regionEpd = item?.regionEpd ?? 0
|
const regionEpd = (item?.regionVal ?? 0) * (item?.epd / (item?.breedVal || 1)) || 0
|
||||||
const farmEpd = item?.farmEpd ?? 0
|
const farmEpd = (item?.farmVal ?? 0) * (item?.epd / (item?.breedVal || 1)) || 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-foreground px-4 py-3 rounded-lg text-sm shadow-lg">
|
<div className="bg-foreground px-4 py-3 rounded-lg text-sm shadow-lg">
|
||||||
<p className="text-white font-bold mb-2 text-lg">{item?.name}</p>
|
<p className="text-white font-bold mb-2">{item?.name}</p>
|
||||||
<div className="space-y-1.5 text-lg">
|
<div className="space-y-1.5 text-xs">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="w-2 h-2 rounded" style={{ backgroundColor: '#10b981' }}></span>
|
<span className="w-2 h-2 rounded" style={{ backgroundColor: '#10b981' }}></span>
|
||||||
@@ -560,7 +511,22 @@ export function CategoryEvaluationCard({
|
|||||||
/>
|
/>
|
||||||
</RadarChart>
|
</RadarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{/* 범례 */}
|
||||||
|
<div className="flex items-center justify-center gap-5 sm:gap-8 py-3 border-t border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded" style={{ backgroundColor: '#10b981' }}></div>
|
||||||
|
<span className="text-base text-muted-foreground">보은군 평균</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded" style={{ backgroundColor: '#1F3A8F' }}></div>
|
||||||
|
<span className="text-base text-muted-foreground">농가 평균</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded" style={{ backgroundColor: '#1482B0' }}></div>
|
||||||
|
<span className="text-base font-semibold text-foreground">{formatCowNoShort(cowNo)} 개체</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 선택된 형질 정보 표시 - 육종가(EPD) 값으로 표시 */}
|
{/* 선택된 형질 정보 표시 - 육종가(EPD) 값으로 표시 */}
|
||||||
@@ -568,10 +534,10 @@ export function CategoryEvaluationCard({
|
|||||||
const selectedTrait = traitChartData.find(t => t.name === selectedTraitName)
|
const selectedTrait = traitChartData.find(t => t.name === selectedTraitName)
|
||||||
if (!selectedTrait) return null
|
if (!selectedTrait) return null
|
||||||
return (
|
return (
|
||||||
<div className="mx-2 mb-4 p-3 bg-slate-50 rounded-xl border border-slate-200 animate-fade-in">
|
<div className="mx-4 mb-4 p-4 bg-slate-50 rounded-xl border border-slate-200 animate-fade-in">
|
||||||
{/* 헤더: 형질명 + 닫기 */}
|
{/* 헤더: 형질명 + 닫기 */}
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="px-4 py-1.5 bg-slate-200 text-slate-700 text-sm font-bold rounded-full">
|
<span className="px-4 py-1.5 bg-slate-200 text-slate-700 text-base font-bold rounded-full">
|
||||||
{selectedTrait.shortName} 조회 기준
|
{selectedTrait.shortName} 조회 기준
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -582,25 +548,25 @@ export function CategoryEvaluationCard({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* 3개 카드 그리드 */}
|
{/* 3개 카드 그리드 */}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{/* 보은군 카드 */}
|
{/* 보은군 카드 */}
|
||||||
<div className="flex flex-col items-center justify-center px-3 py-4 bg-white rounded-xl border-2 border-emerald-300 shadow-sm">
|
<div className="flex flex-col items-center justify-center p-3 bg-white rounded-xl border-2 border-emerald-300 shadow-sm">
|
||||||
<span className="text-xs text-slate-600 mb-1 font-semibold whitespace-nowrap">보은군 평균</span>
|
<span className="text-xs text-muted-foreground mb-1 font-medium">보은군 평균</span>
|
||||||
<span className="text-xl font-bold text-emerald-600">
|
<span className="text-lg font-bold text-emerald-600">
|
||||||
{selectedTrait.regionEpd?.toFixed(2) ?? '-'}
|
{selectedTrait.regionEpd?.toFixed(2) ?? '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* 농가 카드 */}
|
{/* 농가 카드 */}
|
||||||
<div className="flex flex-col items-center justify-center px-3 py-4 bg-white rounded-xl border-2 border-[#1F3A8F]/30 shadow-sm">
|
<div className="flex flex-col items-center justify-center p-3 bg-white rounded-xl border-2 border-[#1F3A8F]/30 shadow-sm">
|
||||||
<span className="text-xs text-slate-600 mb-1 font-semibold whitespace-nowrap">농가 평균</span>
|
<span className="text-xs text-muted-foreground mb-1 font-medium">농가 평균</span>
|
||||||
<span className="text-xl font-bold text-[#1F3A8F]">
|
<span className="text-lg font-bold text-[#1F3A8F]">
|
||||||
{selectedTrait.farmEpd?.toFixed(2) ?? '-'}
|
{selectedTrait.farmEpd?.toFixed(2) ?? '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* 개체 카드 */}
|
{/* 개체 카드 */}
|
||||||
<div className="flex flex-col items-center justify-center px-3 py-4 bg-white rounded-xl border-2 border-[#1482B0]/30 shadow-sm">
|
<div className="flex flex-col items-center justify-center p-3 bg-white rounded-xl border-2 border-[#1482B0]/30 shadow-sm">
|
||||||
<span className="text-xs text-slate-600 mb-1 font-semibold whitespace-nowrap">내 개체</span>
|
<span className="text-xs text-muted-foreground mb-1 font-medium">내 개체</span>
|
||||||
<span className="text-xl font-bold text-[#1482B0]">
|
<span className="text-lg font-bold text-[#1482B0]">
|
||||||
{selectedTrait.epd?.toFixed(2) ?? '-'}
|
{selectedTrait.epd?.toFixed(2) ?? '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ import {
|
|||||||
|
|
||||||
// 형질 데이터 타입
|
// 형질 데이터 타입
|
||||||
interface GenomicTrait {
|
interface GenomicTrait {
|
||||||
id: number
|
id: string
|
||||||
name: string
|
name: string
|
||||||
category: string
|
category: string
|
||||||
breedVal: number
|
breedVal: number
|
||||||
percentile: number
|
percentile: number
|
||||||
|
description: string
|
||||||
actualValue: number
|
actualValue: number
|
||||||
description?: string // 형질 설명
|
unit: string
|
||||||
unit?: string // 단위 (kg, cm 등)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CategoryTraitGridProps {
|
interface CategoryTraitGridProps {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import apiClient from "@/lib/api-client"
|
import { apiClient } from "@/lib/api"
|
||||||
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
|
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
|
||||||
import { useFilterStore } from "@/store/filter-store"
|
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
||||||
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
|
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
|
||||||
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
||||||
import { ALL_TRAITS } from "@/constants/traits"
|
|
||||||
|
|
||||||
// 분포 데이터 타입
|
// 분포 데이터 타입
|
||||||
interface DistributionBin {
|
interface DistributionBin {
|
||||||
@@ -19,12 +18,14 @@ interface DistributionBin {
|
|||||||
|
|
||||||
// 형질 데이터 타입
|
// 형질 데이터 타입
|
||||||
interface GenomicTrait {
|
interface GenomicTrait {
|
||||||
id?: number
|
id: number
|
||||||
traitName?: string
|
name: string
|
||||||
traitCategory?: string
|
category: string
|
||||||
breedVal?: number
|
breedVal: number
|
||||||
percentile?: number
|
percentile: number
|
||||||
traitVal?: number
|
description: string
|
||||||
|
actualValue: number
|
||||||
|
unit: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GenomeIntegratedComparisonProps {
|
interface GenomeIntegratedComparisonProps {
|
||||||
@@ -114,7 +115,7 @@ export function GenomeIntegratedComparison({
|
|||||||
}
|
}
|
||||||
//===========================================================================================
|
//===========================================================================================
|
||||||
|
|
||||||
const { filters } = useFilterStore()
|
const { filters } = useGlobalFilter()
|
||||||
const { selectedYear } = useAnalysisYear()
|
const { selectedYear } = useAnalysisYear()
|
||||||
const [stats, setStats] = useState<IntegratedStats | null>(null)
|
const [stats, setStats] = useState<IntegratedStats | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -131,6 +132,22 @@ export function GenomeIntegratedComparison({
|
|||||||
}[]>([])
|
}[]>([])
|
||||||
const [trendLoading, setTrendLoading] = useState(true)
|
const [trendLoading, setTrendLoading] = useState(true)
|
||||||
|
|
||||||
|
// 전체 35개 형질 목록 (filter.types.ts의 traitWeights 키와 동일)
|
||||||
|
const ALL_TRAITS = [
|
||||||
|
// 성장형질 (1개)
|
||||||
|
'12개월령체중',
|
||||||
|
// 경제형질 (4개)
|
||||||
|
'도체중', '등심단면적', '등지방두께', '근내지방도',
|
||||||
|
// 체형형질 (10개)
|
||||||
|
'체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
||||||
|
// 부위별무게 (10개)
|
||||||
|
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
||||||
|
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
||||||
|
// 부위별비율 (10개)
|
||||||
|
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
||||||
|
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
||||||
|
]
|
||||||
|
|
||||||
// 형질 조건 생성 (형질명 + 가중치)
|
// 형질 조건 생성 (형질명 + 가중치)
|
||||||
const getTraitConditions = () => {
|
const getTraitConditions = () => {
|
||||||
const selected = Object.entries(filters.traitWeights)
|
const selected = Object.entries(filters.traitWeights)
|
||||||
@@ -461,11 +478,11 @@ export function GenomeIntegratedComparison({
|
|||||||
|
|
||||||
setTrendLoading(true)
|
setTrendLoading(true)
|
||||||
try {
|
try {
|
||||||
const ebvStats = await genomeApi.getYearlyEbvStats(farmNo)
|
const dashboardStats = await genomeApi.getDashboardStats(farmNo)
|
||||||
|
|
||||||
// yearlyStats와 yearlyAvgEbv 합치기
|
// yearlyStats와 yearlyAvgEbv 합치기
|
||||||
const yearlyStats = ebvStats.yearlyStats || []
|
const yearlyStats = dashboardStats.yearlyStats || []
|
||||||
const yearlyAvgEbv = ebvStats.yearlyAvgEbv || []
|
const yearlyAvgEbv = dashboardStats.yearlyAvgEbv || []
|
||||||
|
|
||||||
// 연도별 데이터 맵 생성
|
// 연도별 데이터 맵 생성
|
||||||
const yearMap = new Map<number, { analyzedCount: number; avgEbv: number }>()
|
const yearMap = new Map<number, { analyzedCount: number; avgEbv: number }>()
|
||||||
@@ -571,7 +588,7 @@ export function GenomeIntegratedComparison({
|
|||||||
|
|
||||||
// 개별 형질 모드일 때 해당 형질의 데이터 찾기
|
// 개별 형질 모드일 때 해당 형질의 데이터 찾기
|
||||||
const selectedTrait = isTraitMode
|
const selectedTrait = isTraitMode
|
||||||
? selectedTraitData.find(t => t.traitName === chartFilterTrait)
|
? selectedTraitData.find(t => t.name === chartFilterTrait)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const traitComparison = isTraitMode
|
const traitComparison = isTraitMode
|
||||||
@@ -579,8 +596,8 @@ export function GenomeIntegratedComparison({
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
// 표시할 값 결정
|
// 표시할 값 결정
|
||||||
const displayScore = isTraitMode && selectedTrait ? (selectedTrait.breedVal ?? 0) : overallScore
|
const displayScore = isTraitMode && selectedTrait ? selectedTrait.breedVal : overallScore
|
||||||
const displayPercentile = isTraitMode && selectedTrait ? (selectedTrait.percentile ?? 50) : (selectionIndex?.percentile || 50)
|
const displayPercentile = isTraitMode && selectedTrait ? selectedTrait.percentile : (selectionIndex?.percentile || 50)
|
||||||
// 형질 모드일 때는 API에서 가져온 평균값 사용, 없으면 traitComparison 사용
|
// 형질 모드일 때는 API에서 가져온 평균값 사용, 없으면 traitComparison 사용
|
||||||
const displayFarmAvg = isTraitMode
|
const displayFarmAvg = isTraitMode
|
||||||
? (traitRank?.farmAvgEbv ?? traitComparison?.myFarm ?? 0)
|
? (traitRank?.farmAvgEbv ?? traitComparison?.myFarm ?? 0)
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { NEGATIVE_TRAITS } from "@/constants/traits"
|
import { Info, Maximize2, X } from 'lucide-react'
|
||||||
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
|
|
||||||
import { useFilterStore } from "@/store/filter-store"
|
|
||||||
import { Maximize2 } from 'lucide-react'
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
|
CartesianGrid,
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
Customized,
|
Customized,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
@@ -17,8 +15,13 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis
|
YAxis
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
|
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
|
||||||
|
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
||||||
|
|
||||||
// 형질 카테고리별 색상 매핑
|
// 낮을수록 좋은 형질 (부호 반전 필요)
|
||||||
|
const NEGATIVE_TRAITS = ['등지방두께']
|
||||||
|
|
||||||
|
// 카테고리 색상 (모던 & 다이나믹 - 생동감 있는 색상)
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
'성장': '#3b82f6', // 블루
|
'성장': '#3b82f6', // 블루
|
||||||
'생산': '#f59e0b', // 앰버
|
'생산': '#f59e0b', // 앰버
|
||||||
@@ -27,25 +30,73 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
'비율': '#ec4899' // 핑크
|
'비율': '#ec4899' // 핑크
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 형질 비교용 색상 배열
|
||||||
|
const TRAIT_COLORS = [
|
||||||
|
'#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e',
|
||||||
|
'#10b981', '#14b8a6', '#06b6d4', '#0ea5e9', '#3b82f6',
|
||||||
|
'#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899',
|
||||||
|
'#f43f5e', '#fb923c', '#fbbf24', '#a3e635', '#4ade80',
|
||||||
|
'#2dd4bf', '#22d3ee', '#38bdf8', '#60a5fa', '#818cf8',
|
||||||
|
'#a78bfa', '#c084fc', '#e879f9', '#f472b6', '#fb7185',
|
||||||
|
'#fdba74', '#fcd34d', '#bef264', '#86efac', '#5eead4'
|
||||||
|
]
|
||||||
|
|
||||||
|
// 정규분포 CDF (누적분포함수) - σ값을 백분위로 변환
|
||||||
|
// 표준정규분포에서 z값 이하의 확률을 반환 (0~1)
|
||||||
|
function normalCDF(z: number): number {
|
||||||
|
// Abramowitz and Stegun 근사법 (오차 < 7.5×10^-8)
|
||||||
|
const a1 = 0.254829592
|
||||||
|
const a2 = -0.284496736
|
||||||
|
const a3 = 1.421413741
|
||||||
|
const a4 = -1.453152027
|
||||||
|
const a5 = 1.061405429
|
||||||
|
const p = 0.3275911
|
||||||
|
|
||||||
/** 유전체 형질 데이터 타입 */
|
const sign = z < 0 ? -1 : 1
|
||||||
interface GenomicTrait {
|
z = Math.abs(z)
|
||||||
id?: number
|
|
||||||
traitName?: string // 형질명 (예: 도체중, 등지방두께)
|
const t = 1.0 / (1.0 + p * z)
|
||||||
traitCategory?: string // 형질 카테고리 (성장/생산/체형/무게/비율)
|
const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-z * z / 2)
|
||||||
breedVal?: number // 육종가 값
|
|
||||||
percentile?: number // 백분위 순위
|
return 0.5 * (1.0 + sign * y)
|
||||||
traitVal?: number // 형질 값 (EPD)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 형질별 농가/보은군 비교 데이터 */
|
// σ값을 상위 백분위(%)로 변환 (예: +1σ → 상위 15.87%)
|
||||||
|
function sigmaToPercentile(sigma: number): number {
|
||||||
|
// CDF는 "이하" 확률이므로, 상위 %는 (1 - CDF) * 100
|
||||||
|
const percentile = (1 - normalCDF(sigma)) * 100
|
||||||
|
return Math.max(1, Math.min(99, percentile))
|
||||||
|
}
|
||||||
|
|
||||||
|
// σ 값을 등급으로 변환
|
||||||
|
function getGradeFromSigma(sigmaValue: number): { grade: string; color: string; bg: string } {
|
||||||
|
if (sigmaValue >= 1) {
|
||||||
|
return { grade: '우수', color: 'text-green-600', bg: 'bg-green-50' }
|
||||||
|
} else if (sigmaValue >= -1) {
|
||||||
|
return { grade: '보통', color: 'text-gray-600', bg: 'bg-gray-100' }
|
||||||
|
} else {
|
||||||
|
return { grade: '개선필요', color: 'text-orange-600', bg: 'bg-orange-50' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenomicTrait {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
breedVal: number
|
||||||
|
percentile: number
|
||||||
|
description: string
|
||||||
|
actualValue: number
|
||||||
|
unit: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 형질별 비교 데이터 타입
|
||||||
interface TraitComparison {
|
interface TraitComparison {
|
||||||
trait: string // 형질명
|
trait: string
|
||||||
shortName: string // 짧은 형질명 (차트 표시용)
|
shortName: string
|
||||||
myFarm: number // 농가 평균 값
|
myFarm: number // 농가 평균
|
||||||
region: number // 보은군 평균 값
|
region: number // 보은군 평균
|
||||||
diff: number // 농가와 보은군 간 차이
|
diff: number // 차이
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NormalDistributionChartProps {
|
interface NormalDistributionChartProps {
|
||||||
@@ -98,8 +149,6 @@ interface NormalDistributionChartProps {
|
|||||||
// 차트 필터 형질 선택 콜백 (외부 연동용)
|
// 차트 필터 형질 선택 콜백 (외부 연동용)
|
||||||
chartFilterTrait?: string
|
chartFilterTrait?: string
|
||||||
onChartFilterTraitChange?: (trait: string) => void
|
onChartFilterTraitChange?: (trait: string) => void
|
||||||
// 전체 선발지수 히스토그램 (실제 분포 데이터)
|
|
||||||
selectionIndexHistogram?: { bin: number; count: number; farmCount: number }[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NormalDistributionChart({
|
export function NormalDistributionChart({
|
||||||
@@ -137,13 +186,12 @@ export function NormalDistributionChart({
|
|||||||
highlightMode = null,
|
highlightMode = null,
|
||||||
onHighlightModeChange,
|
onHighlightModeChange,
|
||||||
chartFilterTrait: externalChartFilterTrait,
|
chartFilterTrait: externalChartFilterTrait,
|
||||||
onChartFilterTraitChange,
|
onChartFilterTraitChange
|
||||||
selectionIndexHistogram = []
|
|
||||||
}: NormalDistributionChartProps) {
|
}: NormalDistributionChartProps) {
|
||||||
const { filters } = useFilterStore()
|
const { filters } = useGlobalFilter()
|
||||||
|
|
||||||
// 필터에서 고정된 첫 번째 형질 (없으면 첫 번째 선택된 형질, 없으면 '도체중')
|
// 필터에서 고정된 첫 번째 형질 (없으면 첫 번째 선택된 형질, 없으면 '도체중')
|
||||||
const firstPinnedTrait = filters.pinnedTraits?.[0] || selectedTraitData[0]?.traitName || '도체중'
|
const firstPinnedTrait = filters.pinnedTraits?.[0] || selectedTraitData[0]?.name || '도체중'
|
||||||
|
|
||||||
// 차트 필터 - 선택된 형질 또는 전체 선발지수 (외부 제어 가능)
|
// 차트 필터 - 선택된 형질 또는 전체 선발지수 (외부 제어 가능)
|
||||||
// 필터 비활성 시 기본값은 첫 번째 고정 형질
|
// 필터 비활성 시 기본값은 첫 번째 고정 형질
|
||||||
@@ -225,13 +273,13 @@ export function NormalDistributionChart({
|
|||||||
|
|
||||||
if (chartFilterTrait !== 'overall') {
|
if (chartFilterTrait !== 'overall') {
|
||||||
// 모든 형질에서 찾기 (selectedTraitData는 선택된 형질만 포함하므로 allTraits에서 찾아야 함)
|
// 모든 형질에서 찾기 (selectedTraitData는 선택된 형질만 포함하므로 allTraits에서 찾아야 함)
|
||||||
const selectedTrait = allTraits.find(t => t.traitName === chartFilterTrait)
|
const selectedTrait = allTraits.find(t => t.name === chartFilterTrait)
|
||||||
|
|
||||||
if (selectedTrait) {
|
if (selectedTrait) {
|
||||||
// 개별 형질 선택 시: 육종가(EPD) 값 사용
|
// 개별 형질 선택 시: 육종가(EPD) 값 사용
|
||||||
baseScore = selectedTrait.traitVal ?? 0 // 개체 육종가
|
baseScore = selectedTrait.actualValue ?? 0 // 개체 육종가
|
||||||
basePercentile = selectedTrait.percentile ?? 50
|
basePercentile = selectedTrait.percentile
|
||||||
baseLabel = selectedTrait.traitName ?? '알 수 없음'
|
baseLabel = selectedTrait.name
|
||||||
// API에서 가져온 형질별 농가/보은군 평균 육종가(EPD) 사용
|
// API에서 가져온 형질별 농가/보은군 평균 육종가(EPD) 사용
|
||||||
baseFarmScore = traitRankData?.farmAvgEpd ?? 0
|
baseFarmScore = traitRankData?.farmAvgEpd ?? 0
|
||||||
baseRegionScore = traitRankData?.regionAvgEpd ?? 0
|
baseRegionScore = traitRankData?.regionAvgEpd ?? 0
|
||||||
@@ -266,95 +314,16 @@ export function NormalDistributionChart({
|
|||||||
}
|
}
|
||||||
}, [chartFilterTrait, overallScore, overallPercentile, allTraits, traitRankData, farmAvgZ, regionAvgZ])
|
}, [chartFilterTrait, overallScore, overallPercentile, allTraits, traitRankData, farmAvgZ, regionAvgZ])
|
||||||
|
|
||||||
// X축 범위 및 간격 계산 (실제 데이터에 맞게 조정, 중앙 정렬)
|
// X축 범위 및 간격 계산 (내 개체 중심 방식)
|
||||||
const xAxisConfig = useMemo(() => {
|
const xAxisConfig = useMemo(() => {
|
||||||
const cowScore = chartDisplayValues.originalScore
|
|
||||||
|
|
||||||
// 전체 선발지수: selectionIndexHistogram 사용
|
|
||||||
if (chartFilterTrait === 'overall' && selectionIndexHistogram.length > 0) {
|
|
||||||
const bins = selectionIndexHistogram.map(item => item.bin - cowScore)
|
|
||||||
// 내 개체(0), 농가 평균, 보은군 평균도 범위에 포함
|
|
||||||
const allValues = [...bins, 0, chartDisplayValues.farmScore, chartDisplayValues.regionScore]
|
|
||||||
const minData = Math.min(...allValues)
|
|
||||||
const maxData = Math.max(...allValues)
|
|
||||||
|
|
||||||
// 데이터의 중심점 계산
|
|
||||||
const center = (minData + maxData) / 2
|
|
||||||
// 데이터 범위에 20% 여유 추가
|
|
||||||
const dataRange = maxData - minData
|
|
||||||
const padding = dataRange * 0.2
|
|
||||||
// 중심점 기준으로 좌우 대칭 범위 설정
|
|
||||||
const halfRange = (dataRange / 2) + padding
|
|
||||||
|
|
||||||
const min = Math.floor(center - halfRange)
|
|
||||||
const max = Math.ceil(center + halfRange)
|
|
||||||
const range = max - min
|
|
||||||
|
|
||||||
let step: number
|
|
||||||
if (range <= 5) {
|
|
||||||
step = 0.5
|
|
||||||
} else if (range <= 20) {
|
|
||||||
step = 2
|
|
||||||
} else if (range <= 50) {
|
|
||||||
step = 5
|
|
||||||
} else if (range <= 100) {
|
|
||||||
step = 10
|
|
||||||
} else {
|
|
||||||
step = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
return { min, max, step }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 형질별: traitRankData.histogram 사용
|
|
||||||
if (chartFilterTrait !== 'overall' && traitRankData?.histogram && traitRankData.histogram.length > 0) {
|
|
||||||
const bins = traitRankData.histogram.map(item => item.bin - cowScore)
|
|
||||||
// 내 개체(0), 농가 평균, 보은군 평균도 범위에 포함
|
|
||||||
const allValues = [...bins, 0, chartDisplayValues.farmScore, chartDisplayValues.regionScore]
|
|
||||||
const minData = Math.min(...allValues)
|
|
||||||
const maxData = Math.max(...allValues)
|
|
||||||
|
|
||||||
console.log(`[${chartFilterTrait}] X축 범위 계산:`, {
|
|
||||||
bins: `${bins[0].toFixed(2)} ~ ${bins[bins.length-1].toFixed(2)}`,
|
|
||||||
내개체: 0,
|
|
||||||
농가평균위치: chartDisplayValues.farmScore.toFixed(2),
|
|
||||||
보은군평균위치: chartDisplayValues.regionScore.toFixed(2),
|
|
||||||
allValues범위: `${minData.toFixed(2)} ~ ${maxData.toFixed(2)}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 데이터의 중심점 계산
|
|
||||||
const center = (minData + maxData) / 2
|
|
||||||
// 데이터 범위에 20% 여유 추가
|
|
||||||
const dataRange = maxData - minData
|
|
||||||
const padding = dataRange * 0.2
|
|
||||||
// 중심점 기준으로 좌우 대칭 범위 설정
|
|
||||||
const halfRange = (dataRange / 2) + padding
|
|
||||||
|
|
||||||
const min = Math.floor(center - halfRange)
|
|
||||||
const max = Math.ceil(center + halfRange)
|
|
||||||
const range = max - min
|
|
||||||
|
|
||||||
let step: number
|
|
||||||
if (range <= 5) {
|
|
||||||
step = 0.5
|
|
||||||
} else if (range <= 20) {
|
|
||||||
step = 2
|
|
||||||
} else if (range <= 50) {
|
|
||||||
step = 5
|
|
||||||
} else if (range <= 100) {
|
|
||||||
step = 10
|
|
||||||
} else {
|
|
||||||
step = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
return { min, max, step }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 히스토그램 데이터가 없으면 평균 대비 차이로 범위 계산 (폴백)
|
|
||||||
const { cowVsFarm, cowVsRegion } = chartDisplayValues
|
const { cowVsFarm, cowVsRegion } = chartDisplayValues
|
||||||
const maxDiff = Math.max(Math.abs(cowVsFarm), Math.abs(cowVsRegion))
|
const maxDiff = Math.max(Math.abs(cowVsFarm), Math.abs(cowVsRegion))
|
||||||
|
|
||||||
|
// 데이터가 차트의 약 70% 영역에 들어오도록 범위 계산
|
||||||
|
// maxDiff가 range의 70%가 되도록: range = maxDiff / 0.7
|
||||||
const targetRange = maxDiff / 0.7
|
const targetRange = maxDiff / 0.7
|
||||||
|
|
||||||
|
// step 계산: 범위에 따라 적절한 간격 선택
|
||||||
let step: number
|
let step: number
|
||||||
if (targetRange <= 1) {
|
if (targetRange <= 1) {
|
||||||
step = 0.2
|
step = 0.2
|
||||||
@@ -368,11 +337,12 @@ export function NormalDistributionChart({
|
|||||||
step = 10
|
step = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
const minRange = step * 3
|
// 범위를 step 단위로 올림 (최소값 보장)
|
||||||
|
const minRange = step * 3 // 최소 3개의 step
|
||||||
const range = Math.max(minRange, Math.ceil(targetRange / step) * step)
|
const range = Math.max(minRange, Math.ceil(targetRange / step) * step)
|
||||||
|
|
||||||
return { min: -range, max: range, step }
|
return { min: -range, max: range, step }
|
||||||
}, [chartFilterTrait, selectionIndexHistogram, traitRankData, chartDisplayValues])
|
}, [chartDisplayValues])
|
||||||
|
|
||||||
// X축 틱 계산 (동적 간격)
|
// X축 틱 계산 (동적 간격)
|
||||||
const xTicks = useMemo(() => {
|
const xTicks = useMemo(() => {
|
||||||
@@ -384,118 +354,22 @@ export function NormalDistributionChart({
|
|||||||
return ticks
|
return ticks
|
||||||
}, [xAxisConfig])
|
}, [xAxisConfig])
|
||||||
|
|
||||||
// 히스토그램 데이터 생성 (실제 데이터 분포 사용)
|
// 히스토그램 데이터 생성 (내 개체 중심, 정규분포 곡선)
|
||||||
const histogramData = useMemo(() => {
|
const histogramData = useMemo(() => {
|
||||||
// 전체 선발지수: selectionIndexHistogram 사용
|
// X축 범위에 맞게 표준편차 조정 (범위의 약 1/4)
|
||||||
if (chartFilterTrait === 'overall' && selectionIndexHistogram.length > 0) {
|
|
||||||
const histogram = selectionIndexHistogram
|
|
||||||
const totalCount = histogram.reduce((sum, item) => sum + item.count, 0)
|
|
||||||
|
|
||||||
const bins = histogram.map(item => {
|
|
||||||
const cowScore = chartDisplayValues.originalScore
|
|
||||||
const relativeBin = item.bin - cowScore
|
|
||||||
const percent = (item.count / totalCount) * 100
|
|
||||||
const farmPercent = totalCount > 0 ? (item.farmCount / totalCount) * 100 : 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
midPoint: relativeBin,
|
|
||||||
regionPercent: percent,
|
|
||||||
percent: percent,
|
|
||||||
farmPercent: farmPercent,
|
|
||||||
count: item.count,
|
|
||||||
farmCount: item.farmCount
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 🔍 실제 히스토그램 데이터 콘솔 로그
|
|
||||||
const dataMinMax = { min: Math.min(...bins.map(b => b.midPoint)), max: Math.max(...bins.map(b => b.midPoint)) }
|
|
||||||
const percentMinMax = { min: Math.min(...bins.map(b => b.percent)), max: Math.max(...bins.map(b => b.percent)) }
|
|
||||||
const calculatedYMax = Math.max(5, Math.ceil(percentMinMax.max * 1.2))
|
|
||||||
|
|
||||||
console.log('📊 [전체 선발지수 - 차트 범위 자동 조정]', {
|
|
||||||
형질명: '전체 선발지수',
|
|
||||||
전체개체수: totalCount,
|
|
||||||
'📏 X축': {
|
|
||||||
데이터범위: `${dataMinMax.min.toFixed(1)} ~ ${dataMinMax.max.toFixed(1)}`,
|
|
||||||
차트범위: `${xAxisConfig.min} ~ ${xAxisConfig.max}`,
|
|
||||||
범위크기: `${(xAxisConfig.max - xAxisConfig.min).toFixed(1)}`
|
|
||||||
},
|
|
||||||
'📏 Y축': {
|
|
||||||
데이터최대: `${percentMinMax.max.toFixed(1)}%`,
|
|
||||||
차트최대: `${calculatedYMax}%`,
|
|
||||||
여유공간: `${((calculatedYMax - percentMinMax.max) / percentMinMax.max * 100).toFixed(0)}%`
|
|
||||||
},
|
|
||||||
총데이터개수: bins.length,
|
|
||||||
샘플: { 첫5개: bins.slice(0, 5), 마지막5개: bins.slice(-5) }
|
|
||||||
})
|
|
||||||
|
|
||||||
return bins
|
|
||||||
}
|
|
||||||
|
|
||||||
// 형질별 데이터가 있으면 실제 히스토그램 사용
|
|
||||||
if (chartFilterTrait !== 'overall' && traitRankData?.histogram && traitRankData.histogram.length > 0) {
|
|
||||||
const histogram = traitRankData.histogram
|
|
||||||
const totalCount = histogram.reduce((sum, item) => sum + item.count, 0)
|
|
||||||
|
|
||||||
// 백엔드에서 받은 히스토그램을 차트 데이터로 변환
|
|
||||||
const bins = histogram.map(item => {
|
|
||||||
// bin 값은 구간의 시작값 (예: 110, 115, 120...)
|
|
||||||
// 개체 점수 대비 상대 위치로 변환 (내 개체 = 0 기준)
|
|
||||||
const cowScore = chartDisplayValues.originalScore
|
|
||||||
const relativeBin = item.bin - cowScore
|
|
||||||
|
|
||||||
// 백분율 계산
|
|
||||||
const percent = (item.count / totalCount) * 100
|
|
||||||
const farmPercent = totalCount > 0 ? (item.farmCount / totalCount) * 100 : 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
midPoint: relativeBin,
|
|
||||||
regionPercent: percent,
|
|
||||||
percent: percent,
|
|
||||||
farmPercent: farmPercent,
|
|
||||||
count: item.count,
|
|
||||||
farmCount: item.farmCount
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 🔍 실제 히스토그램 데이터 콘솔 로그
|
|
||||||
const dataMinMax = { min: Math.min(...bins.map(b => b.midPoint)), max: Math.max(...bins.map(b => b.midPoint)) }
|
|
||||||
const percentMinMax = { min: Math.min(...bins.map(b => b.percent)), max: Math.max(...bins.map(b => b.percent)) }
|
|
||||||
const calculatedYMax = Math.max(5, Math.ceil(percentMinMax.max * 1.2))
|
|
||||||
|
|
||||||
console.log(`📊 [${chartFilterTrait} - 차트 범위 자동 조정]`, {
|
|
||||||
형질명: chartFilterTrait,
|
|
||||||
전체개체수: totalCount,
|
|
||||||
'📏 X축': {
|
|
||||||
데이터범위: `${dataMinMax.min.toFixed(1)} ~ ${dataMinMax.max.toFixed(1)}`,
|
|
||||||
차트범위: `${xAxisConfig.min} ~ ${xAxisConfig.max}`,
|
|
||||||
범위크기: `${(xAxisConfig.max - xAxisConfig.min).toFixed(1)}`
|
|
||||||
},
|
|
||||||
'📏 Y축': {
|
|
||||||
데이터최대: `${percentMinMax.max.toFixed(1)}%`,
|
|
||||||
차트최대: `${calculatedYMax}%`,
|
|
||||||
여유공간: `${((calculatedYMax - percentMinMax.max) / percentMinMax.max * 100).toFixed(0)}%`
|
|
||||||
},
|
|
||||||
총데이터개수: bins.length,
|
|
||||||
샘플: { 첫5개: bins.slice(0, 5), 마지막5개: bins.slice(-5) }
|
|
||||||
})
|
|
||||||
|
|
||||||
return bins
|
|
||||||
}
|
|
||||||
|
|
||||||
// 히스토그램 데이터가 없을 때만 정규분포 곡선 사용 (폴백)
|
|
||||||
const range = xAxisConfig.max - xAxisConfig.min
|
const range = xAxisConfig.max - xAxisConfig.min
|
||||||
const std = range / 4
|
const std = range / 4
|
||||||
|
|
||||||
|
// 정규분포 PDF 계산 함수 (0~1 범위로 정규화)
|
||||||
const normalPDF = (x: number, mean: number = 0) => {
|
const normalPDF = (x: number, mean: number = 0) => {
|
||||||
const exponent = -Math.pow(x - mean, 2) / (2 * Math.pow(std, 2))
|
const exponent = -Math.pow(x - mean, 2) / (2 * Math.pow(std, 2))
|
||||||
return Math.exp(exponent)
|
return Math.exp(exponent) // 0~1 범위
|
||||||
}
|
}
|
||||||
|
|
||||||
const bins = []
|
const bins = []
|
||||||
const stepSize = range / 100
|
const stepSize = range / 100 // 100개의 점으로 부드러운 곡선
|
||||||
for (let x = xAxisConfig.min; x <= xAxisConfig.max; x += stepSize) {
|
for (let x = xAxisConfig.min; x <= xAxisConfig.max; x += stepSize) {
|
||||||
const pdfValue = normalPDF(x) * 40
|
const pdfValue = normalPDF(x) * 40 // 최대 40%로 스케일링
|
||||||
bins.push({
|
bins.push({
|
||||||
midPoint: x,
|
midPoint: x,
|
||||||
regionPercent: pdfValue,
|
regionPercent: pdfValue,
|
||||||
@@ -503,30 +377,11 @@ export function NormalDistributionChart({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔍 정규분포 곡선 데이터 콘솔 로그
|
|
||||||
console.log('📊 [정규분포 곡선 데이터 - 폴백]', {
|
|
||||||
총데이터개수: bins.length,
|
|
||||||
X축범위: `${xAxisConfig.min} ~ ${xAxisConfig.max}`,
|
|
||||||
표준편차: std,
|
|
||||||
첫5개: bins.slice(0, 5),
|
|
||||||
마지막5개: bins.slice(-5)
|
|
||||||
})
|
|
||||||
|
|
||||||
return bins
|
return bins
|
||||||
}, [xAxisConfig, chartFilterTrait, traitRankData, chartDisplayValues.originalScore, selectionIndexHistogram])
|
}, [xAxisConfig])
|
||||||
|
|
||||||
// Y축 범위 (실제 데이터에 맞게 조정 - 개체수 기준)
|
// 최대 % (Y축 범위용) - 항상 40으로 고정
|
||||||
const maxCount = useMemo(() => {
|
const maxPercent = 40
|
||||||
if (histogramData.length === 0) return 100
|
|
||||||
|
|
||||||
const maxValue = Math.max(...histogramData.map(d => ('count' in d ? d.count : 0) || 0))
|
|
||||||
|
|
||||||
// 실제 최대값에 20% 여유만 추가 (너무 빡빡하지 않게)
|
|
||||||
const calculatedMax = Math.ceil(maxValue * 1.2)
|
|
||||||
|
|
||||||
// 최소 10개체 보장 (데이터가 너무 작을 때만)
|
|
||||||
return Math.max(10, calculatedMax)
|
|
||||||
}, [histogramData])
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -560,7 +415,7 @@ export function NormalDistributionChart({
|
|||||||
<>
|
<>
|
||||||
{/* 카테고리별로 그룹핑 */}
|
{/* 카테고리별로 그룹핑 */}
|
||||||
{(['성장', '생산', '체형', '무게', '비율'] as const).map((category) => {
|
{(['성장', '생산', '체형', '무게', '비율'] as const).map((category) => {
|
||||||
const categoryTraits = allTraits.filter(t => t.traitCategory === category)
|
const categoryTraits = allTraits.filter(t => t.category === category)
|
||||||
if (categoryTraits.length === 0) return null
|
if (categoryTraits.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div key={category}>
|
<div key={category}>
|
||||||
@@ -570,9 +425,9 @@ export function NormalDistributionChart({
|
|||||||
>
|
>
|
||||||
{category} ({categoryTraits.length})
|
{category} ({categoryTraits.length})
|
||||||
</div>
|
</div>
|
||||||
{categoryTraits.map((trait, idx) => (
|
{categoryTraits.map((trait) => (
|
||||||
<SelectItem key={trait.traitName || idx} value={trait.traitName || ''}>
|
<SelectItem key={trait.id} value={trait.name}>
|
||||||
{trait.traitName}
|
{trait.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -584,13 +439,14 @@ export function NormalDistributionChart({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{/* 확대 버튼 */}
|
{/* 확대 버튼 */}
|
||||||
{/* <button
|
<button
|
||||||
onClick={onOpenChartModal}
|
onClick={onOpenChartModal}
|
||||||
className="flex items-center gap-1.5 px-2 sm:px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
|
className="flex items-center gap-1.5 px-2 sm:px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
|
||||||
aria-label="차트 확대">
|
aria-label="차트 확대"
|
||||||
|
>
|
||||||
<Maximize2 className="w-4 h-4" />
|
<Maximize2 className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">확대</span>
|
<span className="hidden sm:inline">확대</span>
|
||||||
</button> */}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 순위 및 평균 대비 요약 (차트 위에 배치) */}
|
{/* 순위 및 평균 대비 요약 (차트 위에 배치) */}
|
||||||
@@ -707,16 +563,16 @@ export function NormalDistributionChart({
|
|||||||
{/* 데스크탑: 기존 레이아웃 */}
|
{/* 데스크탑: 기존 레이아웃 */}
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
{/* 현재 보고 있는 조회 기준 표시 */}
|
{/* 현재 보고 있는 조회 기준 표시 */}
|
||||||
{/* <div className="flex items-center justify-center mb-4">
|
<div className="flex items-center justify-center mb-4">
|
||||||
<span className="px-4 py-1.5 bg-slate-200 text-slate-700 text-base font-bold rounded-full">
|
<span className="px-4 py-1.5 bg-slate-200 text-slate-700 text-base font-bold rounded-full">
|
||||||
{chartFilterTrait === 'overall' ? '전체 선발지수' : chartFilterTrait} 조회 기준
|
{chartFilterTrait === 'overall' ? '전체 선발지수' : chartFilterTrait} 조회 기준
|
||||||
</span>
|
</span>
|
||||||
</div> */}
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{/* 농가 내 순위 */}
|
{/* 농가 내 순위 */}
|
||||||
<div className="flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 border-[#1F3A8F]/30 shadow-sm">
|
<div className="flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 border-[#1F3A8F]/30 shadow-sm">
|
||||||
<span className="text-2xl text-muted-foreground mb-2 font-medium">농가 내 순위</span>
|
<span className="text-sm text-muted-foreground mb-2 font-medium">농가 내 순위</span>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
{traitRankLoading && chartFilterTrait !== 'overall' ? (
|
{traitRankLoading && chartFilterTrait !== 'overall' ? (
|
||||||
<span className="text-2xl font-bold text-muted-foreground">...</span>
|
<span className="text-2xl font-bold text-muted-foreground">...</span>
|
||||||
@@ -746,7 +602,7 @@ export function NormalDistributionChart({
|
|||||||
|
|
||||||
{/* 보은군 내 순위 */}
|
{/* 보은군 내 순위 */}
|
||||||
<div className="flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 border-slate-300 shadow-sm">
|
<div className="flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 border-slate-300 shadow-sm">
|
||||||
<span className="text-2xl text-muted-foreground mb-2 font-medium">보은군 내 순위</span>
|
<span className="text-sm text-muted-foreground mb-2 font-medium">보은군 내 순위</span>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
{traitRankLoading && chartFilterTrait !== 'overall' ? (
|
{traitRankLoading && chartFilterTrait !== 'overall' ? (
|
||||||
<span className="text-2xl font-bold text-muted-foreground">...</span>
|
<span className="text-2xl font-bold text-muted-foreground">...</span>
|
||||||
@@ -776,7 +632,7 @@ export function NormalDistributionChart({
|
|||||||
|
|
||||||
{/* 농가 평균 대비 */}
|
{/* 농가 평균 대비 */}
|
||||||
<div className={`flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 shadow-sm ${(chartDisplayValues.originalScore - chartDisplayValues.originalFarmScore) >= 0 ? 'border-green-300' : 'border-red-300'}`}>
|
<div className={`flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 shadow-sm ${(chartDisplayValues.originalScore - chartDisplayValues.originalFarmScore) >= 0 ? 'border-green-300' : 'border-red-300'}`}>
|
||||||
<span className="text-2xl text-muted-foreground mb-2 font-medium">농가 평균 대비</span>
|
<span className="text-sm text-muted-foreground mb-2 font-medium">농가 평균 대비</span>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
{traitRankLoading && chartFilterTrait !== 'overall' ? (
|
{traitRankLoading && chartFilterTrait !== 'overall' ? (
|
||||||
<span className="text-2xl font-bold text-muted-foreground">...</span>
|
<span className="text-2xl font-bold text-muted-foreground">...</span>
|
||||||
@@ -796,7 +652,7 @@ export function NormalDistributionChart({
|
|||||||
|
|
||||||
{/* 보은군 평균 대비 */}
|
{/* 보은군 평균 대비 */}
|
||||||
<div className={`flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 shadow-sm ${(chartDisplayValues.originalScore - chartDisplayValues.originalRegionScore) >= 0 ? 'border-green-300' : 'border-red-300'}`}>
|
<div className={`flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 shadow-sm ${(chartDisplayValues.originalScore - chartDisplayValues.originalRegionScore) >= 0 ? 'border-green-300' : 'border-red-300'}`}>
|
||||||
<span className="text-2xl text-muted-foreground mb-2 font-medium">보은군 평균 대비</span>
|
<span className="text-sm text-muted-foreground mb-2 font-medium">보은군 평균 대비</span>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
{traitRankLoading && chartFilterTrait !== 'overall' ? (
|
{traitRankLoading && chartFilterTrait !== 'overall' ? (
|
||||||
<span className="text-2xl font-bold text-muted-foreground">...</span>
|
<span className="text-2xl font-bold text-muted-foreground">...</span>
|
||||||
@@ -818,15 +674,6 @@ export function NormalDistributionChart({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-[400px] sm:h-[440px] md:h-[470px] bg-gradient-to-b from-slate-50 to-slate-100 rounded-xl p-2 sm:p-4 relative" role="img" aria-label="농가 및 보은군 내 개체 위치">
|
<div className="h-[400px] sm:h-[440px] md:h-[470px] bg-gradient-to-b from-slate-50 to-slate-100 rounded-xl p-2 sm:p-4 relative" role="img" aria-label="농가 및 보은군 내 개체 위치">
|
||||||
{/* 로딩 상태 */}
|
|
||||||
{(traitRankLoading && chartFilterTrait !== 'overall') || histogramData.length === 0 ? (
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-slate-50/80 rounded-xl z-10">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent mb-4"></div>
|
|
||||||
<p className="text-sm text-muted-foreground font-medium">
|
|
||||||
{chartFilterTrait === 'overall' ? '전체 선발지수' : chartFilterTrait} 분포 데이터 로딩 중...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ComposedChart
|
<ComposedChart
|
||||||
data={histogramData}
|
data={histogramData}
|
||||||
@@ -865,68 +712,31 @@ export function NormalDistributionChart({
|
|||||||
type="number"
|
type="number"
|
||||||
domain={[xAxisConfig.min, xAxisConfig.max]}
|
domain={[xAxisConfig.min, xAxisConfig.max]}
|
||||||
ticks={xTicks}
|
ticks={xTicks}
|
||||||
tick={{ fontSize: isMobileView ? 16 : 18, fill: '#64748b', fontWeight: 700 }}
|
tick={{ fontSize: isMobileView ? 11 : 13, fill: '#64748b', fontWeight: 600 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={{ stroke: '#cbd5e1', strokeWidth: 2 }}
|
axisLine={{ stroke: '#cbd5e1', strokeWidth: 2 }}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => {
|
||||||
if (value === 0) {
|
if (value === 0) return '내 개체'
|
||||||
// cowNo의 뒤에서 5번째부터 2번째까지 4자리 추출 (예: KOR002203259861 -> 5986)
|
|
||||||
const shortId = cowNo ? cowNo.slice(-5, -1) : ''
|
|
||||||
return shortId || '0'
|
|
||||||
}
|
|
||||||
return value > 0 ? `+${value}` : `${value}`
|
return value > 0 ? `+${value}` : `${value}`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
axisLine={{ stroke: '#cbd5e1', strokeWidth: 1 }}
|
axisLine={{ stroke: '#cbd5e1', strokeWidth: 1 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tick={{ fontSize: isMobileView ? 15 : 17, fill: '#64748b', fontWeight: 700 }}
|
tick={{ fontSize: isMobileView ? 10 : 11, fill: '#64748b' }}
|
||||||
width={isMobileView ? 45 : 60}
|
width={isMobileView ? 35 : 45}
|
||||||
domain={[0, Math.ceil(maxCount)]}
|
domain={[0, Math.ceil(maxPercent)]}
|
||||||
tickFormatter={(value) => `${Math.round(value)}`}
|
tickFormatter={(value) => `${Math.round(value)}%`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Tooltip */}
|
{/* 정규분포 곡선 */}
|
||||||
<Tooltip
|
|
||||||
content={({ active, payload }) => {
|
|
||||||
if (!active || !payload || payload.length === 0) return null
|
|
||||||
|
|
||||||
const data = payload[0].payload
|
|
||||||
const cowScore = chartDisplayValues.originalScore
|
|
||||||
const binStart = Math.round((data.midPoint + cowScore) * 100) / 100
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-3 border border-border rounded-lg shadow-lg">
|
|
||||||
<p className="text-sm font-semibold mb-2">
|
|
||||||
구간: {binStart >= 0 ? '+' : ''}{binStart.toFixed(2)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
개체 수: <span className="font-bold text-foreground">{data.count || 0}마리</span>
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
비율: <span className="font-bold text-foreground">{data.percent?.toFixed(1) || 0}%</span>
|
|
||||||
</p>
|
|
||||||
{data.farmCount !== undefined && (
|
|
||||||
<p className="text-sm text-blue-600 mt-1">
|
|
||||||
내 농가: <span className="font-bold">{data.farmCount}마리</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
cursor={{ fill: 'rgba(59, 130, 246, 0.1)' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 실제 데이터 분포 (Area 그래프 + 점 표시) */}
|
|
||||||
<Area
|
<Area
|
||||||
type="linear"
|
type="natural"
|
||||||
dataKey="count"
|
dataKey="percent"
|
||||||
stroke="#64748b"
|
stroke="#64748b"
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
fill="url(#areaFillGradient)"
|
fill="url(#areaFillGradient)"
|
||||||
dot={{ r: 4, fill: '#64748b', strokeWidth: 2, stroke: '#fff' }}
|
dot={false}
|
||||||
activeDot={{ r: 6, fill: '#3b82f6', strokeWidth: 2, stroke: '#fff' }}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 보은군 평균 위치 */}
|
{/* 보은군 평균 위치 */}
|
||||||
@@ -1001,10 +811,10 @@ export function NormalDistributionChart({
|
|||||||
return Math.max(chartX + halfWidth + 5, Math.min(x, chartX + chartWidth - halfWidth - 5))
|
return Math.max(chartX + halfWidth + 5, Math.min(x, chartX + chartWidth - halfWidth - 5))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 배지 크기 (더 크게) - 모바일에서 텍스트가 한 줄로 나오도록 너비 확보
|
// 배지 크기 (더 크게)
|
||||||
const cowBadgeW = isMobile ? 105 : 135
|
const cowBadgeW = isMobile ? 105 : 135
|
||||||
const avgBadgeW = isMobile ? 118 : 135
|
const avgBadgeW = isMobile ? 100 : 135
|
||||||
const regionBadgeW = isMobile ? 125 : 145
|
const regionBadgeW = isMobile ? 105 : 145
|
||||||
const badgeH = isMobile ? 42 : 48
|
const badgeH = isMobile ? 42 : 48
|
||||||
|
|
||||||
// Y 위치 계산 - 겹치지 않게 배치
|
// Y 위치 계산 - 겹치지 않게 배치
|
||||||
@@ -1075,7 +885,7 @@ export function NormalDistributionChart({
|
|||||||
fontSize={isMobile ? 13 : 15}
|
fontSize={isMobile ? 13 : 15}
|
||||||
fontWeight={600}
|
fontWeight={600}
|
||||||
>
|
>
|
||||||
{cowNo ? cowNo.slice(-5, -1) : '0'}
|
내 개체
|
||||||
</text>
|
</text>
|
||||||
<text
|
<text
|
||||||
x={clamp(cowX, cowBadgeW / 2)}
|
x={clamp(cowX, cowBadgeW / 2)}
|
||||||
@@ -1290,11 +1100,10 @@ export function NormalDistributionChart({
|
|||||||
/>
|
/>
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 범례 */}
|
{/* 범례 */}
|
||||||
{/* <div className="flex items-center justify-center gap-4 sm:gap-6 mt-4 pt-4 border-t border-border">
|
<div className="flex items-center justify-center gap-4 sm:gap-6 mt-4 pt-4 border-t border-border">
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||||
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded bg-slate-500"></div>
|
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded bg-slate-500"></div>
|
||||||
<span className="text-sm sm:text-sm text-muted-foreground">보은군 평균</span>
|
<span className="text-sm sm:text-sm text-muted-foreground">보은군 평균</span>
|
||||||
@@ -1307,7 +1116,7 @@ export function NormalDistributionChart({
|
|||||||
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded bg-[#1482B0]"></div>
|
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded bg-[#1482B0]"></div>
|
||||||
<span className="text-sm sm:text-sm font-medium text-foreground">{displayCowNumber.slice(-4)} 개체</span>
|
<span className="text-sm sm:text-sm font-medium text-foreground">{displayCowNumber.slice(-4)} 개체</span>
|
||||||
</div>
|
</div>
|
||||||
</div> */}
|
</div>
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo, useEffect, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { DEFAULT_TRAITS, NEGATIVE_TRAITS, getTraitDisplayName } from "@/constants/traits"
|
|
||||||
import { GenomeCowTraitDto } from "@/types/genome.types"
|
// 기본 7개 형질
|
||||||
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
|
const DEFAULT_TRAITS = ['도체중', '등심단면적', '등지방두께', '근내지방도', '체장', '체고', '등심weight']
|
||||||
|
|
||||||
|
// 낮을수록 좋은 형질 (부호 반전 색상 적용)
|
||||||
|
const NEGATIVE_TRAITS = ['등지방두께']
|
||||||
|
|
||||||
|
// 형질명 표시 (전체 이름)
|
||||||
|
const TRAIT_SHORT_NAMES: Record<string, string> = {
|
||||||
|
'도체중': '도체중',
|
||||||
|
'등심단면적': '등심단면적',
|
||||||
|
'등지방두께': '등지방두께',
|
||||||
|
'근내지방도': '근내지방도',
|
||||||
|
'체장': '체장',
|
||||||
|
'체고': '체고',
|
||||||
|
'등심weight': '등심중량'
|
||||||
|
}
|
||||||
|
|
||||||
// 카테고리별 배지 스타일 (진한 톤)
|
// 카테고리별 배지 스타일 (진한 톤)
|
||||||
const CATEGORY_STYLES: Record<string, { bg: string; text: string; border: string }> = {
|
const CATEGORY_STYLES: Record<string, { bg: string; text: string; border: string }> = {
|
||||||
@@ -19,91 +33,82 @@ const CATEGORY_STYLES: Record<string, { bg: string; text: string; border: string
|
|||||||
'비율': { bg: 'bg-rose-100', text: 'text-rose-800', border: 'border-rose-300' },
|
'비율': { bg: 'bg-rose-100', text: 'text-rose-800', border: 'border-rose-300' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TraitData {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
breedVal: number
|
||||||
|
percentile: number
|
||||||
|
actualValue: number
|
||||||
|
unit: string
|
||||||
|
}
|
||||||
|
|
||||||
interface TraitDistributionChartsProps {
|
interface TraitDistributionChartsProps {
|
||||||
allTraits: GenomeCowTraitDto[]
|
allTraits: TraitData[]
|
||||||
regionAvgZ: number
|
regionAvgZ: number
|
||||||
farmAvgZ: number
|
farmAvgZ: number
|
||||||
cowName?: string
|
cowName?: string
|
||||||
cowNo?: string // API 호출용 개체번호
|
|
||||||
totalCowCount?: number
|
totalCowCount?: number
|
||||||
selectedTraits?: GenomeCowTraitDto[]
|
selectedTraits?: TraitData[]
|
||||||
traitWeights?: Record<string, number>
|
traitWeights?: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블 뷰 컴포넌트 (데스크탑)
|
// 리스트 뷰 컴포넌트
|
||||||
function TraitTableView({ traits, traitRanks }: {
|
function TraitListView({ traits, cowName }: { traits: Array<{ name: string; shortName: string; breedVal: number; percentile: number; category?: string; actualValue?: number }>; cowName: string }) {
|
||||||
traits: Array<{
|
|
||||||
traitName?: string;
|
|
||||||
shortName: string;
|
|
||||||
breedVal: number;
|
|
||||||
percentile?: number;
|
|
||||||
traitCategory?: string;
|
|
||||||
traitVal?: number;
|
|
||||||
hasData?: boolean;
|
|
||||||
}>;
|
|
||||||
traitRanks: Record<string, TraitRankDto>
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Card className="hidden lg:block bg-white border border-border rounded-xl overflow-hidden shadow-md">
|
<Card className="bg-white border border-border rounded-xl overflow-hidden shadow-md">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-[1.5rem]">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b-2 border-border bg-muted/70">
|
<tr className="border-b-2 border-border bg-muted/70">
|
||||||
<th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground">유전형질</th>
|
<th className="px-3 sm:px-5 py-4 text-center text-sm sm:text-base font-bold text-foreground">형질명</th>
|
||||||
<th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground">유전체 육종가</th>
|
<th className="px-3 sm:px-5 py-4 text-left text-sm sm:text-base font-bold text-foreground">카테고리</th>
|
||||||
<th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground">전국 백분위</th>
|
<th className="px-3 sm:px-5 py-4 text-left text-sm sm:text-base font-bold text-foreground">육종가</th>
|
||||||
<th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground">농가 내 순위</th>
|
<th className="px-3 sm:px-5 py-4 text-left text-sm sm:text-base font-bold text-foreground">전국 백분위</th>
|
||||||
<th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground">보은군 내 순위</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{traits.map((trait, idx) => {
|
{traits.map((trait, idx) => (
|
||||||
const rankData = trait.traitName ? traitRanks[trait.traitName] : null
|
<tr key={trait.name} className="border-b border-border last:border-b-0 hover:bg-muted/30 transition-colors">
|
||||||
return (
|
|
||||||
<tr key={trait.traitName || idx} className="border-b border-border last:border-b-0 hover:bg-muted/30 transition-colors">
|
|
||||||
<td className="px-3 sm:px-5 py-4 text-center">
|
<td className="px-3 sm:px-5 py-4 text-center">
|
||||||
<span className="font-medium text-foreground">{trait.shortName}</span>
|
<span className="text-sm sm:text-lg font-semibold text-foreground">{trait.shortName}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 sm:px-5 py-4 text-center">
|
<td className="px-3 sm:px-5 py-4 text-left">
|
||||||
<div className="flex items-center justify-center gap-2">
|
{trait.category && (
|
||||||
<span className={`font-bold ${(() => {
|
<span
|
||||||
const value = trait.traitVal ?? 0
|
className={`inline-flex items-center text-xs sm:text-sm font-bold px-3 sm:px-4 py-1.5 rounded-full whitespace-nowrap border-2 ${CATEGORY_STYLES[trait.category]?.bg || 'bg-slate-50'} ${CATEGORY_STYLES[trait.category]?.text || 'text-slate-700'} ${CATEGORY_STYLES[trait.category]?.border || 'border-slate-200'}`}
|
||||||
const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.traitName || '')
|
>
|
||||||
|
{trait.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 sm:px-5 py-4 text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-base sm:text-xl font-bold ${(() => {
|
||||||
|
const value = trait.actualValue ?? 0
|
||||||
|
const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.name)
|
||||||
|
// 등지방두께: 음수가 좋음(녹색), 양수가 나쁨(빨간색)
|
||||||
|
// 나머지: 양수가 좋음(녹색), 음수가 나쁨(빨간색)
|
||||||
if (value === 0) return 'text-muted-foreground'
|
if (value === 0) return 'text-muted-foreground'
|
||||||
if (isNegativeTrait) {
|
if (isNegativeTrait) {
|
||||||
return value < 0 ? 'text-green-600' : 'text-red-600'
|
return value < 0 ? 'text-green-600' : 'text-red-600'
|
||||||
}
|
}
|
||||||
return value > 0 ? 'text-green-600' : 'text-red-600'
|
return value > 0 ? 'text-green-600' : 'text-red-600'
|
||||||
})()}`}>
|
})()}`}>
|
||||||
{trait.traitVal !== undefined ? (
|
{trait.actualValue !== undefined ? (
|
||||||
<>{trait.traitVal > 0 ? '+' : ''}{trait.traitVal.toFixed(1)}</>
|
<>{trait.actualValue > 0 ? '+' : ''}{trait.actualValue.toFixed(1)}</>
|
||||||
) : '-'}
|
) : '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 sm:px-5 py-4 text-center">
|
<td className="px-3 sm:px-5 py-4 text-left">
|
||||||
<span className="font-bold text-foreground">
|
<span className="text-base sm:text-xl font-bold text-foreground">
|
||||||
상위 {(trait.percentile || 0).toFixed(0)}%
|
상위 {trait.percentile.toFixed(0)}%
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 sm:px-5 py-4 text-center">
|
|
||||||
<span className="font-bold text-foreground">
|
|
||||||
{rankData?.farmRank && rankData.farmTotal ? (
|
|
||||||
`${rankData.farmRank}위/${rankData.farmTotal}두`
|
|
||||||
) : '-'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 sm:px-5 py-4 text-center">
|
|
||||||
<span className="font-bold text-foreground">
|
|
||||||
{rankData?.regionRank && rankData.regionTotal ? (
|
|
||||||
`${rankData.regionRank}위/${rankData.regionTotal}두`
|
|
||||||
) : '-'}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,96 +117,12 @@ function TraitTableView({ traits, traitRanks }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카드 뷰 컴포넌트 (모바일)
|
|
||||||
function TraitCardView({ traits, traitRanks }: {
|
|
||||||
traits: Array<{
|
|
||||||
traitName?: string;
|
|
||||||
shortName: string;
|
|
||||||
breedVal: number;
|
|
||||||
percentile?: number;
|
|
||||||
traitCategory?: string;
|
|
||||||
traitVal?: number;
|
|
||||||
hasData?: boolean;
|
|
||||||
}>;
|
|
||||||
traitRanks: Record<string, TraitRankDto>
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="lg:hidden space-y-3">
|
|
||||||
{traits.map((trait, idx) => {
|
|
||||||
const rankData = trait.traitName ? traitRanks[trait.traitName] : null
|
|
||||||
const value = trait.traitVal ?? 0
|
|
||||||
const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.traitName || '')
|
|
||||||
const valueColor = (() => {
|
|
||||||
if (value === 0) return 'text-muted-foreground'
|
|
||||||
if (isNegativeTrait) {
|
|
||||||
return value < 0 ? 'text-green-600' : 'text-red-600'
|
|
||||||
}
|
|
||||||
return value > 0 ? 'text-green-600' : 'text-red-600'
|
|
||||||
})()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={trait.traitName || idx} className="bg-white border border-border rounded-xl overflow-hidden shadow-sm">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* 형질명 */}
|
|
||||||
<div className="flex items-center justify-between pb-3 border-b border-border">
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">유전형질</span>
|
|
||||||
<span className="text-base font-bold text-foreground">{trait.shortName}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 유전체 육종가 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">유전체 육종가</span>
|
|
||||||
<span className={`text-base font-bold ${valueColor}`}>
|
|
||||||
{trait.traitVal !== undefined ? (
|
|
||||||
<>{trait.traitVal > 0 ? '+' : ''}{trait.traitVal.toFixed(1)}</>
|
|
||||||
) : '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 전국 백분위 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">전국 백분위</span>
|
|
||||||
<span className="text-base font-bold text-foreground">
|
|
||||||
상위 {(trait.percentile || 0).toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 농가 내 순위 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">농가 내 순위</span>
|
|
||||||
<span className="text-base font-bold text-foreground">
|
|
||||||
{rankData?.farmRank && rankData.farmTotal ? (
|
|
||||||
`${rankData.farmRank}위/${rankData.farmTotal}두`
|
|
||||||
) : '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 보은군 내 순위 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">보은군 내 순위</span>
|
|
||||||
<span className="text-base font-bold text-foreground">
|
|
||||||
{rankData?.regionRank && rankData.regionTotal ? (
|
|
||||||
`${rankData.regionRank}위/${rankData.regionTotal}두`
|
|
||||||
) : '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 메인 컴포넌트
|
// 메인 컴포넌트
|
||||||
export function TraitDistributionCharts({
|
export function TraitDistributionCharts({
|
||||||
allTraits,
|
allTraits,
|
||||||
regionAvgZ,
|
regionAvgZ,
|
||||||
farmAvgZ,
|
farmAvgZ,
|
||||||
cowName = '개체',
|
cowName = '개체',
|
||||||
cowNo,
|
|
||||||
totalCowCount = 100,
|
totalCowCount = 100,
|
||||||
selectedTraits = [],
|
selectedTraits = [],
|
||||||
traitWeights = {}
|
traitWeights = {}
|
||||||
@@ -218,81 +139,34 @@ export function TraitDistributionCharts({
|
|||||||
const displayTraits = useMemo(() => {
|
const displayTraits = useMemo(() => {
|
||||||
if (selectedTraits.length > 0) {
|
if (selectedTraits.length > 0) {
|
||||||
return selectedTraits.map(trait => {
|
return selectedTraits.map(trait => {
|
||||||
const weight = traitWeights[trait.traitName || ''] || 1
|
const weight = traitWeights[trait.name] || 1
|
||||||
return {
|
return {
|
||||||
traitName: trait.traitName,
|
name: trait.name,
|
||||||
shortName: getTraitDisplayName(trait.traitName || ''),
|
shortName: TRAIT_SHORT_NAMES[trait.name] || trait.name,
|
||||||
breedVal: (trait.breedVal || 0) * weight,
|
breedVal: trait.breedVal * weight,
|
||||||
percentile: trait.percentile,
|
percentile: trait.percentile,
|
||||||
traitCategory: trait.traitCategory,
|
category: trait.category,
|
||||||
traitVal: trait.traitVal,
|
actualValue: trait.actualValue,
|
||||||
hasData: true
|
hasData: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 기본 7개 형질
|
// 기본 7개 형질
|
||||||
return DEFAULT_TRAITS.map(traitName => {
|
return DEFAULT_TRAITS.map(traitName => {
|
||||||
const trait = allTraits.find(t => t.traitName === traitName)
|
const trait = allTraits.find(t => t.name === traitName)
|
||||||
const weight = traitWeights[traitName] || 1
|
const weight = traitWeights[traitName] || 1
|
||||||
return {
|
return {
|
||||||
traitName: traitName,
|
name: traitName,
|
||||||
shortName: getTraitDisplayName(traitName),
|
shortName: TRAIT_SHORT_NAMES[traitName] || traitName,
|
||||||
breedVal: (trait?.breedVal ?? 0) * weight,
|
breedVal: (trait?.breedVal ?? 0) * weight,
|
||||||
percentile: trait?.percentile ?? 50,
|
percentile: trait?.percentile ?? 50,
|
||||||
traitCategory: trait?.traitCategory,
|
category: trait?.category,
|
||||||
traitVal: trait?.traitVal,
|
actualValue: trait?.actualValue,
|
||||||
hasData: !!trait
|
hasData: !!trait
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [allTraits, selectedTraits, traitWeights])
|
}, [allTraits, selectedTraits, traitWeights])
|
||||||
|
|
||||||
// 표시할 형질명 목록 (순위 조회용)
|
|
||||||
const traitNames = useMemo(() => {
|
|
||||||
return displayTraits
|
|
||||||
.filter(trait => trait.traitName && trait.hasData)
|
|
||||||
.map(trait => trait.traitName!)
|
|
||||||
.sort() // 정렬하여 안정적인 키 생성
|
|
||||||
}, [displayTraits])
|
|
||||||
|
|
||||||
// 형질명 목록의 안정적인 키 (dependency용)
|
|
||||||
const traitNamesKey = useMemo(() => {
|
|
||||||
return traitNames.join(',')
|
|
||||||
}, [traitNames])
|
|
||||||
|
|
||||||
// 각 형질의 순위 정보 가져오기
|
|
||||||
const [traitRanks, setTraitRanks] = useState<Record<string, TraitRankDto>>({})
|
|
||||||
const [loadingRanks, setLoadingRanks] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!cowNo || traitNames.length === 0) return
|
|
||||||
|
|
||||||
const fetchRanks = async () => {
|
|
||||||
setLoadingRanks(true)
|
|
||||||
try {
|
|
||||||
const rankPromises = traitNames.map(traitName =>
|
|
||||||
genomeApi.getTraitRank(cowNo, traitName)
|
|
||||||
.then(rank => ({ traitName, rank }))
|
|
||||||
.catch(() => null)
|
|
||||||
)
|
|
||||||
|
|
||||||
const results = await Promise.all(rankPromises)
|
|
||||||
const ranksMap: Record<string, TraitRankDto> = {}
|
|
||||||
results.forEach(result => {
|
|
||||||
if (result) {
|
|
||||||
ranksMap[result.traitName] = result.rank
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setTraitRanks(ranksMap)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('순위 정보 로드 실패:', error)
|
|
||||||
} finally {
|
|
||||||
setLoadingRanks(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchRanks()
|
|
||||||
}, [cowNo, traitNamesKey])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
@@ -306,11 +180,8 @@ export function TraitDistributionCharts({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 뷰 (데스크탑) */}
|
{/* 리스트 뷰 */}
|
||||||
<TraitTableView traits={displayTraits} traitRanks={traitRanks} />
|
<TraitListView traits={displayTraits} cowName={displayCowNumber} />
|
||||||
|
|
||||||
{/* 카드 뷰 (모바일) */}
|
|
||||||
<TraitCardView traits={displayTraits} traitRanks={traitRanks} />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,24 +3,31 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { mptApi, MptDto, MptReferenceRange, MptCategory } from "@/lib/api/mpt.api"
|
import { mptApi, MptDto } from "@/lib/api"
|
||||||
import { Activity, CheckCircle2, XCircle } from 'lucide-react'
|
import { Activity, CheckCircle2, XCircle } from 'lucide-react'
|
||||||
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
||||||
import { CowDetail } from "@/types/cow.types"
|
import { CowDetail } from "@/types/cow.types"
|
||||||
import { GenomeRequestDto } from "@/lib/api/genome.api"
|
import { GenomeRequestDto } from "@/lib/api"
|
||||||
|
import { MPT_REFERENCE_RANGES } from "@/constants/mpt-reference"
|
||||||
|
|
||||||
// 측정값 상태 판정: 안전(safe) / 주의(caution)
|
// 혈액화학검사 카테고리별 항목
|
||||||
function getMptValueStatus(
|
const MPT_CATEGORIES = [
|
||||||
key: string,
|
{ name: '에너지 대사', items: ['glucose', 'cholesterol', 'nefa', 'bcs'], color: 'bg-muted/50' },
|
||||||
value: number | null,
|
{ name: '단백질 대사', items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'], color: 'bg-muted/50' },
|
||||||
references: Record<string, MptReferenceRange>
|
{ name: '간기능', items: ['ast', 'ggt', 'fattyLiverIdx'], color: 'bg-muted/50' },
|
||||||
): 'safe' | 'caution' | 'unknown' {
|
{ name: '미네랄', items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium'], color: 'bg-muted/50' },
|
||||||
|
{ name: '기타', items: ['creatinine'], color: 'bg-muted/50' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 측정값 상태 판정
|
||||||
|
function getMptValueStatus(key: string, value: number | null): 'normal' | 'warning' | 'danger' | 'unknown' {
|
||||||
if (value === null || value === undefined) return 'unknown'
|
if (value === null || value === undefined) return 'unknown'
|
||||||
const ref = references[key]
|
const ref = MPT_REFERENCE_RANGES[key]
|
||||||
if (!ref || ref.lowerLimit === null || ref.upperLimit === null) return 'unknown'
|
if (!ref || ref.lowerLimit === null || ref.upperLimit === null) return 'unknown'
|
||||||
// 하한값 ~ 상한값 사이면 안전, 그 외 주의
|
if (value >= ref.lowerLimit && value <= ref.upperLimit) return 'normal'
|
||||||
if (value >= ref.lowerLimit && value <= ref.upperLimit) return 'safe'
|
const margin = (ref.upperLimit - ref.lowerLimit) * 0.1
|
||||||
return 'caution'
|
if (value >= ref.lowerLimit - margin && value <= ref.upperLimit + margin) return 'warning'
|
||||||
|
return 'danger'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MptTableProps {
|
interface MptTableProps {
|
||||||
@@ -35,33 +42,14 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
|||||||
const [mptData, setMptData] = useState<MptDto[]>([])
|
const [mptData, setMptData] = useState<MptDto[]>([])
|
||||||
const [selectedMpt, setSelectedMpt] = useState<MptDto | null>(null)
|
const [selectedMpt, setSelectedMpt] = useState<MptDto | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [references, setReferences] = useState<Record<string, MptReferenceRange>>({})
|
|
||||||
const [categories, setCategories] = useState<MptCategory[]>([])
|
|
||||||
const [refLoading, setRefLoading] = useState(true)
|
|
||||||
|
|
||||||
// 참조값 로드
|
|
||||||
useEffect(() => {
|
|
||||||
const loadReference = async () => {
|
|
||||||
try {
|
|
||||||
const data = await mptApi.getReferenceValues()
|
|
||||||
setReferences(data.references)
|
|
||||||
setCategories(data.categories)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('MPT 참조값 로드 실패:', error)
|
|
||||||
} finally {
|
|
||||||
setRefLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadReference()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchMptData = async () => {
|
const fetchMptData = async () => {
|
||||||
if (!cowNo) return
|
if (!cowShortNo) return
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await mptApi.findByCowId(cowNo)
|
const data = await mptApi.findByCowShortNo(cowShortNo)
|
||||||
setMptData(data)
|
setMptData(data)
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
setSelectedMpt(data[0])
|
setSelectedMpt(data[0])
|
||||||
@@ -74,9 +62,9 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetchMptData()
|
fetchMptData()
|
||||||
}, [cowNo])
|
}, [cowShortNo])
|
||||||
|
|
||||||
if (loading || refLoading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -90,11 +78,11 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 개체 정보 섹션 */}
|
{/* 개체 정보 섹션 */}
|
||||||
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">개체 정보</h3>
|
<h3 className="text-lg lg:text-xl font-bold text-foreground">개체 정보</h3>
|
||||||
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{/* 데스크탑: 가로 그리드 */}
|
{/* 데스크탑: 가로 그리드 */}
|
||||||
<div className="hidden lg:grid lg:grid-cols-3 divide-x divide-border">
|
<div className="hidden lg:grid lg:grid-cols-4 divide-x divide-border">
|
||||||
<div>
|
<div>
|
||||||
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||||
<span className="text-base font-semibold text-muted-foreground">개체번호</span>
|
<span className="text-base font-semibold text-muted-foreground">개체번호</span>
|
||||||
@@ -113,18 +101,25 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||||
|
<span className="text-base font-semibold text-muted-foreground">월령</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<span className="text-2xl font-bold text-foreground">
|
||||||
|
{cow?.cowBirthDt
|
||||||
|
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||||
<span className="text-base font-semibold text-muted-foreground">성별</span>
|
<span className="text-base font-semibold text-muted-foreground">성별</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4">
|
<div className="px-5 py-4">
|
||||||
<span className="text-2xl font-bold text-foreground">
|
<span className="text-2xl font-bold text-foreground">
|
||||||
{(() => {
|
{cow?.cowSex === 'F' ? '암' : cow?.cowSex === 'M' ? '수' : '-'}
|
||||||
const sex = cow?.cowSex?.toUpperCase?.() || cow?.cowSex
|
|
||||||
if (sex === 'F' || sex === '암' || sex === '2') return '암소'
|
|
||||||
if (sex === 'M' || sex === '수' || sex === '1') return '수소'
|
|
||||||
return '-'
|
|
||||||
})()}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,25 +138,175 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
|||||||
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
|
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">월령</span>
|
||||||
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
||||||
|
{cow?.cowBirthDt
|
||||||
|
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">성별</span>
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">성별</span>
|
||||||
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
||||||
{(() => {
|
{cow?.cowSex === 'F' ? '암' : cow?.cowSex === 'M' ? '수' : '-'}
|
||||||
const sex = cow?.cowSex?.toUpperCase?.() || cow?.cowSex
|
|
||||||
if (sex === 'F' || sex === '암' || sex === '2') return '암소'
|
|
||||||
if (sex === 'M' || sex === '수' || sex === '1') return '수소'
|
|
||||||
return '-'
|
|
||||||
})()}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 혈통정보 섹션 */}
|
||||||
|
<h3 className="text-lg lg:text-xl font-bold text-foreground">혈통정보</h3>
|
||||||
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{/* 데스크탑: 가로 그리드 */}
|
||||||
|
<div className="hidden lg:grid lg:grid-cols-2 divide-x divide-border">
|
||||||
|
<div>
|
||||||
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||||
|
<span className="text-base font-semibold text-muted-foreground">부 KPN번호</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-2xl font-bold text-foreground break-all">
|
||||||
|
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const chipSireName = genomeRequest?.chipSireName
|
||||||
|
if (chipSireName === '일치') {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5 bg-muted/50 text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
<span>일치</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else if (chipSireName && chipSireName !== '일치') {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
<span>불일치</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||||
|
<span className="text-base font-semibold text-muted-foreground">모 개체번호</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
||||||
|
{cow?.damCowId && cow.damCowId !== '0' ? (
|
||||||
|
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
|
||||||
|
) : (
|
||||||
|
<span className="text-2xl font-bold text-foreground">-</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const chipDamName = genomeRequest?.chipDamName
|
||||||
|
if (chipDamName === '일치') {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5 bg-muted/50 text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
<span>일치</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else if (chipDamName === '불일치') {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
<span>불일치</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else if (chipDamName === '이력제부재') {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5 bg-amber-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
<span>이력제부재</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 모바일: 좌우 배치 리스트 */}
|
||||||
|
<div className="lg:hidden divide-y divide-border">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">부 KPN번호</span>
|
||||||
|
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-base font-bold text-foreground break-all">
|
||||||
|
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const chipSireName = genomeRequest?.chipSireName
|
||||||
|
if (chipSireName === '일치') {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 bg-muted/50 text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
<span>일치</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else if (chipSireName && chipSireName !== '일치') {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||||||
|
<XCircle className="w-3 h-3" />
|
||||||
|
<span>불일치</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">모 개체번호</span>
|
||||||
|
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
|
||||||
|
{cow?.damCowId && cow.damCowId !== '0' ? (
|
||||||
|
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
|
||||||
|
) : (
|
||||||
|
<span className="text-base font-bold text-foreground">-</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const chipDamName = genomeRequest?.chipDamName
|
||||||
|
if (chipDamName === '일치') {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 bg-muted/50 text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
<span>일치</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else if (chipDamName === '불일치') {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||||||
|
<XCircle className="w-3 h-3" />
|
||||||
|
<span>불일치</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else if (chipDamName === '이력제부재') {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 bg-amber-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||||||
|
<XCircle className="w-3 h-3" />
|
||||||
|
<span>이력제부재</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 검사 정보 */}
|
{/* 검사 정보 */}
|
||||||
{selectedMpt && (
|
{selectedMpt && (
|
||||||
<>
|
<>
|
||||||
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">검사 정보</h3>
|
<h3 className="text-lg lg:text-xl font-bold text-foreground">검사 정보</h3>
|
||||||
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="hidden lg:grid lg:grid-cols-4 divide-x divide-border">
|
<div className="hidden lg:grid lg:grid-cols-4 divide-x divide-border">
|
||||||
@@ -181,9 +326,7 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4">
|
<div className="px-5 py-4">
|
||||||
<span className="text-2xl font-bold text-foreground">
|
<span className="text-2xl font-bold text-foreground">
|
||||||
{cow?.cowBirthDt && selectedMpt.testDt
|
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'}
|
||||||
? `${Math.floor((new Date(selectedMpt.testDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
|
||||||
: '-'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,9 +362,7 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="w-24 shrink-0 bg-muted/50 px-4 py-3.5 text-sm font-medium text-muted-foreground">월령</span>
|
<span className="w-24 shrink-0 bg-muted/50 px-4 py-3.5 text-sm font-medium text-muted-foreground">월령</span>
|
||||||
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
||||||
{cow?.cowBirthDt && selectedMpt.testDt
|
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'}
|
||||||
? `${Math.floor((new Date(selectedMpt.testDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
|
||||||
: '-'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -242,65 +383,65 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 혈액화학검사 결과 테이블 - selectedMpt가 있을 때만 표시 */}
|
{/* 혈액화학검사 결과 테이블 */}
|
||||||
{selectedMpt ? (
|
<h3 className="text-lg lg:text-xl font-bold text-foreground">혈액화학검사 결과</h3>
|
||||||
<>
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||||
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">혈액화학검사 결과</h3>
|
|
||||||
|
|
||||||
{/* 데스크탑: 테이블 */}
|
|
||||||
<Card className="hidden lg:block bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-[1.5rem]">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-muted/50 border-b border-border">
|
<tr className="bg-muted/50 border-b border-border">
|
||||||
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '12%' }}>카테고리</th>
|
<th className="px-4 py-3 text-left text-sm font-semibold text-muted-foreground w-28">카테고리</th>
|
||||||
<th className="px-4 py-3 text-left font-semibold text-muted-foreground" style={{ width: '18%' }}>검사항목</th>
|
<th className="px-4 py-3 text-left text-sm font-semibold text-muted-foreground">검사항목</th>
|
||||||
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '15%' }}>측정값</th>
|
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-24">측정값</th>
|
||||||
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '12%' }}>하한값</th>
|
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20">하한값</th>
|
||||||
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '12%' }}>상한값</th>
|
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20">상한값</th>
|
||||||
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '15%' }}>단위</th>
|
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20">단위</th>
|
||||||
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '16%' }}>상태</th>
|
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20">상태</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{categories.map((category) => (
|
{MPT_CATEGORIES.map((category) => (
|
||||||
category.items.map((itemKey, itemIdx) => {
|
category.items.map((itemKey, itemIdx) => {
|
||||||
const ref = references[itemKey]
|
const ref = MPT_REFERENCE_RANGES[itemKey]
|
||||||
const value = selectedMpt ? (selectedMpt[itemKey as keyof MptDto] as number | null) : null
|
const value = selectedMpt ? (selectedMpt[itemKey as keyof MptDto] as number | null) : null
|
||||||
const status = getMptValueStatus(itemKey, value, references)
|
const status = getMptValueStatus(itemKey, value)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={itemKey} className="border-b border-border hover:bg-muted/30">
|
<tr key={itemKey} className="border-b border-border hover:bg-muted/30">
|
||||||
{itemIdx === 0 && (
|
{itemIdx === 0 && (
|
||||||
<td
|
<td
|
||||||
rowSpan={category.items.length}
|
rowSpan={category.items.length}
|
||||||
className={`px-4 py-3 font-semibold text-foreground ${category.color} align-middle text-center`}
|
className={`px-4 py-3 text-sm font-semibold text-foreground ${category.color} align-middle text-center`}
|
||||||
>
|
>
|
||||||
{category.name}
|
{category.name}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="px-4 py-3 font-medium text-foreground">{ref?.name || itemKey}</td>
|
<td className="px-4 py-3 text-sm font-medium text-foreground">{ref?.name || itemKey}</td>
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
<span className={`font-bold ${
|
<span className={`text-lg font-bold ${
|
||||||
status === 'safe' ? 'text-green-600' :
|
status === 'normal' ? 'text-green-600' :
|
||||||
status === 'caution' ? 'text-amber-600' :
|
status === 'warning' ? 'text-amber-600' :
|
||||||
|
status === 'danger' ? 'text-red-600' :
|
||||||
'text-muted-foreground'
|
'text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{value !== null && value !== undefined ? value.toFixed(2) : '-'}
|
{value !== null && value !== undefined ? value.toFixed(2) : '-'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-center text-muted-foreground">{ref?.lowerLimit ?? '-'}</td>
|
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.lowerLimit ?? '-'}</td>
|
||||||
<td className="px-4 py-3 text-center text-muted-foreground">{ref?.upperLimit ?? '-'}</td>
|
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.upperLimit ?? '-'}</td>
|
||||||
<td className="px-4 py-3 text-center text-muted-foreground">{ref?.unit || '-'}</td>
|
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.unit || '-'}</td>
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
{value !== null && value !== undefined ? (
|
{value !== null && value !== undefined ? (
|
||||||
<span className={`inline-flex items-center px-4 py-1 rounded-full font-semibold ${
|
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold ${
|
||||||
status === 'safe' ? 'bg-green-100 text-green-700' :
|
status === 'normal' ? 'bg-green-100 text-green-700' :
|
||||||
status === 'caution' ? 'bg-amber-100 text-amber-700' :
|
status === 'warning' ? 'bg-amber-100 text-amber-700' :
|
||||||
|
status === 'danger' ? 'bg-red-100 text-red-700' :
|
||||||
'bg-slate-100 text-slate-500'
|
'bg-slate-100 text-slate-500'
|
||||||
}`}>
|
}`}>
|
||||||
{status === 'safe' ? '안전' : status === 'caution' ? '주의' : '-'}
|
{status === 'normal' ? '정상' :
|
||||||
|
status === 'warning' ? '주의' :
|
||||||
|
status === 'danger' ? '이상' : '-'}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">-</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
@@ -316,65 +457,11 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 모바일: 카드 레이아웃 */}
|
|
||||||
<div className="lg:hidden space-y-4">
|
|
||||||
{categories.map((category) => (
|
|
||||||
<Card key={category.key} className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
|
||||||
<div className="bg-muted/50 px-4 py-3 border-b border-border">
|
|
||||||
<span className="text-sm font-semibold text-foreground">{category.name}</span>
|
|
||||||
</div>
|
|
||||||
<CardContent className="p-0 divide-y divide-border">
|
|
||||||
{category.items.map((itemKey) => {
|
|
||||||
const ref = references[itemKey]
|
|
||||||
const value = selectedMpt ? (selectedMpt[itemKey as keyof MptDto] as number | null) : null
|
|
||||||
const status = getMptValueStatus(itemKey, value, references)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={itemKey} className="py-2">
|
|
||||||
<div className="flex items-center border-b border-border/50">
|
|
||||||
<span className="w-20 shrink-0 bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">검사항목</span>
|
|
||||||
<span className="flex-1 px-3 py-2 text-sm font-semibold text-foreground">{ref?.name || itemKey}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center border-b border-border/50">
|
|
||||||
<span className="w-20 shrink-0 bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">측정값</span>
|
|
||||||
<div className="flex-1 px-3 py-2 flex items-center justify-between">
|
|
||||||
<span className={`text-base font-bold ${
|
|
||||||
status === 'safe' ? 'text-green-600' :
|
|
||||||
status === 'caution' ? 'text-amber-600' :
|
|
||||||
'text-muted-foreground'
|
|
||||||
}`}>
|
|
||||||
{value !== null && value !== undefined ? value.toFixed(2) : '-'}
|
|
||||||
</span>
|
|
||||||
{value !== null && value !== undefined ? (
|
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
|
||||||
status === 'safe' ? 'bg-green-100 text-green-700' :
|
|
||||||
status === 'caution' ? 'bg-amber-100 text-amber-700' :
|
|
||||||
'bg-slate-100 text-slate-500'
|
|
||||||
}`}>
|
|
||||||
{status === 'safe' ? '안전' : status === 'caution' ? '주의' : '-'}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="w-20 shrink-0 bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">참조범위</span>
|
|
||||||
<span className="flex-1 px-3 py-2 text-sm text-muted-foreground">
|
|
||||||
{ref?.lowerLimit ?? '-'} ~ {ref?.upperLimit ?? '-'} {ref?.unit || ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 검사 이력 (여러 검사 결과가 있을 경우) */}
|
{/* 검사 이력 (여러 검사 결과가 있을 경우) */}
|
||||||
{mptData.length > 1 && (
|
{mptData.length > 1 && (
|
||||||
<>
|
<>
|
||||||
<h3 className="text-lg lg:text-xl font-bold text-foreground">검사 이력</h3>
|
<h3 className="text-lg lg:text-xl font-bold text-foreground">검사 이력</h3>
|
||||||
<Card className="inspt_hist bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{mptData.map((mpt, idx) => (
|
{mptData.map((mpt, idx) => (
|
||||||
@@ -392,19 +479,19 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
|||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
) : (
|
{/* 데이터 없음 안내 */}
|
||||||
/* 데이터 없음 안내 */
|
{/* {!selectedMpt && (
|
||||||
<Card className="bg-slate-50 border border-border rounded-2xl">
|
<Card className="bg-slate-50 border border-border rounded-2xl">
|
||||||
<CardContent className="p-8 text-center">
|
<CardContent className="p-8 text-center">
|
||||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-2">번식능력검사 데이터 없음</h3>
|
<h3 className="text-lg font-semibold text-foreground mb-2">혈액화학검사 데이터 없음</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
이 개체는 번식능력검사를 진행하지 않아 분석보고서를 제공할 수 없습니다.
|
이 개체는 아직 혈액화학검사(MPT) 결과가 등록되지 않았습니다.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user