feat: 물품명 자동 입력 + 운송장 패턴 추천
- CJ대한통운, 한진택배 물품명 자동 파싱 (goods_name) - 별칭 미입력 시 물품명을 label로 자동 설정 - carriers 테이블에 tracking_pattern 컬럼 추가 - 운송장 번호 패턴 기반 택배사 추천 (DB 기반, 하드코딩 제거) - 보내는분/받는분 헤더 텍스트 필터링 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
edc3a2c3d7
commit
6f89d8f0b2
10 changed files with 227 additions and 47 deletions
|
|
@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS carriers (
|
|||
short_name VARCHAR(20) NOT NULL,
|
||||
color VARCHAR(7) NOT NULL,
|
||||
logo_url VARCHAR(500),
|
||||
tracking_pattern VARCHAR(100),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ CREATE TABLE IF NOT EXISTS parcels (
|
|||
carrier_name VARCHAR(100) NOT NULL,
|
||||
tracking_number VARCHAR(100) NOT NULL,
|
||||
label VARCHAR(200),
|
||||
goods_name VARCHAR(300),
|
||||
sender_name VARCHAR(100),
|
||||
recipient_name VARCHAR(100),
|
||||
status VARCHAR(50) DEFAULT 'PENDING',
|
||||
|
|
@ -44,11 +46,11 @@ CREATE TABLE IF NOT EXISTS tracking_events (
|
|||
const S3_BASE = 'https://s3.caadiq.co.kr/traeon/logo';
|
||||
|
||||
const DEFAULT_CARRIERS = [
|
||||
['cjlogistics', 'CJ대한통운', 'CJ', '#E4002B', `${S3_BASE}/cjlogistics.svg`],
|
||||
['hanjin', '한진택배', '한진', '#1B3A6B', `${S3_BASE}/hanjin.svg`],
|
||||
['lotte', '롯데택배', '롯데', '#ED1C24', `${S3_BASE}/lotte.svg`],
|
||||
['epost', '우체국택배', '우체국', '#003DA5', `${S3_BASE}/epost.svg`],
|
||||
['logen', '로젠택배', '로젠', '#F5A623', `${S3_BASE}/logen.svg`],
|
||||
['cjlogistics', 'CJ대한통운', 'CJ', '#E4002B', `${S3_BASE}/cjlogistics.svg`, '10-12'],
|
||||
['hanjin', '한진택배', '한진', '#1B3A6B', `${S3_BASE}/hanjin.svg`, '10,12'],
|
||||
['lotte', '롯데택배', '롯데', '#ED1C24', `${S3_BASE}/lotte.svg`, '12'],
|
||||
['epost', '우체국택배', '우체국', '#003DA5', `${S3_BASE}/epost.svg`, '13'],
|
||||
['logen', '로젠택배', '로젠', '#F5A623', `${S3_BASE}/logen.svg`, '11'],
|
||||
];
|
||||
|
||||
async function dbPlugin(fastify) {
|
||||
|
|
@ -71,19 +73,22 @@ async function dbPlugin(fastify) {
|
|||
}
|
||||
fastify.log.info('테이블 초기화 완료');
|
||||
|
||||
// 마이그레이션: sender_name, recipient_name 컬럼 추가
|
||||
try {
|
||||
await pool.execute('ALTER TABLE parcels ADD COLUMN sender_name VARCHAR(100) AFTER label');
|
||||
} catch (e) { /* 이미 존재 */ }
|
||||
try {
|
||||
await pool.execute('ALTER TABLE parcels ADD COLUMN recipient_name VARCHAR(100) AFTER sender_name');
|
||||
} catch (e) { /* 이미 존재 */ }
|
||||
// 마이그레이션
|
||||
const migrations = [
|
||||
'ALTER TABLE parcels ADD COLUMN sender_name VARCHAR(100) AFTER label',
|
||||
'ALTER TABLE parcels ADD COLUMN recipient_name VARCHAR(100) AFTER sender_name',
|
||||
'ALTER TABLE parcels ADD COLUMN goods_name VARCHAR(300) AFTER label',
|
||||
'ALTER TABLE carriers ADD COLUMN tracking_pattern VARCHAR(100) AFTER logo_url',
|
||||
];
|
||||
for (const sql of migrations) {
|
||||
try { await pool.execute(sql); } catch (e) { /* 이미 존재 */ }
|
||||
}
|
||||
|
||||
// 기본 택배사 데이터 삽입
|
||||
for (const [id, name, shortName, color, logoUrl] of DEFAULT_CARRIERS) {
|
||||
for (const [id, name, shortName, color, logoUrl, pattern] of DEFAULT_CARRIERS) {
|
||||
await pool.execute(
|
||||
'INSERT IGNORE INTO carriers (id, name, short_name, color, logo_url) VALUES (?, ?, ?, ?, ?)',
|
||||
[id, name, shortName, color, logoUrl]
|
||||
'INSERT INTO carriers (id, name, short_name, color, logo_url, tracking_pattern) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE tracking_pattern = VALUES(tracking_pattern)',
|
||||
[id, name, shortName, color, logoUrl, pattern]
|
||||
);
|
||||
}
|
||||
fastify.log.info('기본 택배사 데이터 초기화 완료');
|
||||
|
|
|
|||
|
|
@ -89,10 +89,15 @@ async function trackerPlugin(fastify) {
|
|||
}
|
||||
: null;
|
||||
|
||||
// 헤더 텍스트가 그대로 나오는 경우 필터링
|
||||
const invalidNames = ['보내는 분', '보내는분', '받는 분', '받는분'];
|
||||
const senderName = trackInfo.sender?.name || null;
|
||||
const recipientName = trackInfo.recipient?.name || null;
|
||||
|
||||
return {
|
||||
trackingNumber: trackInfo.trackingNumber,
|
||||
senderName: trackInfo.sender?.name || null,
|
||||
recipientName: trackInfo.recipient?.name || null,
|
||||
senderName: invalidNames.includes(senderName) ? null : senderName,
|
||||
recipientName: invalidNames.includes(recipientName) ? null : recipientName,
|
||||
lastEvent,
|
||||
events,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export default async function carrierRoutes(fastify) {
|
||||
fastify.get('/', async () => {
|
||||
const [rows] = await fastify.db.execute(
|
||||
'SELECT id, name, short_name, color, logo_url FROM carriers ORDER BY name'
|
||||
'SELECT id, name, short_name, color, logo_url, tracking_pattern FROM carriers ORDER BY name'
|
||||
);
|
||||
return rows;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { fetchGoodsName } from '../services/goods-name.js';
|
||||
|
||||
export default async function parcelRoutes(fastify) {
|
||||
// 전체 택배 목록 (페이징)
|
||||
fastify.get('/', async (request) => {
|
||||
|
|
@ -61,10 +63,18 @@ export default async function parcelRoutes(fastify) {
|
|||
return reply.code(409).send({ error: '이미 등록된 운송장입니다' });
|
||||
}
|
||||
|
||||
// 물품명 자동 조회 (CJ대한통운, 한진택배)
|
||||
let goodsName = null;
|
||||
try {
|
||||
goodsName = await fetchGoodsName(carrierId, trackingNumber);
|
||||
} catch (err) {
|
||||
fastify.log.warn(`물품명 조회 실패: ${err.message}`);
|
||||
}
|
||||
|
||||
// 등록
|
||||
const [result] = await fastify.db.execute(
|
||||
'INSERT INTO parcels (carrier_id, carrier_name, tracking_number, label) VALUES (?, ?, ?, ?)',
|
||||
[carrierId, carrier.name, trackingNumber, label || null]
|
||||
'INSERT INTO parcels (carrier_id, carrier_name, tracking_number, label, goods_name) VALUES (?, ?, ?, ?, ?)',
|
||||
[carrierId, carrier.name, trackingNumber, label || goodsName || null, goodsName]
|
||||
);
|
||||
|
||||
const parcelId = result.insertId;
|
||||
|
|
|
|||
62
backend/src/services/goods-name.js
Normal file
62
backend/src/services/goods-name.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* 택배사 웹사이트에서 물품명을 직접 파싱
|
||||
* 지원: CJ대한통운, 한진택배
|
||||
*/
|
||||
|
||||
export async function fetchGoodsName(carrierId, trackingNumber) {
|
||||
try {
|
||||
switch (carrierId) {
|
||||
case 'cjlogistics':
|
||||
return await fetchCjGoodsName(trackingNumber);
|
||||
case 'hanjin':
|
||||
return await fetchHanjinGoodsName(trackingNumber);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCjGoodsName(trackingNumber) {
|
||||
const res = await fetch(
|
||||
'https://trace.cjlogistics.com/next/rest/selectTrackingWaybil.do',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `wblNo=${trackingNumber}`,
|
||||
}
|
||||
);
|
||||
|
||||
const json = await res.json();
|
||||
const goodsName = json?.data?.repGoodsNm;
|
||||
return goodsName?.trim() || null;
|
||||
}
|
||||
|
||||
async function fetchHanjinGoodsName(trackingNumber) {
|
||||
const res = await fetch(
|
||||
'https://www.hanjin.com/kor/CMS/DeliveryMgr/WaybillResult.do',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
wblnum: trackingNumber,
|
||||
mCode: 'MN038',
|
||||
schLang: 'KR',
|
||||
}).toString(),
|
||||
}
|
||||
);
|
||||
|
||||
const html = await res.text();
|
||||
|
||||
// 첫 번째 테이블의 td[4]가 물품명
|
||||
const tdMatches = html.match(/<td[^>]*>([\s\S]*?)<\/td>/g);
|
||||
if (!tdMatches || tdMatches.length < 5) return null;
|
||||
|
||||
const goodsTd = tdMatches[4];
|
||||
const goodsName = goodsTd
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim();
|
||||
|
||||
return goodsName || null;
|
||||
}
|
||||
|
|
@ -174,9 +174,7 @@ CREATE TABLE tracking_events (
|
|||
- [x] 자동갱신 cron 서비스 (node-cron, 30분 간격)
|
||||
- 배송 완료되지 않은 택배만 자동 조회 (DELIVERED 제외)
|
||||
- 상태 변경 시 로그 기록
|
||||
- [ ] 알림 서비스
|
||||
- Discord Webhook 또는 Telegram Bot API
|
||||
- 설정 가능한 Webhook URL (.env)
|
||||
- [ ] 알림 서비스 (보류 — 추후 앱 버전에서 푸시 알림으로 구현 예정)
|
||||
|
||||
### 4단계: Docker 배포
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, Sparkles } from "lucide-react";
|
||||
import { fetchCarriers } from "@/api/parcels";
|
||||
import { getMatchingCarriers } from "@/utils/carrier-pattern";
|
||||
|
||||
function CarrierSelect({ value, onChange }) {
|
||||
function CarrierSelect({ value, onChange, trackingNumber }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
|
|
@ -16,6 +17,23 @@ function CarrierSelect({ value, onChange }) {
|
|||
|
||||
const selected = carriers.find((c) => c.id === value);
|
||||
|
||||
const matchingIds = useMemo(
|
||||
() => getMatchingCarriers(trackingNumber, carriers),
|
||||
[trackingNumber, carriers]
|
||||
);
|
||||
|
||||
const sortedCarriers = useMemo(() => {
|
||||
if (matchingIds.length === 0) return carriers;
|
||||
return [...carriers].sort((a, b) => {
|
||||
const aMatch = matchingIds.indexOf(a.id);
|
||||
const bMatch = matchingIds.indexOf(b.id);
|
||||
if (aMatch !== -1 && bMatch !== -1) return aMatch - bMatch;
|
||||
if (aMatch !== -1) return -1;
|
||||
if (bMatch !== -1) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [carriers, matchingIds]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e) {
|
||||
if (ref.current && !ref.current.contains(e.target)) {
|
||||
|
|
@ -36,7 +54,7 @@ function CarrierSelect({ value, onChange }) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`w-full flex items-center gap-2.5 border rounded-lg px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base text-left transition-colors ${
|
||||
className={`w-full h-[42px] lg:h-[46px] flex items-center gap-2.5 border rounded-lg px-3 lg:px-4 text-sm lg:text-base text-left transition-colors ${
|
||||
open
|
||||
? "border-primary ring-2 ring-primary/30"
|
||||
: "border-gray-300 hover:border-gray-400"
|
||||
|
|
@ -67,22 +85,34 @@ function CarrierSelect({ value, onChange }) {
|
|||
transition={{ duration: 0.15 }}
|
||||
className="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto py-1 origin-top"
|
||||
>
|
||||
{carriers.map((carrier) => (
|
||||
<li key={carrier.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(carrier.id)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base text-left hover:bg-gray-50 transition-colors ${
|
||||
value === carrier.id
|
||||
? "bg-primary/5 text-primary"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<CarrierLogo carrier={carrier} />
|
||||
<span>{carrier.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{sortedCarriers.map((carrier) => {
|
||||
const isMatch = matchingIds.includes(carrier.id);
|
||||
const isSelected = value === carrier.id;
|
||||
return (
|
||||
<li key={carrier.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(carrier.id)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base text-left transition-colors ${
|
||||
isSelected
|
||||
? "bg-primary/5 text-primary"
|
||||
: isMatch
|
||||
? "bg-blue-50/50 text-gray-900"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<CarrierLogo carrier={carrier} />
|
||||
<span>{carrier.name}</span>
|
||||
{isMatch && !isSelected && (
|
||||
<span className="ml-auto text-[11px] text-primary/60 flex items-center gap-0.5">
|
||||
<Sparkles size={10} />
|
||||
추천
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ function ParcelDialog({ parcelId, onClose }) {
|
|||
</span>
|
||||
</div>
|
||||
{(parcel.sender_name || parcel.recipient_name) && (
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400 mt-0.5">
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400 mt-0.5 flex-wrap">
|
||||
{parcel.sender_name && (
|
||||
<span>보내는 분: {parcel.sender_name}</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ function ParcelForm({ onClose }) {
|
|||
<label className="block text-xs lg:text-sm text-gray-500 mb-1">
|
||||
택배사
|
||||
</label>
|
||||
<CarrierSelect value={carrierId} onChange={setCarrierId} />
|
||||
<CarrierSelect value={carrierId} onChange={setCarrierId} trackingNumber={trackingNumber} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -63,7 +63,7 @@ function ParcelForm({ onClose }) {
|
|||
type="text"
|
||||
value={trackingNumber}
|
||||
onChange={(e) => setTrackingNumber(e.target.value)}
|
||||
placeholder="운송장 번호를 입력하세요"
|
||||
placeholder="운송장 번호 입력"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
|
||||
required
|
||||
/>
|
||||
|
|
@ -77,7 +77,7 @@ function ParcelForm({ onClose }) {
|
|||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="예: 쿠팡 - 키보드"
|
||||
placeholder="상품명 입력"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
70
frontend/src/utils/carrier-pattern.js
Normal file
70
frontend/src/utils/carrier-pattern.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* tracking_pattern 형식:
|
||||
* - "13" → 13자리만 매칭
|
||||
* - "10,12" → 10자리 또는 12자리 매칭
|
||||
* - "10-12" → 10~12자리 범위 매칭
|
||||
*/
|
||||
|
||||
function parsePattern(pattern) {
|
||||
if (!pattern) return null;
|
||||
|
||||
if (pattern.includes("-")) {
|
||||
const [min, max] = pattern.split("-").map(Number);
|
||||
return { type: "range", min, max };
|
||||
}
|
||||
|
||||
if (pattern.includes(",")) {
|
||||
const lengths = pattern.split(",").map(Number);
|
||||
return { type: "list", lengths };
|
||||
}
|
||||
|
||||
return { type: "exact", length: Number(pattern) };
|
||||
}
|
||||
|
||||
function matchesPattern(parsed, numLength) {
|
||||
if (!parsed) return false;
|
||||
|
||||
switch (parsed.type) {
|
||||
case "range":
|
||||
return numLength >= parsed.min && numLength <= parsed.max;
|
||||
case "list":
|
||||
return parsed.lengths.includes(numLength);
|
||||
case "exact":
|
||||
return numLength === parsed.length;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function patternSpecificity(parsed, numLength) {
|
||||
if (!parsed) return 0;
|
||||
|
||||
switch (parsed.type) {
|
||||
case "exact":
|
||||
return 5;
|
||||
case "list":
|
||||
return 3;
|
||||
case "range":
|
||||
return 1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMatchingCarriers(trackingNumber, carriers) {
|
||||
if (!trackingNumber || !carriers?.length) return [];
|
||||
|
||||
const num = trackingNumber.replace(/\s/g, "");
|
||||
if (num.length < 10 || !/^\d+$/.test(num)) return [];
|
||||
|
||||
return carriers
|
||||
.map((carrier) => {
|
||||
const parsed = parsePattern(carrier.tracking_pattern);
|
||||
const matches = matchesPattern(parsed, num.length);
|
||||
const specificity = patternSpecificity(parsed, num.length);
|
||||
return { id: carrier.id, matches, specificity };
|
||||
})
|
||||
.filter((c) => c.matches)
|
||||
.sort((a, b) => b.specificity - a.specificity)
|
||||
.map((c) => c.id);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue