Skip to content

Commit 142f410

Browse files
committed
carrier: Add kr.ltl
1 parent d4e9b56 commit 142f410

File tree

3 files changed

+246
-0
lines changed

3 files changed

+246
-0
lines changed

packages/core/src/carrier-registry/DefaultCarrierRegistry.ts

+5
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { KyungdongExpress } from "../carriers/kr.kdexp";
1919
import { Kunyoung } from "../carriers/kr.kunyoung";
2020
import { Logen } from "../carriers/kr.logen";
2121
import { LotteGlobalLogistics } from "../carriers/kr.lotte";
22+
import { LTL } from "../carriers/kr.ltl";
2223
import { SLX } from "../carriers/kr.slx";
2324
import { TodayPickup } from "../carriers/kr.todaypickup";
2425
import { EMS } from "../carriers/un.upu.ems";
@@ -72,6 +73,7 @@ class DefaultCarrierRegistry implements CarrierRegistry {
7273
await this.register(new Logen());
7374
await this.register(new LotteGlobalLogistics());
7475
await this.register(new LotteGlobal());
76+
await this.register(new LTL());
7577
await this.register(new SLX());
7678
await this.register(new CarrierAlias("kr.swgexp.epost", new KoreaPost()));
7779
await this.register(
@@ -91,6 +93,9 @@ class DefaultCarrierRegistry implements CarrierRegistry {
9193
"de.dhl": {
9294
enabled: false,
9395
},
96+
"kr.ltl": {
97+
enabled: false,
98+
},
9499
"us.fedex": {
95100
enabled: false,
96101
},
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { z } from "zod";
2+
3+
const SearchInvoiceInfoResponseTrackDataListItemSchema = z.object({
4+
icon: z.string(),
5+
status: z.string(),
6+
status_text: z.string(),
7+
time: z.string(),
8+
type: z.number(),
9+
});
10+
11+
const SearchInvoiceInfoResponseTrackDataSchema = z.object({
12+
express_status: z.number(),
13+
list: z.array(SearchInvoiceInfoResponseTrackDataListItemSchema),
14+
name: z.string(),
15+
status: z.number(),
16+
status_text: z.string(),
17+
});
18+
19+
const SearchInvoiceInfoResponseErrorDataSchema = z.object({
20+
err_code: z.number(),
21+
err_msg: z.string(),
22+
});
23+
24+
const SearchInvoiceInfoResponseTrackSchema = z.object({
25+
status: z.literal("100"),
26+
data: SearchInvoiceInfoResponseTrackDataSchema,
27+
});
28+
29+
const SearchInvoiceInfoResponseErrorSchema = z.object({
30+
status: z.literal("101"),
31+
data: SearchInvoiceInfoResponseErrorDataSchema,
32+
});
33+
34+
const SearchInvoiceInfoResponseSchema = z.discriminatedUnion("status", [
35+
SearchInvoiceInfoResponseTrackSchema,
36+
SearchInvoiceInfoResponseErrorSchema,
37+
]);
38+
39+
export {
40+
SearchInvoiceInfoResponseSchema,
41+
SearchInvoiceInfoResponseErrorSchema,
42+
SearchInvoiceInfoResponseErrorDataSchema,
43+
SearchInvoiceInfoResponseTrackSchema,
44+
SearchInvoiceInfoResponseTrackDataSchema,
45+
SearchInvoiceInfoResponseTrackDataListItemSchema,
46+
};
+195
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { type Logger } from "winston";
2+
import { DateTime } from "luxon";
3+
import { type z } from "zod";
4+
import {
5+
Carrier,
6+
type CarrierTrackInput,
7+
type TrackInfo,
8+
type TrackEvent,
9+
TrackEventStatusCode,
10+
type TrackEventStatus,
11+
type CarrierInitInput,
12+
} from "../../core";
13+
import {
14+
BadRequestError,
15+
InternalError,
16+
NotFoundError,
17+
} from "../../core/errors";
18+
import { rootLogger } from "../../logger";
19+
import { type CarrierUpstreamFetcher } from "../../carrier-upstream-fetcher/CarrierUpstreamFetcher";
20+
import * as schema from "./LTLAPISchemas";
21+
22+
const carrierLogger = rootLogger.child({
23+
carrierId: "kr.ltl",
24+
});
25+
26+
interface LTLConfig {
27+
accessId: string;
28+
accessKey: string;
29+
}
30+
31+
class LTL extends Carrier {
32+
readonly carrierId = "kr.ltl";
33+
private config: LTLConfig | null = null;
34+
35+
public async init(
36+
input: CarrierInitInput & { config: LTLConfig }
37+
): Promise<void> {
38+
await super.init(input);
39+
this.config = input.config;
40+
}
41+
42+
public async track(input: CarrierTrackInput): Promise<TrackInfo> {
43+
if (this.config == null) {
44+
throw new Error("LTL is not initialized");
45+
}
46+
47+
return await new LTLScraper(
48+
this.config,
49+
this.upstreamFetcher,
50+
input.trackingNumber
51+
).track();
52+
}
53+
}
54+
55+
class LTLScraper {
56+
private readonly logger: Logger;
57+
58+
constructor(
59+
readonly config: LTLConfig,
60+
readonly upstreamFetcher: CarrierUpstreamFetcher,
61+
readonly trackingNumber: string
62+
) {
63+
this.logger = carrierLogger.child({ trackingNumber });
64+
}
65+
66+
public async track(): Promise<TrackInfo> {
67+
const queryString = new URLSearchParams({
68+
number: this.trackingNumber,
69+
}).toString();
70+
const response = await this.upstreamFetcher.fetch(
71+
`https://api.ltl.kr/api/search-invoice-info?${queryString}`,
72+
{
73+
method: "POST",
74+
headers: [
75+
["accessid", this.config.accessId],
76+
["accesskey", this.config.accessKey],
77+
],
78+
}
79+
);
80+
81+
const searchInvoiceInfoResponseJson: z.infer<
82+
typeof schema.SearchInvoiceInfoResponseSchema
83+
> = await response.json();
84+
this.logger.debug("searchInvoiceInfoResponseJson", {
85+
searchInvoiceInfoResponseJson,
86+
});
87+
88+
if (searchInvoiceInfoResponseJson.status !== "100") {
89+
if (
90+
searchInvoiceInfoResponseJson.data.err_msg === "请输入正确的快递单号"
91+
) {
92+
throw new BadRequestError("정확한 운송장 번호를 입력해주세요");
93+
} else if (searchInvoiceInfoResponseJson.data.err_msg === "单号不存在") {
94+
throw new NotFoundError("운송장 번호가 존재하지 않습니다");
95+
} else {
96+
throw new InternalError(searchInvoiceInfoResponseJson.data.err_msg);
97+
}
98+
}
99+
100+
const safeParseResult =
101+
await schema.SearchInvoiceInfoResponseTrackSchema.strict().safeParseAsync(
102+
searchInvoiceInfoResponseJson
103+
);
104+
if (!safeParseResult.success) {
105+
this.logger.warn("response body parse failed (strict)", {
106+
error: safeParseResult.error,
107+
searchInvoiceInfoResponseJson,
108+
});
109+
}
110+
111+
const events: TrackEvent[] = [];
112+
for (const event of searchInvoiceInfoResponseJson.data.list) {
113+
events.unshift(this.transformEvent(event));
114+
}
115+
116+
return {
117+
events,
118+
sender: null,
119+
recipient: null,
120+
carrierSpecificData: new Map(),
121+
};
122+
}
123+
124+
private transformEvent(
125+
item: z.infer<
126+
typeof schema.SearchInvoiceInfoResponseTrackDataListItemSchema
127+
>
128+
): TrackEvent {
129+
return {
130+
status: this.parseStatus(item.status_text),
131+
time: this.parseTime(item.time),
132+
location: null,
133+
contact: null,
134+
description: item.status ?? null,
135+
carrierSpecificData: new Map(),
136+
};
137+
}
138+
139+
private parseStatus(code: string): TrackEventStatus {
140+
switch (code) {
141+
case "at_pickup":
142+
return {
143+
code: TrackEventStatusCode.InformationReceived,
144+
name: "운송장 등록",
145+
carrierSpecificData: new Map(),
146+
};
147+
case "in_transit":
148+
return {
149+
code: TrackEventStatusCode.InTransit,
150+
name: "운송 중",
151+
carrierSpecificData: new Map(),
152+
};
153+
case "out_for_delivery":
154+
return {
155+
code: TrackEventStatusCode.OutForDelivery,
156+
name: "배송 예정",
157+
carrierSpecificData: new Map(),
158+
};
159+
case "delivered":
160+
return {
161+
code: TrackEventStatusCode.Delivered,
162+
name: "배송 완료",
163+
carrierSpecificData: new Map(),
164+
};
165+
}
166+
167+
this.logger.warn("Unexpected status code", {
168+
code,
169+
});
170+
171+
return {
172+
code: TrackEventStatusCode.Unknown,
173+
name: code ?? null,
174+
carrierSpecificData: new Map(),
175+
};
176+
}
177+
178+
private parseTime(time: string): DateTime | null {
179+
const result = DateTime.fromFormat(time, "yyyy-MM-dd HH:mm:ss", {
180+
zone: "UTC+9",
181+
});
182+
183+
if (!result.isValid) {
184+
this.logger.warn("time parse error", {
185+
inputTime: time,
186+
invalidReason: result.invalidReason,
187+
});
188+
return result;
189+
}
190+
191+
return result;
192+
}
193+
}
194+
195+
export { LTL };

0 commit comments

Comments
 (0)