inkreach-sync-data 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. inkreach_sync_data-0.1.0/PKG-INFO +19 -0
  2. inkreach_sync_data-0.1.0/README.md +0 -0
  3. inkreach_sync_data-0.1.0/pyproject.toml +38 -0
  4. inkreach_sync_data-0.1.0/setup.cfg +4 -0
  5. inkreach_sync_data-0.1.0/src/inkreach_sync_data/__init__.py +1 -0
  6. inkreach_sync_data-0.1.0/src/inkreach_sync_data/__main__.py +4 -0
  7. inkreach_sync_data-0.1.0/src/inkreach_sync_data/cli.py +12 -0
  8. inkreach_sync_data-0.1.0/src/inkreach_sync_data/commands/__init__.py +0 -0
  9. inkreach_sync_data-0.1.0/src/inkreach_sync_data/commands/sds/__init__.py +16 -0
  10. inkreach_sync_data-0.1.0/src/inkreach_sync_data/commands/sds/refresh.py +68 -0
  11. inkreach_sync_data-0.1.0/src/inkreach_sync_data/commands/sds/sync_incremental.py +41 -0
  12. inkreach_sync_data-0.1.0/src/inkreach_sync_data/commands/sds/sync_recent.py +44 -0
  13. inkreach_sync_data-0.1.0/src/inkreach_sync_data/lib/sds/__init__.py +51 -0
  14. inkreach_sync_data-0.1.0/src/inkreach_sync_data/lib/sds/config.py +24 -0
  15. inkreach_sync_data-0.1.0/src/inkreach_sync_data/lib/sds/database.py +74 -0
  16. inkreach_sync_data-0.1.0/src/inkreach_sync_data/lib/sds/logistics.py +84 -0
  17. inkreach_sync_data-0.1.0/src/inkreach_sync_data/lib/sds/order.py +106 -0
  18. inkreach_sync_data-0.1.0/src/inkreach_sync_data/lib/sds/product.py +81 -0
  19. inkreach_sync_data-0.1.0/src/inkreach_sync_data/lib/sds/sync.py +81 -0
  20. inkreach_sync_data-0.1.0/src/inkreach_sync_data/lib/sds/token.py +33 -0
  21. inkreach_sync_data-0.1.0/src/inkreach_sync_data/utils.py +20 -0
  22. inkreach_sync_data-0.1.0/src/inkreach_sync_data.egg-info/PKG-INFO +19 -0
  23. inkreach_sync_data-0.1.0/src/inkreach_sync_data.egg-info/SOURCES.txt +25 -0
  24. inkreach_sync_data-0.1.0/src/inkreach_sync_data.egg-info/dependency_links.txt +1 -0
  25. inkreach_sync_data-0.1.0/src/inkreach_sync_data.egg-info/entry_points.txt +2 -0
  26. inkreach_sync_data-0.1.0/src/inkreach_sync_data.egg-info/requires.txt +9 -0
  27. inkreach_sync_data-0.1.0/src/inkreach_sync_data.egg-info/top_level.txt +1 -0
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: inkreach_sync_data
3
+ Version: 0.1.0
4
+ Summary: sync data to database for inkreach
5
+ Author-email: yoyoki <2197651308@qq.com>
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Requires-Python: >=3.13
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: aiohttp>=3.13.3
12
+ Requires-Dist: arrow>=1.4.0
13
+ Requires-Dist: asyncpg>=0.31.0
14
+ Requires-Dist: click>=8.3.1
15
+ Requires-Dist: openpyxl>=3.1.5
16
+ Requires-Dist: pandas>=3.0.1
17
+ Requires-Dist: python-dotenv>=1.2.2
18
+ Requires-Dist: sds-mng>=0.1.47
19
+ Requires-Dist: tenacity>=9.1.4
File without changes
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "inkreach_sync_data"
3
+ version = "0.1.0"
4
+ description = "sync data to database for inkreach"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ authors = [{name = "yoyoki", email = "2197651308@qq.com"}]
8
+ license = {text = "MIT"}
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "License :: OSI Approved :: MIT License",
12
+ ]
13
+ dependencies = [
14
+ "aiohttp>=3.13.3",
15
+ "arrow>=1.4.0",
16
+ "asyncpg>=0.31.0",
17
+ "click>=8.3.1",
18
+ "openpyxl>=3.1.5",
19
+ "pandas>=3.0.1",
20
+ "python-dotenv>=1.2.2",
21
+ "sds-mng>=0.1.47",
22
+ "tenacity>=9.1.4",
23
+ ]
24
+
25
+ [project.scripts]
26
+ inkreach-sync-data = "inkreach_sync_data.cli:cli"
27
+
28
+ [build-system]
29
+ requires = ["setuptools>=61.0"]
30
+ build-backend = "setuptools.build_meta"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "twine>=6.2.0",
38
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from inkreach_sync_data.cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
@@ -0,0 +1,12 @@
1
+ import click
2
+
3
+ from inkreach_sync_data.commands.sds import cli as sds_cli
4
+
5
+
6
+ @click.group()
7
+ def cli():
8
+ """Sync Data 命令行工具"""
9
+ pass
10
+
11
+
12
+ cli.add_command(sds_cli)
@@ -0,0 +1,16 @@
1
+ import click
2
+
3
+ from inkreach_sync_data.commands.sds.sync_recent import cli as sync_recent
4
+ from inkreach_sync_data.commands.sds.refresh import cli as refresh
5
+ from inkreach_sync_data.commands.sds.sync_incremental import cli as sync_incremental
6
+
7
+
8
+ @click.group(name="sds")
9
+ def cli():
10
+ """SDS系统相关命令"""
11
+ pass
12
+
13
+
14
+ cli.add_command(sync_recent)
15
+ cli.add_command(refresh)
16
+ cli.add_command(sync_incremental)
@@ -0,0 +1,68 @@
1
+ import click
2
+ import asyncio
3
+ from pprint import pprint
4
+
5
+ from inkreach_sync_data.utils import (
6
+ fetch_sds_order,
7
+ upsert_orders,
8
+ create_pool,
9
+ SDS_QUERY_MAX_BATCH,
10
+ SYNC_DATA_MAX_CONCURRENT,
11
+ )
12
+
13
+
14
+ @click.command(name="refresh")
15
+ @click.option("-s", "--sql", "sql", required=True, help="获取订单号的SQL语句")
16
+ @click.option(
17
+ "-d",
18
+ "--delete",
19
+ "delete_before_insert",
20
+ is_flag=True,
21
+ default=False,
22
+ help="更新前是否删除原数据",
23
+ )
24
+ def cli(sql, delete_before_insert):
25
+ """刷新特定订单"""
26
+ click.echo(f"SQL: {sql}")
27
+ click.echo(f"删除后更新: {delete_before_insert}")
28
+
29
+ async def run():
30
+ pool = await create_pool()
31
+
32
+ async with pool.acquire() as conn:
33
+ rows = await conn.fetch(sql)
34
+ order_nos = [r["order_no"] for r in rows]
35
+
36
+ pprint(order_nos)
37
+
38
+ if not order_nos:
39
+ click.echo("没有订单号")
40
+ return
41
+
42
+ batches = [
43
+ order_nos[i : i + SDS_QUERY_MAX_BATCH]
44
+ for i in range(0, len(order_nos), SDS_QUERY_MAX_BATCH)
45
+ ]
46
+ semaphore = asyncio.Semaphore(SYNC_DATA_MAX_CONCURRENT)
47
+
48
+ async def process_batch(batch):
49
+ async with semaphore:
50
+ keywords = "\n".join(batch)
51
+ latest_data = await fetch_sds_order(filters={"keywords": keywords})
52
+ if not latest_data:
53
+ return
54
+
55
+ async with pool.acquire() as conn:
56
+ async with conn.transaction():
57
+ if delete_before_insert:
58
+ await conn.execute(
59
+ "DELETE FROM orders WHERE order_no = ANY($1::text[])",
60
+ batch,
61
+ )
62
+
63
+ await upsert_orders(latest_data, pool)
64
+
65
+ await asyncio.gather(*[process_batch(batch) for batch in batches])
66
+ click.echo(f"刷新完成")
67
+
68
+ asyncio.run(run())
@@ -0,0 +1,41 @@
1
+ import click
2
+ import asyncio
3
+ import arrow
4
+
5
+ from inkreach_sync_data.utils import fetch_sds_order, upsert_orders, create_pool
6
+
7
+
8
+ @click.command(name="sync-incremental")
9
+ def cli():
10
+ """增量同步订单"""
11
+
12
+ async def run():
13
+ pool = await create_pool()
14
+
15
+ async with pool.acquire() as conn:
16
+ row = await conn.fetchrow(
17
+ "SELECT MAX(paid_date) as latest_ordered_at FROM orders"
18
+ )
19
+ latest = row["latest_ordered_at"]
20
+
21
+ if latest:
22
+ start = arrow.get(latest).floor("hour")
23
+ else:
24
+ start = arrow.now().shift(days=-2).floor("day")
25
+
26
+ click.echo(f"SDS最新支付时间为 {start.format('YYYY-MM-DD HH:mm:ss')}")
27
+
28
+ end = arrow.now().ceil("day")
29
+
30
+ data = await fetch_sds_order(
31
+ start.format("YYYY-MM-DD HH:mm:ss"),
32
+ end.format("YYYY-MM-DD HH:mm:ss"),
33
+ )
34
+
35
+ if data:
36
+ await upsert_orders(data, pool)
37
+ click.echo(f"成功同步 {len(data)} 条订单")
38
+ else:
39
+ click.echo("没有新订单")
40
+
41
+ asyncio.run(run())
@@ -0,0 +1,44 @@
1
+ import click
2
+ import asyncio
3
+ import arrow
4
+
5
+ from inkreach_sync_data.utils import fetch_sds_order, upsert_orders, create_pool
6
+
7
+
8
+ @click.command(name="sync-recent")
9
+ @click.option("-s", "--start", "start", help="开始时间,格式为 YYYY-MM-DD HH:mm:ss")
10
+ @click.option("-e", "--end", "end", help="结束时间,格式为 YYYY-MM-DD HH:mm:ss")
11
+ @click.option("-d", "--days", "days", type=int, default=1, help="近期几日 (默认: 1)")
12
+ def cli(start, end, days):
13
+ """同步近期订单"""
14
+ arrow.default_tz = "Asia/Shanghai"
15
+
16
+ if start and end:
17
+ start_time = start
18
+ end_time = end
19
+ elif start and not end:
20
+ start_time = start
21
+ end_time = arrow.now().ceil("day").format("YYYY-MM-DD HH:mm:ss")
22
+ elif not start and end:
23
+ start_time = (
24
+ arrow.now().shift(days=-days).floor("day").format("YYYY-MM-DD HH:mm:ss")
25
+ )
26
+ end_time = end
27
+ else:
28
+ start_time = (
29
+ arrow.now().shift(days=-days).floor("day").format("YYYY-MM-DD HH:mm:ss")
30
+ )
31
+ end_time = arrow.now().ceil("day").format("YYYY-MM-DD HH:mm:ss")
32
+
33
+ click.echo(f"同步订单时间范围: {start_time} ~ {end_time}")
34
+
35
+ async def run():
36
+ pool = await create_pool()
37
+ data = await fetch_sds_order(start_time, end_time)
38
+ if data:
39
+ await upsert_orders(data, pool)
40
+ click.echo(f"成功同步 {len(data)} 条订单")
41
+ else:
42
+ click.echo("没有订单数据")
43
+
44
+ asyncio.run(run())
@@ -0,0 +1,51 @@
1
+ from inkreach_sync_data.lib.sds.config import (
2
+ JSON_INDENT,
3
+ SDS_QUERY_MAX_BATCH,
4
+ SYNC_DATA_SQL_MAX_BATCH,
5
+ SYNC_DATA_MAX_CONCURRENT,
6
+ SYNC_DATA_FILE_UPDATE_GAP,
7
+ SYNC_DATA_POSTGRE_HOST,
8
+ SYNC_DATA_POSTGRE_PORT,
9
+ SYNC_DATA_POSTGRE_USER,
10
+ SYNC_DATA_POSTGRE_PASSWORD,
11
+ SYNC_DATA_POSTGRE_DATABASE,
12
+ SDS_MNG_LOGISTICS_PATH,
13
+ SDS_MNG_PRODUCTS_PATH,
14
+ )
15
+ from inkreach_sync_data.lib.sds.token import maybe_refresh_token, get_token_retry_decorator
16
+ from inkreach_sync_data.lib.sds.order import fetch_sds_order
17
+ from inkreach_sync_data.lib.sds.product import update_sds_products, update_sds_products_file
18
+ from inkreach_sync_data.lib.sds.logistics import update_sds_logistics, update_sds_logistics_file
19
+ from inkreach_sync_data.lib.sds.database import upsert_orders, create_pool
20
+ from inkreach_sync_data.lib.sds.sync import (
21
+ sync_recent_order,
22
+ sync_order_incremental,
23
+ refresh_orders,
24
+ )
25
+
26
+ __all__ = [
27
+ "JSON_INDENT",
28
+ "SDS_QUERY_MAX_BATCH",
29
+ "SYNC_DATA_SQL_MAX_BATCH",
30
+ "SYNC_DATA_MAX_CONCURRENT",
31
+ "SYNC_DATA_FILE_UPDATE_GAP",
32
+ "SYNC_DATA_POSTGRE_HOST",
33
+ "SYNC_DATA_POSTGRE_PORT",
34
+ "SYNC_DATA_POSTGRE_USER",
35
+ "SYNC_DATA_POSTGRE_PASSWORD",
36
+ "SYNC_DATA_POSTGRE_DATABASE",
37
+ "SDS_MNG_LOGISTICS_PATH",
38
+ "SDS_MNG_PRODUCTS_PATH",
39
+ "maybe_refresh_token",
40
+ "get_token_retry_decorator",
41
+ "fetch_sds_order",
42
+ "update_sds_products",
43
+ "update_sds_products_file",
44
+ "update_sds_logistics",
45
+ "update_sds_logistics_file",
46
+ "upsert_orders",
47
+ "create_pool",
48
+ "sync_recent_order",
49
+ "sync_order_incremental",
50
+ "refresh_orders",
51
+ ]
@@ -0,0 +1,24 @@
1
+ import os
2
+ from dotenv import load_dotenv
3
+
4
+ load_dotenv()
5
+
6
+ JSON_INDENT = 2
7
+ SDS_QUERY_MAX_BATCH = 200
8
+
9
+ SYNC_DATA_SQL_MAX_BATCH = int(os.environ.get("SYNC_DATA_SQL_MAX_BATCH") or 10000)
10
+ SYNC_DATA_MAX_CONCURRENT = int(os.environ.get("SYNC_DATA_MAX_CONCURRENT") or 2)
11
+ SYNC_DATA_FILE_UPDATE_GAP = int(os.environ.get("SYNC_DATA_FILE_UPDATE_GAP") or 24)
12
+
13
+ SYNC_DATA_POSTGRE_HOST = os.environ.get("SYNC_DATA_POSTGRE_HOST")
14
+ SYNC_DATA_POSTGRE_PORT = os.environ.get("SYNC_DATA_POSTGRE_PORT")
15
+ SYNC_DATA_POSTGRE_USER = os.environ.get("SYNC_DATA_POSTGRE_USER")
16
+ SYNC_DATA_POSTGRE_PASSWORD = os.environ.get("SYNC_DATA_POSTGRE_PASSWORD")
17
+ SYNC_DATA_POSTGRE_DATABASE = os.environ.get("SYNC_DATA_POSTGRE_DATABASE")
18
+
19
+ SDS_MNG_LOGISTICS_PATH = (
20
+ os.environ.get("SDS_MNG_LOGISTICS_PATH") or "sds_mng_logistics.json"
21
+ )
22
+ SDS_MNG_PRODUCTS_PATH = (
23
+ os.environ.get("SDS_MNG_PRODUCTS_PATH") or "sds_mng_products.json"
24
+ )
@@ -0,0 +1,74 @@
1
+ import asyncpg
2
+ from datetime import datetime
3
+ from typing import List, Dict, Any
4
+ from inkreach_sync_data.lib.sds.config import (
5
+ SYNC_DATA_POSTGRE_HOST,
6
+ SYNC_DATA_POSTGRE_PORT,
7
+ SYNC_DATA_POSTGRE_USER,
8
+ SYNC_DATA_POSTGRE_PASSWORD,
9
+ SYNC_DATA_POSTGRE_DATABASE,
10
+ SYNC_DATA_SQL_MAX_BATCH,
11
+ )
12
+ from sds_mng.util import logger
13
+
14
+
15
+ async def upsert_orders(orders_data: List[Dict[str, Any]], pool: asyncpg.Pool):
16
+ if not orders_data:
17
+ return
18
+
19
+ columns = list(set(key for order in orders_data for key in order.keys()))
20
+
21
+ def convert_order(order: Dict) -> tuple:
22
+ row = []
23
+ for col in columns:
24
+ val = order.get(col)
25
+ if col in (
26
+ "paid_date",
27
+ "confirmed_at",
28
+ "completed_date",
29
+ "created_at",
30
+ "ordered_at",
31
+ "completed_at",
32
+ ):
33
+ if val and isinstance(val, str):
34
+ val = datetime.fromisoformat(val)
35
+ row.append(val)
36
+ return tuple(row)
37
+
38
+ values = [convert_order(order) for order in orders_data]
39
+
40
+ placeholders = ", ".join(f"${i + 1}" for i in range(len(columns)))
41
+ update_set = ", ".join(
42
+ f"{col} = EXCLUDED.{col}"
43
+ for col in columns
44
+ if col not in ("production_no", "order_no")
45
+ )
46
+
47
+ upsert_sql = f"""
48
+ INSERT INTO orders ({", ".join(columns)})
49
+ VALUES ({placeholders})
50
+ ON CONFLICT (production_no, order_no) DO UPDATE SET {update_set}
51
+ """
52
+
53
+ count = 0
54
+ for i in range(0, len(orders_data), SYNC_DATA_SQL_MAX_BATCH):
55
+ batch = orders_data[i : i + SYNC_DATA_SQL_MAX_BATCH]
56
+ logger.info(f"正在更新/插入第{count + 1}批次,总共{len(batch)}条数据")
57
+ values = [convert_order(order) for order in batch]
58
+
59
+ async with pool.acquire() as conn:
60
+ await conn.executemany(upsert_sql, values)
61
+ logger.info(f"已更新/插入第{count + 1}批次,总共{len(batch)}条数据")
62
+ count = count + 1
63
+
64
+
65
+ async def create_pool():
66
+ return await asyncpg.create_pool(
67
+ host=SYNC_DATA_POSTGRE_HOST,
68
+ port=SYNC_DATA_POSTGRE_PORT,
69
+ user=SYNC_DATA_POSTGRE_USER,
70
+ password=SYNC_DATA_POSTGRE_PASSWORD,
71
+ database=SYNC_DATA_POSTGRE_DATABASE,
72
+ min_size=5,
73
+ max_size=20,
74
+ )
@@ -0,0 +1,84 @@
1
+ import os
2
+ import json
3
+ import asyncio
4
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
5
+ from sds_mng.setting.fetch_logistics import fetch_id, fetch_logistics
6
+ from sds_mng.exception import UnauthorizedError
7
+ from aiohttp import ClientError
8
+ from inkreach_sync_data.lib.sds.token import maybe_refresh_token
9
+ from inkreach_sync_data.lib.sds.config import JSON_INDENT, SYNC_DATA_FILE_UPDATE_GAP
10
+ from sds_mng.util import logger
11
+ import arrow
12
+
13
+
14
+ @retry(
15
+ stop=stop_after_attempt(3),
16
+ wait=wait_fixed(2),
17
+ retry=retry_if_exception_type((UnauthorizedError, ClientError)),
18
+ before_sleep=maybe_refresh_token,
19
+ reraise=True,
20
+ )
21
+ async def update_sds_logistics():
22
+ logistics_config_path = (
23
+ os.environ.get("SDS_MNG_LOGISTICS_PATH") or "sds_mng_logistics.json"
24
+ )
25
+ ids = await fetch_id(os.environ.get("SDS_MNG_TOKEN"))
26
+ logistics_type = {
27
+ "offline": "普通物流",
28
+ "manual": "手动物流",
29
+ "self_pickup": "自提",
30
+ "online": "线上物流",
31
+ }
32
+ results = await asyncio.gather(
33
+ *[
34
+ fetch_logistics(
35
+ os.environ.get("SDS_MNG_TOKEN"), {"issuingBayAreaId": bay_id}
36
+ )
37
+ for bay_id in ids.values()
38
+ ]
39
+ )
40
+ data = [
41
+ {
42
+ "country": country,
43
+ "logistics": [
44
+ {
45
+ "name": item.get("name"),
46
+ "type": logistics_type.get(item.get("channelType"), "未知"),
47
+ }
48
+ for item in data.get("list", [])
49
+ ],
50
+ }
51
+ for (country, bay_id), data in zip(ids.items(), results)
52
+ ]
53
+ with open(logistics_config_path, "w", encoding="utf-8") as f:
54
+ json.dump(data, f, ensure_ascii=False, indent=JSON_INDENT)
55
+
56
+
57
+ async def update_sds_logistics_file():
58
+ logistics_config_path = (
59
+ os.environ.get("SDS_MNG_LOGISTICS_PATH") or "sds_mng_logistics.json"
60
+ )
61
+
62
+ if not os.path.exists(logistics_config_path):
63
+ await update_sds_products()
64
+ return
65
+
66
+ arrow.default_tz = "Asia/Shanghai"
67
+ mtime = arrow.get(os.path.getmtime(logistics_config_path))
68
+ logger.info(f"物流信息上次更新时间为 {mtime.format('YYYY-MM-DD HH:mm:ss')}")
69
+ if mtime < arrow.now().shift(hours=-SYNC_DATA_FILE_UPDATE_GAP):
70
+ logger.info(
71
+ f"物流信息上次更新时间为 {mtime.format('YYYY-MM-DD HH:mm:ss')},"
72
+ + "可以更新物流信息了"
73
+ )
74
+ await update_sds_logistics()
75
+ else:
76
+ logger.info(
77
+ f"物流信息上次更新时间为 {mtime.format('YYYY-MM-DD HH:mm:ss')},"
78
+ + "可以更新物流信息了"
79
+ )
80
+ return
81
+
82
+
83
+ async def update_sds_products():
84
+ pass
@@ -0,0 +1,106 @@
1
+ import os
2
+ import json
3
+ import asyncio
4
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
5
+ from sds_mng.order.fetch_orders_async import fetch_orders
6
+ from sds_mng.exception import UnauthorizedError
7
+ from aiohttp import ClientError
8
+ from inkreach_sync_data.lib.sds.token import maybe_refresh_token
9
+ from inkreach_sync_data.lib.sds.product import update_sds_products_file
10
+ from inkreach_sync_data.lib.sds.logistics import update_sds_logistics_file
11
+
12
+
13
+ @retry(
14
+ stop=stop_after_attempt(3),
15
+ wait=wait_fixed(2),
16
+ retry=retry_if_exception_type((UnauthorizedError, ClientError)),
17
+ before_sleep=maybe_refresh_token,
18
+ reraise=True,
19
+ )
20
+ async def fetch_sds_order(star="", end="", filters={}, limi=None):
21
+ data = await fetch_orders(
22
+ os.environ.get("SDS_MNG_TOKEN"),
23
+ {
24
+ "orderTimeRangeType": "lastThreeMonths",
25
+ "payDateBegin": star or "",
26
+ "payDateEnd": end or "",
27
+ **filters,
28
+ },
29
+ limit=limi,
30
+ )
31
+ status_map = {
32
+ "1": "待付款",
33
+ "7": "待设计",
34
+ "2": "待确认",
35
+ "5": "搁置中",
36
+ "3": "备货中",
37
+ "9": "部分发货",
38
+ "4": "已完成",
39
+ "98": "已取消",
40
+ "hasApplyAfterAudit": "售后",
41
+ "99": "已删除",
42
+ }
43
+ await asyncio.gather(update_sds_logistics_file(), update_sds_products_file())
44
+
45
+ with open(
46
+ os.environ.get("SDS_MNG_LOGISTICS_PATH") or "sds_mng_logistics.json",
47
+ "r",
48
+ encoding="utf-8",
49
+ ) as f:
50
+ logistics = json.load(f)
51
+
52
+ with open(
53
+ os.environ.get("SDS_MNG_PRODUCTS_PATH") or "sds_mng_products.json",
54
+ "r",
55
+ encoding="utf-8",
56
+ ) as f:
57
+ products = json.load(f)
58
+
59
+ return [
60
+ {
61
+ "production_no": product.get("no"),
62
+ "order_no": order.get("no"),
63
+ "third_party_order_no": order.get("outOrderNo"),
64
+ "product_name": products.get(product.get("productName"), {}).get(
65
+ "product_name"
66
+ ),
67
+ "product_color": product.get("productColorBlock", {}).get("colorName"),
68
+ "product_size": product.get("productSize"),
69
+ "quantity": product.get("num"),
70
+ "waybill_no": order.get("parcelList", [])[0]
71
+ .get("logistics", {})
72
+ .get("carriage", {})
73
+ .get("no")
74
+ if order.get("parcelList", [])
75
+ else None,
76
+ "merchant_no": order.get("merchant", {}).get("name"),
77
+ "country_region": order.get("issuingBayArea", "").get("name"),
78
+ "factory_name": product.get("factory", {}).get("name"),
79
+ "paid_date": order.get("gmtPay"),
80
+ "confirmed_at": order.get("gmtConfirmTime"),
81
+ "completed_date": order.get("gmtFinished"),
82
+ "status": status_map.get(str(product.get("status"))),
83
+ "system_name": "SDS",
84
+ "platform": order.get("merchantStore", {}).get("platformName"),
85
+ "carriage_name": order.get("carriageName"),
86
+ "product_coding": products.get(product.get("productName"), {}).get("sku"),
87
+ "created_at": order.get("gmtCreated"),
88
+ "carriage_type": next(
89
+ (
90
+ log["type"]
91
+ for entry in logistics
92
+ if entry["country"] == order.get("issuingBayArea", "").get("name")
93
+ for log in entry["logistics"]
94
+ if log["name"] == order.get("carriageName")
95
+ ),
96
+ None,
97
+ ),
98
+ "ordered_at": order.get("gmtPay"),
99
+ "completed_at": order.get("gmtFinished"),
100
+ "product_style_no": products.get(product.get("productName"), {}).get(
101
+ "style_no"
102
+ ),
103
+ }
104
+ for order in data.get("list", [])
105
+ for product in order.get("items", [])
106
+ ]
@@ -0,0 +1,81 @@
1
+ import os
2
+ import re
3
+ from pathlib import Path
4
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
5
+ from sds_mng.setting.fetch_producs import fetch_products
6
+ from sds_mng.exception import UnauthorizedError
7
+ from aiohttp import ClientError
8
+ from inkreach_sync_data.lib.sds.token import maybe_refresh_token
9
+ from inkreach_sync_data.lib.sds.config import JSON_INDENT, SYNC_DATA_FILE_UPDATE_GAP
10
+ from sds_mng.util import logger
11
+ import arrow
12
+
13
+
14
+ @retry(
15
+ stop=stop_after_attempt(3),
16
+ wait=wait_fixed(2),
17
+ retry=retry_if_exception_type((UnauthorizedError, ClientError)),
18
+ before_sleep=maybe_refresh_token,
19
+ reraise=True,
20
+ )
21
+ async def update_sds_products():
22
+ config_path = Path(os.getenv("SDS_MNG_PRODUCTS_PATH", "sds_mng_products.json"))
23
+ token = os.getenv("SDS_MNG_TOKEN")
24
+ data = await fetch_products(token)
25
+
26
+ def parse_name(name: str):
27
+ parts = re.split(r"[()]", name)
28
+ if len(parts) < 3:
29
+ return "", "", ""
30
+
31
+ country = parts[0]
32
+ middle = parts[2]
33
+ subs = middle.split("-")
34
+
35
+ if len(subs) == 2:
36
+ return country, subs[0], ""
37
+ if len(subs) == 3:
38
+ return country, subs[0], subs[1]
39
+ return "", "", ""
40
+
41
+ products = {}
42
+ for item in data.get("list", []):
43
+ name = item.get("name", "")
44
+ country, product_name, style_no = parse_name(name)
45
+ products[name] = {
46
+ "sku": item.get("sku", ""),
47
+ "country": country,
48
+ "product_name": product_name.strip(),
49
+ "style_no": style_no,
50
+ }
51
+
52
+ with config_path.open("w", encoding="utf-8") as f:
53
+ json.dump(products, f, ensure_ascii=False, indent=JSON_INDENT)
54
+
55
+
56
+ import json
57
+
58
+
59
+ async def update_sds_products_file():
60
+ products_config_path = (
61
+ os.environ.get("SDS_MNG_PRODUCTS_PATH") or "sds_mng_products.json"
62
+ )
63
+
64
+ if not os.path.exists(products_config_path):
65
+ await update_sds_products()
66
+ return
67
+
68
+ arrow.default_tz = "Asia/Shanghai"
69
+ mtime = arrow.get(os.path.getmtime(products_config_path))
70
+ if mtime < arrow.now().shift(hours=-SYNC_DATA_FILE_UPDATE_GAP):
71
+ logger.info(
72
+ f"产品信息上次更新时间为 {mtime.format('YYYY-MM-DD HH:mm:ss')}, "
73
+ + "可以更新产品信息了"
74
+ )
75
+ await update_sds_products()
76
+ else:
77
+ logger.info(
78
+ f"产品信息上次更新时间为 {mtime.format('YYYY-MM-DD HH:mm:ss')}, "
79
+ + "无需更新产品信息"
80
+ )
81
+ return
@@ -0,0 +1,81 @@
1
+ import asyncio
2
+ import arrow
3
+ import asyncpg
4
+ from pprint import pprint
5
+ from inkreach_sync_data.lib.sds.order import fetch_sds_order
6
+ from inkreach_sync_data.lib.sds.database import upsert_orders, create_pool
7
+ from inkreach_sync_data.lib.sds.config import SDS_QUERY_MAX_BATCH, SYNC_DATA_MAX_CONCURRENT
8
+ from sds_mng.util import logger
9
+
10
+
11
+ async def sync_recent_order(pool, start, end):
12
+ data = await fetch_sds_order(
13
+ start,
14
+ end,
15
+ )
16
+ await upsert_orders(data, pool)
17
+
18
+
19
+ async def sync_order_incremental(pool, default_days_back=2):
20
+ arrow.default_tz = "Asia/Shanghai"
21
+
22
+ async with pool.acquire() as conn:
23
+ row = await conn.fetchrow(
24
+ "SELECT MAX(paid_date) as latest_ordered_at FROM orders"
25
+ )
26
+ latest = row["latest_ordered_at"]
27
+
28
+ if latest:
29
+ start = arrow.get(latest).floor("hour")
30
+ else:
31
+ start = arrow.now().shift(days=-default_days_back).floor("day")
32
+
33
+ logger.info(f"SDS最新支付时间为 {start.format('YYYY-MM-DD HH:mm:ss')}")
34
+
35
+ end = arrow.now().ceil("day")
36
+
37
+ data = await fetch_sds_order(
38
+ start.format("YYYY-MM-DD HH:mm:ss"),
39
+ end.format("YYYY-MM-DD HH:mm:ss"),
40
+ )
41
+
42
+ if data:
43
+ await upsert_orders(data, pool)
44
+
45
+
46
+ async def refresh_orders(
47
+ pool: asyncpg.Pool, query: str, delete_before_insert: bool = True
48
+ ):
49
+ async with pool.acquire() as conn:
50
+ rows = await conn.fetch(query)
51
+ order_nos = [r["order_no"] for r in rows]
52
+
53
+ pprint(order_nos)
54
+
55
+ if not order_nos:
56
+ return
57
+
58
+ batches = [
59
+ order_nos[i : i + SDS_QUERY_MAX_BATCH]
60
+ for i in range(0, len(order_nos), SDS_QUERY_MAX_BATCH)
61
+ ]
62
+ semaphore = asyncio.Semaphore(SYNC_DATA_MAX_CONCURRENT)
63
+
64
+ async def process_batch(batch):
65
+ async with semaphore:
66
+ keywords = "\n".join(batch)
67
+ latest_data = await fetch_sds_order(filters={"keywords": keywords})
68
+ if not latest_data:
69
+ return
70
+
71
+ async with pool.acquire() as conn:
72
+ async with conn.transaction():
73
+ if delete_before_insert:
74
+ await conn.execute(
75
+ "DELETE FROM orders WHERE order_no = ANY($1::text[])",
76
+ batch,
77
+ )
78
+
79
+ await upsert_orders(latest_data, pool)
80
+
81
+ await asyncio.gather(*[process_batch(batch) for batch in batches])
@@ -0,0 +1,33 @@
1
+ import os
2
+ from dotenv import load_dotenv, set_key
3
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
4
+ from sds_mng.auth.get_token import get_token
5
+ from sds_mng.exception import UnauthorizedError
6
+ from aiohttp import ClientError
7
+
8
+ load_dotenv()
9
+
10
+
11
+ def maybe_refresh_token(retry_state):
12
+ exception = retry_state.outcome.exception()
13
+ if isinstance(exception, UnauthorizedError):
14
+ try:
15
+ new_token = get_token(
16
+ os.environ.get("SDS_MNG_USERNAME"), os.environ.get("SDS_MNG_PASSWORD")
17
+ )
18
+ os.environ["SDS_MNG_TOKEN"] = new_token
19
+ set_key(os.environ.get("ENV_PATH") or ".env", "SDS_MNG_TOKEN", new_token)
20
+ print("Token refreshed before retry.")
21
+ except Exception as e:
22
+ print(f"Failed to refresh token: {e}")
23
+ raise e("账号密码错误!")
24
+
25
+
26
+ def get_token_retry_decorator():
27
+ return retry(
28
+ stop=stop_after_attempt(3),
29
+ wait=wait_fixed(2),
30
+ retry=retry_if_exception_type((UnauthorizedError, ClientError)),
31
+ before_sleep=maybe_refresh_token,
32
+ reraise=True,
33
+ )
@@ -0,0 +1,20 @@
1
+ from inkreach_sync_data.lib.sds import (
2
+ JSON_INDENT,
3
+ SDS_QUERY_MAX_BATCH,
4
+ SYNC_DATA_SQL_MAX_BATCH,
5
+ SYNC_DATA_MAX_CONCURRENT,
6
+ SYNC_DATA_FILE_UPDATE_GAP,
7
+ SYNC_DATA_POSTGRE_HOST,
8
+ SYNC_DATA_POSTGRE_PORT,
9
+ SYNC_DATA_POSTGRE_USER,
10
+ SYNC_DATA_POSTGRE_PASSWORD,
11
+ SYNC_DATA_POSTGRE_DATABASE,
12
+ maybe_refresh_token,
13
+ fetch_sds_order,
14
+ update_sds_products,
15
+ update_sds_products_file,
16
+ update_sds_logistics,
17
+ update_sds_logistics_file,
18
+ upsert_orders,
19
+ create_pool,
20
+ )
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: inkreach_sync_data
3
+ Version: 0.1.0
4
+ Summary: sync data to database for inkreach
5
+ Author-email: yoyoki <2197651308@qq.com>
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Requires-Python: >=3.13
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: aiohttp>=3.13.3
12
+ Requires-Dist: arrow>=1.4.0
13
+ Requires-Dist: asyncpg>=0.31.0
14
+ Requires-Dist: click>=8.3.1
15
+ Requires-Dist: openpyxl>=3.1.5
16
+ Requires-Dist: pandas>=3.0.1
17
+ Requires-Dist: python-dotenv>=1.2.2
18
+ Requires-Dist: sds-mng>=0.1.47
19
+ Requires-Dist: tenacity>=9.1.4
@@ -0,0 +1,25 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/inkreach_sync_data/__init__.py
4
+ src/inkreach_sync_data/__main__.py
5
+ src/inkreach_sync_data/cli.py
6
+ src/inkreach_sync_data/utils.py
7
+ src/inkreach_sync_data.egg-info/PKG-INFO
8
+ src/inkreach_sync_data.egg-info/SOURCES.txt
9
+ src/inkreach_sync_data.egg-info/dependency_links.txt
10
+ src/inkreach_sync_data.egg-info/entry_points.txt
11
+ src/inkreach_sync_data.egg-info/requires.txt
12
+ src/inkreach_sync_data.egg-info/top_level.txt
13
+ src/inkreach_sync_data/commands/__init__.py
14
+ src/inkreach_sync_data/commands/sds/__init__.py
15
+ src/inkreach_sync_data/commands/sds/refresh.py
16
+ src/inkreach_sync_data/commands/sds/sync_incremental.py
17
+ src/inkreach_sync_data/commands/sds/sync_recent.py
18
+ src/inkreach_sync_data/lib/sds/__init__.py
19
+ src/inkreach_sync_data/lib/sds/config.py
20
+ src/inkreach_sync_data/lib/sds/database.py
21
+ src/inkreach_sync_data/lib/sds/logistics.py
22
+ src/inkreach_sync_data/lib/sds/order.py
23
+ src/inkreach_sync_data/lib/sds/product.py
24
+ src/inkreach_sync_data/lib/sds/sync.py
25
+ src/inkreach_sync_data/lib/sds/token.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ inkreach-sync-data = inkreach_sync_data.cli:cli
@@ -0,0 +1,9 @@
1
+ aiohttp>=3.13.3
2
+ arrow>=1.4.0
3
+ asyncpg>=0.31.0
4
+ click>=8.3.1
5
+ openpyxl>=3.1.5
6
+ pandas>=3.0.1
7
+ python-dotenv>=1.2.2
8
+ sds-mng>=0.1.47
9
+ tenacity>=9.1.4
@@ -0,0 +1 @@
1
+ inkreach_sync_data