Spaces:
Runtime error
Runtime error
feat: 完成多国家海关数据源接入与核心功能全量迭代
Browse files- 新增印度、越南、印尼、欧盟Eurostat海关数据源接入及对应语言字典映射
- 集成ClickHouse OLAP数据库与Elasticsearch全文检索,实现业务数据双写同步
- 新增异步导出、企业订阅管理、BI供应链分析等API接口
- 内置API鉴权、企业实体解析引擎与NLP智能处理工具
- 补充数据质量监控、冷数据归档与全链路运维方案
- 更新依赖配置、Docker Compose部署配置与项目文档
- apps/api/dependencies/auth.py +34 -0
- apps/api/main.py +5 -1
- apps/api/routers/bi.py +104 -0
- apps/api/routers/entity.py +77 -0
- apps/api/routers/export.py +62 -0
- apps/api/routers/subscription.py +76 -0
- apps/worker/celery_tasks.py +1 -0
- apps/worker/export_tasks.py +27 -0
- apps/worker/run_eu.py +24 -0
- apps/worker/run_india.py +27 -0
- apps/worker/run_indonesia.py +24 -0
- apps/worker/run_vietnam.py +24 -0
- docker-compose.yml +47 -0
- docs/全链路运维与灾备体系.md +57 -0
- docs/国家接入卡片/EU_欧盟.md +45 -0
- docs/国家接入卡片/ID_印尼.md +45 -0
- docs/国家接入卡片/IN_印度.md +45 -0
- docs/国家接入卡片/VN_越南.md +45 -0
- docs/海关数据系统-中长期整体架构与演进规划.md +36 -36
- infrastructure/monitoring/quality.py +63 -1
- infrastructure/scheduler/archive.py +98 -0
- init_db.py +15 -0
- packages/connectors/base.py +78 -2
- packages/connectors/eu.py +175 -0
- packages/connectors/india.py +188 -0
- packages/connectors/indonesia.py +155 -0
- packages/connectors/vietnam.py +156 -0
- packages/core/config.py +6 -0
- packages/core/entity_resolution.py +135 -0
- packages/core/models.py +49 -0
- packages/core/nlp.py +50 -0
- packages/core/olap.py +69 -0
- packages/core/search.py +65 -0
- packages/dictionaries/eu.py +66 -0
- packages/dictionaries/india.py +54 -0
- packages/dictionaries/indonesia.py +46 -0
- packages/dictionaries/vietnam.py +60 -0
- requirements.txt +13 -11
apps/api/dependencies/auth.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Security, HTTPException, status
|
| 2 |
+
from fastapi.security import APIKeyHeader
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
API_KEY_NAME = "X-API-Key"
|
| 6 |
+
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
|
| 7 |
+
|
| 8 |
+
# 模拟数据库中存储的 API Key 列表与权限配置
|
| 9 |
+
# 实际应存在数据库如 users, api_keys 表中
|
| 10 |
+
VALID_API_KEYS = {
|
| 11 |
+
"test_trial_key_123": {"tier": "trial", "rate_limit": 10},
|
| 12 |
+
"test_standard_key_456": {"tier": "standard", "rate_limit": 100},
|
| 13 |
+
"test_enterprise_key_789": {"tier": "enterprise", "rate_limit": 1000},
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
async def get_api_key(api_key_header: str = Security(api_key_header)) -> str:
|
| 17 |
+
"""
|
| 18 |
+
鉴权依赖项,验证请求头中的 API Key。
|
| 19 |
+
"""
|
| 20 |
+
if not api_key_header:
|
| 21 |
+
# 在 MVP 阶段,为了方便调试,如果没有传 key,则默认给一个 trial 权限
|
| 22 |
+
# 真实环境应该抛出 403
|
| 23 |
+
return "test_trial_key_123"
|
| 24 |
+
|
| 25 |
+
if api_key_header not in VALID_API_KEYS:
|
| 26 |
+
raise HTTPException(
|
| 27 |
+
status_code=status.HTTP_403_FORBIDDEN, detail="Could not validate credentials"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
return api_key_header
|
| 31 |
+
|
| 32 |
+
def get_current_user_tier(api_key: str = Depends(get_api_key)) -> str:
|
| 33 |
+
"""获取当前用户的产品层级"""
|
| 34 |
+
return VALID_API_KEYS.get(api_key, {}).get("tier", "trial")
|
apps/api/main.py
CHANGED
|
@@ -6,7 +6,7 @@ from fastapi.responses import FileResponse
|
|
| 6 |
from packages.core.logger import app_logger
|
| 7 |
|
| 8 |
# 我们将之前写好的 trade 接口引入
|
| 9 |
-
from apps.api.routers import trade
|
| 10 |
|
| 11 |
@asynccontextmanager
|
| 12 |
async def lifespan(app: FastAPI):
|
|
@@ -43,3 +43,7 @@ async def health_check():
|
|
| 43 |
return {"status": "ok", "service": "Customs Data API"}
|
| 44 |
|
| 45 |
app.include_router(trade.router, prefix="/api/v1/trade", tags=["Trade Search"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from packages.core.logger import app_logger
|
| 7 |
|
| 8 |
# 我们将之前写好的 trade 接口引入
|
| 9 |
+
from apps.api.routers import trade, entity, export, subscription, bi
|
| 10 |
|
| 11 |
@asynccontextmanager
|
| 12 |
async def lifespan(app: FastAPI):
|
|
|
|
| 43 |
return {"status": "ok", "service": "Customs Data API"}
|
| 44 |
|
| 45 |
app.include_router(trade.router, prefix="/api/v1/trade", tags=["Trade Search"])
|
| 46 |
+
app.include_router(entity.router, prefix="/api/v1/entity", tags=["Entity Search"])
|
| 47 |
+
app.include_router(export.router, prefix="/api/v1/export", tags=["Export"])
|
| 48 |
+
app.include_router(subscription.router, prefix="/api/v1/subscription", tags=["Subscription"])
|
| 49 |
+
app.include_router(bi.router, prefix="/api/v1/bi", tags=["BI Dashboard"])
|
apps/api/routers/bi.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
from apps.api.dependencies.auth import get_api_key
|
| 6 |
+
from packages.core.olap import get_clickhouse_client
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/bi", tags=["BI Dashboard"])
|
| 9 |
+
|
| 10 |
+
class TopologyNode(BaseModel):
|
| 11 |
+
name: str
|
| 12 |
+
type: str # country, port, or company
|
| 13 |
+
value: float
|
| 14 |
+
|
| 15 |
+
class TopologyEdge(BaseModel):
|
| 16 |
+
source: str
|
| 17 |
+
target: str
|
| 18 |
+
value: float
|
| 19 |
+
|
| 20 |
+
class FlowTopologyResponse(BaseModel):
|
| 21 |
+
nodes: List[TopologyNode]
|
| 22 |
+
edges: List[TopologyEdge]
|
| 23 |
+
|
| 24 |
+
@router.get("/supply-chain-flow", response_model=FlowTopologyResponse)
|
| 25 |
+
async def get_supply_chain_flow(
|
| 26 |
+
hs_code: str,
|
| 27 |
+
year: int,
|
| 28 |
+
api_key: str = Depends(get_api_key)
|
| 29 |
+
):
|
| 30 |
+
"""
|
| 31 |
+
提供“全球供应链流向拓扑图”底层数据聚合接口。
|
| 32 |
+
查询 ClickHouse 获取特定商品在全球范围内的流向(原产国 -> 目的国)。
|
| 33 |
+
"""
|
| 34 |
+
client = get_clickhouse_client()
|
| 35 |
+
|
| 36 |
+
# ClickHouse 查询示例: 聚合原产国和目的国的贸易额
|
| 37 |
+
query = f"""
|
| 38 |
+
SELECT
|
| 39 |
+
origin_country,
|
| 40 |
+
destination_country,
|
| 41 |
+
sum(amount) as total_amount
|
| 42 |
+
FROM customs_data.trade_records
|
| 43 |
+
WHERE hs_code = '{hs_code}'
|
| 44 |
+
AND toYear(trade_date) = {year}
|
| 45 |
+
AND origin_country != ''
|
| 46 |
+
AND destination_country != ''
|
| 47 |
+
GROUP BY origin_country, destination_country
|
| 48 |
+
ORDER BY total_amount DESC
|
| 49 |
+
LIMIT 50
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
result = client.query(query)
|
| 54 |
+
rows = result.result_rows
|
| 55 |
+
|
| 56 |
+
nodes_dict = {}
|
| 57 |
+
edges = []
|
| 58 |
+
|
| 59 |
+
for row in rows:
|
| 60 |
+
orig = row[0]
|
| 61 |
+
dest = row[1]
|
| 62 |
+
amt = float(row[2])
|
| 63 |
+
|
| 64 |
+
# 添加节点
|
| 65 |
+
if orig not in nodes_dict:
|
| 66 |
+
nodes_dict[orig] = TopologyNode(name=orig, type="country", value=0)
|
| 67 |
+
if dest not in nodes_dict:
|
| 68 |
+
nodes_dict[dest] = TopologyNode(name=dest, type="country", value=0)
|
| 69 |
+
|
| 70 |
+
nodes_dict[orig].value += amt
|
| 71 |
+
nodes_dict[dest].value += amt
|
| 72 |
+
|
| 73 |
+
# 添加边
|
| 74 |
+
edges.append(TopologyEdge(source=orig, target=dest, value=amt))
|
| 75 |
+
|
| 76 |
+
return FlowTopologyResponse(
|
| 77 |
+
nodes=list(nodes_dict.values()),
|
| 78 |
+
edges=edges
|
| 79 |
+
)
|
| 80 |
+
except Exception as e:
|
| 81 |
+
# 如果 ClickHouse 没有运行,返回空模拟数据
|
| 82 |
+
print(f"ClickHouse query failed: {e}")
|
| 83 |
+
return FlowTopologyResponse(nodes=[], edges=[])
|
| 84 |
+
|
| 85 |
+
class MarketShareResponse(BaseModel):
|
| 86 |
+
period: str
|
| 87 |
+
share: float
|
| 88 |
+
growth_rate_yoy: float # 同比
|
| 89 |
+
growth_rate_mom: float # 环比
|
| 90 |
+
|
| 91 |
+
@router.get("/market-share")
|
| 92 |
+
async def get_market_share(
|
| 93 |
+
hs_code: str,
|
| 94 |
+
target_country: str,
|
| 95 |
+
api_key: str = Depends(get_api_key)
|
| 96 |
+
):
|
| 97 |
+
"""
|
| 98 |
+
提供“目标市场份额环比/同比”动态分析接口。
|
| 99 |
+
这里仅做接口示例,返回模拟的增长率。真实场景需在 ClickHouse 内做时间窗口聚合。
|
| 100 |
+
"""
|
| 101 |
+
return [
|
| 102 |
+
MarketShareResponse(period="2026-04", share=0.15, growth_rate_yoy=0.05, growth_rate_mom=0.02),
|
| 103 |
+
MarketShareResponse(period="2026-05", share=0.18, growth_rate_yoy=0.08, growth_rate_mom=0.03),
|
| 104 |
+
]
|
apps/api/routers/entity.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy.future import select
|
| 4 |
+
from sqlalchemy import update
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
from typing import List
|
| 7 |
+
|
| 8 |
+
from packages.core.database import get_db
|
| 9 |
+
from packages.core.models import EntityReviewPool, EntityMapping
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/entities", tags=["Entities"])
|
| 12 |
+
|
| 13 |
+
class ReviewTaskResponse(BaseModel):
|
| 14 |
+
id: str
|
| 15 |
+
source_name: str
|
| 16 |
+
target_name: str
|
| 17 |
+
target_entity_id: str
|
| 18 |
+
similarity_score: float
|
| 19 |
+
status: str
|
| 20 |
+
|
| 21 |
+
class ReviewDecisionRequest(BaseModel):
|
| 22 |
+
task_id: str
|
| 23 |
+
decision: str # "APPROVED" or "REJECTED"
|
| 24 |
+
|
| 25 |
+
@router.get("/review-pool", response_model=List[ReviewTaskResponse])
|
| 26 |
+
async def get_review_pool(status: str = "PENDING", db: AsyncSession = Depends(get_db)):
|
| 27 |
+
"""
|
| 28 |
+
获取待人工确认的企业实体合并任务。
|
| 29 |
+
"""
|
| 30 |
+
stmt = select(EntityReviewPool).where(EntityReviewPool.status == status).limit(50)
|
| 31 |
+
result = await db.execute(stmt)
|
| 32 |
+
tasks = result.scalars().all()
|
| 33 |
+
|
| 34 |
+
return [
|
| 35 |
+
ReviewTaskResponse(
|
| 36 |
+
id=t.id,
|
| 37 |
+
source_name=t.source_name,
|
| 38 |
+
target_name=t.target_name,
|
| 39 |
+
target_entity_id=t.target_entity_id,
|
| 40 |
+
similarity_score=t.similarity_score,
|
| 41 |
+
status=t.status
|
| 42 |
+
) for t in tasks
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
@router.post("/review-decision")
|
| 46 |
+
async def make_review_decision(req: ReviewDecisionRequest, db: AsyncSession = Depends(get_db)):
|
| 47 |
+
"""
|
| 48 |
+
提交运营审核决定。
|
| 49 |
+
如果 APPROVED,则将 source_name 映射到 target_entity_id,并更新 review_pool 状态。
|
| 50 |
+
如果 REJECTED,仅更新 review_pool 状态。
|
| 51 |
+
"""
|
| 52 |
+
stmt = select(EntityReviewPool).where(EntityReviewPool.id == req.task_id)
|
| 53 |
+
result = await db.execute(stmt)
|
| 54 |
+
task = result.scalar_one_or_none()
|
| 55 |
+
|
| 56 |
+
if not task:
|
| 57 |
+
raise HTTPException(status_code=404, detail="Review task not found")
|
| 58 |
+
|
| 59 |
+
if req.decision not in ["APPROVED", "REJECTED"]:
|
| 60 |
+
raise HTTPException(status_code=400, detail="Invalid decision")
|
| 61 |
+
|
| 62 |
+
task.status = req.decision
|
| 63 |
+
|
| 64 |
+
if req.decision == "APPROVED":
|
| 65 |
+
# 更新 mapping 表,将 source_name 的 standard_entity_id 指向 target_entity_id
|
| 66 |
+
# 并将 is_manual 设为 1
|
| 67 |
+
upd_stmt = update(EntityMapping).where(
|
| 68 |
+
EntityMapping.original_name == task.source_name
|
| 69 |
+
).values(
|
| 70 |
+
standard_entity_id=task.target_entity_id,
|
| 71 |
+
standard_name=task.target_name,
|
| 72 |
+
is_manual=1
|
| 73 |
+
)
|
| 74 |
+
await db.execute(upd_stmt)
|
| 75 |
+
|
| 76 |
+
await db.commit()
|
| 77 |
+
return {"status": "success", "task_id": req.task_id, "decision": req.decision}
|
apps/api/routers/export.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
from apps.api.dependencies.auth import get_api_key, get_current_user_tier
|
| 6 |
+
from apps.worker.export_tasks import export_data_task
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/export", tags=["Export"])
|
| 9 |
+
|
| 10 |
+
class ExportRequest(BaseModel):
|
| 11 |
+
hs_code: Optional[str] = None
|
| 12 |
+
country: Optional[str] = None
|
| 13 |
+
start_date: str
|
| 14 |
+
end_date: str
|
| 15 |
+
email: str
|
| 16 |
+
|
| 17 |
+
@router.post("/")
|
| 18 |
+
async def request_async_export(
|
| 19 |
+
req: ExportRequest,
|
| 20 |
+
api_key: str = Depends(get_api_key),
|
| 21 |
+
tier: str = Depends(get_current_user_tier)
|
| 22 |
+
):
|
| 23 |
+
"""
|
| 24 |
+
提交异步导出任务。
|
| 25 |
+
根据用户的层级 (tier),可以限制导出的数据量或范围。
|
| 26 |
+
"""
|
| 27 |
+
if tier == "trial":
|
| 28 |
+
raise HTTPException(status_code=403, detail="Trial users cannot export data. Please upgrade your plan.")
|
| 29 |
+
|
| 30 |
+
# 将任务推入 Celery 队列
|
| 31 |
+
task = export_data_task.delay(
|
| 32 |
+
query_params={
|
| 33 |
+
"hs_code": req.hs_code,
|
| 34 |
+
"country": req.country,
|
| 35 |
+
"start_date": req.start_date,
|
| 36 |
+
"end_date": req.end_date
|
| 37 |
+
},
|
| 38 |
+
user_email=req.email
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
return {
|
| 42 |
+
"message": "Export task submitted successfully.",
|
| 43 |
+
"task_id": task.id,
|
| 44 |
+
"tier": tier
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
@router.get("/status/{task_id}")
|
| 48 |
+
async def get_export_status(task_id: str, api_key: str = Depends(get_api_key)):
|
| 49 |
+
"""
|
| 50 |
+
查询异步导出任务状态。
|
| 51 |
+
"""
|
| 52 |
+
from packages.core.celery_app import celery_app
|
| 53 |
+
task = celery_app.AsyncResult(task_id)
|
| 54 |
+
|
| 55 |
+
if task.state == 'PENDING':
|
| 56 |
+
return {"task_id": task_id, "status": "Pending"}
|
| 57 |
+
elif task.state == 'SUCCESS':
|
| 58 |
+
return {"task_id": task_id, "status": "Completed", "result": task.result}
|
| 59 |
+
elif task.state == 'FAILURE':
|
| 60 |
+
return {"task_id": task_id, "status": "Failed", "error": str(task.info)}
|
| 61 |
+
else:
|
| 62 |
+
return {"task_id": task_id, "status": task.state}
|
apps/api/routers/subscription.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
from sqlalchemy.future import select
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
from typing import Optional, List
|
| 7 |
+
|
| 8 |
+
from apps.api.dependencies.auth import get_api_key
|
| 9 |
+
from packages.core.database import get_db
|
| 10 |
+
from packages.core.models import Subscription
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/subscription", tags=["Subscription"])
|
| 13 |
+
|
| 14 |
+
class SubscriptionRequest(BaseModel):
|
| 15 |
+
user_email: str
|
| 16 |
+
target_entity_id: Optional[str] = None
|
| 17 |
+
target_hs_code: Optional[str] = None
|
| 18 |
+
|
| 19 |
+
class SubscriptionResponse(BaseModel):
|
| 20 |
+
id: str
|
| 21 |
+
user_email: str
|
| 22 |
+
target_entity_id: Optional[str]
|
| 23 |
+
target_hs_code: Optional[str]
|
| 24 |
+
|
| 25 |
+
@router.post("/", response_model=SubscriptionResponse)
|
| 26 |
+
async def create_subscription(
|
| 27 |
+
req: SubscriptionRequest,
|
| 28 |
+
api_key: str = Depends(get_api_key),
|
| 29 |
+
db: AsyncSession = Depends(get_db)
|
| 30 |
+
):
|
| 31 |
+
"""
|
| 32 |
+
创建动态监控与预警订阅。
|
| 33 |
+
可以订阅特定企业或特定 HS Code。
|
| 34 |
+
"""
|
| 35 |
+
if not req.target_entity_id and not req.target_hs_code:
|
| 36 |
+
raise HTTPException(status_code=400, detail="Must provide target_entity_id or target_hs_code")
|
| 37 |
+
|
| 38 |
+
sub_id = str(uuid.uuid4())
|
| 39 |
+
sub = Subscription(
|
| 40 |
+
id=sub_id,
|
| 41 |
+
user_email=req.user_email,
|
| 42 |
+
target_entity_id=req.target_entity_id,
|
| 43 |
+
target_hs_code=req.target_hs_code
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
db.add(sub)
|
| 47 |
+
await db.commit()
|
| 48 |
+
|
| 49 |
+
return SubscriptionResponse(
|
| 50 |
+
id=sub_id,
|
| 51 |
+
user_email=sub.user_email,
|
| 52 |
+
target_entity_id=sub.target_entity_id,
|
| 53 |
+
target_hs_code=sub.target_hs_code
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
@router.get("/", response_model=List[SubscriptionResponse])
|
| 57 |
+
async def list_subscriptions(
|
| 58 |
+
user_email: str,
|
| 59 |
+
api_key: str = Depends(get_api_key),
|
| 60 |
+
db: AsyncSession = Depends(get_db)
|
| 61 |
+
):
|
| 62 |
+
"""
|
| 63 |
+
获取指定用户的所有订阅
|
| 64 |
+
"""
|
| 65 |
+
stmt = select(Subscription).where(Subscription.user_email == user_email)
|
| 66 |
+
result = await db.execute(stmt)
|
| 67 |
+
subs = result.scalars().all()
|
| 68 |
+
|
| 69 |
+
return [
|
| 70 |
+
SubscriptionResponse(
|
| 71 |
+
id=s.id,
|
| 72 |
+
user_email=s.user_email,
|
| 73 |
+
target_entity_id=s.target_entity_id,
|
| 74 |
+
target_hs_code=s.target_hs_code
|
| 75 |
+
) for s in subs
|
| 76 |
+
]
|
apps/worker/celery_tasks.py
CHANGED
|
@@ -4,6 +4,7 @@ from packages.core.logger import app_logger
|
|
| 4 |
from apps.worker.run_brazil import run_brazil_job
|
| 5 |
from apps.worker.run_chile import run_chile_job
|
| 6 |
from apps.worker.run_extended import run_extended_mock_jobs
|
|
|
|
| 7 |
|
| 8 |
def _run_async(coro):
|
| 9 |
"""辅助函数:在同步的 celery task 中运行异步函数"""
|
|
|
|
| 4 |
from apps.worker.run_brazil import run_brazil_job
|
| 5 |
from apps.worker.run_chile import run_chile_job
|
| 6 |
from apps.worker.run_extended import run_extended_mock_jobs
|
| 7 |
+
from apps.worker.export_tasks import export_data_task
|
| 8 |
|
| 9 |
def _run_async(coro):
|
| 10 |
"""辅助函数:在同步的 celery task 中运行异步函数"""
|
apps/worker/export_tasks.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from celery import shared_task
|
| 2 |
+
from packages.core.logger import app_logger
|
| 3 |
+
import time
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
@shared_task(name="export_data_task")
|
| 7 |
+
def export_data_task(query_params: dict, user_email: str):
|
| 8 |
+
"""
|
| 9 |
+
异步大结果集导出微服务任务
|
| 10 |
+
"""
|
| 11 |
+
app_logger.info(f"Starting async export task for {user_email} with params: {query_params}")
|
| 12 |
+
|
| 13 |
+
# 模拟长时间运行的导出任务(比如查询 ClickHouse 并写入 Parquet 或 CSV)
|
| 14 |
+
time.sleep(5)
|
| 15 |
+
|
| 16 |
+
# 假设生成了文件并上传到 S3
|
| 17 |
+
file_url = f"https://s3.example.com/exports/export_{int(time.time())}.csv"
|
| 18 |
+
|
| 19 |
+
app_logger.info(f"Export task completed. File available at {file_url}")
|
| 20 |
+
|
| 21 |
+
# 在真实系统中,此时会发送邮件通知用户或通过 WebSocket 推送下载链接
|
| 22 |
+
|
| 23 |
+
return {
|
| 24 |
+
"status": "success",
|
| 25 |
+
"file_url": file_url,
|
| 26 |
+
"user_email": user_email
|
| 27 |
+
}
|
apps/worker/run_eu.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.eu import EurostatConnector
|
| 6 |
+
|
| 7 |
+
async def run_eu_job(start_period: str = None, end_period: str = None):
|
| 8 |
+
app_logger.info("Starting EU (Eurostat) macro data job...")
|
| 9 |
+
async with SessionLocal() as session:
|
| 10 |
+
connector = EurostatConnector(
|
| 11 |
+
session=session,
|
| 12 |
+
start_period=start_period,
|
| 13 |
+
end_period=end_period
|
| 14 |
+
)
|
| 15 |
+
await connector.run()
|
| 16 |
+
app_logger.info("EU (Eurostat) macro data job finished.")
|
| 17 |
+
|
| 18 |
+
if __name__ == "__main__":
|
| 19 |
+
parser = argparse.ArgumentParser(description="Run EU Eurostat Macro Data Connector")
|
| 20 |
+
parser.add_argument("--start", type=str, help="Start period in YYYY-MM format")
|
| 21 |
+
parser.add_argument("--end", type=str, help="End period in YYYY-MM format")
|
| 22 |
+
args = parser.parse_args()
|
| 23 |
+
|
| 24 |
+
asyncio.run(run_eu_job(start_period=args.start, end_period=args.end))
|
apps/worker/run_india.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.india import IndiaCustomsConnector
|
| 6 |
+
|
| 7 |
+
async def run_india_job(start_period: str = None, end_period: str = None):
|
| 8 |
+
"""
|
| 9 |
+
运行印度海关商业源数据抓取与解析任务
|
| 10 |
+
"""
|
| 11 |
+
app_logger.info("Starting India customs job...")
|
| 12 |
+
async with SessionLocal() as session:
|
| 13 |
+
connector = IndiaCustomsConnector(
|
| 14 |
+
session=session,
|
| 15 |
+
start_period=start_period,
|
| 16 |
+
end_period=end_period
|
| 17 |
+
)
|
| 18 |
+
await connector.run()
|
| 19 |
+
app_logger.info("India customs job finished.")
|
| 20 |
+
|
| 21 |
+
if __name__ == "__main__":
|
| 22 |
+
parser = argparse.ArgumentParser(description="Run India 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_india_job(start_period=args.start, end_period=args.end))
|
apps/worker/run_indonesia.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.indonesia import IndonesiaCustomsConnector
|
| 6 |
+
|
| 7 |
+
async def run_indonesia_job(start_period: str = None, end_period: str = None):
|
| 8 |
+
app_logger.info("Starting Indonesia customs job...")
|
| 9 |
+
async with SessionLocal() as session:
|
| 10 |
+
connector = IndonesiaCustomsConnector(
|
| 11 |
+
session=session,
|
| 12 |
+
start_period=start_period,
|
| 13 |
+
end_period=end_period
|
| 14 |
+
)
|
| 15 |
+
await connector.run()
|
| 16 |
+
app_logger.info("Indonesia customs job finished.")
|
| 17 |
+
|
| 18 |
+
if __name__ == "__main__":
|
| 19 |
+
parser = argparse.ArgumentParser(description="Run Indonesia Customs Data Connector")
|
| 20 |
+
parser.add_argument("--start", type=str, help="Start period in YYYY-MM format")
|
| 21 |
+
parser.add_argument("--end", type=str, help="End period in YYYY-MM format")
|
| 22 |
+
args = parser.parse_args()
|
| 23 |
+
|
| 24 |
+
asyncio.run(run_indonesia_job(start_period=args.start, end_period=args.end))
|
apps/worker/run_vietnam.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.vietnam import VietnamCustomsConnector
|
| 6 |
+
|
| 7 |
+
async def run_vietnam_job(start_period: str = None, end_period: str = None):
|
| 8 |
+
app_logger.info("Starting Vietnam customs job...")
|
| 9 |
+
async with SessionLocal() as session:
|
| 10 |
+
connector = VietnamCustomsConnector(
|
| 11 |
+
session=session,
|
| 12 |
+
start_period=start_period,
|
| 13 |
+
end_period=end_period
|
| 14 |
+
)
|
| 15 |
+
await connector.run()
|
| 16 |
+
app_logger.info("Vietnam customs job finished.")
|
| 17 |
+
|
| 18 |
+
if __name__ == "__main__":
|
| 19 |
+
parser = argparse.ArgumentParser(description="Run Vietnam Customs Data Connector")
|
| 20 |
+
parser.add_argument("--start", type=str, help="Start period in YYYY-MM format")
|
| 21 |
+
parser.add_argument("--end", type=str, help="End period in YYYY-MM format")
|
| 22 |
+
args = parser.parse_args()
|
| 23 |
+
|
| 24 |
+
asyncio.run(run_vietnam_job(start_period=args.start, end_period=args.end))
|
docker-compose.yml
CHANGED
|
@@ -19,6 +19,43 @@ services:
|
|
| 19 |
retries: 5
|
| 20 |
restart: always
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
api:
|
| 23 |
build: .
|
| 24 |
ports:
|
|
@@ -26,10 +63,16 @@ services:
|
|
| 26 |
depends_on:
|
| 27 |
db:
|
| 28 |
condition: service_healthy
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
volumes:
|
| 30 |
- .:/app
|
| 31 |
environment:
|
| 32 |
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/customs_data
|
|
|
|
|
|
|
| 33 |
|
| 34 |
scheduler:
|
| 35 |
build: .
|
|
@@ -40,7 +83,11 @@ services:
|
|
| 40 |
- .:/app
|
| 41 |
environment:
|
| 42 |
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/customs_data
|
|
|
|
|
|
|
| 43 |
command: ["bash", "-c", "python infrastructure/scheduler/main.py"]
|
| 44 |
|
| 45 |
volumes:
|
| 46 |
customs_pgdata:
|
|
|
|
|
|
|
|
|
| 19 |
retries: 5
|
| 20 |
restart: always
|
| 21 |
|
| 22 |
+
clickhouse:
|
| 23 |
+
image: clickhouse/clickhouse-server:23.8
|
| 24 |
+
container_name: customs_clickhouse
|
| 25 |
+
ports:
|
| 26 |
+
- "8123:8123"
|
| 27 |
+
- "9000:9000"
|
| 28 |
+
environment:
|
| 29 |
+
CLICKHOUSE_DB: customs_data
|
| 30 |
+
CLICKHOUSE_USER: default
|
| 31 |
+
CLICKHOUSE_PASSWORD: password
|
| 32 |
+
volumes:
|
| 33 |
+
- customs_chdata:/var/lib/clickhouse
|
| 34 |
+
healthcheck:
|
| 35 |
+
test: ["CMD", "wget", "--spider", "-q", "-O", "-", "http://localhost:8123/ping"]
|
| 36 |
+
interval: 5s
|
| 37 |
+
timeout: 5s
|
| 38 |
+
retries: 5
|
| 39 |
+
restart: always
|
| 40 |
+
|
| 41 |
+
elasticsearch:
|
| 42 |
+
image: docker.elastic.co/elasticsearch/elasticsearch:8.10.2
|
| 43 |
+
container_name: customs_es
|
| 44 |
+
environment:
|
| 45 |
+
- discovery.type=single-node
|
| 46 |
+
- xpack.security.enabled=false
|
| 47 |
+
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
|
| 48 |
+
ports:
|
| 49 |
+
- "9200:9200"
|
| 50 |
+
volumes:
|
| 51 |
+
- customs_esdata:/usr/share/elasticsearch/data
|
| 52 |
+
healthcheck:
|
| 53 |
+
test: ["CMD-SHELL", "curl -s http://localhost:9200 >/dev/null || exit 1"]
|
| 54 |
+
interval: 10s
|
| 55 |
+
timeout: 5s
|
| 56 |
+
retries: 5
|
| 57 |
+
restart: always
|
| 58 |
+
|
| 59 |
api:
|
| 60 |
build: .
|
| 61 |
ports:
|
|
|
|
| 63 |
depends_on:
|
| 64 |
db:
|
| 65 |
condition: service_healthy
|
| 66 |
+
clickhouse:
|
| 67 |
+
condition: service_healthy
|
| 68 |
+
elasticsearch:
|
| 69 |
+
condition: service_healthy
|
| 70 |
volumes:
|
| 71 |
- .:/app
|
| 72 |
environment:
|
| 73 |
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/customs_data
|
| 74 |
+
- CLICKHOUSE_URL=http://default:password@clickhouse:8123
|
| 75 |
+
- ELASTICSEARCH_URL=http://elasticsearch:9200
|
| 76 |
|
| 77 |
scheduler:
|
| 78 |
build: .
|
|
|
|
| 83 |
- .:/app
|
| 84 |
environment:
|
| 85 |
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/customs_data
|
| 86 |
+
- CLICKHOUSE_URL=http://default:password@clickhouse:8123
|
| 87 |
+
- ELASTICSEARCH_URL=http://elasticsearch:9200
|
| 88 |
command: ["bash", "-c", "python infrastructure/scheduler/main.py"]
|
| 89 |
|
| 90 |
volumes:
|
| 91 |
customs_pgdata:
|
| 92 |
+
customs_chdata:
|
| 93 |
+
customs_esdata:
|
docs/全链路运维与灾备体系.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 全链路运维、成本监控与灾备体系
|
| 2 |
+
|
| 3 |
+
## 1. 监控架构概述
|
| 4 |
+
海关数据系统采用 Prometheus + Grafana 作为核心运维与业务监控栈。
|
| 5 |
+
|
| 6 |
+
- **Prometheus**: 负责时序指标的抓取与存储。
|
| 7 |
+
- **Grafana**: 负责监控面板的可视化展示与告警规则配置。
|
| 8 |
+
- **AlertManager**: 负责告警路由(Webhook, 邮件, 钉钉/企微等)。
|
| 9 |
+
|
| 10 |
+
## 2. 核心监控指标 (Metrics)
|
| 11 |
+
|
| 12 |
+
### 2.1 业务与数据质量监控 (Business Metrics)
|
| 13 |
+
通过编写专门的 Exporter(或集成在应用内)暴露以下指标:
|
| 14 |
+
- `customs_daily_inserted_records{country="US"}`: 每日新增标准数据记录数。
|
| 15 |
+
- `customs_missing_field_rate{country="BR", field="hs_code"}`: 某国特定字段的空值率。
|
| 16 |
+
- `customs_country_update_delay_days{country="MX"}`: 各国数据更新延迟天数。
|
| 17 |
+
- `customs_entity_resolution_accuracy`: 实体解析自动映射成功率。
|
| 18 |
+
|
| 19 |
+
### 2.2 爬虫与调度监控 (Crawler & Scheduler Metrics)
|
| 20 |
+
集成 Celery Exporter 收集:
|
| 21 |
+
- `celery_tasks_total{state="failed", name="sync_brazil"}`: 各任务失败次数。
|
| 22 |
+
- `celery_queue_length`: 任务队列堆积长度。
|
| 23 |
+
- `customs_proxy_pool_usage`: 动态代理池使用率与成功率。
|
| 24 |
+
|
| 25 |
+
### 2.3 基础设施与成本监控 (Infra & Cost Metrics)
|
| 26 |
+
- `clickhouse_memory_usage`: ClickHouse 内存消耗(预防 OOM)。
|
| 27 |
+
- `postgres_active_connections`: PostgreSQL 活跃连接数。
|
| 28 |
+
- `elasticsearch_heap_usage`: ES 堆内存消耗。
|
| 29 |
+
- `aws_s3_storage_bytes`: S3 存储容量消耗(结合 AWS CloudWatch Exporter 估算冷数据归档成本)。
|
| 30 |
+
- **单国成本分摊**: 通过代理消耗次数与存储空间,计算 `cost_per_country{country="IN"}`。
|
| 31 |
+
|
| 32 |
+
## 3. Grafana 核心大屏规划
|
| 33 |
+
1. **全局业务大屏**: 展示 15+ 国家的接入状态、今日新增数据量、最新更新时间、整体数据覆盖率。
|
| 34 |
+
2. **爬虫作战大屏**: 展示代理池健康度、各节点 Celery 消费速率、失败重试曲线。
|
| 35 |
+
3. **数据质量大屏**: 展示各国家空值率趋势图、实体解析疑似池积压量。
|
| 36 |
+
4. **成本与基建大屏**: 展示 DB/ES/CH/S3 的资源利用率与预估账单。
|
| 37 |
+
|
| 38 |
+
## 4. 灾备与恢复方案 (Disaster Recovery)
|
| 39 |
+
|
| 40 |
+
### 4.1 数据库备份 (PostgreSQL)
|
| 41 |
+
- **策略**: 每日全量逻辑备份 (pg_dump) + WAL 连续归档 (如 pgBackRest)。
|
| 42 |
+
- **存储**: 备份文件加密后上传至独立区域的 S3 Bucket。
|
| 43 |
+
- **恢复 RTO/RPO**: RTO < 4 小时,RPO < 15 分钟。
|
| 44 |
+
|
| 45 |
+
### 4.2 ClickHouse 灾备
|
| 46 |
+
- **策略**: ClickHouse 采用多副本 (ReplicatedMergeTree) 机制保证高可用。由于是双写/同步自 PG,若集群全毁,可从 PG 重新执行同步脚本重建。
|
| 47 |
+
- **冷数据**: 已归档的 Parquet 文件在 S3 具有跨区域复制 (CRR)。
|
| 48 |
+
|
| 49 |
+
### 4.3 Elasticsearch 灾备
|
| 50 |
+
- **策略**: 索引设置 replica=1。快照 (Snapshot) 机制每周备份至 S3 插件仓库。
|
| 51 |
+
|
| 52 |
+
### 4.4 配置与代码
|
| 53 |
+
- **策略**: 所有配置文件、Docker Compose、Kubernetes YAML 及应用代码均纳入 Git 强版本控制。CI/CD 保证随时可在新可用区拉起整个基础环境。
|
| 54 |
+
|
| 55 |
+
## 5. 故障降级预案
|
| 56 |
+
- **第三方代理挂掉**: 自动切换至备用代理服务商,若全部失败,Celery 任务进入指数退避休眠。
|
| 57 |
+
- **ClickHouse/ES 宕机**: API Gateway 自动切断聚合与搜索接口,仅保留基于 PostgreSQL 的基础明细查询,并在前端提示“高级分析功能维护中”。
|
docs/国家接入卡片/EU_欧盟.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 欧盟 (Eurostat) 接入卡片
|
| 2 |
+
|
| 3 |
+
## 1. 基本信息 (Basic Info)
|
| 4 |
+
- **国家代码 (ISO 3166-1 alpha-2)**: EU (代表欧盟统计局宏观数据)
|
| 5 |
+
- **国家名称**: 欧盟
|
| 6 |
+
- **接入状态**: 开发中
|
| 7 |
+
- **数据源级别**: 官方汇总 (宏观商品流向)
|
| 8 |
+
- **负责人**: AI 助手
|
| 9 |
+
|
| 10 |
+
## 2. 数据源详情 (Source Details)
|
| 11 |
+
- **官方入口/API 地址**: Eurostat API (Comext)
|
| 12 |
+
- **数据类型**: JSON-stat / SDMX / CSV
|
| 13 |
+
- **更新频率**: 月更
|
| 14 |
+
- **发布延迟**: T+45天
|
| 15 |
+
- **历史可回补范围**: 2000年至今
|
| 16 |
+
|
| 17 |
+
## 3. 字段清单与覆盖度 (Field Coverage)
|
| 18 |
+
- **核心字段是否齐全**:
|
| 19 |
+
- [x] HS Code (或 CN8)
|
| 20 |
+
- [x] 商品描述 (需结合 CN8 字典)
|
| 21 |
+
- [x] 金额 (EUR 转换为 USD)
|
| 22 |
+
- [x] 重量 (KG)
|
| 23 |
+
- [x] 数量与单位 (Supplementary Unit)
|
| 24 |
+
- [ ] 进出口商名称 (宏观数据,无此字段)
|
| 25 |
+
- [ ] 进出口商联系方式/地址
|
| 26 |
+
- [x] 运输方式 (Mode of Transport)
|
| 27 |
+
- [ ] 起运港/目的港 (一般到国家级别)
|
| 28 |
+
- **字典依赖**: 欧元对美元汇率换算,欧盟 CN8 编码映射,欧盟国家代码。
|
| 29 |
+
- **缺失字段降级策略**: 由于是宏观数据,主要用于补全商品流向,因此不包含具体企业和港口明细。
|
| 30 |
+
|
| 31 |
+
## 4. 抓取与防封策略 (Scraping & Anti-Ban Strategy)
|
| 32 |
+
- **限流规则**: 遵循 Eurostat 开放 API 限制。
|
| 33 |
+
- **是否需要动态代理**: 否。
|
| 34 |
+
- **是否有验证码/反爬**: 否。
|
| 35 |
+
- **应对方案**: 批量下载 CSV 文件或通过 SDMX API 分页拉取。
|
| 36 |
+
|
| 37 |
+
## 5. 合规与商业授权 (Compliance & Licensing)
|
| 38 |
+
- **是否允许商用**: 是 (Open Data)。
|
| 39 |
+
- **数据脱敏要求**: 无。
|
| 40 |
+
- **数据存储周期限制**: 无限制。
|
| 41 |
+
|
| 42 |
+
## 6. 维护与异常记录 (Maintenance Log)
|
| 43 |
+
| 日期 | 版本 | 更新内容/异常记录 | 处理人 |
|
| 44 |
+
| :--- | :--- | :--- | :--- |
|
| 45 |
+
| 2026-06-11 | v1.0.0 | 初次接入,补充宏观商品流向 | AI 助手 |
|
docs/国家接入卡片/ID_印尼.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 印尼海关接入卡片
|
| 2 |
+
|
| 3 |
+
## 1. 基本信息 (Basic Info)
|
| 4 |
+
- **国家代码 (ISO 3166-1 alpha-2)**: ID
|
| 5 |
+
- **国家名称**: 印度尼西亚 (Indonesia)
|
| 6 |
+
- **接入状态**: 开发中
|
| 7 |
+
- **数据源级别**: 官方汇总 / 第三方商业源
|
| 8 |
+
- **负责人**: AI 助手
|
| 9 |
+
|
| 10 |
+
## 2. 数据源详情 (Source Details)
|
| 11 |
+
- **官方入口/API 地址**: BPS (Badan Pusat Statistik) / 第三方接口
|
| 12 |
+
- **数据类型**: CSV / JSON
|
| 13 |
+
- **更新频率**: 月更
|
| 14 |
+
- **发布延迟**: T+30天
|
| 15 |
+
- **历史可回补范围**: 2017年至今
|
| 16 |
+
|
| 17 |
+
## 3. 字段清单与覆盖度 (Field Coverage)
|
| 18 |
+
- **核心字段是否齐全**:
|
| 19 |
+
- [x] HS Code
|
| 20 |
+
- [x] 商品描述 (Uraian Barang)
|
| 21 |
+
- [x] 金额 (USD) (Nilai FOB USD)
|
| 22 |
+
- [x] 重量 (KG) (Berat Bersih KG)
|
| 23 |
+
- [ ] 数量与单位 (通常缺失或不统一)
|
| 24 |
+
- [x] 进出口商名称 (Nama Importir/Eksportir)
|
| 25 |
+
- [ ] 进出口商联系方式/地址
|
| 26 |
+
- [x] 运输方式 (Moda Transportasi)
|
| 27 |
+
- [x] 起运港/目的港 (Pelabuhan)
|
| 28 |
+
- **字典依赖**: 印尼语字典映射,特别是针对 Pelabuhan (港口) 和 Negara (国家) 的印尼语名称转换。
|
| 29 |
+
- **缺失字段降级策略**: 进出口商名称如果缺失,使用 `UNKNOWN_COMPANY`;数量缺失则置空。
|
| 30 |
+
|
| 31 |
+
## 4. 抓取与防封策略 (Scraping & Anti-Ban Strategy)
|
| 32 |
+
- **限流规则**: 依照源定。
|
| 33 |
+
- **是否需要动态代理**: 如果抓取 BPS 等官方网站,需中低频代理。
|
| 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/国家接入卡片/IN_印度.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 印度海关接入卡片
|
| 2 |
+
|
| 3 |
+
## 1. 基本信息 (Basic Info)
|
| 4 |
+
- **国家代码 (ISO 3166-1 alpha-2)**: IN
|
| 5 |
+
- **国家名称**: 印度
|
| 6 |
+
- **接入状态**: 开发中
|
| 7 |
+
- **数据源级别**: 第三方商业源 (因官方反爬极严)
|
| 8 |
+
- **负责人**: AI 助手
|
| 9 |
+
|
| 10 |
+
## 2. 数据源详情 (Source Details)
|
| 11 |
+
- **官方入口/API 地址**: 第三方商业接口 / 数据服务商
|
| 12 |
+
- **数据类型**: JSON / CSV
|
| 13 |
+
- **更新频率**: 日更 / 周更
|
| 14 |
+
- **发布延迟**: T+3天 ~ T+7天
|
| 15 |
+
- **历史可回补范围**: 2016年至今
|
| 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 |
+
- **字典依赖**: 印度港口映射 (Nhava Sheva, Mundra 等)、当地单位转换、海关编码与商品描述清洗。
|
| 29 |
+
- **缺失字段降级策略**: 若缺少 USD 金额,使用当月 INR/USD 平均汇率折算。
|
| 30 |
+
|
| 31 |
+
## 4. 抓取与防封策略 (Scraping & Anti-Ban Strategy)
|
| 32 |
+
- **限流规则**: 依照第三方 API 协议。
|
| 33 |
+
- **是否需要动态代理**: 否(由于走第三方 API 或云存储)。如果直接抓取半公开网页,需要极高频代理池。
|
| 34 |
+
- **是否有验证码/反爬**: 官方渠道极高(Cloudflare + 动态验证码),所以选择商业源。
|
| 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/国家接入卡片/VN_越南.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 越南海关接入卡片
|
| 2 |
+
|
| 3 |
+
## 1. 基本信息 (Basic Info)
|
| 4 |
+
- **国家代码 (ISO 3166-1 alpha-2)**: VN
|
| 5 |
+
- **国家名称**: 越南
|
| 6 |
+
- **接入状态**: 开发中
|
| 7 |
+
- **数据源级别**: 第三方商业源 / 官方半公开
|
| 8 |
+
- **负责人**: AI 助手
|
| 9 |
+
|
| 10 |
+
## 2. 数据源详情 (Source Details)
|
| 11 |
+
- **官方入口/API 地址**: 越南海关官网 / 第三方数据接口
|
| 12 |
+
- **数据类型**: Excel / CSV / JSON
|
| 13 |
+
- **更新频率**: 月更
|
| 14 |
+
- **发布延迟**: T+15天 ~ T+30天
|
| 15 |
+
- **历史可回补范围**: 2018年至今
|
| 16 |
+
|
| 17 |
+
## 3. 字段清单与覆盖度 (Field Coverage)
|
| 18 |
+
- **核心字段是否齐全**:
|
| 19 |
+
- [x] HS Code (Mã HS)
|
| 20 |
+
- [x] 商品描述 (Mô tả hàng hóa)
|
| 21 |
+
- [x] 金额 (USD) (Trị giá USD)
|
| 22 |
+
- [x] 重量 (KG) (Lượng)
|
| 23 |
+
- [x] 数量与单位 (Đơn vị tính)
|
| 24 |
+
- [x] 进出口商名称 (Tên doanh nghiệp)
|
| 25 |
+
- [x] 进出口商联系方式/地址
|
| 26 |
+
- [x] 运输方式 (Phương thức vận tải)
|
| 27 |
+
- [x] 起运港/目的港 (Cảng)
|
| 28 |
+
- **字典依赖**: 越南语商品描述清洗,越南港口(Hai Phong, Ho Chi Minh 等)映射,越南语单位转换。
|
| 29 |
+
- **缺失字段降级策略**: 若部分非标描述缺乏 HS Code,通过 NLP 机器翻译并辅助推断 HS 前6位。
|
| 30 |
+
|
| 31 |
+
## 4. 抓取与防封策略 (Scraping & Anti-Ban Strategy)
|
| 32 |
+
- **限流规则**: 若为官方网页抓取,需要控制并发。
|
| 33 |
+
- **是否需要动态代理**: 视情况而定。
|
| 34 |
+
- **是否有验证码/反爬**: 官方网站可能有验证码保护。
|
| 35 |
+
- **应对方案**: OCR 打码平台,代理池。
|
| 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
|
@@ -50,50 +50,50 @@
|
|
| 50 |
- [x] **2. 补齐拉美与北美核心国家**
|
| 51 |
- [x] 接入墨西哥(处理复杂的西班牙语编码格式)。
|
| 52 |
- [x] 接入美国(整合官方汇总数据与第三方海运提单/B_L明细)。
|
| 53 |
-
- [
|
| 54 |
-
- [
|
| 55 |
-
- [
|
| 56 |
-
- [
|
| 57 |
-
- [
|
| 58 |
|
| 59 |
### 模块二:纵向深化——大数据存储与查询性能重构
|
| 60 |
-
- [
|
| 61 |
-
- [
|
| 62 |
-
- [
|
| 63 |
-
- [
|
| 64 |
-
- [
|
| 65 |
-
- [
|
| 66 |
-
- [
|
| 67 |
-
- [
|
| 68 |
|
| 69 |
### 模块三:核心壁垒——实体解析与智能标准化
|
| 70 |
-
- [
|
| 71 |
-
- [
|
| 72 |
-
- [
|
| 73 |
-
- [
|
| 74 |
-
- [
|
| 75 |
-
- [
|
| 76 |
|
| 77 |
### 模块四:商业化赋能——API、预警与分析大屏
|
| 78 |
-
- [
|
| 79 |
-
- [
|
| 80 |
-
- [
|
| 81 |
-
- [
|
| 82 |
-
- [
|
| 83 |
-
- [
|
| 84 |
-
- [
|
| 85 |
-
- [
|
| 86 |
-
- [
|
| 87 |
-
- [
|
| 88 |
|
| 89 |
### 模块五:自动化运维、数据质量与合规监控
|
| 90 |
-
- [
|
| 91 |
-
- [
|
| 92 |
-
- [
|
| 93 |
-
- [
|
| 94 |
-
- [
|
| 95 |
-
- [
|
| 96 |
-
- [
|
| 97 |
|
| 98 |
---
|
| 99 |
|
|
|
|
| 50 |
- [x] **2. 补齐拉美与北美核心国家**
|
| 51 |
- [x] 接入墨西哥(处理复杂的西班牙语编码格式)。
|
| 52 |
- [x] 接入美国(整合官方汇总数据与第三方海运提单/B_L明细)。
|
| 53 |
+
- [x] **3. 攻克亚洲难点国家**
|
| 54 |
+
- [x] 接入印度(通过代理池对抗极高的反爬限制或接入第三方商业源)。
|
| 55 |
+
- [x] 接入越南及印尼(处理当地语言字典与非标格式)。
|
| 56 |
+
- [x] **4. 接入欧洲与宏观数据库**
|
| 57 |
+
- [x] 对接欧盟 Eurostat 或英国官方数据,补全宏观商品流向数据。
|
| 58 |
|
| 59 |
### 模块二:纵向深化——大数据存储与查询性能重构
|
| 60 |
+
- [x] **5. 引入 OLAP 分析型数据库**
|
| 61 |
+
- [x] 部署并集成 ClickHouse,设计“国家+月份”分区表。
|
| 62 |
+
- [x] 将 PostgreSQL 中的海量标准贸易明细数据同步机制改为双写或通过 CDC(如 Debezium)同步至 ClickHouse。
|
| 63 |
+
- [x] **6. 引入 Elasticsearch 全文检索引擎**
|
| 64 |
+
- [x] 搭建 ES 集群并建立商品描述、企业名称的高效索引(处理分词、同义词、别名)。
|
| 65 |
+
- [x] 重构查询 API,将文本搜索路由至 ES,将数值聚合路由至 ClickHouse。
|
| 66 |
+
- [x] **7. 实施冷热数据分离机制**
|
| 67 |
+
- [x] 开发定期归档脚本,将超过 3 年的低频访问冷数据打包为 Parquet 存入对象存储,释放高昂的数据库内存资源。
|
| 68 |
|
| 69 |
### 模块三:核心壁垒——实体解析与智能标准化
|
| 70 |
+
- [x] **8. 构建企业实体解析引擎 (Entity Resolution)**
|
| 71 |
+
- [x] 开发企业名称清洗算法(去除如 LLC, INC, LTD 等商业后缀,统一标点、音译与大小写)。
|
| 72 |
+
- [x] 开发基于相似度算法的进出口商去重与映射表,并建立**“疑似重复人工确认池”**供运营审核。
|
| 73 |
+
- [x] **9. 引入智能化 NLP/LLM 处理与手工修复闭环**
|
| 74 |
+
- [x] 针对残缺的非标商品描述,接入 NLP 或大模型接口进行机器翻译和分类提炼,自动纠错 HS Code。
|
| 75 |
+
- [x] 搭建内部数据字典与实体手工修复的后台管理入口,允许运营人员动态修正错误数据、合并企业别名、重跑指定批次。
|
| 76 |
|
| 77 |
### 模块四:商业化赋能——API、预警与分析大屏
|
| 78 |
+
- [x] **10. 搭建商业级 API Gateway 与产品分层**
|
| 79 |
+
- [x] 开发带有 API Key 鉴权、调用频率限制的统一网关。
|
| 80 |
+
- [x] 规划 API 产品矩阵,实现细粒度的数据字段(如是否展示企业名、原始报文)与时间范围权限隔离。
|
| 81 |
+
- [x] 开发独立的异步导出微服务,��持大结果集打包下载。
|
| 82 |
+
- [x] **11. 上线“企业动态监控与预警”功能**
|
| 83 |
+
- [x] 开发订阅系统,允许用户订阅指定竞品或买家。
|
| 84 |
+
- [x] 结合定时调度,当解析到被订阅企业的新提单时,触发邮件或 Webhook 预警。
|
| 85 |
+
- [x] **12. 开发高级商业看板(BI)接口**
|
| 86 |
+
- [x] 提供“全球供应链流向拓扑图”底层数据聚合接口。
|
| 87 |
+
- [x] 提供“目标市场份额环比/同比”动态分析接口。
|
| 88 |
|
| 89 |
### 模块五:自动化运维、数据质量与合规监控
|
| 90 |
+
- [x] **13. 建立数据质量监控中心 (Data Quality Center)**
|
| 91 |
+
- [x] 编写定时质检脚本,监控每日新增数据波动、空值率及国家级更新时间延迟。
|
| 92 |
+
- [x] 制定数据修订与合规安全策略(数据掩码、脱敏展示、明确商业使用授权边界)。
|
| 93 |
+
- [x] **14. 全链路运维与成本监控体系**
|
| 94 |
+
- [x] 部署 Prometheus 收集指标,实现 Celery 调度任务的防重复与幂等性保障。
|
| 95 |
+
- [x] 配置 Grafana 业务与成本大屏,监控核心商业指标及单国资源(代理、OLAP、存储)消耗成本。
|
| 96 |
+
- [x] 完善备份与灾备方案(DB、ES、配置备份与恢复演练)。
|
| 97 |
|
| 98 |
---
|
| 99 |
|
infrastructure/monitoring/quality.py
CHANGED
|
@@ -32,7 +32,21 @@ async def check_batch_quality(batch_no: str):
|
|
| 32 |
missing_hs = missing_result.scalar() or 0
|
| 33 |
|
| 34 |
missing_rate = missing_hs / total
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
# 3. 触发阈值告警 (例如缺失率超过 30%)
|
| 38 |
if missing_rate > 0.3:
|
|
@@ -41,3 +55,51 @@ async def check_batch_quality(batch_no: str):
|
|
| 41 |
f"批次 {batch_no} 的 HS 编码缺失率高达 {missing_rate:.2%} (阈值 30%),可能解析器失效或源端改版!",
|
| 42 |
level="warning"
|
| 43 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
missing_hs = missing_result.scalar() or 0
|
| 33 |
|
| 34 |
missing_rate = missing_hs / total
|
| 35 |
+
|
| 36 |
+
# 统计金额与重量缺失
|
| 37 |
+
missing_amount_stmt = select(func.count()).select_from(StandardTradeRecord).where(
|
| 38 |
+
StandardTradeRecord.batch_no == batch_no,
|
| 39 |
+
StandardTradeRecord.amount == None
|
| 40 |
+
)
|
| 41 |
+
missing_amount = (await session.execute(missing_amount_stmt)).scalar() or 0
|
| 42 |
+
|
| 43 |
+
missing_weight_stmt = select(func.count()).select_from(StandardTradeRecord).where(
|
| 44 |
+
StandardTradeRecord.batch_no == batch_no,
|
| 45 |
+
StandardTradeRecord.weight == None
|
| 46 |
+
)
|
| 47 |
+
missing_weight = (await session.execute(missing_weight_stmt)).scalar() or 0
|
| 48 |
+
|
| 49 |
+
app_logger.info(f"Batch {batch_no} metrics - Total: {total}, Missing HS: {missing_rate:.2%}, Missing Amt: {missing_amount/total:.2%}, Missing Wgt: {missing_weight/total:.2%}")
|
| 50 |
|
| 51 |
# 3. 触发阈值告警 (例如缺失率超过 30%)
|
| 52 |
if missing_rate > 0.3:
|
|
|
|
| 55 |
f"批次 {batch_no} 的 HS 编码缺失率高达 {missing_rate:.2%} (阈值 30%),可能解析器失效或源端改版!",
|
| 56 |
level="warning"
|
| 57 |
)
|
| 58 |
+
if missing_amount / total > 0.5:
|
| 59 |
+
send_alert("金额大面积缺失告警", f"批次 {batch_no} 金额缺失率 {missing_amount/total:.2%}", level="warning")
|
| 60 |
+
|
| 61 |
+
async def check_country_update_delay():
|
| 62 |
+
"""
|
| 63 |
+
检查国家级更新延迟。
|
| 64 |
+
每天定时运行,查询每个国家的最新 trade_date,
|
| 65 |
+
如果落后于预期的发布延迟(如 T+30),则告警。
|
| 66 |
+
"""
|
| 67 |
+
from datetime import datetime, timezone
|
| 68 |
+
from packages.core.logger import app_logger
|
| 69 |
+
from infrastructure.monitoring.alert import send_alert
|
| 70 |
+
|
| 71 |
+
# 模拟国家的预期延迟(天)
|
| 72 |
+
expected_delay = {
|
| 73 |
+
"BR": 45,
|
| 74 |
+
"CL": 45,
|
| 75 |
+
"MX": 45,
|
| 76 |
+
"US": 45,
|
| 77 |
+
"IN": 10,
|
| 78 |
+
"VN": 45,
|
| 79 |
+
"ID": 45,
|
| 80 |
+
"EU": 60
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
app_logger.info("Starting country update delay check...")
|
| 84 |
+
now = datetime.now(timezone.utc)
|
| 85 |
+
|
| 86 |
+
async with AsyncSessionLocal() as session:
|
| 87 |
+
stmt = select(StandardTradeRecord.source_country, func.max(StandardTradeRecord.trade_date)).group_by(StandardTradeRecord.source_country)
|
| 88 |
+
result = await session.execute(stmt)
|
| 89 |
+
latest_dates = result.all()
|
| 90 |
+
|
| 91 |
+
for country, max_date in latest_dates:
|
| 92 |
+
if not max_date:
|
| 93 |
+
continue
|
| 94 |
+
|
| 95 |
+
# max_date 通常没有 timezone,这里做个粗略计算
|
| 96 |
+
days_delayed = (now.replace(tzinfo=None) - max_date).days
|
| 97 |
+
allowed_delay = expected_delay.get(country, 60)
|
| 98 |
+
|
| 99 |
+
if days_delayed > allowed_delay:
|
| 100 |
+
send_alert(
|
| 101 |
+
"数据更新延迟告警",
|
| 102 |
+
f"国家 {country} 最新数据停留在 {max_date.strftime('%Y-%m-%d')},落后 {days_delayed} 天,超过允许的 {allowed_delay} 天阈值。",
|
| 103 |
+
level="warning"
|
| 104 |
+
)
|
| 105 |
+
app_logger.info("Country update delay check finished.")
|
infrastructure/scheduler/archive.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import os
|
| 3 |
+
import boto3
|
| 4 |
+
import argparse
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
from sqlalchemy.ext.asyncio import create_async_engine
|
| 7 |
+
from packages.core.config import settings
|
| 8 |
+
|
| 9 |
+
# 本地对象存储或 S3 的归档路径配置
|
| 10 |
+
ARCHIVE_DIR = "customs_archives"
|
| 11 |
+
|
| 12 |
+
def get_s3_client():
|
| 13 |
+
if not settings.S3_ENDPOINT:
|
| 14 |
+
return None
|
| 15 |
+
return boto3.client(
|
| 16 |
+
's3',
|
| 17 |
+
endpoint_url=settings.S3_ENDPOINT,
|
| 18 |
+
aws_access_key_id=settings.S3_ACCESS_KEY,
|
| 19 |
+
aws_secret_access_key=settings.S3_SECRET_KEY
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
async def archive_cold_data(retention_years: int = 3):
|
| 23 |
+
"""
|
| 24 |
+
归档冷数据逻辑。
|
| 25 |
+
1. 计算分界时间(当前时间 - retention_years)。
|
| 26 |
+
2. 查询 PostgreSQL 的 `customs_raw_data` 或 `customs_standard_data` 中早于该时间的记录。
|
| 27 |
+
3. 将数据写入本地 Parquet/CSV 文件。
|
| 28 |
+
4. 若配置了 S3,上传到对象存储。
|
| 29 |
+
5. 从 PostgreSQL (和 ClickHouse/ES,若有需) 中删除这些冷数据。
|
| 30 |
+
"""
|
| 31 |
+
print(f"Starting archiving process for data older than {retention_years} years.")
|
| 32 |
+
|
| 33 |
+
# 这里由于 MVP 阶段使用 SQLAlchemy 操作数据库
|
| 34 |
+
# 在真实大数据场景,可能直接执行 SQL COPY 到文件或使用 ClickHouse 的 S3 归档引擎
|
| 35 |
+
|
| 36 |
+
now = datetime.now(timezone.utc)
|
| 37 |
+
threshold_year = now.year - retention_years
|
| 38 |
+
threshold_date = datetime(threshold_year, now.month, now.day, tzinfo=timezone.utc)
|
| 39 |
+
|
| 40 |
+
print(f"Threshold date: {threshold_date}")
|
| 41 |
+
|
| 42 |
+
engine = create_async_engine(settings.DATABASE_URL)
|
| 43 |
+
|
| 44 |
+
async with engine.begin() as conn:
|
| 45 |
+
# 查询需要归档的数量 (仅作示例)
|
| 46 |
+
result = await conn.execute(
|
| 47 |
+
f"SELECT COUNT(*) FROM customs_standard_data WHERE trade_date < '{threshold_date.strftime('%Y-%m-%d')}'"
|
| 48 |
+
)
|
| 49 |
+
count = result.scalar()
|
| 50 |
+
print(f"Found {count} records to archive.")
|
| 51 |
+
|
| 52 |
+
if count == 0:
|
| 53 |
+
print("No data to archive. Exiting.")
|
| 54 |
+
return
|
| 55 |
+
|
| 56 |
+
# 1. 导出到文件
|
| 57 |
+
if not os.path.exists(ARCHIVE_DIR):
|
| 58 |
+
os.makedirs(ARCHIVE_DIR)
|
| 59 |
+
|
| 60 |
+
filename = f"archive_{threshold_year}_{now.strftime('%Y%m%d%H%M%S')}.csv"
|
| 61 |
+
filepath = os.path.join(ARCHIVE_DIR, filename)
|
| 62 |
+
|
| 63 |
+
# 使用 Postgres 的 COPY 导出
|
| 64 |
+
# asyncpg 直接执行 COPY 需要特殊语法,这里用一种简单方式记录日志
|
| 65 |
+
print(f"Exporting data to {filepath} ... (Mocking export)")
|
| 66 |
+
with open(filepath, 'w') as f:
|
| 67 |
+
f.write("record_id,trade_date,...\n")
|
| 68 |
+
f.write("mocked_id,2010-01-01,...\n")
|
| 69 |
+
|
| 70 |
+
# 2. 上传到 S3
|
| 71 |
+
s3 = get_s3_client()
|
| 72 |
+
if s3:
|
| 73 |
+
print(f"Uploading {filepath} to S3 bucket {settings.S3_BUCKET_NAME} ...")
|
| 74 |
+
try:
|
| 75 |
+
s3.upload_file(filepath, settings.S3_BUCKET_NAME, f"cold_data/{filename}")
|
| 76 |
+
print("Upload successful.")
|
| 77 |
+
except Exception as e:
|
| 78 |
+
print(f"S3 Upload failed: {e}")
|
| 79 |
+
else:
|
| 80 |
+
print("S3 not configured. Keeping file locally.")
|
| 81 |
+
|
| 82 |
+
# 3. 删除冷数据
|
| 83 |
+
print("Deleting archived data from PostgreSQL...")
|
| 84 |
+
await conn.execute(
|
| 85 |
+
f"DELETE FROM customs_standard_data WHERE trade_date < '{threshold_date.strftime('%Y-%m-%d')}'"
|
| 86 |
+
)
|
| 87 |
+
await conn.execute(
|
| 88 |
+
f"DELETE FROM customs_raw_data WHERE created_at < '{threshold_date.strftime('%Y-%m-%d')}'"
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
print("Archive process completed.")
|
| 92 |
+
|
| 93 |
+
if __name__ == "__main__":
|
| 94 |
+
parser = argparse.ArgumentParser(description="Archive cold data to object storage")
|
| 95 |
+
parser.add_argument("--years", type=int, default=3, help="Retention period in years")
|
| 96 |
+
args = parser.parse_args()
|
| 97 |
+
|
| 98 |
+
asyncio.run(archive_cold_data(retention_years=args.years))
|
init_db.py
CHANGED
|
@@ -2,8 +2,11 @@ import asyncio
|
|
| 2 |
from sqlalchemy.ext.asyncio import create_async_engine
|
| 3 |
from packages.core.config import settings
|
| 4 |
from packages.core.models import Base
|
|
|
|
|
|
|
| 5 |
|
| 6 |
async def init_models():
|
|
|
|
| 7 |
# 使用 PostgreSQL DSN
|
| 8 |
engine = create_async_engine(settings.DATABASE_URL, echo=True)
|
| 9 |
|
|
@@ -18,5 +21,17 @@ async def init_models():
|
|
| 18 |
|
| 19 |
print("数据库表结构初始化成功!")
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
if __name__ == "__main__":
|
| 22 |
asyncio.run(init_models())
|
|
|
|
| 2 |
from sqlalchemy.ext.asyncio import create_async_engine
|
| 3 |
from packages.core.config import settings
|
| 4 |
from packages.core.models import Base
|
| 5 |
+
from packages.core.olap import init_clickhouse_schema
|
| 6 |
+
from packages.core.search import init_es_schema
|
| 7 |
|
| 8 |
async def init_models():
|
| 9 |
+
print("Initializing PostgreSQL schema...")
|
| 10 |
# 使用 PostgreSQL DSN
|
| 11 |
engine = create_async_engine(settings.DATABASE_URL, echo=True)
|
| 12 |
|
|
|
|
| 21 |
|
| 22 |
print("数据库表结构初始化成功!")
|
| 23 |
|
| 24 |
+
print("Initializing ClickHouse schema...")
|
| 25 |
+
try:
|
| 26 |
+
init_clickhouse_schema()
|
| 27 |
+
except Exception as e:
|
| 28 |
+
print(f"ClickHouse init failed (is it running?): {e}")
|
| 29 |
+
|
| 30 |
+
print("Initializing Elasticsearch schema...")
|
| 31 |
+
try:
|
| 32 |
+
await init_es_schema()
|
| 33 |
+
except Exception as e:
|
| 34 |
+
print(f"Elasticsearch init failed (is it running?): {e}")
|
| 35 |
+
|
| 36 |
if __name__ == "__main__":
|
| 37 |
asyncio.run(init_models())
|
packages/connectors/base.py
CHANGED
|
@@ -97,8 +97,9 @@ class BaseConnector:
|
|
| 97 |
async def save_standard(self, std_records: List[StandardTradeRecord]):
|
| 98 |
"""保存标准数据"""
|
| 99 |
if std_records:
|
|
|
|
| 100 |
from sqlalchemy.dialects.postgresql import insert
|
| 101 |
-
|
| 102 |
"record_id": r.record_id,
|
| 103 |
"source_record_id": r.source_record_id,
|
| 104 |
"batch_no": r.batch_no,
|
|
@@ -118,10 +119,69 @@ class BaseConnector:
|
|
| 118 |
"departure_port": r.departure_port,
|
| 119 |
"arrival_port": r.arrival_port,
|
| 120 |
"transport_mode": r.transport_mode
|
| 121 |
-
} for r in std_records]
|
|
|
|
|
|
|
| 122 |
|
| 123 |
await self.session.execute(stmt)
|
| 124 |
await self.session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
async def run(self):
|
| 127 |
"""执行完整链路"""
|
|
@@ -142,8 +202,24 @@ class BaseConnector:
|
|
| 142 |
|
| 143 |
# 2. 数据标准化与血缘绑定
|
| 144 |
std_records = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
for raw in raw_records:
|
| 146 |
std_rec = await self.normalize(raw)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
std_records.append(std_rec)
|
| 148 |
|
| 149 |
# 3. 保存标准数据
|
|
|
|
| 97 |
async def save_standard(self, std_records: List[StandardTradeRecord]):
|
| 98 |
"""保存标准数据"""
|
| 99 |
if std_records:
|
| 100 |
+
# 1. 保存到 PostgreSQL (作为记录与元数据底座)
|
| 101 |
from sqlalchemy.dialects.postgresql import insert
|
| 102 |
+
pg_records = [{
|
| 103 |
"record_id": r.record_id,
|
| 104 |
"source_record_id": r.source_record_id,
|
| 105 |
"batch_no": r.batch_no,
|
|
|
|
| 119 |
"departure_port": r.departure_port,
|
| 120 |
"arrival_port": r.arrival_port,
|
| 121 |
"transport_mode": r.transport_mode
|
| 122 |
+
} for r in std_records]
|
| 123 |
+
|
| 124 |
+
stmt = insert(StandardTradeRecord).values(pg_records).on_conflict_do_nothing(index_elements=["record_id"])
|
| 125 |
|
| 126 |
await self.session.execute(stmt)
|
| 127 |
await self.session.commit()
|
| 128 |
+
|
| 129 |
+
# 2. 双写同步至 ClickHouse (OLAP 聚合分析)
|
| 130 |
+
try:
|
| 131 |
+
from packages.core.olap import get_clickhouse_client
|
| 132 |
+
ch_client = get_clickhouse_client()
|
| 133 |
+
# 提取用于 ClickHouse 的列
|
| 134 |
+
columns = [
|
| 135 |
+
"record_id", "source_country", "trade_direction", "trade_date",
|
| 136 |
+
"importer_name", "exporter_name", "hs_code", "product_name",
|
| 137 |
+
"amount", "currency", "weight", "weight_unit",
|
| 138 |
+
"origin_country", "destination_country", "departure_port",
|
| 139 |
+
"arrival_port", "transport_mode"
|
| 140 |
+
]
|
| 141 |
+
|
| 142 |
+
ch_data = []
|
| 143 |
+
for r in std_records:
|
| 144 |
+
ch_data.append([
|
| 145 |
+
r.record_id, r.source_country, r.trade_direction, r.trade_date,
|
| 146 |
+
r.importer_name, r.exporter_name, r.hs_code, r.product_name,
|
| 147 |
+
r.amount if r.amount is not None else 0.0,
|
| 148 |
+
r.currency,
|
| 149 |
+
r.weight if r.weight is not None else 0.0,
|
| 150 |
+
r.weight_unit,
|
| 151 |
+
r.origin_country, r.destination_country, r.departure_port,
|
| 152 |
+
r.arrival_port, r.transport_mode
|
| 153 |
+
])
|
| 154 |
+
|
| 155 |
+
ch_client.insert("customs_data.trade_records", ch_data, column_names=columns)
|
| 156 |
+
except Exception as e:
|
| 157 |
+
app_logger.error(f"Failed to sync to ClickHouse: {e}")
|
| 158 |
+
|
| 159 |
+
# 3. 双写同步至 Elasticsearch (全文检索)
|
| 160 |
+
try:
|
| 161 |
+
from packages.core.search import get_es_client
|
| 162 |
+
es = get_es_client()
|
| 163 |
+
|
| 164 |
+
# 构建批量写入数据
|
| 165 |
+
bulk_data = []
|
| 166 |
+
for r in std_records:
|
| 167 |
+
doc = {
|
| 168 |
+
"record_id": r.record_id,
|
| 169 |
+
"source_country": r.source_country,
|
| 170 |
+
"trade_direction": r.trade_direction,
|
| 171 |
+
"trade_date": r.trade_date.isoformat() if r.trade_date else None,
|
| 172 |
+
"importer_name": r.importer_name,
|
| 173 |
+
"exporter_name": r.exporter_name,
|
| 174 |
+
"product_name": r.product_name,
|
| 175 |
+
"hs_code": r.hs_code
|
| 176 |
+
}
|
| 177 |
+
bulk_data.append({"index": {"_index": "trade_entities", "_id": r.record_id}})
|
| 178 |
+
bulk_data.append(doc)
|
| 179 |
+
|
| 180 |
+
if bulk_data:
|
| 181 |
+
await es.bulk(body=bulk_data)
|
| 182 |
+
await es.close()
|
| 183 |
+
except Exception as e:
|
| 184 |
+
app_logger.error(f"Failed to sync to Elasticsearch: {e}")
|
| 185 |
|
| 186 |
async def run(self):
|
| 187 |
"""执行完整链路"""
|
|
|
|
| 202 |
|
| 203 |
# 2. 数据标准化与血缘绑定
|
| 204 |
std_records = []
|
| 205 |
+
from packages.core.entity_resolution import EntityResolutionEngine
|
| 206 |
+
from packages.core.nlp import nlp_processor
|
| 207 |
+
|
| 208 |
+
entity_engine = EntityResolutionEngine(self.session)
|
| 209 |
+
|
| 210 |
for raw in raw_records:
|
| 211 |
std_rec = await self.normalize(raw)
|
| 212 |
+
# 运行实体解析清洗
|
| 213 |
+
if std_rec.importer_name:
|
| 214 |
+
std_rec.importer_name = await entity_engine.resolve_company_name(std_rec.importer_name)
|
| 215 |
+
if std_rec.exporter_name:
|
| 216 |
+
std_rec.exporter_name = await entity_engine.resolve_company_name(std_rec.exporter_name)
|
| 217 |
+
|
| 218 |
+
# NLP 辅助:翻译商品描述与纠错 HS Code
|
| 219 |
+
if std_rec.product_name:
|
| 220 |
+
std_rec.product_name = await nlp_processor.translate_to_english(std_rec.product_name)
|
| 221 |
+
std_rec.hs_code = await nlp_processor.infer_hs_code(std_rec.product_name, std_rec.hs_code)
|
| 222 |
+
|
| 223 |
std_records.append(std_rec)
|
| 224 |
|
| 225 |
# 3. 保存标准数据
|
packages/connectors/eu.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
+
|
| 5 |
+
from packages.connectors.base import BaseConnector
|
| 6 |
+
from packages.core.models import RawTradeRecord, StandardTradeRecord
|
| 7 |
+
from packages.dictionaries.eu import (
|
| 8 |
+
get_country_alpha3,
|
| 9 |
+
get_transport_mode,
|
| 10 |
+
get_hs_name,
|
| 11 |
+
convert_eur_to_usd
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
class EurostatConnector(BaseConnector):
|
| 15 |
+
"""
|
| 16 |
+
欧盟宏观数据适配器,对接 Eurostat。
|
| 17 |
+
"""
|
| 18 |
+
country_code = "EU"
|
| 19 |
+
source_system = "EUROSTAT_API"
|
| 20 |
+
parser_version = "v1.0.0"
|
| 21 |
+
|
| 22 |
+
def __init__(
|
| 23 |
+
self,
|
| 24 |
+
session,
|
| 25 |
+
start_period: Optional[str] = None,
|
| 26 |
+
end_period: Optional[str] = None,
|
| 27 |
+
max_rows_per_slice: Optional[int] = None,
|
| 28 |
+
):
|
| 29 |
+
super().__init__(session=session)
|
| 30 |
+
self.start_period = start_period
|
| 31 |
+
self.end_period = end_period
|
| 32 |
+
self.max_rows_per_slice = max_rows_per_slice
|
| 33 |
+
|
| 34 |
+
async def discover(self) -> List[Any]:
|
| 35 |
+
periods = self._build_periods()
|
| 36 |
+
task_slices: List[Dict[str, Any]] = []
|
| 37 |
+
|
| 38 |
+
for year, month in periods:
|
| 39 |
+
task_slices.append({"year": year, "month": month, "trade_direction": "import"})
|
| 40 |
+
task_slices.append({"year": year, "month": month, "trade_direction": "export"})
|
| 41 |
+
|
| 42 |
+
return task_slices
|
| 43 |
+
|
| 44 |
+
async def fetch(self, task_slice: Any) -> Dict[str, Any]:
|
| 45 |
+
year = int(task_slice["year"])
|
| 46 |
+
month = int(task_slice["month"])
|
| 47 |
+
trade_direction = task_slice["trade_direction"]
|
| 48 |
+
|
| 49 |
+
rows = []
|
| 50 |
+
|
| 51 |
+
# 模拟 Eurostat 宏观数据
|
| 52 |
+
if trade_direction == "import":
|
| 53 |
+
rows.append({
|
| 54 |
+
"period": f"{year}{month:02d}",
|
| 55 |
+
"declarant": "DE",
|
| 56 |
+
"partner": "CN",
|
| 57 |
+
"product": "85171200",
|
| 58 |
+
"flow": "1", # 1 for import
|
| 59 |
+
"transport_mode": "1", # Sea
|
| 60 |
+
"value_eur": 5000000.0,
|
| 61 |
+
"quantity_kg": 25000.0
|
| 62 |
+
})
|
| 63 |
+
|
| 64 |
+
if trade_direction == "export":
|
| 65 |
+
rows.append({
|
| 66 |
+
"period": f"{year}{month:02d}",
|
| 67 |
+
"declarant": "FR",
|
| 68 |
+
"partner": "US",
|
| 69 |
+
"product": "22042100", # Wine
|
| 70 |
+
"flow": "2", # 2 for export
|
| 71 |
+
"transport_mode": "1",
|
| 72 |
+
"value_eur": 8000000.0,
|
| 73 |
+
"quantity_kg": 150000.0
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
return {
|
| 77 |
+
"metadata": {
|
| 78 |
+
"status": "success",
|
| 79 |
+
"period": f"{year}-{month:02d}",
|
| 80 |
+
"trade_direction": trade_direction,
|
| 81 |
+
},
|
| 82 |
+
"data": rows,
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
async def parse(self, raw_data: Any) -> List[Dict[str, Any]]:
|
| 86 |
+
if isinstance(raw_data, dict):
|
| 87 |
+
return raw_data.get("data", [])
|
| 88 |
+
if isinstance(raw_data, list):
|
| 89 |
+
return raw_data
|
| 90 |
+
return []
|
| 91 |
+
|
| 92 |
+
async def normalize(self, raw: RawTradeRecord) -> StandardTradeRecord:
|
| 93 |
+
data = raw.raw_json
|
| 94 |
+
|
| 95 |
+
period_str = data.get("period")
|
| 96 |
+
year = 1970
|
| 97 |
+
month = 1
|
| 98 |
+
if period_str and len(period_str) == 6:
|
| 99 |
+
try:
|
| 100 |
+
year = int(period_str[:4])
|
| 101 |
+
month = int(period_str[4:])
|
| 102 |
+
except ValueError:
|
| 103 |
+
pass
|
| 104 |
+
|
| 105 |
+
trade_date = datetime(year, month, 1)
|
| 106 |
+
|
| 107 |
+
trade_direction = "import" if data.get("flow") == "1" else "export"
|
| 108 |
+
|
| 109 |
+
hs_code = (data.get("product") or "").strip()
|
| 110 |
+
product_name = await get_hs_name(hs_code)
|
| 111 |
+
|
| 112 |
+
declarant_country = await get_country_alpha3(data.get("declarant"))
|
| 113 |
+
partner_country = await get_country_alpha3(data.get("partner"))
|
| 114 |
+
transport_mode = await get_transport_mode(data.get("transport_mode"))
|
| 115 |
+
|
| 116 |
+
value_eur = self._safe_float(data.get("value_eur")) or 0.0
|
| 117 |
+
value_usd = await convert_eur_to_usd(value_eur, year, month)
|
| 118 |
+
|
| 119 |
+
record_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{self.country_code}:{raw.id}"))
|
| 120 |
+
|
| 121 |
+
return StandardTradeRecord(
|
| 122 |
+
record_id=record_id,
|
| 123 |
+
source_record_id=raw.id,
|
| 124 |
+
batch_no=raw.batch_no,
|
| 125 |
+
source_country=self.country_code,
|
| 126 |
+
trade_direction=trade_direction,
|
| 127 |
+
trade_date=trade_date,
|
| 128 |
+
importer_name=None, # 宏观数据没有企业
|
| 129 |
+
exporter_name=None,
|
| 130 |
+
hs_code=hs_code,
|
| 131 |
+
product_name=product_name,
|
| 132 |
+
amount=value_usd,
|
| 133 |
+
currency="USD",
|
| 134 |
+
weight=self._safe_float(data.get("quantity_kg")),
|
| 135 |
+
weight_unit="KG",
|
| 136 |
+
origin_country=partner_country if trade_direction == "import" else declarant_country,
|
| 137 |
+
destination_country=partner_country if trade_direction == "export" else declarant_country,
|
| 138 |
+
transport_mode=transport_mode,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
def _build_periods(self) -> List[tuple[int, int]]:
|
| 142 |
+
if self.start_period and self.end_period:
|
| 143 |
+
return self._month_range(self.start_period, self.end_period)
|
| 144 |
+
|
| 145 |
+
now = datetime.now(timezone.utc)
|
| 146 |
+
year = now.year
|
| 147 |
+
month = now.month - 1
|
| 148 |
+
if month == 0:
|
| 149 |
+
year -= 1
|
| 150 |
+
month = 12
|
| 151 |
+
return [(year, month)]
|
| 152 |
+
|
| 153 |
+
def _month_range(self, start_period: str, end_period: str) -> List[tuple[int, int]]:
|
| 154 |
+
start = datetime.strptime(start_period, "%Y-%m")
|
| 155 |
+
end = datetime.strptime(end_period, "%Y-%m")
|
| 156 |
+
periods: List[tuple[int, int]] = []
|
| 157 |
+
current = start
|
| 158 |
+
|
| 159 |
+
while current <= end:
|
| 160 |
+
periods.append((current.year, current.month))
|
| 161 |
+
if current.month == 12:
|
| 162 |
+
current = current.replace(year=current.year + 1, month=1)
|
| 163 |
+
else:
|
| 164 |
+
current = current.replace(month=current.month + 1)
|
| 165 |
+
|
| 166 |
+
return periods
|
| 167 |
+
|
| 168 |
+
@staticmethod
|
| 169 |
+
def _safe_float(value: Optional[Any]) -> Optional[float]:
|
| 170 |
+
if value in (None, ""):
|
| 171 |
+
return None
|
| 172 |
+
try:
|
| 173 |
+
return float(str(value).replace(",", "."))
|
| 174 |
+
except (TypeError, ValueError):
|
| 175 |
+
return None
|
packages/connectors/india.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.india import (
|
| 9 |
+
get_country_alpha3,
|
| 10 |
+
get_transport_mode,
|
| 11 |
+
get_hs_name
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
class IndiaCustomsConnector(BaseConnector):
|
| 15 |
+
"""
|
| 16 |
+
印度海关数据适配器。
|
| 17 |
+
因官方反爬极严,通常通过第三方商业源(API/CSV)获取。这里模拟商业源 JSON API。
|
| 18 |
+
"""
|
| 19 |
+
country_code = "IN"
|
| 20 |
+
source_system = "INDIA_COMMERCIAL_API"
|
| 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 |
+
# 模拟调用第三方商业 API 返回的数据
|
| 51 |
+
rows = []
|
| 52 |
+
|
| 53 |
+
if trade_direction == "import":
|
| 54 |
+
rows.append({
|
| 55 |
+
"date": f"{year}-{month:02d}-15",
|
| 56 |
+
"hs_code": "854231",
|
| 57 |
+
"product_desc": "ELECTRONIC INTEGRATED CIRCUITS - PROCESSORS AND CONTROLLERS",
|
| 58 |
+
"importer": "TATA ELECTRONICS PVT LTD",
|
| 59 |
+
"exporter": "TSMC SHANGHAI",
|
| 60 |
+
"origin_country": "CHINA",
|
| 61 |
+
"port_of_discharge": "NHAVA SHEVA SEA",
|
| 62 |
+
"port_of_loading": "SHANGHAI",
|
| 63 |
+
"mode": "SEA",
|
| 64 |
+
"quantity": 10000,
|
| 65 |
+
"unit": "NOS",
|
| 66 |
+
"gross_weight_kg": 500.0,
|
| 67 |
+
"value_usd": 500000.0
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
if trade_direction == "export":
|
| 71 |
+
rows.append({
|
| 72 |
+
"date": f"{year}-{month:02d}-20",
|
| 73 |
+
"hs_code": "300490",
|
| 74 |
+
"product_desc": "MEDICAMENTS - OTHER",
|
| 75 |
+
"importer": "PHARMA DISTRIBUTORS LLC",
|
| 76 |
+
"exporter": "SUN PHARMACEUTICAL INDUSTRIES LTD",
|
| 77 |
+
"destination_country": "USA",
|
| 78 |
+
"port_of_discharge": "NEW YORK",
|
| 79 |
+
"port_of_loading": "MUNDRA SEA",
|
| 80 |
+
"mode": "SEA",
|
| 81 |
+
"quantity": 50000,
|
| 82 |
+
"unit": "PAC",
|
| 83 |
+
"gross_weight_kg": 2000.0,
|
| 84 |
+
"value_usd": 150000.0
|
| 85 |
+
})
|
| 86 |
+
|
| 87 |
+
return {
|
| 88 |
+
"metadata": {
|
| 89 |
+
"status": "success",
|
| 90 |
+
"period": f"{year}-{month:02d}",
|
| 91 |
+
"trade_direction": trade_direction,
|
| 92 |
+
},
|
| 93 |
+
"data": rows,
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
async def parse(self, raw_data: Any) -> List[Dict[str, Any]]:
|
| 97 |
+
if isinstance(raw_data, dict):
|
| 98 |
+
return raw_data.get("data", [])
|
| 99 |
+
if isinstance(raw_data, list):
|
| 100 |
+
return raw_data
|
| 101 |
+
return []
|
| 102 |
+
|
| 103 |
+
async def normalize(self, raw: RawTradeRecord) -> StandardTradeRecord:
|
| 104 |
+
data = raw.raw_json
|
| 105 |
+
|
| 106 |
+
# 提取交易日期
|
| 107 |
+
date_str = data.get("date")
|
| 108 |
+
if date_str:
|
| 109 |
+
try:
|
| 110 |
+
trade_date = datetime.strptime(date_str, "%Y-%m-%d")
|
| 111 |
+
except ValueError:
|
| 112 |
+
trade_date = datetime(1970, 1, 1)
|
| 113 |
+
else:
|
| 114 |
+
trade_date = datetime(1970, 1, 1)
|
| 115 |
+
|
| 116 |
+
# 提取方向
|
| 117 |
+
if data.get("destination_country"):
|
| 118 |
+
trade_direction = "export"
|
| 119 |
+
partner_country_raw = data.get("destination_country")
|
| 120 |
+
else:
|
| 121 |
+
trade_direction = "import"
|
| 122 |
+
partner_country_raw = data.get("origin_country")
|
| 123 |
+
|
| 124 |
+
hs_code = (data.get("hs_code") or "").strip()
|
| 125 |
+
product_name = data.get("product_desc") or await get_hs_name(hs_code)
|
| 126 |
+
|
| 127 |
+
partner_country = await get_country_alpha3(partner_country_raw)
|
| 128 |
+
transport_mode = await get_transport_mode(data.get("mode"))
|
| 129 |
+
|
| 130 |
+
record_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{self.country_code}:{raw.id}"))
|
| 131 |
+
|
| 132 |
+
return StandardTradeRecord(
|
| 133 |
+
record_id=record_id,
|
| 134 |
+
source_record_id=raw.id,
|
| 135 |
+
batch_no=raw.batch_no,
|
| 136 |
+
source_country=self.country_code,
|
| 137 |
+
trade_direction=trade_direction,
|
| 138 |
+
trade_date=trade_date,
|
| 139 |
+
importer_name=data.get("importer"),
|
| 140 |
+
exporter_name=data.get("exporter"),
|
| 141 |
+
hs_code=hs_code,
|
| 142 |
+
product_name=product_name,
|
| 143 |
+
amount=self._safe_float(data.get("value_usd")),
|
| 144 |
+
currency="USD",
|
| 145 |
+
weight=self._safe_float(data.get("gross_weight_kg")),
|
| 146 |
+
weight_unit="KG",
|
| 147 |
+
origin_country=partner_country if trade_direction == "import" else "IND",
|
| 148 |
+
destination_country=partner_country if trade_direction == "export" else "IND",
|
| 149 |
+
departure_port=data.get("port_of_loading"),
|
| 150 |
+
arrival_port=data.get("port_of_discharge"),
|
| 151 |
+
transport_mode=transport_mode,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
def _build_periods(self) -> List[tuple[int, int]]:
|
| 155 |
+
if self.start_period and self.end_period:
|
| 156 |
+
return self._month_range(self.start_period, self.end_period)
|
| 157 |
+
|
| 158 |
+
now = datetime.now(timezone.utc)
|
| 159 |
+
year = now.year
|
| 160 |
+
month = now.month - 1
|
| 161 |
+
if month == 0:
|
| 162 |
+
year -= 1
|
| 163 |
+
month = 12
|
| 164 |
+
return [(year, month)]
|
| 165 |
+
|
| 166 |
+
def _month_range(self, start_period: str, end_period: str) -> List[tuple[int, int]]:
|
| 167 |
+
start = datetime.strptime(start_period, "%Y-%m")
|
| 168 |
+
end = datetime.strptime(end_period, "%Y-%m")
|
| 169 |
+
periods: List[tuple[int, int]] = []
|
| 170 |
+
current = start
|
| 171 |
+
|
| 172 |
+
while current <= end:
|
| 173 |
+
periods.append((current.year, current.month))
|
| 174 |
+
if current.month == 12:
|
| 175 |
+
current = current.replace(year=current.year + 1, month=1)
|
| 176 |
+
else:
|
| 177 |
+
current = current.replace(month=current.month + 1)
|
| 178 |
+
|
| 179 |
+
return periods
|
| 180 |
+
|
| 181 |
+
@staticmethod
|
| 182 |
+
def _safe_float(value: Optional[Any]) -> Optional[float]:
|
| 183 |
+
if value in (None, ""):
|
| 184 |
+
return None
|
| 185 |
+
try:
|
| 186 |
+
return float(str(value).replace(",", "."))
|
| 187 |
+
except (TypeError, ValueError):
|
| 188 |
+
return None
|
packages/connectors/indonesia.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
+
|
| 5 |
+
from packages.connectors.base import BaseConnector
|
| 6 |
+
from packages.core.models import RawTradeRecord, StandardTradeRecord
|
| 7 |
+
from packages.dictionaries.indonesia import (
|
| 8 |
+
get_country_alpha3,
|
| 9 |
+
get_transport_mode,
|
| 10 |
+
get_hs_name
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
class IndonesiaCustomsConnector(BaseConnector):
|
| 14 |
+
country_code = "ID"
|
| 15 |
+
source_system = "INDONESIA_CUSTOMS_API"
|
| 16 |
+
parser_version = "v1.0.0"
|
| 17 |
+
|
| 18 |
+
def __init__(
|
| 19 |
+
self,
|
| 20 |
+
session,
|
| 21 |
+
start_period: Optional[str] = None,
|
| 22 |
+
end_period: Optional[str] = None,
|
| 23 |
+
max_rows_per_slice: Optional[int] = None,
|
| 24 |
+
):
|
| 25 |
+
super().__init__(session=session)
|
| 26 |
+
self.start_period = start_period
|
| 27 |
+
self.end_period = end_period
|
| 28 |
+
self.max_rows_per_slice = max_rows_per_slice
|
| 29 |
+
|
| 30 |
+
async def discover(self) -> List[Any]:
|
| 31 |
+
periods = self._build_periods()
|
| 32 |
+
task_slices: List[Dict[str, Any]] = []
|
| 33 |
+
|
| 34 |
+
for year, month in periods:
|
| 35 |
+
task_slices.append({"year": year, "month": month, "trade_direction": "import"})
|
| 36 |
+
task_slices.append({"year": year, "month": month, "trade_direction": "export"})
|
| 37 |
+
|
| 38 |
+
return task_slices
|
| 39 |
+
|
| 40 |
+
async def fetch(self, task_slice: Any) -> Dict[str, Any]:
|
| 41 |
+
year = int(task_slice["year"])
|
| 42 |
+
month = int(task_slice["month"])
|
| 43 |
+
trade_direction = task_slice["trade_direction"]
|
| 44 |
+
|
| 45 |
+
rows = []
|
| 46 |
+
|
| 47 |
+
if trade_direction == "export":
|
| 48 |
+
rows.append({
|
| 49 |
+
"date": f"{year}-{month:02d}-20",
|
| 50 |
+
"hs_code": "440290",
|
| 51 |
+
"uraian_barang": "Kayu lainnya",
|
| 52 |
+
"nama_eksportir": "PT KAYU JAYA",
|
| 53 |
+
"nama_importir": "UNKNOWN_COMPANY",
|
| 54 |
+
"negara": "TIONGKOK",
|
| 55 |
+
"pelabuhan": "TANJUNG PRIOK",
|
| 56 |
+
"moda_transportasi": "LAUT",
|
| 57 |
+
"berat_bersih_kg": 15000.0,
|
| 58 |
+
"nilai_fob_usd": 25000.0
|
| 59 |
+
})
|
| 60 |
+
|
| 61 |
+
return {
|
| 62 |
+
"metadata": {
|
| 63 |
+
"status": "success",
|
| 64 |
+
"period": f"{year}-{month:02d}",
|
| 65 |
+
"trade_direction": trade_direction,
|
| 66 |
+
},
|
| 67 |
+
"data": rows,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
async def parse(self, raw_data: Any) -> List[Dict[str, Any]]:
|
| 71 |
+
if isinstance(raw_data, dict):
|
| 72 |
+
return raw_data.get("data", [])
|
| 73 |
+
if isinstance(raw_data, list):
|
| 74 |
+
return raw_data
|
| 75 |
+
return []
|
| 76 |
+
|
| 77 |
+
async def normalize(self, raw: RawTradeRecord) -> StandardTradeRecord:
|
| 78 |
+
data = raw.raw_json
|
| 79 |
+
|
| 80 |
+
date_str = data.get("date")
|
| 81 |
+
if date_str:
|
| 82 |
+
try:
|
| 83 |
+
trade_date = datetime.strptime(date_str, "%Y-%m-%d")
|
| 84 |
+
except ValueError:
|
| 85 |
+
trade_date = datetime(1970, 1, 1)
|
| 86 |
+
else:
|
| 87 |
+
trade_date = datetime(1970, 1, 1)
|
| 88 |
+
|
| 89 |
+
trade_direction = raw.raw_json.get("trade_direction", "import") # 如果没传默认为import
|
| 90 |
+
|
| 91 |
+
hs_code = (data.get("hs_code") or "").strip()
|
| 92 |
+
product_name = data.get("uraian_barang") or await get_hs_name(hs_code)
|
| 93 |
+
|
| 94 |
+
partner_country = await get_country_alpha3(data.get("negara"))
|
| 95 |
+
transport_mode = await get_transport_mode(data.get("moda_transportasi"))
|
| 96 |
+
|
| 97 |
+
record_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{self.country_code}:{raw.id}"))
|
| 98 |
+
|
| 99 |
+
return StandardTradeRecord(
|
| 100 |
+
record_id=record_id,
|
| 101 |
+
source_record_id=raw.id,
|
| 102 |
+
batch_no=raw.batch_no,
|
| 103 |
+
source_country=self.country_code,
|
| 104 |
+
trade_direction=trade_direction,
|
| 105 |
+
trade_date=trade_date,
|
| 106 |
+
importer_name=data.get("nama_importir"),
|
| 107 |
+
exporter_name=data.get("nama_eksportir"),
|
| 108 |
+
hs_code=hs_code,
|
| 109 |
+
product_name=product_name,
|
| 110 |
+
amount=self._safe_float(data.get("nilai_fob_usd")),
|
| 111 |
+
currency="USD",
|
| 112 |
+
weight=self._safe_float(data.get("berat_bersih_kg")),
|
| 113 |
+
weight_unit="KG",
|
| 114 |
+
origin_country=partner_country if trade_direction == "import" else "IDN",
|
| 115 |
+
destination_country=partner_country if trade_direction == "export" else "IDN",
|
| 116 |
+
departure_port=data.get("pelabuhan") if trade_direction == "export" else None,
|
| 117 |
+
arrival_port=data.get("pelabuhan") if trade_direction == "import" else None,
|
| 118 |
+
transport_mode=transport_mode,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
def _build_periods(self) -> List[tuple[int, int]]:
|
| 122 |
+
if self.start_period and self.end_period:
|
| 123 |
+
return self._month_range(self.start_period, self.end_period)
|
| 124 |
+
|
| 125 |
+
now = datetime.now(timezone.utc)
|
| 126 |
+
year = now.year
|
| 127 |
+
month = now.month - 1
|
| 128 |
+
if month == 0:
|
| 129 |
+
year -= 1
|
| 130 |
+
month = 12
|
| 131 |
+
return [(year, month)]
|
| 132 |
+
|
| 133 |
+
def _month_range(self, start_period: str, end_period: str) -> List[tuple[int, int]]:
|
| 134 |
+
start = datetime.strptime(start_period, "%Y-%m")
|
| 135 |
+
end = datetime.strptime(end_period, "%Y-%m")
|
| 136 |
+
periods: List[tuple[int, int]] = []
|
| 137 |
+
current = start
|
| 138 |
+
|
| 139 |
+
while current <= end:
|
| 140 |
+
periods.append((current.year, current.month))
|
| 141 |
+
if current.month == 12:
|
| 142 |
+
current = current.replace(year=current.year + 1, month=1)
|
| 143 |
+
else:
|
| 144 |
+
current = current.replace(month=current.month + 1)
|
| 145 |
+
|
| 146 |
+
return periods
|
| 147 |
+
|
| 148 |
+
@staticmethod
|
| 149 |
+
def _safe_float(value: Optional[Any]) -> Optional[float]:
|
| 150 |
+
if value in (None, ""):
|
| 151 |
+
return None
|
| 152 |
+
try:
|
| 153 |
+
return float(str(value).replace(",", "."))
|
| 154 |
+
except (TypeError, ValueError):
|
| 155 |
+
return None
|
packages/connectors/vietnam.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
+
|
| 5 |
+
from packages.connectors.base import BaseConnector
|
| 6 |
+
from packages.core.models import RawTradeRecord, StandardTradeRecord
|
| 7 |
+
from packages.dictionaries.vietnam import (
|
| 8 |
+
get_country_alpha3,
|
| 9 |
+
get_transport_mode,
|
| 10 |
+
get_hs_name
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
class VietnamCustomsConnector(BaseConnector):
|
| 14 |
+
country_code = "VN"
|
| 15 |
+
source_system = "VIETNAM_CUSTOMS_API"
|
| 16 |
+
parser_version = "v1.0.0"
|
| 17 |
+
|
| 18 |
+
def __init__(
|
| 19 |
+
self,
|
| 20 |
+
session,
|
| 21 |
+
start_period: Optional[str] = None,
|
| 22 |
+
end_period: Optional[str] = None,
|
| 23 |
+
max_rows_per_slice: Optional[int] = None,
|
| 24 |
+
):
|
| 25 |
+
super().__init__(session=session)
|
| 26 |
+
self.start_period = start_period
|
| 27 |
+
self.end_period = end_period
|
| 28 |
+
self.max_rows_per_slice = max_rows_per_slice
|
| 29 |
+
|
| 30 |
+
async def discover(self) -> List[Any]:
|
| 31 |
+
periods = self._build_periods()
|
| 32 |
+
task_slices: List[Dict[str, Any]] = []
|
| 33 |
+
|
| 34 |
+
for year, month in periods:
|
| 35 |
+
task_slices.append({"year": year, "month": month, "trade_direction": "import"})
|
| 36 |
+
task_slices.append({"year": year, "month": month, "trade_direction": "export"})
|
| 37 |
+
|
| 38 |
+
return task_slices
|
| 39 |
+
|
| 40 |
+
async def fetch(self, task_slice: Any) -> Dict[str, Any]:
|
| 41 |
+
year = int(task_slice["year"])
|
| 42 |
+
month = int(task_slice["month"])
|
| 43 |
+
trade_direction = task_slice["trade_direction"]
|
| 44 |
+
|
| 45 |
+
rows = []
|
| 46 |
+
|
| 47 |
+
if trade_direction == "import":
|
| 48 |
+
rows.append({
|
| 49 |
+
"date": f"{year}-{month:02d}-10",
|
| 50 |
+
"ma_hs": "851712",
|
| 51 |
+
"mo_ta_hang_hoa": "Điện thoại di động",
|
| 52 |
+
"ten_doanh_nghiep": "SAMSUNG ELECTRONICS VIETNAM",
|
| 53 |
+
"doi_tac": "SAMSUNG KOREA",
|
| 54 |
+
"quoc_gia": "HÀN QUỐC",
|
| 55 |
+
"cang": "HAI PHONG",
|
| 56 |
+
"phuong_thuc_van_tai": "ĐƯỜNG BIỂN",
|
| 57 |
+
"luong": 1000,
|
| 58 |
+
"don_vi_tinh": "Cái",
|
| 59 |
+
"tri_gia_usd": 500000.0
|
| 60 |
+
})
|
| 61 |
+
|
| 62 |
+
return {
|
| 63 |
+
"metadata": {
|
| 64 |
+
"status": "success",
|
| 65 |
+
"period": f"{year}-{month:02d}",
|
| 66 |
+
"trade_direction": trade_direction,
|
| 67 |
+
},
|
| 68 |
+
"data": rows,
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
async def parse(self, raw_data: Any) -> List[Dict[str, Any]]:
|
| 72 |
+
if isinstance(raw_data, dict):
|
| 73 |
+
return raw_data.get("data", [])
|
| 74 |
+
if isinstance(raw_data, list):
|
| 75 |
+
return raw_data
|
| 76 |
+
return []
|
| 77 |
+
|
| 78 |
+
async def normalize(self, raw: RawTradeRecord) -> StandardTradeRecord:
|
| 79 |
+
data = raw.raw_json
|
| 80 |
+
|
| 81 |
+
date_str = data.get("date")
|
| 82 |
+
if date_str:
|
| 83 |
+
try:
|
| 84 |
+
trade_date = datetime.strptime(date_str, "%Y-%m-%d")
|
| 85 |
+
except ValueError:
|
| 86 |
+
trade_date = datetime(1970, 1, 1)
|
| 87 |
+
else:
|
| 88 |
+
trade_date = datetime(1970, 1, 1)
|
| 89 |
+
|
| 90 |
+
trade_direction = raw.raw_json.get("trade_direction", "import") # 如果没传默认为import
|
| 91 |
+
|
| 92 |
+
hs_code = (data.get("ma_hs") or "").strip()
|
| 93 |
+
product_name = data.get("mo_ta_hang_hoa") or await get_hs_name(hs_code)
|
| 94 |
+
|
| 95 |
+
partner_country = await get_country_alpha3(data.get("quoc_gia"))
|
| 96 |
+
transport_mode = await get_transport_mode(data.get("phuong_thuc_van_tai"))
|
| 97 |
+
|
| 98 |
+
record_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{self.country_code}:{raw.id}"))
|
| 99 |
+
|
| 100 |
+
return StandardTradeRecord(
|
| 101 |
+
record_id=record_id,
|
| 102 |
+
source_record_id=raw.id,
|
| 103 |
+
batch_no=raw.batch_no,
|
| 104 |
+
source_country=self.country_code,
|
| 105 |
+
trade_direction=trade_direction,
|
| 106 |
+
trade_date=trade_date,
|
| 107 |
+
importer_name=data.get("ten_doanh_nghiep") if trade_direction == "import" else data.get("doi_tac"),
|
| 108 |
+
exporter_name=data.get("doi_tac") if trade_direction == "import" else data.get("ten_doanh_nghiep"),
|
| 109 |
+
hs_code=hs_code,
|
| 110 |
+
product_name=product_name,
|
| 111 |
+
amount=self._safe_float(data.get("tri_gia_usd")),
|
| 112 |
+
currency="USD",
|
| 113 |
+
weight=self._safe_float(data.get("luong")),
|
| 114 |
+
weight_unit="KG", # 简化的假设
|
| 115 |
+
origin_country=partner_country if trade_direction == "import" else "VNM",
|
| 116 |
+
destination_country=partner_country if trade_direction == "export" else "VNM",
|
| 117 |
+
departure_port=data.get("cang") if trade_direction == "export" else None,
|
| 118 |
+
arrival_port=data.get("cang") if trade_direction == "import" else None,
|
| 119 |
+
transport_mode=transport_mode,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
def _build_periods(self) -> List[tuple[int, int]]:
|
| 123 |
+
if self.start_period and self.end_period:
|
| 124 |
+
return self._month_range(self.start_period, self.end_period)
|
| 125 |
+
|
| 126 |
+
now = datetime.now(timezone.utc)
|
| 127 |
+
year = now.year
|
| 128 |
+
month = now.month - 1
|
| 129 |
+
if month == 0:
|
| 130 |
+
year -= 1
|
| 131 |
+
month = 12
|
| 132 |
+
return [(year, month)]
|
| 133 |
+
|
| 134 |
+
def _month_range(self, start_period: str, end_period: str) -> List[tuple[int, int]]:
|
| 135 |
+
start = datetime.strptime(start_period, "%Y-%m")
|
| 136 |
+
end = datetime.strptime(end_period, "%Y-%m")
|
| 137 |
+
periods: List[tuple[int, int]] = []
|
| 138 |
+
current = start
|
| 139 |
+
|
| 140 |
+
while current <= end:
|
| 141 |
+
periods.append((current.year, current.month))
|
| 142 |
+
if current.month == 12:
|
| 143 |
+
current = current.replace(year=current.year + 1, month=1)
|
| 144 |
+
else:
|
| 145 |
+
current = current.replace(month=current.month + 1)
|
| 146 |
+
|
| 147 |
+
return periods
|
| 148 |
+
|
| 149 |
+
@staticmethod
|
| 150 |
+
def _safe_float(value: Optional[Any]) -> Optional[float]:
|
| 151 |
+
if value in (None, ""):
|
| 152 |
+
return None
|
| 153 |
+
try:
|
| 154 |
+
return float(str(value).replace(",", "."))
|
| 155 |
+
except (TypeError, ValueError):
|
| 156 |
+
return None
|
packages/core/config.py
CHANGED
|
@@ -9,6 +9,12 @@ class Settings(BaseSettings):
|
|
| 9 |
# 数据库配置 (PostgreSQL)
|
| 10 |
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5433/customs_data"
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
# Redis 配置
|
| 13 |
REDIS_URL: str = "redis://localhost:6379/0"
|
| 14 |
|
|
|
|
| 9 |
# 数据库配置 (PostgreSQL)
|
| 10 |
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5433/customs_data"
|
| 11 |
|
| 12 |
+
# ClickHouse 配置
|
| 13 |
+
CLICKHOUSE_URL: str = "http://default:password@localhost:8123"
|
| 14 |
+
|
| 15 |
+
# Elasticsearch 配置
|
| 16 |
+
ELASTICSEARCH_URL: str = "http://localhost:9200"
|
| 17 |
+
|
| 18 |
# Redis 配置
|
| 19 |
REDIS_URL: str = "redis://localhost:6379/0"
|
| 20 |
|
packages/core/entity_resolution.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
import difflib
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
from sqlalchemy.future import select
|
| 5 |
+
from sqlalchemy import or_
|
| 6 |
+
|
| 7 |
+
from packages.core.models import EntityMapping, EntityReviewPool
|
| 8 |
+
from packages.normalizers.company import clean_company_name
|
| 9 |
+
|
| 10 |
+
class EntityResolutionEngine:
|
| 11 |
+
"""
|
| 12 |
+
企业实体解析引擎。
|
| 13 |
+
负责名称清洗、去重映射与疑似重复池的管理。
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
def __init__(self, session: AsyncSession):
|
| 17 |
+
self.session = session
|
| 18 |
+
self.AUTO_MATCH_THRESHOLD = 0.92 # 高于此分数自动映射
|
| 19 |
+
self.REVIEW_THRESHOLD = 0.85 # 介于 0.85 ~ 0.92 之间进入人工确认池
|
| 20 |
+
|
| 21 |
+
def calculate_similarity(self, a: str, b: str) -> float:
|
| 22 |
+
"""
|
| 23 |
+
计算两个字符串的相似度。
|
| 24 |
+
这里使用 Python 自带的 SequenceMatcher。
|
| 25 |
+
商业环境中可引入 TF-IDF, Jaro-Winkler 或基于 embedding 的相似度。
|
| 26 |
+
"""
|
| 27 |
+
return difflib.SequenceMatcher(None, a, b).ratio()
|
| 28 |
+
|
| 29 |
+
async def resolve_company_name(self, raw_name: str) -> str:
|
| 30 |
+
"""
|
| 31 |
+
解析公司名称,返回标准化的主体名称。
|
| 32 |
+
如果能匹配到现有映射,返回标准名称。
|
| 33 |
+
如果在疑似区间,插入 review pool 并返回清洗后名称。
|
| 34 |
+
如果是全新名称,创建新实体并返回。
|
| 35 |
+
"""
|
| 36 |
+
if not raw_name:
|
| 37 |
+
return ""
|
| 38 |
+
|
| 39 |
+
cleaned_name = clean_company_name(raw_name)
|
| 40 |
+
if not cleaned_name:
|
| 41 |
+
return ""
|
| 42 |
+
|
| 43 |
+
# 1. 查找精确匹配的映射
|
| 44 |
+
stmt = select(EntityMapping).where(EntityMapping.original_name == cleaned_name)
|
| 45 |
+
result = await self.session.execute(stmt)
|
| 46 |
+
mapping = result.scalar_one_or_none()
|
| 47 |
+
|
| 48 |
+
if mapping:
|
| 49 |
+
return mapping.standard_name
|
| 50 |
+
|
| 51 |
+
# 2. 如果没有精确匹配,尝试模糊查找 (这里用简单的 LIKE 前缀或者提取已有实体做比对)
|
| 52 |
+
# 在真实海量数据场景,需要借助 Elasticsearch 的 Fuzzy 查询或向量搜索。
|
| 53 |
+
# 这里为了演示,假设我们查询标准名称首字母相同的若干记录做内存比对。
|
| 54 |
+
|
| 55 |
+
prefix = cleaned_name[:3]
|
| 56 |
+
if len(prefix) < 3:
|
| 57 |
+
# 名字太短,直接作为新实体
|
| 58 |
+
return await self._create_new_entity(cleaned_name)
|
| 59 |
+
|
| 60 |
+
stmt = select(EntityMapping.standard_name, EntityMapping.standard_entity_id).where(
|
| 61 |
+
EntityMapping.standard_name.like(f"{prefix}%")
|
| 62 |
+
).distinct()
|
| 63 |
+
|
| 64 |
+
result = await self.session.execute(stmt)
|
| 65 |
+
candidates = result.all()
|
| 66 |
+
|
| 67 |
+
best_match = None
|
| 68 |
+
best_score = 0.0
|
| 69 |
+
best_entity_id = None
|
| 70 |
+
|
| 71 |
+
for cand_name, entity_id in candidates:
|
| 72 |
+
score = self.calculate_similarity(cleaned_name, cand_name)
|
| 73 |
+
if score > best_score:
|
| 74 |
+
best_score = score
|
| 75 |
+
best_match = cand_name
|
| 76 |
+
best_entity_id = entity_id
|
| 77 |
+
|
| 78 |
+
if best_score >= self.AUTO_MATCH_THRESHOLD:
|
| 79 |
+
# 自动映射
|
| 80 |
+
await self._create_mapping(cleaned_name, best_entity_id, best_match, best_score)
|
| 81 |
+
return best_match
|
| 82 |
+
|
| 83 |
+
elif best_score >= self.REVIEW_THRESHOLD:
|
| 84 |
+
# 进入人工确认池
|
| 85 |
+
await self._create_review_task(cleaned_name, best_match, best_entity_id, best_score)
|
| 86 |
+
# 在确认前,先作为独立实体对待或返回清洗名称
|
| 87 |
+
return await self._create_new_entity(cleaned_name)
|
| 88 |
+
|
| 89 |
+
else:
|
| 90 |
+
# 分数太低,作为新实体
|
| 91 |
+
return await self._create_new_entity(cleaned_name)
|
| 92 |
+
|
| 93 |
+
async def _create_new_entity(self, cleaned_name: str) -> str:
|
| 94 |
+
entity_id = str(uuid.uuid4())
|
| 95 |
+
await self._create_mapping(cleaned_name, entity_id, cleaned_name, 1.0)
|
| 96 |
+
return cleaned_name
|
| 97 |
+
|
| 98 |
+
async def _create_mapping(self, original_name: str, entity_id: str, standard_name: str, score: float):
|
| 99 |
+
mapping = EntityMapping(
|
| 100 |
+
id=str(uuid.uuid4()),
|
| 101 |
+
original_name=original_name,
|
| 102 |
+
standard_entity_id=entity_id,
|
| 103 |
+
standard_name=standard_name,
|
| 104 |
+
confidence_score=score
|
| 105 |
+
)
|
| 106 |
+
self.session.add(mapping)
|
| 107 |
+
# 忽略唯一键冲突 (如果有并发情况)
|
| 108 |
+
try:
|
| 109 |
+
await self.session.flush()
|
| 110 |
+
except Exception:
|
| 111 |
+
await self.session.rollback()
|
| 112 |
+
|
| 113 |
+
async def _create_review_task(self, source_name: str, target_name: str, target_entity_id: str, score: float):
|
| 114 |
+
# 检查是否已存在
|
| 115 |
+
stmt = select(EntityReviewPool).where(
|
| 116 |
+
EntityReviewPool.source_name == source_name,
|
| 117 |
+
EntityReviewPool.target_entity_id == target_entity_id
|
| 118 |
+
)
|
| 119 |
+
result = await self.session.execute(stmt)
|
| 120 |
+
existing = result.scalar_one_or_none()
|
| 121 |
+
|
| 122 |
+
if not existing:
|
| 123 |
+
task = EntityReviewPool(
|
| 124 |
+
id=str(uuid.uuid4()),
|
| 125 |
+
source_name=source_name,
|
| 126 |
+
target_name=target_name,
|
| 127 |
+
target_entity_id=target_entity_id,
|
| 128 |
+
similarity_score=score,
|
| 129 |
+
status="PENDING"
|
| 130 |
+
)
|
| 131 |
+
self.session.add(task)
|
| 132 |
+
try:
|
| 133 |
+
await self.session.flush()
|
| 134 |
+
except Exception:
|
| 135 |
+
await self.session.rollback()
|
packages/core/models.py
CHANGED
|
@@ -57,6 +57,55 @@ class StandardTradeRecord(Base):
|
|
| 57 |
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 58 |
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
__table_args__ = (
|
| 61 |
Index("idx_trade_search", "source_country", "trade_direction", "trade_date"),
|
| 62 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 58 |
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
| 59 |
|
| 60 |
+
class Subscription(Base):
|
| 61 |
+
"""
|
| 62 |
+
企业动态监控与预警订阅表
|
| 63 |
+
"""
|
| 64 |
+
__tablename__ = "subscriptions"
|
| 65 |
+
|
| 66 |
+
id = Column(String(50), primary_key=True)
|
| 67 |
+
user_email = Column(String(100), index=True, nullable=False, comment="订阅用户邮箱")
|
| 68 |
+
target_entity_id = Column(String(50), nullable=True, comment="订阅的目标企业ID")
|
| 69 |
+
target_hs_code = Column(String(20), nullable=True, comment="订阅的HS Code")
|
| 70 |
+
last_notified_at = Column(DateTime(timezone=True), nullable=True, comment="上次通知时间")
|
| 71 |
+
|
| 72 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 73 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
| 74 |
+
|
| 75 |
__table_args__ = (
|
| 76 |
Index("idx_trade_search", "source_country", "trade_direction", "trade_date"),
|
| 77 |
)
|
| 78 |
+
|
| 79 |
+
class EntityMapping(Base):
|
| 80 |
+
"""
|
| 81 |
+
企业实体映射表:
|
| 82 |
+
用于将各种不同的原始公司名称映射到唯一的标准公司主体。
|
| 83 |
+
"""
|
| 84 |
+
__tablename__ = "entity_mappings"
|
| 85 |
+
|
| 86 |
+
id = Column(String(50), primary_key=True)
|
| 87 |
+
original_name = Column(String(500), unique=True, nullable=False, comment="原始名称(或清洗后的中间名称)")
|
| 88 |
+
standard_entity_id = Column(String(50), index=True, nullable=False, comment="标准主体ID")
|
| 89 |
+
standard_name = Column(String(500), nullable=False, comment="标准主体名称")
|
| 90 |
+
confidence_score = Column(Float, default=1.0, comment="映射置信度")
|
| 91 |
+
is_manual = Column(Integer, default=0, comment="是否人工确认: 1-是, 0-否")
|
| 92 |
+
|
| 93 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 94 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
| 95 |
+
|
| 96 |
+
class EntityReviewPool(Base):
|
| 97 |
+
"""
|
| 98 |
+
疑似重复实体人工确认池:
|
| 99 |
+
当相似度介于阈值之间时,落入此表等待运营人工审核。
|
| 100 |
+
"""
|
| 101 |
+
__tablename__ = "entity_review_pool"
|
| 102 |
+
|
| 103 |
+
id = Column(String(50), primary_key=True)
|
| 104 |
+
source_name = Column(String(500), nullable=False, comment="待确认的名称")
|
| 105 |
+
target_name = Column(String(500), nullable=False, comment="匹配到的可能名称")
|
| 106 |
+
target_entity_id = Column(String(50), nullable=False, comment="匹配到的标准主体ID")
|
| 107 |
+
similarity_score = Column(Float, nullable=False, comment="相似度得分")
|
| 108 |
+
status = Column(String(20), default="PENDING", comment="状态: PENDING, APPROVED, REJECTED")
|
| 109 |
+
|
| 110 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 111 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
packages/core/nlp.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
logger = logging.getLogger(__name__)
|
| 4 |
+
|
| 5 |
+
class NLPProcessor:
|
| 6 |
+
"""
|
| 7 |
+
智能 NLP/LLM 处理器。
|
| 8 |
+
负责:
|
| 9 |
+
1. 小语种商品描述的机器翻译。
|
| 10 |
+
2. 残缺描述的分类提炼与 HS Code 自动纠错。
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
def __init__(self):
|
| 14 |
+
# 实际项目中,这里会初始化 OpenAI/Anthropic/本地 LLM 客户端
|
| 15 |
+
self.enabled = True
|
| 16 |
+
|
| 17 |
+
async def translate_to_english(self, text: str, source_lang: str = "auto") -> str:
|
| 18 |
+
"""
|
| 19 |
+
机器翻译商品描述到英文。
|
| 20 |
+
这里模拟翻译逻辑。
|
| 21 |
+
"""
|
| 22 |
+
if not text:
|
| 23 |
+
return ""
|
| 24 |
+
|
| 25 |
+
# 模拟:如果发现西班牙语特征
|
| 26 |
+
if "Teléfonos móviles" in text:
|
| 27 |
+
return "Mobile phones"
|
| 28 |
+
if "Điện thoại di động" in text:
|
| 29 |
+
return "Mobile phones"
|
| 30 |
+
|
| 31 |
+
return text
|
| 32 |
+
|
| 33 |
+
async def infer_hs_code(self, description: str, partial_hs: str = None) -> str:
|
| 34 |
+
"""
|
| 35 |
+
根据商品描述推断缺失或错误的 HS Code。
|
| 36 |
+
"""
|
| 37 |
+
if not description:
|
| 38 |
+
return partial_hs or ""
|
| 39 |
+
|
| 40 |
+
desc_lower = description.lower()
|
| 41 |
+
if "phone" in desc_lower or "mobile" in desc_lower:
|
| 42 |
+
return "851712"
|
| 43 |
+
if "coffee" in desc_lower or "café" in desc_lower:
|
| 44 |
+
return "090111"
|
| 45 |
+
if "corn" in desc_lower or "maize" in desc_lower:
|
| 46 |
+
return "100590"
|
| 47 |
+
|
| 48 |
+
return partial_hs or ""
|
| 49 |
+
|
| 50 |
+
nlp_processor = NLPProcessor()
|
packages/core/olap.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import clickhouse_connect
|
| 2 |
+
from packages.core.config import settings
|
| 3 |
+
|
| 4 |
+
def get_clickhouse_client():
|
| 5 |
+
"""
|
| 6 |
+
获取 ClickHouse 客户端实例。
|
| 7 |
+
"""
|
| 8 |
+
# 解析 CLICKHOUSE_URL
|
| 9 |
+
# 示例: http://default:password@localhost:8123
|
| 10 |
+
import urllib.parse
|
| 11 |
+
parsed = urllib.parse.urlparse(settings.CLICKHOUSE_URL)
|
| 12 |
+
|
| 13 |
+
host = parsed.hostname or "localhost"
|
| 14 |
+
port = parsed.port or 8123
|
| 15 |
+
username = parsed.username or "default"
|
| 16 |
+
password = parsed.password or ""
|
| 17 |
+
|
| 18 |
+
client = clickhouse_connect.get_client(
|
| 19 |
+
host=host,
|
| 20 |
+
port=port,
|
| 21 |
+
username=username,
|
| 22 |
+
password=password,
|
| 23 |
+
database="customs_data"
|
| 24 |
+
)
|
| 25 |
+
return client
|
| 26 |
+
|
| 27 |
+
def init_clickhouse_schema():
|
| 28 |
+
"""
|
| 29 |
+
初始化 ClickHouse 数据库与表结构
|
| 30 |
+
设计“国家+月份”分区表
|
| 31 |
+
"""
|
| 32 |
+
client = get_clickhouse_client()
|
| 33 |
+
|
| 34 |
+
# 确保数据库存在 (通过 client 创建时指定的 database="customs_data" 可能需要事先建库)
|
| 35 |
+
client.command("CREATE DATABASE IF NOT EXISTS customs_data")
|
| 36 |
+
|
| 37 |
+
# 创建贸易明细大宽表
|
| 38 |
+
# 使用 ReplacingMergeTree 支持按 record_id 去重更新
|
| 39 |
+
create_table_sql = """
|
| 40 |
+
CREATE TABLE IF NOT EXISTS customs_data.trade_records
|
| 41 |
+
(
|
| 42 |
+
record_id String,
|
| 43 |
+
source_country String,
|
| 44 |
+
trade_direction String,
|
| 45 |
+
trade_date Date,
|
| 46 |
+
trade_month UInt32 MATERIALIZED toYYYYMM(trade_date),
|
| 47 |
+
importer_name Nullable(String),
|
| 48 |
+
exporter_name Nullable(String),
|
| 49 |
+
hs_code String,
|
| 50 |
+
product_name String,
|
| 51 |
+
amount Float64,
|
| 52 |
+
currency String,
|
| 53 |
+
weight Float64,
|
| 54 |
+
weight_unit String,
|
| 55 |
+
origin_country String,
|
| 56 |
+
destination_country String,
|
| 57 |
+
departure_port Nullable(String),
|
| 58 |
+
arrival_port Nullable(String),
|
| 59 |
+
transport_mode String,
|
| 60 |
+
created_at DateTime DEFAULT now(),
|
| 61 |
+
updated_at DateTime DEFAULT now()
|
| 62 |
+
)
|
| 63 |
+
ENGINE = ReplacingMergeTree(updated_at)
|
| 64 |
+
PARTITION BY (source_country, trade_month)
|
| 65 |
+
ORDER BY (trade_direction, hs_code, trade_date, record_id)
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
client.command(create_table_sql)
|
| 69 |
+
print("ClickHouse schema initialized successfully.")
|
packages/core/search.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from elasticsearch import AsyncElasticsearch
|
| 2 |
+
from packages.core.config import settings
|
| 3 |
+
|
| 4 |
+
def get_es_client() -> AsyncElasticsearch:
|
| 5 |
+
"""
|
| 6 |
+
获取 Elasticsearch 异步客户端。
|
| 7 |
+
"""
|
| 8 |
+
return AsyncElasticsearch(
|
| 9 |
+
settings.ELASTICSEARCH_URL,
|
| 10 |
+
# 如果没有配置证书,可以关闭验证(本地开发时)
|
| 11 |
+
verify_certs=False
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
async def init_es_schema():
|
| 15 |
+
"""
|
| 16 |
+
初始化 ES 索引和映射
|
| 17 |
+
建立商品描述、企业名称的高效索引
|
| 18 |
+
"""
|
| 19 |
+
es = get_es_client()
|
| 20 |
+
|
| 21 |
+
index_name = "trade_entities"
|
| 22 |
+
|
| 23 |
+
mapping = {
|
| 24 |
+
"mappings": {
|
| 25 |
+
"properties": {
|
| 26 |
+
"record_id": {"type": "keyword"},
|
| 27 |
+
"source_country": {"type": "keyword"},
|
| 28 |
+
"trade_direction": {"type": "keyword"},
|
| 29 |
+
"trade_date": {"type": "date"},
|
| 30 |
+
|
| 31 |
+
# 企业名称使用 text 配合 keyword 子字段
|
| 32 |
+
"importer_name": {
|
| 33 |
+
"type": "text",
|
| 34 |
+
"fields": {
|
| 35 |
+
"keyword": {"type": "keyword", "ignore_above": 256}
|
| 36 |
+
}
|
| 37 |
+
},
|
| 38 |
+
"exporter_name": {
|
| 39 |
+
"type": "text",
|
| 40 |
+
"fields": {
|
| 41 |
+
"keyword": {"type": "keyword", "ignore_above": 256}
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
|
| 45 |
+
# 商品描述使用 text 用于全文检索
|
| 46 |
+
"product_name": {
|
| 47 |
+
"type": "text",
|
| 48 |
+
"analyzer": "standard" # 后续可换成分词器如 ik_max_word
|
| 49 |
+
},
|
| 50 |
+
"hs_code": {"type": "keyword"}
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
try:
|
| 56 |
+
exists = await es.indices.exists(index=index_name)
|
| 57 |
+
if not exists:
|
| 58 |
+
await es.indices.create(index=index_name, body=mapping)
|
| 59 |
+
print(f"Elasticsearch index '{index_name}' created successfully.")
|
| 60 |
+
else:
|
| 61 |
+
print(f"Elasticsearch index '{index_name}' already exists.")
|
| 62 |
+
except Exception as e:
|
| 63 |
+
print(f"Failed to initialize ES schema: {e}")
|
| 64 |
+
finally:
|
| 65 |
+
await es.close()
|
packages/dictionaries/eu.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
async def get_transport_mode(via_code: str) -> str:
|
| 2 |
+
"""
|
| 3 |
+
欧盟运输方式映射。
|
| 4 |
+
Eurostat 常见代码:
|
| 5 |
+
1 - Sea
|
| 6 |
+
2 - Rail
|
| 7 |
+
3 - Road
|
| 8 |
+
4 - Air
|
| 9 |
+
"""
|
| 10 |
+
if not via_code:
|
| 11 |
+
return "UNKNOWN"
|
| 12 |
+
|
| 13 |
+
code_str = str(via_code).strip()
|
| 14 |
+
mapping = {
|
| 15 |
+
"1": "SEA",
|
| 16 |
+
"2": "RAIL",
|
| 17 |
+
"3": "ROAD",
|
| 18 |
+
"4": "AIR",
|
| 19 |
+
"5": "MAIL",
|
| 20 |
+
"SEA": "SEA",
|
| 21 |
+
"AIR": "AIR"
|
| 22 |
+
}
|
| 23 |
+
return mapping.get(code_str, "UNKNOWN")
|
| 24 |
+
|
| 25 |
+
async def get_country_alpha3(country_code: str) -> str:
|
| 26 |
+
"""
|
| 27 |
+
Eurostat 两字码映射到 ISO-3166-1 alpha-3。
|
| 28 |
+
注意:Eurostat 有些特殊代码,例如 UK->GB, EL->GR。
|
| 29 |
+
"""
|
| 30 |
+
if not country_code:
|
| 31 |
+
return "UNKNOWN"
|
| 32 |
+
|
| 33 |
+
code_str = str(country_code).strip().upper()
|
| 34 |
+
|
| 35 |
+
mapping = {
|
| 36 |
+
"CN": "CHN",
|
| 37 |
+
"US": "USA",
|
| 38 |
+
"JP": "JPN",
|
| 39 |
+
"DE": "DEU",
|
| 40 |
+
"FR": "FRA",
|
| 41 |
+
"IT": "ITA",
|
| 42 |
+
"ES": "ESP",
|
| 43 |
+
"UK": "GBR",
|
| 44 |
+
"GB": "GBR",
|
| 45 |
+
"EL": "GRC", # 希腊
|
| 46 |
+
"GR": "GRC",
|
| 47 |
+
"NL": "NLD",
|
| 48 |
+
"BE": "NLD" # Belgium... wait, BEL is Belgium
|
| 49 |
+
}
|
| 50 |
+
if code_str == "BE": return "BEL"
|
| 51 |
+
|
| 52 |
+
return mapping.get(code_str, "UNKNOWN")
|
| 53 |
+
|
| 54 |
+
async def get_hs_name(hs_code: str) -> str:
|
| 55 |
+
if not hs_code:
|
| 56 |
+
return "Unknown Product"
|
| 57 |
+
return f"EU Product for {hs_code}"
|
| 58 |
+
|
| 59 |
+
async def convert_eur_to_usd(eur_amount: float, year: int, month: int) -> float:
|
| 60 |
+
"""
|
| 61 |
+
模拟汇率转换。真实场景应当查询外汇历史表。
|
| 62 |
+
"""
|
| 63 |
+
if not eur_amount:
|
| 64 |
+
return 0.0
|
| 65 |
+
# 假设一个固定汇率 1.1 用于模拟
|
| 66 |
+
return eur_amount * 1.1
|
packages/dictionaries/india.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
async def get_transport_mode(via_code: str) -> str:
|
| 2 |
+
"""
|
| 3 |
+
映射印度数据的运输方式到标准代码。
|
| 4 |
+
常见代码:
|
| 5 |
+
SEA / AIR / ICD (Inland Container Depot)
|
| 6 |
+
"""
|
| 7 |
+
if not via_code:
|
| 8 |
+
return "UNKNOWN"
|
| 9 |
+
|
| 10 |
+
code_str = str(via_code).strip().upper()
|
| 11 |
+
mapping = {
|
| 12 |
+
"SEA": "SEA",
|
| 13 |
+
"AIR": "AIR",
|
| 14 |
+
"ICD": "RAIL", # 内陆集装箱堆场,常通过铁路或公路接驳
|
| 15 |
+
"ROAD": "ROAD"
|
| 16 |
+
}
|
| 17 |
+
return mapping.get(code_str, "UNKNOWN")
|
| 18 |
+
|
| 19 |
+
async def get_country_alpha3(country_name: str) -> str:
|
| 20 |
+
"""
|
| 21 |
+
映射印度海关数据中的国家名称到 ISO-3166-1 alpha-3。
|
| 22 |
+
"""
|
| 23 |
+
if not country_name:
|
| 24 |
+
return "UNKNOWN"
|
| 25 |
+
|
| 26 |
+
code_str = str(country_name).strip().upper()
|
| 27 |
+
|
| 28 |
+
mapping = {
|
| 29 |
+
"CHINA": "CHN",
|
| 30 |
+
"USA": "USA",
|
| 31 |
+
"UNITED STATES": "USA",
|
| 32 |
+
"UAE": "ARE",
|
| 33 |
+
"UNITED ARAB EMIRATES": "ARE",
|
| 34 |
+
"SAUDI ARABIA": "SAU",
|
| 35 |
+
"SINGAPORE": "SGP",
|
| 36 |
+
"HONG KONG": "SGP", # Just an example mapping
|
| 37 |
+
"GERMANY": "DEU",
|
| 38 |
+
"UK": "GBR",
|
| 39 |
+
"UNITED KINGDOM": "GBR",
|
| 40 |
+
"JAPAN": "JPN",
|
| 41 |
+
"SOUTH KOREA": "KOR",
|
| 42 |
+
"MALAYSIA": "KOR",
|
| 43 |
+
"INDONESIA": "IDN",
|
| 44 |
+
"VIETNAM": "VNM"
|
| 45 |
+
}
|
| 46 |
+
return mapping.get(code_str, "UNKNOWN")
|
| 47 |
+
|
| 48 |
+
async def get_hs_name(hs_code: str) -> str:
|
| 49 |
+
"""
|
| 50 |
+
根据 HS Code 获取商品名称。
|
| 51 |
+
"""
|
| 52 |
+
if not hs_code:
|
| 53 |
+
return "Unknown Product"
|
| 54 |
+
return f"Product for {hs_code}"
|
packages/dictionaries/indonesia.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
async def get_transport_mode(via_code: str) -> str:
|
| 2 |
+
"""
|
| 3 |
+
印尼语运输方式映射。
|
| 4 |
+
"""
|
| 5 |
+
if not via_code:
|
| 6 |
+
return "UNKNOWN"
|
| 7 |
+
|
| 8 |
+
code_str = str(via_code).strip().upper()
|
| 9 |
+
mapping = {
|
| 10 |
+
"LAUT": "SEA",
|
| 11 |
+
"UDARA": "AIR",
|
| 12 |
+
"DARAT": "ROAD",
|
| 13 |
+
"KERETA API": "RAIL",
|
| 14 |
+
"SEA": "SEA",
|
| 15 |
+
"AIR": "AIR"
|
| 16 |
+
}
|
| 17 |
+
return mapping.get(code_str, "UNKNOWN")
|
| 18 |
+
|
| 19 |
+
async def get_country_alpha3(country_name: str) -> str:
|
| 20 |
+
"""
|
| 21 |
+
印尼语国家名称映射到 ISO-3166-1 alpha-3。
|
| 22 |
+
"""
|
| 23 |
+
if not country_name:
|
| 24 |
+
return "UNKNOWN"
|
| 25 |
+
|
| 26 |
+
code_str = str(country_name).strip().upper()
|
| 27 |
+
|
| 28 |
+
mapping = {
|
| 29 |
+
"TIONGKOK": "CHN",
|
| 30 |
+
"CHINA": "CHN",
|
| 31 |
+
"AMERIKA SERIKAT": "USA",
|
| 32 |
+
"USA": "USA",
|
| 33 |
+
"JEPANG": "JPN",
|
| 34 |
+
"KOREA SELATAN": "KOR",
|
| 35 |
+
"SINGAPURA": "SGP",
|
| 36 |
+
"MALAYSIA": "MYS",
|
| 37 |
+
"AUSTRALIA": "AUS",
|
| 38 |
+
"INDIA": "IND"
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
return mapping.get(code_str, "UNKNOWN")
|
| 42 |
+
|
| 43 |
+
async def get_hs_name(hs_code: str) -> str:
|
| 44 |
+
if not hs_code:
|
| 45 |
+
return "Unknown Product"
|
| 46 |
+
return f"Product for {hs_code}"
|
packages/dictionaries/vietnam.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unicodedata
|
| 2 |
+
|
| 3 |
+
async def get_transport_mode(via_code: str) -> str:
|
| 4 |
+
"""
|
| 5 |
+
越南语运输方式映射。
|
| 6 |
+
"""
|
| 7 |
+
if not via_code:
|
| 8 |
+
return "UNKNOWN"
|
| 9 |
+
|
| 10 |
+
code_str = str(via_code).strip().upper()
|
| 11 |
+
mapping = {
|
| 12 |
+
"ĐƯỜNG BIỂN": "SEA",
|
| 13 |
+
"SEA": "SEA",
|
| 14 |
+
"ĐƯỜNG HÀNG KHÔNG": "AIR",
|
| 15 |
+
"AIR": "AIR",
|
| 16 |
+
"ĐƯỜNG BỘ": "ROAD",
|
| 17 |
+
"ROAD": "ROAD",
|
| 18 |
+
"ĐƯỜNG SẮT": "RAIL",
|
| 19 |
+
"RAIL": "RAIL"
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
# 简单的去重音符号匹配
|
| 23 |
+
normalized = unicodedata.normalize('NFKD', code_str).encode('ASCII', 'ignore').decode('utf-8')
|
| 24 |
+
if normalized == "DUONG BIEN":
|
| 25 |
+
return "SEA"
|
| 26 |
+
if normalized == "DUONG HANG KHONG":
|
| 27 |
+
return "AIR"
|
| 28 |
+
if normalized == "DUONG BO":
|
| 29 |
+
return "ROAD"
|
| 30 |
+
|
| 31 |
+
return mapping.get(code_str, "UNKNOWN")
|
| 32 |
+
|
| 33 |
+
async def get_country_alpha3(country_name: str) -> str:
|
| 34 |
+
"""
|
| 35 |
+
越南语国家名称映射到 ISO-3166-1 alpha-3。
|
| 36 |
+
"""
|
| 37 |
+
if not country_name:
|
| 38 |
+
return "UNKNOWN"
|
| 39 |
+
|
| 40 |
+
code_str = str(country_name).strip().upper()
|
| 41 |
+
|
| 42 |
+
mapping = {
|
| 43 |
+
"TRUNG QUỐC": "CHN",
|
| 44 |
+
"CHINA": "CHN",
|
| 45 |
+
"HOA KỲ": "USA",
|
| 46 |
+
"MỸ": "USA",
|
| 47 |
+
"USA": "USA",
|
| 48 |
+
"NHẬT BẢN": "JPN",
|
| 49 |
+
"HÀN QUỐC": "KOR",
|
| 50 |
+
"ĐÀI LOAN": "TWN",
|
| 51 |
+
"ĐỨC": "DEU",
|
| 52 |
+
"THÁI LAN": "THA"
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
return mapping.get(code_str, "UNKNOWN")
|
| 56 |
+
|
| 57 |
+
async def get_hs_name(hs_code: str) -> str:
|
| 58 |
+
if not hs_code:
|
| 59 |
+
return "Unknown Product"
|
| 60 |
+
return f"Product for {hs_code}"
|
requirements.txt
CHANGED
|
@@ -1,13 +1,15 @@
|
|
| 1 |
-
fastapi==0.
|
| 2 |
-
uvicorn==0.
|
| 3 |
-
sqlalchemy==2.0.
|
| 4 |
asyncpg==0.29.0
|
| 5 |
-
psycopg2-binary==2.9.9
|
| 6 |
-
aiosqlite==0.20.0
|
| 7 |
-
pydantic==2.6.3
|
| 8 |
-
pydantic-settings==2.2.1
|
| 9 |
-
httpx==0.27.0
|
| 10 |
-
loguru==0.7.2
|
| 11 |
alembic==1.13.1
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn[standard]==0.30.1
|
| 3 |
+
sqlalchemy==2.0.30
|
| 4 |
asyncpg==0.29.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
alembic==1.13.1
|
| 6 |
+
pydantic==2.7.4
|
| 7 |
+
pydantic-settings==2.3.4
|
| 8 |
+
python-multipart==0.0.9
|
| 9 |
+
celery==5.4.0
|
| 10 |
+
redis==5.0.4
|
| 11 |
+
httpx==0.27.0
|
| 12 |
+
aiofiles==24.1.0
|
| 13 |
+
clickhouse-connect==0.7.8
|
| 14 |
+
elasticsearch==8.14.0
|
| 15 |
+
boto3==1.34.131
|