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:
caadiq 2026-03-24 20:14:42 +09:00
parent edc3a2c3d7
commit 6f89d8f0b2
10 changed files with 227 additions and 47 deletions

View file

@ -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('기본 택배사 데이터 초기화 완료');

View file

@ -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,
};

View file

@ -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;
});

View file

@ -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;

View 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;
}

View file

@ -174,9 +174,7 @@ CREATE TABLE tracking_events (
- [x] 자동갱신 cron 서비스 (node-cron, 30분 간격)
- 배송 완료되지 않은 택배만 자동 조회 (DELIVERED 제외)
- 상태 변경 시 로그 기록
- [ ] 알림 서비스
- Discord Webhook 또는 Telegram Bot API
- 설정 가능한 Webhook URL (.env)
- [ ] 알림 서비스 (보류 — 추후 앱 버전에서 푸시 알림으로 구현 예정)
### 4단계: Docker 배포

View file

@ -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>

View file

@ -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>
)}

View file

@ -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>

View 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);
}