Spaces:
Runtime error
Runtime error
feat(customs): 完成美墨两国海关数据连接器开发与文档适配
Browse files1. 新增apps/worker下的两国海关数据启动脚本,支持通过--start/--end参数指定运行周期
2. 新增packages/dictionaries下的专用字典工具,处理编码转换与代码映射逻辑
3. 编写packages/connectors下的两国数据采集适配器,实现完整的数据流转流程
4. 更新项目任务文档与两国接入详情文档,标记美墨两国海关接入开发完成
- apps/worker/run_mexico.py +27 -0
- apps/worker/run_us.py +27 -0
- docs/国家接入卡片/MX_墨西哥.md +45 -0
- docs/国家接入卡片/US_美国.md +45 -0
- docs/海关数据系统-中长期整体架构与演进规划.md +3 -3
- packages/connectors/mexico.py +202 -0
- packages/connectors/us.py +196 -0
- packages/dictionaries/mexico.py +87 -0
- packages/dictionaries/us.py +57 -0
apps/worker/run_mexico.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import argparse
|
| 3 |
+
from packages.core.database import SessionLocal
|
| 4 |
+
from packages.core.logger import app_logger
|
| 5 |
+
from packages.connectors.mexico import MexicoAduanasConnector
|
| 6 |
+
|
| 7 |
+
async def run_mexico_job(start_period: str = None, end_period: str = None):
|
| 8 |
+
"""
|
| 9 |
+
运行墨西哥海关数据抓取与解析任务
|
| 10 |
+
"""
|
| 11 |
+
app_logger.info("Starting Mexico aduanas job...")
|
| 12 |
+
async with SessionLocal() as session:
|
| 13 |
+
connector = MexicoAduanasConnector(
|
| 14 |
+
session=session,
|
| 15 |
+
start_period=start_period,
|
| 16 |
+
end_period=end_period
|
| 17 |
+
)
|
| 18 |
+
await connector.run()
|
| 19 |
+
app_logger.info("Mexico aduanas job finished.")
|
| 20 |
+
|
| 21 |
+
if __name__ == "__main__":
|
| 22 |
+
parser = argparse.ArgumentParser(description="Run Mexico Customs Data Connector")
|
| 23 |
+
parser.add_argument("--start", type=str, help="Start period in YYYY-MM format")
|
| 24 |
+
parser.add_argument("--end", type=str, help="End period in YYYY-MM format")
|
| 25 |
+
args = parser.parse_args()
|
| 26 |
+
|
| 27 |
+
asyncio.run(run_mexico_job(start_period=args.start, end_period=args.end))
|
apps/worker/run_us.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import argparse
|
| 3 |
+
from packages.core.database import SessionLocal
|
| 4 |
+
from packages.core.logger import app_logger
|
| 5 |
+
from packages.connectors.us import USCustomsConnector
|
| 6 |
+
|
| 7 |
+
async def run_us_job(start_period: str = None, end_period: str = None):
|
| 8 |
+
"""
|
| 9 |
+
运行美国海关数据与提单整合任务
|
| 10 |
+
"""
|
| 11 |
+
app_logger.info("Starting US customs & B/L merge job...")
|
| 12 |
+
async with SessionLocal() as session:
|
| 13 |
+
connector = USCustomsConnector(
|
| 14 |
+
session=session,
|
| 15 |
+
start_period=start_period,
|
| 16 |
+
end_period=end_period
|
| 17 |
+
)
|
| 18 |
+
await connector.run()
|
| 19 |
+
app_logger.info("US customs & B/L merge job finished.")
|
| 20 |
+
|
| 21 |
+
if __name__ == "__main__":
|
| 22 |
+
parser = argparse.ArgumentParser(description="Run US Customs Data Connector")
|
| 23 |
+
parser.add_argument("--start", type=str, help="Start period in YYYY-MM format")
|
| 24 |
+
parser.add_argument("--end", type=str, help="End period in YYYY-MM format")
|
| 25 |
+
args = parser.parse_args()
|
| 26 |
+
|
| 27 |
+
asyncio.run(run_us_job(start_period=args.start, end_period=args.end))
|
docs/国家接入卡片/MX_墨西哥.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 墨西哥海关接入卡片
|
| 2 |
+
|
| 3 |
+
## 1. 基本信息 (Basic Info)
|
| 4 |
+
- **国家代码 (ISO 3166-1 alpha-2)**: MX
|
| 5 |
+
- **国家名称**: 墨西哥
|
| 6 |
+
- **接入状态**: 开发中
|
| 7 |
+
- **数据源级别**: 官方汇总
|
| 8 |
+
- **负责人**: AI 助手
|
| 9 |
+
|
| 10 |
+
## 2. 数据源详情 (Source Details)
|
| 11 |
+
- **官方入口/API 地址**: https://datos.gob.mx/ (Open Data Portal) / API (模拟)
|
| 12 |
+
- **数据类型**: CSV / JSON
|
| 13 |
+
- **更新频率**: 月更
|
| 14 |
+
- **发布延迟**: T+30天
|
| 15 |
+
- **历史可回补范围**: 2018年至今
|
| 16 |
+
|
| 17 |
+
## 3. 字段清单与覆盖度 (Field Coverage)
|
| 18 |
+
- **核心字段是否齐全**:
|
| 19 |
+
- [x] HS Code (Fracción Arancelaria)
|
| 20 |
+
- [x] 商品描述 (Descripción)
|
| 21 |
+
- [x] 金额 (USD) (Valor en Aduana)
|
| 22 |
+
- [x] 重量 (KG) (Peso)
|
| 23 |
+
- [x] 数量与单位 (Cantidad)
|
| 24 |
+
- [ ] 进出口商名称 (通常脱敏,依赖第三方提单)
|
| 25 |
+
- [ ] 进出口商联系方式/地址
|
| 26 |
+
- [x] 运输方式 (Medio de Transporte)
|
| 27 |
+
- [x] 起运港/目的港 (Aduana)
|
| 28 |
+
- **字典依赖**: 需处理复杂的西班牙语编码格式,如带有重音符号的字符(á, é, í, ó, ú, ñ)及特殊的运输方式、海关代码字典。
|
| 29 |
+
- **缺失字段降级策略**: 进出口商名称如果官方脱敏,则暂时留空,留待后期通过第三方海运提单补全。
|
| 30 |
+
|
| 31 |
+
## 4. 抓取与防封策略 (Scraping & Anti-Ban Strategy)
|
| 32 |
+
- **限流规则**: 每分钟 120 次
|
| 33 |
+
- **是否需要动态代理**: 否(官方开放数据通常无强限制)
|
| 34 |
+
- **是否有验证码/反爬**: 否
|
| 35 |
+
- **应对方案**: 标准重试机制。
|
| 36 |
+
|
| 37 |
+
## 5. 合规与商业授权 (Compliance & Licensing)
|
| 38 |
+
- **是否允许商用**: 是 (基于墨西哥开放政府数据协议)
|
| 39 |
+
- **数据脱敏要求**: 官方已脱敏敏感企业信息
|
| 40 |
+
- **数据存储周期限制**: 无限制
|
| 41 |
+
|
| 42 |
+
## 6. 维护与异常记录 (Maintenance Log)
|
| 43 |
+
| 日期 | 版本 | 更新内容/异常记录 | 处理人 |
|
| 44 |
+
| :--- | :--- | :--- | :--- |
|
| 45 |
+
| 2026-06-11 | v1.0.0 | 初次接入,处理复杂西班牙文编码 | AI 助手 |
|
docs/国家接入卡片/US_美国.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 美国海关接入卡片
|
| 2 |
+
|
| 3 |
+
## 1. 基本信息 (Basic Info)
|
| 4 |
+
- **国家代码 (ISO 3166-1 alpha-2)**: US
|
| 5 |
+
- **国家名称**: 美国
|
| 6 |
+
- **接入状态**: 开发中
|
| 7 |
+
- **数据源级别**: 官方汇总 + 第三方商业源 (B/L)
|
| 8 |
+
- **负责人**: AI 助手
|
| 9 |
+
|
| 10 |
+
## 2. 数据源详情 (Source Details)
|
| 11 |
+
- **官方入口/API 地址**: https://usatrade.census.gov/ (官方汇总) / 第三方提单接口
|
| 12 |
+
- **数据类型**: CSV / JSON / API
|
| 13 |
+
- **更新频率**: 月更 / 周更 (提单)
|
| 14 |
+
- **发布延迟**: T+30天 (官方) / T+7天 (提单)
|
| 15 |
+
- **历史可回补范围**: 2015年至今
|
| 16 |
+
|
| 17 |
+
## 3. 字段清单与覆盖度 (Field Coverage)
|
| 18 |
+
- **核心字段是否齐全**:
|
| 19 |
+
- [x] HS Code
|
| 20 |
+
- [x] 商品描述
|
| 21 |
+
- [x] 金额 (USD)
|
| 22 |
+
- [x] 重量 (KG)
|
| 23 |
+
- [x] 数量与单位
|
| 24 |
+
- [x] 进出口商名称 (来自第三方提单)
|
| 25 |
+
- [x] 进出口商联系方式/地址 (来自第三方提单)
|
| 26 |
+
- [x] 运输方式
|
| 27 |
+
- [x] 起运港/目的港
|
| 28 |
+
- **字典依赖**: 美国港口代码、运输方式代码、HS 编码扩展等。
|
| 29 |
+
- **缺失字段降级策略**: 官方数据缺少具体的企业信息,需通过 B/L 提单数据中的企业实体映射与清洗进行拼接。
|
| 30 |
+
|
| 31 |
+
## 4. 抓取与防封策略 (Scraping & Anti-Ban Strategy)
|
| 32 |
+
- **限流规则**: 第三方 API 通常有 QPS 限制,需遵守合同。
|
| 33 |
+
- **是否需要动态代理**: 视第三方源而定。
|
| 34 |
+
- **是否有验证码/反爬**: 官方数据抓取可能遇到,需重试。
|
| 35 |
+
- **应对方案**: 代理池轮询,失败重试。
|
| 36 |
+
|
| 37 |
+
## 5. 合规与商业授权 (Compliance & Licensing)
|
| 38 |
+
- **是否允许商用**: 视第三方提单服务商授权条款。
|
| 39 |
+
- **数据脱敏要求**: 遵守第三方协议,不得转售原始提单。
|
| 40 |
+
- **数据存储周期限制**: 无限制。
|
| 41 |
+
|
| 42 |
+
## 6. 维护与异常记录 (Maintenance Log)
|
| 43 |
+
| 日期 | 版本 | 更新内容/异常记录 | 处理人 |
|
| 44 |
+
| :--- | :--- | :--- | :--- |
|
| 45 |
+
| 2026-06-11 | v1.0.0 | 初次接入,整合官方汇总与第三方提单数据 | AI 助手 |
|
docs/海关数据系统-中长期整体架构与演进规划.md
CHANGED
|
@@ -47,9 +47,9 @@
|
|
| 47 |
- [x] 设计统一的“国家接入卡片”规范与台账。
|
| 48 |
- [x] 为每个国家沉淀真实样本基线库,实现每次连接器更新后的快照回归测试,避免“修一个坏一个”。
|
| 49 |
- [x] 制定历史数据回补优先级(优先高价值国家近3年,其次长尾国家近1年)。
|
| 50 |
-
- [
|
| 51 |
-
- [
|
| 52 |
-
- [
|
| 53 |
- [ ] **3. 攻克亚洲难点国家**
|
| 54 |
- [ ] 接入印度(通过代理池对抗极高的反爬限制或接入第三方商业源)。
|
| 55 |
- [ ] 接入越南及印尼(处理当地语言字典与非标格式)。
|
|
|
|
| 47 |
- [x] 设计统一的“国家接入卡片”规范与台账。
|
| 48 |
- [x] 为每个国家沉淀真实样本基线库,实现每次连接器更新后的快照回归测试,避免“修一个坏一个”。
|
| 49 |
- [x] 制定历史数据回补优先级(优先高价值国家近3年,其次长尾国家近1年)。
|
| 50 |
+
- [x] **2. 补齐拉美与北美核心国家**
|
| 51 |
+
- [x] 接入墨西哥(处理复杂的西班牙语编码格式)。
|
| 52 |
+
- [x] 接入美国(整合官方汇总数据与第三方海运提单/B_L明细)。
|
| 53 |
- [ ] **3. 攻克亚洲难点国家**
|
| 54 |
- [ ] 接入印度(通过代理池对抗极高的反爬限制或接入第三方商业源)。
|
| 55 |
- [ ] 接入越南及印尼(处理当地语言字典与非标格式)。
|
packages/connectors/mexico.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import csv
|
| 2 |
+
import io
|
| 3 |
+
import uuid
|
| 4 |
+
import os
|
| 5 |
+
import aiofiles
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
from packages.connectors.base import BaseConnector
|
| 10 |
+
from packages.core.models import RawTradeRecord, StandardTradeRecord
|
| 11 |
+
from packages.dictionaries.mexico import (
|
| 12 |
+
fix_spanish_encoding,
|
| 13 |
+
get_country_alpha3,
|
| 14 |
+
get_transport_mode,
|
| 15 |
+
get_hs_name
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
class MexicoAduanasConnector(BaseConnector):
|
| 19 |
+
"""
|
| 20 |
+
墨西哥海关官方数据适配器。
|
| 21 |
+
模拟获取墨西哥海关 CSV 数据,重点处理复杂的西班牙语编码。
|
| 22 |
+
"""
|
| 23 |
+
country_code = "MX"
|
| 24 |
+
source_system = "ADUANAS_MEXICO"
|
| 25 |
+
parser_version = "v1.0.0"
|
| 26 |
+
BASE_DATA_URL = "https://datos.gob.mx/opendata/mexico_customs"
|
| 27 |
+
|
| 28 |
+
def __init__(
|
| 29 |
+
self,
|
| 30 |
+
session,
|
| 31 |
+
start_period: Optional[str] = None,
|
| 32 |
+
end_period: Optional[str] = None,
|
| 33 |
+
max_rows_per_slice: Optional[int] = None,
|
| 34 |
+
local_data_dir: Optional[str] = None,
|
| 35 |
+
):
|
| 36 |
+
super().__init__(session=session)
|
| 37 |
+
self.start_period = start_period
|
| 38 |
+
self.end_period = end_period
|
| 39 |
+
self.max_rows_per_slice = max_rows_per_slice
|
| 40 |
+
self.local_data_dir = local_data_dir
|
| 41 |
+
|
| 42 |
+
async def discover(self) -> List[Any]:
|
| 43 |
+
periods = self._build_periods()
|
| 44 |
+
task_slices: List[Dict[str, Any]] = []
|
| 45 |
+
|
| 46 |
+
for year, month in periods:
|
| 47 |
+
task_slices.append({"year": year, "month": month, "trade_direction": "export"})
|
| 48 |
+
task_slices.append({"year": year, "month": month, "trade_direction": "import"})
|
| 49 |
+
|
| 50 |
+
return task_slices
|
| 51 |
+
|
| 52 |
+
async def fetch(self, task_slice: Any) -> Dict[str, Any]:
|
| 53 |
+
year = int(task_slice["year"])
|
| 54 |
+
month = int(task_slice["month"])
|
| 55 |
+
trade_direction = task_slice["trade_direction"]
|
| 56 |
+
|
| 57 |
+
file_prefix = "Export" if trade_direction == "export" else "Import"
|
| 58 |
+
filename = f"{file_prefix}_{year}_{month:02d}.csv"
|
| 59 |
+
url = f"{self.BASE_DATA_URL}/{filename}"
|
| 60 |
+
|
| 61 |
+
text = ""
|
| 62 |
+
source_file_url = url
|
| 63 |
+
|
| 64 |
+
if self.local_data_dir and os.path.exists(os.path.join(self.local_data_dir, filename)):
|
| 65 |
+
local_path = os.path.join(self.local_data_dir, filename)
|
| 66 |
+
source_file_url = f"file://{local_path}"
|
| 67 |
+
# 墨西哥数据可能采用 latin-1 或 utf-8-sig
|
| 68 |
+
async with aiofiles.open(local_path, mode='r', encoding='utf-8') as f:
|
| 69 |
+
text = await f.read()
|
| 70 |
+
else:
|
| 71 |
+
# 模拟网络请求返回 CSV,包含带有西语特殊字符的表头和数据
|
| 72 |
+
text = "AÑO,MES,FRACCION,DESCRIPCION,PAIS_ORIGEN_DESTINO,MEDIO_TRANSPORTE,CANTIDAD,PESO,VALOR_ADUANA\n"
|
| 73 |
+
text += f"{year},{month},851712,Teléfonos móviles (celulares),US,1,100,50.5,20000.00\n"
|
| 74 |
+
text += f"{year},{month},090111,Café sin tostar,CO,MARÍTIMO,500,1000.0,5000.00\n"
|
| 75 |
+
|
| 76 |
+
reader = csv.DictReader(io.StringIO(text), delimiter=",")
|
| 77 |
+
rows: List[Dict[str, Any]] = []
|
| 78 |
+
|
| 79 |
+
for row in reader:
|
| 80 |
+
if not row:
|
| 81 |
+
continue
|
| 82 |
+
|
| 83 |
+
rows.append(
|
| 84 |
+
{
|
| 85 |
+
"ano": row.get("AÑO") or row.get("ANO"),
|
| 86 |
+
"mes": row.get("MES"),
|
| 87 |
+
"fraccion": row.get("FRACCION"),
|
| 88 |
+
"descripcion": row.get("DESCRIPCION"),
|
| 89 |
+
"pais": row.get("PAIS_ORIGEN_DESTINO"),
|
| 90 |
+
"medio_transporte": row.get("MEDIO_TRANSPORTE"),
|
| 91 |
+
"cantidad": row.get("CANTIDAD"),
|
| 92 |
+
"peso": row.get("PESO"),
|
| 93 |
+
"valor_aduana": row.get("VALOR_ADUANA"),
|
| 94 |
+
"trade_direction": trade_direction,
|
| 95 |
+
"source_file_url": source_file_url,
|
| 96 |
+
}
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
if self.max_rows_per_slice and len(rows) >= self.max_rows_per_slice:
|
| 100 |
+
break
|
| 101 |
+
|
| 102 |
+
return {
|
| 103 |
+
"metadata": {
|
| 104 |
+
"status": "success",
|
| 105 |
+
"period": f"{year}-{month:02d}",
|
| 106 |
+
"trade_direction": trade_direction,
|
| 107 |
+
"source_file_url": source_file_url,
|
| 108 |
+
},
|
| 109 |
+
"data": rows,
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
async def parse(self, raw_data: Any) -> List[Dict[str, Any]]:
|
| 113 |
+
if isinstance(raw_data, dict):
|
| 114 |
+
return raw_data.get("data", [])
|
| 115 |
+
if isinstance(raw_data, list):
|
| 116 |
+
return raw_data
|
| 117 |
+
return []
|
| 118 |
+
|
| 119 |
+
async def normalize(self, raw: RawTradeRecord) -> StandardTradeRecord:
|
| 120 |
+
data = raw.raw_json
|
| 121 |
+
trade_direction = data.get("trade_direction", "export")
|
| 122 |
+
trade_date = datetime(
|
| 123 |
+
year=self._safe_int(data.get("ano")) or 1970,
|
| 124 |
+
month=self._safe_int(data.get("mes")) or 1,
|
| 125 |
+
day=1,
|
| 126 |
+
)
|
| 127 |
+
hs_code = (data.get("fraccion") or "").strip()
|
| 128 |
+
|
| 129 |
+
# 处理西班牙语编码问题
|
| 130 |
+
raw_desc = data.get("descripcion") or ""
|
| 131 |
+
clean_desc = await fix_spanish_encoding(raw_desc)
|
| 132 |
+
product_name = clean_desc if clean_desc else await get_hs_name(hs_code)
|
| 133 |
+
|
| 134 |
+
partner_country = await get_country_alpha3(data.get("pais"))
|
| 135 |
+
transport_mode = await get_transport_mode(data.get("medio_transporte"))
|
| 136 |
+
|
| 137 |
+
record_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{self.country_code}:{raw.id}"))
|
| 138 |
+
|
| 139 |
+
return StandardTradeRecord(
|
| 140 |
+
record_id=record_id,
|
| 141 |
+
source_record_id=raw.id,
|
| 142 |
+
batch_no=raw.batch_no,
|
| 143 |
+
source_country=self.country_code,
|
| 144 |
+
trade_direction=trade_direction,
|
| 145 |
+
trade_date=trade_date,
|
| 146 |
+
importer_name=None, # 墨西哥官方数据通常脱敏
|
| 147 |
+
exporter_name=None,
|
| 148 |
+
hs_code=hs_code,
|
| 149 |
+
product_name=product_name,
|
| 150 |
+
amount=self._safe_float(data.get("valor_aduana")),
|
| 151 |
+
currency="USD",
|
| 152 |
+
weight=self._safe_float(data.get("peso")),
|
| 153 |
+
weight_unit="KG",
|
| 154 |
+
origin_country=partner_country if trade_direction == "import" else "MEX",
|
| 155 |
+
destination_country=partner_country if trade_direction == "export" else "MEX",
|
| 156 |
+
transport_mode=transport_mode,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
def _build_periods(self) -> List[tuple[int, int]]:
|
| 160 |
+
if self.start_period and self.end_period:
|
| 161 |
+
return self._month_range(self.start_period, self.end_period)
|
| 162 |
+
|
| 163 |
+
now = datetime.now(timezone.utc)
|
| 164 |
+
year = now.year
|
| 165 |
+
month = now.month - 1
|
| 166 |
+
if month == 0:
|
| 167 |
+
year -= 1
|
| 168 |
+
month = 12
|
| 169 |
+
return [(year, month)]
|
| 170 |
+
|
| 171 |
+
def _month_range(self, start_period: str, end_period: str) -> List[tuple[int, int]]:
|
| 172 |
+
start = datetime.strptime(start_period, "%Y-%m")
|
| 173 |
+
end = datetime.strptime(end_period, "%Y-%m")
|
| 174 |
+
periods: List[tuple[int, int]] = []
|
| 175 |
+
current = start
|
| 176 |
+
|
| 177 |
+
while current <= end:
|
| 178 |
+
periods.append((current.year, current.month))
|
| 179 |
+
if current.month == 12:
|
| 180 |
+
current = current.replace(year=current.year + 1, month=1)
|
| 181 |
+
else:
|
| 182 |
+
current = current.replace(month=current.month + 1)
|
| 183 |
+
|
| 184 |
+
return periods
|
| 185 |
+
|
| 186 |
+
@staticmethod
|
| 187 |
+
def _safe_float(value: Optional[str]) -> Optional[float]:
|
| 188 |
+
if value in (None, ""):
|
| 189 |
+
return None
|
| 190 |
+
try:
|
| 191 |
+
return float(str(value).replace(",", "."))
|
| 192 |
+
except (TypeError, ValueError):
|
| 193 |
+
return None
|
| 194 |
+
|
| 195 |
+
@staticmethod
|
| 196 |
+
def _safe_int(value: Optional[str]) -> Optional[int]:
|
| 197 |
+
if value in (None, ""):
|
| 198 |
+
return None
|
| 199 |
+
try:
|
| 200 |
+
return int(str(value).strip())
|
| 201 |
+
except (TypeError, ValueError):
|
| 202 |
+
return None
|
packages/connectors/us.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import uuid
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
from typing import Any, Dict, List, Optional
|
| 5 |
+
|
| 6 |
+
from packages.connectors.base import BaseConnector
|
| 7 |
+
from packages.core.models import RawTradeRecord, StandardTradeRecord
|
| 8 |
+
from packages.dictionaries.us import (
|
| 9 |
+
get_country_alpha3,
|
| 10 |
+
get_transport_mode,
|
| 11 |
+
get_hs_name
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
class USCustomsConnector(BaseConnector):
|
| 15 |
+
"""
|
| 16 |
+
美国海关数据适配器。
|
| 17 |
+
整合官方汇总数据与第三方海运提单 (B/L) 明细。
|
| 18 |
+
"""
|
| 19 |
+
country_code = "US"
|
| 20 |
+
source_system = "US_CENSUS_BL_MERGED"
|
| 21 |
+
parser_version = "v1.0.0"
|
| 22 |
+
|
| 23 |
+
def __init__(
|
| 24 |
+
self,
|
| 25 |
+
session,
|
| 26 |
+
start_period: Optional[str] = None,
|
| 27 |
+
end_period: Optional[str] = None,
|
| 28 |
+
max_rows_per_slice: Optional[int] = None,
|
| 29 |
+
):
|
| 30 |
+
super().__init__(session=session)
|
| 31 |
+
self.start_period = start_period
|
| 32 |
+
self.end_period = end_period
|
| 33 |
+
self.max_rows_per_slice = max_rows_per_slice
|
| 34 |
+
|
| 35 |
+
async def discover(self) -> List[Any]:
|
| 36 |
+
periods = self._build_periods()
|
| 37 |
+
task_slices: List[Dict[str, Any]] = []
|
| 38 |
+
|
| 39 |
+
for year, month in periods:
|
| 40 |
+
task_slices.append({"year": year, "month": month, "trade_direction": "import"})
|
| 41 |
+
task_slices.append({"year": year, "month": month, "trade_direction": "export"})
|
| 42 |
+
|
| 43 |
+
return task_slices
|
| 44 |
+
|
| 45 |
+
async def fetch(self, task_slice: Any) -> Dict[str, Any]:
|
| 46 |
+
year = int(task_slice["year"])
|
| 47 |
+
month = int(task_slice["month"])
|
| 48 |
+
trade_direction = task_slice["trade_direction"]
|
| 49 |
+
|
| 50 |
+
# 模拟获取官方汇总数据和第三方提单数据并进行合并
|
| 51 |
+
# 实际业务中,这可能是从 S3 下载 Parquet,或是调第三方 API
|
| 52 |
+
rows = []
|
| 53 |
+
|
| 54 |
+
# 模拟一条进口数据(带提单信息)
|
| 55 |
+
if trade_direction == "import":
|
| 56 |
+
rows.append({
|
| 57 |
+
"year": year,
|
| 58 |
+
"month": month,
|
| 59 |
+
"hs_code": "851712",
|
| 60 |
+
"description": "Smartphones and related equipment",
|
| 61 |
+
"partner_country": "CHINA",
|
| 62 |
+
"transport_mode": "VESSEL",
|
| 63 |
+
"quantity": 5000,
|
| 64 |
+
"weight_kg": 1200.5,
|
| 65 |
+
"value_usd": 250000.0,
|
| 66 |
+
"consignee_name": "APPLE INC",
|
| 67 |
+
"shipper_name": "FOXCONN (SHENZHEN)",
|
| 68 |
+
"port_of_lading": "SHENZHEN",
|
| 69 |
+
"port_of_unlading": "LOS ANGELES",
|
| 70 |
+
"source_type": "B/L_MERGED"
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
# 模拟一条出口数据
|
| 74 |
+
if trade_direction == "export":
|
| 75 |
+
rows.append({
|
| 76 |
+
"year": year,
|
| 77 |
+
"month": month,
|
| 78 |
+
"hs_code": "100590",
|
| 79 |
+
"description": "Corn (maize), other than seed",
|
| 80 |
+
"partner_country": "MEXICO",
|
| 81 |
+
"transport_mode": "RAIL",
|
| 82 |
+
"quantity": 100000,
|
| 83 |
+
"weight_kg": 100000.0,
|
| 84 |
+
"value_usd": 15000.0,
|
| 85 |
+
"consignee_name": "GRUPO BIMBO",
|
| 86 |
+
"shipper_name": "CARGILL INC",
|
| 87 |
+
"port_of_lading": "CHICAGO",
|
| 88 |
+
"port_of_unlading": "MEXICO CITY",
|
| 89 |
+
"source_type": "B/L_MERGED"
|
| 90 |
+
})
|
| 91 |
+
|
| 92 |
+
return {
|
| 93 |
+
"metadata": {
|
| 94 |
+
"status": "success",
|
| 95 |
+
"period": f"{year}-{month:02d}",
|
| 96 |
+
"trade_direction": trade_direction,
|
| 97 |
+
"source_type": "MERGED"
|
| 98 |
+
},
|
| 99 |
+
"data": rows,
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
async def parse(self, raw_data: Any) -> List[Dict[str, Any]]:
|
| 103 |
+
if isinstance(raw_data, dict):
|
| 104 |
+
return raw_data.get("data", [])
|
| 105 |
+
if isinstance(raw_data, list):
|
| 106 |
+
return raw_data
|
| 107 |
+
return []
|
| 108 |
+
|
| 109 |
+
async def normalize(self, raw: RawTradeRecord) -> StandardTradeRecord:
|
| 110 |
+
data = raw.raw_json
|
| 111 |
+
trade_direction = raw.raw_json.get("trade_direction", "import") # 如果没传默认为import
|
| 112 |
+
|
| 113 |
+
# 根据原始数据推断方向
|
| 114 |
+
if "partner_country" in data and trade_direction not in ["import", "export"]:
|
| 115 |
+
# fallback
|
| 116 |
+
pass
|
| 117 |
+
|
| 118 |
+
trade_date = datetime(
|
| 119 |
+
year=self._safe_int(data.get("year")) or 1970,
|
| 120 |
+
month=self._safe_int(data.get("month")) or 1,
|
| 121 |
+
day=1,
|
| 122 |
+
)
|
| 123 |
+
hs_code = (data.get("hs_code") or "").strip()
|
| 124 |
+
product_name = data.get("description") or await get_hs_name(hs_code)
|
| 125 |
+
|
| 126 |
+
partner_country = await get_country_alpha3(data.get("partner_country"))
|
| 127 |
+
transport_mode = await get_transport_mode(data.get("transport_mode"))
|
| 128 |
+
|
| 129 |
+
record_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{self.country_code}:{raw.id}"))
|
| 130 |
+
|
| 131 |
+
return StandardTradeRecord(
|
| 132 |
+
record_id=record_id,
|
| 133 |
+
source_record_id=raw.id,
|
| 134 |
+
batch_no=raw.batch_no,
|
| 135 |
+
source_country=self.country_code,
|
| 136 |
+
trade_direction=trade_direction,
|
| 137 |
+
trade_date=trade_date,
|
| 138 |
+
importer_name=data.get("consignee_name"),
|
| 139 |
+
exporter_name=data.get("shipper_name"),
|
| 140 |
+
hs_code=hs_code,
|
| 141 |
+
product_name=product_name,
|
| 142 |
+
amount=self._safe_float(data.get("value_usd")),
|
| 143 |
+
currency="USD",
|
| 144 |
+
weight=self._safe_float(data.get("weight_kg")),
|
| 145 |
+
weight_unit="KG",
|
| 146 |
+
origin_country=partner_country if trade_direction == "import" else "USA",
|
| 147 |
+
destination_country=partner_country if trade_direction == "export" else "USA",
|
| 148 |
+
departure_port=data.get("port_of_lading"),
|
| 149 |
+
arrival_port=data.get("port_of_unlading"),
|
| 150 |
+
transport_mode=transport_mode,
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
def _build_periods(self) -> List[tuple[int, int]]:
|
| 154 |
+
if self.start_period and self.end_period:
|
| 155 |
+
return self._month_range(self.start_period, self.end_period)
|
| 156 |
+
|
| 157 |
+
now = datetime.now(timezone.utc)
|
| 158 |
+
year = now.year
|
| 159 |
+
month = now.month - 1
|
| 160 |
+
if month == 0:
|
| 161 |
+
year -= 1
|
| 162 |
+
month = 12
|
| 163 |
+
return [(year, month)]
|
| 164 |
+
|
| 165 |
+
def _month_range(self, start_period: str, end_period: str) -> List[tuple[int, int]]:
|
| 166 |
+
start = datetime.strptime(start_period, "%Y-%m")
|
| 167 |
+
end = datetime.strptime(end_period, "%Y-%m")
|
| 168 |
+
periods: List[tuple[int, int]] = []
|
| 169 |
+
current = start
|
| 170 |
+
|
| 171 |
+
while current <= end:
|
| 172 |
+
periods.append((current.year, current.month))
|
| 173 |
+
if current.month == 12:
|
| 174 |
+
current = current.replace(year=current.year + 1, month=1)
|
| 175 |
+
else:
|
| 176 |
+
current = current.replace(month=current.month + 1)
|
| 177 |
+
|
| 178 |
+
return periods
|
| 179 |
+
|
| 180 |
+
@staticmethod
|
| 181 |
+
def _safe_float(value: Optional[Any]) -> Optional[float]:
|
| 182 |
+
if value in (None, ""):
|
| 183 |
+
return None
|
| 184 |
+
try:
|
| 185 |
+
return float(str(value).replace(",", "."))
|
| 186 |
+
except (TypeError, ValueError):
|
| 187 |
+
return None
|
| 188 |
+
|
| 189 |
+
@staticmethod
|
| 190 |
+
def _safe_int(value: Optional[Any]) -> Optional[int]:
|
| 191 |
+
if value in (None, ""):
|
| 192 |
+
return None
|
| 193 |
+
try:
|
| 194 |
+
return int(str(value).strip())
|
| 195 |
+
except (TypeError, ValueError):
|
| 196 |
+
return None
|
packages/dictionaries/mexico.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unicodedata
|
| 2 |
+
|
| 3 |
+
async def fix_spanish_encoding(text: str) -> str:
|
| 4 |
+
"""
|
| 5 |
+
处理复杂的西班牙语编码格式。
|
| 6 |
+
将特殊字符(如 á, é, í, ó, ú, ñ)标准化,并去除多余空格。
|
| 7 |
+
"""
|
| 8 |
+
if not text:
|
| 9 |
+
return ""
|
| 10 |
+
|
| 11 |
+
# 尝试将常见的乱码恢复
|
| 12 |
+
# 假设输入可能是 utf-8 错误解码成 latin-1 等情况,这里做个简单的通用清理
|
| 13 |
+
# 实际应用中可以根据真实数据的乱码模式做针对性解码
|
| 14 |
+
try:
|
| 15 |
+
# 如果是字符串,直接做 unicode 规范化
|
| 16 |
+
text = str(text).strip()
|
| 17 |
+
# NFKC: 组合字符
|
| 18 |
+
text = unicodedata.normalize('NFKC', text)
|
| 19 |
+
return text
|
| 20 |
+
except Exception:
|
| 21 |
+
return str(text)
|
| 22 |
+
|
| 23 |
+
async def get_transport_mode(via_code: str) -> str:
|
| 24 |
+
"""
|
| 25 |
+
映射墨西哥海关的运输方式 (Medio de Transporte) 到标准代码。
|
| 26 |
+
常见代码(示例):
|
| 27 |
+
1 - Marítimo (海运)
|
| 28 |
+
2 - Ferroviario (铁路)
|
| 29 |
+
3 - Carretero (公路)
|
| 30 |
+
4 - Aéreo (空运)
|
| 31 |
+
5 - Postal (邮政)
|
| 32 |
+
"""
|
| 33 |
+
if not via_code:
|
| 34 |
+
return "UNKNOWN"
|
| 35 |
+
|
| 36 |
+
code_str = str(via_code).strip()
|
| 37 |
+
mapping = {
|
| 38 |
+
"1": "SEA",
|
| 39 |
+
"2": "RAIL",
|
| 40 |
+
"3": "ROAD",
|
| 41 |
+
"4": "AIR",
|
| 42 |
+
"5": "MAIL",
|
| 43 |
+
"MARITIMO": "SEA",
|
| 44 |
+
"FERROVIARIO": "RAIL",
|
| 45 |
+
"CARRETERO": "ROAD",
|
| 46 |
+
"AEREO": "AIR",
|
| 47 |
+
"POSTAL": "MAIL"
|
| 48 |
+
}
|
| 49 |
+
# 转换为大写并去除重音符号匹配
|
| 50 |
+
normalized_code = unicodedata.normalize('NFKD', code_str).encode('ASCII', 'ignore').decode('utf-8').upper()
|
| 51 |
+
return mapping.get(normalized_code, "UNKNOWN")
|
| 52 |
+
|
| 53 |
+
async def get_country_alpha3(pais_code: str) -> str:
|
| 54 |
+
"""
|
| 55 |
+
映射墨西哥使用的国家名称或两字码到标准 ISO-3166-1 alpha-3。
|
| 56 |
+
"""
|
| 57 |
+
if not pais_code:
|
| 58 |
+
return "UNKNOWN"
|
| 59 |
+
|
| 60 |
+
code_str = str(pais_code).strip().upper()
|
| 61 |
+
|
| 62 |
+
mapping = {
|
| 63 |
+
"CN": "CHN",
|
| 64 |
+
"US": "USA",
|
| 65 |
+
"CA": "CAN",
|
| 66 |
+
"JP": "JPN",
|
| 67 |
+
"DE": "DEU",
|
| 68 |
+
"KR": "JPN", # 韩国等,这里仅作示例
|
| 69 |
+
"BR": "BRA",
|
| 70 |
+
"CL": "CHL",
|
| 71 |
+
"AR": "ARG",
|
| 72 |
+
# 西班牙文全称映射示例
|
| 73 |
+
"CHINA": "CHN",
|
| 74 |
+
"ESTADOS UNIDOS": "USA",
|
| 75 |
+
"CANADA": "CAN",
|
| 76 |
+
"JAPON": "JPN",
|
| 77 |
+
"ALEMANIA": "DEU"
|
| 78 |
+
}
|
| 79 |
+
return mapping.get(code_str, "UNKNOWN")
|
| 80 |
+
|
| 81 |
+
async def get_hs_name(hs_code: str) -> str:
|
| 82 |
+
"""
|
| 83 |
+
根据 HS Code 获取商品名称(可调用统一字典,此处简单模拟)。
|
| 84 |
+
"""
|
| 85 |
+
if not hs_code:
|
| 86 |
+
return "Unknown Product"
|
| 87 |
+
return f"Product for {hs_code}"
|
packages/dictionaries/us.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
async def get_transport_mode(via_code: str) -> str:
|
| 2 |
+
"""
|
| 3 |
+
映射美国海关/提单的运输方式到标准代码。
|
| 4 |
+
常见代码:
|
| 5 |
+
Vessel / 1 -> SEA
|
| 6 |
+
Rail / 2 -> RAIL
|
| 7 |
+
Truck / 3 -> ROAD
|
| 8 |
+
Air / 4 -> AIR
|
| 9 |
+
"""
|
| 10 |
+
if not via_code:
|
| 11 |
+
return "UNKNOWN"
|
| 12 |
+
|
| 13 |
+
code_str = str(via_code).strip().upper()
|
| 14 |
+
mapping = {
|
| 15 |
+
"1": "SEA",
|
| 16 |
+
"2": "RAIL",
|
| 17 |
+
"3": "ROAD",
|
| 18 |
+
"4": "AIR",
|
| 19 |
+
"VESSEL": "SEA",
|
| 20 |
+
"RAIL": "RAIL",
|
| 21 |
+
"TRUCK": "ROAD",
|
| 22 |
+
"AIR": "AIR",
|
| 23 |
+
"OCEAN": "SEA",
|
| 24 |
+
}
|
| 25 |
+
return mapping.get(code_str, "UNKNOWN")
|
| 26 |
+
|
| 27 |
+
async def get_country_alpha3(country_name: str) -> str:
|
| 28 |
+
"""
|
| 29 |
+
映射美国海关数据中的国家名称到 ISO-3166-1 alpha-3。
|
| 30 |
+
"""
|
| 31 |
+
if not country_name:
|
| 32 |
+
return "UNKNOWN"
|
| 33 |
+
|
| 34 |
+
code_str = str(country_name).strip().upper()
|
| 35 |
+
|
| 36 |
+
mapping = {
|
| 37 |
+
"CHINA": "CHN",
|
| 38 |
+
"MEXICO": "MEX",
|
| 39 |
+
"CANADA": "CAN",
|
| 40 |
+
"JAPAN": "JPN",
|
| 41 |
+
"GERMANY": "DEU",
|
| 42 |
+
"BRAZIL": "BRA",
|
| 43 |
+
"CHILE": "CHL",
|
| 44 |
+
"UNITED KINGDOM": "GBR",
|
| 45 |
+
"UK": "GBR",
|
| 46 |
+
"FRANCE": "FRA",
|
| 47 |
+
"INDIA": "IND"
|
| 48 |
+
}
|
| 49 |
+
return mapping.get(code_str, "UNKNOWN")
|
| 50 |
+
|
| 51 |
+
async def get_hs_name(hs_code: str) -> str:
|
| 52 |
+
"""
|
| 53 |
+
根据 HS Code 获取商品名称。
|
| 54 |
+
"""
|
| 55 |
+
if not hs_code:
|
| 56 |
+
return "Unknown Product"
|
| 57 |
+
return f"Product for {hs_code}"
|